mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-22 14:07:14 +02:00
* fix(plan-tune): reject never-ask on one-way ids at --write --check already ignored those prefs; --write still stored them and --stats counted them as a working NEVER_ASK. Refuse the write and count leftover on-disk prefs as INERT_ONE_WAY. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix: gstack-config get returns "" with exit 0 for keys that have no default Skill preambles read configuration with VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>") and that fallback only fires on a non-zero exit. lookup_default ended in a catch-all that echoed "" and returned 0, so for any key missing from the table VAR came back empty and the default written right there in the preamble was unreachable. The skill then branched on a value it never specified: "skip entirely if QUESTION_TUNING is false", reached with QUESTION_TUNING="". Four keys that skills actually read had no entry and took that path: question_tuning -> callers assume "false" repo_mode -> callers assume "unknown" team_mode -> callers assume "false" transcript_ingest_mode -> callers assume "off" Each default above is the value the call sites already substitute in their own `|| echo` fallback, so this only makes reachable what was already intended. The catch-all now returns non-zero. That is deliberately scoped to the unknown-key arm alone: keys whose default is intentionally empty still exit 0, because "" is their real answer and their callers depend on it -- cross_project_learnings ("unset triggers the first-time prompt"), redact_repo_visibility ("empty falls through to gh/glab detection"), salience_allowlist, user_slug_at_*. Making every empty answer an error would have broken those. test/gstack-config-defaults.test.ts pins the class rather than the four instances: it parses the case arms and asserts every `gstack-config get <key>` site in the tree is covered, so adding a read without a default fails CI. It also pins the exit-code contract in both directions. Verified failing against the pre-fix script, where it names exactly those four keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(redact): a typo'd subcommand no longer exits 0 having done nothing main() recognised exactly two subcommands and let everything else fall through to the stdin scan. On empty stdin that prints "(no findings)" and exits 0, so: $ gstack-redact install-prepush-hooks # plural typo gstack-redact scan — repo UNKNOWN (no findings) $ echo $? 0 No hook was installed, and the operator has every reason to believe the credential guard is armed. A guard that silently no-ops must never exit 0. Two smaller faults in the same dispatch, both of which lead people here: - There was no --help handler, so `gstack-redact --help` fell through to the scanner. Piping a credential to it scanned the secret and exited 3. - With no piped input and no --from-file, readInput() blocks on readSync(fd 0) until an EOF that an interactive terminal never sends. That prints nothing at all, so it reads as a hang rather than as "this is a filter, feed it". Now: --help/-h/help prints usage and exits 0; an unrecognised positional prints the offender and exits 1; a TTY with nothing piped in prints usage instead of blocking. "scan" stays accepted, because the human output header reads "gstack-redact scan — repo …" and that is what people type. Usage errors exit 1, deliberately not 2 or 3. Those mean MEDIUM and HIGH findings and callers gate dispatch on them, so a usage error exiting 2 would be read as "medium findings — prompt the user". A test pins that. Tests: 4 written failing first, then fixed. Full suite 7,722 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(browse): one ambiguous ref no longer kills the whole annotated screenshot `snapshot -a` exits 1 with "Selector matched multiple elements" on most real pages, so /qa, /canary and /land-and-deploy silently produce reports whose screenshots do not exist. Plain `screenshot <path>` is unaffected. Refs are built as getByRole(role, {name}) and disambiguated with .nth() when role+name repeats. That disambiguation cannot fire for a node with NO accessible name: the locator degrades to getByRole(role) with no name filter, and the count driving .nth() is taken from the FILTERED aria snapshot while getByRole matches the unfiltered DOM. Measured on a live page: the tree surfaced 2 unnamed paragraphs, the DOM had 9. Landmarks (banner/main/contentinfo) and paragraphs are correctly unnamed per ARIA, so this is the common case rather than an edge case. boundingBox() then hits Playwright strict mode, and the catch allowlisted only timeout/closed/Target/Execution-context messages — so the strict-mode error was re-thrown and aborted every remaining annotation. Two changes: - `.first()` before boundingBox(), so an ambiguous ref draws a box on its first match instead of aborting. The heatmap path below has always tolerated this via a bare `catch {}`; annotate was the only path that could be killed outright. - the catch no longer re-throws on unrecognised messages. A box we cannot measure is a box we do not draw, never a reason to lose the rest of the page. Set BROWSE_DEBUG to see what was skipped. Also: `-o` passed without `-a`/`-H` was silently ignored (exit 0, no file), which reads as "screenshots are broken" rather than "you forgot a flag". It now warns and points at `browse screenshot <path>`. Verified by rebuilding both ways against the same page with 51 refs present: before — "Selector matched multiple elements", no file written after — exit 0, 229KB PNG Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(version-bump): missing or empty VERSION no longer repairs a fabricated 0.0.0.0 into package.json repair now fails with exit 2 when the VERSION file is absent or empty instead of folding to DEFAULT ("0.0.0.0") — which passed VERSION_RE and regressed package.json below where it started. classify gains an additive versionFileExists field so /ship can tell a real 0.0.0.0 from a fabricated one. Re-derived from PR #2612 under the generated-file screening rule. Fixes #2600 (repair half; the path-configurability half landed in v1.67 via #2531). Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory-ingest): --probe counts post-attribution, through the same gate --bulk uses probeMode previously stat'd every walked file, so setup-gbrain gated its silent bulk ingest on pre-filter counts that the write path would never ingest (#2394). The attribution decision now lives in ONE shared gate (sessionIsAttributable — cheap-parse: cwd extraction + memoized resolveGitRemote, never a full page build) used by BOTH probeMode and preparePages, so the two stages' post-attribution counts are structurally identical. ProbeReport gains skipped_unattributed; the probe prints what it excluded and --include-unattributed restores raw counts. The parity is pinned at the prepare stage (probe post-attribution == transcripts reaching import), deliberately NOT == final written. Re-derived from PR #2612 under the generated-file screening rule; the shared-gate design and the remote memo are additions from the plan review. Fixes #2394. Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(browse): allow CPU and network throttling for performance measurement Adds Emulation.setCPUThrottlingRate and Network.emulateNetworkConditions to CDP_ALLOWLIST. Motivation: diagnosing a real "uploads take 1-2 minutes" report, the only machine available was a fast developer workstation. Client-side processing measured 1.4s where the user experienced minutes, so the conclusion had to be reached arithmetically rather than observed. Throttling would have let the measurement reproduce the reporter's conditions directly. Both fit the existing posture rather than widening it: - Emulation already allows setDeviceMetricsOverride, clearDeviceMetricsOverride and setUserAgentOverride, which are equally mutating and scoped to the tab. - Neither method reads page content. setCPUThrottlingRate affects only timing; emulateNetworkConditions constrains traffic rather than inspecting it, so no request bodies, headers or cookies are exposed. Both are output: 'trusted' because they return no page-derived data. scope 'tab' for both, matching the surrounding Emulation entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(session-update): lock pidfile records the live holder; hard TTL bounds every wedge (#2613) echo $$ inside the backgrounded subshell recorded the PARENT hook's PID — which exits immediately — so every subsequent session judged the lock stale and rm -rf'd a LIVE holder's lock, letting concurrent updaters run over each other. The pidfile now records ${BASHPID:-$(sh -c 'echo $PPID')} (macOS bash 3.2 has no BASHPID; the sh child's PPID is exactly this subshell). Staleness is now two independent detectors: PID liveness (as before, but against the real holder), and a 30-minute hard TTL on the heartbeat mtime — reclaimed regardless of kill -0, so a recycled PID or hung holder can't wedge the lock forever. The holder touches the pidfile after the pull and after setup, so a legitimately-slow run keeps itself alive. Empty and missing pidfiles are respected inside the TTL window (the mkdir→echo race) and reclaimed past it. Fixes #2613. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(browse): explicit windowsHide on every Bun.spawn site + census tripwire (#2575 residual) Bun.spawn sites were structurally outside the windowsHide census (it swept child_process bindings only). The runtime was already safe — native Bun hides consoles by default and bun-polyfill.cjs defaults windowsHide !== false since #2523/#2539 — but implicit defaults are exactly what regress silently. Every Bun.spawn/spawnSync in browse/src now carries the explicit flag (harmless on unix-only sites like Xvfb/xattr/open), and a second SWEEP in windows-spawn-hide.test.ts fails CI on any new flagless Bun.spawn site. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain): brain worktree advances on the daily sync — no more silently stale brains (#2516) The daily pull refreshed only ~/.gstack itself, never the detached worktree at ~/.gstack-brain-worktree that gbrain actually indexes — so after setup the brain served stale pages forever unless setup-gbrain/sync-gbrain happened to run. brain-sync --once now advances the worktree once per 24h behind an ATTEMPT stamp (.brain-worktree-last-advance — a persistently-failing advance warns once a day, not at every skill boundary), inside the existing run lock and before any ingest step touches the worktree. The new gstack-gbrain-source-wireup --advance-only is built for the unattended cadence: git-only (no gbrain prereqs), pins every operation to the managed worktree (refuses paths that are not worktrees of the artifacts repo), refuses dirty worktrees, and never runs the force-remove recovery — a cron path must not be able to delete local changes. A static pin keeps the force-remove out. docs/gbrain-sync.md stops overclaiming the old cadence. Fixes #2516. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(memory-ingest): honor the per-remote deny/read-only trust policy (#2392) Transcript ingest now respects the same trust store as code import — the gate existed only in gstack-gbrain-sync's runCodeImport, so memory-ingest happily ingested transcripts from deny-listed repos. preparePages filters prepared transcript pages through ONE batch policy lookup (new 'get --batch' verb on bin/gstack-gbrain-repo-policy — the script owns URL normalization; the client adds repoPolicyTierBatch, one spawn for all distinct remotes, so large corpora never pay a 10s-timeout subprocess per remote). Outcomes match code-import semantics: read-only → clean skip (skipped_policy_readonly), deny → counted refusal (skipped_policy_deny), corrupted/unreadable store → HARD ERROR before any write (state, staging, egress receipt, and import all untouched) with the recovery command named — policy corruption must never read as successful ingestion. Artifacts are never policy-filtered (their git_remote is a project slug, not a remote). Fixes #2392. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(config): repo_mode keeps its empty no-default semantics (#2611 follow-up) The ported defaults table synthesized repo_mode → "unknown", but EMPTY is load-bearing for that key: gstack-repo-mode treats any non-empty answer as a user override and skips its own repo classification — the synthesized default turned the classifier into dead code (REPO_MODE=unknown everywhere; caught by test/gstack-repo-mode.test.ts via the wave's cross-agent blame protocol). repo_mode joins the empty-is-real carve-outs (empty output, exit 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pair-agent): consent before killing a healthy headless daemon The pair-agent headed switch spawned 'connect --force-restart' unconditionally — auto-killing a live headless daemon (open tabs, cookies, logins) in direct contradiction of the iron rule it sits beside ('only an explicit --force-restart may kill a live daemon'). The CLI now captures daemon liveness BEFORE ensureServer (which can itself boot a fresh daemon) and relaunches only when the user passed --force-restart to pair-agent; otherwise it prints the tab count and continues against the existing daemon. The /pair-agent skill gains a matching one-way-door consent question (template half rides the wave's template block). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain-status): MCP scoping is per-project, and project-local beats user scope hasRemoteOnlyGbrainMcp scanned EVERY project's mcpServers in ~/.claude.json, so one project's remote gbrain registration reclassified broken local engines as thin-client machine-wide. It now reads user scope plus only the cwd's nearest-ancestor project key. The precedence itself was verified empirically and hermetically (fake HOME + CLAUDE_CONFIG_DIR fixtures, claude 2.1.233): with both scopes defining gbrain, 'claude mcp get gbrain' reports Scope: Local config — PROJECT-LOCAL WINS. Both in-repo consumers assumed the opposite; brain-cache's endpoint resolution flips to nearest-ancestor-project-first, and the stale user-first pin in brain-cache-roundtrip now pins the verified precedence. (The user-first jq in the brain-sync preamble resolver gets the same swap in the template block.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(slug): gstack-slug matches remote-slug's owner-repo canonical form (live misfile bug) Found live during this wave's CEO review: bin/gstack-slug emitted SLUG=garrytan for this garrytan/gstack worktree while remote-slug correctly gave garrytan-gstack — decisions, timeline, ceo-plans, and learnings were filing into the wrong project store (observed polluting Context Recovery with another repo's decisions). Root cause: a stray empty ~/.git directory made the walk-up crown $HOME as the outermost project root; the remote lookup ran only against that root, failed silently, and the basename fallback cached 'garrytan' sticky. NOT worktree-specific — any strong marker on a non-repo ancestor triggered it. Fix: the walk now finds the outermost ancestor whose .git actually resolves an origin remote and derives owner-repo with remote-slug's byte-identical parse; marker-only ancestors keep anchoring the basename fallback but can no longer shadow a real remote. A new cache self-heal recomputes the poisoned shape (cached == basename of a marker root while a remote-bearing repo exists below), preserving legit #2212 stickiness. Nested-repo walk-up, no-remote and non-git fallbacks, and the SLUG=/BRANCH= eval contract are unchanged, pinned by a 10-case parity suite. Store migration for pre-fix data is tracked in TODOS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(brain-sync): per-record spool dir — the enqueue/drain race dies structurally Producers appended lines to .brain-queue.jsonl while the drain re-read and os.replace'd it; the in-code comment admitted a lockless append between the re-read and the replace was lost. Locks and rename-rotation designs were both reviewed and rejected (each retained a tail race); the shipped design is a maildir-style spool: one FILE per record in .brain-queue.d/ (tmp + atomic rename), the drain snapshots filenames, processes, and deletes exactly what it snapshotted. Writer and drainer never share an inode — nothing to race. Semantics: at-least-once (a crash between process and unlink re-drains; downstream content-hash dedup absorbs duplicates); retained (privacy-held) records keep their files; unparseable records are kept + warned, never destroyed. Legacy .brain-queue.jsonl migrates atomically on the next drain (crash-leftover .migrating files recovered too); status/drop-queue count both surfaces; discover-new writes spool records and advances its cursor per-record-written. The preamble's queue-depth line switches to spool count in this wave's template block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin-context): native slug fallback walks up like bash gstack-slug slugFromEnvironment derived the slug from the INNERMOST repo's origin while bash gstack-slug walks to the outermost project root — nested/vendored repos split their stores across the bash/native boundary (win32 hits the native path constantly). The native fallback now ports _outermost_project_root faithfully (strong/weak markers, outermost-strong-wins, 64-depth cap, fixed-point termination) plus the full resolution order: env override → walk-up → sticky cache with the #1125 self-heal → remote get-url → basename. Twelve mirrored scenarios drive BOTH implementations against the same fixtures and pin identical slugs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(next-version): git fallback queries the live remote, never mutates, and keeps 3-digit width The degraded path counted every remote-tracking ref on every remote — stale experiment branches and second remotes inflated version allocation, and a failed base read flipped 3-digit repos to 4-digit slots. Now: ls-remote --heads origin first (GIT_TERMINAL_PROMPT=0, 5s timeout, zero local ref mutation); on failure, local refs/remotes/origin ONLY with an explicit stale-refs warning; a failed base read zeroes at the LOCAL version file's width so a 3-digit repo allocates 0.0.1, not 0.0.1.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(setup): hooks register the global-install path and re-point stale ones Registering hooks from a dev worktree baked that worktree's absolute path into settings.json — deleting the worktree left a dead hook erroring on every session stop, and the presence-only dedup (list-sources | grep) could never re-point it. setup's hook paths now route through _hook_install_path (global install preferred, source dir fallback), and the new ensure-event verb on gstack-settings-hook compares the registered command payload against canonical: identical → no write, different → single atomic replacement (never zero or two registrations). The plan-tune hooks had the same stale pattern and get the same fix without re-triggering their consent prompt. Also hardened: bun 1.3.13 turns an uncaught sync fs error in bun -e into a SILENT exit 0 — the registrar's write path now catches, prints, and exits 1, so a failed update can never report fake-green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(preamble): learnings capture is unconditional at completion (#2402) 43 of 44 learnings entries came from explicit /learn — the completion-status prose read 'if you discovered a durable project quirk... log it', which models treated as optional. The step now ALWAYS runs: review the session for durable learnings, log each one, and state 'No durable learnings this session' explicitly when the review comes up empty — an empty result, never a skipped step. Re-derived from PR #2612 under the generated-file screening rule. Fixes #2402. Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(scrape): untrusted-content warning on the page-fetching skills (#2441) /scrape and /skillify consumed page content with zero injection guidance — the CHANGELOG claimed coverage the skills didn't have. The warning now lives in ONE exported const (UNTRUSTED_CONTENT_WARNING in resolvers/browse.ts), embedded in the browse COMMAND_REFERENCE as before AND injected standalone into both skills via the new {{UNTRUSTED_CONTENT_WARNING}} token — single source, wording can never drift between surfaces. Re-derived from PR #2612 under the generated-file screening rule. (Structural isolation for skillify-generated code is tracked as its own TODO.) Fixes #2441. Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): checklist paths resolve from the installed skill root (#2518) /review Step 2 read .claude/skills/review/checklist.md — a path relative to the TARGET repo, which only resolves in gstack's own checkout. Every checklist/greptile-triage/TODOS-format reference (six across five templates — two more than the issue named, same class) now uses the installed-root form ~/.claude/skills/gstack/review/... that the templates' other references already use. The install-root class itself (non-default install dirs) is #1882, deliberately its own PR. Fixes #2518. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pair-agent): one-way-door consent question before a daemon relaunch (template half) The skill flow now checks daemon liveness before Step 4 and asks an explicit one-way-door question (tabs/cookies/logins are lost) before passing --force-restart — never proceeding on a vague reply. Pairs with the CLI-half commit that stopped pair-agent auto-killing live daemons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(codex): resume does not amortize the ~21K session prelude (#2387) Measured (#2387): every codex exec call pays Codex's session prelude, and a resumed call came in slightly ABOVE a fresh one — resume buys continuity, never token savings. The skill now says so where the resume flow lives: prefer one codex call per skill, batch questions into it. Fixes #2387. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(upgrade): fast-forward first; reset --hard only behind a proved-safe gate (#2517) /gstack-upgrade went straight to stash + reset --hard origin/main. Now it tries git pull --ff-only --autostash first (the same policy session-update's auto-upgrade uses). The destructive fallback runs unprompted ONLY when both git status --porcelain AND git rev-list origin/main..HEAD are empty — a clean tree with unpushed local commits is NOT safe, reset destroys them. Anything else requires an explicit one-way-door confirmation that lists every dirty file and unpushed commit being discarded. Fixes #2517. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(preamble): brain-sync block counts the spool queue and resolves MCP project-first Two resolver halves deferred from earlier wave commits: the queue-depth line counts .brain-queue.d/*.json spool records (plus legacy lines until the drain migrates them), and GBRAIN_MCP_ENTRY_JQ swaps its operands to nearest-ancestor-project-first — matching the empirically verified Claude Code precedence (project-local beats user scope) instead of the backwards user-first assumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate SKILL.md docs + golden fixtures (single regen for the template block) Pure generator output for the six template/resolver commits above (learnings capture, untrusted-content warning, review paths, pair-agent consent, codex resume note, upgrade ff-only, brain-sync block) — bun run gen:skill-docs + --host codex + --host factory, with the three ship golden fixtures refreshed per the documented procedure. The three sidecar-path pins in gen-skill-docs.test.ts move to the new installed-root/$GSTACK_ROOT contract (#2518). Restores template freshness; full suite green from here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: TODOS.md — strike the six wave-fixed residuals, add two follow-ups The v1.67 adversarial-review residuals section shrinks to the one item the wave couldn't reach (iOS tap routing — needs real-device verification). New entries: skillify structural isolation (a prose warning is not a boundary for page-derived generated code) and the slug store migration (pre-fix sessions on stray-marker machines filed data under the degraded slug; post-fix reads go to the correct store, so history needs a merge/alias). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: align cross-cutting pins with the wave's contracts Three suites pinned pre-wave behavior: browse's gstack-config test asserted the old unknown-key ''/exit-0 shape (#2611 made it exit 1); the Windows-paths suite pinned O_APPEND enqueue atomicity (the spool design satisfies the same invariant via tmp + os.replace, one file per record — pinned in its new form); and nine carve-guard skeleton ceilings absorbed the #2402 unconditional-learnings prose (~450B per skill), bumped with measured values per the guard's own protocol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: re-anchor the referenced-path scanner self-check to the gstack-rooted review refs The self-check pinned the review checklist as a class-1 alias-relative ref; #2518 moved those refs to the installed gstack root (class 2). The guard now proves the scanner sees them in their new class, so the class-2 assertion can't go vacuous. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: pin the wave's prose-tier behaviors (ship coverage-audit gap closure) The coverage audit found one regression-shaped gap: nothing pinned that the upgrade template's ff-only pull precedes the gated reset --hard (#2517) — a future template edit reverting to reset-first would fail nothing. Pinned: the ordering, the FF_OK gate, and the unpushed-commits check. Also pinned the two minor gaps: the {{UNTRUSTED_CONTENT_WARNING}} injection points in scrape/skillify (#2441) and brain-uninstall's spool-dir cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: pre-landing review round — 8 auto-fixes + 8 accepted findings hardened The ship review army (4 specialists + red-team + checklist, 29 findings) produced 8 mechanical auto-fixes and 11 decisions; the accepted set: - win32 slug parity completed: lib/bin-context.ts gains the remote-first outermost walk + degraded-cache self-heal the bash side got this wave — the two implementations now agree on the stray-marker live-bug shape, pinned by shared fixtures (multi-specialist 9/10 finding). - probe honors the plan's bounded-read decision: 256KB prefix, extraction semantics mirrored from parseTranscriptJsonl so probe/prepare can never diverge on the same file (>1MB transcript test). - policy normalize parity: bash normalize() now matches canonicalizeRemote on .git/-trailing and uppercase-.GIT shapes (7-shape corpus pinned two ways) — a deny for those shapes could previously slip the transcript gate. - session-update reclaim is TOCTOU-safe (atomic mv-aside on both branches). - settings-hook: unparseable settings.json errors instead of being replaced with {}; ensure-event keys on (event, source) so matcher changes update in place — never zero or two registrations. - dot-only slug guard at both parse sites (hostile 'url = ..' can't escape projects/); enqueue tmp-file janitor (1h TTL, inside the drain lock); brain-sync .migrating never clobbered; drop-queue/status count .migrating; snapshot -o warning correct + surfaced in diff mode; version-bump test order-dependence removed; uninstall clears the advance stamp. Deferred with record: slug heal-probe cost sentinel (P3 TODO), FF_OK conflation (noted, misdiagnosis-only). 270 pass / 0 fail across the 10 touched suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: adversarial round — the P0 finalize fail-safe and 12 hardened findings Three adversarial passes (Claude fresh-context, Codex chaos, Codex structured with P1 gate) on the full wave diff. Multi-source findings, all fixed: - P0: finalize_queue is now explicit-delete-only — a record is unlinked ONLY when classification proves it staged or dropped; a classifier crash, a missing class file, or a malformed pulled .brain-privacy-map.json (which previously nuked the whole snapshotted queue, remotely triggerable) now retains everything, warns, and re-drains next run. load_privacy_map treats corrupt maps as retain-all, never as empty. - next-version cannot silently drop a live claim: unreadable advertised refs get a targeted --depth=1 fetch + retry; still-unreadable claims surface as UNKNOWN warnings instead of duplicate-version silence. - session-update lock: ownership-checked EXIT trap (a TTL-reclaimed holder can no longer delete the new holder's lock) + a 5-min background heartbeat so a legitimately-slow pull/setup is never reclaimed while alive. - ensure-event collapses ALL same-(event,source) duplicates to one canonical entry; unique per-process tmp path; setup call sites surface (not swallow) the hardened refusals. - memory-ingest: --limit counts only policy-permitted pages (denied records no longer starve permitted ones); --probe applies the same policy filter as --bulk (skipped_policy_* fields on the report). - version-bump repair accepts a genuine literal 0.0.0.0 VERSION file. - slug heal restricted to the stray-.git shape — package.json-anchored wrapper roots keep their legit sticky identity (#2212 preserved). - brain-sync: idle fast path sees leftover .migrating records; unparseable spool records quarantine instead of warning forever; migration comment stops overclaiming the transition-window race. - CDP throttling justifications document override persistence (callers own restoration), pinned in the allowlist test. Deferred with record: deny retroactivity for already-ingested pages (P2 TODO, same semantics as the code-import gate); legacy-migration tail race (transition-window, requires pre-spool writers). 288 pass / 0 fail across the 10 touched suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate SKILL.md docs + goldens (Windows-separator jq fix) Pure generator output for the brain-sync block's jq ancestor match now accepting backslash-formed Windows project keys — previously project-scoped brains were invisible on Windows while the TS scope resolvers saw them. Golden ship fixtures refreshed per the documented procedure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: codex verify-pass residuals — chunked cwd read, post-filter partial count, migrating depth The verify re-review passed the P1 gate (0 P1s) and left three residuals, all applied: transcriptCwdFromPrefix reads in chunks until one complete record (4MB cap) so a giant first prompt can't truncate mid-JSON and break probe/bulk parity; partial_pages derives from the FINAL prepared set instead of the whole scanned corpus; the preamble queue-depth line counts leftover .brain-queue.jsonl.migrating records like the status path does (regen + goldens included). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v1.68.0.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: update project documentation for v1.68.0.0 BROWSER.md: fix the $B cdp example (positional JSON params, not --json; depth is the real CDP param) and add the new perf-throttling examples (Emulation.setCPUThrottlingRate, Network.emulateNetworkConditions) with their clear-override counterparts. USING_GBRAIN_WITH_GSTACK.md: the state-files table row for the sync queue now names the maildir-style spool dir .brain-queue.d/ that replaced .brain-queue.jsonl this release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: align memory-pipeline probe pins with the #2394 stage-count contract The paid-tier E2E pinned the pre-fix contract (probe headline = raw discovered). Probe now counts post-attribution — the same gate --bulk uses — with an explicit unattributed-skip line. Adds the --include-unattributed companion pin so all 9 fixtures stay accounted for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(next-version): batch missing-tip fetches — one bounded round trip, never a per-branch crawl The targeted-fetch retry for branches whose advertised tip has no local object ran ONE git fetch per branch (10s cap each). On a shallow clone against a busy remote that crawls the network for minutes — CI's shard deadline killed the free suite mid-file. Missing tips now collect into a single batched shallow fetch (15s cap); refs still missing after the batch (one unservable ref fails the whole transfer) get a capped per-branch retry, and anything past the cap warns as an UNKNOWN claim instead of fetching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(next-version): pin the batched fetch + make the offline-contract tests hermetic Two new G2 pins: N unfetched claim branches resolve with exactly ONE fetch spawn (PATH-shimmed git counts invocations), and one unservable ref no longer poisons the batch — live claims resolve via the bounded retry while only the ghost warns UNKNOWN. The #2545 offline-contract tests now run the CLI in a local fixture repo instead of the repo's own checkout: the checkout path did a live ls-remote against the real origin (operator-network-dependent, and the CI shard-deadline hang). The online-contract test gains a succeeding gh stub, so fallback:null is asserted deterministically instead of only when the operator happens to be authed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(redact-cli): derive the synthetic AWS-key fixture — no contiguous credential literal in source The CI quality gate scans every ADDED diff line with the redact engine, so the #2610 port's raw fixture literals failed the very gate they exist to test. The fixture is now assembled at runtime; the scanner still receives the identical bytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(next-version): pin the fixture's host via origin-URL sniff — kills the last environment dependence The hermetic offline-contract fixture had no origin remote, so detectHost() fell through to auth probes: a machine with glab authed passed via the gitlab path while a bare CI runner read host:unknown (offline stays false there) and failed. The fixture now pushes to a local bare origin at a path containing github.com — the URL sniff pins host:github identically everywhere, asserted explicitly in both tests, with every git call still local. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: y$un_ <forrest.sun527@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: benjamin beres <benjamin.beres@bienpreter.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ricky <ricky@kinokostudio.com.hk> Co-authored-by: Connex Client Access <paul@paulkortman.com> Co-authored-by: henbima <henbima@gmail.com>
2348 lines
106 KiB
Bash
Executable File
2348 lines
106 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gstack setup — build browser binary + register skills with Claude Code / Codex
|
|
set -e
|
|
umask 077 # Restrict new files to owner-only (0o600 files, 0o700 dirs)
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
gstack setup — install gstack skills + build browse binary
|
|
|
|
Usage: ./setup [options]
|
|
|
|
Options:
|
|
--host <name> Install for a specific host (claude, codex, kiro, factory,
|
|
opencode, openclaw, hermes, gbrain, auto). Default: claude.
|
|
--model <id> Codex model profile override. Otherwise reads Codex config.
|
|
--prefix Install skills with the gstack- prefix (e.g. /gstack-review).
|
|
--no-prefix Install skills with short names (e.g. /review). Default.
|
|
--team Switch to team mode (per-repo gstack with auto-update).
|
|
--no-team Force solo install even if a team-mode repo is detected.
|
|
-q, --quiet Suppress progress output.
|
|
-h, --help Show this help and exit.
|
|
|
|
Examples:
|
|
./setup # solo install for Claude Code
|
|
./setup --host codex # install for OpenAI Codex CLI
|
|
./setup --host codex --model gpt-5.6-sol
|
|
./setup --team # team mode for a shared repo
|
|
./setup --no-prefix # use short slash-command names
|
|
|
|
Docs: https://github.com/garrytan/gstack
|
|
EOF
|
|
}
|
|
|
|
# Short-circuit on -h/--help before any environment checks so users can
|
|
# discover flags even without bun installed.
|
|
for _arg in "$@"; do
|
|
case "$_arg" in
|
|
-h|--help) usage; exit 0 ;;
|
|
esac
|
|
done
|
|
|
|
if ! command -v bun >/dev/null 2>&1; then
|
|
echo "Error: bun is required but not installed." >&2
|
|
echo "Install with checksum verification:" >&2
|
|
echo ' BUN_VERSION="1.3.10"' >&2
|
|
echo ' tmpfile=$(mktemp)' >&2
|
|
echo ' curl -fsSL "https://bun.sh/install" -o "$tmpfile"' >&2
|
|
echo ' echo "Verify checksum before running: shasum -a 256 $tmpfile"' >&2
|
|
echo ' BUN_VERSION="$BUN_VERSION" bash "$tmpfile" && rm "$tmpfile"' >&2
|
|
exit 1
|
|
fi
|
|
|
|
INSTALL_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
INSTALL_SKILLS_DIR="$(dirname "$INSTALL_GSTACK_DIR")"
|
|
BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse"
|
|
CODEX_SKILLS="${CODEX_HOME:-$HOME/.codex}/skills"
|
|
CODEX_GSTACK="$CODEX_SKILLS/gstack"
|
|
FACTORY_SKILLS="$HOME/.factory/skills"
|
|
FACTORY_GSTACK="$FACTORY_SKILLS/gstack"
|
|
OPENCODE_SKILLS="$HOME/.config/opencode/skills"
|
|
OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack"
|
|
CURSOR_SKILLS="$HOME/.cursor/skills"
|
|
CURSOR_GSTACK="$CURSOR_SKILLS/gstack"
|
|
|
|
IS_WINDOWS=0
|
|
case "$(uname -s)" in
|
|
MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;;
|
|
esac
|
|
|
|
# Windows: binaries are compiled with .exe suffix
|
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
|
BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse.exe"
|
|
fi
|
|
|
|
# ─── Symlink-or-copy helper ───────────────────────────────────
|
|
# On macOS/Linux: create a symlink (existing behavior).
|
|
# On Windows without Developer Mode (MSYS2/Git Bash): plain ln -snf silently
|
|
# creates a frozen file copy that doesn't refresh after `git pull`. We use
|
|
# explicit `cp -R` / `cp -f` so the user gets a real copy and the staleness
|
|
# is reportable (re-run ./setup after pull). Auto-detects file vs dir.
|
|
#
|
|
# INVARIANT: every symlink in this script MUST route through this helper.
|
|
# A raw ln call here will be caught by test/setup-windows-fallback.test.ts
|
|
# (the static-invariant assertion D7).
|
|
_link_or_copy() {
|
|
local src="$1"
|
|
local dst="$2"
|
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
|
rm -rf "$dst"
|
|
# Unix `ln -snf` accepts a name-only or relative-path source even when the
|
|
# target doesn't resolve from CWD (e.g. the connect-chrome alias points at
|
|
# the sibling-relative "gstack/open-gstack-browser"). On Windows the
|
|
# equivalent semantics don't exist — we'd need a real source on disk to
|
|
# copy. Skip the alias quietly rather than aborting setup under `set -e`.
|
|
if [ ! -e "$src" ]; then
|
|
return 0
|
|
fi
|
|
if [ -d "$src" ]; then
|
|
cp -R "$src" "$dst"
|
|
else
|
|
cp -f "$src" "$dst"
|
|
fi
|
|
else
|
|
ln -snf "$src" "$dst"
|
|
fi
|
|
}
|
|
|
|
# ─── Ownership gates for the Windows refresh bypass (#2444 → #2142) ─────────
|
|
# On Windows a refresh means rm -rf + re-copy (_link_or_copy). The host
|
|
# skills dirs are SHARED namespaces (~/.codex/skills, ~/.factory/skills,
|
|
# ~/.cursor/skills, ...), so a gstack* glob name can collide with a user's
|
|
# OWN real directory (e.g. ~/.cursor/skills/gstack-notes) — deleting it on
|
|
# every ./setup re-run is silent data loss. Mirror of bin/gstack-uninstall's
|
|
# provenance gate (#2563): an existing REAL skill dir may only be replaced
|
|
# when its SKILL.md carries the generated banner. Missing targets and
|
|
# symlinks always pass (replacing a link never destroys content); non-dir
|
|
# targets pass (file targets live inside gstack-owned roots).
|
|
_owned_for_windows_refresh() {
|
|
local dst="$1"
|
|
if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then return 0; fi
|
|
if [ -L "$dst" ]; then return 0; fi
|
|
if [ ! -d "$dst" ]; then return 0; fi
|
|
grep -q '<!-- AUTO-GENERATED from' "$dst/SKILL.md" 2>/dev/null
|
|
}
|
|
|
|
# A sidecar/runtime ROOT (…/skills/gstack) is provably USER-owned when it is
|
|
# a real dir whose SKILL.md exists but lacks the generated banner — a
|
|
# hand-written skill squatting on the canonical name. The sidecar installers
|
|
# skip it entirely rather than write into (or wipe) someone else's skill.
|
|
# A root with NO SKILL.md stays presumed ours: it is the documented gstack
|
|
# install location and old/partial installs legitimately look like that.
|
|
_sidecar_root_user_owned() {
|
|
local root="$1"
|
|
[ -d "$root" ] || return 1
|
|
[ -L "$root" ] && return 1
|
|
[ -f "$root/SKILL.md" ] || return 1
|
|
! grep -q '<!-- AUTO-GENERATED from' "$root/SKILL.md" 2>/dev/null
|
|
}
|
|
|
|
# Swap a freshly-rendered tmp dir into the live render location (#2569
|
|
# hardening). Installed skills SYMLINK into the live dir, so it is only ever
|
|
# replaced AFTER a successful render — a failed render leaves the previous
|
|
# render (and every link into it) fully intact. Keep in sync with
|
|
# bin/gstack-config's _swap_in_render (same contract, both pinned by
|
|
# test/user-render-out-dir-install.test.ts).
|
|
_swap_in_render() {
|
|
local render_dir="$1" render_tmp="$2"
|
|
local render_old="$render_dir.old.$$"
|
|
rm -rf "$render_old"
|
|
if [ -e "$render_dir" ] || [ -L "$render_dir" ]; then mv "$render_dir" "$render_old"; fi
|
|
mv "$render_tmp" "$render_dir"
|
|
rm -rf "$render_old"
|
|
}
|
|
|
|
_WINDOWS_COPY_NOTE_PRINTED=0
|
|
_print_windows_copy_note_once() {
|
|
if [ "$IS_WINDOWS" -eq 1 ] && [ "$_WINDOWS_COPY_NOTE_PRINTED" -eq 0 ]; then
|
|
echo " note: Windows install uses file copies (no Developer Mode required). Re-run ./setup after every 'git pull' to refresh skill files."
|
|
_WINDOWS_COPY_NOTE_PRINTED=1
|
|
fi
|
|
}
|
|
|
|
# ─── Quiet mode helper ────────────────────────────────────────
|
|
QUIET=0
|
|
log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }
|
|
|
|
# ─── Parse flags ──────────────────────────────────────────────
|
|
HOST="claude"
|
|
LOCAL_INSTALL=0
|
|
SKILL_PREFIX=1
|
|
SKILL_PREFIX_FLAG=0
|
|
TEAM_MODE=0
|
|
NO_TEAM_MODE=0
|
|
PLAN_TUNE_HOOKS_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = explicit
|
|
MODEL_OVERRIDE=""
|
|
MODEL_OVERRIDE_SET=0
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, cursor, slate, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;;
|
|
--host=*) HOST="${1#--host=}"; shift ;;
|
|
--model) [ -z "$2" ] && echo "Missing value for --model" >&2 && exit 1; MODEL_OVERRIDE="$2"; MODEL_OVERRIDE_SET=1; shift 2 ;;
|
|
--model=*) MODEL_OVERRIDE="${1#--model=}"; MODEL_OVERRIDE_SET=1; shift ;;
|
|
--local) LOCAL_INSTALL=1; shift ;;
|
|
--prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;;
|
|
--no-prefix) SKILL_PREFIX=0; SKILL_PREFIX_FLAG=1; shift ;;
|
|
--team) TEAM_MODE=1; shift ;;
|
|
--no-team) NO_TEAM_MODE=1; shift ;;
|
|
--plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="yes"; shift ;;
|
|
--no-plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="no"; shift ;;
|
|
--plan-tune-hooks=*) PLAN_TUNE_HOOKS_MODE="${1#--plan-tune-hooks=}"; shift ;;
|
|
-q|--quiet) QUIET=1; shift ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
|
|
case "$HOST" in
|
|
claude|codex|kiro|factory|opencode|cursor|slate|auto) ;;
|
|
openclaw)
|
|
echo ""
|
|
echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code"
|
|
echo "sessions natively via ACP. gstack provides methodology artifacts, not a"
|
|
echo "full skill installation."
|
|
echo ""
|
|
echo "To integrate gstack with OpenClaw:"
|
|
echo " 1. Tell your OpenClaw agent: 'install gstack for openclaw'"
|
|
echo " 2. Or generate artifacts: bun run gen:skill-docs --host openclaw"
|
|
echo " 3. See docs/OPENCLAW.md for the full architecture"
|
|
echo ""
|
|
exit 0 ;;
|
|
hermes)
|
|
echo ""
|
|
echo "Hermes integration uses the same model as OpenClaw — Hermes spawns"
|
|
echo "Claude Code sessions, and gstack provides methodology artifacts."
|
|
echo ""
|
|
echo "To integrate gstack with Hermes:"
|
|
echo " 1. Tell your Hermes agent: 'install gstack for hermes'"
|
|
echo " 2. Or generate artifacts: bun run gen:skill-docs --host hermes"
|
|
echo ""
|
|
exit 0 ;;
|
|
gbrain)
|
|
echo ""
|
|
echo "GBrain is a mod for gstack — it makes coding skills brain-aware."
|
|
echo "GBrain generates brain-enhanced skill variants that search your brain"
|
|
echo "for context before starting and save results after finishing."
|
|
echo ""
|
|
echo "To generate brain-aware skills:"
|
|
echo " bun run gen:skill-docs --host gbrain"
|
|
echo ""
|
|
echo "GBrain setup and brain skills ship from the GBrain repo."
|
|
echo ""
|
|
exit 0 ;;
|
|
*) echo "Unknown --host value: $HOST (expected claude, codex, kiro, factory, opencode, cursor, slate, openclaw, hermes, gbrain, or auto)" >&2; exit 1 ;;
|
|
esac
|
|
|
|
# ─── Resolve skill prefix preference ─────────────────────────
|
|
# Priority: CLI flag > saved config > interactive prompt (or flat default for non-TTY)
|
|
GSTACK_CONFIG="$SOURCE_GSTACK_DIR/bin/gstack-config"
|
|
export GSTACK_SETUP_RUNNING=1 # Prevent gstack-config post-set hook from triggering relink mid-setup
|
|
if [ "$SKILL_PREFIX_FLAG" -eq 0 ]; then
|
|
_saved_prefix="$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || true)"
|
|
if [ "$_saved_prefix" = "true" ]; then
|
|
SKILL_PREFIX=1
|
|
elif [ "$_saved_prefix" = "false" ]; then
|
|
SKILL_PREFIX=0
|
|
else
|
|
# No saved preference — prompt interactively (or default flat for non-TTY/quiet)
|
|
if [ "$QUIET" -eq 1 ]; then
|
|
SKILL_PREFIX=0
|
|
elif [ -t 0 ]; then
|
|
echo ""
|
|
echo "Skill naming: how should gstack skills appear?"
|
|
echo ""
|
|
echo " 1) Short names: /qa, /ship, /review"
|
|
echo " Recommended. Clean and fast to type."
|
|
echo ""
|
|
echo " 2) Namespaced: /gstack-qa, /gstack-ship, /gstack-review"
|
|
echo " Use this if you run other skill packs alongside gstack to avoid conflicts."
|
|
echo ""
|
|
printf "Choice [1/2] (default: 1, auto-selects in 10s): "
|
|
read -t 10 -r _prefix_choice </dev/tty 2>/dev/null || _prefix_choice=""
|
|
case "$_prefix_choice" in
|
|
2) SKILL_PREFIX=1 ;;
|
|
*) SKILL_PREFIX=0 ;;
|
|
esac
|
|
else
|
|
SKILL_PREFIX=0
|
|
fi
|
|
# Save the choice for future runs
|
|
"$GSTACK_CONFIG" set skill_prefix "$([ "$SKILL_PREFIX" -eq 1 ] && echo true || echo false)" 2>/dev/null || true
|
|
fi
|
|
else
|
|
# Flag was passed explicitly — persist the choice
|
|
"$GSTACK_CONFIG" set skill_prefix "$([ "$SKILL_PREFIX" -eq 1 ] && echo true || echo false)" 2>/dev/null || true
|
|
fi
|
|
|
|
# --local: install to .claude/skills/ in the current working directory (deprecated)
|
|
if [ "$LOCAL_INSTALL" -eq 1 ]; then
|
|
echo "Warning: --local is deprecated. Use global install + --team instead." >&2
|
|
echo " See: https://github.com/garrytan/gstack#team-mode" >&2
|
|
if [ "$HOST" = "codex" ]; then
|
|
echo "Error: --local is only supported for Claude Code (not Codex)." >&2
|
|
exit 1
|
|
fi
|
|
INSTALL_SKILLS_DIR="$(pwd)/.claude/skills"
|
|
mkdir -p "$INSTALL_SKILLS_DIR"
|
|
HOST="claude"
|
|
INSTALL_CODEX=0
|
|
fi
|
|
|
|
# For auto: detect which agents are installed
|
|
INSTALL_CLAUDE=0
|
|
INSTALL_CODEX=0
|
|
INSTALL_KIRO=0
|
|
INSTALL_FACTORY=0
|
|
INSTALL_OPENCODE=0
|
|
INSTALL_CURSOR=0
|
|
if [ "$HOST" = "auto" ]; then
|
|
command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1
|
|
command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1
|
|
command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1
|
|
command -v droid >/dev/null 2>&1 && INSTALL_FACTORY=1
|
|
command -v opencode >/dev/null 2>&1 && INSTALL_OPENCODE=1
|
|
# Cursor's `cursor` CLI shim isn't always on PATH; ~/.cursor is the
|
|
# reliable footprint of an installed Cursor IDE.
|
|
command -v cursor >/dev/null 2>&1 && INSTALL_CURSOR=1
|
|
[ -d "$HOME/.cursor" ] && INSTALL_CURSOR=1
|
|
# If none found, default to claude
|
|
if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ]; then
|
|
INSTALL_CLAUDE=1
|
|
fi
|
|
elif [ "$HOST" = "claude" ]; then
|
|
INSTALL_CLAUDE=1
|
|
elif [ "$HOST" = "codex" ]; then
|
|
INSTALL_CODEX=1
|
|
elif [ "$HOST" = "kiro" ]; then
|
|
INSTALL_KIRO=1
|
|
elif [ "$HOST" = "factory" ]; then
|
|
INSTALL_FACTORY=1
|
|
elif [ "$HOST" = "opencode" ]; then
|
|
INSTALL_OPENCODE=1
|
|
elif [ "$HOST" = "cursor" ]; then
|
|
INSTALL_CURSOR=1
|
|
fi
|
|
|
|
if [ "$MODEL_OVERRIDE_SET" -eq 1 ] && [ "$INSTALL_CODEX" -eq 0 ]; then
|
|
echo "Error: --model is supported only when Codex is selected (--host codex or --host auto with Codex installed)." >&2
|
|
exit 1
|
|
fi
|
|
|
|
migrate_direct_codex_install() {
|
|
local gstack_dir="$1"
|
|
local codex_gstack="$2"
|
|
local migrated_dir="$HOME/.gstack/repos/gstack"
|
|
|
|
[ "$gstack_dir" = "$codex_gstack" ] || return 0
|
|
[ -L "$gstack_dir" ] && return 0
|
|
|
|
mkdir -p "$(dirname "$migrated_dir")"
|
|
if [ -e "$migrated_dir" ] && [ "$migrated_dir" != "$gstack_dir" ]; then
|
|
echo "gstack setup failed: direct Codex install detected at $gstack_dir" >&2
|
|
echo "A migrated repo already exists at $migrated_dir; move one of them aside and rerun setup." >&2
|
|
exit 1
|
|
fi
|
|
|
|
log "Migrating direct Codex install to $migrated_dir to avoid duplicate skill discovery..."
|
|
mv "$gstack_dir" "$migrated_dir"
|
|
SOURCE_GSTACK_DIR="$migrated_dir"
|
|
INSTALL_GSTACK_DIR="$migrated_dir"
|
|
INSTALL_SKILLS_DIR="$(dirname "$INSTALL_GSTACK_DIR")"
|
|
BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse"
|
|
# Windows: binaries are compiled with .exe suffix (same as the top-level
|
|
# BROWSE_BIN assignment — this re-derivation must not drop the suffix).
|
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
|
BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse.exe"
|
|
fi
|
|
}
|
|
|
|
if [ "$INSTALL_CODEX" -eq 1 ]; then
|
|
migrate_direct_codex_install "$SOURCE_GSTACK_DIR" "$CODEX_GSTACK"
|
|
fi
|
|
|
|
# Kill an entire process tree rooted at $1, leaves first. Killing only the
|
|
# backgrounded subshell orphans the wedged node/bun -> Chromium probe
|
|
# processes underneath it — re-creating the #2136 stuck-process pile-up and
|
|
# potentially leaving Playwright cache locks held. macOS ships no setsid
|
|
# binary, so a portable group-kill isn't available; walk `pgrep -P` children
|
|
# depth-first instead (pgrep exists on macOS and Linux). Falls back to a
|
|
# plain kill of the root pid when pgrep is unavailable.
|
|
_kill_tree() {
|
|
local pid="$1" child
|
|
if command -v pgrep >/dev/null 2>&1; then
|
|
for child in $(pgrep -P "$pid" 2>/dev/null); do
|
|
_kill_tree "$child"
|
|
done
|
|
fi
|
|
kill -9 "$pid" 2>/dev/null || true
|
|
}
|
|
|
|
# Deadline-bounded wait for a background probe. macOS ships no GNU timeout;
|
|
# poll the PID and SIGKILL the whole probe tree past the deadline. Returns
|
|
# the probe's exit code, or 124 on timeout.
|
|
_wait_with_deadline() {
|
|
local pid="$1" deadline_s="$2" waited=0
|
|
while kill -0 "$pid" 2>/dev/null; do
|
|
if [ "$waited" -ge "$deadline_s" ]; then
|
|
_kill_tree "$pid"
|
|
wait "$pid" 2>/dev/null || true
|
|
return 124
|
|
fi
|
|
sleep 1
|
|
waited=$((waited + 1))
|
|
done
|
|
wait "$pid"
|
|
}
|
|
|
|
ensure_playwright_browser() {
|
|
# #2136: fresh installs hung forever at this probe (macOS arm64) and
|
|
# re-runs stacked stuck process trees, so skills never got linked. Two
|
|
# fixes: prefer Node for the launch probe everywhere it exists (the
|
|
# bun --eval launch is the same pipe-bug family already worked around on
|
|
# Windows), and bound the probe with a 90s deadline — a wedged probe now
|
|
# reports failure (which routes to the install path) instead of hanging
|
|
# setup.
|
|
local probe_cmd
|
|
if command -v node >/dev/null 2>&1; then
|
|
probe_cmd='node -e "const { chromium } = require((process.cwd()) + \"/node_modules/playwright\"); (async () => { const b = await chromium.launch(); await b.close(); })().then(() => process.exit(0), () => process.exit(1))"'
|
|
elif [ "$IS_WINDOWS" -eq 1 ]; then
|
|
echo "gstack setup failed: Node.js is required on Windows" >&2
|
|
return 1
|
|
else
|
|
probe_cmd="bun --eval 'import { chromium } from \"playwright\"; const browser = await chromium.launch(); await browser.close();'"
|
|
fi
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
eval "$probe_cmd"
|
|
) >/dev/null 2>&1 &
|
|
_wait_with_deadline $! 90
|
|
}
|
|
|
|
# P0 #2554: a macOS XProtect definition update can start SIGKILLing the
|
|
# Chromium revision the lockfile pins, which surfaces here as a failed launch
|
|
# probe. Clear com.apple.quarantine on the Playwright cache bundles ONLY —
|
|
# never a GSTACK_CHROMIUM_PATH bundle (that belongs to the wrapper/embedder;
|
|
# same scope contract as browse's probePoisonedChromiumBundle) — so the
|
|
# reinstall below produces a launchable browser. Best-effort and macOS-only.
|
|
_clear_playwright_quarantine() {
|
|
[ "$(uname -s)" = "Darwin" ] || return 0
|
|
local cache_root="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/Library/Caches/ms-playwright}"
|
|
[ -d "$cache_root" ] || return 0
|
|
local d
|
|
for d in "$cache_root"/chromium-* "$cache_root"/chromium_headless_shell-*; do
|
|
[ -d "$d" ] || continue
|
|
echo " clearing com.apple.quarantine on $(basename "$d") (XProtect self-heal, #2554)" >&2
|
|
xattr -dr com.apple.quarantine "$d" 2>/dev/null || true
|
|
done
|
|
}
|
|
|
|
# Ensure a color-emoji font is installed (Linux only).
|
|
#
|
|
# Chromium renders emoji code points as .notdef "tofu" (▯) when no color-emoji
|
|
# font is installed. macOS ships "Apple Color Emoji" and Windows ships "Segoe UI
|
|
# Emoji", so they're fine out of the box. Most Linux distros and containers ship
|
|
# NO color-emoji font, which is why make-pdf output shows tofu in headers/tables
|
|
# that contain emoji. Install Noto Color Emoji to fix it.
|
|
#
|
|
# Best-effort: warn (don't fail) if we can't install — PDFs still generate, they
|
|
# just fall back to tofu for emoji as before. Skip entirely with
|
|
# GSTACK_SKIP_FONTS=1 (CI without sudo, managed machines, offline envs).
|
|
#
|
|
# Returns 0 and sets EMOJI_FONT_INSTALLED=1 when it actually installs a font.
|
|
EMOJI_FONT_INSTALLED=0
|
|
ensure_emoji_font() {
|
|
# macOS/Windows ship a color-emoji font; nothing to do.
|
|
[ "$(uname -s)" = "Linux" ] || return 0
|
|
[ "${GSTACK_SKIP_FONTS:-0}" = "1" ] && return 0
|
|
|
|
# Idempotency: a real COLOR emoji font that resolves for an actual emoji code
|
|
# point (U+1F600). `fc-list :lang=und-zsye` is too broad — it matches symbol
|
|
# and last-resort fallback fonts — so we use fc-match and require color=True.
|
|
if command -v fc-match >/dev/null 2>&1; then
|
|
if fc-match -f '%{family[0]}\t%{color}\n' ':lang=und-zsye:charset=1F600' 2>/dev/null | grep -qi 'True'; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
local sudo=""
|
|
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
|
# -n: never prompt. If a password is required we fail fast into the
|
|
# warn-not-fail path below instead of hanging a non-interactive setup.
|
|
sudo="sudo -n"
|
|
fi
|
|
|
|
# Every package-manager call is wrapped in `timeout` so a stuck dpkg/rpm lock
|
|
# or a wedged mirror fails fast into the warn path instead of hanging setup.
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
echo "Installing color-emoji font (fonts-noto-color-emoji) so make-pdf emoji render (set GSTACK_SKIP_FONTS=1 to skip)..."
|
|
DEBIAN_FRONTEND=noninteractive timeout 30 $sudo apt-get update -qq >/dev/null 2>&1 || true
|
|
DEBIAN_FRONTEND=noninteractive timeout 120 $sudo apt-get install -y -qq fonts-noto-color-emoji >/dev/null 2>&1 || return 1
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
echo "Installing color-emoji font (google-noto-color-emoji-fonts)..."
|
|
timeout 120 $sudo dnf install -y google-noto-color-emoji-fonts >/dev/null 2>&1 || return 1
|
|
elif command -v pacman >/dev/null 2>&1; then
|
|
echo "Installing color-emoji font (noto-fonts-emoji)..."
|
|
timeout 120 $sudo pacman -Sy --noconfirm noto-fonts-emoji >/dev/null 2>&1 || return 1
|
|
elif command -v apk >/dev/null 2>&1; then
|
|
echo "Installing color-emoji font (font-noto-emoji)..."
|
|
timeout 120 $sudo apk add --no-cache font-noto-emoji >/dev/null 2>&1 || return 1
|
|
else
|
|
return 1
|
|
fi
|
|
|
|
# Refresh fontconfig cache so Chromium picks up the new font. Run under sudo
|
|
# for the system cache dirs (unprivileged fc-cache fails on unwritable dirs).
|
|
if command -v fc-cache >/dev/null 2>&1; then
|
|
$sudo fc-cache -f >/dev/null 2>&1 || fc-cache -f >/dev/null 2>&1 || true
|
|
fi
|
|
EMOJI_FONT_INSTALLED=1
|
|
return 0
|
|
}
|
|
|
|
# After a fresh font install, stop any running browse render daemon so the next
|
|
# make-pdf render spawns a fresh Chromium that sees the new font. Chromium
|
|
# caches its font list at process start, so a daemon that was alive before the
|
|
# install would keep emitting tofu. `browse stop` is the graceful API; the
|
|
# daemon auto-respawns on the next render. Best-effort and per-project-root, so
|
|
# we also print a note for daemons in other roots.
|
|
refresh_browse_daemon_for_fonts() {
|
|
[ "$EMOJI_FONT_INSTALLED" -eq 1 ] || return 0
|
|
if [ -x "$BROWSE_BIN" ]; then
|
|
"$BROWSE_BIN" stop >/dev/null 2>&1 || true
|
|
fi
|
|
echo " Installed a color-emoji font. The next make-pdf render will show emoji."
|
|
echo " If a gstack browser is running in another project, restart it to pick up the font."
|
|
}
|
|
|
|
prepare_bun_for_windows_compile() {
|
|
BUN_CMD="bun"
|
|
BUN_CMD_WAS_COPIED=0
|
|
[ "$IS_WINDOWS" -eq 1 ] || return 0
|
|
|
|
local bun_path
|
|
bun_path="$(command -v bun 2>/dev/null || true)"
|
|
case "$bun_path" in
|
|
*[![:ascii:]]*)
|
|
local bun_copy_dir="$SOURCE_GSTACK_DIR/.tmp-bun-bin"
|
|
mkdir -p "$bun_copy_dir"
|
|
cp -f "$bun_path" "$bun_copy_dir/bun.exe"
|
|
BUN_CMD="$bun_copy_dir/bun.exe"
|
|
BUN_CMD_WAS_COPIED=1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
bun_cmd() {
|
|
"$BUN_CMD" "$@"
|
|
}
|
|
|
|
cleanup_copied_bun() {
|
|
if [ "${BUN_CMD_WAS_COPIED:-0}" -eq 1 ]; then
|
|
rm -rf "$SOURCE_GSTACK_DIR/.tmp-bun-bin"
|
|
fi
|
|
}
|
|
|
|
prepare_bun_for_windows_compile
|
|
trap cleanup_copied_bun EXIT
|
|
|
|
# Resolve the model overlay used for generated Codex skills. Setup auto-detects
|
|
# only Codex because it has one canonical TOML config surface; direct generator
|
|
# calls remain deterministic and use the host default unless --model is explicit.
|
|
# The resolver runs on EVERY setup, not just codex installs: step 1b regenerates
|
|
# .agents/ unconditionally, and existing ~/.codex/skills symlinks point into it —
|
|
# a plain `./setup` on a Sol user's machine must not clobber their profile with
|
|
# the hardcoded fallback. The resolver is a read-only TOML lookup that falls
|
|
# back to gpt when no Codex config exists.
|
|
CODEX_GENERATION_MODEL="gpt"
|
|
CODEX_GENERATION_MODEL_SOURCE="default (gpt)"
|
|
_CODEX_MODEL_ARGS=(run scripts/resolve-codex-generation-model.ts)
|
|
if [ "$MODEL_OVERRIDE_SET" -eq 1 ]; then
|
|
_CODEX_MODEL_ARGS+=(--explicit "$MODEL_OVERRIDE")
|
|
fi
|
|
_CODEX_MODEL_OUTPUT="$(cd "$SOURCE_GSTACK_DIR" && bun_cmd "${_CODEX_MODEL_ARGS[@]}")"
|
|
IFS=$'\t' read -r CODEX_GENERATION_MODEL CODEX_GENERATION_MODEL_SOURCE <<< "$_CODEX_MODEL_OUTPUT"
|
|
if [ -z "$CODEX_GENERATION_MODEL" ]; then
|
|
echo "gstack setup failed: Codex model resolver returned no model" >&2
|
|
exit 1
|
|
fi
|
|
if [ "$INSTALL_CODEX" -eq 1 ] || [ "$CODEX_GENERATION_MODEL" != "gpt" ]; then
|
|
log "Codex skill profile: $CODEX_GENERATION_MODEL"
|
|
log "Source: $CODEX_GENERATION_MODEL_SOURCE"
|
|
fi
|
|
|
|
# 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock)
|
|
NEEDS_BUILD=0
|
|
if [ ! -x "$BROWSE_BIN" ]; then
|
|
NEEDS_BUILD=1
|
|
elif [ -n "$(find "$SOURCE_GSTACK_DIR/browse/src" -type f -newer "$BROWSE_BIN" -print -quit 2>/dev/null)" ]; then
|
|
NEEDS_BUILD=1
|
|
elif [ "$SOURCE_GSTACK_DIR/package.json" -nt "$BROWSE_BIN" ]; then
|
|
NEEDS_BUILD=1
|
|
elif [ -f "$SOURCE_GSTACK_DIR/bun.lock" ] && [ "$SOURCE_GSTACK_DIR/bun.lock" -nt "$BROWSE_BIN" ]; then
|
|
NEEDS_BUILD=1
|
|
fi
|
|
|
|
if [ "$NEEDS_BUILD" -eq 1 ]; then
|
|
log "Building browse binary..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
|
bun_cmd run build
|
|
)
|
|
# Safety net: write .version if build script didn't (e.g., git not available during build)
|
|
if [ ! -f "$SOURCE_GSTACK_DIR/browse/dist/.version" ]; then
|
|
git -C "$SOURCE_GSTACK_DIR" rev-parse HEAD > "$SOURCE_GSTACK_DIR/browse/dist/.version" 2>/dev/null || true
|
|
fi
|
|
|
|
# macOS Apple Silicon: ad-hoc codesign compiled binaries.
|
|
# Bun's --compile can produce a corrupt or linker-only code signature that
|
|
# macOS kills with SIGKILL (exit 137). The two-step remove+re-sign is
|
|
# required because a naive `codesign -s - -f` fails when the existing
|
|
# signature block is corrupt. This is idempotent and costs <1s.
|
|
#
|
|
# Some binaries (observed: find-browse, gstack-global-discover) also carry
|
|
# trailing zero-padding AFTER the Mach-O LC_CODE_SIGNATURE region. macOS
|
|
# codesign requires the signature to be the last content and extend to EOF,
|
|
# so the padding triggers "main executable failed strict validation" on
|
|
# re-sign (and "internal error in Code Signing subsystem" on remove). We
|
|
# truncate that trailing slack to the end of LC_CODE_SIGNATURE first, which
|
|
# lets the identical re-sign succeed. The binary runs either way: Bun's
|
|
# adhoc code-page signature satisfies the kernel's exec check even when
|
|
# `codesign --verify` is unhappy, so a re-sign failure only warns when the
|
|
# binary is genuinely SIGKILL'd on exec (exit 137).
|
|
# See: https://github.com/garrytan/gstack/issues/997
|
|
if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then
|
|
for _bin in browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf bin/gstack-global-discover; do
|
|
_bin_path="$SOURCE_GSTACK_DIR/$_bin"
|
|
[ -f "$_bin_path" ] && [ -x "$_bin_path" ] || continue
|
|
# Strip any trailing bytes past LC_CODE_SIGNATURE so codesign can re-sign.
|
|
# otool prints the signature's dataoff+datasize; if the file is larger,
|
|
# the extra bytes are Bun padding that breaks strict validation.
|
|
_sig_end=$(otool -l "$_bin_path" 2>/dev/null | awk '/LC_CODE_SIGNATURE/{f=1} f&&/dataoff/{o=$2} f&&/datasize/{print o+$2; exit}')
|
|
_fsize=$(stat -f%z "$_bin_path" 2>/dev/null)
|
|
if [ -n "$_sig_end" ] && [ -n "$_fsize" ] && [ "$_sig_end" -gt 0 ] 2>/dev/null && [ "$_sig_end" -lt "$_fsize" ] 2>/dev/null; then
|
|
_trunc_tmp=$(mktemp 2>/dev/null) || _trunc_tmp=""
|
|
if [ -n "$_trunc_tmp" ] && head -c "$_sig_end" "$_bin_path" > "$_trunc_tmp" 2>/dev/null; then
|
|
cat "$_trunc_tmp" > "$_bin_path" && chmod +x "$_bin_path"
|
|
fi
|
|
[ -n "$_trunc_tmp" ] && rm -f "$_trunc_tmp"
|
|
fi
|
|
codesign --remove-signature "$_bin_path" 2>/dev/null || true
|
|
if ! codesign -s - -f "$_bin_path" 2>/dev/null; then
|
|
# Re-sign failed. Only warn if the binary genuinely cannot execute
|
|
# (SIGKILL = exit 137). Otherwise Bun's adhoc code-page signature still
|
|
# runs fine and the codesign --verify miss is cosmetic. set -e safe.
|
|
_probe_rc=0
|
|
"$_bin_path" --help >/dev/null 2>&1 || _probe_rc=$?
|
|
if [ "$_probe_rc" -eq 137 ]; then
|
|
log "warning: codesign failed for $_bin and it is SIGKILL'd on exec (exit 137) — it may not run on Apple Silicon"
|
|
else
|
|
log "note: codesign could not re-sign $_bin, but it executes fine (Bun adhoc signature); continuing"
|
|
fi
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# macOS: install coreutils for `gtimeout` (Codex hang protection in /codex + /autoplan).
|
|
# macOS ships BSD `timeout`-less; Homebrew's coreutils installs GNU timeout as
|
|
# `gtimeout` to avoid shadowing BSD utilities. The /codex and /autoplan skills
|
|
# fall back to unwrapped codex invocations when neither is available — this
|
|
# auto-install upgrades them to hang-protected where possible.
|
|
# Skip entirely with GSTACK_SKIP_COREUTILS=1 (CI, managed machines, offline envs).
|
|
if [ "$(uname -s)" = "Darwin" ] && [ "${GSTACK_SKIP_COREUTILS:-0}" != "1" ]; then
|
|
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
|
|
if command -v brew >/dev/null 2>&1; then
|
|
log "Installing coreutils for Codex hang protection (set GSTACK_SKIP_COREUTILS=1 to skip)..."
|
|
brew install coreutils >/dev/null 2>&1 || log "warning: brew install coreutils failed; /codex will run without hang protection"
|
|
else
|
|
log "warning: Homebrew not found. /codex will run without hang protection. Install coreutils manually or set GSTACK_SKIP_COREUTILS=1."
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ ! -x "$BROWSE_BIN" ]; then
|
|
echo "gstack setup failed: browse binary missing at $BROWSE_BIN" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# 1b. Generate .agents/ Codex skill docs — always regenerate to prevent stale descriptions.
|
|
# .agents/ is no longer committed — generated at setup time from .tmpl templates.
|
|
# bun run build generates the host-default artifact. Always render Codex again
|
|
# with the resolved user profile so a build cannot overwrite a Sol-specific render.
|
|
# Always regenerate: generation is fast (<2s) and mtime-based staleness checks are fragile
|
|
# (miss stale files when timestamps match after clone/checkout/upgrade).
|
|
AGENTS_DIR="$SOURCE_GSTACK_DIR/.agents/skills"
|
|
NEEDS_AGENTS_GEN=1
|
|
|
|
if [ "$NEEDS_AGENTS_GEN" -eq 1 ]; then
|
|
log "Generating .agents/ skill docs..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
|
bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL"
|
|
)
|
|
fi
|
|
|
|
# 1c. Generate .factory/ Factory Droid skill docs
|
|
if [ "$INSTALL_FACTORY" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
|
|
log "Generating .factory/ skill docs..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
|
bun_cmd run gen:skill-docs --host factory
|
|
)
|
|
fi
|
|
|
|
# 1d. Generate .opencode/ OpenCode skill docs
|
|
if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
|
|
log "Generating .opencode/ skill docs..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
|
bun_cmd run gen:skill-docs --host opencode
|
|
)
|
|
fi
|
|
|
|
# 1e. Generate .cursor/ Cursor skill docs
|
|
if [ "$INSTALL_CURSOR" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
|
|
log "Generating .cursor/ skill docs..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
|
bun_cmd run gen:skill-docs --host cursor
|
|
)
|
|
fi
|
|
|
|
# 2. Ensure Playwright's Chromium is available
|
|
# Detect Ubuntu 26.04: Playwright does not yet ship a native chromium build for
|
|
# ubuntu26.04-x64. Override the platform to ubuntu24.04-x64 so the installer
|
|
# picks the correct binary. This is safe because the ubuntu24.04 build runs
|
|
# fine on ubuntu26.04 (same glibc lineage). See #2101.
|
|
_PLAYWRIGHT_PLATFORM_OVERRIDE=""
|
|
if [ -f /etc/os-release ]; then
|
|
_os_id=$(grep '^ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
|
|
_os_ver=$(grep '^VERSION_ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
|
|
if [ "$_os_id" = "ubuntu" ] && [ "$_os_ver" = "26.04" ]; then
|
|
_PLAYWRIGHT_PLATFORM_OVERRIDE="ubuntu24.04-x64"
|
|
echo "Ubuntu 26.04 detected — using PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=$_PLAYWRIGHT_PLATFORM_OVERRIDE"
|
|
fi
|
|
fi
|
|
|
|
if ! ensure_playwright_browser; then
|
|
echo "Installing Playwright Chromium..."
|
|
# XProtect self-heal (#2554): the probe failure may be the OS killing the
|
|
# cached Chromium, not a missing install. Clear quarantine on the Playwright
|
|
# cache bundles before reinstalling so the fresh fetch launches clean.
|
|
_clear_playwright_quarantine
|
|
_PW_LOCK="${TMPDIR:-/tmp}/gstack-playwright-install.lock"
|
|
# Stale-lock self-heal: a SIGKILL'd prior setup leaves the lock dir behind
|
|
# forever (mkdir mutexes have no owner). If the recorded holder PID is dead,
|
|
# reclaim instead of telling the user to rmdir by hand.
|
|
if [ -d "$_PW_LOCK" ] && [ -f "$_PW_LOCK/pid" ]; then
|
|
_PW_HOLDER=$(cat "$_PW_LOCK/pid" 2>/dev/null || true)
|
|
if [ -n "$_PW_HOLDER" ] && ! kill -0 "$_PW_HOLDER" 2>/dev/null; then
|
|
echo " reclaiming stale Chromium-install lock (holder pid $_PW_HOLDER is gone)" >&2
|
|
rm -rf "$_PW_LOCK" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
if mkdir "$_PW_LOCK" 2>/dev/null; then
|
|
echo "$$" > "$_PW_LOCK/pid" 2>/dev/null || true
|
|
# Chain the earlier cleanup_copied_bun EXIT trap: `trap ... EXIT` REPLACES
|
|
# the previous handler, so the lock trap must run both or any run taking
|
|
# this path leaves .tmp-bun-bin behind.
|
|
trap 'rm -rf "$_PW_LOCK" 2>/dev/null || true; cleanup_copied_bun' EXIT
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
if [ -n "$_PLAYWRIGHT_PLATFORM_OVERRIDE" ]; then
|
|
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="$_PLAYWRIGHT_PLATFORM_OVERRIDE" bunx playwright install chromium
|
|
else
|
|
bunx playwright install chromium
|
|
fi
|
|
)
|
|
rm -rf "$_PW_LOCK" 2>/dev/null || true
|
|
# Restore the original handler (never `trap - EXIT`, which would clear
|
|
# cleanup_copied_bun for the rest of the script).
|
|
trap cleanup_copied_bun EXIT
|
|
else
|
|
echo " another gstack setup is already installing Chromium (lock: $_PW_LOCK)." >&2
|
|
echo " Wait for it to finish, then re-run ./setup. If no other setup is running," >&2
|
|
echo " remove the stale lock: rm -rf \"$_PW_LOCK\"" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
|
# On Windows, Node.js launches Chromium (not Bun — see oven-sh/bun#4253).
|
|
# Ensure playwright is importable by Node from the gstack directory.
|
|
if ! command -v node >/dev/null 2>&1; then
|
|
echo "gstack setup failed: Node.js is required on Windows (Bun cannot launch Chromium due to a pipe bug)" >&2
|
|
echo " Install Node.js: https://nodejs.org/" >&2
|
|
exit 1
|
|
fi
|
|
echo "Windows detected — verifying Node.js can load Playwright..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
# Bun's node_modules already has playwright; verify Node can require it
|
|
node -e "require('playwright')" 2>/dev/null || npm install --no-save playwright
|
|
# @ngrok/ngrok is externalized in server-node.mjs and resolved at runtime.
|
|
# Verify the platform-specific native binary is installed so /pair-agent
|
|
# tunnels don't fail later with a cryptic module-not-found error.
|
|
node -e "require('@ngrok/ngrok')" 2>/dev/null || npm install --no-save @ngrok/ngrok
|
|
)
|
|
fi
|
|
fi
|
|
|
|
if ! ensure_playwright_browser; then
|
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
|
echo "gstack setup failed: Playwright Chromium could not be launched via Node.js" >&2
|
|
echo " This is a known issue with Bun on Windows (oven-sh/bun#4253)." >&2
|
|
echo " Ensure Node.js is installed and 'node -e \"require('playwright')\"' works." >&2
|
|
else
|
|
echo "gstack setup failed: Playwright Chromium could not be launched" >&2
|
|
fi
|
|
exit 1
|
|
fi
|
|
|
|
# 2b. Ensure a color-emoji font is installed so make-pdf emoji render (Linux).
|
|
# Best-effort: warn instead of failing if it can't install.
|
|
if ! ensure_emoji_font; then
|
|
echo " Note: could not auto-install a color-emoji font. Emoji in make-pdf" >&2
|
|
echo " output may render as boxes (▯). Install one manually, e.g.:" >&2
|
|
echo " Debian/Ubuntu: sudo apt-get install fonts-noto-color-emoji" >&2
|
|
echo " Fedora: sudo dnf install google-noto-color-emoji-fonts" >&2
|
|
echo " Arch: sudo pacman -S noto-fonts-emoji" >&2
|
|
echo " Alpine: sudo apk add font-noto-emoji" >&2
|
|
else
|
|
refresh_browse_daemon_for_fonts
|
|
fi
|
|
|
|
# 3. Ensure ~/.gstack global state directory exists
|
|
mkdir -p "$HOME/.gstack/projects"
|
|
|
|
# ─── Helper: link a skill's runtime assets into its installed dir ────────────
|
|
# Installs EVERY runtime asset a skill ships next to its SKILL.md (#2317,
|
|
# #2454): review/checklist.md + specialists/, qa/templates + references,
|
|
# gstack-upgrade/migrations, careful/bin, freeze/bin, sections/, etc.
|
|
# Exclusion list rather than inclusion list (F7) so a new asset file is
|
|
# installed by default instead of silently dropped:
|
|
# - SKILL.md linked separately by the caller (name-aware)
|
|
# - node_modules dependency trees, never a runtime read
|
|
# - dist compiled binaries; skills reference them repo-anchored
|
|
# (~/.claude/skills/gstack/browse/dist/...), never
|
|
# alias-relative, and fresh clones haven't built them
|
|
# - test test fixtures
|
|
# - *.tmpl generator sources; the generated file is the asset
|
|
# - hidden files excluded by the glob (no dotglob)
|
|
# Shared so any flattened-skill installer can reuse it (the Claude path is
|
|
# the first consumer; codex/factory/opencode install from generated trees).
|
|
_link_skill_runtime_assets() {
|
|
local src_dir="$1"
|
|
local dst_dir="$2"
|
|
local asset asset_name
|
|
for asset in "$src_dir"/*; do
|
|
[ -e "$asset" ] || continue # empty-glob guard
|
|
asset_name="$(basename "$asset")"
|
|
case "$asset_name" in
|
|
SKILL.md|node_modules|dist|test|*.tmpl) continue ;;
|
|
esac
|
|
# Refresh unconditionally: rm the old entry (symlink OR real copy — the
|
|
# Windows install pattern) so re-runs after `git pull` pick up changes.
|
|
if [ -e "$dst_dir/$asset_name" ] || [ -L "$dst_dir/$asset_name" ]; then
|
|
rm -rf "$dst_dir/$asset_name"
|
|
fi
|
|
_link_or_copy "$asset" "$dst_dir/$asset_name"
|
|
# P5: the exclusion list above filters DIRECT children only, but the
|
|
# Windows cp -R copy sweeps NESTED gitignored build output too (concrete:
|
|
# ios-qa/scripts/gen-accessors-tool/.build is 252MB). Prune post-copy —
|
|
# a rendered skill install is never a build root, so nested
|
|
# node_modules/.build/dist are dead weight. ONLY here: the generic
|
|
# _link_or_copy stays untouched because runtime roots (browse/, design/)
|
|
# intentionally copy their dist/ binaries.
|
|
if [ "$IS_WINDOWS" -eq 1 ] && [ -d "$dst_dir/$asset_name" ] && [ ! -L "$dst_dir/$asset_name" ]; then
|
|
find "$dst_dir/$asset_name" -type d \( -name node_modules -o -name .build -o -name dist \) -prune -exec rm -rf {} + 2>/dev/null || true
|
|
fi
|
|
done
|
|
}
|
|
|
|
# ─── Helper: link Claude skill subdirectories into a skills parent directory ──
|
|
# Creates real directories (not symlinks) at the top level with a SKILL.md symlink
|
|
# inside. This ensures Claude discovers them as top-level skills, not nested under
|
|
# gstack/ (which would auto-prefix them as gstack-*).
|
|
# When SKILL_PREFIX=1, directories are prefixed with "gstack-".
|
|
# Use --no-prefix to restore flat names.
|
|
link_claude_skill_dirs() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local linked=()
|
|
for skill_dir in "$gstack_dir"/*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
dir_name="$(basename "$skill_dir")"
|
|
# Skip node_modules
|
|
[ "$dir_name" = "node_modules" ] && continue
|
|
# Use frontmatter name: if present (e.g., run-tests/ with name: test → symlink as "test")
|
|
skill_name=$(grep -m1 '^name:' "$skill_dir/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]')
|
|
[ -z "$skill_name" ] && skill_name="$dir_name"
|
|
# Apply gstack- prefix unless --no-prefix or already prefixed
|
|
if [ "$SKILL_PREFIX" -eq 1 ]; then
|
|
case "$skill_name" in
|
|
gstack-*) link_name="$skill_name" ;;
|
|
*) link_name="gstack-$skill_name" ;;
|
|
esac
|
|
else
|
|
link_name="$skill_name"
|
|
fi
|
|
target="$skills_dir/$link_name"
|
|
# Upgrade old directory symlinks to real directories
|
|
if [ -L "$target" ]; then
|
|
rm -f "$target"
|
|
fi
|
|
# Create real directory with symlinked SKILL.md (absolute path)
|
|
# Use mkdir -p unconditionally (idempotent) to avoid TOCTOU race
|
|
mkdir -p "$target"
|
|
# Validate target isn't a symlink before creating the link
|
|
if [ -L "$target/SKILL.md" ]; then rm "$target/SKILL.md"; fi
|
|
# #2569: prefer a rendered :user variant when present. gbrain installs
|
|
# render brain-aware SKILL.md into ${GSTACK_HOME}/render/claude via
|
|
# gen:skill-docs --out-dir instead of dirtying the tracked source
|
|
# checkout; when a render exists for this skill, serve it. The rendered
|
|
# file's section-base paths point into the render dir, so section reads
|
|
# resolve there too.
|
|
_skill_md_src="$gstack_dir/$dir_name/SKILL.md"
|
|
_render_dir="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
|
|
if [ -f "$_render_dir/$dir_name/SKILL.md" ]; then
|
|
_skill_md_src="$_render_dir/$dir_name/SKILL.md"
|
|
fi
|
|
_link_or_copy "$_skill_md_src" "$target/SKILL.md"
|
|
# Link every runtime asset the skill ships next to its SKILL.md (#2317,
|
|
# #2454): sections/ for carved skills, review's checklist.md +
|
|
# specialists/, qa's templates/ + references/, gstack-upgrade's
|
|
# migrations/, careful/freeze's bin/, ... Without this, only SKILL.md
|
|
# landed and /review 404'd at "Read .claude/skills/review/checklist.md"
|
|
# on every fresh Claude install. Routes through _link_or_copy so Windows
|
|
# gets real copies refreshed on every ./setup.
|
|
_link_skill_runtime_assets "$gstack_dir/$dir_name" "$target"
|
|
linked+=("$link_name")
|
|
fi
|
|
done
|
|
if [ ${#linked[@]} -gt 0 ]; then
|
|
echo " linked skills: ${linked[*]}"
|
|
_print_windows_copy_note_once
|
|
fi
|
|
}
|
|
|
|
# ─── Helper: install an alias SKILL.md as a rewritten COPY ───────────────────
|
|
# Alias dirs (_gstack-command, connect-chrome) must NOT symlink the canonical
|
|
# SKILL.md: the alias then carries the canonical frontmatter name:, Claude Code
|
|
# sees two skills with the same name, and drops the ENTIRE personal-skills set
|
|
# (#2511, #2201). Copy-then-rewrite instead: sed reads the SOURCE and writes a
|
|
# fresh copy with name: set to the alias. It must never edit through an
|
|
# existing symlink — that would rewrite the generated source file itself.
|
|
# NOTE: every alias name passed to this helper (_gstack-command,
|
|
# connect-chrome, gstack-connect-chrome) is hardcoded in the _INVENTORY seed
|
|
# list in bin/gstack-uninstall — keep the two sites in sync when adding or
|
|
# renaming an alias, or uninstall will refuse to delete the new alias dir.
|
|
_install_alias_skill_md() {
|
|
local src_skill_md="$1"
|
|
local dst_dir="$2"
|
|
local alias_name="$3"
|
|
[ -f "$src_skill_md" ] || return 0
|
|
# Old installs left the alias as a whole-dir symlink — replace it.
|
|
if [ -L "$dst_dir" ]; then rm -f "$dst_dir"; fi
|
|
mkdir -p "$dst_dir"
|
|
# Remove any prior symlinked SKILL.md so the redirect below cannot write
|
|
# through it into the generated source.
|
|
rm -f "$dst_dir/SKILL.md"
|
|
sed "1,/^---\$/ s/^name:[[:space:]].*/name: $alias_name/" "$src_skill_md" > "$dst_dir/SKILL.md"
|
|
}
|
|
|
|
# Claude Code skips the repo-shaped ~/.claude/skills/gstack directory when
|
|
# building the user-facing slash-command list. Keep the repo path for runtime
|
|
# assets, and add a separate thin wrapper. Its frontmatter name is rewritten to
|
|
# `_gstack-command` (the dir name) so it never collides with the canonical
|
|
# `gstack` name (#2511).
|
|
link_claude_root_skill_alias() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local target="$skills_dir/_gstack-command"
|
|
|
|
[ -f "$gstack_dir/SKILL.md" ] || return 0
|
|
_install_alias_skill_md "$gstack_dir/SKILL.md" "$target" "_gstack-command"
|
|
echo " linked root skill alias: gstack"
|
|
}
|
|
|
|
# ─── Helper: remove old unprefixed Claude skill entries ───────────────────────
|
|
# Migration: when switching from flat names to gstack- prefixed names,
|
|
# clean up stale symlinks or directories that point into the gstack directory.
|
|
cleanup_old_claude_symlinks() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local removed=()
|
|
for skill_dir in "$gstack_dir"/*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
skill_name="$(basename "$skill_dir")"
|
|
[ "$skill_name" = "node_modules" ] && continue
|
|
# Skip already-prefixed dirs (gstack-upgrade) — no old symlink to clean
|
|
case "$skill_name" in gstack-*) continue ;; esac
|
|
old_target="$skills_dir/$skill_name"
|
|
# Remove directory symlinks pointing into gstack/
|
|
if [ -L "$old_target" ]; then
|
|
link_dest="$(readlink "$old_target" 2>/dev/null || true)"
|
|
case "$link_dest" in
|
|
gstack/*|*/gstack/*)
|
|
rm -f "$old_target"
|
|
removed+=("$skill_name")
|
|
;;
|
|
esac
|
|
# Remove real directories with symlinked SKILL.md pointing into gstack/
|
|
elif [ -d "$old_target" ] && [ -L "$old_target/SKILL.md" ]; then
|
|
link_dest="$(readlink "$old_target/SKILL.md" 2>/dev/null || true)"
|
|
case "$link_dest" in
|
|
*gstack*)
|
|
rm -rf "$old_target"
|
|
removed+=("$skill_name")
|
|
;;
|
|
esac
|
|
# Windows install pattern: real dir with real-file SKILL.md (no symlink
|
|
# available, so we can't readlink to verify provenance). The outer loop
|
|
# iterates known gstack skill names from "$gstack_dir"/*, so a name match
|
|
# plus IS_WINDOWS is safe to treat as gstack-managed during a mode flip.
|
|
elif [ "$IS_WINDOWS" -eq 1 ] && [ -d "$old_target" ] && [ -f "$old_target/SKILL.md" ]; then
|
|
rm -rf "$old_target"
|
|
removed+=("$skill_name")
|
|
fi
|
|
fi
|
|
done
|
|
if [ ${#removed[@]} -gt 0 ]; then
|
|
echo " cleaned up old entries: ${removed[*]}"
|
|
fi
|
|
}
|
|
|
|
# ─── Helper: remove old prefixed Claude skill entries ─────────────────────────
|
|
# Reverse migration: when switching from gstack- prefixed names to flat names,
|
|
# clean up stale gstack-* symlinks or directories that point into the gstack directory.
|
|
cleanup_prefixed_claude_symlinks() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local removed=()
|
|
for skill_dir in "$gstack_dir"/*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
skill_name="$(basename "$skill_dir")"
|
|
[ "$skill_name" = "node_modules" ] && continue
|
|
# Only clean up prefixed entries for dirs that AREN'T already prefixed
|
|
# (e.g., remove gstack-qa but NOT gstack-upgrade which is the real dir name)
|
|
case "$skill_name" in gstack-*) continue ;; esac
|
|
prefixed_target="$skills_dir/gstack-$skill_name"
|
|
# Remove directory symlinks pointing into gstack/
|
|
if [ -L "$prefixed_target" ]; then
|
|
link_dest="$(readlink "$prefixed_target" 2>/dev/null || true)"
|
|
case "$link_dest" in
|
|
gstack/*|*/gstack/*)
|
|
rm -f "$prefixed_target"
|
|
removed+=("gstack-$skill_name")
|
|
;;
|
|
esac
|
|
# Remove real directories with symlinked SKILL.md pointing into gstack/
|
|
elif [ -d "$prefixed_target" ] && [ -L "$prefixed_target/SKILL.md" ]; then
|
|
link_dest="$(readlink "$prefixed_target/SKILL.md" 2>/dev/null || true)"
|
|
case "$link_dest" in
|
|
*gstack*)
|
|
rm -rf "$prefixed_target"
|
|
removed+=("gstack-$skill_name")
|
|
;;
|
|
esac
|
|
# Windows install pattern: real dir with real-file SKILL.md. Same
|
|
# reasoning as cleanup_old_claude_symlinks — directory name match plus
|
|
# IS_WINDOWS is safe during a mode flip.
|
|
elif [ "$IS_WINDOWS" -eq 1 ] && [ -d "$prefixed_target" ] && [ -f "$prefixed_target/SKILL.md" ]; then
|
|
rm -rf "$prefixed_target"
|
|
removed+=("gstack-$skill_name")
|
|
fi
|
|
fi
|
|
done
|
|
if [ ${#removed[@]} -gt 0 ]; then
|
|
echo " cleaned up prefixed entries: ${removed[*]}"
|
|
fi
|
|
}
|
|
|
|
# ─── Helper: link generated Codex skills into a skills parent directory ──
|
|
# Installs from .agents/skills/gstack-* (the generated Codex-format skills)
|
|
# instead of source dirs (which have Claude paths).
|
|
link_codex_skill_dirs() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local agents_dir="$gstack_dir/.agents/skills"
|
|
local linked=()
|
|
|
|
if [ ! -d "$agents_dir" ]; then
|
|
echo " Generating .agents/ skill docs..."
|
|
( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" )
|
|
fi
|
|
|
|
if [ ! -d "$agents_dir" ]; then
|
|
echo " warning: .agents/skills/ generation failed — run 'bun run gen:skill-docs --host codex --model $CODEX_GENERATION_MODEL' manually" >&2
|
|
return 1
|
|
fi
|
|
|
|
for skill_dir in "$agents_dir"/gstack*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
skill_name="$(basename "$skill_dir")"
|
|
# Skip the sidecar directory — it contains runtime asset symlinks (bin/,
|
|
# browse/), not a skill. Linking it would overwrite the root gstack
|
|
# symlink that Step 5 already pointed at the repo root.
|
|
[ "$skill_name" = "gstack" ] && continue
|
|
target="$skills_dir/$skill_name"
|
|
# Create or update symlink
|
|
# #2444: on Windows the installed target is a REAL directory copy, so
|
|
# the symlink-or-missing guard skipped every re-run and SKILL.md never
|
|
# refreshed after `git pull`. IS_WINDOWS bypasses the guard —
|
|
# _link_or_copy rm -rf's the destination first, refreshing the copy.
|
|
# #2142: a real dir may only be replaced when it is provably ours
|
|
# (_owned_for_windows_refresh), never a user's own colliding dir.
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
|
|
if _owned_for_windows_refresh "$target"; then
|
|
_link_or_copy "$skill_dir" "$target"
|
|
linked+=("$skill_name")
|
|
else
|
|
echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2
|
|
fi
|
|
fi
|
|
fi
|
|
done
|
|
if [ ${#linked[@]} -gt 0 ]; then
|
|
echo " linked skills: ${linked[*]}"
|
|
fi
|
|
}
|
|
|
|
# ─── Helper: create .agents/skills/gstack/ sidecar symlinks ──────────
|
|
# Codex/Gemini/Cursor read skills from .agents/skills/. We link runtime
|
|
# assets (bin/, browse/dist/, review/, qa/, etc.) so skill templates can
|
|
# resolve paths like $SKILL_ROOT/review/design-checklist.md.
|
|
create_agents_sidecar() {
|
|
local repo_root="$1"
|
|
local agents_gstack="$repo_root/.agents/skills/gstack"
|
|
# #2142: a hand-written skill squatting on the canonical name is the
|
|
# user's — never write into it (the Windows branch would rm -rf its
|
|
# subdirs on every re-run).
|
|
if _sidecar_root_user_owned "$agents_gstack"; then
|
|
echo " left in place (existing dir not gstack-managed — no generated banner): $agents_gstack" >&2
|
|
return 0
|
|
fi
|
|
mkdir -p "$agents_gstack"
|
|
|
|
# Sidecar directories that skills reference at runtime. bin scripts import
|
|
# shared modules via ../lib, so bin and lib must always travel together.
|
|
for asset in bin lib browse review qa; do
|
|
local src="$SOURCE_GSTACK_DIR/$asset"
|
|
local dst="$agents_gstack/$asset"
|
|
if [ -d "$src" ] || [ -f "$src" ]; then
|
|
# #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
|
|
# skipped the refresh. _link_or_copy rm -rf's the destination first.
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then
|
|
_link_or_copy "$src" "$dst"
|
|
fi
|
|
fi
|
|
done
|
|
|
|
# Sidecar files that skills reference at runtime
|
|
for file in ETHOS.md; do
|
|
local src="$SOURCE_GSTACK_DIR/$file"
|
|
local dst="$agents_gstack/$file"
|
|
if [ -f "$src" ]; then
|
|
# #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
|
|
# skipped the refresh. _link_or_copy rm -rf's the destination first.
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then
|
|
_link_or_copy "$src" "$dst"
|
|
fi
|
|
fi
|
|
done
|
|
|
|
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
|
|
# (file-level on purpose: migrations/ and functions/ are dev-only)
|
|
if [ -f "$SOURCE_GSTACK_DIR/supabase/config.sh" ]; then
|
|
mkdir -p "$agents_gstack/supabase"
|
|
_link_or_copy "$SOURCE_GSTACK_DIR/supabase/config.sh" "$agents_gstack/supabase/config.sh"
|
|
fi
|
|
}
|
|
|
|
# ─── Helper: create a minimal ~/.codex/skills/gstack runtime root ───────────
|
|
# Codex scans ~/.codex/skills recursively. Exposing the whole repo here causes
|
|
# duplicate skills because source SKILL.md files and generated Codex skills are
|
|
# both discoverable. Keep this directory limited to runtime assets + root skill.
|
|
create_codex_runtime_root() {
|
|
local gstack_dir="$1"
|
|
local codex_gstack="$2"
|
|
local agents_dir="$gstack_dir/.agents/skills"
|
|
|
|
if [ -L "$codex_gstack" ]; then
|
|
rm -f "$codex_gstack"
|
|
elif [ -d "$codex_gstack" ] && [ "$codex_gstack" != "$gstack_dir" ]; then
|
|
# Old direct installs left a real directory here with stale source skills.
|
|
# Remove it so we start fresh with only the minimal runtime assets.
|
|
rm -rf "$codex_gstack"
|
|
fi
|
|
|
|
mkdir -p "$codex_gstack" "$codex_gstack/browse" "$codex_gstack/gstack-upgrade" "$codex_gstack/review"
|
|
|
|
if [ -f "$agents_dir/gstack/SKILL.md" ]; then
|
|
_link_or_copy "$agents_dir/gstack/SKILL.md" "$codex_gstack/SKILL.md"
|
|
fi
|
|
if [ -d "$gstack_dir/bin" ]; then
|
|
_link_or_copy "$gstack_dir/bin" "$codex_gstack/bin"
|
|
fi
|
|
if [ -d "$gstack_dir/lib" ]; then
|
|
_link_or_copy "$gstack_dir/lib" "$codex_gstack/lib"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/dist" ]; then
|
|
_link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/bin" ]; then
|
|
_link_or_copy "$gstack_dir/browse/bin" "$codex_gstack/browse/bin"
|
|
fi
|
|
if [ -f "$agents_dir/gstack-upgrade/SKILL.md" ]; then
|
|
_link_or_copy "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md"
|
|
fi
|
|
# plan-eng-review's inline office-hours step reads
|
|
# $GSTACK_ROOT/office-hours/SKILL.md (#2449) — install the codex-rendered
|
|
# variant there so the documented path exists.
|
|
if [ -f "${agents_dir}/gstack-office-hours/SKILL.md" ]; then
|
|
mkdir -p "${codex_gstack}/office-hours"
|
|
_link_or_copy "${agents_dir}/gstack-office-hours/SKILL.md" "${codex_gstack}/office-hours/SKILL.md"
|
|
fi
|
|
# Review runtime assets (individual files, NOT the whole review/ dir which has SKILL.md)
|
|
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
|
|
if [ -f "$gstack_dir/review/$f" ]; then
|
|
_link_or_copy "$gstack_dir/review/$f" "$codex_gstack/review/$f"
|
|
fi
|
|
done
|
|
# ETHOS.md — referenced by "Search Before Building" in all skill preambles
|
|
if [ -f "$gstack_dir/ETHOS.md" ]; then
|
|
_link_or_copy "$gstack_dir/ETHOS.md" "$codex_gstack/ETHOS.md"
|
|
fi
|
|
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
|
|
if [ -f "$gstack_dir/supabase/config.sh" ]; then
|
|
mkdir -p "$codex_gstack/supabase"
|
|
_link_or_copy "$gstack_dir/supabase/config.sh" "$codex_gstack/supabase/config.sh"
|
|
fi
|
|
}
|
|
|
|
create_factory_runtime_root() {
|
|
local gstack_dir="$1"
|
|
local factory_gstack="$2"
|
|
local factory_dir="$gstack_dir/.factory/skills"
|
|
|
|
if [ -L "$factory_gstack" ]; then
|
|
rm -f "$factory_gstack"
|
|
elif [ -d "$factory_gstack" ] && [ "$factory_gstack" != "$gstack_dir" ]; then
|
|
rm -rf "$factory_gstack"
|
|
fi
|
|
|
|
mkdir -p "$factory_gstack" "$factory_gstack/browse" "$factory_gstack/gstack-upgrade" "$factory_gstack/review"
|
|
|
|
if [ -f "$factory_dir/gstack/SKILL.md" ]; then
|
|
_link_or_copy "$factory_dir/gstack/SKILL.md" "$factory_gstack/SKILL.md"
|
|
fi
|
|
if [ -d "$gstack_dir/bin" ]; then
|
|
_link_or_copy "$gstack_dir/bin" "$factory_gstack/bin"
|
|
fi
|
|
if [ -d "$gstack_dir/lib" ]; then
|
|
_link_or_copy "$gstack_dir/lib" "$factory_gstack/lib"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/dist" ]; then
|
|
_link_or_copy "$gstack_dir/browse/dist" "$factory_gstack/browse/dist"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/bin" ]; then
|
|
_link_or_copy "$gstack_dir/browse/bin" "$factory_gstack/browse/bin"
|
|
fi
|
|
if [ -f "$factory_dir/gstack-upgrade/SKILL.md" ]; then
|
|
_link_or_copy "$factory_dir/gstack-upgrade/SKILL.md" "$factory_gstack/gstack-upgrade/SKILL.md"
|
|
fi
|
|
# plan-eng-review's inline office-hours step reads
|
|
# $GSTACK_ROOT/office-hours/SKILL.md (#2449) — install the factory-rendered
|
|
# variant there so the documented path exists.
|
|
if [ -f "${factory_dir}/gstack-office-hours/SKILL.md" ]; then
|
|
mkdir -p "${factory_gstack}/office-hours"
|
|
_link_or_copy "${factory_dir}/gstack-office-hours/SKILL.md" "${factory_gstack}/office-hours/SKILL.md"
|
|
fi
|
|
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
|
|
if [ -f "$gstack_dir/review/$f" ]; then
|
|
_link_or_copy "$gstack_dir/review/$f" "$factory_gstack/review/$f"
|
|
fi
|
|
done
|
|
if [ -f "$gstack_dir/ETHOS.md" ]; then
|
|
_link_or_copy "$gstack_dir/ETHOS.md" "$factory_gstack/ETHOS.md"
|
|
fi
|
|
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
|
|
if [ -f "$gstack_dir/supabase/config.sh" ]; then
|
|
mkdir -p "$factory_gstack/supabase"
|
|
_link_or_copy "$gstack_dir/supabase/config.sh" "$factory_gstack/supabase/config.sh"
|
|
fi
|
|
}
|
|
|
|
create_opencode_runtime_root() {
|
|
local gstack_dir="$1"
|
|
local opencode_gstack="$2"
|
|
local opencode_dir="$gstack_dir/.opencode/skills"
|
|
|
|
if [ -L "$opencode_gstack" ]; then
|
|
rm -f "$opencode_gstack"
|
|
elif [ -d "$opencode_gstack" ] && [ "$opencode_gstack" != "$gstack_dir" ]; then
|
|
rm -rf "$opencode_gstack"
|
|
fi
|
|
|
|
mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/design" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa" "$opencode_gstack/plan-devex-review"
|
|
|
|
if [ -f "$opencode_dir/gstack/SKILL.md" ]; then
|
|
_link_or_copy "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md"
|
|
fi
|
|
if [ -d "$gstack_dir/bin" ]; then
|
|
_link_or_copy "$gstack_dir/bin" "$opencode_gstack/bin"
|
|
fi
|
|
if [ -d "$gstack_dir/lib" ]; then
|
|
_link_or_copy "$gstack_dir/lib" "$opencode_gstack/lib"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/dist" ]; then
|
|
_link_or_copy "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/bin" ]; then
|
|
_link_or_copy "$gstack_dir/browse/bin" "$opencode_gstack/browse/bin"
|
|
fi
|
|
if [ -d "$gstack_dir/design/dist" ]; then
|
|
_link_or_copy "$gstack_dir/design/dist" "$opencode_gstack/design/dist"
|
|
fi
|
|
if [ -f "$opencode_dir/gstack-upgrade/SKILL.md" ]; then
|
|
_link_or_copy "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md"
|
|
fi
|
|
# plan-eng-review's inline office-hours step reads
|
|
# $GSTACK_ROOT/office-hours/SKILL.md (#2449) — install the opencode-rendered
|
|
# variant there so the documented path exists.
|
|
if [ -f "${opencode_dir}/gstack-office-hours/SKILL.md" ]; then
|
|
mkdir -p "${opencode_gstack}/office-hours"
|
|
_link_or_copy "${opencode_dir}/gstack-office-hours/SKILL.md" "${opencode_gstack}/office-hours/SKILL.md"
|
|
fi
|
|
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
|
|
if [ -f "$gstack_dir/review/$f" ]; then
|
|
_link_or_copy "$gstack_dir/review/$f" "$opencode_gstack/review/$f"
|
|
fi
|
|
done
|
|
if [ -d "$gstack_dir/review/specialists" ]; then
|
|
_link_or_copy "$gstack_dir/review/specialists" "$opencode_gstack/review/specialists"
|
|
fi
|
|
if [ -d "$gstack_dir/qa/templates" ]; then
|
|
_link_or_copy "$gstack_dir/qa/templates" "$opencode_gstack/qa/templates"
|
|
fi
|
|
if [ -d "$gstack_dir/qa/references" ]; then
|
|
_link_or_copy "$gstack_dir/qa/references" "$opencode_gstack/qa/references"
|
|
fi
|
|
if [ -f "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" ]; then
|
|
_link_or_copy "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" "$opencode_gstack/plan-devex-review/dx-hall-of-fame.md"
|
|
fi
|
|
if [ -f "$gstack_dir/ETHOS.md" ]; then
|
|
_link_or_copy "$gstack_dir/ETHOS.md" "$opencode_gstack/ETHOS.md"
|
|
fi
|
|
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
|
|
if [ -f "$gstack_dir/supabase/config.sh" ]; then
|
|
mkdir -p "$opencode_gstack/supabase"
|
|
_link_or_copy "$gstack_dir/supabase/config.sh" "$opencode_gstack/supabase/config.sh"
|
|
fi
|
|
}
|
|
|
|
link_factory_skill_dirs() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local factory_dir="$gstack_dir/.factory/skills"
|
|
local linked=()
|
|
|
|
if [ ! -d "$factory_dir" ]; then
|
|
echo " Generating .factory/ skill docs..."
|
|
( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host factory )
|
|
fi
|
|
|
|
if [ ! -d "$factory_dir" ]; then
|
|
echo " warning: .factory/skills/ generation failed — run 'bun run gen:skill-docs --host factory' manually" >&2
|
|
return 1
|
|
fi
|
|
|
|
for skill_dir in "$factory_dir"/gstack*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
skill_name="$(basename "$skill_dir")"
|
|
[ "$skill_name" = "gstack" ] && continue
|
|
target="$skills_dir/$skill_name"
|
|
# #2444: on Windows the installed target is a REAL directory copy, so
|
|
# the symlink-or-missing guard skipped every re-run and SKILL.md never
|
|
# refreshed after `git pull`. IS_WINDOWS bypasses the guard —
|
|
# _link_or_copy rm -rf's the destination first, refreshing the copy.
|
|
# #2142: a real dir may only be replaced when it is provably ours
|
|
# (_owned_for_windows_refresh), never a user's own colliding dir.
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
|
|
if _owned_for_windows_refresh "$target"; then
|
|
_link_or_copy "$skill_dir" "$target"
|
|
linked+=("$skill_name")
|
|
else
|
|
echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2
|
|
fi
|
|
fi
|
|
fi
|
|
done
|
|
if [ ${#linked[@]} -gt 0 ]; then
|
|
echo " linked skills: ${linked[*]}"
|
|
fi
|
|
}
|
|
|
|
link_opencode_skill_dirs() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local opencode_dir="$gstack_dir/.opencode/skills"
|
|
local linked=()
|
|
|
|
if [ ! -d "$opencode_dir" ]; then
|
|
echo " Generating .opencode/ skill docs..."
|
|
( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host opencode )
|
|
fi
|
|
|
|
if [ ! -d "$opencode_dir" ]; then
|
|
echo " warning: .opencode/skills/ generation failed — run 'bun run gen:skill-docs --host opencode' manually" >&2
|
|
return 1
|
|
fi
|
|
|
|
for skill_dir in "$opencode_dir"/gstack*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
skill_name="$(basename "$skill_dir")"
|
|
[ "$skill_name" = "gstack" ] && continue
|
|
target="$skills_dir/$skill_name"
|
|
# #2444: on Windows the installed target is a REAL directory copy, so
|
|
# the symlink-or-missing guard skipped every re-run and SKILL.md never
|
|
# refreshed after `git pull`. IS_WINDOWS bypasses the guard —
|
|
# _link_or_copy rm -rf's the destination first, refreshing the copy.
|
|
# #2142: a real dir may only be replaced when it is provably ours
|
|
# (_owned_for_windows_refresh), never a user's own colliding dir.
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
|
|
if _owned_for_windows_refresh "$target"; then
|
|
_link_or_copy "$skill_dir" "$target"
|
|
linked+=("$skill_name")
|
|
else
|
|
echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2
|
|
fi
|
|
fi
|
|
fi
|
|
done
|
|
if [ ${#linked[@]} -gt 0 ]; then
|
|
echo " linked skills: ${linked[*]}"
|
|
fi
|
|
}
|
|
|
|
# ─── Helper: create a minimal ~/.cursor/skills/gstack runtime root ──────────
|
|
# Cursor scans ~/.cursor/skills. Same shape as the Codex/Factory/OpenCode
|
|
# runtime roots: root SKILL.md from the generated tree + runtime assets only.
|
|
# Contributed by @szsunyuan (PR #2547), re-derived onto the current installers.
|
|
create_cursor_runtime_root() {
|
|
local gstack_dir="$1"
|
|
local cursor_gstack="$2"
|
|
local cursor_dir="$gstack_dir/.cursor/skills"
|
|
local generated_root="$cursor_dir/gstack"
|
|
|
|
if [ -L "$cursor_gstack" ]; then
|
|
rm -f "$cursor_gstack"
|
|
elif _sidecar_root_user_owned "$cursor_gstack"; then
|
|
# #2142: a hand-written skill squatting on the canonical name is the
|
|
# user's — never wipe it to make room for the runtime root.
|
|
echo " left in place (existing dir not gstack-managed — no generated banner): $cursor_gstack" >&2
|
|
return 0
|
|
elif [ -d "$cursor_gstack" ] && [ "$cursor_gstack" != "$gstack_dir" ] && [ "$cursor_gstack" != "$generated_root" ]; then
|
|
rm -rf "$cursor_gstack"
|
|
fi
|
|
|
|
mkdir -p "$cursor_gstack" "$cursor_gstack/browse" "$cursor_gstack/gstack-upgrade" "$cursor_gstack/review"
|
|
|
|
if [ -f "$cursor_dir/gstack/SKILL.md" ]; then
|
|
_link_or_copy "$cursor_dir/gstack/SKILL.md" "$cursor_gstack/SKILL.md"
|
|
fi
|
|
# bin scripts import shared modules via ../lib — bin and lib travel together.
|
|
if [ -d "$gstack_dir/bin" ]; then
|
|
_link_or_copy "$gstack_dir/bin" "$cursor_gstack/bin"
|
|
fi
|
|
if [ -d "$gstack_dir/lib" ]; then
|
|
_link_or_copy "$gstack_dir/lib" "$cursor_gstack/lib"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/dist" ]; then
|
|
_link_or_copy "$gstack_dir/browse/dist" "$cursor_gstack/browse/dist"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/bin" ]; then
|
|
_link_or_copy "$gstack_dir/browse/bin" "$cursor_gstack/browse/bin"
|
|
fi
|
|
if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then
|
|
_link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md"
|
|
fi
|
|
# Review runtime assets — the cursor host config ships the lean pair.
|
|
for f in checklist.md TODOS-format.md; do
|
|
if [ -f "$gstack_dir/review/$f" ]; then
|
|
_link_or_copy "$gstack_dir/review/$f" "$cursor_gstack/review/$f"
|
|
fi
|
|
done
|
|
if [ -f "$gstack_dir/ETHOS.md" ]; then
|
|
_link_or_copy "$gstack_dir/ETHOS.md" "$cursor_gstack/ETHOS.md"
|
|
fi
|
|
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
|
|
if [ -f "$gstack_dir/supabase/config.sh" ]; then
|
|
mkdir -p "$cursor_gstack/supabase"
|
|
_link_or_copy "$gstack_dir/supabase/config.sh" "$cursor_gstack/supabase/config.sh"
|
|
fi
|
|
}
|
|
|
|
# Plant runtime assets into the repo-local generated skill dir so in-repo
|
|
# GSTACK_ROOT (preamble prefers $_ROOT/.cursor/skills/gstack) has bin/.
|
|
# NEVER wipe this directory — it holds the generated SKILL.md files.
|
|
create_cursor_sidecar() {
|
|
local repo_root="$1"
|
|
local cursor_gstack="$repo_root/.cursor/skills/gstack"
|
|
local cursor_dir="$repo_root/.cursor/skills"
|
|
|
|
# #2142: same user-ownership gate as create_agents_sidecar — but the
|
|
# generated tree's own root (cursor_dir/gstack carries the banner) always
|
|
# passes, so normal installs refresh as before.
|
|
if _sidecar_root_user_owned "$cursor_gstack"; then
|
|
echo " left in place (existing dir not gstack-managed — no generated banner): $cursor_gstack" >&2
|
|
return 0
|
|
fi
|
|
|
|
mkdir -p "$cursor_gstack" "$cursor_gstack/browse" "$cursor_gstack/gstack-upgrade" "$cursor_gstack/review"
|
|
|
|
if [ -d "$repo_root/bin" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/bin" ] || [ ! -e "$cursor_gstack/bin" ]; then
|
|
_link_or_copy "$repo_root/bin" "$cursor_gstack/bin"
|
|
fi
|
|
fi
|
|
if [ -d "$repo_root/lib" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/lib" ] || [ ! -e "$cursor_gstack/lib" ]; then
|
|
_link_or_copy "$repo_root/lib" "$cursor_gstack/lib"
|
|
fi
|
|
fi
|
|
if [ -d "$repo_root/browse/dist" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/browse/dist" ] || [ ! -e "$cursor_gstack/browse/dist" ]; then
|
|
_link_or_copy "$repo_root/browse/dist" "$cursor_gstack/browse/dist"
|
|
fi
|
|
fi
|
|
if [ -d "$repo_root/browse/bin" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/browse/bin" ] || [ ! -e "$cursor_gstack/browse/bin" ]; then
|
|
_link_or_copy "$repo_root/browse/bin" "$cursor_gstack/browse/bin"
|
|
fi
|
|
fi
|
|
if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/gstack-upgrade/SKILL.md" ] || [ ! -e "$cursor_gstack/gstack-upgrade/SKILL.md" ]; then
|
|
_link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md"
|
|
fi
|
|
fi
|
|
for f in checklist.md TODOS-format.md; do
|
|
if [ -f "$repo_root/review/$f" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/review/$f" ] || [ ! -e "$cursor_gstack/review/$f" ]; then
|
|
_link_or_copy "$repo_root/review/$f" "$cursor_gstack/review/$f"
|
|
fi
|
|
fi
|
|
done
|
|
if [ -f "$repo_root/ETHOS.md" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/ETHOS.md" ] || [ ! -e "$cursor_gstack/ETHOS.md" ]; then
|
|
_link_or_copy "$repo_root/ETHOS.md" "$cursor_gstack/ETHOS.md"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
link_cursor_skill_dirs() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local cursor_dir="$gstack_dir/.cursor/skills"
|
|
local linked=()
|
|
|
|
if [ ! -d "$cursor_dir" ]; then
|
|
echo " Generating .cursor/ skill docs..."
|
|
( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host cursor )
|
|
fi
|
|
|
|
if [ ! -d "$cursor_dir" ]; then
|
|
echo " warning: .cursor/skills/ generation failed — run 'bun run gen:skill-docs --host cursor' manually" >&2
|
|
return 1
|
|
fi
|
|
|
|
for skill_dir in "$cursor_dir"/gstack*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
skill_name="$(basename "$skill_dir")"
|
|
[ "$skill_name" = "gstack" ] && continue
|
|
target="$skills_dir/$skill_name"
|
|
# #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
|
|
# skipped the refresh. Only replace a symlink, a missing path, or a
|
|
# PROVABLY gstack-managed real dir; never a user's own Cursor skill
|
|
# dir that merely starts with gstack (#2142).
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
|
|
if _owned_for_windows_refresh "$target"; then
|
|
_link_or_copy "$skill_dir" "$target"
|
|
linked+=("$skill_name")
|
|
else
|
|
echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2
|
|
fi
|
|
fi
|
|
fi
|
|
done
|
|
if [ ${#linked[@]} -gt 0 ]; then
|
|
echo " linked skills: ${linked[*]}"
|
|
fi
|
|
}
|
|
|
|
# 4. Install for Claude (default)
|
|
SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")"
|
|
SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")"
|
|
CODEX_REPO_LOCAL=0
|
|
if [ "$SKILLS_BASENAME" = "skills" ] && [ "$SKILLS_PARENT_BASENAME" = ".agents" ]; then
|
|
CODEX_REPO_LOCAL=1
|
|
fi
|
|
|
|
if [ "$INSTALL_CLAUDE" -eq 1 ]; then
|
|
if [ "$SKILLS_BASENAME" = "skills" ]; then
|
|
# Clean up stale symlinks from the opposite prefix mode
|
|
if [ "$SKILL_PREFIX" -eq 1 ]; then
|
|
cleanup_old_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
|
|
else
|
|
cleanup_prefixed_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
|
|
fi
|
|
# Patch name: fields BEFORE creating symlinks so link_claude_skill_dirs
|
|
# reads the correct (patched) name: values for symlink naming
|
|
"$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX"
|
|
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
|
|
link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
|
|
_CLAUDE_SKILLS_LINKED=1
|
|
# Self-healing: re-run gstack-relink to ensure name: fields and directory
|
|
# names are consistent with the config. This catches cases where an interrupted
|
|
# setup, stale git state, or gen:skill-docs left name: fields out of sync.
|
|
GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink"
|
|
if [ -x "$GSTACK_RELINK" ]; then
|
|
GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" >/dev/null 2>&1 || true
|
|
fi
|
|
# Backwards-compat alias: /connect-chrome → /open-gstack-browser
|
|
# Rewritten copy, not a symlink: a symlinked alias re-serves the canonical
|
|
# name: open-gstack-browser, so one of the two silently shadows the other
|
|
# (#2201) — and duplicate names can drop the whole skill set (#2511).
|
|
_OGB_LINK="$INSTALL_SKILLS_DIR/connect-chrome"
|
|
_OGB_ALIAS_NAME="connect-chrome"
|
|
if [ "$SKILL_PREFIX" -eq 1 ]; then
|
|
_OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome"
|
|
_OGB_ALIAS_NAME="gstack-connect-chrome"
|
|
fi
|
|
_install_alias_skill_md "$SOURCE_GSTACK_DIR/open-gstack-browser/SKILL.md" "$_OGB_LINK" "$_OGB_ALIAS_NAME"
|
|
if [ "$LOCAL_INSTALL" -eq 1 ]; then
|
|
log "gstack ready (project-local)."
|
|
log " skills: $INSTALL_SKILLS_DIR"
|
|
else
|
|
log "gstack ready (claude)."
|
|
fi
|
|
log " browse: $BROWSE_BIN"
|
|
else
|
|
# Not inside a skills/ directory — would symlink the source into
|
|
# ~/.claude/skills/gstack/ and register from there.
|
|
CLAUDE_SKILLS_DIR="$HOME/.claude/skills"
|
|
CLAUDE_GSTACK_LINK="$CLAUDE_SKILLS_DIR/gstack"
|
|
|
|
# Conductor worktree guard: if ~/.claude/skills/gstack is already a real
|
|
# (non-symlink) directory pointing to a *different* install, refuse to plant
|
|
# a symlink there. On macOS/BSD, `ln -snf SRC DST` won't replace a real DST;
|
|
# it creates DST/$(basename SRC) → SRC inside it. The result is per-worktree
|
|
# symlinks leaking into the global install that Claude Code picks up as
|
|
# separate top-level skills (dublin-v1, lincoln-v2, ...). Typical trigger:
|
|
# running ./setup from a Conductor worktree of the gstack repo itself.
|
|
_SKIP_CLAUDE_REGISTER=0
|
|
if [ -d "$CLAUDE_GSTACK_LINK" ] && [ ! -L "$CLAUDE_GSTACK_LINK" ]; then
|
|
_EXISTING_REAL=$(cd "$CLAUDE_GSTACK_LINK" 2>/dev/null && pwd -P || echo "")
|
|
if [ -n "$_EXISTING_REAL" ] && [ "$_EXISTING_REAL" != "$SOURCE_GSTACK_DIR" ]; then
|
|
_SKIP_CLAUDE_REGISTER=1
|
|
fi
|
|
fi
|
|
|
|
if [ "$_SKIP_CLAUDE_REGISTER" -eq 1 ]; then
|
|
log ""
|
|
log " $CLAUDE_GSTACK_LINK already exists as a separate global install."
|
|
log " Skipping Claude skill registration to avoid polluting it with"
|
|
log " per-worktree symlinks. (Binaries still built locally for dev.)"
|
|
log ""
|
|
log " Global install: $CLAUDE_GSTACK_LINK"
|
|
log " This worktree: $SOURCE_GSTACK_DIR"
|
|
log ""
|
|
log " To register this worktree as the active gstack, remove the global"
|
|
log " install first: rm -rf $CLAUDE_GSTACK_LINK"
|
|
log ""
|
|
log "gstack built (claude registration skipped)."
|
|
log " browse: $BROWSE_BIN"
|
|
else
|
|
mkdir -p "$CLAUDE_SKILLS_DIR"
|
|
_link_or_copy "$SOURCE_GSTACK_DIR" "$CLAUDE_GSTACK_LINK"
|
|
log " symlinked $CLAUDE_GSTACK_LINK -> $SOURCE_GSTACK_DIR"
|
|
INSTALL_SKILLS_DIR="$CLAUDE_SKILLS_DIR"
|
|
INSTALL_GSTACK_DIR="$CLAUDE_GSTACK_LINK"
|
|
# Clean up stale symlinks from the opposite prefix mode
|
|
if [ "$SKILL_PREFIX" -eq 1 ]; then
|
|
cleanup_old_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
|
|
else
|
|
cleanup_prefixed_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
|
|
fi
|
|
"$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX"
|
|
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
|
|
link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
|
|
_CLAUDE_SKILLS_LINKED=1
|
|
GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink"
|
|
if [ -x "$GSTACK_RELINK" ]; then
|
|
GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" >/dev/null 2>&1 || true
|
|
fi
|
|
# Rewritten copy, not a symlink: a symlinked alias re-serves the
|
|
# canonical name: open-gstack-browser, so one of the two silently
|
|
# shadows the other (#2201) — and duplicate names can drop the whole
|
|
# skill set (#2511).
|
|
_OGB_LINK="$INSTALL_SKILLS_DIR/connect-chrome"
|
|
_OGB_ALIAS_NAME="connect-chrome"
|
|
if [ "$SKILL_PREFIX" -eq 1 ]; then
|
|
_OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome"
|
|
_OGB_ALIAS_NAME="gstack-connect-chrome"
|
|
fi
|
|
_install_alias_skill_md "$SOURCE_GSTACK_DIR/open-gstack-browser/SKILL.md" "$_OGB_LINK" "$_OGB_ALIAS_NAME"
|
|
log "gstack ready (claude)."
|
|
log " browse: $BROWSE_BIN"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# 5. Install for Codex
|
|
if [ "$INSTALL_CODEX" -eq 1 ]; then
|
|
if [ "$CODEX_REPO_LOCAL" -eq 1 ]; then
|
|
CODEX_SKILLS="$INSTALL_SKILLS_DIR"
|
|
CODEX_GSTACK="$INSTALL_GSTACK_DIR"
|
|
fi
|
|
mkdir -p "$CODEX_SKILLS"
|
|
|
|
# Skip runtime root creation for repo-local installs — the checkout IS the runtime root.
|
|
# create_codex_runtime_root would create self-referential symlinks (bin → bin, etc.).
|
|
if [ "$CODEX_REPO_LOCAL" -eq 0 ]; then
|
|
create_codex_runtime_root "$SOURCE_GSTACK_DIR" "$CODEX_GSTACK"
|
|
fi
|
|
# Install generated Codex-format skills (not Claude source dirs)
|
|
link_codex_skill_dirs "$SOURCE_GSTACK_DIR" "$CODEX_SKILLS"
|
|
|
|
log "gstack ready (codex)."
|
|
log " browse: $BROWSE_BIN"
|
|
log " codex skills: $CODEX_SKILLS"
|
|
log " model profile: $CODEX_GENERATION_MODEL ($CODEX_GENERATION_MODEL_SOURCE)"
|
|
log " model changes: rerun ./setup --host codex"
|
|
if [ "$MODEL_OVERRIDE_SET" -eq 1 ]; then
|
|
log " note: --model applies to this run only. To persist across upgrades,"
|
|
log " set model = \"$MODEL_OVERRIDE\" in \${CODEX_HOME:-~/.codex}/config.toml."
|
|
fi
|
|
fi
|
|
|
|
# 6. Install for Kiro CLI (copy from .agents/skills, rewrite paths)
|
|
if [ "$INSTALL_KIRO" -eq 1 ]; then
|
|
KIRO_SKILLS="$HOME/.kiro/skills"
|
|
AGENTS_DIR="$SOURCE_GSTACK_DIR/.agents/skills"
|
|
mkdir -p "$KIRO_SKILLS"
|
|
|
|
# Kiro builds from the codex-shaped render but fronts Claude-family models
|
|
# (hosts/kiro.ts defaultModel: 'claude'). Re-render with the claude overlay
|
|
# before copying so Kiro skills never ship the GPT/Sol behavioral patch;
|
|
# the resolved Codex profile is restored right after the copy loop.
|
|
if [ "$CODEX_GENERATION_MODEL" != "claude" ]; then
|
|
log "Rendering claude-profile skills for Kiro..."
|
|
( cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs --host codex --model claude )
|
|
fi
|
|
|
|
# Create gstack dir with symlinks for runtime assets, copy+sed for SKILL.md
|
|
KIRO_GSTACK="$KIRO_SKILLS/gstack"
|
|
# Remove old whole-dir symlink from previous installs
|
|
[ -L "$KIRO_GSTACK" ] && rm -f "$KIRO_GSTACK"
|
|
mkdir -p "$KIRO_GSTACK" "$KIRO_GSTACK/browse" "$KIRO_GSTACK/gstack-upgrade" "$KIRO_GSTACK/review"
|
|
_link_or_copy "$SOURCE_GSTACK_DIR/bin" "$KIRO_GSTACK/bin"
|
|
_link_or_copy "$SOURCE_GSTACK_DIR/lib" "$KIRO_GSTACK/lib"
|
|
_link_or_copy "$SOURCE_GSTACK_DIR/browse/dist" "$KIRO_GSTACK/browse/dist"
|
|
_link_or_copy "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin"
|
|
# ETHOS.md — referenced by "Search Before Building" in all skill preambles
|
|
if [ -f "$SOURCE_GSTACK_DIR/ETHOS.md" ]; then
|
|
_link_or_copy "$SOURCE_GSTACK_DIR/ETHOS.md" "$KIRO_GSTACK/ETHOS.md"
|
|
fi
|
|
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
|
|
if [ -f "$SOURCE_GSTACK_DIR/supabase/config.sh" ]; then
|
|
mkdir -p "$KIRO_GSTACK/supabase"
|
|
_link_or_copy "$SOURCE_GSTACK_DIR/supabase/config.sh" "$KIRO_GSTACK/supabase/config.sh"
|
|
fi
|
|
# gstack-upgrade skill — sed COPY, never a symlink: a symlink would track
|
|
# .agents after the Codex-profile restore below (wrong overlay AND a baked
|
|
# './setup --host codex' that reinstalls the wrong host on /gstack-upgrade).
|
|
if [ -f "$AGENTS_DIR/gstack-upgrade/SKILL.md" ]; then
|
|
sed -e 's|\$HOME/.codex/skills/gstack|$HOME/.kiro/skills/gstack|g' \
|
|
-e "s|~/.codex/skills/gstack|~/.kiro/skills/gstack|g" \
|
|
-e "s|~/.claude/skills/gstack|~/.kiro/skills/gstack|g" \
|
|
-e 's|\./setup --host codex|./setup --host kiro|g' \
|
|
"$AGENTS_DIR/gstack-upgrade/SKILL.md" > "$KIRO_GSTACK/gstack-upgrade/SKILL.md"
|
|
fi
|
|
# Review runtime assets (individual files, not whole dir)
|
|
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
|
|
if [ -f "$SOURCE_GSTACK_DIR/review/$f" ]; then
|
|
_link_or_copy "$SOURCE_GSTACK_DIR/review/$f" "$KIRO_GSTACK/review/$f"
|
|
fi
|
|
done
|
|
|
|
# Rewrite root SKILL.md paths for Kiro
|
|
sed -e "s|~/.claude/skills/gstack|~/.kiro/skills/gstack|g" \
|
|
-e "s|\.claude/skills/gstack|.kiro/skills/gstack|g" \
|
|
-e "s|\.claude/skills|.kiro/skills|g" \
|
|
"$SOURCE_GSTACK_DIR/SKILL.md" > "$KIRO_GSTACK/SKILL.md"
|
|
|
|
if [ ! -d "$AGENTS_DIR" ]; then
|
|
echo " warning: no .agents/skills/ directory found — run 'bun run build' first" >&2
|
|
else
|
|
for skill_dir in "$AGENTS_DIR"/gstack*/; do
|
|
[ -f "$skill_dir/SKILL.md" ] || continue
|
|
skill_name="$(basename "$skill_dir")"
|
|
target_dir="$KIRO_SKILLS/$skill_name"
|
|
mkdir -p "$target_dir"
|
|
# Generated Codex skills use $HOME/.codex (not ~/), plus $GSTACK_ROOT variables.
|
|
# Rewrite the default GSTACK_ROOT value, any remaining literal paths, and
|
|
# the SETUP_COMMAND host (the artifact was rendered for codex).
|
|
sed -e 's|\$HOME/.codex/skills/gstack|$HOME/.kiro/skills/gstack|g' \
|
|
-e "s|~/.codex/skills/gstack|~/.kiro/skills/gstack|g" \
|
|
-e "s|~/.claude/skills/gstack|~/.kiro/skills/gstack|g" \
|
|
-e 's|\./setup --host codex|./setup --host kiro|g' \
|
|
"$skill_dir/SKILL.md" > "$target_dir/SKILL.md"
|
|
# Carved skills (v2 plan T9): rewrite + copy each sections/*.md the same way,
|
|
# so a runtime "Read sections/<name>.md" resolves under ~/.kiro and doesn't
|
|
# leak a ~/.codex or ~/.claude path. Kiro builds from the codex output, so
|
|
# these section files only exist for skills that have been carved.
|
|
if [ -d "$skill_dir/sections" ]; then
|
|
mkdir -p "$target_dir/sections"
|
|
for section_file in "$skill_dir/sections"/*; do
|
|
[ -f "$section_file" ] || continue
|
|
sed -e 's|\$HOME/.codex/skills/gstack|$HOME/.kiro/skills/gstack|g' \
|
|
-e "s|~/.codex/skills/gstack|~/.kiro/skills/gstack|g" \
|
|
-e "s|~/.claude/skills/gstack|~/.kiro/skills/gstack|g" \
|
|
-e 's|\./setup --host codex|./setup --host kiro|g' \
|
|
"$section_file" > "$target_dir/sections/$(basename "$section_file")"
|
|
done
|
|
fi
|
|
done
|
|
echo "gstack ready (kiro)."
|
|
echo " browse: $BROWSE_BIN"
|
|
echo " kiro skills: $KIRO_SKILLS"
|
|
fi
|
|
|
|
# Restore the resolved Codex profile — ~/.codex/skills symlinks point into
|
|
# .agents/skills, so the tree must not stay on the Kiro claude render.
|
|
if [ "$CODEX_GENERATION_MODEL" != "claude" ]; then
|
|
( cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" )
|
|
fi
|
|
fi
|
|
|
|
# 6b. Install for Factory Droid
|
|
if [ "$INSTALL_FACTORY" -eq 1 ]; then
|
|
mkdir -p "$FACTORY_SKILLS"
|
|
create_factory_runtime_root "$SOURCE_GSTACK_DIR" "$FACTORY_GSTACK"
|
|
link_factory_skill_dirs "$SOURCE_GSTACK_DIR" "$FACTORY_SKILLS"
|
|
echo "gstack ready (factory)."
|
|
echo " browse: $BROWSE_BIN"
|
|
echo " factory skills: $FACTORY_SKILLS"
|
|
fi
|
|
|
|
# 6c. Install for OpenCode
|
|
if [ "$INSTALL_OPENCODE" -eq 1 ]; then
|
|
mkdir -p "$OPENCODE_SKILLS"
|
|
create_opencode_runtime_root "$SOURCE_GSTACK_DIR" "$OPENCODE_GSTACK"
|
|
link_opencode_skill_dirs "$SOURCE_GSTACK_DIR" "$OPENCODE_SKILLS"
|
|
echo "gstack ready (opencode)."
|
|
echo " browse: $BROWSE_BIN"
|
|
echo " opencode skills: $OPENCODE_SKILLS"
|
|
fi
|
|
|
|
# 6d. Install for Cursor
|
|
if [ "$INSTALL_CURSOR" -eq 1 ]; then
|
|
mkdir -p "$CURSOR_SKILLS"
|
|
create_cursor_runtime_root "$SOURCE_GSTACK_DIR" "$CURSOR_GSTACK"
|
|
# Link before sidecar. Sidecar mkdir -p creates .cursor/skills/gstack, which
|
|
# would make link_cursor_skill_dirs' "[ ! -d generated ]" gen fallback a no-op.
|
|
link_cursor_skill_dirs "$SOURCE_GSTACK_DIR" "$CURSOR_SKILLS"
|
|
create_cursor_sidecar "$SOURCE_GSTACK_DIR"
|
|
echo "gstack ready (cursor)."
|
|
echo " browse: $BROWSE_BIN"
|
|
echo " cursor skills: $CURSOR_SKILLS"
|
|
fi
|
|
|
|
# 7. Create .agents/ sidecar symlinks for the real Codex skill target.
|
|
# The root Codex skill ends up pointing at $SOURCE_GSTACK_DIR/.agents/skills/gstack,
|
|
# so the runtime assets must live there for both global and repo-local installs.
|
|
if [ "$INSTALL_CODEX" -eq 1 ]; then
|
|
create_agents_sidecar "$SOURCE_GSTACK_DIR"
|
|
fi
|
|
|
|
# 8. Run pending version migrations
|
|
# Migrations handle state fixes that ./setup alone can't cover (stale config,
|
|
# orphaned files, directory structure changes). Each migration is idempotent.
|
|
MIGRATIONS_DIR="$SOURCE_GSTACK_DIR/gstack-upgrade/migrations"
|
|
CURRENT_VERSION=$(cat "$SOURCE_GSTACK_DIR/VERSION" 2>/dev/null || echo "unknown")
|
|
LAST_SETUP_VERSION=$(cat "$HOME/.gstack/.last-setup-version" 2>/dev/null || echo "0.0.0.0")
|
|
if [ -d "$MIGRATIONS_DIR" ] && [ "$CURRENT_VERSION" != "unknown" ] && [ "$LAST_SETUP_VERSION" != "$CURRENT_VERSION" ]; then
|
|
# Fresh install (no marker file) — skip migrations, just write marker
|
|
if [ ! -f "$HOME/.gstack/.last-setup-version" ]; then
|
|
: # fall through to marker write below
|
|
else
|
|
find "$MIGRATIONS_DIR" -maxdepth 1 -name 'v*.sh' -type f 2>/dev/null | sort -V | while IFS= read -r migration; do
|
|
m_ver="$(basename "$migration" .sh | sed 's/^v//')"
|
|
# Run if migration is newer than last setup version AND not newer than current version
|
|
if [ "$(printf '%s\n%s' "$LAST_SETUP_VERSION" "$m_ver" | sort -V | head -1)" = "$LAST_SETUP_VERSION" ] && [ "$LAST_SETUP_VERSION" != "$m_ver" ] \
|
|
&& [ "$(printf '%s\n%s' "$m_ver" "$CURRENT_VERSION" | sort -V | tail -1)" = "$CURRENT_VERSION" ]; then
|
|
echo " running migration $m_ver..."
|
|
# GSTACK_INSTALL_DIR: migrations that clean the INSTALL (not just
|
|
# ~/.gstack state) default to ~/.claude/skills/gstack when unset —
|
|
# a repo-local ./setup would silently no-op them against the wrong
|
|
# tree without this.
|
|
GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" bash "$migration" || echo " warning: migration $m_ver had errors (non-fatal)"
|
|
fi
|
|
done
|
|
fi
|
|
fi
|
|
mkdir -p "$HOME/.gstack"
|
|
if [ "$CURRENT_VERSION" != "unknown" ]; then
|
|
echo "$CURRENT_VERSION" > "$HOME/.gstack/.last-setup-version"
|
|
fi
|
|
|
|
# 9. First-time welcome + legacy cleanup
|
|
if [ ! -f "$HOME/.gstack/.welcome-seen" ]; then
|
|
log ""
|
|
log " gstack is ready. First move:"
|
|
log " New idea / empty repo? /office-hours or /spec"
|
|
log " Existing code? /qa to see it work, or /investigate"
|
|
log " (Run /gstack-upgrade anytime to stay current)"
|
|
log ""
|
|
# Best-effort onboarding telemetry (respects telemetry!=off; never blocks setup).
|
|
if [ -x "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" ]; then
|
|
"$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" --event-type onboarding --skill _setup_welcome --outcome shown >/dev/null 2>&1 || true
|
|
fi
|
|
touch "$HOME/.gstack/.welcome-seen"
|
|
fi
|
|
rm -f /tmp/gstack-latest-version
|
|
|
|
# 10. Team mode: register/unregister SessionStart hook
|
|
SETTINGS_HOOK="$SOURCE_GSTACK_DIR/bin/gstack-settings-hook"
|
|
# On Windows (Git Bash / MSYS2 / Cygwin), extensionless scripts can't be
|
|
# launched directly by the OS — the file-association dialog appears instead.
|
|
# Prefix with 'bash' so Claude Code's hook runner invokes Git Bash explicitly.
|
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
|
HOOK_CMD="bash $SOURCE_GSTACK_DIR/bin/gstack-session-update"
|
|
else
|
|
HOOK_CMD="$SOURCE_GSTACK_DIR/bin/gstack-session-update"
|
|
fi
|
|
|
|
if [ "$TEAM_MODE" -eq 1 ]; then
|
|
"$GSTACK_CONFIG" set auto_upgrade true 2>/dev/null || true
|
|
"$GSTACK_CONFIG" set team_mode true 2>/dev/null || true
|
|
|
|
# Register SessionStart hook in Claude Code settings
|
|
if [ -x "$SETTINGS_HOOK" ]; then
|
|
"$SETTINGS_HOOK" add "$HOOK_CMD" 2>/dev/null || true
|
|
fi
|
|
|
|
log ""
|
|
log "Team mode enabled: gstack will auto-update at the start of each Claude Code session."
|
|
log " Hook: $HOOK_CMD"
|
|
log " To disable: ./setup --no-team"
|
|
log ""
|
|
log "Bootstrap your repo:"
|
|
log " cd <your-repo> && $SOURCE_GSTACK_DIR/bin/gstack-team-init required"
|
|
fi
|
|
|
|
if [ "$NO_TEAM_MODE" -eq 1 ]; then
|
|
"$GSTACK_CONFIG" set auto_upgrade false 2>/dev/null || true
|
|
"$GSTACK_CONFIG" set team_mode false 2>/dev/null || true
|
|
|
|
# Remove SessionStart hook from Claude Code settings
|
|
if [ -x "$SETTINGS_HOOK" ]; then
|
|
"$SETTINGS_HOOK" remove "$HOOK_CMD" 2>/dev/null || true
|
|
fi
|
|
|
|
log "Team mode disabled: auto-update hook removed."
|
|
fi
|
|
|
|
# ─── GBrain detection + conditional SKILL.md render ─────────────────────
|
|
#
|
|
# Detect whether gbrain is installed and persist the result to
|
|
# ~/.gstack/gbrain-detection.json so gen-skill-docs can decide whether to
|
|
# render GBRAIN_CONTEXT_LOAD and GBRAIN_SAVE_RESULTS blocks. If detected,
|
|
# render the Claude-host :user variant (un-suppressed brain-aware blocks)
|
|
# into an UNTRACKED out-dir — ${GSTACK_HOME}/render/claude — and repoint the
|
|
# installed skills at it (#2569). The old in-place render wrote into TRACKED
|
|
# files of the install checkout, so a global-git install stayed permanently
|
|
# dirty and every upgrade stashed 16 files of generated dirt.
|
|
#
|
|
# If gbrain is not detected, the canonical no-gbrain SKILL.md files stay
|
|
# as-is (zero token overhead) and any stale render dir is removed so it
|
|
# can't shadow canonical files on the next relink.
|
|
#
|
|
# Users who install gbrain after running ./setup should re-run setup OR
|
|
# call `gstack-config gbrain-refresh`.
|
|
DETECT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-gbrain-detect"
|
|
GBRAIN_STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}"
|
|
DETECTION_FILE="$GBRAIN_STATE_DIR/gbrain-detection.json"
|
|
_GSTACK_RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GBRAIN_STATE_DIR/render/claude}"
|
|
# PID-unique tmp so concurrent setups (parallel Conductor workspaces) can't
|
|
# clobber each other's in-flight detection write.
|
|
DETECTION_TMP="$DETECTION_FILE.$$.tmp"
|
|
mkdir -p "$GBRAIN_STATE_DIR"
|
|
if [ -x "$DETECT_BIN" ]; then
|
|
if "$DETECT_BIN" > "$DETECTION_TMP" 2>/dev/null; then
|
|
mv "$DETECTION_TMP" "$DETECTION_FILE"
|
|
# Single source of truth for "is gbrain usable" — `--is-ok` runs live
|
|
# detection (exit 0 iff ok), so setup, bin/dev-setup, and gstack-config
|
|
# all gate on the same check instead of re-grepping the JSON.
|
|
if "$DETECT_BIN" --is-ok 2>/dev/null; then
|
|
if [ -n "${GSTACK_SKIP_GBRAIN_REGEN:-}" ]; then
|
|
# Dev/source tree (set by bin/dev-setup): detection is persisted
|
|
# above; the dev workspace renders the :user variant into its own
|
|
# untracked dir (.claude/gstack-rendered), and other projects get
|
|
# blocks via `gstack-config gbrain-refresh`.
|
|
log "gbrain detected — GSTACK_SKIP_GBRAIN_REGEN set: leaving tracked SKILL.md canonical (dev/source tree)."
|
|
else
|
|
log "gbrain detected — rendering brain-aware Claude SKILL.md into $_GSTACK_RENDER_DIR (~250 token overhead per planning skill; source checkout stays clean)..."
|
|
# Render into a tmp dir and swap it in only on SUCCESS. Installed
|
|
# skills SYMLINK into the render dir (relink prefers it), so wiping
|
|
# it before the render meant one transient failure left every
|
|
# brain-aware SKILL.md link dangling — the whole skill set vanished
|
|
# from Claude Code until a successful re-render.
|
|
_GSTACK_RENDER_TMP="$_GSTACK_RENDER_DIR.tmp.$$"
|
|
rm -rf "$_GSTACK_RENDER_TMP"
|
|
if (
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
# No pipe before the || guard: `cmd | tail -3` reports TAIL's exit
|
|
# status, so a generator crash read as success (same masking the
|
|
# main gen:skill-docs site had). Capture, show the tail, propagate.
|
|
_GEN_USER_OUT=$(bun_cmd run gen:skill-docs:user --host claude --out-dir "$_GSTACK_RENDER_TMP" 2>&1)
|
|
_GEN_USER_RC=$?
|
|
printf '%s\n' "$_GEN_USER_OUT" | tail -3
|
|
exit "$_GEN_USER_RC"
|
|
); then
|
|
_swap_in_render "$_GSTACK_RENDER_DIR" "$_GSTACK_RENDER_TMP"
|
|
# Repoint the installed skills at the fresh render — the installer
|
|
# prefers rendered files when present (#2569).
|
|
if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then
|
|
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null
|
|
fi
|
|
else
|
|
rm -rf "$_GSTACK_RENDER_TMP"
|
|
log " warning: gen:skill-docs:user failed — previous render (if any) left in place, links stay valid. Run 'bun run gen:skill-docs:user --host claude --out-dir $_GSTACK_RENDER_DIR' manually if you want fresh brain-aware blocks"
|
|
fi
|
|
fi
|
|
else
|
|
log "gbrain not detected — brain-aware blocks suppressed in planning-skill SKILL.md files (zero token overhead)."
|
|
log " To enable: install gbrain via /setup-gbrain, then re-run ./setup or 'gstack-config gbrain-refresh'."
|
|
# A render from a previous gbrain install would shadow canonical files
|
|
# on the next link/relink — drop it and restore canonical links.
|
|
if [ -d "$_GSTACK_RENDER_DIR" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ]; then
|
|
rm -rf "$_GSTACK_RENDER_DIR"
|
|
if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then
|
|
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null
|
|
fi
|
|
fi
|
|
fi
|
|
else
|
|
rm -f "$DETECTION_TMP"
|
|
log " warning: gstack-gbrain-detect failed — brain-aware blocks will stay suppressed"
|
|
fi
|
|
fi
|
|
|
|
# Hook commands registered into ~/.claude/settings.json must survive deletion
|
|
# of the directory setup ran from. A dev-worktree setup used to bake
|
|
# $SOURCE_GSTACK_DIR's absolute path into the registration; deleting that
|
|
# worktree left a dead hook erroring on every trigger. Prefer the global
|
|
# install (~/.claude/skills/gstack — a persistent checkout, or a stable
|
|
# symlink) and fall back to the setup-time source tree only when no global
|
|
# install exists yet (first install from a fresh clone).
|
|
_hook_install_path() {
|
|
local rel="$1"
|
|
local global_hook="$HOME/.claude/skills/gstack/$rel"
|
|
if [ -x "$global_hook" ]; then
|
|
printf '%s' "$global_hook"
|
|
else
|
|
printf '%s' "$SOURCE_GSTACK_DIR/$rel"
|
|
fi
|
|
}
|
|
|
|
# 11. Plan-tune cathedral hook install (T8).
|
|
#
|
|
# Registers PostToolUse (deterministic AUQ capture) + PreToolUse (preference
|
|
# enforcement) hooks in ~/.claude/settings.json so /plan-tune actually does
|
|
# something at runtime instead of being agent-convention. Explicit consent UX
|
|
# per D4 + Codex: never mutate settings.json silently.
|
|
#
|
|
# Idempotent via _gstack_source tag = 'plan-tune-cathedral'. If both hooks
|
|
# already registered under that tag, the install skips the consent prompt and
|
|
# only refreshes the registered command paths in place (ensure-event is a
|
|
# no-op when they already match — see the stale-path note above).
|
|
PLAN_TUNE_LOG_HOOK="$(_hook_install_path hosts/claude/hooks/question-log-hook)"
|
|
PLAN_TUNE_PREF_HOOK="$(_hook_install_path hosts/claude/hooks/question-preference-hook)"
|
|
AUQ_ERROR_FALLBACK_HOOK="$(_hook_install_path hosts/claude/hooks/auq-error-fallback-hook)"
|
|
PLAN_TUNE_INSTALL_MARKER="$HOME/.gstack/.plan-tune-hooks-prompted"
|
|
|
|
if [ "$NO_TEAM_MODE" -ne 1 ] \
|
|
&& [ -x "$SETTINGS_HOOK" ] \
|
|
&& [ -x "$PLAN_TUNE_LOG_HOOK" ] \
|
|
&& [ -x "$PLAN_TUNE_PREF_HOOK" ]; then
|
|
|
|
# Already installed? Require BOTH the plan-tune source AND the AUQ-error-fallback
|
|
# source — so an existing install that predates the fallback hook re-runs the
|
|
# install (which is idempotent for the plan-tune hooks) and picks up the new one.
|
|
ALREADY_INSTALLED=0
|
|
_HOOK_SOURCES=$("$SETTINGS_HOOK" list-sources 2>/dev/null || true)
|
|
if printf '%s' "$_HOOK_SOURCES" | grep -q "plan-tune-cathedral" \
|
|
&& printf '%s' "$_HOOK_SOURCES" | grep -q "auq-error-fallback"; then
|
|
ALREADY_INSTALLED=1
|
|
fi
|
|
|
|
# Resolve the desired action without ever blocking.
|
|
# Priority: CLI flag (--plan-tune-hooks / --no-plan-tune-hooks)
|
|
# > env (GSTACK_PLAN_TUNE_HOOKS=yes|no)
|
|
# > saved config (plan_tune_hooks)
|
|
# > smart default ("prompt" → timed prompt on a real TTY, else skip).
|
|
# This guarantees scripted/workspace setups (conductor, CI) are never
|
|
# interactive: pass --no-plan-tune-hooks (or --plan-tune-hooks) and the
|
|
# block runs to completion with no `read`.
|
|
PT_DECISION="$PLAN_TUNE_HOOKS_MODE"
|
|
[ -z "$PT_DECISION" ] && PT_DECISION="${GSTACK_PLAN_TUNE_HOOKS:-}"
|
|
[ -z "$PT_DECISION" ] && PT_DECISION="$("$GSTACK_CONFIG" get plan_tune_hooks 2>/dev/null || true)"
|
|
# Normalize: strip whitespace + lowercase so "YES", "Yes", " yes" from a flag
|
|
# or env var all resolve correctly (an unrecognized opt-in must NOT silently
|
|
# downgrade to skip). Unknown values fall through to "prompt".
|
|
PT_DECISION=$(printf '%s' "$PT_DECISION" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
|
case "$PT_DECISION" in
|
|
y|yes|true|install|on|1) PT_DECISION="yes" ;;
|
|
n|no|false|skip|off|0) PT_DECISION="no" ;;
|
|
*) PT_DECISION="prompt" ;;
|
|
esac
|
|
|
|
# Conductor host reliability: the PreToolUse preference hook also carries the
|
|
# Conductor-prose enforcement (deny the flaky mcp__conductor__AskUserQuestion,
|
|
# redirect to a prose decision brief). A Conductor workspace setup otherwise
|
|
# falls through to "prompt" → the non-interactive skip below, leaving Conductor
|
|
# users without that backstop. Treat Conductor as an implicit opt-in — but
|
|
# only on the silent fall-through, never overriding an explicit --no-plan-tune-hooks.
|
|
if [ "$PT_DECISION" = "prompt" ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then
|
|
PT_DECISION="yes"
|
|
_PT_CONDUCTOR_AUTO=1
|
|
fi
|
|
|
|
_install_plan_tune_hooks() {
|
|
# ensure-event (not add-event): registers when missing, RE-POINTS a stale
|
|
# command path in place when the registration differs, and is a true no-op
|
|
# (no write, no backup churn) when it already matches.
|
|
"$SETTINGS_HOOK" ensure-event \
|
|
--event PostToolUse \
|
|
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
|
|
--command "$PLAN_TUNE_LOG_HOOK" \
|
|
--source plan-tune-cathedral \
|
|
--timeout 5
|
|
"$SETTINGS_HOOK" ensure-event \
|
|
--event PreToolUse \
|
|
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
|
|
--command "$PLAN_TUNE_PREF_HOOK" \
|
|
--source plan-tune-cathedral \
|
|
--timeout 5
|
|
# AskUserQuestion-failure prose-fallback reliability hook (OV3:B). Fires only when
|
|
# an AskUserQuestion call returns an error/missing result; inert on success and
|
|
# inert if the platform doesn't invoke PostToolUse on tool errors. MUST use its
|
|
# OWN source tag: gstack-settings-hook dedupes by (event, matcher, source) and
|
|
# REPLACES the entry's hooks, so sharing 'plan-tune-cathedral' would overwrite the
|
|
# question-log capture hook (same event+matcher). A distinct source = a second
|
|
# PostToolUse entry; both run in parallel.
|
|
if [ -x "$AUQ_ERROR_FALLBACK_HOOK" ]; then
|
|
"$SETTINGS_HOOK" ensure-event \
|
|
--event PostToolUse \
|
|
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
|
|
--command "$AUQ_ERROR_FALLBACK_HOOK" \
|
|
--source auq-error-fallback \
|
|
--timeout 5
|
|
fi
|
|
}
|
|
|
|
if [ "$ALREADY_INSTALLED" -eq 1 ]; then
|
|
# Consent already recorded — no prompt. But a registration from an earlier
|
|
# setup may carry a stale absolute path (a since-deleted dev worktree);
|
|
# ensure-event re-points it in place and no-ops when everything matches.
|
|
# Non-fatal to setup, but never silent: the hardened settings-hook refuses
|
|
# to rewrite a corrupt settings.json (exit 1), and swallowing that refusal
|
|
# left users with stale hooks and no signal.
|
|
if ! _PT_ENSURE_ERR=$(_install_plan_tune_hooks 2>&1 >/dev/null); then
|
|
log " warning: settings hook update failed: $(printf '%s\n' "$_PT_ENSURE_ERR" | head -1) — run $SETTINGS_HOOK manually"
|
|
fi
|
|
log ""
|
|
log "Plan-tune hooks already installed. Run \`$SETTINGS_HOOK list-sources\` to inspect."
|
|
elif [ "$PT_DECISION" = "yes" ]; then
|
|
# Explicit opt-in (flag / env / config) or Conductor implicit opt-in. Non-interactive.
|
|
_install_plan_tune_hooks
|
|
log ""
|
|
if [ "${_PT_CONDUCTOR_AUTO:-0}" -eq 1 ]; then
|
|
log "AskUserQuestion reliability hooks installed (Conductor detected): decisions"
|
|
log "render as a prose brief instead of the flaky AskUserQuestion tool. Inspect with /plan-tune."
|
|
else
|
|
log "Plan-tune hooks installed. Run /plan-tune anytime to inspect."
|
|
fi
|
|
touch "$PLAN_TUNE_INSTALL_MARKER"
|
|
elif [ "$PT_DECISION" = "no" ]; then
|
|
# Explicit opt-out (flag / env / config). Non-interactive.
|
|
log ""
|
|
log "Plan-tune cathedral hooks not installed (opted out)."
|
|
log "Install later with: ./setup --plan-tune-hooks (or /update-config)."
|
|
touch "$PLAN_TUNE_INSTALL_MARKER"
|
|
elif [ -f "$PLAN_TUNE_INSTALL_MARKER" ]; then
|
|
# Previously declined. Don't re-ask. User can re-enable via /update-config.
|
|
:
|
|
elif [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then
|
|
# Real interactive terminal with no recorded preference: ask, with explicit
|
|
# consent + diff preview. The read is time-bounded and defaults to "skip" so
|
|
# it can never hang an automated/forwarded TTY (the conductor failure mode).
|
|
_PT_PROMPT_TIMEOUT=10 # single source of truth for the read + the countdown text
|
|
log ""
|
|
log "──────────────────────────────────────────────────────────"
|
|
log "Plan-tune cathedral: install Claude Code hooks?"
|
|
log "──────────────────────────────────────────────────────────"
|
|
log ""
|
|
log "These hooks make /plan-tune settings actually bind at runtime:"
|
|
log " • PostToolUse hook captures every AskUserQuestion fire (no agent"
|
|
log " compliance required). Today it's agent-convention and the log"
|
|
log " is empty in dogfood."
|
|
log " • PreToolUse hook enforces 'never-ask' preferences via Claude Code's"
|
|
log " permissionDecision protocol. Today preferences are agent-honored"
|
|
log " convention; this makes them binding."
|
|
log ""
|
|
log "Diff preview (PostToolUse capture hook):"
|
|
"$SETTINGS_HOOK" diff-event \
|
|
--event PostToolUse \
|
|
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
|
|
--command "$PLAN_TUNE_LOG_HOOK" \
|
|
--source plan-tune-cathedral \
|
|
--timeout 5 2>/dev/null || true
|
|
log ""
|
|
log "Backup: settings.json.bak.<ts> written before any mutation."
|
|
log "Rollback: $SETTINGS_HOOK rollback"
|
|
log ""
|
|
printf "Install both hooks now? [y/N] (default: N, auto-skips in %ss): " "$_PT_PROMPT_TIMEOUT"
|
|
read -t "$_PT_PROMPT_TIMEOUT" -r PLAN_TUNE_INSTALL_REPLY </dev/tty 2>/dev/null || PLAN_TUNE_INSTALL_REPLY=""
|
|
case "$PLAN_TUNE_INSTALL_REPLY" in
|
|
y|Y)
|
|
_install_plan_tune_hooks
|
|
log ""
|
|
log "Plan-tune hooks installed. Run /plan-tune anytime to inspect."
|
|
touch "$PLAN_TUNE_INSTALL_MARKER"
|
|
;;
|
|
n|N)
|
|
log ""
|
|
log "Skipped. Re-run ./setup --plan-tune-hooks or use /update-config to install later."
|
|
touch "$PLAN_TUNE_INSTALL_MARKER"
|
|
;;
|
|
*)
|
|
# Empty / timed out — treat as "ask me again" (don't persist a decline).
|
|
log ""
|
|
log "No response — skipped for now. Re-run ./setup --plan-tune-hooks to install."
|
|
;;
|
|
esac
|
|
else
|
|
# Non-interactive (CI, scripted/workspace setup, quiet). Never prompt.
|
|
log ""
|
|
log "Plan-tune cathedral hooks not installed (non-interactive setup)."
|
|
log "Install with: ./setup --plan-tune-hooks"
|
|
log " (or set GSTACK_PLAN_TUNE_HOOKS=yes, or run the commands below)"
|
|
log " $SETTINGS_HOOK add-event --event PostToolUse \\"
|
|
log " --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \\"
|
|
log " --command $PLAN_TUNE_LOG_HOOK --source plan-tune-cathedral --timeout 5"
|
|
log " $SETTINGS_HOOK add-event --event PreToolUse \\"
|
|
log " --matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \\"
|
|
log " --command $PLAN_TUNE_PREF_HOOK --source plan-tune-cathedral --timeout 5"
|
|
fi
|
|
fi
|
|
|
|
# ─── Timeline Stop hook (#2553) ──────────────────────────────────────────────
|
|
# The preamble writes event:"started" to the project timeline at every skill
|
|
# start; the completion write lives in end-of-workflow prose and is
|
|
# unenforceable — interrupted sessions leaked started > completed forever.
|
|
# Register a Stop-event hook that closes dangling entries. FAIL-OPEN contract
|
|
# (F5): the hook always exits 0 and repairs best-effort — it can never block
|
|
# a session. Removed by --no-team and gstack-uninstall.
|
|
#
|
|
# The command path prefers the global install (see _hook_install_path): a
|
|
# dev-worktree setup used to bake its own absolute dir into settings.json, so
|
|
# deleting the worktree left a dead hook erroring on every session stop — and
|
|
# the old presence-only dedup (list-sources | grep) never re-pointed it on a
|
|
# re-run. ensure-event registers when missing, replaces a stale path in place
|
|
# (one atomic write — never zero or two registrations), and no-ops when the
|
|
# registration already matches.
|
|
TIMELINE_STOP_HOOK="$(_hook_install_path hosts/claude/hooks/timeline-stop-hook)"
|
|
if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] && [ -x "$TIMELINE_STOP_HOOK" ]; then
|
|
if _TL_ENSURE_OUT=$("$SETTINGS_HOOK" ensure-event \
|
|
--event Stop \
|
|
--command "$TIMELINE_STOP_HOOK" \
|
|
--source gstack-timeline-stop \
|
|
--timeout 5 2>&1); then
|
|
case "$_TL_ENSURE_OUT" in
|
|
*unchanged*)
|
|
: # already registered with the canonical command — quiet no-op
|
|
;;
|
|
*re-pointed*)
|
|
log " re-pointed Stop hook to $TIMELINE_STOP_HOOK (previous registration held a stale path)"
|
|
;;
|
|
*)
|
|
log " registered Stop hook: session timeline entries now close even when a skill is interrupted (backup: settings.json.bak.<ts>; remove: $SETTINGS_HOOK remove-source --source gstack-timeline-stop)"
|
|
;;
|
|
esac
|
|
else
|
|
# Non-fatal to setup, but never silent: the hardened settings-hook refuses
|
|
# to rewrite a corrupt settings.json (exit 1), and swallowing that refusal
|
|
# left the Stop hook unregistered with no signal.
|
|
log " warning: settings hook update failed: $(printf '%s\n' "$_TL_ENSURE_OUT" | head -1) — run $SETTINGS_HOOK manually"
|
|
fi
|
|
fi
|
|
|
|
# Also tear down plan-tune + timeline hooks on --no-team (matches the existing pattern).
|
|
if [ "$NO_TEAM_MODE" -eq 1 ] && [ -x "$SETTINGS_HOOK" ]; then
|
|
"$SETTINGS_HOOK" remove-source --source plan-tune-cathedral 2>/dev/null || true
|
|
"$SETTINGS_HOOK" remove-source --source gstack-timeline-stop 2>/dev/null || true
|
|
fi
|
|
|
|
# ─── Redact pre-push guard consent (#1946) ───────────────────────────────────
|
|
# The credential pre-push hook is per-REPO state — setup runs in the gstack
|
|
# checkout, the wrong repo to install it into, so setup NEVER installs the
|
|
# hook itself. /ship installs it silently in any repo where
|
|
# redact_prepush_hook=true. What setup owns is CONSENT: on a real interactive
|
|
# terminal it asks ONCE whether pushes should be scanned, recording the
|
|
# answer to the existing redact_prepush_hook key (default stays false — a
|
|
# timeout or non-interactive run changes nothing and keeps the hint-only
|
|
# posture). An explicit answer is persisted and never re-asked; an explicit
|
|
# "false" is a recorded decline (adversarial review finding 11).
|
|
# `gstack-config get` defaults absent keys to "false", which is
|
|
# indistinguishable from a decline — test key presence in the config file.
|
|
_GSTACK_CFG_FILE="${GSTACK_HOME:-$HOME/.gstack}/config.yaml"
|
|
if ! grep -q '^redact_prepush_hook:' "$_GSTACK_CFG_FILE" 2>/dev/null; then
|
|
if [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then
|
|
_REDACT_PROMPT_TIMEOUT=10
|
|
log ""
|
|
log "Credential push guard: gstack can block pushes containing credentials"
|
|
log "(a per-repo git pre-push hook; /ship installs it automatically in every"
|
|
log "repo you ship from — nothing is installed right now)."
|
|
printf "Enable the pre-push credential guard? [y/N] (default: N, auto-skips in %ss): " "$_REDACT_PROMPT_TIMEOUT"
|
|
read -t "$_REDACT_PROMPT_TIMEOUT" -r _REDACT_REPLY </dev/tty 2>/dev/null || _REDACT_REPLY=""
|
|
case "$_REDACT_REPLY" in
|
|
y|Y)
|
|
"$GSTACK_CONFIG" set redact_prepush_hook true 2>/dev/null || true
|
|
log "Enabled. /ship will install the guard in each repo at first push."
|
|
;;
|
|
n|N)
|
|
"$GSTACK_CONFIG" set redact_prepush_hook false 2>/dev/null || true
|
|
log "Declined — recorded. Re-enable anytime: gstack-config set redact_prepush_hook true"
|
|
;;
|
|
*)
|
|
# Timed out / empty: don't persist a decline — hint and ask next time.
|
|
log ""
|
|
log "Skipped for now. Enable anytime: gstack-config set redact_prepush_hook true"
|
|
;;
|
|
esac
|
|
else
|
|
log ""
|
|
log "Tip: gstack can block pushes containing credentials (per-repo git hook)."
|
|
log " Enable once: gstack-config set redact_prepush_hook true — /ship"
|
|
log " installs the hook automatically in every repo you ship from."
|
|
fi
|
|
fi
|