Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## 0.3.9 — 2026-03-15

### Added
- **`bin/gstack-config` CLI** — simple get/set/list interface for `~/.gstack/config.yaml`. Used by update-check and upgrade skill for persistent settings (auto_upgrade, update_check).
- **Smart update check** — 12h cache TTL (was 24h), exponential snooze backoff (24h → 48h → 1 week) when user declines upgrades, `update_check: false` config option to disable checks entirely. Snooze resets when a new version is released.
- **Auto-upgrade mode** — set `auto_upgrade: true` in config or `GSTACK_AUTO_UPGRADE=1` env var to skip the upgrade prompt and update automatically.
- **4-option upgrade prompt** — "Yes, upgrade now", "Always keep me up to date", "Not now" (snooze), "Never ask again" (disable).
- **Vendored copy sync** — `/gstack-upgrade` now detects and updates local vendored copies in the current project after upgrading the primary install.
- 25 new tests: 11 for gstack-config CLI, 14 for snooze/config paths in update-check.

### Changed
- README upgrade/troubleshooting sections simplified to reference `/gstack-upgrade` instead of long paste commands.
- Upgrade skill template bumped to v1.1.0 with `Write` tool permission for config editing.
- All SKILL.md preambles updated with new upgrade flow description.

## 0.3.8 — 2026-03-14

