Skip to content

Commit 6b69c46

Browse files
garrytanclaude
andauthored
feat: daily update check + /gstack-upgrade skill (v0.3.4) (garrytan#42)
* feat: add daily update check script + /gstack-upgrade skill bin/gstack-update-check: pure bash, checks VERSION against remote once/day, outputs UPGRADE_AVAILABLE or JUST_UPGRADED. Uses ~/.gstack/ for state. gstack-upgrade/SKILL.md: new skill with inline upgrade flow for all preambles. Detects global-git, local-git, vendored installs. Shows What's New from CHANGELOG. browse/test/gstack-update-check.test.ts: 10 test cases covering all branch paths. * refactor: remove version check from find-browse, simplify to binary locator Delete checkVersion(), readCache(), writeCache(), fetchRemoteSHA(), resolveSkillDir(), CacheEntry interface, REPO_URL/CACHE_PATH/CACHE_TTL constants, and META output from find-browse.ts. Version checking is now handled by bin/gstack-update-check (previous commit). * feat: add update check preamble to all 9 skills Every skill now runs bin/gstack-update-check on invocation. If an upgrade is available, reads gstack-upgrade/SKILL.md inline upgrade flow. Also adds AskUserQuestion to 5 skills that lacked it (gstack root, browse, qa, retro, setup-browser-cookies) and Bash to plan-eng-review. Simplifies qa and setup-browser-cookies setup blocks (removes META parsing). * chore: bump version and changelog (v0.3.4) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unused import + add corrupt cache test Address pre-landing review findings: - Remove unused mkdirSync import from gstack-update-check.test.ts - Add Path I test: corrupt cache file falls through to remote fetch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a468374 commit 6b69c46

21 files changed

Lines changed: 556 additions & 270 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Changelog
22

3+
## 0.3.4 — 2026-03-13
4+
5+
### Added
6+
- **Daily update check** — all 9 skills now check for new versions once per day via `bin/gstack-update-check` (pure bash, <5ms cached). Prompts user via AskUserQuestion with option to upgrade or defer 24h.
7+
- **`/gstack-upgrade` skill** — standalone upgrade command that detects install type (global-git, local-git, vendored), upgrades, and shows a "What's New" summary from CHANGELOG
8+
- **"Just upgraded" confirmation** — after upgrading, the next skill invocation shows "Running gstack v{new} (just updated!)" via `~/.gstack/just-upgraded-from` marker
9+
- **`AskUserQuestion` added to 5 skills** — gstack (root), browse, qa, retro, setup-browser-cookies now have AskUserQuestion in allowed-tools for upgrade prompts
10+
- **`Bash` added to plan-eng-review** — enables the update check preamble to run in plan review sessions
11+
- `browse/test/gstack-update-check.test.ts` — 10 test cases covering all script branch paths with `GSTACK_REMOTE_URL` env var for test isolation
12+
- `TODOS.md` for tracking deferred work
13+
14+
### Changed
15+
- **Version check is now one system** — removed SHA-based `checkVersion()` from `browse/src/find-browse.ts` (~120 lines deleted) and `browse/test/find-browse.test.ts` (~100 lines deleted). Replaced by `bin/gstack-update-check` bash script using semver VERSION comparison with 24h cache.
16+
- Simplified `qa/SKILL.md` and `setup-browser-cookies/SKILL.md` setup blocks — removed old `BROWSE_OUTPUT`/`META` parsing, now use simple `find-browse` call
17+
- Updated `browse/bin/find-browse` shim comments to reflect simplified role (binary locator only)
18+
19+
### Removed
20+
- `checkVersion()`, `readCache()`, `writeCache()`, `fetchRemoteSHA()`, `resolveSkillDir()`, `CacheEntry` interface from `browse/src/find-browse.ts`
21+
- `META:UPDATE_AVAILABLE` protocol from find-browse output
22+
- Old META-based upgrade instructions from qa and setup-browser-cookies SKILL.md files
23+
- Legacy `/tmp/gstack-latest-version` cache file (cleaned up by `setup` script)
24+
325
## Unreleased — 2026-03-14
426

527
### Changed

SKILL.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,21 @@ description: |
1010
allowed-tools:
1111
- Bash
1212
- Read
13+
- AskUserQuestion
1314

1415
---
1516
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
1617
<!-- Regenerate: bun run gen:skill-docs -->
1718

19+
## Update Check (run first)
20+
21+
```bash
22+
_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
23+
[ -n "$_UPD" ] && echo "$_UPD"
24+
```
25+
26+
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.
27+
1828
# gstack browse: QA Testing & Dogfooding
1929

2030
Persistent headless Chromium. First call auto-starts (~3s), then ~100-200ms per command.

SKILL.md.tmpl

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,19 @@ description: |
1010
allowed-tools:
1111
- Bash
1212
- Read
13+
- AskUserQuestion
1314

1415
---
1516

17+
## Update Check (run first)
18+
19+
```bash
20+
_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
21+
[ -n "$_UPD" ] && echo "$_UPD"
22+
```
23+
24+
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.
25+
1626
# gstack browse: QA Testing & Dogfooding
1727

1828
Persistent headless Chromium. First call auto-starts (~3s), then ~100-200ms per command.

TODOS.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# TODOS
2+
3+
## Auto-upgrade mode (zero-prompt)
4+
5+
**What:** Add a `GSTACK_AUTO_UPGRADE=1` env var or `~/.gstack/config` option that skips the AskUserQuestion prompt and upgrades automatically when a new version is detected.
6+
7+
**Why:** Power users and CI environments may want zero-friction upgrades without being asked every time.
8+
9+
**Context:** The current upgrade system (v0.3.4) always prompts via AskUserQuestion. This TODO adds an opt-in bypass. Implementation is ~10 lines in the preamble instructions: check for the env var/config before calling AskUserQuestion, and if set, go straight to the upgrade flow. Depends on the full upgrade system being stable first — wait for user feedback on the prompt-based flow before adding this.
10+
11+
**Effort:** S (small)
12+
**Priority:** P3 (nice-to-have, revisit after adoption data)

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.3.3
1+
0.3.4

bin/gstack-update-check

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env bash
2+
# gstack-update-check — daily version check for all skills.
3+
#
4+
# Output (one line, or nothing):
5+
# JUST_UPGRADED <old> <new> — marker found from recent upgrade
6+
# UPGRADE_AVAILABLE <old> <new> — remote VERSION differs from local
7+
# (nothing) — up to date or check skipped
8+
#
9+
# Env overrides (for testing):
10+
# GSTACK_DIR — override auto-detected gstack root
11+
# GSTACK_REMOTE_URL — override remote VERSION URL
12+
# GSTACK_STATE_DIR — override ~/.gstack state directory
13+
set -euo pipefail
14+
15+
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
16+
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
17+
CACHE_FILE="$STATE_DIR/last-update-check"
18+
MARKER_FILE="$STATE_DIR/just-upgraded-from"
19+
VERSION_FILE="$GSTACK_DIR/VERSION"
20+
REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}"
21+
22+
# ─── Step 1: Read local version ──────────────────────────────
23+
LOCAL=""
24+
if [ -f "$VERSION_FILE" ]; then
25+
LOCAL="$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]')"
26+
fi
27+
if [ -z "$LOCAL" ]; then
28+
exit 0 # No VERSION file → skip check
29+
fi
30+
31+
# ─── Step 2: Check "just upgraded" marker ─────────────────────
32+
if [ -f "$MARKER_FILE" ]; then
33+
OLD="$(cat "$MARKER_FILE" 2>/dev/null | tr -d '[:space:]')"
34+
rm -f "$MARKER_FILE"
35+
mkdir -p "$STATE_DIR"
36+
echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
37+
if [ -n "$OLD" ]; then
38+
echo "JUST_UPGRADED $OLD $LOCAL"
39+
fi
40+
exit 0
41+
fi
42+
43+
# ─── Step 3: Check cache freshness (24h = 1440 min) ──────────
44+
if [ -f "$CACHE_FILE" ]; then
45+
# Cache is fresh if modified within 1440 minutes
46+
STALE=$(find "$CACHE_FILE" -mmin +1440 2>/dev/null || true)
47+
if [ -z "$STALE" ]; then
48+
# Cache is fresh — read it
49+
CACHED="$(cat "$CACHE_FILE" 2>/dev/null || true)"
50+
case "$CACHED" in
51+
UP_TO_DATE*)
52+
exit 0
53+
;;
54+
UPGRADE_AVAILABLE*)
55+
# Verify local version still matches cached old version
56+
CACHED_OLD="$(echo "$CACHED" | awk '{print $2}')"
57+
if [ "$CACHED_OLD" = "$LOCAL" ]; then
58+
echo "$CACHED"
59+
exit 0
60+
fi
61+
# Local version changed (manual upgrade?) — fall through to re-check
62+
;;
63+
esac
64+
fi
65+
fi
66+
67+
# ─── Step 4: Slow path — fetch remote version ────────────────
68+
mkdir -p "$STATE_DIR"
69+
70+
REMOTE=""
71+
REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)"
72+
REMOTE="$(echo "$REMOTE" | tr -d '[:space:]')"
73+
74+
# Validate: must look like a version number (reject HTML error pages)
75+
if ! echo "$REMOTE" | grep -qE '^[0-9]+\.[0-9.]+$'; then
76+
# Invalid or empty response — assume up to date
77+
echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
78+
exit 0
79+
fi
80+
81+
if [ "$LOCAL" = "$REMOTE" ]; then
82+
echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
83+
exit 0
84+
fi
85+
86+
# Versions differ — upgrade available
87+
echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE"
88+
echo "UPGRADE_AVAILABLE $LOCAL $REMOTE"

