diff --git a/.gitignore b/.gitignore index 6a3ae8915..b042b9ded 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .env node_modules/ +node_modules.gstack-migrate/ +node_modules.gstack-migrate.lock/ dist/ browse/dist/ design/dist/ @@ -40,6 +42,7 @@ lib/cso/images/gstack-cso-preparation .cursor/ .copilot/ .agy/ +.gemini-cli/ .openclaw/ .hermes/ .gbrain/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4af548dec..6b891029a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.92.22.0] - 2026-11-01 + +**gstack installs into Gemini CLI, and installs made before v1.91.231.0 finally drop the test and eval packages they never needed.** `./setup --host gemini` puts every gstack skill in `~/.gemini/skills`, where Gemini CLI 0.66.0 lists them (#1247). Separately, `./setup` used to treat any checkout that had TypeScript installed as a contributor's, so every install made before v1.91.231.0 (which ran a full `bun install`) kept about 510 MB of dev packages in `node_modules` forever (#3447). + +### Added +- **Gemini CLI host** (experimental): `./setup --host gemini` (alias `gemini-cli`; `--host auto` picks it only when `gemini` is on PATH) links `gstack-` directories into `~/.gemini/skills` plus the `~/.gemini/skills/gstack` runtime root. The render goes to `.gemini-cli/` with frontmatter names that match their directories, `CLAUDE.md` read as `GEMINI.md`, and a tool glossary for Gemini CLI's tool names and for Antigravity CLI, which can read the same folder. Ask for a skill by name (`gstack-office-hours`); Gemini CLI loads it with `activate_skill`. `gstack-uninstall` removes only gstack-managed entries there, so your own skills stay. `--local --host gemini` is refused with a note that Gemini CLI loads workspace skills only in trusted folders. docs/ADDING_A_HOST.md lists the verified facts. +- **An explicit contributor marker**: `bin/dev-setup` runs `git config gstack.contributor true`, and `GSTACK_CONTRIBUTOR=1` marks a single run. A marked clone keeps the full install on every `./setup`. + +### Changed +- **An unmarked clone that still has the dev toolchain moves to production dependencies once.** setup takes a lock in the checkout, moves `node_modules` aside, installs production dependencies, checks every runtime package resolves, then deletes the old copy. If the install fails, the full `node_modules` goes back and setup prints `SETUP_DEPS_MIGRATE_FAILED` with the cause; nothing is removed and the next `./setup` tries again. CI, and a checkout whose git config cannot be read, never migrate. On a real install `node_modules` went from 1079 MB to 569 MB. Bun's shared package cache is left alone, because other projects can read it through symlinks. + +### Tests +- test/setup-production-deps.test.ts: the marker (git config, env, CI, unreadable git config), the one-time migration and its no-op rerun, restore on a failed or unverified install, recovery of an interrupted move, the lock, an untouched Bun cache, and a real-Bun fixture for a marked and an unmarked clone. test/uninstall.test.ts: Gemini CLI entries removed, the user's own kept. Host config, conformance, install-registry, render-prune and runtime-root tests cover the new host. + ## [1.92.21.0] - 2026-11-01 **The PR and train path check out blobless clones, and the history reads they gate on fail loudly instead of reading a failed fetch as "absent" (GSTA-203 PR 5).** Free Tests' plan job, the Version Gate and E2E Evals' route, screen summary, planner, slices, reruns, bisect and report checked out every file of every commit (about 232 MB) to read a handful of old files. They now check out with `filter: blob:none` (about 17 MB), and git fetches an old file's contents the first time something reads it. diff --git a/CLAUDE.md b/CLAUDE.md index d425dc6e7..f2a630b7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,7 +129,7 @@ and commit both; never edit the generated file (`test/design-checklist-sync.test.ts` fails on drift). Generation uses each host's `defaultModel` (`claude` for most hosts, `gpt` -for Codex, `gemini` for Antigravity's `agy`) unless `--model` is explicit. Codex installs additionally read the +for Codex, `gemini` for Antigravity's `agy` and Gemini CLI) unless `--model` is explicit. Codex installs additionally read the top-level model from `${CODEX_HOME:-~/.codex}/config.toml`; rerun `./setup --host codex` after changing that model. Note: `bun run build` and a bare `gen:skill-docs --host codex` render the host default (gpt) — if your diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 715592905..6132d56ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ bun install # install dependencies bin/dev-setup # activate dev mode ``` -`./setup` on its own installs production dependencies only. `bun install` (and `bin/dev-setup`, which runs it) adds the dev toolchain the tests and evals need: the Claude Agent SDK, TypeScript and Prettier. Once they are installed, a later `./setup` runs the full install too, so they stay at `bun.lock`'s versions. +`./setup` on its own installs production dependencies only. `bin/dev-setup` adds the dev toolchain the tests and evals need (the Claude Agent SDK, TypeScript and Prettier) and marks the clone as a contributor's with `git config gstack.contributor true`. In a marked clone a later `./setup` runs the full install too, so the toolchain stays at `bun.lock`'s versions. In an unmarked clone `./setup` moves back to production dependencies, which is how installs made before v1.91.231.0 drop the toolchain they never needed. If you ran plain `bun install` instead of `bin/dev-setup`, run `git config gstack.contributor true` once, or set `GSTACK_CONTRIBUTOR=1` for a single run. > **Full clone vs shallow.** The README's user-facing install uses `--depth 1` for speed. As a contributor, use a full clone (no `--depth` flag) — you'll need history for `git log`, `git blame`, `git bisect`, and reviewing PRs against earlier versions. If you already have a `--depth 1` clone from following the README, promote it to a full clone with `git fetch --unshallow`. @@ -736,11 +736,11 @@ worth the review overhead. ## Multi-host development -gstack generates SKILL.md files for 10 hosts from one set of `.tmpl` templates. +gstack generates SKILL.md files for 13 hosts from one set of `.tmpl` templates. Each host is a typed config in `hosts/*.ts`. The generator reads these configs to produce host-appropriate output (different frontmatter, paths, tool names). -**Supported hosts:** Claude (primary), Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, GBrain. +**Supported hosts:** Claude (primary), Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, GBrain, Copilot, Antigravity, Gemini CLI. ### Generating for all hosts @@ -749,7 +749,7 @@ to produce host-appropriate output (different frontmatter, paths, tool names). bun run gen:skill-docs # Claude (default) bun run gen:skill-docs --host codex # Codex bun run gen:skill-docs --host opencode # OpenCode -bun run gen:skill-docs --host all # All 10 hosts +bun run gen:skill-docs --host all # All 13 hosts # Or use build, which does all hosts + compiles binaries bun run build diff --git a/README.md b/README.md index c10f1ae0f..011832a81 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,13 @@ installs and passes the conformance tests but has no certification run yet; | Kiro | experimental | `--host kiro` → `~/.kiro/skills/gstack-*/` | ask for `gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host kiro` | | GitHub Copilot CLI | experimental | `--host copilot` → `~/.copilot/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host copilot` | | Antigravity CLI | experimental | `--host agy` (or `--host antigravity`) → `~/.gemini/antigravity-cli/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host agy` | +| Gemini CLI | experimental | `--host gemini` (or `--host gemini-cli`) → `~/.gemini/skills/gstack-*/` | ask for `gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host gemini` | | Slate | instruction-only | `--host slate` (points at the Claude install; Slate reads `.claude/skills`) | `/office-hours` via the Claude install | advisory, not blocked | — | `./setup --host claude` | | OpenClaw | experimental | `--host openclaw` (five task skills and one `gstack` router skill in OpenClaw's skills folder; automatic when setup runs inside OpenClaw; ACP route in [docs/OPENCLAW.md](docs/OPENCLAW.md)) | ask for the task; the agent opens the matching task skill or the `gstack` router | advisory, not blocked | — | `./setup --host openclaw` | | Hermes | experimental | `--host hermes` (every skill in `$HERMES_HOME/skills/gstack`; automatic when setup runs inside Hermes) | ask for the task, or `/gstack-review` | advisory, not blocked | — | `./setup --host hermes` | | GBrain (mod) | instruction-only | `--host gbrain` (brain-aware variants ship from the GBrain repo) | — | advisory, not blocked | — | — | -Per-host notes (agent harnesses, Copilot, Antigravity, OpenClaw and ClawHub, the Codex behavioral profile and model tiers, outside-review providers, the design binary's models) are in [docs/hosts.md](docs/hosts.md). Any rules-reading agent (Zed, Amp, Jules) can use the 2KB [agents digest](agents-digest/gstack-AGENTS.md) with no install. Adding a host: [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md). +Per-host notes (agent harnesses, Copilot, Antigravity, Gemini CLI, OpenClaw and ClawHub, the Codex behavioral profile and model tiers, outside-review providers, the design binary's models) are in [docs/hosts.md](docs/hosts.md). Any rules-reading agent (Zed, Amp, Jules) can use the 2KB [agents digest](agents-digest/gstack-AGENTS.md) with no install. Adding a host: [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md). ## See it work diff --git a/VERSION b/VERSION index 702e3354d..893844ff7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.21.0 +1.92.22.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index ba555e925..cee2c4bf1 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.21.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.22.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/dev-setup b/bin/dev-setup index f77026715..9a8f6f6f6 100755 --- a/bin/dev-setup +++ b/bin/dev-setup @@ -22,9 +22,12 @@ if [ ! -f "$REPO_ROOT/.env" ]; then fi fi -# 2. Install dependencies, devDependencies included. Always run: a checkout -# first installed by ./setup has production dependencies only, and an -# up-to-date install is a no-op. +# 2. Install dependencies, devDependencies included, and mark this clone as a +# contributor's (#3447) so ./setup keeps the dev toolchain instead of moving it +# to the production-only install. Always run: a checkout first installed by +# ./setup has production dependencies only, and an up-to-date install is a no-op. +git -C "$REPO_ROOT" config --local gstack.contributor true 2>/dev/null \ + || echo "Warning: could not set git config gstack.contributor in $REPO_ROOT; export GSTACK_CONTRIBUTOR=1 before ./setup to keep the dev toolchain." >&2 echo "Installing dependencies..." (cd "$REPO_ROOT" && bun install) diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index ef78dec8f..e592bae08 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -141,6 +141,7 @@ gstack_result_anchor() { BROWSER_UNAVAILABLE) printf '%s' 'browser-ensure-failed' ;; INSTALL_PREFLIGHT_FAILED) printf '%s' 'capy-install-preflight-failed' ;; SETUP_PS1_EMPTY_ARGUMENT) printf '%s' 'setup-ps1-empty-argument' ;; + SETUP_DEPS_MIGRATE_FAILED) printf '%s' 'setup-deps-migrate-failed' ;; POLICY_SOURCE_UNAVAILABLE) printf '%s' 'policy-source-unavailable' ;; POLICY_INVALID) printf '%s' 'policy-invalid' ;; POLICY_CONTAINMENT) printf '%s' 'policy-containment' ;; diff --git a/bin/gstack-host-renders.sh b/bin/gstack-host-renders.sh index 83e667c87..b0295a6fb 100644 --- a/bin/gstack-host-renders.sh +++ b/bin/gstack-host-renders.sh @@ -24,7 +24,7 @@ # host:render-dir for every host whose render lives in its own checkout dir. # Claude renders into the tracked tree. test/host-renders.test.ts keeps this # equal to hostSubdir in hosts/*.ts. -GSTACK_HOST_RENDER_DIRS="codex:.agents kiro:.kiro factory:.factory opencode:.opencode cursor:.cursor copilot:.copilot agy:.agy slate:.slate openclaw:.openclaw hermes:.hermes gbrain:.gbrain" +GSTACK_HOST_RENDER_DIRS="codex:.agents kiro:.kiro factory:.factory opencode:.opencode cursor:.cursor copilot:.copilot agy:.agy gemini:.gemini-cli slate:.slate openclaw:.openclaw hermes:.hermes gbrain:.gbrain" gstack_render_hosts_file() { printf '%s/.gstack-installed-hosts\n' "$1"; } diff --git a/bin/gstack-install-registry.sh b/bin/gstack-install-registry.sh index 0fe5e9e91..27ed91c57 100644 --- a/bin/gstack-install-registry.sh +++ b/bin/gstack-install-registry.sh @@ -184,7 +184,7 @@ gstack_install_render_for() { gstack_host_tier() { case "$1" in claude) echo full ;; - codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes) echo experimental ;; + codex|kiro|factory|opencode|cursor|copilot|agy|gemini|openclaw|hermes) echo experimental ;; slate|gbrain) echo instruction-only ;; *) echo unknown ;; esac @@ -256,6 +256,7 @@ gstack_install_known_roots() { printf 'cursor\tglobal\t%s\t%s\n' "$HOME/.cursor/skills" "$HOME/.cursor/skills/gstack" printf 'copilot\tglobal\t%s\t%s\n' "$HOME/.copilot/skills" "$HOME/.copilot/skills/gstack" printf 'agy\tglobal\t%s\t%s\n' "$HOME/.gemini/antigravity-cli/skills" "$HOME/.gemini/antigravity-cli/skills/gstack" + printf 'gemini\tglobal\t%s\t%s\n' "$HOME/.gemini/skills" "$HOME/.gemini/skills/gstack" proj="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [ -n "$proj" ] && [ "$proj" != "$HOME" ]; then printf 'claude\tproject\t%s\t%s\n' "$proj/.claude/skills" "$proj/.claude/skills/gstack" diff --git a/bin/gstack-team-init b/bin/gstack-team-init index d284ad04c..f27cfe7dd 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -82,7 +82,7 @@ else ```bash _GS="" -for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.copilot/skills/gstack" "$HOME/.gemini/antigravity-cli/skills/gstack" "$HOME/.gstack/repos/gstack"; do +for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.copilot/skills/gstack" "$HOME/.gemini/antigravity-cli/skills/gstack" "$HOME/.gemini/skills/gstack" "$HOME/.gstack/repos/gstack"; do [ -z "$_GS" ] && [ -n "$_D" ] && [ -d "$_D/bin" ] && _GS="$_D" done [ -n "$_GS" ] && echo "GSTACK_OK: $_GS" || echo "GSTACK_MISSING" @@ -153,7 +153,7 @@ const home = process.env.HOME || process.env.USERPROFILE || os.homedir(); const roots = [process.env.GSTACK_ROOT || ''].concat([ '.claude/skills/gstack', '.codex/skills/gstack', '.factory/skills/gstack', '.kiro/skills/gstack', '.config/opencode/skills/gstack', '.slate/skills/gstack', '.cursor/skills/gstack', '.openclaw/skills/gstack', - '.hermes/skills/gstack', '.gbrain/skills/gstack', '.copilot/skills/gstack', '.gemini/antigravity-cli/skills/gstack', '.gstack/repos/gstack', + '.hermes/skills/gstack', '.gbrain/skills/gstack', '.copilot/skills/gstack', '.gemini/antigravity-cli/skills/gstack', '.gemini/skills/gstack', '.gstack/repos/gstack', ].map((rel) => path.join(home, rel))); const gstackRoot = roots.find((root) => { try { return root !== '' && fs.statSync(path.join(root, 'bin')).isDirectory(); } catch { return false; } diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index 5bbc12222..c88a53568 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -24,6 +24,7 @@ # ~/.config/opencode/skills/gstack* — OpenCode skill install + per-skill symlinks # ~/.copilot/skills/gstack* — GitHub Copilot CLI skill install + per-skill symlinks # ~/.gemini/antigravity-cli/skills/gstack* — Antigravity CLI skill install + per-skill symlinks +# ~/.gemini/skills/gstack* — Gemini CLI skill install + per-skill symlinks (gstack-managed only) # harness registrations — every file gstack-harness wrote into OpenClaw, # Hermes, a Dot or another harness (from its # ledger; edited files move to a backup), and @@ -141,6 +142,7 @@ if [ "$FORCE" -eq 0 ]; then [ -d "$HOME/.config/opencode/skills" ] && echo " ~/.config/opencode/skills/gstack*" [ -d "$HOME/.copilot/skills" ] && echo " ~/.copilot/skills/gstack*" [ -d "$HOME/.gemini/antigravity-cli/skills" ] && echo " ~/.gemini/antigravity-cli/skills/gstack*" + [ -d "$HOME/.gemini/skills" ] && echo " ~/.gemini/skills/gstack*" [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR" [ -n "$_LEAVE_ROOT" ] && echo " (kept) $_LEAVE_ROOT: state root selected by $RESOLVED_VAR; left in place" @@ -546,6 +548,22 @@ if [ -d "$ANTIGRAVITY_SKILLS" ]; then done fi +# ─── Remove Gemini CLI skills ─────────────────────────────── +# Same provenance gate. ~/.gemini/skills also holds the user's own skills, so +# only gstack-managed gstack* entries go. +GEMINI_SKILLS="$HOME/.gemini/skills" +if [ -d "$GEMINI_SKILLS" ]; then + for _ITEM in "$GEMINI_SKILLS"/gstack*; do + [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue + if _cursor_item_is_gstack_managed "$_ITEM"; then + rm -rf "$_ITEM" + REMOVED+=("gemini/$(basename "$_ITEM")") + else + _SKIPPED_DIRS+=("$_ITEM") + fi + done +fi + # ─── Reconcile the install registry ───────────────────────── # A registry that survives (--keep-state, or a state root selected by an env # var and left in place) must forget the installs removed above, or the next diff --git a/docs/ADDING_A_HOST.md b/docs/ADDING_A_HOST.md index 07e12b395..f58fffe83 100644 --- a/docs/ADDING_A_HOST.md +++ b/docs/ADDING_A_HOST.md @@ -2,7 +2,7 @@ gstack uses a declarative host config system. Each supported AI coding agent (Claude, Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, -GBrain, Copilot, Antigravity) is defined as a typed TypeScript config object built by the +GBrain, Copilot, Antigravity, Gemini CLI) is defined as a typed TypeScript config object built by the `defineHost()` factory. Rendering a new host means creating one file and re-exporting it: the generator, tests, and validation pick it up. Installing it is not free: setup still has one install arm per installable host (see @@ -76,13 +76,13 @@ Inventory of every setup side effect, by phase (line numbers are approximate): |---|---|---| | Build | `browse/dist`, `design/dist`, `make-pdf/dist`, `bin/gstack-cso-*` in the source checkout | source | | Hosts record | `.gstack-installed-hosts` in the source checkout: every host setup installed from it, additive (#1694) | source | -| Generation | renders for the recorded hosts only (`.agents/`, `.kiro/`, `.factory/`, `.opencode/`, `.cursor/`, `.copilot/`, `.agy/`) in the source checkout; `gstack-patch-names` rewrites Claude `name:` fields in place | source | +| Generation | renders for the recorded hosts only (`.agents/`, `.kiro/`, `.factory/`, `.opencode/`, `.cursor/`, `.copilot/`, `.agy/`, `.gemini-cli/`) in the source checkout; `gstack-patch-names` rewrites Claude `name:` fields in place | source | | Render prune | renders of hosts the record does not name: links into the checkout removed, generated files moved to `$GSTACK_STATE_ROOT/backups/host-renders/-/`, other files kept | source / state root | | Chromium | Playwright cache (`~/.cache/ms-playwright`), lock under the state root | machine | | Config | `skill_prefix`, `timeline_stop_hook`, team-mode keys in `$GSTACK_STATE_ROOT/config.yaml` | state root | | Claude arm | `~/.claude/skills/gstack` link, one dir per skill with a `SKILL.md` link, alias copies | selected host | | Codex arm | `${CODEX_HOME:-~/.codex}/skills/gstack` runtime root, `gstack-*` links into `.agents/skills`, `.agents/skills/gstack` sidecar | selected host | -| Kiro, Factory, OpenCode, Cursor, Copilot, Antigravity arms | the host's `skills/gstack` runtime root (built beside the live one and swapped in) and `gstack-*` links | selected host | +| Kiro, Factory, OpenCode, Cursor, Copilot, Antigravity, Gemini CLI arms | the host's `skills/gstack` runtime root (built beside the live one and swapped in) and `gstack-*` links | selected host | | OpenCode commands | `~/.config/opencode/commands/gstack-*.md` with a managed marker (#2629); user command files are never touched | selected host | | Registry | one row per activated install in `installs.tsv` | state root | | Migrations | whatever each `v*.sh` repairs; marker in the state root | state root | @@ -245,12 +245,15 @@ which Google's docs do not state, so the rendered text names no number. **Shared `~/.gemini`.** Antigravity CLI and Gemini CLI both live under `~/.gemini`. Gemini CLI reads `~/.gemini/skills` (and `~/.agents/skills`); -Antigravity CLI reads `~/.gemini/antigravity-cli/skills`. Setup writes only -gstack entries in the latter, uninstall removes only gstack-managed entries -there, and `--host auto` selects Antigravity only for `agy` on `PATH` or an -existing `~/.gemini/antigravity-cli`, never for a bare `~/.gemini`. -`test/setup-install-registry.test.ts` holds both directions. Gemini CLI as a -host is not supported. Two overlaps remain: workspace `.agents/skills` is also +Antigravity CLI reads `~/.gemini/antigravity-cli/skills`, and Google's +migration table lists `~/.gemini/skills` as an Antigravity global path too. +Each host's arm writes only gstack entries in its own directory, uninstall +removes only gstack-managed entries in each, and `--host auto` selects +Antigravity only for `agy` on `PATH` or an existing +`~/.gemini/antigravity-cli`, and Gemini CLI only for `gemini` on `PATH`, +never either for a bare `~/.gemini`. `test/setup-install-registry.test.ts` +holds every direction, including both hosts installed together. Gemini CLI is +its own host ("Gemini CLI" below). Two overlaps remain: workspace `.agents/skills` is also where a project-local Codex install puts its Codex-flavored `gstack-*` skills, which Antigravity will list in that repo, and the Antigravity 2.0 app and IDE read `~/.gemini/config/skills`, which this host does not install @@ -277,6 +280,46 @@ root's extra markdown files (`ETHOS.md`, `review/*.md`) as skills, the partial-read behavior of `view_file` on long skills, `ask_question` option handling, and an upgrade from an existing install. +## Gemini CLI (#1247) + +Shipped as `experimental` (`hosts/gemini.ts`, `./setup --host gemini`, alias +`--host gemini-cli`). Verified against Gemini CLI 0.66.0 (its bundled +`docs/cli/skills.md` and `SkillManager.discoverSkills`) on 2026-10-31: + +| Fact | Value | Source | +|---|---|---| +| User skills | `~/.gemini/skills//SKILL.md`, and the `~/.agents/skills` alias, which wins inside the user tier | [Agent Skills](https://geminicli.com/docs/cli/skills/), `packages/core/src/skills/skillManager.ts` | +| Workspace skills | `.gemini/skills` and the `.agents/skills` alias, loaded only in a trusted folder (`/trust`) | same | +| Precedence | built-in < extension < user < workspace; same name: the higher tier wins | same | +| Discovery | `//SKILL.md`, one level deep; symlinked skill dirs are followed; the name comes from frontmatter `name` | same | +| Invocation | the model calls `activate_skill` after you approve; `/skills list` and `gemini skills list` show them | same | +| Tool names | `run_shell_command`, `read_file`, `read_many_files`, `write_file`, `replace`, `grep_search`, `glob`, `list_directory`, `ask_user`, `invoke_agent`, `activate_skill`, `enter_plan_mode`, `exit_plan_mode`, `write_todos`, `web_fetch`, `google_web_search` | 0.66.0 bundle | +| Context file | `GEMINI.md` | 0.66.0 bundle | + +What the host does: skills in `~/.gemini/skills/gstack-*`, runtime root +`~/.gemini/skills/gstack` with `.source-path` for `/gstack-upgrade`, `name:` +equal to the `gstack-` directory, frontmatter limited to `name` and +`description` (1024-character limit), the Gemini model overlay, and a +one-paragraph tool glossary ahead of the preamble's STATUS rules. The glossary +also names Antigravity's `run_command`, `view_file` and `ask_question`, +because an Antigravity CLI that reads `~/.gemini/skills` sees these skills. +The render directory is `.gemini-cli/` (gitignored), not `.gemini/`, so this +repo's own trusted workspace never lists render output as skills. `--local` +is refused: it installs only Claude, and Gemini CLI reads workspace skills only +in trusted folders. + +Overlaps: inside a trusted repo, a project-local Codex install's `.agents/skills` +(Codex-flavored `gstack-*` skills) is the workspace tier, which outranks +`~/.gemini/skills`, so Gemini CLI uses those there. gstack never writes +`~/.agents/skills`: that alias is shared with other tools and outranks +`~/.gemini/skills`. Antigravity's own install stays in +`~/.gemini/antigravity-cli/skills`. + +Unverified without a Gemini API key on the build machines (why it is not +`full`): a live model run of any skill, `ask_user` option handling, and +plan mode with gstack's plan-review skills. Discovery is verified: +`gemini skills list` after `./setup --host gemini` lists the `gstack-*` skills. + ## Instruction-only hosts and agent harnesses Slate and GBrain have no install arm. OpenClaw and Hermes are harness hosts: @@ -313,6 +356,7 @@ hosts/ ├── gbrain.ts # GBrain ├── copilot.ts # GitHub Copilot CLI ├── agy.ts # Google Antigravity CLI +├── gemini.ts # Google Gemini CLI └── index.ts # Registry: imports all, derives Host type ``` diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index cc71ce742..58532d09d 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -20,6 +20,7 @@ gstack/ │ ├── codex.ts, factory.ts, kiro.ts # Existing hosts │ ├── opencode.ts, slate.ts, cursor.ts, openclaw.ts # IDE hosts │ ├── hermes.ts, gbrain.ts # Agent runtime hosts +│ ├── copilot.ts, agy.ts, gemini.ts # GitHub Copilot CLI, Antigravity CLI, Gemini CLI │ └── index.ts # Registry: exports all, derives Host type ├── scripts/ # Build + DX tooling │ ├── gen-skill-docs.ts # Template → SKILL.md generator (config-driven) diff --git a/docs/hosts.md b/docs/hosts.md index 161e669a8..30eacb14c 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -137,6 +137,14 @@ Antigravity only when `agy` is on `PATH` or `~/.gemini/antigravity-cli` exists, never for a Gemini-CLI-only `~/.gemini`. The Antigravity 2.0 app and IDE read `~/.gemini/config/skills`, which this host does not install. +Gemini CLI loads gstack skills itself when a task matches (`activate_skill`, +after you approve); ask for `gstack-office-hours`, or list them with +`/skills list`. Setup writes only `~/.gemini/skills/gstack*` and never +`~/.agents/skills`, and `--host auto` selects Gemini CLI only when `gemini` is on +`PATH`. Inside a repo with a project-local Codex install, Gemini CLI uses the +Codex skills in `.agents/skills` (the workspace tier wins), and workspace skills +load only in trusted folders (`/trust`). + Outside reviews require the selected CLI to be installed and authenticated: Claude Code when using gstack in Codex, or Codex on other harnesses. External harnesses discover these commands as `/gstack-claude-code` and `/gstack-codex`; each harness omits its own wrapper. Explicit provider requests keep that provider. The existing `codex_reviews` setting controls automatic outside reviews where supported, regardless of the provider selected. `/claude` has been renamed to `/claude-code`. Re-run `./setup --host ` to migrate managed installations, including other harnesses sharing the checkout. Setup preserves the previous installation if replacement generation or installation fails and prints repair instructions. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 225c66bda..654641b76 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1239,8 +1239,8 @@ git -C ~/.claude/skills/gstack update-ref -d 'refs/gstack/rescue/' ### `gstack setup: refusing to replace for : this runtime directory contains the source checkout setup runs from (), so replacing it would delete the checkout. Nothing was changed.` -**Meaning.** Setup installs Codex, Factory, OpenCode, Cursor, Copilot and -Antigravity by building a runtime root (such as `~/.cursor/skills/gstack`) and +**Meaning.** Setup installs Codex, Factory, OpenCode, Cursor, Copilot, +Antigravity and Gemini CLI by building a runtime root (such as `~/.cursor/skills/gstack`) and swapping it in for the old one, and Kiro by writing into `~/.kiro/skills/gstack`. Your gstack checkout lives at that root or inside it, so the swap would delete the checkout (#3084), and Kiro would link the checkout into itself. Setup refuses before @@ -2206,6 +2206,25 @@ anything. --- + +### `gstack: WARN kept the full dependency install (SETUP_DEPS_MIGRATE_FAILED; ...): ` + +**Meaning.** Your install was made before v1.91.231.0, so it still carries the +test and eval packages (about 520 MB, mostly the Claude Agent SDK). setup moved +`node_modules` aside to install production dependencies only, that install +failed, and setup put the full `node_modules` back. Nothing was removed, and +gstack keeps working on the full install. + +**Fix.** Fix the cause the warning names (no network, a proxy, a full disk), +then run `./setup` again. Developing gstack in this checkout? Mark it, and +setup keeps the dev packages from then on: + +```bash +git config gstack.contributor true +``` + +--- + ### `Chromium bootstrap: chromium-install — x playwright install chromium exited 127 (command not found); install Bun from bun.sh (winget Bun ships no bunx), or skip with ./setup --no-browser (Git Bash) or .\setup.ps1 --no-browser (PowerShell)` diff --git a/docs/uninstall.md b/docs/uninstall.md index a0309dd7a..8428c44d1 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -54,6 +54,7 @@ rm -rf "${HERMES_HOME:-$HOME/.hermes}/skills/gstack" 2>/dev/null rm -rf ~/.cursor/skills/gstack* 2>/dev/null rm -rf ~/.config/opencode/skills/gstack* 2>/dev/null rm -rf ~/.gemini/antigravity-cli/skills/gstack* 2>/dev/null +rm -rf ~/.gemini/skills/gstack* 2>/dev/null # 6. Remove temp files rm -f /tmp/gstack-* 2>/dev/null diff --git a/hosts/gemini.ts b/hosts/gemini.ts new file mode 100644 index 000000000..a9f5417ae --- /dev/null +++ b/hosts/gemini.ts @@ -0,0 +1,59 @@ +import { defineHost, GBRAIN_RESOLVERS, preambleToolGlossary } from './define-host'; + +// Google Gemini CLI (#1247). Paths: user skills in ~/.gemini/skills, workspace +// skills in .gemini/skills (trusted folders only), found at //SKILL.md +// and indexed by frontmatter name (geminicli.com/docs/cli/skills). Tool names: +// Gemini CLI 0.66.0. Google's migration table lists ~/.gemini/skills as an +// Antigravity CLI global path too, so the glossary maps agy's names as well. +// Verified facts in docs/ADDING_A_HOST.md "Gemini CLI". +const GEMINI_TOOL_GLOSSARY = '**Gemini CLI tool names:** `AskUserQuestion` means your `ask_user` tool; there is no `mcp__*__AskUserQuestion` variant. The Bash tool means `run_shell_command`, the Read tool `read_file`, the Write tool `write_file`, the Edit tool `replace`, Grep `grep_search`, Glob `glob`, the Agent tool `invoke_agent`, and `ExitPlanMode` means `exit_plan_mode`. The Skill tool means `activate_skill`, or reading the skill\'s SKILL.md with `read_file`. In Antigravity CLI, which can read these skills too, use `run_command`, `view_file` and `ask_question` instead.'; + +const gemini = defineHost({ + name: 'gemini', + displayName: 'Gemini CLI', + cliAliases: ['gemini-cli'], + defaultModel: 'gemini', + tier: 'experimental', + capabilities: { toolExecution: true, questions: 'native', planMode: true, delegation: true, browser: true, safetyHooks: 'advisory' }, + + globalRoot: '.gemini/skills/gstack', + localSkillRoot: '.gemini/skills/gstack', + // Render output only: .gemini is Gemini CLI's own workspace directory, so a + // render there would be listed as workspace skills in this repo. + hostSubdir: '.gemini-cli', + + // Gemini CLI indexes skills by frontmatter name, so names match their + // gstack- directories. 1024 is the Agent Skills description limit. + frontmatter: { + mode: 'allowlist', + keepFields: ['name', 'description'], + descriptionLimit: 1024, + descriptionLimitBehavior: 'error', + nameMatchesDirectory: true, + }, + + // Literal ~/.gemini/skills paths (the agy model); .source-path (written by + // setup) lets /gstack-upgrade find the checkout behind the runtime root. + pathRewrites: [ + { from: 'if [ -d "$HOME/.claude/skills/gstack/.git" ]', to: 'if [ -d "$(cat "$HOME/.gemini/skills/gstack/.source-path" 2>/dev/null)/.git" ]' }, + { from: 'INSTALL_DIR="$HOME/.claude/skills/gstack"', to: 'INSTALL_DIR="$(cat "$HOME/.gemini/skills/gstack/.source-path")"' }, + { from: '$HOME/.claude/skills/gstack', to: '$HOME/.gemini/skills/gstack' }, + { from: '~/.claude/skills/gstack', to: '~/.gemini/skills/gstack' }, + { from: '.claude/skills/gstack', to: '.gemini/skills/gstack' }, + { from: '.claude/skills/review', to: '.gemini/skills/gstack/review' }, + { from: '.claude/skills', to: '.gemini/skills' }, + { from: 'CLAUDE.md', to: 'GEMINI.md' }, + ], + toolRewrites: preambleToolGlossary(GEMINI_TOOL_GLOSSARY), + + suppressedResolvers: ['REVIEW_ARMY', ...GBRAIN_RESOLVERS], + + runtimeRoot: { + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'freeze/bin', 'careful/bin', 'gstack-upgrade', 'ETHOS.md'], + globalFiles: { + 'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'], + }, + }, +}); + +export default gemini; diff --git a/hosts/index.ts b/hosts/index.ts index 9477f76f3..f7064fecd 100644 --- a/hosts/index.ts +++ b/hosts/index.ts @@ -18,9 +18,10 @@ import hermes from './hermes'; import gbrain from './gbrain'; import copilot from './copilot'; import agy from './agy'; +import gemini from './gemini'; /** All registered host configs. Add new hosts here. */ -export const ALL_HOST_CONFIGS: HostConfig[] = [claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, copilot, agy]; +export const ALL_HOST_CONFIGS: HostConfig[] = [claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, copilot, agy, gemini]; /** Map from host name to config. */ export const HOST_CONFIG_MAP: Record = Object.fromEntries( @@ -44,7 +45,7 @@ export function getHostConfig(name: string): HostConfig { /** * Resolve a host name from a CLI argument, handling aliases. - * e.g., 'agents' → 'codex', 'droid' → 'factory', 'antigravity' → 'agy' + * e.g., 'agents' → 'codex', 'droid' → 'factory', 'antigravity' → 'agy', 'gemini-cli' → 'gemini' */ export function resolveHostArg(arg: string): string { // Direct name match @@ -67,4 +68,4 @@ export function getExternalHosts(): HostConfig[] { } // Re-export individual configs for direct import -export { claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, copilot, agy }; +export { claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, copilot, agy, gemini }; diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 599370c10..2dece5f82 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -166,6 +166,11 @@ export const RESULT_CODES = { summary: 'an argument given to .\\setup.ps1 is an empty string, which its Git Bash hand-off cannot carry, so nothing ran', fix: 'drop the empty argument or give it a value, then run .\\setup.ps1 again', }, + SETUP_DEPS_MIGRATE_FAILED: { + anchor: 'setup-deps-migrate-failed', + summary: 'setup tried to move an install made before v1.91.231.0 to production-only dependencies, the production install failed, and setup put the full install back', + fix: 'read the cause on the warning line (offline, proxy, disk full), fix it, then re-run ./setup; the full install keeps working meanwhile', + }, // C-stamp (plan C1, C2, C3, C5, C7): ship policy, restamp, tree receipt, ship receipt. POLICY_SOURCE_UNAVAILABLE: { anchor: 'policy-source-unavailable', diff --git a/package.json b/package.json index e4022f3c6..4ccb22127 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.21", + "version": "1.92.22", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/setup b/setup index ce2a5cccc..2ae82558e 100755 --- a/setup +++ b/setup @@ -23,7 +23,7 @@ Usage: ./setup [options] Options: --host Install for one host. Default: claude. Hosts by tier: full: claude - experimental: codex, kiro, factory, opencode, cursor, copilot, agy, openclaw, hermes + experimental: codex, kiro, factory, opencode, cursor, copilot, agy, gemini, openclaw, hermes instruction-only: slate, gbrain auto: every installed agent that setup detects An explicit --host never changes another host's install. @@ -212,7 +212,7 @@ _setup_unknown_option() { } while [ $# -gt 0 ]; do case "$1" in - --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, cursor, copilot, agy, slate, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; HOST_FLAG_SET=1; shift 2 ;; + --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, cursor, copilot, agy, gemini, slate, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; HOST_FLAG_SET=1; shift 2 ;; --host=*) HOST="${1#--host=}"; HOST_FLAG_SET=1; shift ;; --model) [ -z "$2" ] && echo "Missing value for --model" >&2 && exit 1; MODEL_OVERRIDE="$2"; MODEL_OVERRIDE_SET=1; shift 2 ;; --model=*) MODEL_OVERRIDE="${1#--model=}"; MODEL_OVERRIDE_SET=1; shift ;; @@ -239,8 +239,9 @@ while [ $# -gt 0 ]; do done [ "$HOST" = "antigravity" ] && HOST="agy" # Antigravity CLI is named after its binary +[ "$HOST" = "gemini-cli" ] && HOST="gemini" case "$HOST" in - claude|codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes|auto) ;; + claude|codex|kiro|factory|opencode|cursor|copilot|agy|gemini|openclaw|hermes|auto) ;; slate) echo "" echo "Slate is not yet a first-class install target (docs/designs/SLATE_HOST.md —" @@ -265,7 +266,7 @@ case "$HOST" in echo "" exit 0 ;; *) - echo "Unknown --host value: $HOST — nothing was installed or changed. Expected one of: claude (default), codex, kiro, factory, opencode, cursor, copilot, agy (alias antigravity), slate, openclaw, hermes, gbrain, or auto. Tiers: ./setup --help" >&2; exit 1 ;; + echo "Unknown --host value: $HOST — nothing was installed or changed. Expected one of: claude (default), codex, kiro, factory, opencode, cursor, copilot, agy (alias antigravity), gemini (alias gemini-cli), slate, openclaw, hermes, gbrain, or auto. Tiers: ./setup --help" >&2; exit 1 ;; esac # Bun versions come from one sourced file (floor and tested; E1). @@ -550,6 +551,8 @@ COPILOT_SKILLS="$HOME/.copilot/skills" COPILOT_GSTACK="$COPILOT_SKILLS/gstack" AGY_SKILLS="$HOME/.gemini/antigravity-cli/skills" AGY_GSTACK="$AGY_SKILLS/gstack" +GEMINI_SKILLS="$HOME/.gemini/skills" +GEMINI_GSTACK="$GEMINI_SKILLS/gstack" SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")" SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")" @@ -1362,8 +1365,9 @@ _remove_disabled_host_entry() { 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" ] || [ "$HOST" = "copilot" ]; then + if [ "$HOST" = "codex" ] || [ "$HOST" = "copilot" ] || [ "$HOST" = "gemini" ]; then echo "Error: --local is only supported for Claude Code (not $HOST)." >&2 + [ "$HOST" = "gemini" ] && echo " Gemini CLI loads workspace skills (.gemini/skills) only in trusted folders (/trust); ./setup --host gemini installs to ~/.gemini/skills." >&2 exit 1 fi INSTALL_SKILLS_DIR="$(pwd)/.claude/skills" @@ -1381,6 +1385,7 @@ INSTALL_OPENCODE=0 INSTALL_CURSOR=0 INSTALL_COPILOT=0 INSTALL_AGY=0 +INSTALL_GEMINI=0 INSTALL_HARNESS="" if [ "$HOST" = "auto" ]; then command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1 @@ -1398,8 +1403,10 @@ if [ "$HOST" = "auto" ]; then # Gemini CLI's and never selects Antigravity. command -v agy >/dev/null 2>&1 && INSTALL_AGY=1 [ -d "$HOME/.gemini/antigravity-cli" ] && INSTALL_AGY=1 + # ~/.gemini also holds Antigravity's config, so only the binary selects Gemini CLI. + command -v gemini >/dev/null 2>&1 && INSTALL_GEMINI=1 # If none found, default to claude - if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ] && [ "$INSTALL_AGY" -eq 0 ]; then + if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ] && [ "$INSTALL_AGY" -eq 0 ] && [ "$INSTALL_GEMINI" -eq 0 ]; then INSTALL_CLAUDE=1 fi elif [ "$HOST" = "claude" ]; then @@ -1418,6 +1425,8 @@ elif [ "$HOST" = "copilot" ]; then INSTALL_COPILOT=1 elif [ "$HOST" = "agy" ]; then INSTALL_AGY=1 +elif [ "$HOST" = "gemini" ]; then + INSTALL_GEMINI=1 elif [ "$HOST" = "openclaw" ] || [ "$HOST" = "hermes" ]; then INSTALL_HARNESS="$HOST" fi @@ -1425,8 +1434,8 @@ fi # A host that passes --host validation but sets no INSTALL_* flag would # silently configure nothing and exit 0 (the #2361 slate failure class). # Fail loudly if a future host lands in the accept-list without a dispatch arm. -if [ "$HOST" != "auto" ] && [ -z "$INSTALL_HARNESS" ] && [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ] && [ "$INSTALL_AGY" -eq 0 ]; then - echo "Error: no install arm exists for host '$HOST' — it passed --host validation but sets no INSTALL_* flag, so setup would configure nothing and exit 0. This is a setup bug. Valid install targets: claude, codex, kiro, factory, opencode, cursor, copilot, agy, openclaw, hermes (informational: slate, gbrain)." >&2 +if [ "$HOST" != "auto" ] && [ -z "$INSTALL_HARNESS" ] && [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ] && [ "$INSTALL_AGY" -eq 0 ] && [ "$INSTALL_GEMINI" -eq 0 ]; then + echo "Error: no install arm exists for host '$HOST' — it passed --host validation but sets no INSTALL_* flag, so setup would configure nothing and exit 0. This is a setup bug. Valid install targets: claude, codex, kiro, factory, opencode, cursor, copilot, agy, gemini, openclaw, hermes (informational: slate, gbrain)." >&2 exit 1 fi @@ -1450,7 +1459,8 @@ fi # contains, this checkout (#3084). _activate_runtime_root checks again; Kiro # writes into its root in place, so it is refused here too. for _rr in "$INSTALL_KIRO kiro $KIRO_SKILLS/gstack" "$INSTALL_FACTORY factory $FACTORY_GSTACK" "$INSTALL_OPENCODE opencode $OPENCODE_GSTACK" \ - "$INSTALL_CURSOR cursor $CURSOR_GSTACK" "$INSTALL_COPILOT copilot $COPILOT_GSTACK" "$INSTALL_AGY agy $AGY_GSTACK"; do + "$INSTALL_CURSOR cursor $CURSOR_GSTACK" "$INSTALL_COPILOT copilot $COPILOT_GSTACK" "$INSTALL_AGY agy $AGY_GSTACK" \ + "$INSTALL_GEMINI gemini $GEMINI_GSTACK"; do [ "${_rr%% *}" -eq 1 ] || continue _rr=${_rr#* } _runtime_root_refused "${_rr%% *}" "${_rr#* }" && exit 1 @@ -1682,18 +1692,112 @@ bun_cmd() { "$BUN_CMD" "$@" } +# Who is a contributor is explicit (#3447): `git config gstack.contributor true` +# in the checkout (bin/dev-setup writes it) or GSTACK_CONTRIBUTOR=1. A git error +# (safe.directory, permissions) is "unknown", never "user", so it never prunes. +# CI keeps whatever its own `bun install` put there. +_deps_role() { + [ "${GSTACK_CONTRIBUTOR:-}" = "1" ] && { echo contributor; return; } + local value rc=0 + value="$(git -C "$PWD" config --bool --get gstack.contributor 2>/dev/null)" || rc=$? + if [ "$rc" -eq 0 ]; then + [ "$value" = "true" ] && echo contributor || echo user + return + fi + [ "$rc" -ne 1 ] && { echo unknown; return; } + [ -n "${CI:-}" ] && { echo ci; return; } + echo user +} + +_deps_has_dev() { + [ -d node_modules/typescript ] || [ -d node_modules/@anthropic-ai/claude-agent-sdk ] +} + +# Every package.json "dependencies" entry resolves in node_modules. +_deps_verified() { + [ -d node_modules ] || return 1 + local name + for name in $(awk '/^ "dependencies": \{/{f=1;next} f&&/^ \}/{exit} f{gsub(/[",:]/," ");print $1}' package.json); do + [ -f "node_modules/$name/package.json" ] || return 1 + done +} + +# Checkout-local migration lock: a live holder means skip; a dead or missing +# holder (killed setup) is reclaimed. +_deps_lock() { + local lock="node_modules.gstack-migrate.lock" holder + if ! mkdir "$lock" 2>/dev/null; then + holder="$(cat "$lock/pid" 2>/dev/null || true)" + case "$holder" in + ''|*[!0-9]*|0) [ -z "$(find "$lock" -maxdepth 0 -mmin +5 2>/dev/null)" ] && [ -z "$holder" ] && return 1 ;; + *) kill -0 "$holder" 2>/dev/null && return 1 ;; + esac + rm -rf "$lock" + mkdir "$lock" 2>/dev/null || return 1 + fi + echo "$$" > "$lock/pid" +} + +_deps_unlock() { rm -rf node_modules.gstack-migrate.lock; } + +# A backup left by an interrupted migration: keep whichever copy is usable. +_deps_recover() { + [ -d node_modules.gstack-migrate ] || return 0 + if _deps_verified; then + rm -rf node_modules.gstack-migrate + else + rm -rf node_modules && mv node_modules.gstack-migrate node_modules + fi +} + +# Installs made before v1.91.231.0 carry the dev toolchain. Locks, recovers, and +# moves node_modules aside; succeeds only when it moved, so the caller installs +# production-only and calls _deps_migration_finish. +_deps_migration_start() { + [ -d node_modules.gstack-migrate ] || _deps_has_dev || return 1 + _deps_lock || return 1 + _deps_recover + if _deps_has_dev; then + _DEPS_BEFORE_KB="$(du -sk node_modules 2>/dev/null | cut -f1)" + mv node_modules node_modules.gstack-migrate && return 0 + fi + _deps_unlock + return 1 +} + +_deps_migration_finish() { + local rc="$1" out="$2" after + if [ "$rc" -eq 0 ]; then + rm -rf node_modules.gstack-migrate + after="$(du -sk node_modules 2>/dev/null | cut -f1)" + _deps_unlock + echo "gstack: moved this install to production dependencies: node_modules $(( ${_DEPS_BEFORE_KB:-0} / 1024 )) MB -> $(( ${after:-0} / 1024 )) MB. Developing gstack in this checkout? Run: git config gstack.contributor true, then ./setup again." + return 0 + fi + rm -rf node_modules + mv node_modules.gstack-migrate node_modules + _deps_unlock + echo "gstack: WARN kept the full dependency install (SETUP_DEPS_MIGRATE_FAILED; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#setup-deps-migrate-failed): $(printf '%s\n' "$out" | tail -1 | cut -c1-200). Nothing was removed; the next ./setup tries again." >&2 + return 1 +} + # Users get production dependencies only. devDependencies are the test and eval # toolchain (the Claude Agent SDK's native binaries alone are ~440 MB on Linux), and # nothing setup, the build or an installed skill runs needs them. Bun never -# prunes packages that are already installed, so a contributor's full -# `bun install` (CONTRIBUTING.md, bin/dev-setup, CI) survives a later ./setup. +# prunes packages that are already installed, so a user install that predates +# v1.91.231.0 migrates once (_deps_migration_start), while a contributor (or a +# checkout of unknown role, or CI) that has the dev toolchain gets the full +# install, which keeps its dev packages at bun.lock's versions. # Bun's --production implies a frozen lockfile, so when bun.lock is out of date # (a fork or a local edit) refresh it first, then install production-only again. -# A checkout that already has the dev toolchain (node_modules/typescript) is a -# contributor's: a production install would leave its dev packages at old -# versions after bun.lock moves, so it gets the full install instead. install_runtime_deps() { - if [ -d node_modules/typescript ]; then + local role out rc=0 + role="$(_deps_role)" + if [ "$role" = "user" ] && _deps_migration_start; then + out="$( { bun_cmd install --production --frozen-lockfile || { bun_cmd install --lockfile-only && bun_cmd install --production --frozen-lockfile; }; } 2>&1 )" && _deps_verified || rc=$? + _deps_migration_finish "$rc" "$out" && return 0 + fi + if [ "$role" = "contributor" ] || _deps_has_dev; then bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install return fi @@ -1849,6 +1953,7 @@ _RENDER_HOSTS_SELECTED="" [ "$INSTALL_CURSOR" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED cursor" [ "$INSTALL_COPILOT" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED copilot" [ "$INSTALL_AGY" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED agy" +[ "$INSTALL_GEMINI" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED gemini" # shellcheck disable=SC2086 # the host list splits on purpose if ! gstack_render_hosts_add "$SOURCE_GSTACK_DIR" $_RENDER_HOSTS_SELECTED; then echo " warning: could not write $(gstack_render_hosts_file "$SOURCE_GSTACK_DIR"); this build renders every host and nothing is pruned" >&2 @@ -2126,6 +2231,16 @@ if [ "$INSTALL_AGY" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MODE_A ) fi +# 1h. Generate .gemini-cli/ Gemini CLI skill docs +if [ "$INSTALL_GEMINI" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MODE_ARG" ]; }; then + log "Generating .gemini-cli/ skill docs..." + ( + cd "$SOURCE_GSTACK_DIR" + install_runtime_deps + bun_cmd run gen:skill-docs --host gemini ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} + ) +fi + # 2. Ensure Playwright's Chromium is available _PLAYWRIGHT_PLATFORM_OVERRIDE="" _PLAYWRIGHT_UNSUPPORTED_ARCH="" @@ -3448,6 +3563,81 @@ link_agy_skill_dirs() { fi } +# ─── Helper: ~/.gemini/skills/gstack runtime root ────────────────────────── +# Same shape as the Antigravity root. ~/.gemini/skills is Gemini CLI's user +# skills directory, shared with the user's own skills: setup writes only gstack +# entries there. Built through _activate_runtime_root. +create_gemini_runtime_root() { + local gstack_dir="$1" + local gemini_gstack="$2" + local gemini_dir="$gstack_dir/.gemini-cli/skills" + local asset f + + mkdir -p "$gemini_gstack" "$gemini_gstack/browse" "$gemini_gstack/gstack-upgrade" "$gemini_gstack/review" + if [ -f "$gemini_dir/gstack/SKILL.md" ]; then + _copy_skill_md "$gemini_dir/gstack/SKILL.md" "$gemini_gstack/SKILL.md" + fi + for asset in bin lib browse/dist browse/bin; do + if [ -d "$gstack_dir/$asset" ]; then + _link_or_copy "$gstack_dir/$asset" "$gemini_gstack/$asset" + fi + done + _link_runtime_dists "$gstack_dir" "$gemini_gstack" + if [ -f "$gemini_dir/gstack-upgrade/SKILL.md" ]; then + _copy_skill_md "$gemini_dir/gstack-upgrade/SKILL.md" "$gemini_gstack/gstack-upgrade/SKILL.md" + fi + _copy_runtime_skill_refs "$gemini_dir" "$gemini_gstack" + if [ -f "$gstack_dir/ETHOS.md" ]; then + _link_or_copy "$gstack_dir/ETHOS.md" "$gemini_gstack/ETHOS.md" + fi + if [ -f "$gstack_dir/supabase/config.sh" ]; then + mkdir -p "$gemini_gstack/supabase" + _link_or_copy "$gstack_dir/supabase/config.sh" "$gemini_gstack/supabase/config.sh" + fi +} + +# Frontmatter names already equal the gstack- directories +# (nameMatchesDirectory), so no name patching. Fails when nothing was linked. +link_gemini_skill_dirs() { + local gstack_dir="$1" + local skills_dir="$2" + local gemini_dir="$gstack_dir/.gemini-cli/skills" + local linked=() + + if [ ! -d "$gemini_dir" ]; then + echo " Generating .gemini-cli/ skill docs..." + ( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host gemini ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) + fi + if [ ! -d "$gemini_dir" ]; then + echo " warning: .gemini-cli/skills/ generation failed — run 'bun run gen:skill-docs --host gemini' manually" >&2 + return 1 + fi + + for skill_dir in "$gemini_dir"/gstack*/; do + if [ -f "$skill_dir/SKILL.md" ]; then + skill_name="$(basename "$skill_dir")" + [ "$skill_name" = "gstack" ] && continue + case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac + target="$skills_dir/$skill_name" + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then + if _owned_for_windows_refresh "$target"; then + _link_or_copy "$skill_dir" "$target" + linked+=("$skill_name") + else + echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2 + fi + fi + fi + done + if [ ${#linked[@]} -gt 0 ]; then + echo " linked skills: ${linked[*]}" + fi + if ! ls "$skills_dir"/gstack-*/SKILL.md >/dev/null 2>&1; then + echo " error: no gstack skills are installed in $skills_dir" >&2 + return 1 + fi +} + # 4. Install for Claude (default) # _claude_scope SKILLS_DIR — "global" for the user's Claude skills dir, else # "project ". @@ -3868,6 +4058,25 @@ if [ "$INSTALL_AGY" -eq 1 ]; then fi fi +# 6g. Install for Gemini CLI +if [ "$INSTALL_GEMINI" -eq 1 ]; then + if _sidecar_root_user_owned "$GEMINI_GSTACK"; then + echo " left in place (existing dir not gstack-managed — no generated banner): $GEMINI_GSTACK" >&2 + _setup_row gemini global "$GEMINI_SKILLS" - "$SETUP_VERSION" skipped "left alone: $GEMINI_GSTACK is not gstack-managed (no generated banner)" + else + _setup_arm_begin gemini global "$GEMINI_SKILLS" + mkdir -p "$GEMINI_SKILLS" + _prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.gemini-cli/skills" "$GEMINI_SKILLS" + link_gemini_skill_dirs "$SOURCE_GSTACK_DIR" "$GEMINI_SKILLS" + _activate_runtime_root create_gemini_runtime_root "$SOURCE_GSTACK_DIR" "$GEMINI_GSTACK" + _setup_arm_publish gemini global - "$GEMINI_SKILLS" "$GEMINI_GSTACK" - + echo "gstack ready (gemini)." + echo " browse: $BROWSE_BIN" + _browser_hint + echo " gemini skills: $GEMINI_SKILLS (ask for gstack-; Gemini CLI activates it with activate_skill)" + fi +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. diff --git a/test/fixtures/skill-md-size-allowlist.json b/test/fixtures/skill-md-size-allowlist.json index 5f6c8c08b..45435e0a9 100644 --- a/test/fixtures/skill-md-size-allowlist.json +++ b/test/fixtures/skill-md-size-allowlist.json @@ -29,6 +29,11 @@ "factory/gstack-review": 125920, "gbrain/gstack-autoplan": 108748, "gbrain/gstack-plan-eng-review": 124972, + "gemini/gstack-autoplan": 108899, + "gemini/gstack-plan-design-review": 109210, + "gemini/gstack-plan-devex-review": 114248, + "gemini/gstack-plan-eng-review": 143588, + "gemini/gstack-review": 103679, "hermes/gstack-autoplan": 108751, "hermes/gstack-plan-eng-review": 125008, "kiro/gstack-autoplan": 108115, diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 7a52ba393..b6296dc27 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -3693,7 +3693,7 @@ describe('setup script validation', () => { expect(setupContent).toContain('--host'); // #2361: slate moved OUT of the install accept-list (it was accepted but // never dispatched — a silent exit-0 no-op) into an informational arm. - expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes|auto'); + expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|copilot|agy|gemini|openclaw|hermes|auto'); expect(setupContent).toMatch(/^ {2}slate\)/m); }); diff --git a/test/host-config.test.ts b/test/host-config.test.ts index a7155b8bb..e05ef8ed6 100644 --- a/test/host-config.test.ts +++ b/test/host-config.test.ts @@ -24,6 +24,7 @@ import { cursor, openclaw, agy, + gemini, } from '../hosts/index'; import { HOST_PATHS } from '../scripts/resolvers/types'; import { RESOLVERS } from '../scripts/resolvers'; @@ -37,8 +38,8 @@ const RESOLVER_NAMES = new Set(Object.keys(RESOLVERS)); // ─── hosts/index.ts ───────────────────────────────────────── describe('hosts/index.ts', () => { - test('ALL_HOST_CONFIGS has 12 hosts', () => { - expect(ALL_HOST_CONFIGS.length).toBe(12); + test('ALL_HOST_CONFIGS has 13 hosts', () => { + expect(ALL_HOST_CONFIGS.length).toBe(13); }); test('ALL_HOST_NAMES matches config names', () => { @@ -61,6 +62,7 @@ describe('hosts/index.ts', () => { expect(cursor.name).toBe('cursor'); expect(openclaw.name).toBe('openclaw'); expect(agy.name).toBe('agy'); + expect(gemini.name).toBe('gemini'); }); test('getHostConfig returns correct config', () => { @@ -83,6 +85,7 @@ describe('hosts/index.ts', () => { expect(resolveHostArg('agents')).toBe('codex'); expect(resolveHostArg('droid')).toBe('factory'); expect(resolveHostArg('antigravity')).toBe('agy'); + expect(resolveHostArg('gemini-cli')).toBe('gemini'); }); test('resolveHostArg throws on unknown alias', () => { @@ -514,10 +517,11 @@ describe('golden-file regression', () => { // ─── Individual host config correctness ───────────────────── describe('host config correctness', () => { - test('Codex renders the GPT overlay, Antigravity the Gemini overlay, every other host the Claude overlay', () => { + test('Codex renders the GPT overlay, Antigravity and Gemini CLI the Gemini overlay, every other host the Claude overlay', () => { expect(codex.defaultModel).toBe('gpt'); expect(agy.defaultModel).toBe('gemini'); - for (const host of ALL_HOST_CONFIGS.filter(h => h.name !== 'codex' && h.name !== 'agy')) { + expect(gemini.defaultModel).toBe('gemini'); + for (const host of ALL_HOST_CONFIGS.filter(h => !['codex', 'agy', 'gemini'].includes(h.name))) { expect(host.defaultModel).toBe('claude'); } }); @@ -811,6 +815,50 @@ describe('Antigravity CLI host render', () => { }); }); +describe('Gemini CLI host render', () => { + const OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gemini-')); + beforeAll(() => { + const r = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'gemini', '--out-dir', OUT], { cwd: ROOT, timeout: 120_000 }); + if (r.exitCode !== 0) throw new Error(r.stderr.toString()); + }); + afterAll(() => fs.rmSync(OUT, { recursive: true, force: true })); + const dir = path.join(OUT, '.gemini-cli', 'skills'); + const read = (skill: string) => fs.readFileSync(path.join(dir, skill, 'SKILL.md'), 'utf8'); + + test('paths name the Gemini CLI install, never Claude, Codex or Antigravity skill dirs', () => { + for (const skill of ['gstack-review', 'gstack-ship', 'gstack-careful', 'gstack-upgrade']) { + const md = readSkillStartup(path.join(dir, skill, 'SKILL.md')); + expect(md).not.toContain('~/.claude/skills/gstack'); + expect(md).not.toContain('$HOME/.claude/skills/gstack'); + expect(md).not.toContain('.codex/skills'); + expect(md).not.toContain('antigravity-cli/skills'); + } + expect(read('gstack-review')).toContain('_r=~/.gemini/skills/gstack'); + expect(read('gstack-upgrade')).toContain('$HOME/.gemini/skills/gstack/.source-path'); + expect(read('gstack-upgrade')).toContain('./setup --host gemini --refresh-registered'); + }); + + test('the tool-name glossary names Gemini CLI 0.66.0 tools and Antigravity\'s, ahead of the preamble STATUS rules', () => { + const md = read('gstack-review'); + const glossary = md.indexOf('**Gemini CLI tool names:**'); + expect(glossary).toBeGreaterThan(-1); + expect(glossary).toBeLessThan(md.indexOf('Read the echoed `KEY: value` STATUS lines')); + const line = md.slice(glossary, md.indexOf('\n', glossary)); + expectTokens(line, ['`ask_user`', '`run_shell_command`', '`read_file`', '`write_file`', '`replace`', '`grep_search`', '`glob`', '`invoke_agent`', '`exit_plan_mode`', '`activate_skill`']); + expectTokens(line, ['Antigravity', '`run_command`', '`view_file`', '`ask_question`']); + }); + + test('frontmatter keeps only name and description, and every name equals its directory', () => { + const names = fs.readdirSync(dir).filter(d => fs.existsSync(path.join(dir, d, 'SKILL.md'))); + expect(names.length).toBeGreaterThan(20); + for (const name of names) { + const fm = read(name).split('\n---\n')[0]; + expect(fm.match(/^name:\s*(\S+)/m)![1]).toBe(name); + expect(fm.split('\n').filter(l => /^[a-z-]+:/.test(l)).map(l => l.split(':')[0])).toEqual(['name', 'description']); + } + }); +}); + describe('host renders name the host\'s own tools and identities (#2626, #2015, #2825, #2338)', () => { const OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-host-tools-')); beforeAll(() => { diff --git a/test/host-conformance.test.ts b/test/host-conformance.test.ts index ca136f179..56d6bb725 100644 --- a/test/host-conformance.test.ts +++ b/test/host-conformance.test.ts @@ -31,9 +31,10 @@ const DISCOVERY: Record = { cursor: '.cursor/skills', copilot: '.copilot/skills', agy: '.gemini/antigravity-cli/skills', + gemini: '.gemini/skills', }; -/** Every directory any host (or a shared agents layout) reads; .gemini/skills is Gemini CLI's. */ -const ALL_DISCOVERY_DIRS = [...Object.values(DISCOVERY), '.agents/skills', '.copilot/skills', '.hermes/skills', '.gemini/skills']; +/** Every directory any host (or a shared agents layout) reads. */ +const ALL_DISCOVERY_DIRS = [...Object.values(DISCOVERY), '.agents/skills', '.hermes/skills']; /** Harness hosts (install.registration 'harness') register through gstack-harness: test/harness-register.test.ts certifies them. */ const installable = ALL_HOST_CONFIGS.filter(c => c.tier !== 'instruction-only' && c.install.registration !== 'harness').map(c => c.name); diff --git a/test/host-renders-prune.test.ts b/test/host-renders-prune.test.ts index 436e2c2d5..0cbe6995b 100644 --- a/test/host-renders-prune.test.ts +++ b/test/host-renders-prune.test.ts @@ -17,7 +17,7 @@ import { runGeneration } from '../scripts/gen-skill-docs'; import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, put, runSetup, type Fixture } from './helpers/install-fixture'; const ROOT = path.resolve(import.meta.dir, '..'); -const OTHER_DIRS = ['.agents', '.factory', '.kiro', '.opencode', '.cursor', '.copilot', '.agy', '.slate', '.openclaw', '.hermes', '.gbrain']; +const OTHER_DIRS = ['.agents', '.factory', '.kiro', '.opencode', '.cursor', '.copilot', '.agy', '.gemini-cli', '.slate', '.openclaw', '.hermes', '.gbrain']; afterEach(cleanupFixtures); afterAll(cleanupSeed); @@ -68,7 +68,7 @@ describe.skipIf(process.platform === 'win32')('setup prunes unrecorded host rend expect(fs.existsSync(path.join(f.dir, 'outside'))).toBe(true); expect(fs.existsSync(path.join(src, '.cursor/skills/gstack/bin'))).toBe(false); expect(fs.existsSync(path.join(src, 'bin/gstack-config'))).toBe(true); - for (const dir of ['.agents', '.opencode', '.copilot', '.agy', '.slate', '.openclaw', '.hermes', '.gbrain']) expect(fs.existsSync(path.join(src, dir)), dir).toBe(false); + for (const dir of ['.agents', '.opencode', '.copilot', '.agy', '.gemini-cli', '.slate', '.openclaw', '.hermes', '.gbrain']) expect(fs.existsSync(path.join(src, dir)), dir).toBe(false); expect(r.stdout).toContain('kept .factory/notes.md: not proven generated (no gstack banner or byte match)'); expect(r.stdout).toContain('kept .kiro/skills/my-skill/SKILL.md: not proven generated'); diff --git a/test/plan-mode-preamble-render.test.ts b/test/plan-mode-preamble-render.test.ts index 24bedde3f..60d10c400 100644 --- a/test/plan-mode-preamble-render.test.ts +++ b/test/plan-mode-preamble-render.test.ts @@ -49,7 +49,7 @@ describe('every installed-skill render carries the plan-mode arg', () => { test('setup: a build (which renders without the arg) does not skip the host re-renders when the key is off', () => { const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8'); const guards = setup.split('\n').filter(line => /^if \[ "\$INSTALL_[A-Z]+" -eq 1 \] && .*NEEDS_BUILD/.test(line)); - expect(guards.length).toBe(5); + expect(guards.length).toBe(6); for (const line of guards) expect(line, line).toContain('{ [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MODE_ARG" ]; }'); }); diff --git a/test/runtime-root-assets.test.ts b/test/runtime-root-assets.test.ts index 31d3bea91..d18eed0be 100644 --- a/test/runtime-root-assets.test.ts +++ b/test/runtime-root-assets.test.ts @@ -37,6 +37,7 @@ const ROOT_BUILDERS: Record = { cursor: 'create_cursor_runtime_root', copilot: 'create_copilot_runtime_root', agy: 'create_agy_runtime_root', + gemini: 'create_gemini_runtime_root', }; // Deferred, with the reason recorded in TODOS.md: /ship's measure loop runs diff --git a/test/setup-install-registry.test.ts b/test/setup-install-registry.test.ts index d4dada624..09d8bdfea 100644 --- a/test/setup-install-registry.test.ts +++ b/test/setup-install-registry.test.ts @@ -191,20 +191,25 @@ describe.skipIf(process.platform === 'win32')('Antigravity CLI shares ~/.gemini skill: readFileSync(join(home, '.gemini/skills/my-skill/SKILL.md'), 'utf8'), }); - test.skipIf(Bun.which('agy') !== null)('auto-detect ignores a Gemini-CLI-only ~/.gemini and selects agy by its own config dir', () => { + // A machine with gemini or agy installed must not change what auto selects. + const pinnedPath = (f: { commands: string }) => ({ + PATH: [f.commands, ...(process.env.PATH ?? '').split(':').filter(d => d && !['gemini', 'agy'].some(bin => existsSync(join(d, bin))))].join(':'), + }); + + test('auto-detect ignores a Gemini-CLI-only ~/.gemini and selects agy by its own config dir', () => { const f = makeFixture(); const src = makeSource(f, join(f.home, 'gstack')); put(join(f.home, '.gemini/settings.json'), '{"theme":"mine"}\n'); put(join(f.home, '.gemini/skills/my-skill/SKILL.md'), '---\nname: my-skill\ndescription: mine\n---\n'); const before = geminiCliFiles(f.home); - const geminiOnly = runSetup(f, join(src, 'setup'), ['--host', 'auto']); + const geminiOnly = runSetup(f, join(src, 'setup'), ['--host', 'auto'], { env: pinnedPath(f) }); expect(geminiOnly.status, geminiOnly.stdout + geminiOnly.stderr).toBe(0); expect(existsSync(join(f.home, '.gemini/antigravity-cli'))).toBe(false); expect(registryRows(f).map(row => row[0])).not.toContain('agy'); mkdirSync(join(f.home, '.gemini/antigravity-cli'), { recursive: true }); - const withAgy = runSetup(f, join(src, 'setup'), ['--host', 'auto']); + const withAgy = runSetup(f, join(src, 'setup'), ['--host', 'auto'], { env: pinnedPath(f) }); expect(withAgy.status, withAgy.stdout + withAgy.stderr).toBe(0); expect(existsSync(join(f.home, '.gemini/antigravity-cli/skills/gstack-review/SKILL.md'))).toBe(true); expect(registryRows(f).map(row => row[0])).toContain('agy'); @@ -236,6 +241,88 @@ describe.skipIf(process.platform === 'win32')('Antigravity CLI shares ~/.gemini expect(geminiCliFiles(f.home)).toEqual(before); expect(registryRows(f)).toEqual([]); }, 120_000); + + test('auto selects Gemini CLI only for gemini on PATH, never for a bare ~/.gemini', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, 'gstack')); + put(join(f.home, '.gemini/settings.json'), '{"theme":"mine"}\n'); + put(join(f.home, '.gemini/skills/my-skill/SKILL.md'), '---\nname: my-skill\ndescription: mine\n---\n'); + const before = geminiCliFiles(f.home); + + const bare = runSetup(f, join(src, 'setup'), ['--host', 'auto'], { env: pinnedPath(f) }); + expect(bare.status, bare.stdout + bare.stderr).toBe(0); + expect(registryRows(f).map(row => row[0])).not.toContain('gemini'); + expect(readdirSync(join(f.home, '.gemini/skills'))).toEqual(['my-skill']); + + put(join(f.commands, 'gemini'), '#!/bin/sh\nexit 0\n', 0o755); + const onPath = runSetup(f, join(src, 'setup'), ['--host', 'auto'], { env: pinnedPath(f) }); + expect(onPath.status, onPath.stdout + onPath.stderr).toBe(0); + expect(registryRows(f).filter(row => row[0] === 'gemini').map(row => row[3])).toEqual([join(f.home, '.gemini/skills')]); + expect(existsSync(join(f.home, '.gemini/skills/gstack-review/SKILL.md'))).toBe(true); + expect(existsSync(join(f.home, '.gemini/antigravity-cli'))).toBe(false); + expect(geminiCliFiles(f.home)).toEqual(before); + }, 120_000); + + test('--host gemini-cli (alias) writes only gstack entries in ~/.gemini/skills; uninstall removes only those', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, 'gstack')); + put(join(f.home, '.gemini/settings.json'), '{"theme":"mine"}\n'); + put(join(f.home, '.gemini/skills/my-skill/SKILL.md'), '---\nname: my-skill\ndescription: mine\n---\n'); + put(join(f.home, '.gemini/skills/gstack-notes/SKILL.md'), '---\nname: gstack-notes\ndescription: my notes\n---\n'); + put(join(f.home, '.gemini/antigravity-cli/settings.json'), '{"agentMode":"plan"}\n'); + const before = { ...geminiCliFiles(f.home), notes: readFileSync(join(f.home, '.gemini/skills/gstack-notes/SKILL.md'), 'utf8') }; + const agyBefore = tree(join(f.home, '.gemini/antigravity-cli')); + + const r = runSetup(f, join(src, 'setup'), ['--host', 'gemini-cli']); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain('gstack ready (gemini)'); + expect(registryRows(f).map(row => [row[0], row[3]])).toEqual([['gemini', join(f.home, '.gemini/skills')]]); + expect(readFileSync(join(f.home, '.gemini/skills/gstack/.source-path'), 'utf8').trim()).toBe(src); + expect(readFileSync(join(src, '.gstack-installed-hosts'), 'utf8').split('\n')).toContain('gemini'); + expect(existsSync(join(src, '.gemini-cli/skills/gstack-review/SKILL.md'))).toBe(true); + const entries = readdirSync(join(f.home, '.gemini/skills')); + expect(entries.filter(n => !n.startsWith('gstack'))).toEqual(['my-skill']); + expect(entries).toContain('gstack-review'); + expect({ ...geminiCliFiles(f.home), notes: readFileSync(join(f.home, '.gemini/skills/gstack-notes/SKILL.md'), 'utf8') }).toEqual(before); + expect(tree(join(f.home, '.gemini/antigravity-cli'))).toEqual(agyBefore); + + const un = spawnSync('bash', [join(src, 'bin/gstack-uninstall'), '--force', '--keep-state'], { cwd: f.home, env: f.env, encoding: 'utf8', timeout: 30_000 }); + expect(un.status, un.stderr).toBe(0); + expect(readdirSync(join(f.home, '.gemini/skills')).sort()).toEqual(['gstack-notes', 'my-skill']); + expect({ ...geminiCliFiles(f.home), notes: readFileSync(join(f.home, '.gemini/skills/gstack-notes/SKILL.md'), 'utf8') }).toEqual(before); + expect(tree(join(f.home, '.gemini/antigravity-cli'))).toEqual(agyBefore); + expect(registryRows(f)).toEqual([]); + }, 120_000); + + test('--local --host gemini is refused with the trusted-folder note and writes no skills', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, 'gstack')); + const r = runSetup(f, join(src, 'setup'), ['--local', '--host', 'gemini']); + expect(r.status).toBe(1); + expect(r.stderr).toContain('Error: --local is only supported for Claude Code (not gemini).'); + expect(r.stderr).toContain('Gemini CLI loads workspace skills (.gemini/skills) only in trusted folders (/trust)'); + expect(existsSync(join(f.home, '.gemini'))).toBe(false); + expect(existsSync(join(f.home, '.claude/skills'))).toBe(false); + expect(r.stdout).not.toContain('gstack ready'); + }, 60_000); + + test('gemini and agy installed together: each arm writes only its own directory', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, 'gstack')); + expect(runSetup(f, join(src, 'setup'), ['--host', 'gemini']).status).toBe(0); + const geminiTree = tree(join(f.home, '.gemini/skills')); + const agy = runSetup(f, join(src, 'setup'), ['--host', 'agy']); + expect(agy.status, agy.stdout + agy.stderr).toBe(0); + expect(tree(join(f.home, '.gemini/skills'))).toEqual(geminiTree); + const agyTree = tree(join(f.home, '.gemini/antigravity-cli')); + const again = runSetup(f, join(src, 'setup'), ['--host', 'gemini']); + expect(again.status, again.stdout + again.stderr).toBe(0); + expect(tree(join(f.home, '.gemini/antigravity-cli'))).toEqual(agyTree); + expect(registryRows(f).map(row => [row[0], row[3]]).sort()).toEqual([ + ['agy', join(f.home, '.gemini/antigravity-cli/skills')], ['gemini', join(f.home, '.gemini/skills')]]); + expect(readFileSync(join(f.home, '.gemini/skills/gstack-review/SKILL.md'), 'utf8')).toContain('**Gemini CLI tool names:**'); + expect(readFileSync(join(f.home, '.gemini/antigravity-cli/skills/gstack-review/SKILL.md'), 'utf8')).toContain('**Antigravity tool names:**'); + }, 180_000); }); describe('install registry helper', () => { diff --git a/test/setup-production-deps.test.ts b/test/setup-production-deps.test.ts index 9664d568f..5289b4e13 100644 --- a/test/setup-production-deps.test.ts +++ b/test/setup-production-deps.test.ts @@ -40,6 +40,23 @@ function sliceFunction(source: string, name: string): string { return source.slice(start + 1, end + 3); } +/** install_runtime_deps and the helpers it calls, sliced out of setup in definition order. */ +function depsFunctions(setup: string): string { + const names = ['bun_cmd', '_deps_role', '_deps_has_dev', '_deps_verified', '_deps_lock', '_deps_unlock', '_deps_recover', + '_deps_migration_start', '_deps_migration_finish', 'install_runtime_deps']; + return names.map((n) => (n === '_deps_unlock' ? setup.split('\n').find((l) => l.startsWith('_deps_unlock() {'))! + '\n' : sliceFunction(setup, n))).join('\n'); +} + +/** A git repository marked as a contributor's checkout (what bin/dev-setup writes). */ +function markContributor(dir: string): void { + gitInit(dir); + spawnSync('git', ['-C', dir, 'config', '--local', 'gstack.contributor', 'true'], { timeout: 10_000 }); +} + +function gitInit(dir: string): void { + spawnSync('git', ['init', '-q', dir], { timeout: 10_000 }); +} + /** * A bun that records each argv line. Like Bun, a --frozen-lockfile install fails * ($FROZEN_RC) until `install --lockfile-only` has refreshed the lockfile; any other @@ -56,7 +73,7 @@ function stubBun(base: string): { bun: string; calls: () => string[] } { describe.skipIf(process.platform === 'win32')('setup installs production dependencies only', () => { const setup = read('setup'); const script = (bun: string, call: string) => - `set -e\nBUN_CMD=${JSON.stringify(bun)}\n${sliceFunction(setup, 'bun_cmd')}\n${sliceFunction(setup, 'install_runtime_deps')}\n${call}\n`; + `set -e\nBUN_CMD=${JSON.stringify(bun)}\n${depsFunctions(setup)}\n${call}\n`; test('install_runtime_deps passes --production with the frozen lockfile, and refreshes a stale lockfile before installing production-only again', () => { const base = tempBase(); @@ -81,17 +98,20 @@ describe.skipIf(process.platform === 'win32')('setup installs production depende expect(stub3.calls()).toEqual(['install --production --frozen-lockfile', 'install --lockfile-only']); }); - // Value: protects=a contributor checkout (dev toolchain present) keeps its dev packages at bun.lock's versions after ./setup; + // Value: protects=a contributor checkout (marked, dev toolchain present) keeps its dev packages at bun.lock's versions after ./setup; // fails_when=setup runs the production install there, which Bun reports as "no changes" and leaves old dev versions; // why_new=every other row starts without node_modules; seam=none; no_claim=Bun's own update behavior (stub bun) - test('a checkout with the dev toolchain gets the full frozen install, falling back to a plain install', () => { + // #3447: the contributor is the checkout marked with git config gstack.contributor, not any checkout that has typescript. + test('a marked contributor checkout gets the full frozen install, falling back to a plain install', () => { const base = tempBase(); + markContributor(base); fs.mkdirSync(path.join(base, 'node_modules', 'typescript'), { recursive: true }); const { bun, calls } = stubBun(base); expect(runBashScript(script(bun, 'install_runtime_deps'), { cwd: base, env: { ...process.env, FROZEN_RC: '0' } }).status).toBe(0); expect(calls()).toEqual(['install --frozen-lockfile']); const base2 = tempBase(); + markContributor(base2); fs.mkdirSync(path.join(base2, 'node_modules', 'typescript'), { recursive: true }); const stub2 = stubBun(base2); expect(runBashScript(script(stub2.bun, 'install_runtime_deps'), { cwd: base2, env: { ...process.env, FROZEN_RC: '1' } }).status).toBe(0); @@ -103,6 +123,7 @@ describe.skipIf(process.platform === 'win32')('setup installs production depende // why_new=both rows above succeed, and the failure row in the test above runs the production branch; seam=none; // no_claim=setup's own subshell wiring around the call (stub bun) const base3 = tempBase(); + markContributor(base3); fs.mkdirSync(path.join(base3, 'node_modules', 'typescript'), { recursive: true }); const stub3 = stubBun(base3); const failed = runBashScript(script(stub3.bun, 'install_runtime_deps\necho AFTER'), { cwd: base3, env: { ...process.env, FROZEN_RC: '1', FALLBACK_RC: '4' } }); @@ -150,6 +171,25 @@ describe.skipIf(process.platform === 'win32')('setup installs production depende const r = runBashScript(`set -e\nREPO_ROOT=${JSON.stringify(base)}\nbun() { ${JSON.stringify(bun)} "$@"; }\n${block}`); expect(r.status).toBe(0); expect(calls()).toEqual(['install']); + // #3447: outside git the marker cannot be written; dev-setup says how to mark the run instead and still installs. + expect(r.stderr).toContain('GSTACK_CONTRIBUTOR=1'); + }); + + // Value: protects=a contributor's clone keeps its dev toolchain on every later ./setup; + // fails_when=bin/dev-setup stops writing the marker install_runtime_deps reads; why_new=#3447 adds the marker; seam=none; no_claim=none + test('bin/dev-setup marks the clone as a contributor checkout', () => { + const devSetup = read('bin/dev-setup'); + const start = devSetup.indexOf('# 2. Install dependencies'); + const block = devSetup.slice(start, devSetup.indexOf('# 3.', start)); + const base = tempBase(); + gitInit(base); + const { bun } = stubBun(base); + const r = runBashScript(`set -e\nREPO_ROOT=${JSON.stringify(base)}\nbun() { ${JSON.stringify(bun)} "$@"; }\n${block}`, { env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' } }); + expect(r.status, r.stderr).toBe(0); + const marker = spawnSync('git', ['-C', base, 'config', '--bool', '--get', 'gstack.contributor'], { encoding: 'utf-8', timeout: 10_000 }); + expect(marker.stdout.trim()).toBe('true'); + const role = runBashScript(`${sliceFunction(read('setup'), '_deps_role')}\n_deps_role\n`, { cwd: base, env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' } }); + expect(role.stdout.trim()).toBe('contributor'); }); }); @@ -174,7 +214,7 @@ describe.skipIf(process.platform === 'win32')('install_runtime_deps with the rea const env = { ...isolatedBunEnv(base), BUN_INSTALL_CACHE_DIR: path.join(base, 'cache') }; const setup = read('setup'); const run = (call: string) => runBashScript( - `set -e\nBUN_CMD=${JSON.stringify(process.execPath)}\n${sliceFunction(setup, 'bun_cmd')}\n${sliceFunction(setup, 'install_runtime_deps')}\n${call}\n`, + `set -e\nBUN_CMD=${JSON.stringify(process.execPath)}\n${depsFunctions(setup)}\n${call}\n`, { cwd: project, env, timeout: 90_000 }); const has = (name: string) => fs.existsSync(path.join(project, 'node_modules', name, 'package.json')); @@ -214,7 +254,7 @@ describe.skipIf(process.platform === 'win32')('install_runtime_deps with the rea const env = { ...isolatedBunEnv(base), BUN_INSTALL_CACHE_DIR: path.join(base, 'cache') }; const setup = read('setup'); const run = (call: string) => runBashScript( - `set -e\nBUN_CMD=${JSON.stringify(process.execPath)}\n${sliceFunction(setup, 'bun_cmd')}\n${sliceFunction(setup, 'install_runtime_deps')}\n${call}\n`, + `set -e\nBUN_CMD=${JSON.stringify(process.execPath)}\n${depsFunctions(setup)}\n${call}\n`, { cwd: project, env, timeout: 90_000 }); const has = (name: string) => fs.existsSync(path.join(project, 'node_modules', name, 'package.json')); @@ -229,6 +269,230 @@ describe.skipIf(process.platform === 'win32')('install_runtime_deps with the rea }); }); +// #3447: installs made before v1.91.231.0 ran a full bun install, so they carry the dev +// toolchain, and the old rule ("node_modules/typescript means contributor") kept them on it +// forever. The contributor is now the checkout marked with git config gstack.contributor +// (bin/dev-setup) or GSTACK_CONTRIBUTOR=1; every other checkout with dev packages migrates once. +describe.skipIf(process.platform === 'win32')('install_runtime_deps moves older installs to production dependencies', () => { + const setup = read('setup'); + const SDK = '@anthropic-ai/claude-agent-sdk'; + + /** A user checkout as an install made before v1.91.231.0 leaves it: a git clone with the full dev toolchain. */ + function oldInstall(opts: { git?: boolean } = {}): string { + const base = tempBase(); + if (opts.git !== false) gitInit(base); + fs.writeFileSync(path.join(base, 'package.json'), JSON.stringify({ name: 'fixture', dependencies: { 'runtime-pkg': '1.0.0' } }, null, 2) + '\n'); + fs.writeFileSync(path.join(base, 'bun.lock'), `{\n "packages": {\n "${SDK}": ["${SDK}@0.2.141", "", {}],\n }\n}\n`); + for (const dir of ['typescript', SDK, 'runtime-pkg']) { + fs.mkdirSync(path.join(base, 'node_modules', dir), { recursive: true }); + fs.writeFileSync(path.join(base, 'node_modules', dir, 'package.json'), '{}'); + } + return base; + } + + /** + * A bun for the migration rows: records argv; a --production install recreates node_modules with + * runtime-pkg unless PROD_RC is nonzero (or PROD_SKIP_DEPS=1 leaves it out); `pm cache` prints + * $CACHE_DIR; any other install exits 0. + */ + function migrateBun(base: string): { bun: string; calls: () => string[] } { + const log = path.join(base, '.calls'); + const bun = path.join(base, '.bun-stub'); + fs.writeFileSync(bun, `#!/usr/bin/env bash +echo "$*" >> ${JSON.stringify(log)} +case "$*" in + "pm cache") echo "\${CACHE_DIR:-}"; exit 0 ;; + *--production*) + [ "\${PROD_RC:-0}" -ne 0 ] && { echo "error: network down" >&2; exit "\${PROD_RC}"; } + mkdir -p node_modules + [ "\${PROD_SKIP_DEPS:-0}" = 1 ] || { mkdir -p node_modules/runtime-pkg && echo '{}' > node_modules/runtime-pkg/package.json; } + exit 0 ;; +esac +exit 0 +`, { mode: 0o755 }); + return { bun, calls: () => (fs.existsSync(log) ? fs.readFileSync(log, 'utf-8').trim().split('\n').filter((l) => l !== 'pm cache') : []) }; + } + + const run = (base: string, bun: string, env: Record = {}) => { + const clean = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'CI' && k !== 'GSTACK_CONTRIBUTOR')); + return runBashScript(`set -e\nBUN_CMD=${JSON.stringify(bun)}\n${depsFunctions(setup)}\ninstall_runtime_deps\n`, + { cwd: base, env: { ...clean, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', ...env } }); + }; + const has = (base: string, dir: string) => fs.existsSync(path.join(base, 'node_modules', dir)); + const leftovers = (base: string) => ['node_modules.gstack-migrate', 'node_modules.gstack-migrate.lock'].filter((p) => fs.existsSync(path.join(base, p))); + + // Value: protects=an install made before v1.91.231.0 drops its ~520 MB of dev packages on the next ./setup; + // fails_when=setup keeps treating node_modules/typescript as a contributor marker (the pre-#3447 rule); + // why_new=no row ran an unmarked checkout that already has the dev toolchain; seam=none; no_claim=real Bun (stub bun) + test('an unmarked checkout with the dev toolchain migrates to the production install once', () => { + const base = oldInstall(); + const { bun, calls } = migrateBun(base); + const r = run(base, bun); + expect(r.status, r.stderr).toBe(0); + expect(calls()).toEqual(['install --production --frozen-lockfile']); + expect([has(base, 'typescript'), has(base, SDK), has(base, 'runtime-pkg')]).toEqual([false, false, true]); + expect(leftovers(base)).toEqual([]); + expect(r.stdout).toContain('gstack: moved this install to production dependencies'); + expect(r.stdout).toContain('git config gstack.contributor true, then ./setup again'); + + const again = run(base, bun); + expect(again.status, again.stderr).toBe(0); + expect(again.stdout).not.toContain('moved this install'); + expect(calls()).toEqual(['install --production --frozen-lockfile', 'install --production --frozen-lockfile']); + }); + + test('a contributor, a CI run and a checkout whose role git cannot read keep the dev toolchain', () => { + const marked = oldInstall(); + spawnSync('git', ['-C', marked, 'config', '--local', 'gstack.contributor', 'true'], { timeout: 10_000 }); + const envOnly = oldInstall(); + const ci = oldInstall(); + const unreadable = oldInstall(); + const fakeGit = path.join(unreadable, '.fake-bin'); + fs.mkdirSync(fakeGit); + fs.writeFileSync(path.join(fakeGit, 'git'), '#!/usr/bin/env bash\necho "fatal: detected dubious ownership" >&2\nexit 128\n', { mode: 0o755 }); + for (const [base, env] of [[marked, {}], [envOnly, { GSTACK_CONTRIBUTOR: '1' }], [ci, { CI: 'true' }], [unreadable, { PATH: `${fakeGit}:${process.env.PATH}` }]] as const) { + const { bun, calls } = migrateBun(base); + const r = run(base, bun, env); + expect(r.status, r.stderr).toBe(0); + expect(calls()).toEqual(['install --frozen-lockfile']); + expect([has(base, 'typescript'), has(base, SDK)]).toEqual([true, true]); + expect(leftovers(base)).toEqual([]); + } + const marker = spawnSync('git', ['-C', marked, 'config', '--bool', 'gstack.contributor', 'false'], { timeout: 10_000 }); + expect(marker.status).toBe(0); + const { bun } = migrateBun(marked); + expect(run(marked, bun).status).toBe(0); + expect(has(marked, 'typescript')).toBe(false); + }); + + test('a fresh CI checkout still gets the production install', () => { + const base = tempBase(); + gitInit(base); + fs.writeFileSync(path.join(base, 'package.json'), JSON.stringify({ name: 'fixture', dependencies: { 'runtime-pkg': '1.0.0' } }, null, 2) + '\n'); + const { bun, calls } = migrateBun(base); + expect(run(base, bun, { CI: 'true' }).status).toBe(0); + expect(calls()).toEqual(['install --production --frozen-lockfile']); + }); + + // Value: protects=an offline upgrade never leaves a user without node_modules; + // fails_when=the migration deletes the old node_modules before the production install is proven, or swallows the failure; + // why_new=the success row cannot fail the install; seam=none; no_claim=real network failures (stub bun) + test('a failed or unverified production install puts the full install back and says why', () => { + for (const env of [{ PROD_RC: '7' }, { PROD_SKIP_DEPS: '1' }] as Record[]) { + const base = oldInstall(); + const { bun, calls } = migrateBun(base); + const r = run(base, bun, env); + expect(r.status, r.stderr).toBe(0); + expect([has(base, 'typescript'), has(base, SDK), has(base, 'runtime-pkg')]).toEqual([true, true, true]); + expect(leftovers(base)).toEqual([]); + expect(r.stderr).toContain('SETUP_DEPS_MIGRATE_FAILED'); + expect(r.stderr).toContain('Nothing was removed'); + expect(calls().at(-1)).toBe('install --frozen-lockfile'); + } + const offline = oldInstall(); + expect(run(offline, migrateBun(offline).bun, { PROD_RC: '7' }).stderr).toContain('error: network down'); + }); + + test('a backup left by an interrupted migration is recovered on the next run', () => { + const moved = oldInstall(); + fs.renameSync(path.join(moved, 'node_modules'), path.join(moved, 'node_modules.gstack-migrate')); + const stub = migrateBun(moved); + expect(run(moved, stub.bun).status).toBe(0); + expect([has(moved, 'typescript'), has(moved, 'runtime-pkg')]).toEqual([false, true]); + expect(leftovers(moved)).toEqual([]); + + const finished = oldInstall(); + fs.renameSync(path.join(finished, 'node_modules'), path.join(finished, 'node_modules.gstack-migrate')); + fs.mkdirSync(path.join(finished, 'node_modules', 'runtime-pkg'), { recursive: true }); + fs.writeFileSync(path.join(finished, 'node_modules', 'runtime-pkg', 'package.json'), '{}'); + expect(run(finished, migrateBun(finished).bun).status).toBe(0); + expect(has(finished, 'typescript')).toBe(false); + expect(leftovers(finished)).toEqual([]); + }); + + test('a live migration lock skips the migration; a dead holder is reclaimed', () => { + const busy = oldInstall(); + fs.mkdirSync(path.join(busy, 'node_modules.gstack-migrate.lock')); + fs.writeFileSync(path.join(busy, 'node_modules.gstack-migrate.lock', 'pid'), String(process.pid)); + const b = migrateBun(busy); + expect(run(busy, b.bun).status).toBe(0); + expect(b.calls()).toEqual(['install --frozen-lockfile']); + expect(has(busy, 'typescript')).toBe(true); + + const stale = oldInstall(); + fs.mkdirSync(path.join(stale, 'node_modules.gstack-migrate.lock')); + fs.writeFileSync(path.join(stale, 'node_modules.gstack-migrate.lock', 'pid'), '2147483646'); + expect(run(stale, migrateBun(stale).bun).status).toBe(0); + expect(has(stale, 'typescript')).toBe(false); + expect(leftovers(stale)).toEqual([]); + }); + + // Value: protects=another project's install that reads the Agent SDK out of Bun's shared cache (Bun's symlink + // backend links each file there with no extra hardlink) keeps working after a gstack migration; + // fails_when=the migration deletes Bun cache entries; why_new=a review reproduced a symlink-backend consumer + // losing its package.json when cache entries with no extra hardlinks were deleted; seam=none; no_claim=Bun's cache layout + test('the migration never touches Bun\'s shared cache', () => { + const base = oldInstall(); + const cache = path.join(base, '.cache'); + const entry = path.join(cache, '@anthropic-ai', 'claude-agent-sdk-linux-x64@0.2.141@@@1'); + fs.mkdirSync(entry, { recursive: true }); + fs.writeFileSync(path.join(entry, 'cli.js'), 'x'); + const consumer = path.join(base, '.symlink-project', 'node_modules', 'sdk'); + fs.mkdirSync(consumer, { recursive: true }); + fs.symlinkSync(path.join(entry, 'cli.js'), path.join(consumer, 'cli.js')); + const { bun, calls } = migrateBun(base); + const r = run(base, bun, { CACHE_DIR: cache }); + expect(r.status, r.stderr).toBe(0); + expect(has(base, 'typescript')).toBe(false); + expect(calls()).toEqual(['install --production --frozen-lockfile']); + expect(fs.readFileSync(path.join(consumer, 'cli.js'), 'utf-8')).toBe('x'); + }); + + test('a checkout outside git with the dev toolchain is a user install', () => { + const base = oldInstall({ git: false }); + expect(run(base, migrateBun(base).bun).status).toBe(0); + expect(has(base, 'typescript')).toBe(false); + }); +}); + +// Value: protects=with the real Bun, an older full install loses its dev packages and a marked contributor keeps them; +// fails_when=the production install after the move resolves differently from a fresh one, or the marker read breaks; +// why_new=the migration rows stub bun; seam=none; no_claim=registry packages (local file: packages; offline) +describe.skipIf(process.platform === 'win32')('the migration with the real Bun', () => { + test('an unmarked full install loses its dev packages; a marked one keeps them across two runs', () => { + for (const marked of [false, true]) { + const base = tempBase(); + const project = path.join(base, 'project'); + for (const name of ['runtime-pkg', 'typescript']) { + fs.mkdirSync(path.join(project, 'pkgs', name), { recursive: true }); + fs.writeFileSync(path.join(project, 'pkgs', name, 'package.json'), JSON.stringify({ name, version: '1.0.0' })); + } + fs.writeFileSync(path.join(project, 'package.json'), JSON.stringify({ + name: 'migrate-fixture', version: '0.0.0', + dependencies: { 'runtime-pkg': 'file:./pkgs/runtime-pkg' }, + devDependencies: { typescript: 'file:./pkgs/typescript' }, + }, null, 2) + '\n'); + gitInit(project); + if (marked) spawnSync('git', ['-C', project, 'config', '--local', 'gstack.contributor', 'true'], { timeout: 10_000 }); + const env = { ...isolatedBunEnv(base), BUN_INSTALL_CACHE_DIR: path.join(base, 'cache'), GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' }; + delete (env as Record).CI; + delete (env as Record).GSTACK_CONTRIBUTOR; + const setup = read('setup'); + const runIt = (call: string) => runBashScript(`set -e\nBUN_CMD=${JSON.stringify(process.execPath)}\n${depsFunctions(setup)}\n${call}\n`, { cwd: project, env, timeout: 90_000 }); + const has = (name: string) => fs.existsSync(path.join(project, 'node_modules', name, 'package.json')); + const full = runIt('bun_cmd install'); + expect(full.status, full.stderr).toBe(0); + expect(has('typescript')).toBe(true); + for (let i = 0; i < 2; i++) { + const r = runIt('install_runtime_deps'); + expect(r.status, r.stderr).toBe(0); + expect([has('runtime-pkg'), has('typescript')]).toEqual([true, marked]); + } + expect(fs.existsSync(path.join(project, 'node_modules.gstack-migrate'))).toBe(false); + } + }); +}); + /** Bare package names a source file loads (import/require/dynamic import), type-only imports excluded. */ function importedPackages(text: string): string[] { const out = new Set(); @@ -339,14 +603,14 @@ describe('setup and runtime need only production dependencies', () => { }); // Generated by /ship Step 7 coverage audit (GSTA-97). - // Value: protects=a production-only install never creates the dev-toolchain marker install_runtime_deps reads, so a user's next ./setup stays production-only; + // Value: protects=a production-only install never creates a dev-toolchain package _deps_has_dev reads, so a user's next ./setup stays production-only; // fails_when=a production dependency, or a package it pulls in (required peers included), starts depending on the marker package, so every repeat ./setup goes full; // why_new=the real-Bun rows use local fixture packages, and nothing walks the repo's own bun.lock production closure; // seam=none; no_claim=Bun's actual hoisting or platform filtering (a static, name-based walk of bun.lock that counts every candidate entry) - test('the production closure in bun.lock never installs the dev-toolchain marker setup checks', () => { - const marker = sliceFunction(read('setup'), 'install_runtime_deps').match(/\[ -d node_modules\/([^\s\]]+) \]/)?.[1]; - expect(marker).toBeDefined(); - expect(PKG.devDependencies).toHaveProperty([marker!]); + test('the production closure in bun.lock never installs the dev-toolchain packages setup checks', () => { + const markers = [...sliceFunction(read('setup'), '_deps_has_dev').matchAll(/\[ -d node_modules\/([^\s\]]+) \]/g)].map((m) => m[1]); + expect(markers).toEqual(['typescript', '@anthropic-ai/claude-agent-sdk']); + for (const marker of markers) expect(PKG.devDependencies).toHaveProperty([marker]); type Lock = { workspaces: Record }>; packages: Record }; const closure = (lock: Lock): Set => { const seen = new Set(); @@ -369,14 +633,16 @@ describe('setup and runtime need only production dependencies', () => { const lock: Lock = JSON.parse(read('bun.lock').replace(/,(\s*[}\]])/g, '$1')); const production = closure(lock); expect(production.has('playwright-core')).toBe(true); - expect(production.has(marker!)).toBe(false); - // Negative controls: a required peer on the marker is caught, through a nested entry too; an optional peer is not. + for (const marker of markers) expect(production.has(marker), marker).toBe(false); + // Negative controls: a required peer on a marker is caught, through a nested entry too; an optional peer is not. const synthetic = (meta: Record): Lock => ({ workspaces: { '': { dependencies: { a: '1' } } }, packages: { a: ['a@1', '', { dependencies: { b: '1' } }, 'sha'], b: ['b@1', '', {}, 'sha'], 'a/b': ['b@2', '', meta, 'sha'] }, }); - expect(closure(synthetic({ peerDependencies: { [marker!]: '*' } })).has(marker!)).toBe(true); - expect(closure(synthetic({ peerDependencies: { [marker!]: '*' }, optionalPeers: [marker!] })).has(marker!)).toBe(false); + for (const marker of markers) { + expect(closure(synthetic({ peerDependencies: { [marker]: '*' } })).has(marker)).toBe(true); + expect(closure(synthetic({ peerDependencies: { [marker]: '*' }, optionalPeers: [marker] })).has(marker)).toBe(false); + } }); test('the dev toolchain stays out of the user install', () => { diff --git a/test/setup-runtime-root-guard.test.ts b/test/setup-runtime-root-guard.test.ts index 600d64a4e..a291b0afc 100644 --- a/test/setup-runtime-root-guard.test.ts +++ b/test/setup-runtime-root-guard.test.ts @@ -55,7 +55,7 @@ describe.skipIf(process.platform === 'win32')('setup never replaces a runtime ro test('a checkout nested inside a host runtime root is refused for every arm that writes a root', () => { for (const [host, root] of [['kiro', '.kiro/skills/gstack'], ['factory', '.factory/skills/gstack'], ['opencode', '.config/opencode/skills/gstack'], - ['copilot', '.copilot/skills/gstack'], ['agy', '.gemini/antigravity-cli/skills/gstack']] as const) { + ['copilot', '.copilot/skills/gstack'], ['agy', '.gemini/antigravity-cli/skills/gstack'], ['gemini', '.gemini/skills/gstack']] as const) { const f = makeFixture(); const src = makeSource(f, join(f.home, root, 'checkout')); const before = tree(join(f.home, root)); @@ -72,7 +72,7 @@ describe.skipIf(process.platform === 'win32')('setup never replaces a runtime ro const fn = setup.slice(setup.indexOf('_activate_runtime_root() {')); expect(fn.slice(0, fn.indexOf('\n}\n'))).toMatch(/_runtime_root_refused "\$\{_SETUP_ARM%%\t\*\}" "\$root" && return 1/); const arms = [...setup.matchAll(/_activate_runtime_root create_(\w+)_runtime_root/g)].map(m => m[1]); - expect([...new Set(arms)].sort()).toEqual(['agy', 'codex', 'copilot', 'cursor', 'factory', 'opencode']); + expect([...new Set(arms)].sort()).toEqual(['agy', 'codex', 'copilot', 'cursor', 'factory', 'gemini', 'opencode']); }); test('control: the canonical ~/.claude/skills/gstack install and a repeat ./setup succeed', () => { diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index 83d5e3926..2afaa641b 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -204,6 +204,46 @@ describe('gstack-uninstall', () => { expect(fs.existsSync(path.join(mockGitRoot, '.cursor', 'rules', 'keep.md'))).toBe(true); }); + // Value: protects=uninstall clears gstack's Gemini CLI skills from ~/.gemini/skills, a folder the user's own skills share; + // fails_when=the Gemini block is dropped, or loses the provenance gate and sweeps a user's gstack-prefixed skill; + // why_new=#1247 adds the Gemini CLI host; seam=none; no_claim=Gemini CLI itself (temp HOME fixture) + test('--force removes Gemini CLI gstack skills and leaves the user\'s own', () => { + const banner = '\n# x\n'; + const skills = path.join(mockHome, '.gemini', 'skills'); + const render = path.join(tmpDir, 'render', 'gstack-review'); + fs.mkdirSync(render, { recursive: true }); + fs.writeFileSync(path.join(render, 'SKILL.md'), banner); + fs.mkdirSync(path.join(skills, 'gstack'), { recursive: true }); + fs.writeFileSync(path.join(skills, 'gstack', 'SKILL.md'), banner); + fs.symlinkSync(render, path.join(skills, 'gstack-review')); + for (const [name, body] of [['gstack-my-notes', '# mine\n'], ['frontend-design', 'keep']]) { + fs.mkdirSync(path.join(skills, name), { recursive: true }); + fs.writeFileSync(path.join(skills, name, 'SKILL.md'), body); + } + fs.writeFileSync(path.join(mockHome, '.gemini', 'settings.json'), '{}'); + + const result = spawnSync('bash', [UNINSTALL, '--force'], { + stdio: 'pipe', + timeout: 30_000, + env: { + ...process.env, + HOME: mockHome, + GSTACK_DIR: path.join(mockHome, '.claude', 'skills', 'gstack'), + GSTACK_STATE_DIR: path.join(mockHome, '.gstack'), + }, + cwd: mockGitRoot, + }); + + expect(result.status).toBe(0); + expect(fs.existsSync(path.join(skills, 'gstack'))).toBe(false); + expect(fs.existsSync(path.join(skills, 'gstack-review'))).toBe(false); + expect(fs.existsSync(path.join(render, 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(skills, 'gstack-my-notes', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(skills, 'frontend-design', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(mockHome, '.gemini', 'settings.json'))).toBe(true); + expect(result.stderr.toString()).toContain('gstack-my-notes'); + }); + test("a user's own gstack-prefixed Cursor dir (no banner) survives and is listed", () => { // S5: the bare gstack* glob must not sweep a dir that merely starts // with "gstack" — provenance comes from the generated banner, and a