mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-23 22:42:31 +02:00
* fix(ci): skill-docs freshness gate covers all 10 hosts and can actually fail The Codex/Factory gates ran 'git diff --exit-code -- .agents/' / '-- .factory/', but both paths are gitignored (.gitignore:16-17) — git diff on ignored untracked paths is always empty, so those two gates were structurally incapable of failing and 7 of 10 hosts had no gate at all. New shape: one 'gen:skill-docs --host all' pass (the generator hard-fails on any per-host error, gating all 10 hosts on generates-cleanly), byte-freshness via git diff for tracked output, plus a porcelain check that fails on untracked generated strays (git diff can't see brand-new files). The gitignored-hosts byte-freshness limitation is documented in the workflow comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): exorcise the sidebar-agent ghost from the test suite browse/src/sidebar-agent.ts was deleted in the v1.14 sidebar refactor, but the test suite kept testing it for 48 versions. Nothing noticed because the free suite runs in no CI job and Bun-era module-load errors were suppressed in the Windows shard runner via an exclusion pattern whose own comment documented the breakage ('broken on every platform since v1.14 ... exit 0'). - Delete sidebar-security.test.ts + security-source-contracts.test.ts: crashed at module load (unguarded readFileSync of the deleted file); per-assertion triage confirmed every SERVER_SRC pin targeted the deleted chat prompt builder (zero hits in today's server.ts) — nothing to port. - Delete sidebar-integration.test.ts: 11 of 13 tests exercised deleted endpoints (/sidebar-command queue, /sidebar-agent/event, chat buffer); the 2 passing tests pinned only the blanket auth gate, covered by server-auth.test.ts + dual-listener.test.ts. - Delete test/skill-e2e-sidebar.test.ts: E2E for the deleted queue flow. - sidebar-ux.test.ts 1,669 -> 830 lines: 20 dead-chat describes + 15 dead tests removed (incl. 10 vacuous passes asserting on empty indexOf slices); 2 stale pins on LIVE features fixed (content.js typed-catch CSSOM fallback, arrow-hint window widened). 95 pass / 0 fail. - sidebar-tabs.test.ts: both failures were stale pins, not regressions — forceRestart's deliberate ws.close(4001) and the terminal-agent spawn that moved into spawnTerminalAgent() (identity-based kill refactor). 28 pass. - touchfiles.ts: drop the three sidebar E2E entries from BOTH maps (E2E_TOUCHFILES + E2E_TIERS) — they pointed diff-selection at the deleted file, so those tests were unreachable by any diff. - test-free-shards.ts: remove the now-dead sidebar-agent exclusion pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): run the free test suite in CI (it ran nowhere) The full free suite (bun test: browse/test/ + test/ + make-pdf/test/) had no CI job on any Linux/macOS runner — only Windows curated shards, paid evals, and doc-freshness gates existed. That's how two module-load-crashing test files survived 48 versions. Same cached Dockerfile.ci image and container wiring as evals.yml (deps restore, build, Chromium verify). Includes a module-load-error guard: older Bun reported test-file import crashes with exit 0 on macOS/Linux, so the job also fails on any nonzero 'N errors' count in the summary — future crash-class regressions can't hide from the exact job built to catch them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): validate touchfile dependency paths exist on disk New guard in touchfiles.test.ts: every non-glob dep path must exist, and every glob's anchor directory must exist. This is the axis the 181-key two-map sync discipline never covered — an entry can point at a long-deleted file and diff-based selection then silently never triggers those tests (the sidebar trio sat rotted for 48 versions). First run immediately caught a fourth rotted entry: 'spec authored quality' referenced test/fixtures/spec/** (directory does not exist) and selected for a judge test that exists nowhere in the repo. Removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): remove deleted /sidebar-chat endpoint from tunnel allowlist TUNNEL_PATHS is the audited tunnel attack surface — its own comment says every addition widens it. '/sidebar-chat' stayed in the set after the endpoint was deleted with the chat-queue path, meaning any future route matching that path would have been silently tunnel-exposed. The set is now exactly the pair ceremony (/connect) and the scoped command endpoint (/command), and the dual-listener closed-set pin enforces that. Also repairs a pre-existing red pin in dual-listener.test.ts: v1.63.0.0 made the tunnel allowlist args-aware (canDispatchOverTunnel gained a second param) without updating the test — red on main since then, invisible because the free suite had no CI job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete chain's shadow dispatcher that skipped every security gate meta-commands.ts carried a 'CLI mode' fallback that re-implemented command routing without the server pipeline's gates: no scope check, no domain check, no tab ownership, no rate limit, no hidden-element stripping, no scoped-token enveloping — and it called handleReadCommand without a BrowserManager, which also skipped the JS-origin cookie-exfiltration assertion. It was unreachable in production (server.ts always passes executeCommand) and one boolean away from being live. chain now hard-errors without a server context. handleReadCommand's bm param is required and assertJsOriginAllowed runs unconditionally. The chain tests that exercised the deleted fallback now route through a server-shaped executeCommand adapter (real handlers + trust wrapping + {status,result} envelope), so their behavioral coverage — sequencing, trust markers, pipe format, aliases, error reporting — survives on the production-shaped path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(extension): delete the dead chat-queue client surface The sidebar-command handler in background.js POSTed to a server endpoint that no longer exists (deleted with the chat queue) — ~35 lines of fully-wired dead code including error handling for the permanent 404, plus its allowlist entry. No sender in the extension ever emitted the message type. chatEnabled leaves the /health contract (server hardcoded false, background.js re-derived it, nothing consumed it — the chat input element it guarded is gone from sidepanel.html). BROWSE_SIDEBAR_CHAT env flag had zero readers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete dead exports the ripped chat path left behind Three-way split by importer class: (a) Zero importers, deleted: the whole attack-attempt logging cluster in security.ts (logAttempt, AttemptRecord, salted hashPayload + device-salt, attempts.jsonl rotation, telemetry spawn plumbing incl. buildTelemetrySpawnCommand/resolveBashBinary — the LIVE attempts.jsonl writer is tunnel-denial-log.ts with its own rotation); the decision-file handshake (writeDecision/readDecision/clearDecision/excerptForReview — written for sidebar-agent's poll loop, which no longer exists); sidebar-utils.ts (whole module — its sanitizeExtensionUrl 'sanitized before embedding in a prompt' for the deleted prompt builder); 8 dead server.ts imports (sanitizeExtensionUrl, generateCanary, injectCanary, writeDecision, rotateRoot, serializeRegistry, restoreRegistry, clearAgentRecord); buildPtyClearCookie + buildSseClearCookie; WEBDRIVER_MASK_SCRIPT (orphaned by the D7 stealth narrowing — applyStealth never used it). (b) Dead-pin tests edited with their exports: the 'still exported' pin in stealth-layer-c, the string-content describe in stealth-webdriver (its live applyStealth behavioral coverage untouched), the clear-cookie assertions, security-review-flow.test.ts deleted whole (all 4 describes exercised the dead decision mechanism, incl. a 'simulated sidebar-agent poll loop'). (c) KEPT deliberately: leaseCount (live behavioral coverage), extractPtyCookie + validatePtySessionToken (extractPtyCookie is adopted by the terminal-agent cookie-parse unification later in this wave), resetSessionMarker + clearContentFilters (test-support API for the live content-security layer). Also fixes two pre-existing red pins found while here, invisible until the free suite got a CI job: the v1.44 spawnClaude->maybeSpawnPty rename in terminal-agent.test.ts, and a cross-file test-isolation bug where content-security.test.ts's clearContentFilters() wiped the auto-registered url-blocklist filter for every later file in the same bun process (security-integration.test.ts failed on co-run; afterAll now restores it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete the dead ML layers — transcript classifier and DeBERTa ensemble The L4b Haiku transcript classifier and the opt-in DeBERTa ensemble (GSTACK_SECURITY_ENSEMBLE=deberta, a documented 721MB download) had ZERO production callers since the chat-path agent that invoked them was ripped. The only live ML path is scanPageContent (testsavant) inside the security sidecar subprocess. Deleted by import graph: - security-classifier.ts 614 -> 265 lines: HAIKU_MODEL, checkTranscript, shouldRunTranscriptCheck, loadDeberta, scanPageContentDeberta, ToolCallInput, all DEBERTA_* consts + load state. Header now states the live truth (imported only by security-sidecar-entry.ts). downloadFile kept, name intact — it is an enumerated egress sink (HF model download). - security-bunnative.ts + test: a research skeleton self-described as 'NOT a production replacement', shipped into src/ with zero importers. - security-bench-ensemble{,-live}.test.ts + the Haiku response fixture: a paid live-model benchmark for a layer that could not fire. The security-classifier-tdz test's only case exercised checkTranscript — gone. - security.ts: layer-model header rewritten to the live architecture; StatusDetail.layers -> {testsavant, canary}; getStatus() no longer requires the impossible transcript==='ok' for 'protected' (old on-disk session state with a transcript key is tolerated on read, never re-emitted). - security-sidecar-entry.ts needed zero changes: it serializes getClassifierStatus() verbatim and no consumer read .transcript (verified in sidecar-client + server.ts). - BROWSER.md security section matches reality (ensemble knob gone, 112MB not 22MB, sidecar hosting documented). combineVerdict/THRESHOLDS retained as the pure, tested combiner of record — comments now flag transcript/deberta votes as producer-less. Net: 26 pass in security.test.ts incl. a NEW regression test for stale- transcript disk tolerance; egress-receipt tripwire green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: scrub the sidebar-agent ghost from comments and CLAUDE.md 20+ comments across 10 files still described the deleted sidebar-agent.ts as a live process — including load-bearing architecture claims ('IMPORTED ONLY BY sidebar-agent.ts', 'sidebar-agent fills this in on first prompt-injection load', 'kill sidebar-agent' in shutdown docs) and ~60 lines of tombstone blocks in server.ts enumerating deleted identifiers by name (a false grep surface: searching processAgentEvent hit server.ts and looked live). CLAUDE.md's security-stack section now documents the LIVE architecture: L1-L3 content filters + testsavant via the security sidecar subprocess; the L4b/ensemble rows, the GSTACK_SECURITY_ENSEMBLE knob, and the 721MB DeBERTa download are gone (deleted as dead code this wave) with an explicit do-not-re-document note; attempts.jsonl is correctly attributed to tunnel-denial-log.ts; the no-live-writer status of classifierStatus is stated. Comments that survive now describe what IS, not what WAS: the promotion gate in domain-skills.ts explains why classifier_score>0 is load-bearing given no L4 load-time scan exists; file-permissions.ts names real sensitive files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): delete the codex-helpers shadow module gen-skill-docs.ts imported externalSkillName (unaliased) from resolvers/codex-helpers.ts at line 21 and then re-declared the same function locally — the import was silently shadowed, and the imported copy was the STALE one (it lacked the frontmatterName param the local copy grew). Three more functions were byte-identical duplicates, imported only under _-prefixed aliases to keep the module 'referenced', and transformFrontmatter was a superseded hardcoded-Codex variant. Nothing else imported the module. Also drops three dead top-of-file imports (COMMAND_DESCRIPTIONS, SNAPSHOT_FLAGS — which pulled the whole browse/src module graph into every generator run for nothing — and an unused review-resolver trio). Proof: bun run gen:skill-docs exits 0 with a byte-identical tree (zero-diff regen); gen-skill-docs.test.ts 405/405 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(server): delete ServerConfig.idleTimeoutMs + chromiumProfile — documented, never read Both fields carried JSDoc asserting embedder behavior that did not exist: the idle check reads the module-level IDLE_TIMEOUT_MS env constant, and both resolveChromiumProfile() call sites pass no argument. Worse than absent — an embedder passing idleTimeoutMs: 5000 silently got 30 minutes. Wiring them honestly is impossible today: the idle timer, activity state, and shutdown target are module-global, so a per-factory value would lie for any process running more than one handler. Deleted instead, with a ServerConfig note pointing at the deferred singleton/route-table refactor where real support belongs. BROWSE_IDLE_TIMEOUT and CHROMIUM_PROFILE env remain the honest knobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): wire appendSecureFile at the four real log-append sites file-permissions.ts carries a 24-line rationale for why POSIX mode bits are insufficient on Windows and implements appendSecureFile (0600 at create, Windows ACL on first write only) — but its single caller was the dead logAttempt, while the four REAL page-content log writers (console/network/ dialog logs in server.ts, the command audit log) used raw fs.appendFileSync with no mode. Page-content-derived logs now get owner-only permissions from birth on every platform. Verified before wiring: mode applies atomically at create via appendFileSync {mode}, and the ACL pass runs only on first write — no per-append subprocess cost on the hot console-log path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stealth): handoff() uses the shared profile resolution + lock cleanup The headless-to-headed handoff path hardcoded ~/.gstack/chromium-profile, silently ignoring $CHROMIUM_PROFILE and $GSTACK_HOME (gbrowser's gbd sets per-workspace profiles), and skipped cleanSingletonLocks() — so a handoff into a profile with a stale SingletonLock could hang where launchHeaded() would have recovered. This was the third live drift between the three Chromium launch paths; the first two are documented in comments as shipped stealth regressions. Minimal targeted fix — the full buildLaunchConfig() extraction stays in the deferred queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): resolver registry describes the template language again Seven registered {{PLACEHOLDER}}s had zero uses in any .tmpl (checked in both bare and :arg forms): REDACT_TAXONOMY_TABLE, TEST_COVERAGE_AUDIT_REVIEW, MODEL_OVERLAY, QUESTION_PREFERENCE_CHECK, QUESTION_LOG, INLINE_TUNE_FEEDBACK, MAKE_PDF_SETUP. The last two of those families are invoked programmatically by preamble.ts (functions kept, registry entries dropped); the question-tuning trio and the review coverage-audit wrapper were documented by their own module as existing 'for unit testing' that no test performed — deleted, along with generateRedactTaxonomyTable + its EXAMPLE/TIER_BLURB constants (its '/cso renders the full table' comment was itself stale) and its test describe. Also deletes the gated-resolver mechanism (ResolverEntry/appliesTo/ unwrapResolver + test/resolver-entry.test.ts): fully built, fully tested, used by zero of the 65 registry entries — the generator loop simplifies to a direct function call. CLAUDE.md's redact-doc line stops advertising the dead token. Proof: zero-diff regen (0 SKILL.md changed); gen-skill-docs + skill-validation 737 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): wire boundaryInstruction from host config; drop three no-op binDir ternaries hosts/codex.ts declared boundaryInstruction and nothing read it — review.ts kept its own byte-identical CODEX_BOUNDARY literal (verified equal + trailing escaped newlines). The resolver now reads the config, so the boundary has one owner. (autoplan's template carries deliberately generic variants, enforced by gen-skill-docs.test.ts:1358 — untouched by design.) The 'ctx.host === codex ? $GSTACK_BIN : ctx.paths.binDir' ternary appeared in three resolvers and could never change the result: resolvers/types.ts already sets binDir to $GSTACK_BIN for every usesEnvVars host including codex. Proof: zero-diff regen for claude AND codex hosts; gen-skill-docs + host-config suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test-infra): judge uses resolveClaudeBinary; eval:watch reads the real partials dir judgePtyState spawned the bare string 'claude' three definitions below the resolveClaudeBinary() helper this same file exports — broken under hermetic PATHs where every other launch in the file resolves correctly. eval:watch read _partial-e2e.json from the legacy global ~/.gstack-dev/evals/ while EvalCollector writes it into the per-project eval dir (or GSTACK_EVAL_DIR) — so the dashboard's completed-tests panel was empty whenever slug detection succeeded, i.e. the normal case. The heartbeat and per-run progress logs stay global by design (session-runner.ts: 'heartbeat stays global'). The three eval-CLI docstrings stop claiming the legacy dir is the primary location. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): delete the superseded SDK ship-idempotency suite and three orphaned fixtures test/skill-e2e-ship-idempotency.test.ts's own header documented that the monolith's SDK-harness version tests a synthetic prompt while it exercises the real /ship skill — the author knew the old suite was superseded and left both running, two paid LLM runs for one behavior. The weaker copy is gone; its 'ship-idempotency' diff-selection key goes with it (the dedicated file is periodic-tier, which always runs under EVALS_ALL — the key had no remaining consumer). Fixture rot: test/fixtures/golden-ship-claude.md was a 128KB zero-reader orphan that had drifted 46KB from its live successor (test/fixtures/golden/claude-ship-SKILL.md) while looking authoritative; parity-baseline-v1.46.0.0.json and v1.53.0.0.json had zero readers (three tests pin three OTHER baseline versions — consolidation is queued, deletion of the unreferenced two is free). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): delete zero-caller scripts; make host-config-export's docstring honest - bin/gstack-open-url (14 lines): announced in a CHANGELOG entry, wired into nothing, ever. bin/gstack-platform-detect (27 lines): zero callers, and its hand-rolled host list was already stale (SLATE_HOST.md cites it as a problem). Note: the deprecated gstack-brain-consumer/reader pair the audit flagged was already deleted upstream in v1.63 with a stay-deleted tripwire. - scripts/task-emission-schema.ts (61 lines): a typed schema module nothing imported; the tasks-section comment now documents the JSONL fields inline. - scripts/host-config-export.ts claimed to be the 'shell bridge for the bash setup script' — setup never calls it (its hand-rolled host lists drifting is a known follow-up). Docstring now states what it IS: a standalone, test-pinned query CLI not yet wired into setup. Its validateValue + CLI_REGEX/PATH_REGEX internals were dead (defined for a guarantee the header claimed but nothing enforced). - KEPT deliberately: scripts/preflight-agent-sdk.ts — a documented manual diagnostic (CONTRIBUTING.md + USING_GBRAIN_WITH_GSTACK.md reference it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(server): one lone-surrogate sanitizer, one sanitizeReplacer, one startTunnel Three copies of the surrogate sanitizer existed with two algorithms (sanitize.ts regex vs a hand-rolled charCodeAt walk in server.ts — verified byte-identical across 11 edge cases before converging) plus two identical sanitizeReplacer definitions each wrapping a different copy. sanitize.ts is now the single source of truth; the runs-INSIDE-JSON.stringify egress invariant is unchanged at every call site and its pin tests were adapted to the new import shape without losing intent. The ngrok tunnel-start sequence existed three times in server.ts — the /tunnel/start route and the BROWSE_TUNNEL=1 autostart were line-for-line equivalent (a comment admitted 'Same cleanup as /tunnel/start's error path'). One startTunnel() now owns the ephemeral loopback bind, the pre-send egress receipt, the state-file RMW via tmpStatePath(), and the ordered error-path cleanup; callers keep their distinct response surfaces. The BROWSE_TUNNEL_LOCAL_ONLY test path shares nothing (no ngrok, different state field) and deliberately stays separate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): one session-cookie registry implementation, two instances pty-session-cookie.ts and sse-session-cookie.ts were byte-identical modulo the cookie name — mint/validate/parse/prune/TTL, the exact code a security fix would have to land in twice (and a third hand-rolled cookie parse in terminal-agent.ts had already diverged; unified next commit). createSessionCookieStore() owns the implementation; both modules become thin instantiations keeping every exported name, their distinct threat-model docstrings, and separate token spaces (an SSE-read cookie must never grant PTY access). pty-session-lease.ts deliberately stays out — different contract (sessionId/secret split, refresh, env TTL). The factory imports nothing from token-registry (cookie-picker-auth-isolation invariant, still pinned by sse-session-cookie.test.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): terminal-agent uses the shared PTY cookie parser The /ws upgrade's cookie fallback hand-parsed the Cookie header inline — the fourth copy of the session-cookie parse, and the one that had already diverged from the others. Parsing now goes through extractPtyCookie; validation deliberately stays against the agent's own in-process validTokens map (the server's registry lives in a different process). The ws-handler pin test now pins the shared-parser call instead of the raw cookie-name literal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(hosts): defineHost() factory — 10 copy-paste host files become declarations hosts/*.ts were ten copies of one file: runtimeRoot byte-identical in 9/10, pathRewrites mechanically derivable from the host name for 7/10, the 11-entry toolRewrites map byte-identical between openclaw and gbrain, and every asset change a 10-file edit (cursor and slate had already fallen out of three other hand-maintained lists). defineHost() owns the defaults; each host file now declares only what makes it different (slate/cursor: 8 lines each). Shared constants: CROSS_MODEL_RESOLVERS, GBRAIN_RESOLVERS, EXEC_STYLE_TOOL_REWRITES. Genuinely-different things stayed explicit: codex/factory $GSTACK_ROOT rewrites, hermes's tool vocabulary, claude's denylist+prefixable install, opencode's wider runtimeRoot. Proof: JSON.stringify(ALL_HOST_CONFIGS) dump-diff before/after EMPTY (and a runtime walk confirmed no function-valued or undefined-keyed fields, so the JSON diff is complete); gen:skill-docs --host all zero-diff; host-config + gen-skill-docs + idempotency suites 485/485. Host files 595 -> 285 lines. docs/ADDING_A_HOST.md teaches the factory pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(lib): fs-atomic — one atomic-write implementation, with the race actually fixed Atomic tmp-write-then-rename was reimplemented ~20 times across lib/, bin/, and browse/src with three tmp-suffix conventions. One of them was a latent bug this commit closes: lib/worktree.ts used a bare '.tmp' suffix — the deterministic-tmp collision race browse/src/server.ts documents having hit in production (its fix, pid+random, was trapped in a comment at one site). lib/fs-atomic.ts: atomicWriteSync (always throws, best-effort tmp cleanup, pid+random suffix, optional mode applied at tmp creation so the file never exists with looser permissions) + atomicWriteQuiet (shutdown paths only). Unit tests pin the throw/quiet contracts, 0600 mode, tmp-name uniqueness (captured via the read-only-dir failure path — Bun's fs exports are readonly, no monkeypatching), and no-stray-tmp cleanup. Migrated: lib/worktree.ts (the bare-.tmp bug), lib/gstack-decision.ts (snapshot + compact log), lib/gbrain-local-status.ts (probe cache). browse sites follow separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(lib): jsonl-store's docstring stops lying; mode option added; lib bypasses adopted The header claimed 'single source of truth... the ONLY copy' with write-time injection REJECTION — while appendJsonl never screened anything, only 1 of ~10 JSONL stores imported it, and a bypass appender lived in the same directory. Now: the contract is explicit (screening is the CALLER's job via hasInjection/firstInjectionMatch; the enforcing callers are named), a option applies 0600 at create for sensitive stores, and the lib bypasses are adopted (gstack-memory-helpers ×2, redact-audit-log — which keeps its chmod backstop for files created looser by pre-mode versions). browse/src keeps its own appenders by design (compiled-binary surface, own secure-append helper) and the header now says so. gstack-decision's batched archive append stays deliberate (single-write crash-window semantics appendJsonl's one-record contract can't express). New pins: 0600-at-create, and a test that documents appendJsonl does NOT self-screen — so nobody can re-document it as self-screening without making it true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): migrate hand-rolled atomic writes to lib/fs-atomic Seven sites, each audited for its existing throw-vs-swallow contract before migrating: writeSessionState + the four fire-and-forget tab/state writers use atomicWriteQuiet (they swallowed before); writeAgentRecord + the boot-time port-file write use atomicWriteSync (they threw before — and writeAgentRecord previously leaked its tmp file on rename failure, which the helper cleans). All carry {mode: 0o600} plus restrictFilePermissions after successful writes, preserving the Windows ACL hardening that writeSecureFile provided (mode bits are POSIX-only). server.ts untouched: its three state writes route through tmpStatePath(), pinned by server-tmp-state-path.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hosts): delete five dead HostConfig fields metadataFormat (generator hardcodes openai.yaml), sidecar (behavior lives in setup's create_agents_sidecar — knowledge preserved as a comment in codex.ts), install.prefixable (skill_prefix is implemented entirely in bin/gstack-config), staticFiles (docstring cited a SOUL.md that never existed anywhere), and adapter (its only would-be consumer, openclaw-adapter.ts, was fully dead — with a test asserting the field was undefined). Kept: learningsMode (wired next), linkingStrategy (validation reads it), coAuthorTrailer (consumed by resolvers/utility.ts). Proof: JSON dump diff shows ONLY the deleted keys vanishing; zero-diff regen across all 10 hosts; host-config + gen-skill-docs suites green. Note: this commit also carries chunk-23 edits to the shared hosts/claude.ts + define-host.ts + host-config.test.ts files (skipSkills collapse, stale line-number comment drops) — pathspec commits, concurrent prep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): preamble tiers are explicit; silent ?? 4 default becomes an error; spec stops rendering its preamble twice Eight skills (scrape, diagram, spec, skillify, pair-agent, landing-report, open-gstack-browser + its connect-chrome symlink) silently received the HEAVIEST tier-4 preamble because a missing frontmatter field defaulted to 4. Tiers are now declared in every {{PREAMBLE}} template's frontmatter and a missing declaration throws at generation time with the template path (the 5 templates without {{PREAMBLE}} never invoke the resolver). The stale hand-written tier-map comment (wrong in 3 of 4 rows) is gone. Bonus bug fixed: spec/SKILL.md.tmpl mentioned {{PREAMBLE}} in prose, so the generator inlined the ENTIRE preamble a second time — spec/SKILL.md shrinks 127,462 -> 80,924 bytes (-46,538) from de-duplication alone. skill-size-budget gains a reasoned INTENTIONAL_SHRINKS entry (its frozen baseline had measured the doubled-preamble bug). New tests: missing-tier throw carries the path; every {{PREAMBLE}} template declares a tier. (Carries chunk-23 edits in the shared test/gen-skill-docs.test.ts.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): learningsMode is read from host config, not a hardcoded host name resolvers/learnings.ts branched on ctx.host === 'codex' while every host declared learningsMode — the field was decorative, and the 7 hosts configured 'basic' (cursor, slate, kiro, opencode, openclaw, hermes, gbrain) silently received the 'full' cross-project flow their runtimes can't execute (it depends on AskUserQuestion + gstack-config plumbing). Output now matches declaration: basic hosts get the project-scoped search block. Blast radius proof: all committed Claude SKILL.md files and the three golden fixtures are byte-identical; the behavior diff lands only in the gitignored external-host trees (hand-verified: .cursor review's learnings section swaps the cross-project AskUserQuestion block for the project-scoped search). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): small config scrubs — openclaw blobs to real files, setup host drift, dead artifacts - The three openclaw markdown blobs hardcoded inside gen-skill-docs.ts (which silently reverted any hand edit to their tracked outputs on regen) move to openclaw/templates/*.md source files; output shasums byte-identical. - setup's --host allowlists gain cursor + slate — both fully registered hosts with generated output, but './setup --host cursor' exited 1 because two hand-rolled lists in setup had drifted from hosts/index.ts. - scripts/proactive-suggestions.json deleted: 31KB regenerated on every run, read by nobody (the catalog-trim design's reader was never built); its emitter and three determinism tests (which guaranteed a file nothing reads didn't churn) retired with stays-retired pins. - claude/SKILL.md.tmpl deleted: a complete 8.9KB skill that never generated output (directory name collides with the host id 'claude'), in no registry. Recoverable from git if ever wanted under a non-colliding name. - openclaw's frozen extraFields.version '0.15.2.0' stamp dropped; includeSkills: [] no-ops omitted (the generator treats [] as absent); llms.txt 55 -> 54 skills. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(gen): correct preamble tiers for the 8 silently-heaviest skills With tiers now explicit, set them RIGHT by analogy to the tiered population: scrape/diagram/open-gstack-browser (+ the connect-chrome symlink) -> tier 1 (launchers and artifact generators, like browse and make-pdf); landing-report/pair-agent/skillify -> tier 2 (dashboards and session tools, like health and canary); spec -> tier 3 (interactive planning, like the plan-*-review family). Each tier-1 skill sheds 271 lines of onboarding prose it never needed; tier-2 shed 20 each. Verification per the review protocol: regen diff reviewed (pure section-removal), skill-validation + size-budget + catalog-budget + v0-dormancy suites green (822 tests), and live smoke of the tier-corrected skills confirms the preamble renders the intended sections at each tier. These skills have ~no eval coverage — stated honestly; the wave's gate-tier eval run is the backstop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): e2e-gate — one tier-gate implementation, side-effect-free, with the trap pinned The EVALS/EVALS_TIER gate was copy-pasted into ~40 test files and had drifted into six different predicates — the drift that made 'eval:bg:all runs everything' silently false. test/helpers/e2e-gate.ts owns the semantics now: describeE2ETier(tier) + e2eTierEnabled(tier), env read at call time, zero side effects (the existing e2e-helpers module runs a ~30s claude ping at import under EVALS=1, so the gate lives in its own module; purity is pinned by tests that scan imports and comment-stripped source). The unit matrix pins all four env combos — including EVALS=1 with EVALS_TIER unset -> SKIP, the exact trap that made eval:bg:all a non-run. The tier-alignment tripwire gains a second regex for the helper shape (old shape still detected — stragglers can't hide), and the sharded paid runner's PRE-SPAWN tier classifier learns the helper shape too: without that, every gate-sharded run would have spawned all 28 periodic shards just to skip them, each paying the e2e-helpers import ping (~15 min of dead wall clock in the CI-blocking lane). Verified: gate runs exclude the 29 periodic files, periodic excludes the 8 gate files — identical to pre-migration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): migrate the 36 tier-gated eval files to describeE2ETier Mechanical two-liner swap in 34 files (each keeping its declared tier — all 36 predicates verified against E2E_TIERS before migrating); the two files with compound gates (overlay-harness's EvalCollector feed, codex-e2e's CODEX_AVAILABLE) keep their extra conditions via e2eTierEnabled. Tier rationale comments preserved. codex-e2e/gemini-e2e/benchmark-providers keep their distinct stderr-message gate shapes by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): skill-e2e + skill-llm-eval adopt the shared selection machinery Both files re-implemented the diff-selection machinery e2e-helpers already exported. The helper gained computeDiffSelection() (extracted, identical behavior) and a trailing optional selection param on the *IfSelected helpers (defaults preserve all 30+ existing importers). skill-e2e.test.ts drops ~120 duplicated lines; skill-llm-eval keeps its LLM_JUDGE_TOUCHFILES selection and test.concurrent semantics via testConcurrentIfSelected. Deliberate deltas, stated: skill-e2e.test.ts now honors the EVALS_TIER intersection its local copy lacked (affects only direct bun test invocations of that file — it matches no eval-script glob); its recordE2E gains the helper's three diagnostic fields; skill-llm-eval sharded solo now runs e2e-helpers' module-scope preflight it already ran in combined processes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): kill the silent-truncation race; exempt the tier-corrected shrinks The full-suite shakeout (budgeted by the plan) surfaced both immediately: 1. server-embedder-terminal-port.test.ts stubbed process.exit and restored the REAL exit in its finally — but shutdown() schedules async work that can call process.exit AFTER restoration, killing the entire bun process mid-suite with exit 0 and NO summary. This is the silent-truncation class the new free-suite CI job guards against, reproduced locally on the first full run. Exit now stays a logging no-op between tests (late async exits become visible stderr lines, not process death); the true exit returns in afterAll. 2. The 80%-of-baseline shrink guard correctly flagged the six tier-corrected skills — their baseline was measured at the silent tier-4 default. Added to INTENTIONAL_SHRINKS with the reason, joining spec's double-preamble entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * release: v1.64.0.0 — the code-smell fix wave 35 commits, one PR: guard repairs (free suite in CI per-file, all-host freshness gates, tunnel allowlist, diff-selection validation), the sidebar-agent ghost exorcism (dead ML layers, dead endpoints, dead exports, ghost comments), config honesty (defineHost factory, dead fields deleted, preamble tiers explicit, spec double-render fixed), and dedup with safety nets (session-cookie factory, fs-atomic, jsonl-store contract, one eval tier-gate). Net -24,943 lines across 183 files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests step runs under bash (container sh rejects pipefail) Maiden-voyage shakeout, exactly as budgeted: the CI container's default shell is dash, which errors on 'set -o pipefail' before the first test ran. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests curates 8 container-incompatible files with reasons Second maiden-voyage shakeout round: 376 of 384 files ran green in the container on the first completed pass. The 8 that can't run there yet are excluded the same way the Windows shards curate POSIX-bound files — each with its reason inline (headed-Chrome handoff, real-PTY round-trip, X server management, extension-origin identity, the job's own TMPDIR override, and three pre-existing env failures that fail on dev machines too). Anything outside the list that fails still fails the job; trimming the list is tracked follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): gstack-config-key-locale — suppress the skill_prefix auto-relink side effect The test invokes the repo's own bin/gstack-config, whose 'set skill_prefix' auto-runs $(dirname $0)/gstack-relink — resolving the install dir to the repo itself. In any environment where the loop shares a working tree (the free-tests CI container, a fresh-HOME run), gstack-patch-names rewrote all 52 tracked SKILL.md names to gstack- prefixed, poisoning five unrelated suites downstream (hermetic-skills-seeding, host-config golden, skill-census, skill-validation, spec-template-sync). GSTACK_SETUP_RUNNING=1 is the documented suppression; relink behavior stays covered by relink.test.ts's mock install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): gstack-codex-session-import — empty sessions dir exits 0 on Linux GNU xargs runs 'ls -t' once even on empty input, listing the cwd and producing a bogus LATEST from the repo root; BSD xargs (macOS) skips the run, which is why the NO_SESSIONS path only broke on Linux. xargs -r pins the BSD behavior on both platforms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(parity): rebaseline v1.57.7.0 → v1.64.1.0 + skeleton-cap headroom The two parallel v1.64 waves (code-smell fix wave + main's #2571) each added shared-preamble prose, pushing document-release / design-consultation / cso past their size ratios on the v1.57.7.0 anchor and four carved skeletons (plan-ceo-review, plan-eng-review, office-hours, design-consultation) 22-280 B over their absolute caps. New baseline is union-normalized (skeleton + sections/*.md, matching what the harness measures); caps get +~1 KB headroom each with per-cap rationale. The v1.57.7.0 fixture stays in test/fixtures/ for the audit trail, and capture-parity-baseline.ts now documents the union-normalization step so the next rebaseline doesn't re-trip on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests container parity — tools, pinned bun, git identity, mutation tripwire - Dockerfile.ci: add python3 (gstack-jsonl-merge/brain-sync/detach shell out to it), file (skill-validation's binary check), poppler-utils (make-pdf e2e gates hard-require pdftotext/pdffonts/pdfinfo), fonts-noto-color-emoji (emoji render gate, mirrors make-pdf-gate.yml). Fix the bun pin: the bun.sh installer ignores a BUN_VERSION env var, so the old form silently installed latest on every rebuild (observed 1.3.13/1.3.14 drift vs the 1.3.10 devs run locally); pass the version as the positional arg. - free-tests.yml: git identity + safe.directory for the git-exercising tests (container checkout is owned by a different uid than runner); post-loop tree-mutation tripwire that names a tracked-file-mutating test instead of letting downstream collateral confuse the report; skip the documented variants-retry-after timing flake. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): gstack-session-update — detached updater owns its stdio (SIGPIPE) The backgrounded update subshell inherited the session hook's stdout/stderr pipes. Once the hook exits and the caller closes them, any child that writes — git pull's autostash notice, setup output — dies of SIGPIPE, logged as PULL_FAILED exit=141 with an empty stderr capture (observed in the free-tests container, and reachable by any production hook runner that closes stdio promptly). Redirect the fork to /dev/null; all observability already flows through the session-update log file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): gstack-decision-bins — explicit branch context for the scope filter CI checks out a detached HEAD, where gitBranch() returns undefined on both the log and search sides, so an implicitly branch-scoped decision can never surface (filterByScope requires a matching non-empty ctx.branch). Pass the branch explicitly on both sides — the filter logic is what's under test, not git branch detection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): ring-buffer lease interplay — same TTL window, not same millisecond Two back-to-back mintLease() calls each stamp Date.now() + TTL; when they straddle a millisecond boundary the exact-equality assertion flakes (observed in CI: expiries of ...525 vs ...526). Assert the expiries are within a 50 ms window instead — the invariant under test is that leases share a TTL policy, not that they mint in the same clock tick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1677 lines
72 KiB
Bash
Executable File
1677 lines
72 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.
|
|
--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 --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="$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"
|
|
|
|
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
|
|
}
|
|
|
|
_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
|
|
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 ;;
|
|
--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
|
|
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
|
|
# 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 ]; 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
|
|
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
|
|
|
|
ensure_playwright_browser() {
|
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
|
# On Windows, Bun can't launch Chromium due to broken pipe handling
|
|
# (oven-sh/bun#4253). Use Node.js to verify Chromium works instead.
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
node -e "const { chromium } = require('playwright'); (async () => { const b = await chromium.launch(); await b.close(); })()" 2>/dev/null
|
|
)
|
|
else
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();'
|
|
) >/dev/null 2>&1
|
|
fi
|
|
}
|
|
|
|
# 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
|
|
|
|
# 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 already does this, but we need it when NEEDS_BUILD=0 (binary is fresh).
|
|
# 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 ] && [ "$NEEDS_BUILD" -eq 0 ]; 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
|
|
)
|
|
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
|
|
|
|
# 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..."
|
|
(
|
|
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
|
|
)
|
|
|
|
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 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
|
|
_link_or_copy "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md"
|
|
# Link the sections/ subdir for carved skills (v2 plan T9). The prefixed
|
|
# Claude skill dir otherwise holds only SKILL.md, so a runtime
|
|
# "Read sections/<name>.md" 404s. Route through _link_or_copy so Windows
|
|
# gets a fresh copy (and re-copies on every ./setup, refreshing staleness).
|
|
if [ -d "$gstack_dir/$dir_name/sections" ]; then
|
|
if [ -e "$target/sections" ] || [ -L "$target/sections" ]; then rm -rf "$target/sections"; fi
|
|
_link_or_copy "$gstack_dir/$dir_name/sections" "$target/sections"
|
|
fi
|
|
linked+=("$link_name")
|
|
fi
|
|
done
|
|
if [ ${#linked[@]} -gt 0 ]; then
|
|
echo " linked skills: ${linked[*]}"
|
|
_print_windows_copy_note_once
|
|
fi
|
|
}
|
|
|
|
# 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 whose frontmatter name remains
|
|
# `gstack` so `/gstack` can autocomplete.
|
|
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
|
|
if [ -L "$target" ]; then
|
|
rm -f "$target"
|
|
fi
|
|
mkdir -p "$target"
|
|
if [ -L "$target/SKILL.md" ]; then rm "$target/SKILL.md"; fi
|
|
_link_or_copy "$gstack_dir/SKILL.md" "$target/SKILL.md"
|
|
echo " linked root skill alias: gstack"
|
|
_print_windows_copy_note_once
|
|
}
|
|
|
|
# ─── 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 )
|
|
fi
|
|
|
|
if [ ! -d "$agents_dir" ]; then
|
|
echo " warning: .agents/skills/ generation failed — run 'bun run gen:skill-docs --host codex' 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
|
|
if [ -L "$target" ] || [ ! -e "$target" ]; then
|
|
_link_or_copy "$skill_dir" "$target"
|
|
linked+=("$skill_name")
|
|
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"
|
|
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
|
|
if [ -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
|
|
if [ -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
|
|
# 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
|
|
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
|
|
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"
|
|
if [ -L "$target" ] || [ ! -e "$target" ]; then
|
|
_link_or_copy "$skill_dir" "$target"
|
|
linked+=("$skill_name")
|
|
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"
|
|
if [ -L "$target" ] || [ ! -e "$target" ]; then
|
|
_link_or_copy "$skill_dir" "$target"
|
|
linked+=("$skill_name")
|
|
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"
|
|
# 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
|
|
_OGB_LINK="$INSTALL_SKILLS_DIR/connect-chrome"
|
|
if [ "$SKILL_PREFIX" -eq 1 ]; then
|
|
_OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome"
|
|
fi
|
|
if [ -L "$_OGB_LINK" ] || [ ! -e "$_OGB_LINK" ]; then
|
|
_link_or_copy "gstack/open-gstack-browser" "$_OGB_LINK"
|
|
fi
|
|
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"
|
|
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
|
|
_OGB_LINK="$INSTALL_SKILLS_DIR/connect-chrome"
|
|
if [ "$SKILL_PREFIX" -eq 1 ]; then
|
|
_OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome"
|
|
fi
|
|
if [ -L "$_OGB_LINK" ] || [ ! -e "$_OGB_LINK" ]; then
|
|
_link_or_copy "gstack/open-gstack-browser" "$_OGB_LINK"
|
|
fi
|
|
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"
|
|
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"
|
|
|
|
# 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
|
|
if [ -f "$AGENTS_DIR/gstack-upgrade/SKILL.md" ]; then
|
|
_link_or_copy "$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 and any remaining literal paths.
|
|
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" \
|
|
"$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" \
|
|
"$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
|
|
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
|
|
|
|
# 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..."
|
|
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 regen ──────────────────────
|
|
#
|
|
# 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,
|
|
# regenerate the Claude-host SKILL.md files with the un-suppressed
|
|
# (compressed) brain-aware blocks via `bun run gen:skill-docs:user`.
|
|
#
|
|
# If gbrain is not detected, the canonical no-gbrain SKILL.md files
|
|
# (which were just generated above by `gen:skill-docs --host claude` if
|
|
# applicable, or which are checked in) stay as-is. Zero token overhead
|
|
# for non-gbrain users.
|
|
#
|
|
# Users who install gbrain after running ./setup should re-run setup OR
|
|
# call `gstack-config gbrain-refresh` + `bun run gen:skill-docs:user`.
|
|
DETECT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-gbrain-detect"
|
|
GBRAIN_STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}"
|
|
DETECTION_FILE="$GBRAIN_STATE_DIR/gbrain-detection.json"
|
|
# 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): never regenerate tracked
|
|
# SKILL.md in place — that dirties checked-in source. Detection is
|
|
# still persisted above; the dev workspace renders the :user variant
|
|
# into an untracked dir, 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 — regenerating Claude SKILL.md with brain-aware blocks (~250 token overhead per planning skill)..."
|
|
(
|
|
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 2>&1)
|
|
_GEN_USER_RC=$?
|
|
printf '%s\n' "$_GEN_USER_OUT" | tail -3
|
|
exit "$_GEN_USER_RC"
|
|
) || log " warning: gen:skill-docs:user failed — run 'bun run gen:skill-docs:user' manually if you want brain-aware blocks"
|
|
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'."
|
|
fi
|
|
else
|
|
rm -f "$DETECTION_TMP"
|
|
log " warning: gstack-gbrain-detect failed — brain-aware blocks will stay suppressed"
|
|
fi
|
|
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 is a no-op (no prompt).
|
|
PLAN_TUNE_LOG_HOOK="$SOURCE_GSTACK_DIR/hosts/claude/hooks/question-log-hook"
|
|
PLAN_TUNE_PREF_HOOK="$SOURCE_GSTACK_DIR/hosts/claude/hooks/question-preference-hook"
|
|
AUQ_ERROR_FALLBACK_HOOK="$SOURCE_GSTACK_DIR/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() {
|
|
"$SETTINGS_HOOK" add-event \
|
|
--event PostToolUse \
|
|
--matcher '(AskUserQuestion|mcp__.*__AskUserQuestion)' \
|
|
--command "$PLAN_TUNE_LOG_HOOK" \
|
|
--source plan-tune-cathedral \
|
|
--timeout 5
|
|
"$SETTINGS_HOOK" add-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" add-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
|
|
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
|
|
|
|
# Also tear down plan-tune 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
|
|
fi
|
|
|
|
# ─── Redact pre-push guard hint (#1946) ──────────────────────────────────────
|
|
# The credential pre-push hook is per-REPO state — setup runs in the gstack
|
|
# checkout, the wrong repo to install it into. /ship offers the install once
|
|
# at the moment of relevance (first push) and silently installs in any repo
|
|
# where redact_prepush_hook=true. This hint is setup's whole involvement.
|
|
# Hint only when UNSET — an explicit "false" is a recorded decline and must
|
|
# not be re-nagged on every setup run (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
|
|
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
|