browse/SKILL.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,21 @@ description: |
1010
allowed-tools:
1111
- Bash
1212
- Read
13+
- AskUserQuestion
1314

1415
---
1516
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
1617
<!-- Regenerate: bun run gen:skill-docs -->
1718

19+
## Update Check (run first)
20+
21+
```bash
22+
_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
23+
[ -n "$_UPD" ] && echo "$_UPD"
24+
```
25+
26+
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.
27+
1828
# browse: QA Testing & Dogfooding
1929

2030
Persistent headless Chromium. First call auto-starts (~3s), then ~100ms per command.

browse/SKILL.md.tmpl

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,19 @@ description: |
1010
allowed-tools:
1111
- Bash
1212
- Read
13+
- AskUserQuestion
1314

1415
---
1516

17+
## Update Check (run first)
18+
19+
```bash
20+
_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
21+
[ -n "$_UPD" ] && echo "$_UPD"
22+
```
23+
24+
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.
25+
1626
# browse: QA Testing & Dogfooding
1727

1828
Persistent headless Chromium. First call auto-starts (~3s), then ~100ms per command.

browse/bin/find-browse

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
#!/bin/bash
22
# Shim: delegates to compiled find-browse binary, falls back to basic discovery.
3-
# The compiled binary adds version checking and META signal support.
3+
# The compiled binary handles git root detection for workspace-local installs.
44
DIR="$(cd "$(dirname "$0")/.." && pwd)/dist"
55
if test -x "$DIR/find-browse"; then
66
exec "$DIR/find-browse" "$@"
77
fi
8-
# Fallback: basic discovery (no version check)
8+
# Fallback: basic discovery
99
ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
1010
if [ -n "$ROOT" ] && test -x "$ROOT/.claude/skills/gstack/browse/dist/browse"; then
1111
echo "$ROOT/.claude/skills/gstack/browse/dist/browse"