### Added
Expand Down
8 changes: 3 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,18 +598,16 @@ Run `cd ~/.claude/skills/gstack && ./setup` (or `cd .claude/skills/gstack && ./s
Run `cd ~/.claude/skills/gstack && bun install && bun run build`. This compiles the browser binary. Requires Bun v1.0+.

**Project copy is stale?**
Re-copy from global: `for s in browse plan-ceo-review plan-eng-review review ship retro qa setup-browser-cookies; do rm -f .claude/skills/$s; done && rm -rf .claude/skills/gstack && cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup`
Run `/gstack-upgrade` — it updates both the global install and any vendored project copy automatically.

**`bun` not installed?**
Install it: `curl -fsSL https://bun.sh/install | bash`

## Upgrading

Paste this into Claude Code:

> Update gstack: run `cd ~/.claude/skills/gstack && git fetch origin && git reset --hard origin/main && ./setup`. If this project also has gstack at .claude/skills/gstack, update it too: run `for s in browse plan-ceo-review plan-eng-review review ship retro qa setup-browser-cookies; do rm -f .claude/skills/$s; done && rm -rf .claude/skills/gstack && cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup`
Run `/gstack-upgrade` in Claude Code. It detects your install type (global or vendored), upgrades, syncs any project copies, and shows what's new.

The `setup` script rebuilds the browser binary and re-symlinks skills. It takes a few seconds.
Or set `auto_upgrade: true` in `~/.gstack/config.yaml` to upgrade automatically whenever a new version is available.

## Uninstalling

Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ _UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/sk
[ -n "$_UPD" ] && echo "$_UPD" || true
```

If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.

# gstack browse: QA Testing & Dogfooding

Expand Down
15 changes: 4 additions & 11 deletions TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,17 +338,6 @@
**Effort:** XS
**Priority:** P2

### Auto-upgrade mode (zero-prompt)

**What:** `GSTACK_AUTO_UPGRADE=1` env var or `~/.gstack/config` option that skips the AskUserQuestion prompt and upgrades automatically.

**Why:** Power users and CI environments want zero-friction upgrades.

**Context:** Current upgrade system (v0.3.4) always prompts. This adds opt-in bypass. ~10 lines in preamble instructions.

**Effort:** S
**Priority:** P3

### Eval web dashboard

**What:** `bun run eval:dashboard` serves local HTML with charts: cost trending, detection rate, pass/fail history.
Expand Down Expand Up @@ -395,3 +384,7 @@
### E2E test cost tracking
- Track cumulative API spend, warn if over threshold
**Completed:** v0.3.6

### Auto-upgrade mode + smart update check
- Config CLI (`bin/gstack-config`), auto-upgrade via `~/.gstack/config.yaml`, 12h cache TTL, exponential snooze backoff (24h→48h→1wk), "never ask again" option, vendored copy sync on upgrade
**Completed:** v0.3.8
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.3.8
0.3.9
38 changes: 38 additions & 0 deletions bin/gstack-config
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# gstack-config — read/write ~/.gstack/config.yaml
#
# Usage:
# gstack-config get <key> — read a config value
# gstack-config set <key> <value> — write a config value
# gstack-config list — show all config
#
# Env overrides (for testing):
# GSTACK_STATE_DIR — override ~/.gstack state directory
set -euo pipefail

STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
CONFIG_FILE="$STATE_DIR/config.yaml"

case "${1:-}" in
get)
KEY="${2:?Usage: gstack-config get <key>}"
grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true
;;
set)
KEY="${2:?Usage: gstack-config set <key> <value>}"
VALUE="${3:?Usage: gstack-config set <key> <value>}"
mkdir -p "$STATE_DIR"
if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then
sed -i '' "s/^${KEY}:.*/${KEY}: ${VALUE}/" "$CONFIG_FILE"
else
echo "${KEY}: ${VALUE}" >> "$CONFIG_FILE"
fi
;;
list)
cat "$CONFIG_FILE" 2>/dev/null || true
;;
*)
echo "Usage: gstack-config {get|set|list} [key] [value]"
exit 1
;;
esac
74 changes: 69 additions & 5 deletions bin/gstack-update-check
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# gstack-update-check — daily version check for all skills.
# gstack-update-check — periodic version check for all skills.
#
# Output (one line, or nothing):
# JUST_UPGRADED <old> <new> — marker found from recent upgrade
# UPGRADE_AVAILABLE <old> <new> — remote VERSION differs from local
# (nothing) — up to date or check skipped
# (nothing) — up to date, snoozed, disabled, or check skipped
#
# Env overrides (for testing):
# GSTACK_DIR — override auto-detected gstack root
Expand All @@ -16,9 +16,65 @@ GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
CACHE_FILE="$STATE_DIR/last-update-check"
MARKER_FILE="$STATE_DIR/just-upgraded-from"
SNOOZE_FILE="$STATE_DIR/update-snoozed"
VERSION_FILE="$GSTACK_DIR/VERSION"
REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}"

# ─── Step 0: Check if updates are disabled ────────────────────
_UC=$("$GSTACK_DIR/bin/gstack-config" get update_check 2>/dev/null || true)
if [ "$_UC" = "false" ]; then
exit 0
fi

# ─── Snooze helper ──────────────────────────────────────────
# check_snooze <remote_version>
# Returns 0 if snoozed (should stay quiet), 1 if not snoozed (should output).
#
# Snooze file format: <version> <level> <epoch>
# Level durations: 1=24h, 2=48h, 3+=7d
# New version (version mismatch) resets snooze.
check_snooze() {
local remote_ver="$1"
if [ ! -f "$SNOOZE_FILE" ]; then
return 1 # no snooze file → not snoozed
fi
local snoozed_ver snoozed_level snoozed_epoch
snoozed_ver="$(awk '{print $1}' "$SNOOZE_FILE" 2>/dev/null || true)"
snoozed_level="$(awk '{print $2}' "$SNOOZE_FILE" 2>/dev/null || true)"
snoozed_epoch="$(awk '{print $3}' "$SNOOZE_FILE" 2>/dev/null || true)"

# Validate: all three fields must be non-empty
if [ -z "$snoozed_ver" ] || [ -z "$snoozed_level" ] || [ -z "$snoozed_epoch" ]; then
return 1 # corrupt file → not snoozed
fi

# Validate: level and epoch must be integers
case "$snoozed_level" in *[!0-9]*) return 1 ;; esac
case "$snoozed_epoch" in *[!0-9]*) return 1 ;; esac

# New version dropped? Ignore snooze.
if [ "$snoozed_ver" != "$remote_ver" ]; then
return 1
fi

# Compute snooze duration based on level
local duration
case "$snoozed_level" in
1) duration=86400 ;; # 24 hours
2) duration=172800 ;; # 48 hours
*) duration=604800 ;; # 7 days (level 3+)
esac

local now
now="$(date +%s)"
local expires=$(( snoozed_epoch + duration ))
if [ "$now" -lt "$expires" ]; then
return 0 # still snoozed
fi

return 1 # snooze expired
}

# ─── Step 1: Read local version ──────────────────────────────
LOCAL=""
if [ -f "$VERSION_FILE" ]; then
Expand All @@ -32,6 +88,7 @@ fi
if [ -f "$MARKER_FILE" ]; then
OLD="$(cat "$MARKER_FILE" 2>/dev/null | tr -d '[:space:]')"
rm -f "$MARKER_FILE"
rm -f "$SNOOZE_FILE"
mkdir -p "$STATE_DIR"
echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
if [ -n "$OLD" ]; then
Expand All @@ -40,10 +97,10 @@ if [ -f "$MARKER_FILE" ]; then
exit 0
fi

# ─── Step 3: Check cache freshness (24h = 1440 min) ──────────
# ─── Step 3: Check cache freshness (12h = 720 min) ──────────
if [ -f "$CACHE_FILE" ]; then
# Cache is fresh if modified within 1440 minutes
STALE=$(find "$CACHE_FILE" -mmin +1440 2>/dev/null || true)
# Cache is fresh if modified within 720 minutes
STALE=$(find "$CACHE_FILE" -mmin +720 2>/dev/null || true)
if [ -z "$STALE" ]; then
# Cache is fresh — read it
CACHED="$(cat "$CACHE_FILE" 2>/dev/null || true)"
Expand All @@ -60,6 +117,10 @@ if [ -f "$CACHE_FILE" ]; then
# Verify local version still matches cached old version
CACHED_OLD="$(echo "$CACHED" | awk '{print $2}')"
if [ "$CACHED_OLD" = "$LOCAL" ]; then
CACHED_NEW="$(echo "$CACHED" | awk '{print $3}')"
if check_snooze "$CACHED_NEW"; then
exit 0 # snoozed — stay quiet
fi
echo "$CACHED"
exit 0
fi
Expand Down Expand Up @@ -90,4 +151,7 @@ fi

# Versions differ — upgrade available
echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE"
if check_snooze "$REMOTE"; then
exit 0 # snoozed — stay quiet
fi
echo "UPGRADE_AVAILABLE $LOCAL $REMOTE"
2 changes: 1 addition & 1 deletion browse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ _UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/sk
[ -n "$_UPD" ] && echo "$_UPD" || true
```

If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (AskUserQuestion → upgrade if yes, `touch ~/.gstack/last-update-check` if no). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.
If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED <from> <to>`: tell user "Running gstack v{to} (just updated!)" and continue.

# browse: QA Testing & Dogfooding

Expand Down
125 changes: 125 additions & 0 deletions browse/test/gstack-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Tests for bin/gstack-config bash script.
*
* Uses Bun.spawnSync to invoke the script with temp dirs and
* GSTACK_STATE_DIR env override for full isolation.
*/

import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-config');

let stateDir: string;

function run(args: string[] = [], extraEnv: Record<string, string> = {}) {
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
env: {
...process.env,
GSTACK_STATE_DIR: stateDir,
...extraEnv,
},
stdout: 'pipe',
stderr: 'pipe',
});
return {
exitCode: result.exitCode,
stdout: result.stdout.toString().trim(),
stderr: result.stderr.toString().trim(),
};
}

beforeEach(() => {
stateDir = mkdtempSync(join(tmpdir(), 'gstack-config-test-'));
});

afterEach(() => {
rmSync(stateDir, { recursive: true, force: true });
});

describe('gstack-config', () => {
// ─── get ──────────────────────────────────────────────────
test('get on missing file returns empty, exit 0', () => {
const { exitCode, stdout } = run(['get', 'auto_upgrade']);
expect(exitCode).toBe(0);
expect(stdout).toBe('');
});

test('get existing key returns value', () => {
writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\n');
const { exitCode, stdout } = run(['get', 'auto_upgrade']);
expect(exitCode).toBe(0);
expect(stdout).toBe('true');
});

test('get missing key returns empty', () => {
writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\n');
const { exitCode, stdout } = run(['get', 'nonexistent']);
expect(exitCode).toBe(0);
expect(stdout).toBe('');
});

test('get returns last value when key appears multiple times', () => {
writeFileSync(join(stateDir, 'config.yaml'), 'foo: bar\nfoo: baz\n');
const { exitCode, stdout } = run(['get', 'foo']);
expect(exitCode).toBe(0);
expect(stdout).toBe('baz');
});

// ─── set ──────────────────────────────────────────────────
test('set creates file and writes key on missing file', () => {
const { exitCode } = run(['set', 'auto_upgrade', 'true']);
expect(exitCode).toBe(0);
const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8');
expect(content).toContain('auto_upgrade: true');
});

test('set appends new key to existing file', () => {
writeFileSync(join(stateDir, 'config.yaml'), 'foo: bar\n');
const { exitCode } = run(['set', 'auto_upgrade', 'true']);
expect(exitCode).toBe(0);
const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8');
expect(content).toContain('foo: bar');
expect(content).toContain('auto_upgrade: true');
});

test('set replaces existing key in-place', () => {
writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: false\n');
const { exitCode } = run(['set', 'auto_upgrade', 'true']);
expect(exitCode).toBe(0);
const content = readFileSync(join(stateDir, 'config.yaml'), 'utf-8');
expect(content).toContain('auto_upgrade: true');
expect(content).not.toContain('auto_upgrade: false');
});

test('set creates state dir if missing', () => {
const nestedDir = join(stateDir, 'nested', 'dir');
const { exitCode } = run(['set', 'foo', 'bar'], { GSTACK_STATE_DIR: nestedDir });
expect(exitCode).toBe(0);
expect(existsSync(join(nestedDir, 'config.yaml'))).toBe(true);
});

// ─── list ─────────────────────────────────────────────────
test('list shows all keys', () => {
writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\nupdate_check: false\n');
const { exitCode, stdout } = run(['list']);
expect(exitCode).toBe(0);
expect(stdout).toContain('auto_upgrade: true');
expect(stdout).toContain('update_check: false');
});

test('list on missing file returns empty, exit 0', () => {
const { exitCode, stdout } = run(['list']);
expect(exitCode).toBe(0);
expect(stdout).toBe('');
});

// ─── usage ────────────────────────────────────────────────
test('no args shows usage and exits 1', () => {
const { exitCode, stdout } = run([]);
expect(exitCode).toBe(1);
expect(stdout).toContain('Usage');
});
});
Loading