diff --git a/installer/.gitignore b/installer/.gitignore new file mode 100644 index 0000000000..f5d9e557bd --- /dev/null +++ b/installer/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tgz +.DS_Store diff --git a/installer/.npmignore b/installer/.npmignore new file mode 100644 index 0000000000..0f40c19554 --- /dev/null +++ b/installer/.npmignore @@ -0,0 +1,5 @@ +src/ +tsconfig.json +node_modules/ +*.tgz +.DS_Store diff --git a/installer/PUBLISHING.md b/installer/PUBLISHING.md new file mode 100644 index 0000000000..887d4988ca --- /dev/null +++ b/installer/PUBLISHING.md @@ -0,0 +1,57 @@ +# Publishing `@garrytan/gstack` + +This installer is designed to be published to the `@garrytan` npm scope. That scope is owned by [@garrytan](https://github.com/garrytan), so merging this PR by itself does not put it on npm — Garry (or a maintainer with scope access) needs to run `npm publish`. + +## Steps for Garry / scope owner + +```bash +cd installer +npm install +npm run build +npm publish --access public +``` + +Users can then run: + +```bash +npx @garrytan/gstack +``` + +## Testing before publish (non-scope-owners) + +If you want to verify `npx @garrytan/gstack` end-to-end without waiting for a publish, you can temporarily publish under your own npm scope: + +1. Change `"name"` in `installer/package.json` to your scope — e.g. `"@your-handle/gstack"`. +2. `npm login` +3. `npm publish --access public` +4. `npx @your-handle/gstack` + +Revert the `name` field before merging the PR. **Do not** commit a scope change — the upstream PR should land with `@garrytan/gstack`. + +## Version bumps + +The installer has its own `version` independent of the main `gstack` `VERSION` file. Bump `installer/package.json` when: + +- New command or flag added +- Existing command behavior changes +- A bug fix ships + +Follow semver: `0.x.y` while the API is still settling, `1.0.0` when the command surface is stable. + +## What the installer does at runtime + +The installer is a thin wrapper — it clones `https://github.com/garrytan/gstack.git` into `~/.claude/skills/gstack` and shells out to that repo's `./setup` script. So publishing a new installer version **does not** ship a new gstack — users always get the latest `main` branch of the main repo at install time. + +This means the installer rarely needs to change. The main reasons would be: + +- Host registry expands (new agent supported by gstack) +- `./setup` learns a new flag the installer wants to surface +- Bug in the installer itself + +## CI (future) + +Not wired yet. A reasonable path: + +- `installer/` gets its own workflow at `.github/workflows/installer-ci.yml` +- Runs `npm install`, `npm run build`, `npm pack` on PRs +- On release tag `installer-v*`, runs `npm publish` diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000000..5c5ee5c0e6 --- /dev/null +++ b/installer/README.md @@ -0,0 +1,83 @@ +# `@garrytan/gstack` — installer CLI + +Interactive installer for [gstack](https://github.com/garrytan/gstack), Garry Tan's Claude Code skill pack and workflow tooling. + +## Usage + +```bash +# Zero-friction: interactive wizard +npx @garrytan/gstack + +# Scripted: verb-based subcommands +npx @garrytan/gstack install --host claude,codex +npx @garrytan/gstack install --local # vendored (deprecated — prefer team mode) +npx @garrytan/gstack init --tier required +npx @garrytan/gstack upgrade +npx @garrytan/gstack uninstall --project --yes +npx @garrytan/gstack uninstall --local --yes # remove vendored project install +npx @garrytan/gstack doctor +npx @garrytan/gstack status +npx @garrytan/gstack list +npx @garrytan/gstack disable /qa +npx @garrytan/gstack enable /qa +``` + +Works with `npx`, `bunx`, and `pnpm dlx`. + +## What it does + +**`install`** — clones gstack into `~/.claude/skills/gstack`, builds the browse/design binaries via `bun`, registers with your chosen AI hosts (Claude Code, Codex, Factory Droid, OpenCode, Kiro), and inserts a `` / `` block into `~/.claude/CLAUDE.md` documenting the available skills. + +**`install --local`** — vendored mode: installs gstack into `/.claude/skills/gstack` instead of the home directory. Everything stays inside the project. **Deprecated upstream** in favor of team mode (`init`) because vendoring means no cross-project auto-update and ~100MB duplicated per project. Exposed here because `./setup --local` still supports it. Claude Code only (other hosts skipped). + +**`init`** — runs inside a git repo. Installs globally if needed, enables team mode (the SessionStart auto-update hook), runs `gstack-team-init ` to bootstrap the repo, and stages/commits the changes. Teammates get gstack automatically on their next session. + +**`uninstall`** — removes the install and walks every host's skills directory (`~/.claude/skills`, `~/.codex/skills`, `~/.factory/skills`, `~/.config/opencode/skills`, `~/.kiro/skills`) removing any symlink or directory whose `SKILL.md` points into the gstack install. Cleans the CLAUDE.md block and scrubs the PreToolUse hook from project `settings.json`. `~/.gstack/` (session state) is preserved. + +**`upgrade`** — `git fetch` + hard reset to `origin/main` in `~/.claude/skills/gstack`, then re-runs `./setup --host auto` to rebuild and re-link. + +**`doctor`** — checks git, bun, install state, binary freshness, skill count, and per-host registration. Exit code 1 if any check fails. + +**`status`** — one-screen summary: version, install path, team mode, auto-upgrade, skill prefix mode, per-host registration, per-project disabled-skills list. + +**`list`** — enumerates installed skills with descriptions parsed from each `SKILL.md` frontmatter. + +**`enable ` / `disable `** — toggle skills per-project via `.claude/settings.local.json`'s `disabledSkills` array. Names can be `qa`, `/qa`, or `gstack-qa` — all normalize to the same entry. + +## Requirements + +- Node.js 18+ (for the installer itself) +- [bun](https://bun.sh/) 1.0+ (for building gstack binaries) +- git +- bash (Windows: Git Bash or WSL) + +## Philosophy + +The installer is a thin wrapper around gstack's existing [`./setup`](https://github.com/garrytan/gstack/blob/main/setup) bash script — no logic is duplicated. This keeps the installer small, auditable, and guaranteed to stay in sync with upstream. If `setup` learns a new flag, the installer picks it up by exposing a new option. + +## Development + +```bash +cd installer +npm install +npm run build # compile TS to dist/ +npm start -- --help # run the built CLI + +# Watch mode +npm run dev + +# Smoke test locally +npm link +gstack --help +``` + +To test without publishing: + +```bash +# From anywhere, use the local checkout: +npx /absolute/path/to/gstack/installer install +``` + +## License + +MIT — same as gstack. diff --git a/installer/package-lock.json b/installer/package-lock.json new file mode 100644 index 0000000000..ff19d7a9d7 --- /dev/null +++ b/installer/package-lock.json @@ -0,0 +1,127 @@ +{ + "name": "@jkresabal/gstack", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@jkresabal/gstack", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.7.0", + "picocolors": "^1.0.1" + }, + "bin": { + "gstack": "dist/cli.js" + }, + "devDependencies": { + "@types/bun": "^1.3.13", + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@clack/core": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.5.tgz", + "integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz", + "integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==", + "bundleDependencies": [ + "is-unicode-supported" + ], + "license": "MIT", + "dependencies": { + "@clack/core": "^0.3.3", + "is-unicode-supported": "*", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts/node_modules/is-unicode-supported": { + "version": "1.3.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/bun": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.13.tgz", + "integrity": "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.13" + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/bun-types": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.13.tgz", + "integrity": "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/installer/package.json b/installer/package.json new file mode 100644 index 0000000000..215ea6137f --- /dev/null +++ b/installer/package.json @@ -0,0 +1,55 @@ +{ + "name": "@garrytan/gstack", + "version": "0.1.0", + "description": "Interactive installer for gstack — Garry Tan's Claude Code skills, hosts, and workflow tooling.", + "license": "MIT", + "type": "module", + "bin": { + "gstack": "./dist/cli.js" + }, + "files": [ + "dist", + "README.md", + "PUBLISHING.md" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build", + "dev": "tsc --watch", + "start": "node dist/cli.js", + "clean": "rm -rf dist", + "test": "bun test test/", + "test:unit": "bun test test/unit/", + "test:integration": "bun run build && bun test test/integration/", + "pretest": "bun run build" + }, + "keywords": [ + "gstack", + "claude-code", + "skills", + "installer", + "cli", + "ai-agents" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/garrytan/gstack.git", + "directory": "installer" + }, + "homepage": "https://github.com/garrytan/gstack#readme", + "bugs": { + "url": "https://github.com/garrytan/gstack/issues" + }, + "dependencies": { + "@clack/prompts": "^0.7.0", + "picocolors": "^1.0.1" + }, + "devDependencies": { + "@types/bun": "^1.3.13", + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + } +} diff --git a/installer/src/cli.ts b/installer/src/cli.ts new file mode 100644 index 0000000000..af18e6f737 --- /dev/null +++ b/installer/src/cli.ts @@ -0,0 +1,256 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; + +process.stdout.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EPIPE") process.exit(0); + throw err; +}); +import { runWizard } from "./wizard.js"; +import { installGlobal } from "./commands/install.js"; +import { initProject } from "./commands/init.js"; +import { uninstall } from "./commands/uninstall.js"; +import { upgrade } from "./commands/upgrade.js"; +import { doctor } from "./commands/doctor.js"; +import { status } from "./commands/status.js"; +import { list } from "./commands/list.js"; +import { enable, disable } from "./commands/toggle.js"; +import { HOSTS, type HostId, hostById } from "./lib/hosts.js"; +import { createLogger, colors } from "./lib/logger.js"; + +interface ParsedArgs { + positional: string[]; + flags: Record; + list: Record; +} + +function parseArgs(argv: string[]): ParsedArgs { + const positional: string[] = []; + const flags: Record = {}; + const list: Record = {}; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg.startsWith("--")) { + const eq = arg.indexOf("="); + let key: string; + let value: string | undefined; + if (eq !== -1) { + key = arg.slice(2, eq); + value = arg.slice(eq + 1); + } else { + key = arg.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("-")) { + value = next; + i++; + } + } + if (value === undefined) { + flags[key] = true; + } else if (key === "host") { + list.host = list.host ?? []; + list.host.push(...value.split(",").map((s) => s.trim()).filter(Boolean)); + } else { + flags[key] = value; + } + } else if (arg.startsWith("-") && arg.length > 1) { + const key = arg.slice(1); + flags[key] = true; + } else { + positional.push(arg); + } + } + + return { positional, flags, list }; +} + +function parseHosts(args: ParsedArgs): HostId[] { + const raw = args.list.host ?? []; + const hosts: HostId[] = []; + for (const r of raw) { + if (r === "auto") { + return ["claude"]; + } + const meta = hostById(r); + if (!meta) { + console.error(colors.red(`Unknown host: ${r}`)); + console.error(`Valid: ${HOSTS.map((h) => h.id).join(", ")}`); + process.exit(2); + } + hosts.push(meta.id); + } + return hosts; +} + +function bool(args: ParsedArgs, name: string, fallback: boolean): boolean { + const v = args.flags[name]; + if (v === undefined) return fallback; + if (typeof v === "boolean") return v; + if (v === "true" || v === "1" || v === "yes") return true; + if (v === "false" || v === "0" || v === "no") return false; + return fallback; +} + +function getVersion(): string { + try { + const require = createRequire(import.meta.url); + const pkg = require("../package.json") as { version: string }; + return pkg.version; + } catch { + return "unknown"; + } +} + +const HELP = `${colors.bold("gstack")} — installer for Garry Tan's gstack skill pack + +${colors.bold("Usage:")} + npx @garrytan/gstack interactive wizard + npx @garrytan/gstack [opts] + +${colors.bold("Commands:")} + install Install gstack globally (~/.claude/skills/gstack) + install --local Install gstack inside this project (vendored, deprecated) + init Add gstack to the current project (team mode) + uninstall Remove gstack (global; add --project or --local for per-repo) + upgrade Pull latest gstack and rebuild + doctor Diagnose install issues + status Show install version, hosts, and settings + list List available skills + enable Enable a skill in the current project + disable Disable a skill in the current project + +${colors.bold("Common options:")} + --host Register with host (repeatable, comma-separated). + Valid: ${HOSTS.map((h) => h.id).join(", ")} + --prefix Use gstack-* skill names + --no-prefix Use flat skill names (default) + --no-claude-md Don't write gstack section to CLAUDE.md + --yes, -y Skip confirmation prompts + --reinstall Remove existing install before installing + --quiet, -q Suppress non-essential output + --tier init only: "required" or "optional" (default: required) + --no-commit init only: stage but don't commit changes + --local install only: vendor into /.claude/skills/gstack + uninstall only: remove vendored project-local install + --project uninstall only: remove team-mode config from current repo + --keep-claude-md uninstall only: leave CLAUDE.md section in place + +${colors.bold("Examples:")} + npx @garrytan/gstack install --host claude,codex + npx @garrytan/gstack init --tier optional + npx @garrytan/gstack uninstall --project --yes + npx @garrytan/gstack doctor +`; + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const quiet = bool(args, "quiet", bool(args, "q", false)); + + if (args.flags.version || args.flags.v) { + console.log(getVersion()); + return; + } + if (args.flags.help || args.flags.h) { + console.log(HELP); + return; + } + + const cmd = args.positional[0]; + + if (!cmd) { + await runWizard(); + return; + } + + const hosts = parseHosts(args); + const prefix = args.flags.prefix === true ? true : args.flags["no-prefix"] === true ? false : false; + const writeClaudeMd = !bool(args, "no-claude-md", false); + const yes = bool(args, "yes", bool(args, "y", false)); + const reinstall = bool(args, "reinstall", false); + + switch (cmd) { + case "install": + await installGlobal({ + hosts: hosts.length > 0 ? hosts : (["claude"] as HostId[]), + prefix, + writeClaudeMd, + quiet, + reinstall, + local: bool(args, "local", false), + }); + break; + case "init": { + const tierFlag = typeof args.flags.tier === "string" ? args.flags.tier : "required"; + if (tierFlag !== "required" && tierFlag !== "optional") { + console.error(colors.red(`Invalid --tier: ${tierFlag} (expected "required" or "optional")`)); + process.exit(2); + } + await initProject({ + tier: tierFlag, + commit: !bool(args, "no-commit", false), + quiet, + writeClaudeMd, + globalArgs: { + hosts: hosts.length > 0 ? hosts : (["claude"] as HostId[]), + prefix, + writeClaudeMd, + quiet, + reinstall, + }, + }); + break; + } + case "uninstall": + await uninstall({ + project: bool(args, "project", false), + local: bool(args, "local", false), + yes, + keepClaudeMd: bool(args, "keep-claude-md", false), + quiet, + }); + break; + case "upgrade": + await upgrade({ quiet }); + break; + case "doctor": + await doctor({ quiet }); + break; + case "status": + await status({ quiet }); + break; + case "list": + await list({ quiet }); + break; + case "enable": { + const name = args.positional[1]; + if (!name) { + console.error(colors.red("Usage: gstack enable ")); + process.exit(2); + } + await enable({ skillName: name, quiet }); + break; + } + case "disable": { + const name = args.positional[1]; + if (!name) { + console.error(colors.red("Usage: gstack disable ")); + process.exit(2); + } + await disable({ skillName: name, quiet }); + break; + } + default: { + const log = createLogger(false); + log.error(`Unknown command: ${cmd}`); + console.log(""); + console.log(HELP); + process.exit(2); + } + } +} + +main().catch((err) => { + const log = createLogger(false); + log.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/installer/src/commands/doctor.ts b/installer/src/commands/doctor.ts new file mode 100644 index 0000000000..1a5d60495b --- /dev/null +++ b/installer/src/commands/doctor.ts @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveActiveInstall, readVersion } from "../lib/paths.js"; +import { + checkRequirements, + getBunVersion, + getGitVersion, + detectInstalledHosts, +} from "../lib/system.js"; +import { getInstalledCommit } from "../lib/git.js"; +import { scanSkills } from "../lib/skills.js"; +import { HOSTS } from "../lib/hosts.js"; +import { createLogger, colors } from "../lib/logger.js"; + +export interface DoctorArgs { + quiet: boolean; +} + +interface Check { + name: string; + status: "ok" | "warn" | "fail"; + detail: string; +} + +export async function doctor(args: DoctorArgs): Promise { + const log = createLogger(args.quiet); + const { paths, mode } = resolveActiveInstall(); + const checks: Check[] = []; + + const sys = await checkRequirements(); + checks.push({ + name: "git", + status: sys.missing.includes("git") ? "fail" : "ok", + detail: (await getGitVersion()) ?? "not found", + }); + checks.push({ + name: "bun", + status: sys.missing.includes("bun") ? "fail" : "ok", + detail: (await getBunVersion()) ?? "not found (required to build binaries)", + }); + + const installed = mode !== "none"; + checks.push({ + name: "install", + status: installed ? "ok" : "fail", + detail: installed + ? `${paths.gstackDir}${mode === "project-local" ? " (project-local)" : ""}` + : `missing (run \`gstack install\`)`, + }); + + if (installed) { + const version = readVersion(paths); + const commit = await getInstalledCommit(paths); + checks.push({ + name: "version", + status: "ok", + detail: `${version ?? "(unversioned)"}${commit ? ` @ ${commit}` : ""}`, + }); + + const browseBin = path.join(paths.gstackDir, "browse", "dist", "browse"); + const hasBinary = fs.existsSync(browseBin); + checks.push({ + name: "browse binary", + status: hasBinary ? "ok" : "warn", + detail: hasBinary ? browseBin : "not built (run `gstack upgrade` to rebuild)", + }); + + const skills = scanSkills(paths); + checks.push({ + name: "skills", + status: skills.length > 0 ? "ok" : "warn", + detail: `${skills.length} discovered`, + }); + + const hosts = await detectInstalledHosts(); + for (const host of HOSTS) { + const skillsPath = host.skillsDir.replace("~", paths.home); + const gstackEntry = path.join(skillsPath, "gstack"); + const entryExists = fs.existsSync(gstackEntry); + const isInstalledHost = hosts.includes(host.id); + if (!isInstalledHost && !entryExists) continue; + checks.push({ + name: `host: ${host.label}`, + status: entryExists ? "ok" : isInstalledHost ? "warn" : "ok", + detail: entryExists + ? `registered at ${gstackEntry}` + : isInstalledHost + ? `detected but not registered (run \`gstack install --host ${host.id}\`)` + : "not installed", + }); + } + } + + for (const w of sys.warnings) log.warn(w); + + log.plain(""); + const pad = Math.max(...checks.map((c) => c.name.length)); + for (const c of checks) { + const badge = + c.status === "ok" + ? colors.green("✓") + : c.status === "warn" + ? colors.yellow("!") + : colors.red("✗"); + log.plain(`${badge} ${c.name.padEnd(pad)} ${colors.dim(c.detail)}`); + } + + const failed = checks.filter((c) => c.status === "fail").length; + const warned = checks.filter((c) => c.status === "warn").length; + log.plain(""); + if (failed > 0) { + log.error(`${failed} check${failed === 1 ? "" : "s"} failed.`); + process.exit(1); + } else if (warned > 0) { + log.warn(`${warned} warning${warned === 1 ? "" : "s"}.`); + } else { + log.success("All checks passed."); + } +} diff --git a/installer/src/commands/init.ts b/installer/src/commands/init.ts new file mode 100644 index 0000000000..e5d6ab09c9 --- /dev/null +++ b/installer/src/commands/init.ts @@ -0,0 +1,76 @@ +import path from "node:path"; +import fs from "node:fs"; +import { resolveInstallPaths, isInstalled, findGitRoot } from "../lib/paths.js"; +import { runSetup, runTeamInit } from "../lib/setup.js"; +import { run } from "../lib/exec.js"; +import { buildGstackBlock, upsertClaudeMd } from "../lib/claude-md.js"; +import { createLogger } from "../lib/logger.js"; +import { installGlobal, type InstallArgs } from "./install.js"; + +export interface InitArgs { + tier: "required" | "optional"; + commit: boolean; + quiet: boolean; + writeClaudeMd: boolean; + globalArgs: InstallArgs; +} + +export async function initProject(args: InitArgs): Promise { + const log = createLogger(args.quiet); + const paths = resolveInstallPaths(); + + const repoRoot = findGitRoot(process.cwd()); + if (!repoRoot) { + log.error("Not inside a git repository. `gstack init` must be run from a project root."); + log.dim("Run `git init` first, or use `gstack install` for a personal (non-team) install."); + process.exit(1); + } + + if (!isInstalled(paths)) { + log.info("gstack not installed globally yet — installing first"); + await installGlobal(args.globalArgs); + } + + log.info("Enabling team mode (auto-update hook)"); + await runSetup(paths, { + host: "claude", + team: true, + prefix: args.globalArgs.prefix, + quiet: args.quiet, + }); + + log.info(`Bootstrapping ${repoRoot} with tier=${args.tier}`); + await runTeamInit(paths, repoRoot, args.tier); + + if (args.writeClaudeMd) { + const projectClaudeMd = path.join(repoRoot, "CLAUDE.md"); + const block = buildGstackBlock(paths); + const result = upsertClaudeMd(projectClaudeMd, block); + if (result.action !== "unchanged") { + log.success(`${result.action} gstack section in ${result.targetPath}`); + } + } + + const stageTargets = [".claude", "CLAUDE.md"].filter((rel) => + fs.existsSync(path.join(repoRoot, rel)), + ); + if (stageTargets.length > 0) { + await run("git", ["-C", repoRoot, "add", ...stageTargets]); + log.success(`Staged: ${stageTargets.join(", ")}`); + } + + if (args.commit) { + const msg = "require gstack for AI-assisted work"; + const r = await run("git", ["-C", repoRoot, "commit", "-m", msg]); + if (r.code === 0) { + log.success(`Committed: "${msg}"`); + } else { + log.warn("Nothing to commit (or commit failed — staged files remain)."); + } + } else { + log.dim("Review staged changes and commit when ready."); + } + + log.plain(""); + log.success(`gstack ${args.tier} in this repo. Teammates will auto-update on session start.`); +} diff --git a/installer/src/commands/install.ts b/installer/src/commands/install.ts new file mode 100644 index 0000000000..f300a45eaa --- /dev/null +++ b/installer/src/commands/install.ts @@ -0,0 +1,116 @@ +import fs from "node:fs"; +import * as p from "@clack/prompts"; +import { + resolveInstallPaths, + resolveProjectInstallPaths, + isInstalled, +} from "../lib/paths.js"; +import { checkRequirements } from "../lib/system.js"; +import { cloneGstack, pullGstack } from "../lib/git.js"; +import { runSetupForHosts } from "../lib/setup.js"; +import { buildGstackBlock, upsertClaudeMd } from "../lib/claude-md.js"; +import { HOSTS, type HostId } from "../lib/hosts.js"; +import { createLogger, colors } from "../lib/logger.js"; + +export interface InstallArgs { + hosts: HostId[]; + prefix: boolean; + writeClaudeMd: boolean; + quiet: boolean; + reinstall: boolean; + local?: boolean; + projectDir?: string; +} + +export async function installGlobal(args: InstallArgs): Promise { + const isLocal = args.local === true; + const projectDir = args.projectDir ?? process.cwd(); + const paths = isLocal + ? resolveProjectInstallPaths(projectDir) + : resolveInstallPaths(); + const log = createLogger(args.quiet); + + if (isLocal) { + log.warn( + `Project-only install is ${colors.yellow("deprecated upstream")}. You give up cross-project auto-update and vendor ~100MB per project.`, + ); + log.dim("For shared repos, team mode (`gstack init`) is strictly better."); + log.plain(""); + } + + const sys = await checkRequirements(); + if (!sys.ok) { + log.error(`Missing required tools: ${sys.missing.join(", ")}`); + log.plain(""); + log.plain("Install them and try again:"); + if (sys.missing.includes("bun")) log.bullet("bun: https://bun.sh/"); + if (sys.missing.includes("git")) log.bullet("git: https://git-scm.com/"); + process.exit(1); + } + for (const warn of sys.warnings) log.warn(warn); + + const alreadyInstalled = isInstalled(paths); + + if (alreadyInstalled && !args.reinstall) { + const s = p.spinner(); + s.start("Updating existing gstack checkout"); + try { + await pullGstack(paths); + s.stop("Updated existing gstack checkout"); + } catch (err) { + s.stop("Pull failed"); + throw err; + } + } else { + if (alreadyInstalled && args.reinstall) { + log.info(`Removing existing install at ${paths.gstackDir}`); + fs.rmSync(paths.gstackDir, { recursive: true, force: true }); + } + const s = p.spinner(); + s.start(`Cloning gstack into ${paths.gstackDir}`); + try { + await cloneGstack(paths); + s.stop("Cloned gstack"); + } catch (err) { + s.stop("Clone failed"); + throw err; + } + } + + const hosts = isLocal + ? (["claude"] as HostId[]) + : args.hosts.length > 0 + ? args.hosts + : (["claude"] as HostId[]); + if (isLocal && args.hosts.length > 1) { + log.warn("Project-only install supports Claude Code only; other hosts skipped."); + } + log.info(`Registering with ${hosts.map((h) => HOSTS.find((x) => x.id === h)?.label ?? h).join(", ")}`); + await runSetupForHosts(paths, hosts, { + prefix: args.prefix, + local: isLocal, + quiet: args.quiet, + }); + + if (args.writeClaudeMd) { + const block = buildGstackBlock(paths); + const result = upsertClaudeMd(paths.claudeMd, block); + if (result.action === "unchanged") { + log.dim(`CLAUDE.md already up to date (${result.targetPath})`); + } else { + log.success(`${result.action} gstack section in ${result.targetPath}`); + } + } + + log.plain(""); + log.success(isLocal ? "gstack installed in this project." : "gstack installed."); + log.bullet(`Location: ${paths.gstackDir}`); + log.bullet(`Hosts: ${hosts.join(", ")}`); + if (isLocal) { + log.bullet("Mode: project-only (vendored)"); + log.dim("Teammates who clone this repo get the same install."); + log.dim("Remember to add .claude/skills/gstack/node_modules/ to .gitignore if you commit the checkout."); + } + log.plain(""); + log.plain("Next: open Claude Code and try /office-hours, /review, or /qa"); +} diff --git a/installer/src/commands/list.ts b/installer/src/commands/list.ts new file mode 100644 index 0000000000..11f3b08fe8 --- /dev/null +++ b/installer/src/commands/list.ts @@ -0,0 +1,41 @@ +import { resolveActiveInstall } from "../lib/paths.js"; +import { scanSkills } from "../lib/skills.js"; +import { createLogger, colors } from "../lib/logger.js"; + +export interface ListArgs { + quiet: boolean; +} + +export async function list(args: ListArgs): Promise { + const log = createLogger(args.quiet); + const { paths, mode } = resolveActiveInstall(); + + if (mode === "none") { + log.error("gstack is not installed. Run `gstack install` first."); + process.exit(1); + } + + const skills = scanSkills(paths); + if (skills.length === 0) { + log.warn("No skills discovered."); + return; + } + + log.plain(colors.bold(`${skills.length} skills available:`)); + log.plain(""); + + const pad = Math.max(...skills.map((s) => s.skillName.length)); + for (const skill of skills) { + const name = `/${skill.skillName}`.padEnd(pad + 2); + const desc = skill.description + ? colors.dim(truncate(skill.description, 80)) + : colors.dim("(no description)"); + log.plain(` ${colors.cyan(name)} ${desc}`); + } +} + +function truncate(s: string, max: number): string { + const flat = s.replace(/\s+/g, " ").trim(); + if (flat.length <= max) return flat; + return flat.slice(0, max - 1) + "…"; +} diff --git a/installer/src/commands/status.ts b/installer/src/commands/status.ts new file mode 100644 index 0000000000..f2086424ff --- /dev/null +++ b/installer/src/commands/status.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + resolveInstallPaths, + isInstalled, + readVersion, + findGitRoot, + findLocalInstall, +} from "../lib/paths.js"; +import { getInstalledCommit } from "../lib/git.js"; +import { readGstackConfig } from "../lib/setup.js"; +import { listDisabledSkills } from "../lib/project-config.js"; +import { scanSkills } from "../lib/skills.js"; +import { HOSTS } from "../lib/hosts.js"; +import { createLogger, colors } from "../lib/logger.js"; + +export interface StatusArgs { + quiet: boolean; +} + +export async function status(args: StatusArgs): Promise { + const log = createLogger(args.quiet); + const globalPaths = resolveInstallPaths(); + const localPaths = findLocalInstall(process.cwd()); + + const paths = isInstalled(globalPaths) + ? globalPaths + : localPaths ?? globalPaths; + const mode = + paths === globalPaths && isInstalled(globalPaths) + ? "global" + : localPaths + ? "project-local" + : "none"; + + if (mode === "none") { + log.plain(colors.bold("gstack:") + " " + colors.red("not installed")); + log.dim("Run `gstack install` to install globally, or `gstack install --local` for project-only."); + return; + } + + const version = readVersion(paths); + const commit = await getInstalledCommit(paths); + const teamMode = await readGstackConfig(paths, "team_mode"); + const autoUpgrade = await readGstackConfig(paths, "auto_upgrade"); + const skillPrefix = await readGstackConfig(paths, "skill_prefix"); + + log.plain(colors.bold("gstack") + " " + colors.dim(`(${version ?? "unversioned"}${commit ? ` @ ${commit}` : ""})`)); + log.plain(""); + log.plain(` ${colors.dim("Mode:")} ${mode === "project-local" ? colors.yellow("project-local (vendored)") : "global"}`); + log.plain(` ${colors.dim("Install:")} ${paths.gstackDir}`); + log.plain(` ${colors.dim("Team mode:")} ${teamMode === "true" ? colors.green("on") : "off"}`); + log.plain(` ${colors.dim("Auto-upgrade:")} ${autoUpgrade === "true" ? colors.green("on") : "off"}`); + log.plain(` ${colors.dim("Skill prefix:")} ${skillPrefix === "true" ? "gstack-*" : "flat"}`); + + const skills = scanSkills(paths); + log.plain(` ${colors.dim("Skills:")} ${skills.length}`); + + log.plain(""); + log.plain(colors.bold("Hosts registered:")); + for (const host of HOSTS) { + const skillsPath = host.skillsDir.replace("~", paths.home); + const gstackEntry = path.join(skillsPath, "gstack"); + const exists = fs.existsSync(gstackEntry); + const badge = exists ? colors.green("✓") : colors.dim("·"); + log.plain(` ${badge} ${host.label.padEnd(16)} ${colors.dim(exists ? gstackEntry : "not registered")}`); + } + + const repoRoot = findGitRoot(process.cwd()); + if (repoRoot) { + const disabled = listDisabledSkills(repoRoot); + if (disabled.length > 0) { + log.plain(""); + log.plain(colors.bold(`Project (${repoRoot}):`)); + log.plain(` ${colors.dim("Disabled skills:")} ${disabled.join(", ")}`); + } + } +} diff --git a/installer/src/commands/toggle.ts b/installer/src/commands/toggle.ts new file mode 100644 index 0000000000..9fd97e1f05 --- /dev/null +++ b/installer/src/commands/toggle.ts @@ -0,0 +1,60 @@ +import { resolveInstallPaths, findGitRoot } from "../lib/paths.js"; +import { scanSkills } from "../lib/skills.js"; +import { disableSkill, enableSkill, listDisabledSkills } from "../lib/project-config.js"; +import { createLogger } from "../lib/logger.js"; + +export interface ToggleArgs { + skillName: string; + quiet: boolean; +} + +function normalizeName(name: string): string { + return name.replace(/^\//, "").replace(/^gstack-/, ""); +} + +function validateSkill(skillName: string): boolean { + const paths = resolveInstallPaths(); + const skills = scanSkills(paths); + const normalized = normalizeName(skillName); + return skills.some((s) => normalizeName(s.skillName) === normalized); +} + +export async function enable(args: ToggleArgs): Promise { + const log = createLogger(args.quiet); + const repoRoot = findGitRoot(process.cwd()); + if (!repoRoot) { + log.error("Not inside a git repository. `gstack enable` configures per-project settings."); + process.exit(1); + } + const name = normalizeName(args.skillName); + const changed = enableSkill(repoRoot, name); + if (changed) { + log.success(`Enabled /${name} in this project.`); + } else { + const current = listDisabledSkills(repoRoot); + if (current.includes(name)) { + log.warn(`/${name} was already enabled (not in disabled list).`); + } else { + log.info(`/${name} is already enabled.`); + } + } +} + +export async function disable(args: ToggleArgs): Promise { + const log = createLogger(args.quiet); + const repoRoot = findGitRoot(process.cwd()); + if (!repoRoot) { + log.error("Not inside a git repository. `gstack disable` configures per-project settings."); + process.exit(1); + } + const name = normalizeName(args.skillName); + if (!validateSkill(name)) { + log.warn(`No installed skill matches "${name}". Disabling anyway (in case it's installed later).`); + } + const changed = disableSkill(repoRoot, name); + if (changed) { + log.success(`Disabled /${name} in this project.`); + } else { + log.info(`/${name} was already disabled.`); + } +} diff --git a/installer/src/commands/uninstall.ts b/installer/src/commands/uninstall.ts new file mode 100644 index 0000000000..f3e494fb41 --- /dev/null +++ b/installer/src/commands/uninstall.ts @@ -0,0 +1,172 @@ +import fs from "node:fs"; +import path from "node:path"; +import * as p from "@clack/prompts"; +import { + resolveInstallPaths, + isInstalled, + findGitRoot, + findLocalInstall, +} from "../lib/paths.js"; +import { + cleanupHostSymlinks, + removeGstackInstall, + projectGstackArtifacts, + scrubSettingsJson, +} from "../lib/cleanup.js"; +import { removeGstackBlock } from "../lib/claude-md.js"; +import { runSetup } from "../lib/setup.js"; +import { createLogger } from "../lib/logger.js"; +import { run } from "../lib/exec.js"; + +export interface UninstallArgs { + project: boolean; + local: boolean; + yes: boolean; + keepClaudeMd: boolean; + quiet: boolean; +} + +export async function uninstall(args: UninstallArgs): Promise { + if (args.local) { + await uninstallLocal(args); + } else if (args.project) { + await uninstallProject(args); + } else { + await uninstallGlobal(args); + } +} + +async function uninstallLocal(args: UninstallArgs): Promise { + const log = createLogger(args.quiet); + const paths = findLocalInstall(process.cwd()); + if (!paths) { + log.error("No project-local gstack install found (looked for .claude/skills/gstack in cwd and parents)."); + process.exit(1); + } + + if (!args.yes) { + const proceed = await p.confirm({ + message: `Remove project-local gstack at ${paths.gstackDir}?`, + initialValue: false, + }); + if (p.isCancel(proceed) || !proceed) { + log.dim("Aborted."); + return; + } + } + + fs.rmSync(paths.gstackDir, { recursive: true, force: true }); + log.bullet(`removed ${paths.gstackDir}`); + + if (!args.keepClaudeMd) { + const { removeGstackBlock: remove } = await import("../lib/claude-md.js"); + if (remove(paths.claudeMd)) log.bullet(`removed gstack block from ${paths.claudeMd}`); + } + + log.plain(""); + log.success("Project-local gstack uninstalled."); +} + +async function uninstallGlobal(args: UninstallArgs): Promise { + const log = createLogger(args.quiet); + const paths = resolveInstallPaths(); + + if (!isInstalled(paths)) { + log.info("gstack is not installed globally."); + return; + } + + if (!args.yes) { + const proceed = await p.confirm({ + message: `Remove gstack from ${paths.gstackDir} and all registered host symlinks?`, + initialValue: false, + }); + if (p.isCancel(proceed) || !proceed) { + log.dim("Aborted."); + return; + } + } + + if (isInstalled(paths)) { + try { + await runSetup(paths, { host: "claude", noTeam: true, quiet: true }); + } catch { + // setup --no-team may fail if checkout is broken; continue + } + } + + const cleanup = cleanupHostSymlinks(paths); + for (const link of cleanup.removedSymlinks) log.bullet(`unlinked ${link}`); + for (const dir of cleanup.removedDirs) log.bullet(`removed ${dir}`); + + if (removeGstackInstall(paths)) { + log.bullet(`removed ${paths.gstackDir}`); + } + + if (!args.keepClaudeMd) { + if (removeGstackBlock(paths.claudeMd)) { + log.bullet(`removed gstack block from ${paths.claudeMd}`); + } + } + + log.plain(""); + log.success("gstack uninstalled."); + log.dim(`State kept at ${paths.gstackStateDir} (session history, config). Delete manually if you want a clean slate.`); +} + +async function uninstallProject(args: UninstallArgs): Promise { + const log = createLogger(args.quiet); + + const repoRoot = findGitRoot(process.cwd()); + if (!repoRoot) { + log.error("Not inside a git repository."); + process.exit(1); + } + + const artifacts = projectGstackArtifacts(repoRoot); + const projectClaudeMd = path.join(repoRoot, "CLAUDE.md"); + const hasClaudeMdBlock = + fs.existsSync(projectClaudeMd) && + fs.readFileSync(projectClaudeMd, "utf-8").includes(""); + + if (artifacts.length === 0 && !hasClaudeMdBlock) { + log.info("No project-level gstack artifacts found."); + return; + } + + if (!args.yes) { + log.plain("This will remove from the project:"); + for (const artifact of artifacts) log.bullet(artifact); + if (hasClaudeMdBlock && !args.keepClaudeMd) log.bullet(`gstack section in ${projectClaudeMd}`); + const proceed = await p.confirm({ + message: "Proceed?", + initialValue: false, + }); + if (p.isCancel(proceed) || !proceed) { + log.dim("Aborted."); + return; + } + } + + for (const artifact of artifacts) { + fs.rmSync(artifact, { recursive: true, force: true }); + log.bullet(`removed ${artifact}`); + } + + const settingsPath = path.join(repoRoot, ".claude", "settings.json"); + if (scrubSettingsJson(settingsPath)) { + log.bullet(`scrubbed gstack hooks from ${settingsPath}`); + } + + if (hasClaudeMdBlock && !args.keepClaudeMd) { + removeGstackBlock(projectClaudeMd); + log.bullet(`removed gstack block from ${projectClaudeMd}`); + } + + const stageTargets = [".claude", "CLAUDE.md", ".gstack"]; + await run("git", ["-C", repoRoot, "add", "--", ...stageTargets]); + + log.plain(""); + log.success("gstack removed from this project."); + log.dim("Review `git status` and commit when ready."); +} diff --git a/installer/src/commands/upgrade.ts b/installer/src/commands/upgrade.ts new file mode 100644 index 0000000000..9976a8ab54 --- /dev/null +++ b/installer/src/commands/upgrade.ts @@ -0,0 +1,48 @@ +import * as p from "@clack/prompts"; +import { resolveActiveInstall, readVersion } from "../lib/paths.js"; +import { pullGstack, getInstalledCommit } from "../lib/git.js"; +import { runSetup } from "../lib/setup.js"; +import { createLogger } from "../lib/logger.js"; + +export interface UpgradeArgs { + quiet: boolean; +} + +export async function upgrade(args: UpgradeArgs): Promise { + const log = createLogger(args.quiet); + const { paths, mode } = resolveActiveInstall(); + + if (mode === "none") { + log.error("gstack is not installed. Run `gstack install` first."); + process.exit(1); + } + if (mode === "project-local") { + log.info(`Upgrading project-local install at ${paths.gstackDir}`); + } + + const beforeVersion = readVersion(paths); + const beforeCommit = await getInstalledCommit(paths); + + const s = p.spinner(); + s.start("Pulling latest gstack"); + try { + await pullGstack(paths); + s.stop("Pulled latest gstack"); + } catch (err) { + s.stop("Pull failed"); + throw err; + } + + const afterVersion = readVersion(paths); + const afterCommit = await getInstalledCommit(paths); + + log.info(`Rebuilding (setup re-runs with existing host config)`); + await runSetup(paths, { host: "auto", quiet: args.quiet }); + + log.plain(""); + if (beforeCommit === afterCommit) { + log.success(`Already up to date (${afterVersion ?? afterCommit}).`); + } else { + log.success(`Upgraded: ${beforeVersion ?? beforeCommit} → ${afterVersion ?? afterCommit}`); + } +} diff --git a/installer/src/lib/claude-md.ts b/installer/src/lib/claude-md.ts new file mode 100644 index 0000000000..3bbb819889 --- /dev/null +++ b/installer/src/lib/claude-md.ts @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { InstallPaths } from "./paths.js"; +import { skillCommandList } from "./skills.js"; + +const BEGIN_MARKER = ""; +const END_MARKER = ""; + +export function buildGstackBlock(paths: InstallPaths): string { + const skills = skillCommandList(paths); + const skillsLine = + skills.length > 0 + ? `Available skills: ${skills.join(", ")}.` + : "Available skills: (run `gstack list` to view)"; + + return [ + BEGIN_MARKER, + "## gstack", + "", + "Use the `/browse` skill from gstack for all web browsing, deployment verification,", + "and QA. Never use `mcp__claude-in-chrome__*` tools — they are slow and unreliable.", + "", + skillsLine, + "", + "Run `npx @garrytan/gstack upgrade` to update. See https://github.com/garrytan/gstack.", + END_MARKER, + "", + ].join("\n"); +} + +export interface ClaudeMdResult { + action: "created" | "updated" | "inserted" | "unchanged"; + targetPath: string; +} + +export function upsertClaudeMd( + targetPath: string, + block: string, +): ClaudeMdResult { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + + let existing = ""; + const existed = fs.existsSync(targetPath); + if (existed) existing = fs.readFileSync(targetPath, "utf-8"); + + if (existing.includes(BEGIN_MARKER) && existing.includes(END_MARKER)) { + const before = existing.slice(0, existing.indexOf(BEGIN_MARKER)); + const afterStart = existing.indexOf(END_MARKER) + END_MARKER.length; + const after = existing.slice(afterStart).replace(/^\n/, ""); + const next = before + block + (after.length > 0 ? "\n" + after : ""); + if (next === existing) return { action: "unchanged", targetPath }; + fs.writeFileSync(targetPath, next, "utf-8"); + return { action: "updated", targetPath }; + } + + if (!existed) { + fs.writeFileSync(targetPath, block, "utf-8"); + return { action: "created", targetPath }; + } + + const sep = existing.endsWith("\n") ? "\n" : "\n\n"; + fs.writeFileSync(targetPath, existing + sep + block, "utf-8"); + return { action: "inserted", targetPath }; +} + +export function removeGstackBlock(targetPath: string): boolean { + if (!fs.existsSync(targetPath)) return false; + const existing = fs.readFileSync(targetPath, "utf-8"); + if (!existing.includes(BEGIN_MARKER)) return false; + const before = existing.slice(0, existing.indexOf(BEGIN_MARKER)).replace(/\n+$/, ""); + const afterStart = existing.indexOf(END_MARKER) + END_MARKER.length; + const after = existing.slice(afterStart).replace(/^\n+/, ""); + const next = [before, after].filter(Boolean).join("\n\n") + "\n"; + fs.writeFileSync(targetPath, next, "utf-8"); + return true; +} diff --git a/installer/src/lib/cleanup.ts b/installer/src/lib/cleanup.ts new file mode 100644 index 0000000000..5fac4a3866 --- /dev/null +++ b/installer/src/lib/cleanup.ts @@ -0,0 +1,177 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { InstallPaths } from "./paths.js"; + +const HOST_SKILL_DIRS = [ + ".claude/skills", + ".codex/skills", + ".factory/skills", + ".config/opencode/skills", + ".kiro/skills", +]; + +export interface CleanupResult { + removedSymlinks: string[]; + removedDirs: string[]; + gstackDirRemoved: boolean; +} + +function realpathSafe(p: string): string { + try { + return fs.realpathSync(p); + } catch { + return p; + } +} + +function linkPointsInto(linkPath: string, targetDir: string): boolean { + try { + const dest = fs.readlinkSync(linkPath); + const absDest = path.isAbsolute(dest) ? dest : path.resolve(path.dirname(linkPath), dest); + const realDest = realpathSafe(absDest); + const realTarget = realpathSafe(targetDir); + return ( + absDest === targetDir || + absDest.startsWith(targetDir + path.sep) || + realDest === realTarget || + realDest.startsWith(realTarget + path.sep) + ); + } catch { + return false; + } +} + +function directoryReferencesGstack(dirPath: string, gstackDir: string): boolean { + const skillMd = path.join(dirPath, "SKILL.md"); + try { + const stat = fs.lstatSync(skillMd); + if (!stat.isSymbolicLink()) return false; + return linkPointsInto(skillMd, gstackDir); + } catch { + return false; + } +} + +export function cleanupHostSymlinks(paths: InstallPaths): CleanupResult { + const result: CleanupResult = { + removedSymlinks: [], + removedDirs: [], + gstackDirRemoved: false, + }; + + for (const rel of HOST_SKILL_DIRS) { + const dir = path.join(paths.home, rel); + if (!fs.existsSync(dir)) continue; + let entries: string[]; + try { + entries = fs.readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + const full = path.join(dir, entry); + let stat: fs.Stats; + try { + stat = fs.lstatSync(full); + } catch { + continue; + } + if (stat.isSymbolicLink()) { + if (linkPointsInto(full, paths.gstackDir)) { + try { + fs.unlinkSync(full); + result.removedSymlinks.push(full); + } catch { + // ignore + } + } + } else if (stat.isDirectory()) { + if (directoryReferencesGstack(full, paths.gstackDir)) { + try { + fs.rmSync(full, { recursive: true, force: true }); + result.removedDirs.push(full); + } catch { + // ignore + } + } + } + } + } + + return result; +} + +export function removeGstackInstall(paths: InstallPaths): boolean { + if (!fs.existsSync(paths.gstackDir)) return false; + fs.rmSync(paths.gstackDir, { recursive: true, force: true }); + return true; +} + +export function projectGstackArtifacts(repoRoot: string): string[] { + const found: string[] = []; + const gstackRefs = [ + path.join(repoRoot, ".claude", "skills", "gstack"), + path.join(repoRoot, ".claude", "hooks", "check-gstack.sh"), + path.join(repoRoot, ".claude", "hooks", "gstack-session-update"), + path.join(repoRoot, ".gstack"), + ]; + for (const p of gstackRefs) { + if (fs.existsSync(p)) found.push(p); + } + return found; +} + +interface SettingsHook { + matcher?: string; + hooks?: Array<{ type?: string; command?: string }>; +} + +interface SettingsShape { + hooks?: { + PreToolUse?: SettingsHook[]; + SessionStart?: SettingsHook[]; + [key: string]: SettingsHook[] | undefined; + }; + [key: string]: unknown; +} + +export function scrubSettingsJson(settingsPath: string): boolean { + if (!fs.existsSync(settingsPath)) return false; + let settings: SettingsShape; + try { + settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); + } catch { + return false; + } + if (!settings.hooks) return false; + + let changed = false; + for (const phase of Object.keys(settings.hooks)) { + const entries = settings.hooks[phase]; + if (!Array.isArray(entries)) continue; + const filtered = entries.filter((entry) => { + const cmds = entry.hooks ?? []; + return !cmds.some((h) => { + const cmd = h.command ?? ""; + return cmd.includes("check-gstack") || cmd.includes("gstack-session-update"); + }); + }); + if (filtered.length !== entries.length) { + changed = true; + if (filtered.length === 0) { + delete settings.hooks[phase]; + } else { + settings.hooks[phase] = filtered; + } + } + } + + if (settings.hooks && Object.keys(settings.hooks).length === 0) { + delete settings.hooks; + } + + if (changed) { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8"); + } + return changed; +} diff --git a/installer/src/lib/exec.ts b/installer/src/lib/exec.ts new file mode 100644 index 0000000000..7845e8cace --- /dev/null +++ b/installer/src/lib/exec.ts @@ -0,0 +1,61 @@ +import { spawn, type SpawnOptions } from "node:child_process"; + +export interface RunResult { + code: number; + stdout: string; + stderr: string; +} + +export interface RunOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + stream?: boolean; + stdinInherit?: boolean; +} + +export function run(cmd: string, args: string[], opts: RunOptions = {}): Promise { + return new Promise((resolve, reject) => { + const spawnOpts: SpawnOptions = { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + stdio: opts.stream + ? ["inherit", "inherit", "inherit"] + : [opts.stdinInherit ? "inherit" : "ignore", "pipe", "pipe"], + }; + + const child = spawn(cmd, args, spawnOpts); + let stdout = ""; + let stderr = ""; + + if (!opts.stream) { + child.stdout?.on("data", (d) => (stdout += d.toString())); + child.stderr?.on("data", (d) => (stderr += d.toString())); + } + + child.on("error", reject); + child.on("close", (code) => { + resolve({ code: code ?? 0, stdout, stderr }); + }); + }); +} + +export async function runOrThrow( + cmd: string, + args: string[], + opts: RunOptions = {}, +): Promise { + const result = await run(cmd, args, opts); + if (result.code !== 0) { + const invocation = `${cmd} ${args.join(" ")}`; + throw new Error( + `Command failed (${result.code}): ${invocation}\n${result.stderr || result.stdout}`.trim(), + ); + } + return result; +} + +export async function hasCmd(cmd: string): Promise { + const which = process.platform === "win32" ? "where" : "which"; + const result = await run(which, [cmd]); + return result.code === 0; +} diff --git a/installer/src/lib/git.ts b/installer/src/lib/git.ts new file mode 100644 index 0000000000..68629d24ae --- /dev/null +++ b/installer/src/lib/git.ts @@ -0,0 +1,44 @@ +import fs from "node:fs"; +import { runOrThrow, run } from "./exec.js"; +import { GSTACK_REPO_URL, GSTACK_REPO_BRANCH, type InstallPaths } from "./paths.js"; + +export async function cloneGstack(paths: InstallPaths): Promise { + fs.mkdirSync(paths.claudeSkillsDir, { recursive: true }); + await runOrThrow( + "git", + [ + "clone", + "--single-branch", + "--depth", + "1", + "--branch", + GSTACK_REPO_BRANCH, + GSTACK_REPO_URL, + paths.gstackDir, + ], + { stream: true }, + ); +} + +export async function pullGstack(paths: InstallPaths): Promise { + await runOrThrow("git", ["-C", paths.gstackDir, "fetch", "--depth", "1", "origin", GSTACK_REPO_BRANCH], { + stream: true, + }); + await runOrThrow( + "git", + ["-C", paths.gstackDir, "reset", "--hard", `origin/${GSTACK_REPO_BRANCH}`], + { stream: true }, + ); +} + +export async function getInstalledCommit(paths: InstallPaths): Promise { + const r = await run("git", ["-C", paths.gstackDir, "rev-parse", "--short", "HEAD"]); + if (r.code !== 0) return null; + return r.stdout.trim(); +} + +export async function currentRepoToplevel(cwd: string): Promise { + const r = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]); + if (r.code !== 0) return null; + return r.stdout.trim(); +} diff --git a/installer/src/lib/hosts.ts b/installer/src/lib/hosts.ts new file mode 100644 index 0000000000..98761b66e2 --- /dev/null +++ b/installer/src/lib/hosts.ts @@ -0,0 +1,56 @@ +export type HostId = + | "claude" + | "codex" + | "factory" + | "opencode" + | "kiro"; + +export interface HostMeta { + id: HostId; + label: string; + detectCmd: string; + skillsDir: string; + description: string; +} + +export const HOSTS: HostMeta[] = [ + { + id: "claude", + label: "Claude Code", + detectCmd: "claude", + skillsDir: "~/.claude/skills", + description: "Anthropic's official CLI (primary host)", + }, + { + id: "codex", + label: "Codex", + detectCmd: "codex", + skillsDir: "~/.codex/skills", + description: "OpenAI Codex CLI", + }, + { + id: "factory", + label: "Factory Droid", + detectCmd: "droid", + skillsDir: "~/.factory/skills", + description: "Factory AI droid", + }, + { + id: "opencode", + label: "OpenCode", + detectCmd: "opencode", + skillsDir: "~/.config/opencode/skills", + description: "SST OpenCode agent", + }, + { + id: "kiro", + label: "Kiro", + detectCmd: "kiro-cli", + skillsDir: "~/.kiro/skills", + description: "Kiro CLI", + }, +]; + +export function hostById(id: string): HostMeta | undefined { + return HOSTS.find((h) => h.id === id); +} diff --git a/installer/src/lib/logger.ts b/installer/src/lib/logger.ts new file mode 100644 index 0000000000..23578670b2 --- /dev/null +++ b/installer/src/lib/logger.ts @@ -0,0 +1,28 @@ +import pc from "picocolors"; + +export interface Logger { + info(msg: string): void; + success(msg: string): void; + warn(msg: string): void; + error(msg: string): void; + dim(msg: string): void; + bullet(msg: string): void; + plain(msg: string): void; +} + +export function createLogger(quiet = false): Logger { + const print = (s: string) => { + if (!quiet) process.stdout.write(s + "\n"); + }; + return { + info: (msg) => print(pc.cyan("i ") + msg), + success: (msg) => print(pc.green("✓ ") + msg), + warn: (msg) => print(pc.yellow("! ") + msg), + error: (msg) => process.stderr.write(pc.red("✗ ") + msg + "\n"), + dim: (msg) => print(pc.dim(msg)), + bullet: (msg) => print(pc.dim(" • ") + msg), + plain: (msg) => print(msg), + }; +} + +export const colors = pc; diff --git a/installer/src/lib/paths.ts b/installer/src/lib/paths.ts new file mode 100644 index 0000000000..9ea3675105 --- /dev/null +++ b/installer/src/lib/paths.ts @@ -0,0 +1,105 @@ +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +export const GSTACK_REPO_URL = "https://github.com/garrytan/gstack.git"; +export const GSTACK_REPO_BRANCH = "main"; + +export interface InstallPaths { + home: string; + claudeDir: string; + claudeSkillsDir: string; + gstackDir: string; + gstackStateDir: string; + claudeMd: string; +} + +export function resolveInstallPaths(): InstallPaths { + const home = os.homedir(); + const claudeDir = path.join(home, ".claude"); + return { + home, + claudeDir, + claudeSkillsDir: path.join(claudeDir, "skills"), + gstackDir: path.join(claudeDir, "skills", "gstack"), + gstackStateDir: path.join(home, ".gstack"), + claudeMd: path.join(claudeDir, "CLAUDE.md"), + }; +} + +export function resolveProjectInstallPaths(projectDir: string): InstallPaths { + const home = os.homedir(); + const claudeDir = path.join(projectDir, ".claude"); + return { + home, + claudeDir, + claudeSkillsDir: path.join(claudeDir, "skills"), + gstackDir: path.join(claudeDir, "skills", "gstack"), + gstackStateDir: path.join(home, ".gstack"), + claudeMd: path.join(projectDir, "CLAUDE.md"), + }; +} + +export function findLocalInstall(startDir: string): InstallPaths | null { + let cur = path.resolve(startDir); + while (true) { + const candidate = path.join(cur, ".claude", "skills", "gstack"); + if (fs.existsSync(candidate)) { + return resolveProjectInstallPaths(cur); + } + const parent = path.dirname(cur); + if (parent === cur) return null; + cur = parent; + } +} + +export interface ResolvedInstall { + paths: InstallPaths; + mode: "global" | "project-local" | "none"; +} + +export function resolveActiveInstall(cwd: string = process.cwd()): ResolvedInstall { + const globalPaths = resolveInstallPaths(); + if (isInstalled(globalPaths)) return { paths: globalPaths, mode: "global" }; + const local = findLocalInstall(cwd); + if (local && isInstalled(local)) return { paths: local, mode: "project-local" }; + return { paths: globalPaths, mode: "none" }; +} + +export function isInstalled(paths: InstallPaths): boolean { + try { + const stat = fs.lstatSync(paths.gstackDir); + return stat.isDirectory() || stat.isSymbolicLink(); + } catch { + return false; + } +} + +export function readVersion(paths: InstallPaths): string | null { + try { + const versionPath = path.join(paths.gstackDir, "VERSION"); + return fs.readFileSync(versionPath, "utf-8").trim(); + } catch { + return null; + } +} + +export function isGitRepo(dir: string): boolean { + let cur = path.resolve(dir); + while (true) { + if (fs.existsSync(path.join(cur, ".git"))) return true; + const parent = path.dirname(cur); + if (parent === cur) return false; + cur = parent; + } +} + +export function findGitRoot(dir: string): string | null { + let cur = path.resolve(dir); + while (true) { + if (fs.existsSync(path.join(cur, ".git"))) return cur; + const parent = path.dirname(cur); + if (parent === cur) return null; + cur = parent; + } +} diff --git a/installer/src/lib/project-config.ts b/installer/src/lib/project-config.ts new file mode 100644 index 0000000000..ccc82b7ef6 --- /dev/null +++ b/installer/src/lib/project-config.ts @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import path from "node:path"; + +export interface ClaudeSettings { + disabledSkills?: string[]; + [key: string]: unknown; +} + +function settingsPath(repoRoot: string): string { + return path.join(repoRoot, ".claude", "settings.local.json"); +} + +export function readSettings(repoRoot: string): ClaudeSettings { + const p = settingsPath(repoRoot); + if (!fs.existsSync(p)) return {}; + try { + return JSON.parse(fs.readFileSync(p, "utf-8")) as ClaudeSettings; + } catch { + return {}; + } +} + +export function writeSettings(repoRoot: string, settings: ClaudeSettings): void { + const p = settingsPath(repoRoot); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(settings, null, 2) + "\n", "utf-8"); +} + +export function disableSkill(repoRoot: string, skillName: string): boolean { + const s = readSettings(repoRoot); + const current = new Set(s.disabledSkills ?? []); + if (current.has(skillName)) return false; + current.add(skillName); + s.disabledSkills = [...current].sort(); + writeSettings(repoRoot, s); + return true; +} + +export function enableSkill(repoRoot: string, skillName: string): boolean { + const s = readSettings(repoRoot); + if (!s.disabledSkills || s.disabledSkills.length === 0) return false; + const before = s.disabledSkills.length; + s.disabledSkills = s.disabledSkills.filter((n) => n !== skillName); + if (s.disabledSkills.length === before) return false; + if (s.disabledSkills.length === 0) delete s.disabledSkills; + writeSettings(repoRoot, s); + return true; +} + +export function listDisabledSkills(repoRoot: string): string[] { + return readSettings(repoRoot).disabledSkills ?? []; +} diff --git a/installer/src/lib/setup.ts b/installer/src/lib/setup.ts new file mode 100644 index 0000000000..a6c7da55d7 --- /dev/null +++ b/installer/src/lib/setup.ts @@ -0,0 +1,65 @@ +import path from "node:path"; +import { run, runOrThrow } from "./exec.js"; +import type { InstallPaths } from "./paths.js"; +import type { HostId } from "./hosts.js"; + +export interface SetupOptions { + host: HostId | "auto"; + prefix?: boolean; + team?: boolean; + noTeam?: boolean; + local?: boolean; + quiet?: boolean; +} + +export async function runSetup(paths: InstallPaths, opts: SetupOptions): Promise { + const args: string[] = ["--host", opts.host]; + if (opts.prefix === true) args.push("--prefix"); + if (opts.prefix === false) args.push("--no-prefix"); + if (opts.team) args.push("--team"); + if (opts.noTeam) args.push("--no-team"); + if (opts.local) args.push("--local"); + if (opts.quiet) args.push("-q"); + + const setupScript = path.join(paths.gstackDir, "setup"); + + await runOrThrow("bash", [setupScript, ...args], { + cwd: paths.gstackDir, + stream: true, + }); +} + +export async function runSetupForHosts( + paths: InstallPaths, + hosts: HostId[], + opts: Omit, +): Promise { + if (hosts.length === 0) return; + for (const host of hosts) { + await runSetup(paths, { ...opts, host }); + } +} + +export async function runTeamInit( + paths: InstallPaths, + cwd: string, + tier: "required" | "optional", +): Promise { + const teamInit = path.join(paths.gstackDir, "bin", "gstack-team-init"); + await runOrThrow(teamInit, [tier], { cwd, stream: true }); +} + +export async function readGstackConfig( + paths: InstallPaths, + key: string, +): Promise { + const cfg = path.join(paths.gstackDir, "bin", "gstack-config"); + try { + const r = await run(cfg, ["get", key]); + if (r.code !== 0) return null; + const out = r.stdout.trim(); + return out.length === 0 ? null : out; + } catch { + return null; + } +} diff --git a/installer/src/lib/skills.ts b/installer/src/lib/skills.ts new file mode 100644 index 0000000000..7152ddf2d8 --- /dev/null +++ b/installer/src/lib/skills.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { InstallPaths } from "./paths.js"; + +export interface Skill { + dirName: string; + skillName: string; + description: string | null; + path: string; +} + +const SKIP_DIRS = new Set([ + "node_modules", + "dist", + "docs", + "scripts", + "test", + "bin", + "lib", + "browse", + "design", + "make-pdf", + "extension", + "hosts", + "contrib", + "benchmark-models", + "model-overlays", + "openclaw", + "supabase", + ".github", + ".agents", + ".claude", + ".factory", + ".opencode", + ".codex", + ".kiro", + "docs", + "agents", +]); + +function parseFrontmatter(content: string): { name?: string; description?: string } { + if (!content.startsWith("---")) return {}; + const end = content.indexOf("\n---", 3); + if (end === -1) return {}; + const fm = content.slice(3, end); + const result: { name?: string; description?: string } = {}; + const lines = fm.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const m = line.match(/^(name|description):\s*(.*)$/); + if (!m) continue; + const key = m[1] as "name" | "description"; + let value = m[2].trim(); + + if (value === "|" || value === ">" || value === "|-" || value === ">-") { + const isFolded = value.startsWith(">"); + const collected: string[] = []; + let j = i + 1; + let indent = -1; + while (j < lines.length) { + const next = lines[j]; + if (next.trim() === "") { + collected.push(""); + j++; + continue; + } + const leading = next.match(/^(\s+)/); + const width = leading ? leading[1].length : 0; + if (width === 0) break; + if (indent === -1) indent = width; + if (width < indent) break; + collected.push(next.slice(indent)); + j++; + } + i = j - 1; + value = isFolded + ? collected.join(" ").replace(/\s+/g, " ").trim() + : collected.join("\n").trim(); + } else if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + result[key] = value; + } + return result; +} + +export function scanSkills(paths: InstallPaths): Skill[] { + const skills: Skill[] = []; + let entries: string[]; + try { + entries = fs.readdirSync(paths.gstackDir); + } catch { + return []; + } + for (const entry of entries.sort()) { + if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue; + const dir = path.join(paths.gstackDir, entry); + const skillPath = path.join(dir, "SKILL.md"); + let stat: fs.Stats; + try { + stat = fs.statSync(dir); + } catch { + continue; + } + if (!stat.isDirectory()) continue; + if (!fs.existsSync(skillPath)) continue; + + let fm: { name?: string; description?: string } = {}; + try { + const content = fs.readFileSync(skillPath, "utf-8"); + fm = parseFrontmatter(content); + } catch { + // ignore + } + + skills.push({ + dirName: entry, + skillName: fm.name ?? entry, + description: fm.description ?? null, + path: dir, + }); + } + return skills; +} + +export function skillCommandList(paths: InstallPaths): string[] { + return scanSkills(paths).map((s) => `/${s.skillName}`); +} diff --git a/installer/src/lib/system.ts b/installer/src/lib/system.ts new file mode 100644 index 0000000000..2852759459 --- /dev/null +++ b/installer/src/lib/system.ts @@ -0,0 +1,48 @@ +import { hasCmd, run } from "./exec.js"; +import { HOSTS, type HostId } from "./hosts.js"; + +export interface SystemCheck { + ok: boolean; + missing: string[]; + warnings: string[]; +} + +export async function checkRequirements(): Promise { + const missing: string[] = []; + const warnings: string[] = []; + + if (!(await hasCmd("git"))) missing.push("git"); + if (!(await hasCmd("bun"))) missing.push("bun"); + + if (process.platform === "win32" && !(await hasCmd("node"))) { + missing.push("node (Windows only)"); + } + + if (process.platform === "win32") { + warnings.push( + "Windows is partially supported. The setup script requires bash (use Git Bash or WSL).", + ); + } + + return { ok: missing.length === 0, missing, warnings }; +} + +export async function detectInstalledHosts(): Promise { + const found: HostId[] = []; + for (const h of HOSTS) { + if (await hasCmd(h.detectCmd)) found.push(h.id); + } + return found; +} + +export async function getBunVersion(): Promise { + const r = await run("bun", ["--version"]); + if (r.code !== 0) return null; + return r.stdout.trim(); +} + +export async function getGitVersion(): Promise { + const r = await run("git", ["--version"]); + if (r.code !== 0) return null; + return r.stdout.trim(); +} diff --git a/installer/src/wizard.ts b/installer/src/wizard.ts new file mode 100644 index 0000000000..beee301b49 --- /dev/null +++ b/installer/src/wizard.ts @@ -0,0 +1,228 @@ +import * as p from "@clack/prompts"; +import { HOSTS, type HostId } from "./lib/hosts.js"; +import { detectInstalledHosts } from "./lib/system.js"; +import { findGitRoot, resolveInstallPaths, isInstalled } from "./lib/paths.js"; +import { installGlobal } from "./commands/install.js"; +import { initProject } from "./commands/init.js"; + +function assertValue(value: T | symbol): asserts value is T { + if (p.isCancel(value)) { + p.cancel("Aborted."); + process.exit(0); + } +} + +export async function runWizard(): Promise { + const paths = resolveInstallPaths(); + const inRepo = findGitRoot(process.cwd()) !== null; + const alreadyInstalled = isInstalled(paths); + const detectedHosts = await detectInstalledHosts(); + + p.intro("gstack installer"); + + if (!alreadyInstalled) { + p.note( + "gstack turns Claude Code into a virtual engineering team.\n" + + "CEO review, eng manager, designer, QA, release engineer — all /commands.\n\n" + + "Two install modes:\n" + + " • Machine install — just for you, manual upgrades\n" + + " • Team mode — machine install + repo config so teammates auto-update", + "about", + ); + } else { + p.note( + `Already installed at ${paths.gstackDir}.\n\n` + + "Team mode adds auto-update + repo-level config on top of this install.", + "detected", + ); + } + + type Mode = "install" | "init" | "local" | "uninstall" | "doctor"; + const mode = await p.select<{ value: Mode; label: string; hint?: string }[], Mode>({ + message: "What do you want to do?", + options: [ + { + value: "install", + label: alreadyInstalled ? "Update global install" : "Install gstack on this machine", + hint: "installs to ~/.claude/skills/gstack — just you, manual upgrades", + }, + { + value: "init", + label: "Enable team mode for this repo", + hint: inRepo + ? "global install + commits team-sync config to this repo so teammates auto-update" + : "must be inside a git repo", + }, + { + value: "local", + label: "Install inside this project only (vendored)", + hint: "installs to /.claude/skills/gstack — deprecated, prefer team mode", + }, + { value: "uninstall", label: "Uninstall", hint: "remove gstack" }, + { value: "doctor", label: "Doctor", hint: "diagnose install issues" }, + ], + initialValue: alreadyInstalled && inRepo ? "init" : "install", + }); + assertValue(mode); + + if (mode === "doctor") { + const { doctor } = await import("./commands/doctor.js"); + await doctor({ quiet: false }); + return; + } + + if (mode === "uninstall") { + type UninstallTarget = "global" | "project" | "local"; + const target = await p.select< + { value: UninstallTarget; label: string; hint?: string }[], + UninstallTarget + >({ + message: "Uninstall from where?", + options: [ + { value: "global", label: "Global (~/.claude/skills/gstack)" }, + { + value: "project", + label: "Team-mode config in this repo", + hint: "removes .claude/hooks/check-gstack.sh, settings.json hook, CLAUDE.md block", + }, + { + value: "local", + label: "Project-local (vendored) install", + hint: "removes /.claude/skills/gstack", + }, + ], + initialValue: "global", + }); + assertValue(target); + const { uninstall } = await import("./commands/uninstall.js"); + await uninstall({ + project: target === "project", + local: target === "local", + yes: false, + keepClaudeMd: false, + quiet: false, + }); + return; + } + + const hosts = await selectHosts(detectedHosts); + + type PrefixChoice = "flat" | "prefixed"; + const prefixChoice = await p.select<{ value: PrefixChoice; label: string; hint?: string }[], PrefixChoice>({ + message: "Skill naming", + options: [ + { value: "flat", label: "Flat: /qa, /review, /ship", hint: "clean, recommended" }, + { + value: "prefixed", + label: "Namespaced: /gstack-qa, /gstack-review", + hint: "use if you run other skill packs", + }, + ], + initialValue: "flat", + }); + assertValue(prefixChoice); + + const writeClaudeMdChoice = await p.confirm({ + message: "Add gstack section to CLAUDE.md?", + initialValue: true, + }); + assertValue(writeClaudeMdChoice); + + if (mode === "install") { + await installGlobal({ + hosts, + prefix: prefixChoice === "prefixed", + writeClaudeMd: writeClaudeMdChoice, + quiet: false, + reinstall: false, + }); + p.outro("Done. Open Claude Code and try /office-hours."); + return; + } + + if (mode === "local") { + const confirmLocal = await p.confirm({ + message: + "Project-only install is deprecated upstream. You give up cross-project auto-update and vendor ~100MB into this project. Continue?", + initialValue: false, + }); + assertValue(confirmLocal); + if (!confirmLocal) { + p.outro("Aborted. Use `gstack install` (global) or `gstack init` (team mode) instead."); + return; + } + await installGlobal({ + hosts: ["claude"], + prefix: prefixChoice === "prefixed", + writeClaudeMd: writeClaudeMdChoice, + quiet: false, + reinstall: false, + local: true, + projectDir: process.cwd(), + }); + p.outro("Done. Project-local install complete."); + return; + } + + if (mode === "init") { + if (!inRepo) { + p.log.error("Not inside a git repository. Run `git init` first."); + process.exit(1); + } + + type Tier = "required" | "optional"; + const tier = await p.select<{ value: Tier; label: string; hint?: string }[], Tier>({ + message: "Team mode tier", + options: [ + { + value: "required", + label: "Required", + hint: "PreToolUse hook blocks Claude Code work until teammate runs gstack install", + }, + { + value: "optional", + label: "Optional", + hint: "CLAUDE.md nudge only — teammate can ignore", + }, + ], + initialValue: "required", + }); + assertValue(tier); + + const commit = await p.confirm({ + message: 'Commit as "require gstack for AI-assisted work"?', + initialValue: true, + }); + assertValue(commit); + + await initProject({ + tier, + commit, + quiet: false, + writeClaudeMd: writeClaudeMdChoice, + globalArgs: { + hosts, + prefix: prefixChoice === "prefixed", + writeClaudeMd: writeClaudeMdChoice, + quiet: false, + reinstall: false, + }, + }); + p.outro(`Done. Tier: ${tier}.`); + } +} + +async function selectHosts(detected: HostId[]): Promise { + const choice = await p.multiselect({ + message: "Which AI coding tools should gstack register with?", + options: HOSTS.map((h) => ({ + value: h.id, + label: h.label, + hint: detected.includes(h.id) ? "detected" : h.description, + })), + initialValues: detected.length > 0 ? detected : (["claude"] as HostId[]), + required: true, + }); + assertValue(choice); + return choice as HostId[]; +} diff --git a/installer/test/helpers.ts b/installer/test/helpers.ts new file mode 100644 index 0000000000..a5f63ca525 --- /dev/null +++ b/installer/test/helpers.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export function makeTmpDir(prefix = "gstack-installer-test-"): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +export function rmTmpDir(dir: string): void { + if (!dir.startsWith(os.tmpdir())) { + throw new Error(`refusing to rm non-tmp path: ${dir}`); + } + fs.rmSync(dir, { recursive: true, force: true }); +} + +export function write(dir: string, relPath: string, content: string): string { + const full = path.join(dir, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content, "utf-8"); + return full; +} + +export function writeSkill( + dir: string, + skillDir: string, + frontmatter: Record, + body = "body", +): string { + const fm = Object.entries(frontmatter) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"); + const content = `---\n${fm}\n---\n\n${body}\n`; + return write(dir, path.join(skillDir, "SKILL.md"), content); +} + +export function read(file: string): string { + return fs.readFileSync(file, "utf-8"); +} + +export function readJson(file: string): T { + return JSON.parse(fs.readFileSync(file, "utf-8")) as T; +} + +export function initGitRepo(dir: string): void { + const { execSync } = require("node:child_process") as typeof import("node:child_process"); + execSync("git init -q", { cwd: dir }); + execSync("git config user.email test@example.com", { cwd: dir }); + execSync("git config user.name test", { cwd: dir }); +} diff --git a/installer/test/integration/cli.test.ts b/installer/test/integration/cli.test.ts new file mode 100644 index 0000000000..44b46e422c --- /dev/null +++ b/installer/test/integration/cli.test.ts @@ -0,0 +1,415 @@ +import { describe, test, expect, afterEach, beforeEach } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { makeTmpDir, rmTmpDir, initGitRepo, write } from "../helpers.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const CLI = path.resolve(here, "..", "..", "dist", "cli.js"); + +interface RunOpts { + cwd?: string; + env?: Record; +} + +function runCli(args: string[], opts: RunOpts = {}) { + const env = { ...process.env, ...(opts.env ?? {}) }; + const result = spawnSync("node", [CLI, ...args], { + cwd: opts.cwd ?? process.cwd(), + env, + encoding: "utf-8", + timeout: 10_000, + }); + return { + code: result.status ?? 0, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + +describe("cli: --help and --version", () => { + test("--help exits 0 and prints usage", () => { + const r = runCli(["--help"]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("gstack"); + expect(r.stdout).toContain("Commands:"); + expect(r.stdout).toContain("install"); + expect(r.stdout).toContain("init"); + }); + + test("-h works as --help", () => { + const r = runCli(["-h"]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("Commands:"); + }); + + test("--version prints the package version", () => { + const r = runCli(["--version"]); + expect(r.code).toBe(0); + expect(r.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + }); +}); + +describe("cli: unknown command", () => { + test("exits 2 with error message", () => { + const r = runCli(["bogus-command"]); + expect(r.code).toBe(2); + expect(r.stderr + r.stdout).toContain("Unknown command"); + }); +}); + +describe("cli: invalid args", () => { + test("enable with no skill name exits 2", () => { + const r = runCli(["enable"]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("Usage"); + }); + + test("disable with no skill name exits 2", () => { + const r = runCli(["disable"]); + expect(r.code).toBe(2); + }); + + test("init with invalid --tier exits 2", () => { + const r = runCli(["init", "--tier", "sometimes"]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("Invalid --tier"); + }); + + test("install with unknown --host exits 2", () => { + const r = runCli(["install", "--host", "not-a-host"]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("Unknown host"); + }); +}); + +describe("cli: enable/disable flow", () => { + let tmp: string; + let homeTmp: string; + + beforeEach(() => { + tmp = makeTmpDir(); + homeTmp = makeTmpDir("gstack-home-"); + initGitRepo(tmp); + }); + + afterEach(() => { + rmTmpDir(tmp); + rmTmpDir(homeTmp); + }); + + test("disable creates settings.local.json with skill", () => { + const r = runCli(["disable", "qa"], { cwd: tmp, env: { HOME: homeTmp } }); + expect(r.code).toBe(0); + expect(r.stdout).toContain("Disabled /qa"); + const settings = JSON.parse( + fs.readFileSync(path.join(tmp, ".claude", "settings.local.json"), "utf-8"), + ); + expect(settings.disabledSkills).toEqual(["qa"]); + }); + + test("enable removes disabled skill", () => { + runCli(["disable", "qa"], { cwd: tmp, env: { HOME: homeTmp } }); + const r = runCli(["enable", "qa"], { cwd: tmp, env: { HOME: homeTmp } }); + expect(r.code).toBe(0); + expect(r.stdout).toContain("Enabled /qa"); + const settings = JSON.parse( + fs.readFileSync(path.join(tmp, ".claude", "settings.local.json"), "utf-8"), + ); + expect(settings.disabledSkills).toBeUndefined(); + }); + + test("normalizes /-prefixed and gstack- prefixed names", () => { + runCli(["disable", "/qa"], { cwd: tmp, env: { HOME: homeTmp } }); + runCli(["disable", "gstack-review"], { cwd: tmp, env: { HOME: homeTmp } }); + const settings = JSON.parse( + fs.readFileSync(path.join(tmp, ".claude", "settings.local.json"), "utf-8"), + ); + expect(settings.disabledSkills).toEqual(["qa", "review"]); + }); + + test("enable outside git repo exits 1", () => { + const noRepo = makeTmpDir(); + try { + const r = runCli(["enable", "qa"], { cwd: noRepo, env: { HOME: homeTmp } }); + expect(r.code).toBe(1); + expect(r.stderr).toContain("Not inside a git repository"); + } finally { + rmTmpDir(noRepo); + } + }); +}); + +describe("cli: status with no install", () => { + let homeTmp: string; + + beforeEach(() => { + homeTmp = makeTmpDir("gstack-home-"); + }); + + afterEach(() => { + rmTmpDir(homeTmp); + }); + + test("exits cleanly with 'not installed' message", () => { + const r = runCli(["status"], { env: { HOME: homeTmp } }); + expect(r.code).toBe(0); + expect(r.stdout).toContain("not installed"); + }); +}); + +describe("cli: doctor with no install", () => { + let homeTmp: string; + + beforeEach(() => { + homeTmp = makeTmpDir("gstack-home-"); + }); + + afterEach(() => { + rmTmpDir(homeTmp); + }); + + test("exits 1 with install check failed", () => { + const r = runCli(["doctor"], { env: { HOME: homeTmp } }); + expect(r.code).toBe(1); + expect(r.stdout + r.stderr).toContain("install"); + }); +}); + +describe("cli: list with no install", () => { + let homeTmp: string; + + beforeEach(() => { + homeTmp = makeTmpDir("gstack-home-"); + }); + + afterEach(() => { + rmTmpDir(homeTmp); + }); + + test("exits 1", () => { + const r = runCli(["list"], { env: { HOME: homeTmp } }); + expect(r.code).toBe(1); + expect(r.stderr).toContain("not installed"); + }); +}); + +describe("cli: list against fake install", () => { + let homeTmp: string; + + beforeEach(() => { + homeTmp = makeTmpDir("gstack-home-"); + const gstackDir = path.join(homeTmp, ".claude", "skills", "gstack"); + fs.mkdirSync(gstackDir, { recursive: true }); + fs.writeFileSync(path.join(gstackDir, "VERSION"), "0.0.0-test"); + fs.mkdirSync(path.join(gstackDir, "qa"), { recursive: true }); + fs.writeFileSync( + path.join(gstackDir, "qa", "SKILL.md"), + "---\nname: qa\ndescription: Test QA skill\n---\nbody\n", + ); + fs.mkdirSync(path.join(gstackDir, "ship"), { recursive: true }); + fs.writeFileSync( + path.join(gstackDir, "ship", "SKILL.md"), + "---\nname: ship\ndescription: |\n Multiline\n ship description\n---\n", + ); + }); + + afterEach(() => rmTmpDir(homeTmp)); + + test("lists discovered skills with descriptions", () => { + const r = runCli(["list"], { env: { HOME: homeTmp } }); + expect(r.code).toBe(0); + expect(r.stdout).toContain("/qa"); + expect(r.stdout).toContain("Test QA skill"); + expect(r.stdout).toContain("/ship"); + expect(r.stdout).toContain("ship description"); + }); + + test("status prints version and install path", () => { + const r = runCli(["status"], { env: { HOME: homeTmp } }); + expect(r.code).toBe(0); + expect(r.stdout).toContain("0.0.0-test"); + expect(r.stdout).toContain("Skills:"); + expect(r.stdout).toContain("2"); + }); +}); + +describe("cli: EPIPE handling", () => { + let homeTmp: string; + + beforeEach(() => { + homeTmp = makeTmpDir("gstack-home-"); + const gstackDir = path.join(homeTmp, ".claude", "skills", "gstack"); + fs.mkdirSync(gstackDir, { recursive: true }); + for (let i = 0; i < 50; i++) { + fs.mkdirSync(path.join(gstackDir, `skill-${i}`), { recursive: true }); + fs.writeFileSync( + path.join(gstackDir, `skill-${i}`, "SKILL.md"), + `---\nname: skill-${i}\ndescription: skill number ${i}\n---\n`, + ); + } + }); + + afterEach(() => rmTmpDir(homeTmp)); + + test("does not crash when piped to a closed reader", () => { + const result = spawnSync("bash", ["-c", `node ${CLI} list | head -1`], { + env: { ...process.env, HOME: homeTmp }, + encoding: "utf-8", + timeout: 10_000, + }); + const combined = (result.stderr ?? "") + (result.stdout ?? ""); + expect(combined).not.toContain("EPIPE"); + expect(combined).not.toContain("Unhandled"); + }); +}); + +describe("cli: uninstall --project", () => { + let tmp: string; + let homeTmp: string; + + beforeEach(() => { + tmp = makeTmpDir(); + homeTmp = makeTmpDir("gstack-home-"); + initGitRepo(tmp); + + fs.mkdirSync(path.join(tmp, ".claude", "skills", "gstack"), { recursive: true }); + write(tmp, ".claude/hooks/check-gstack.sh", "#!/bin/bash\necho gstack\n"); + write( + tmp, + ".claude/settings.json", + JSON.stringify({ + theme: "dark", + hooks: { + PreToolUse: [ + { matcher: "Skill", hooks: [{ type: "command", command: "check-gstack.sh" }] }, + ], + }, + }), + ); + write( + tmp, + "CLAUDE.md", + "# Project\n\n\ngstack section\n\n\n# Rest\n", + ); + }); + + afterEach(() => { + rmTmpDir(tmp); + rmTmpDir(homeTmp); + }); + + test("removes artifacts, scrubs settings hook, preserves other settings", () => { + const r = runCli(["uninstall", "--project", "--yes"], { + cwd: tmp, + env: { HOME: homeTmp }, + }); + expect(r.code).toBe(0); + expect(fs.existsSync(path.join(tmp, ".claude", "skills", "gstack"))).toBe(false); + expect(fs.existsSync(path.join(tmp, ".claude", "hooks", "check-gstack.sh"))).toBe(false); + + const settings = JSON.parse( + fs.readFileSync(path.join(tmp, ".claude", "settings.json"), "utf-8"), + ); + expect(settings.theme).toBe("dark"); + expect(settings.hooks).toBeUndefined(); + + const claudeMd = fs.readFileSync(path.join(tmp, "CLAUDE.md"), "utf-8"); + expect(claudeMd).toContain("# Project"); + expect(claudeMd).toContain("# Rest"); + expect(claudeMd).not.toContain("gstack section"); + }); + + test("--keep-claude-md preserves the gstack block", () => { + const r = runCli(["uninstall", "--project", "--yes", "--keep-claude-md"], { + cwd: tmp, + env: { HOME: homeTmp }, + }); + expect(r.code).toBe(0); + const claudeMd = fs.readFileSync(path.join(tmp, "CLAUDE.md"), "utf-8"); + expect(claudeMd).toContain("gstack section"); + }); +}); + +describe("cli: status detects project-local install", () => { + let tmp: string; + let homeTmp: string; + + beforeEach(() => { + tmp = makeTmpDir(); + homeTmp = makeTmpDir("gstack-home-"); + const gstackDir = path.join(tmp, ".claude", "skills", "gstack"); + fs.mkdirSync(gstackDir, { recursive: true }); + fs.writeFileSync(path.join(gstackDir, "VERSION"), "0.0.0-local-test"); + fs.mkdirSync(path.join(gstackDir, "qa"), { recursive: true }); + fs.writeFileSync( + path.join(gstackDir, "qa", "SKILL.md"), + "---\nname: qa\ndescription: Local QA\n---\n", + ); + }); + + afterEach(() => { + rmTmpDir(tmp); + rmTmpDir(homeTmp); + }); + + test("status shows project-local mode when only a local install exists", () => { + const r = runCli(["status"], { cwd: tmp, env: { HOME: homeTmp } }); + expect(r.code).toBe(0); + expect(r.stdout).toContain("project-local"); + expect(r.stdout).toContain("0.0.0-local-test"); + }); + + test("list discovers skills from project-local install", () => { + const r = runCli(["list"], { cwd: tmp, env: { HOME: homeTmp } }); + expect(r.code).toBe(0); + expect(r.stdout).toContain("/qa"); + }); + + test("uninstall --local --yes removes the project-local install", () => { + const r = runCli(["uninstall", "--local", "--yes"], { + cwd: tmp, + env: { HOME: homeTmp }, + }); + expect(r.code).toBe(0); + expect(fs.existsSync(path.join(tmp, ".claude", "skills", "gstack"))).toBe(false); + }); + + test("uninstall --local fails cleanly when no project-local install exists", () => { + const nowhere = makeTmpDir(); + try { + const r = runCli(["uninstall", "--local", "--yes"], { + cwd: nowhere, + env: { HOME: homeTmp }, + }); + expect(r.code).toBe(1); + expect(r.stderr).toContain("No project-local gstack install"); + } finally { + rmTmpDir(nowhere); + } + }); +}); + +describe("cli: no args launches wizard", () => { + let homeTmp: string; + + beforeEach(() => { + homeTmp = makeTmpDir("gstack-home-"); + }); + + afterEach(() => { + rmTmpDir(homeTmp); + }); + + test("prints wizard intro when stdin is closed", () => { + const result = spawnSync("node", [CLI], { + env: { ...process.env, HOME: homeTmp }, + encoding: "utf-8", + timeout: 5_000, + input: "", + }); + const combined = (result.stdout ?? "") + (result.stderr ?? ""); + expect(combined).toContain("gstack"); + }); +}); diff --git a/installer/test/unit/claude-md.test.ts b/installer/test/unit/claude-md.test.ts new file mode 100644 index 0000000000..5f9ced0566 --- /dev/null +++ b/installer/test/unit/claude-md.test.ts @@ -0,0 +1,130 @@ +import { describe, test, expect, afterEach, beforeEach } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +import { makeTmpDir, rmTmpDir, read } from "../helpers.js"; +import { + buildGstackBlock, + upsertClaudeMd, + removeGstackBlock, +} from "../../src/lib/claude-md.js"; +import type { InstallPaths } from "../../src/lib/paths.js"; + +function makePaths(home: string): InstallPaths { + return { + home, + claudeDir: path.join(home, ".claude"), + claudeSkillsDir: path.join(home, ".claude", "skills"), + gstackDir: path.join(home, ".claude", "skills", "gstack"), + gstackStateDir: path.join(home, ".gstack"), + claudeMd: path.join(home, ".claude", "CLAUDE.md"), + }; +} + +describe("buildGstackBlock", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("includes fence markers", () => { + const paths = makePaths(tmp); + const block = buildGstackBlock(paths); + expect(block).toContain(""); + expect(block).toContain(""); + }); + + test("falls back to `run list` hint when no skills found", () => { + const paths = makePaths(tmp); + const block = buildGstackBlock(paths); + expect(block).toMatch(/Available skills:.*list/); + }); + + test("lists discovered skills", () => { + const paths = makePaths(tmp); + fs.mkdirSync(paths.gstackDir, { recursive: true }); + fs.mkdirSync(path.join(paths.gstackDir, "qa")); + fs.writeFileSync( + path.join(paths.gstackDir, "qa", "SKILL.md"), + "---\nname: qa\ndescription: QA skill\n---\n", + ); + const block = buildGstackBlock(paths); + expect(block).toContain("/qa"); + }); +}); + +describe("upsertClaudeMd", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("creates file when absent", () => { + const target = path.join(tmp, "CLAUDE.md"); + const result = upsertClaudeMd(target, "BLOCK\n"); + expect(result.action).toBe("created"); + expect(read(target)).toBe("BLOCK\n"); + }); + + test("appends when existing file has no gstack block", () => { + const target = path.join(tmp, "CLAUDE.md"); + fs.writeFileSync(target, "# My Project\n\nExisting content.\n"); + const result = upsertClaudeMd(target, "\nnew\n\n"); + expect(result.action).toBe("inserted"); + const content = read(target); + expect(content).toContain("# My Project"); + expect(content).toContain("Existing content"); + expect(content).toContain(""); + }); + + test("replaces existing gstack block in place", () => { + const target = path.join(tmp, "CLAUDE.md"); + fs.writeFileSync( + target, + "# Head\n\n\nold\n\n\n# Tail\n", + ); + const block = "\nnew\n\n"; + const result = upsertClaudeMd(target, block); + expect(result.action).toBe("updated"); + const content = read(target); + expect(content).toContain("# Head"); + expect(content).toContain("# Tail"); + expect(content).toContain("new"); + expect(content).not.toContain("old"); + }); + + test("idempotent when block is unchanged", () => { + const target = path.join(tmp, "CLAUDE.md"); + const block = "\nsame\n\n"; + fs.writeFileSync(target, block); + const result = upsertClaudeMd(target, block); + expect(result.action).toBe("unchanged"); + }); +}); + +describe("removeGstackBlock", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("returns false when file does not exist", () => { + expect(removeGstackBlock(path.join(tmp, "nope.md"))).toBe(false); + }); + + test("returns false when no gstack block present", () => { + const target = path.join(tmp, "CLAUDE.md"); + fs.writeFileSync(target, "# Just a file\n"); + expect(removeGstackBlock(target)).toBe(false); + }); + + test("removes block and preserves surrounding content", () => { + const target = path.join(tmp, "CLAUDE.md"); + fs.writeFileSync( + target, + "# Head\n\n\nmiddle\n\n\n# Tail\n", + ); + expect(removeGstackBlock(target)).toBe(true); + const content = read(target); + expect(content).toContain("# Head"); + expect(content).toContain("# Tail"); + expect(content).not.toContain("gstack:"); + expect(content).not.toContain("middle"); + }); +}); diff --git a/installer/test/unit/cleanup.test.ts b/installer/test/unit/cleanup.test.ts new file mode 100644 index 0000000000..9d311f8f9f --- /dev/null +++ b/installer/test/unit/cleanup.test.ts @@ -0,0 +1,242 @@ +import { describe, test, expect, afterEach, beforeEach } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +import { makeTmpDir, rmTmpDir, readJson, write } from "../helpers.js"; +import { + scrubSettingsJson, + projectGstackArtifacts, + cleanupHostSymlinks, + removeGstackInstall, +} from "../../src/lib/cleanup.js"; +import type { InstallPaths } from "../../src/lib/paths.js"; + +describe("scrubSettingsJson", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("returns false when file does not exist", () => { + expect(scrubSettingsJson(path.join(tmp, "nope.json"))).toBe(false); + }); + + test("returns false when settings have no hooks", () => { + const file = write(tmp, "settings.json", JSON.stringify({ theme: "dark" })); + expect(scrubSettingsJson(file)).toBe(false); + }); + + test("returns false when settings have no gstack hooks", () => { + const file = write( + tmp, + "settings.json", + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ type: "command", command: "echo safe" }] }, + ], + }, + }), + ); + expect(scrubSettingsJson(file)).toBe(false); + }); + + test("removes gstack check-gstack hook entry but keeps others", () => { + const file = write( + tmp, + "settings.json", + JSON.stringify({ + theme: "dark", + hooks: { + PreToolUse: [ + { + matcher: "Skill", + hooks: [{ type: "command", command: "/path/to/check-gstack.sh" }], + }, + { + matcher: "Bash", + hooks: [{ type: "command", command: "echo other" }], + }, + ], + }, + }), + ); + expect(scrubSettingsJson(file)).toBe(true); + const after = readJson<{ theme: string; hooks: { PreToolUse: unknown[] } }>(file); + expect(after.theme).toBe("dark"); + expect(after.hooks.PreToolUse).toHaveLength(1); + expect(JSON.stringify(after.hooks.PreToolUse)).toContain("echo other"); + expect(JSON.stringify(after.hooks.PreToolUse)).not.toContain("check-gstack"); + }); + + test("removes gstack-session-update hook entry", () => { + const file = write( + tmp, + "settings.json", + JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "~/.gstack/bin/gstack-session-update" }] }, + ], + }, + }), + ); + expect(scrubSettingsJson(file)).toBe(true); + const after = readJson<{ hooks?: unknown }>(file); + expect(after.hooks).toBeUndefined(); + }); + + test("removes hooks object entirely when all phases become empty", () => { + const file = write( + tmp, + "settings.json", + JSON.stringify({ + hooks: { + PreToolUse: [ + { hooks: [{ type: "command", command: "check-gstack" }] }, + ], + }, + }), + ); + expect(scrubSettingsJson(file)).toBe(true); + const after = readJson<{ hooks?: unknown }>(file); + expect(after.hooks).toBeUndefined(); + }); +}); + +describe("projectGstackArtifacts", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("returns empty when no artifacts exist", () => { + expect(projectGstackArtifacts(tmp)).toEqual([]); + }); + + test("detects vendored skills dir", () => { + fs.mkdirSync(path.join(tmp, ".claude", "skills", "gstack"), { recursive: true }); + const artifacts = projectGstackArtifacts(tmp); + expect(artifacts).toHaveLength(1); + expect(artifacts[0]).toContain(".claude/skills/gstack"); + }); + + test("detects check-gstack hook script", () => { + write(tmp, ".claude/hooks/check-gstack.sh", "#!/bin/bash"); + const artifacts = projectGstackArtifacts(tmp); + expect(artifacts.some((a) => a.endsWith("check-gstack.sh"))).toBe(true); + }); + + test("detects .gstack dir", () => { + fs.mkdirSync(path.join(tmp, ".gstack")); + const artifacts = projectGstackArtifacts(tmp); + expect(artifacts.some((a) => a.endsWith(".gstack"))).toBe(true); + }); +}); + +describe("cleanupHostSymlinks", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + function makePaths(home: string): InstallPaths { + const gstackDir = path.join(home, ".claude", "skills", "gstack"); + return { + home, + claudeDir: path.join(home, ".claude"), + claudeSkillsDir: path.join(home, ".claude", "skills"), + gstackDir, + gstackStateDir: path.join(home, ".gstack"), + claudeMd: path.join(home, ".claude", "CLAUDE.md"), + }; + } + + test("removes symlinks pointing into gstack install", () => { + const paths = makePaths(tmp); + fs.mkdirSync(paths.gstackDir, { recursive: true }); + fs.mkdirSync(path.join(paths.gstackDir, "qa"), { recursive: true }); + fs.writeFileSync(path.join(paths.gstackDir, "qa", "SKILL.md"), "---\nname: qa\n---\n"); + + const linkPath = path.join(paths.claudeSkillsDir, "qa"); + fs.symlinkSync(path.join(paths.gstackDir, "qa"), linkPath); + + const result = cleanupHostSymlinks(paths); + expect(result.removedSymlinks).toContain(linkPath); + expect(fs.existsSync(linkPath)).toBe(false); + }); + + test("removes directories with SKILL.md symlinks pointing into gstack", () => { + const paths = makePaths(tmp); + fs.mkdirSync(paths.gstackDir, { recursive: true }); + fs.writeFileSync(path.join(paths.gstackDir, "SKILL.md"), "---\nname: gstack\n---\n"); + + const linkDir = path.join(paths.claudeSkillsDir, "my-skill"); + fs.mkdirSync(linkDir); + fs.symlinkSync(path.join(paths.gstackDir, "SKILL.md"), path.join(linkDir, "SKILL.md")); + + const result = cleanupHostSymlinks(paths); + expect(result.removedDirs).toContain(linkDir); + expect(fs.existsSync(linkDir)).toBe(false); + }); + + test("follows realpath when gstack dir is under a symlinked parent", () => { + const paths = makePaths(tmp); + const realGstack = path.join(tmp, "real-home", ".claude", "skills", "gstack"); + fs.mkdirSync(path.dirname(realGstack), { recursive: true }); + fs.mkdirSync(realGstack); + fs.writeFileSync(path.join(realGstack, "SKILL.md"), "---\nname: g\n---\n"); + fs.rmSync(paths.claudeDir, { recursive: true, force: true }); + fs.symlinkSync(path.join(tmp, "real-home", ".claude"), paths.claudeDir); + + const linkDir = path.join(paths.claudeSkillsDir, "sk"); + fs.mkdirSync(linkDir); + fs.symlinkSync(path.join(realGstack, "SKILL.md"), path.join(linkDir, "SKILL.md")); + + const result = cleanupHostSymlinks(paths); + expect(result.removedDirs.some((d) => d.endsWith("/sk"))).toBe(true); + }); + + test("leaves unrelated symlinks alone", () => { + const paths = makePaths(tmp); + fs.mkdirSync(paths.gstackDir, { recursive: true }); + fs.mkdirSync(path.join(tmp, "other"), { recursive: true }); + + const unrelated = path.join(paths.claudeSkillsDir, "other-skill"); + fs.mkdirSync(path.dirname(unrelated), { recursive: true }); + fs.symlinkSync(path.join(tmp, "other"), unrelated); + + cleanupHostSymlinks(paths); + expect(fs.existsSync(unrelated)).toBe(true); + }); +}); + +describe("removeGstackInstall", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("returns false when install does not exist", () => { + const paths: InstallPaths = { + home: tmp, + claudeDir: path.join(tmp, ".claude"), + claudeSkillsDir: path.join(tmp, ".claude", "skills"), + gstackDir: path.join(tmp, "nonexistent"), + gstackStateDir: path.join(tmp, ".gstack"), + claudeMd: path.join(tmp, ".claude", "CLAUDE.md"), + }; + expect(removeGstackInstall(paths)).toBe(false); + }); + + test("removes install directory", () => { + const gstackDir = path.join(tmp, ".claude", "skills", "gstack"); + fs.mkdirSync(gstackDir, { recursive: true }); + fs.writeFileSync(path.join(gstackDir, "VERSION"), "1.0.0"); + const paths: InstallPaths = { + home: tmp, + claudeDir: path.join(tmp, ".claude"), + claudeSkillsDir: path.join(tmp, ".claude", "skills"), + gstackDir, + gstackStateDir: path.join(tmp, ".gstack"), + claudeMd: path.join(tmp, ".claude", "CLAUDE.md"), + }; + expect(removeGstackInstall(paths)).toBe(true); + expect(fs.existsSync(gstackDir)).toBe(false); + }); +}); diff --git a/installer/test/unit/paths.test.ts b/installer/test/unit/paths.test.ts new file mode 100644 index 0000000000..105612d3ca --- /dev/null +++ b/installer/test/unit/paths.test.ts @@ -0,0 +1,159 @@ +import { describe, test, expect, afterEach, beforeEach } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +import { execSync } from "node:child_process"; +import { makeTmpDir, rmTmpDir } from "../helpers.js"; +import { + findGitRoot, + findLocalInstall, + isGitRepo, + isInstalled, + readVersion, + resolveProjectInstallPaths, +} from "../../src/lib/paths.js"; +import type { InstallPaths } from "../../src/lib/paths.js"; + +describe("findGitRoot", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("returns null when not in a git repo", () => { + expect(findGitRoot(tmp)).toBeNull(); + }); + + test("returns repo root from repo root", () => { + execSync("git init -q", { cwd: tmp }); + const root = findGitRoot(tmp); + expect(root).toBeTruthy(); + expect(fs.realpathSync(root!)).toBe(fs.realpathSync(tmp)); + }); + + test("walks up from subdirectory", () => { + execSync("git init -q", { cwd: tmp }); + const sub = path.join(tmp, "a", "b", "c"); + fs.mkdirSync(sub, { recursive: true }); + const root = findGitRoot(sub); + expect(root).toBeTruthy(); + expect(fs.realpathSync(root!)).toBe(fs.realpathSync(tmp)); + }); +}); + +describe("isGitRepo", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("false for non-repo", () => { + expect(isGitRepo(tmp)).toBe(false); + }); + + test("true for repo", () => { + execSync("git init -q", { cwd: tmp }); + expect(isGitRepo(tmp)).toBe(true); + }); +}); + +describe("isInstalled", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + function makePaths(gstackDir: string): InstallPaths { + return { + home: tmp, + claudeDir: path.join(tmp, ".claude"), + claudeSkillsDir: path.join(tmp, ".claude", "skills"), + gstackDir, + gstackStateDir: path.join(tmp, ".gstack"), + claudeMd: path.join(tmp, ".claude", "CLAUDE.md"), + }; + } + + test("false when directory is missing", () => { + expect(isInstalled(makePaths(path.join(tmp, "nope")))).toBe(false); + }); + + test("true for real directory", () => { + const dir = path.join(tmp, "gstack"); + fs.mkdirSync(dir); + expect(isInstalled(makePaths(dir))).toBe(true); + }); + + test("true for symlink to directory", () => { + const target = path.join(tmp, "target"); + const link = path.join(tmp, "link"); + fs.mkdirSync(target); + fs.symlinkSync(target, link); + expect(isInstalled(makePaths(link))).toBe(true); + }); +}); + +describe("resolveProjectInstallPaths", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("roots install inside the project, not home", () => { + const p = resolveProjectInstallPaths(tmp); + expect(p.gstackDir).toBe(path.join(tmp, ".claude", "skills", "gstack")); + expect(p.claudeMd).toBe(path.join(tmp, "CLAUDE.md")); + }); +}); + +describe("findLocalInstall", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("returns null when no local install is present", () => { + expect(findLocalInstall(tmp)).toBeNull(); + }); + + test("finds install at cwd", () => { + fs.mkdirSync(path.join(tmp, ".claude", "skills", "gstack"), { recursive: true }); + const found = findLocalInstall(tmp); + expect(found).toBeTruthy(); + expect(fs.realpathSync(found!.gstackDir)).toBe( + fs.realpathSync(path.join(tmp, ".claude", "skills", "gstack")), + ); + }); + + test("walks up to find install in parent", () => { + fs.mkdirSync(path.join(tmp, ".claude", "skills", "gstack"), { recursive: true }); + const sub = path.join(tmp, "a", "b"); + fs.mkdirSync(sub, { recursive: true }); + expect(findLocalInstall(sub)).toBeTruthy(); + }); +}); + +describe("readVersion", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("returns null when VERSION is missing", () => { + const paths: InstallPaths = { + home: tmp, + claudeDir: tmp, + claudeSkillsDir: tmp, + gstackDir: tmp, + gstackStateDir: tmp, + claudeMd: path.join(tmp, "CLAUDE.md"), + }; + expect(readVersion(paths)).toBeNull(); + }); + + test("reads and trims VERSION file", () => { + fs.writeFileSync(path.join(tmp, "VERSION"), "1.2.3.4\n"); + const paths: InstallPaths = { + home: tmp, + claudeDir: tmp, + claudeSkillsDir: tmp, + gstackDir: tmp, + gstackStateDir: tmp, + claudeMd: path.join(tmp, "CLAUDE.md"), + }; + expect(readVersion(paths)).toBe("1.2.3.4"); + }); +}); diff --git a/installer/test/unit/project-config.test.ts b/installer/test/unit/project-config.test.ts new file mode 100644 index 0000000000..31e44757e9 --- /dev/null +++ b/installer/test/unit/project-config.test.ts @@ -0,0 +1,98 @@ +import { describe, test, expect, afterEach, beforeEach } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +import { makeTmpDir, rmTmpDir, readJson, write } from "../helpers.js"; +import { + enableSkill, + disableSkill, + listDisabledSkills, + readSettings, + writeSettings, +} from "../../src/lib/project-config.js"; + +describe("disableSkill", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("creates settings file with disabled skill", () => { + expect(disableSkill(tmp, "qa")).toBe(true); + const s = readJson<{ disabledSkills: string[] }>( + path.join(tmp, ".claude", "settings.local.json"), + ); + expect(s.disabledSkills).toEqual(["qa"]); + }); + + test("returns false when already disabled", () => { + disableSkill(tmp, "qa"); + expect(disableSkill(tmp, "qa")).toBe(false); + }); + + test("keeps list sorted", () => { + disableSkill(tmp, "zulu"); + disableSkill(tmp, "alpha"); + disableSkill(tmp, "mike"); + expect(listDisabledSkills(tmp)).toEqual(["alpha", "mike", "zulu"]); + }); + + test("preserves other keys in settings file", () => { + write( + tmp, + ".claude/settings.local.json", + JSON.stringify({ customKey: "customValue", disabledSkills: ["existing"] }), + ); + disableSkill(tmp, "new"); + const s = readJson<{ customKey: string; disabledSkills: string[] }>( + path.join(tmp, ".claude", "settings.local.json"), + ); + expect(s.customKey).toBe("customValue"); + expect(s.disabledSkills).toEqual(["existing", "new"]); + }); +}); + +describe("enableSkill", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("returns false when skill is not disabled", () => { + expect(enableSkill(tmp, "qa")).toBe(false); + }); + + test("removes disabled skill", () => { + disableSkill(tmp, "qa"); + disableSkill(tmp, "ship"); + expect(enableSkill(tmp, "qa")).toBe(true); + expect(listDisabledSkills(tmp)).toEqual(["ship"]); + }); + + test("deletes disabledSkills key when list becomes empty", () => { + disableSkill(tmp, "qa"); + enableSkill(tmp, "qa"); + const s = readSettings(tmp); + expect(s.disabledSkills).toBeUndefined(); + }); + + test("preserves other keys when clearing disabledSkills", () => { + writeSettings(tmp, { otherKey: "keep", disabledSkills: ["only"] }); + enableSkill(tmp, "only"); + const raw = fs.readFileSync(path.join(tmp, ".claude", "settings.local.json"), "utf-8"); + expect(raw).toContain("otherKey"); + expect(raw).not.toContain("disabledSkills"); + }); +}); + +describe("readSettings", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("returns empty object when file missing", () => { + expect(readSettings(tmp)).toEqual({}); + }); + + test("returns empty object when file is invalid JSON", () => { + write(tmp, ".claude/settings.local.json", "{ not json }"); + expect(readSettings(tmp)).toEqual({}); + }); +}); diff --git a/installer/test/unit/skills.test.ts b/installer/test/unit/skills.test.ts new file mode 100644 index 0000000000..23e820930e --- /dev/null +++ b/installer/test/unit/skills.test.ts @@ -0,0 +1,139 @@ +import { describe, test, expect, afterEach, beforeEach } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +import { makeTmpDir, rmTmpDir, writeSkill } from "../helpers.js"; +import { scanSkills, skillCommandList } from "../../src/lib/skills.js"; +import type { InstallPaths } from "../../src/lib/paths.js"; + +function makePaths(home: string): InstallPaths { + const gstackDir = path.join(home, "gstack"); + fs.mkdirSync(gstackDir, { recursive: true }); + return { + home, + claudeDir: path.join(home, ".claude"), + claudeSkillsDir: path.join(home, ".claude", "skills"), + gstackDir, + gstackStateDir: path.join(home, ".gstack"), + claudeMd: path.join(home, ".claude", "CLAUDE.md"), + }; +} + +describe("scanSkills", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("returns empty for missing install", () => { + const paths: InstallPaths = { + home: tmp, + claudeDir: path.join(tmp, ".claude"), + claudeSkillsDir: path.join(tmp, ".claude", "skills"), + gstackDir: path.join(tmp, "nonexistent"), + gstackStateDir: path.join(tmp, ".gstack"), + claudeMd: path.join(tmp, ".claude", "CLAUDE.md"), + }; + expect(scanSkills(paths)).toEqual([]); + }); + + test("discovers skills with SKILL.md", () => { + const paths = makePaths(tmp); + writeSkill(paths.gstackDir, "qa", { name: "qa", description: "QA skill" }); + writeSkill(paths.gstackDir, "ship", { name: "ship", description: "Ship skill" }); + const skills = scanSkills(paths); + expect(skills).toHaveLength(2); + expect(skills.map((s) => s.skillName).sort()).toEqual(["qa", "ship"]); + }); + + test("uses frontmatter name when different from directory", () => { + const paths = makePaths(tmp); + writeSkill(paths.gstackDir, "run-tests", { name: "test", description: "Test runner" }); + const skills = scanSkills(paths); + expect(skills[0].skillName).toBe("test"); + expect(skills[0].dirName).toBe("run-tests"); + }); + + test("parses YAML block scalar descriptions (description: |)", () => { + const paths = makePaths(tmp); + fs.mkdirSync(path.join(paths.gstackDir, "autoplan")); + fs.writeFileSync( + path.join(paths.gstackDir, "autoplan", "SKILL.md"), + `--- +name: autoplan +description: | + First line of the description. + Second line that continues. +--- +body +`, + ); + const skills = scanSkills(paths); + expect(skills[0].description).toContain("First line"); + expect(skills[0].description).toContain("Second line"); + }); + + test("parses folded scalar (description: >)", () => { + const paths = makePaths(tmp); + fs.mkdirSync(path.join(paths.gstackDir, "folded")); + fs.writeFileSync( + path.join(paths.gstackDir, "folded", "SKILL.md"), + `--- +name: folded +description: > + wrapped + text + here +--- +`, + ); + const skills = scanSkills(paths); + expect(skills[0].description).toBe("wrapped text here"); + }); + + test("strips quotes from quoted description", () => { + const paths = makePaths(tmp); + writeSkill(paths.gstackDir, "q", { name: "q", description: '"quoted value"' }); + const skills = scanSkills(paths); + expect(skills[0].description).toBe("quoted value"); + }); + + test("skips node_modules and other infra dirs", () => { + const paths = makePaths(tmp); + writeSkill(paths.gstackDir, "node_modules", { name: "n", description: "d" }); + writeSkill(paths.gstackDir, "browse", { name: "b", description: "d" }); + writeSkill(paths.gstackDir, "scripts", { name: "s", description: "d" }); + writeSkill(paths.gstackDir, "real-skill", { name: "real-skill", description: "d" }); + const skills = scanSkills(paths); + expect(skills.map((s) => s.dirName)).toEqual(["real-skill"]); + }); + + test("skips dirs without SKILL.md", () => { + const paths = makePaths(tmp); + fs.mkdirSync(path.join(paths.gstackDir, "empty")); + writeSkill(paths.gstackDir, "filled", { name: "filled", description: "d" }); + const skills = scanSkills(paths); + expect(skills.map((s) => s.dirName)).toEqual(["filled"]); + }); + + test("skips dotfiles", () => { + const paths = makePaths(tmp); + writeSkill(paths.gstackDir, ".agents", { name: "a", description: "d" }); + writeSkill(paths.gstackDir, "visible", { name: "visible", description: "d" }); + const skills = scanSkills(paths); + expect(skills.map((s) => s.dirName)).toEqual(["visible"]); + }); +}); + +describe("skillCommandList", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("returns /-prefixed names", () => { + const paths = makePaths(tmp); + writeSkill(paths.gstackDir, "qa", { name: "qa", description: "d" }); + writeSkill(paths.gstackDir, "ship", { name: "ship", description: "d" }); + const commands = skillCommandList(paths); + expect(commands).toContain("/qa"); + expect(commands).toContain("/ship"); + }); +}); diff --git a/installer/tsconfig.json b/installer/tsconfig.json new file mode 100644 index 0000000000..f61467f398 --- /dev/null +++ b/installer/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": false, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "verbatimModuleSyntax": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/installer/tsconfig.test.json b/installer/tsconfig.test.json new file mode 100644 index 0000000000..1a73815434 --- /dev/null +++ b/installer/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["node", "bun"] + }, + "include": ["src/**/*", "test/**/*"] +}