browse/src/find-browse.ts

Lines changed: 3 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,14 @@
11
/**
2-
* find-browse — locate the gstack browse binary + check for updates.
2+
* find-browse — locate the gstack browse binary.
33
*
44
* Compiled to browse/dist/find-browse (standalone binary, no bun runtime needed).
5-
*
6-
* Output protocol:
7-
* Line 1: /path/to/binary (always present)
8-
* Line 2+: META:<TYPE> <json-payload> (optional, 0 or more)
9-
*
10-
* META types:
11-
* META:UPDATE_AVAILABLE — local binary is behind origin/main
12-
*
13-
* All version checks are best-effort: network failures, missing files, and
14-
* cache errors degrade gracefully to outputting only the binary path.
5+
* Outputs the absolute path to the browse binary on stdout, or exits 1 if not found.
156
*/
167

178
import { existsSync } from 'fs';
18-
import { readFileSync, writeFileSync } from 'fs';
19-
import { join, dirname } from 'path';
9+
import { join } from 'path';
2010
import { homedir } from 'os';
2111

22-
const REPO_URL = 'https://github.com/garrytan/gstack.git';
23-
const CACHE_PATH = '/tmp/gstack-latest-version';
24-
const CACHE_TTL = 14400; // 4 hours in seconds
25-
2612
// ─── Binary Discovery ───────────────────────────────────────────
2713

