mirror of https://github.com/garrytan/gstack.git
Merge 1a9970b354 into a3259400a3
This commit is contained in:
commit
3ceec07c2e
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
src/
|
||||
tsconfig.json
|
||||
node_modules/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
|
|
@ -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`
|
||||
|
|
@ -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 `<!-- gstack:begin -->` / `<!-- gstack:end -->` block into `~/.claude/CLAUDE.md` documenting the available skills.
|
||||
|
||||
**`install --local`** — vendored mode: installs gstack into `<cwd>/.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 <tier>` 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 <skill>` / `disable <skill>`** — 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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string | boolean>;
|
||||
list: Record<string, string[]>;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const positional: string[] = [];
|
||||
const flags: Record<string, string | boolean> = {};
|
||||
const list: Record<string, string[]> = {};
|
||||
|
||||
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 <command> [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 <name> Enable a skill in the current project
|
||||
disable <name> Disable a skill in the current project
|
||||
|
||||
${colors.bold("Common options:")}
|
||||
--host <id> 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 <t> init only: "required" or "optional" (default: required)
|
||||
--no-commit init only: stage but don't commit changes
|
||||
--local install only: vendor into <cwd>/.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<void> {
|
||||
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 <skill>"));
|
||||
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 <skill>"));
|
||||
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);
|
||||
});
|
||||
|
|
@ -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<void> {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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.`);
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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");
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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) + "…";
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
if (args.local) {
|
||||
await uninstallLocal(args);
|
||||
} else if (args.project) {
|
||||
await uninstallProject(args);
|
||||
} else {
|
||||
await uninstallGlobal(args);
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallLocal(args: UninstallArgs): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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("<!-- gstack:begin -->");
|
||||
|
||||
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.");
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = "<!-- gstack:begin -->";
|
||||
const END_MARKER = "<!-- gstack:end -->";
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<RunResult> {
|
||||
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<RunResult> {
|
||||
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<boolean> {
|
||||
const which = process.platform === "win32" ? "where" : "which";
|
||||
const result = await run(which, [cmd]);
|
||||
return result.code === 0;
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
const r = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
||||
if (r.code !== 0) return null;
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?? [];
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<SetupOptions, "host">,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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<SystemCheck> {
|
||||
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<HostId[]> {
|
||||
const found: HostId[] = [];
|
||||
for (const h of HOSTS) {
|
||||
if (await hasCmd(h.detectCmd)) found.push(h.id);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export async function getBunVersion(): Promise<string | null> {
|
||||
const r = await run("bun", ["--version"]);
|
||||
if (r.code !== 0) return null;
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
export async function getGitVersion(): Promise<string | null> {
|
||||
const r = await run("git", ["--version"]);
|
||||
if (r.code !== 0) return null;
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
|
@ -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<T>(value: T | symbol): asserts value is T {
|
||||
if (p.isCancel(value)) {
|
||||
p.cancel("Aborted.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWizard(): Promise<void> {
|
||||
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 <repo>/.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 <repo>/.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<HostId[]> {
|
||||
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[];
|
||||
}
|
||||
|
|
@ -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<string, string>,
|
||||
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<T = unknown>(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 });
|
||||
}
|
||||
|
|
@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<!-- gstack:begin -->\ngstack section\n<!-- gstack:end -->\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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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("<!-- gstack:begin -->");
|
||||
expect(block).toContain("<!-- gstack:end -->");
|
||||
});
|
||||
|
||||
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, "<!-- gstack:begin -->\nnew\n<!-- gstack:end -->\n");
|
||||
expect(result.action).toBe("inserted");
|
||||
const content = read(target);
|
||||
expect(content).toContain("# My Project");
|
||||
expect(content).toContain("Existing content");
|
||||
expect(content).toContain("<!-- gstack:begin -->");
|
||||
});
|
||||
|
||||
test("replaces existing gstack block in place", () => {
|
||||
const target = path.join(tmp, "CLAUDE.md");
|
||||
fs.writeFileSync(
|
||||
target,
|
||||
"# Head\n\n<!-- gstack:begin -->\nold\n<!-- gstack:end -->\n\n# Tail\n",
|
||||
);
|
||||
const block = "<!-- gstack:begin -->\nnew\n<!-- gstack:end -->\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 = "<!-- gstack:begin -->\nsame\n<!-- gstack:end -->\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<!-- gstack:begin -->\nmiddle\n<!-- gstack:end -->\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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": ".",
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"]
|
||||
}
|
||||
Loading…
Reference in New Issue