2814
function getGitRoot(): string | null {
@@ -55,114 +41,6 @@ export function locateBinary(): string | null {
5541
return null;
5642
}
5743

58-
// ─── Version Check ──────────────────────────────────────────────
59-
60-
interface CacheEntry {
61-
sha: string;
62-
timestamp: number;
63-
}
64-
65-
function readCache(): CacheEntry | null {
66-
try {
67-
const content = readFileSync(CACHE_PATH, 'utf-8').trim();
68-
const parts = content.split(/\s+/);
69-
if (parts.length < 2) return null;
70-
const sha = parts[0];
71-
const timestamp = parseInt(parts[1], 10);
72-
if (!sha || isNaN(timestamp)) return null;
73-
// Validate SHA is hex
74-
if (!/^[0-9a-f]{40}$/i.test(sha)) return null;
75-
return { sha, timestamp };
76-
} catch {
77-
return null;
78-
}
79-
}
80-
81-
function writeCache(sha: string, timestamp: number): void {
82-
try {
83-
writeFileSync(CACHE_PATH, `${sha} ${timestamp}\n`);
84-
} catch {
85-
// Cache write failure is non-fatal
86-
}
87-
}
88-
89-
function fetchRemoteSHA(): string | null {
90-
try {
91-
const proc = Bun.spawnSync(['git', 'ls-remote', REPO_URL, 'refs/heads/main'], {
92-
stdout: 'pipe',
93-
stderr: 'pipe',
94-
timeout: 10_000, // 10s timeout
95-
});
96-
if (proc.exitCode !== 0) return null;
97-
const output = proc.stdout.toString().trim();
98-
const sha = output.split(/\s+/)[0];
99-
if (!sha || !/^[0-9a-f]{40}$/i.test(sha)) return null;
100-
return sha;
101-
} catch {
102-
return null;
103-
}
104-
}
105-
106-
function resolveSkillDir(binaryPath: string): string | null {
107-
const home = homedir();
108-
const globalPrefix = join(home, '.claude', 'skills', 'gstack');
109-
if (binaryPath.startsWith(globalPrefix)) return globalPrefix;
110-
111-
// Workspace-local: binary is at $ROOT/.claude/skills/gstack/browse/dist/browse
112-
// Skill dir is $ROOT/.claude/skills/gstack
113-
const parts = binaryPath.split('/.claude/skills/gstack/');
114-
if (parts.length === 2) return parts[0] + '/.claude/skills/gstack';
115-
116-
return null;
117-
}
118-
119-
export function checkVersion(binaryDir: string): string | null {
120-
// Read local version
121-
const versionFile = join(binaryDir, '.version');
122-
let localSHA: string;
123-
try {
124-
localSHA = readFileSync(versionFile, 'utf-8').trim();
125-
} catch {
126-
return null; // No .version file → skip check
127-
}
128-
if (!localSHA) return null;
129-
130-
const now = Math.floor(Date.now() / 1000);
131-
132-
// Check cache
133-
let remoteSHA: string | null = null;
134-
const cache = readCache();
135-
if (cache && (now - cache.timestamp) < CACHE_TTL) {
136-
remoteSHA = cache.sha;
137-
}
138-
139-
// Fetch from remote if cache miss
140-
if (!remoteSHA) {
141-
remoteSHA = fetchRemoteSHA();
142-
if (remoteSHA) {
143-
writeCache(remoteSHA, now);
144-
}
145-
}
146-
147-
if (!remoteSHA) return null; // Offline or error → skip check
148-
149-
// Compare
150-
if (localSHA === remoteSHA) return null; // Up to date
151-
152-
// Determine skill directory for update command
153-
const binaryPath = join(binaryDir, 'browse');
154-
const skillDir = resolveSkillDir(binaryPath);
155-
if (!skillDir) return null;
156-
157-
const payload = JSON.stringify({
158-
current: localSHA.slice(0, 8),
159-
latest: remoteSHA.slice(0, 8),
160-
command: `cd ${skillDir} && git stash && git fetch origin && git reset --hard origin/main && ./setup`,
161-
});
162-
163-
return `META:UPDATE_AVAILABLE ${payload}`;
164-
}
165-
16644
// ─── Main ───────────────────────────────────────────────────────
16745

16846
function main() {
@@ -173,9 +51,6 @@ function main() {
17351
}
17452

17553
console.log(bin);
176-
177-
const meta = checkVersion(dirname(bin));
178-
if (meta) console.log(meta);
17954
}
18055

18156
main();

0 commit comments

Comments
 (0)