feat(installer): project-only (vendored) install mode

Adds a third install scope: `gstack install --local` vendors gstack into
<cwd>/.claude/skills/gstack/ instead of ~/.claude/skills/gstack/. Surfaces
./setup --local, which upstream deprecated in favor of team mode but still
supports. Guarded with an explicit deprecation notice in both the CLI
output and the interactive wizard confirmation step.

Why expose a deprecated mode:
- Some users genuinely want vendored installs (offline machines, strict
  "one project = one dir" policies, air-gapped CI, forked gstack per repo)
- ./setup already supports it, so the CLI would be lying by omission
- The wizard makes the deprecation cost visible before commit

Changes:
- paths.ts: resolveProjectInstallPaths(dir), findLocalInstall(startDir)
  walking up to find <dir>/.claude/skills/gstack, and a resolveActiveInstall
  helper that returns {paths, mode: "global" | "project-local" | "none"}
  so status/doctor/list/upgrade all detect both install kinds
- install.ts: accepts local + projectDir, routes to project paths, passes
  --local through to ./setup, restricts hosts to claude (matching setup
  behavior), prints deprecation warning
- uninstall.ts: new `gstack uninstall --local` removes the project-local
  install (searches cwd upward), distinct from `--project` which removes
  team-mode config
- wizard.ts: fourth top-level option "Install inside this project only
  (vendored)" with a confirm-before-commit step spelling out the tradeoff
- cli.ts: --local flag on install and uninstall, help text updated
- status: shows "Mode: project-local (vendored)" when applicable, yellow

Tests: +8 (85 total, still green)
- resolveProjectInstallPaths roots correctly
- findLocalInstall at cwd + walks up to parent
- status shows project-local mode when only vendored install exists
- list discovers skills in vendored install
- uninstall --local removes the vendored dir
- uninstall --local exits 1 with clear message when no install present
This commit is contained in:
jkrperson 2026-04-25 03:00:59 +08:00
parent f6299c4d67
commit 1a9970b354
13 changed files with 314 additions and 29 deletions

View File

@ -10,9 +10,11 @@ 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
@ -26,6 +28,8 @@ Works with `npx`, `bunx`, and `pnpm dlx`.
**`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.

View File

@ -109,8 +109,9 @@ ${colors.bold("Usage:")}
${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 for just this repo)
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
@ -129,7 +130,9 @@ ${colors.bold("Common options:")}
--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
--project uninstall only: remove from current project, not global
--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:")}
@ -173,6 +176,7 @@ async function main(): Promise<void> {
writeClaudeMd,
quiet,
reinstall,
local: bool(args, "local", false),
});
break;
case "init": {
@ -199,6 +203,7 @@ async function main(): Promise<void> {
case "uninstall":
await uninstall({
project: bool(args, "project", false),
local: bool(args, "local", false),
yes,
keepClaudeMd: bool(args, "keep-claude-md", false),
quiet,

View File

@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { resolveInstallPaths, isInstalled, readVersion } from "../lib/paths.js";
import { resolveActiveInstall, readVersion } from "../lib/paths.js";
import {
checkRequirements,
getBunVersion,
@ -24,7 +24,7 @@ interface Check {
export async function doctor(args: DoctorArgs): Promise<void> {
const log = createLogger(args.quiet);
const paths = resolveInstallPaths();
const { paths, mode } = resolveActiveInstall();
const checks: Check[] = [];
const sys = await checkRequirements();
@ -39,11 +39,13 @@ export async function doctor(args: DoctorArgs): Promise<void> {
detail: (await getBunVersion()) ?? "not found (required to build binaries)",
});
const installed = isInstalled(paths);
const installed = mode !== "none";
checks.push({
name: "install",
status: installed ? "ok" : "fail",
detail: installed ? paths.gstackDir : `missing (run \`gstack install\`)`,
detail: installed
? `${paths.gstackDir}${mode === "project-local" ? " (project-local)" : ""}`
: `missing (run \`gstack install\`)`,
});
if (installed) {

View File

@ -1,12 +1,16 @@
import fs from "node:fs";
import * as p from "@clack/prompts";
import { resolveInstallPaths, isInstalled } from "../lib/paths.js";
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 } from "../lib/logger.js";
import { createLogger, colors } from "../lib/logger.js";
export interface InstallArgs {
hosts: HostId[];
@ -14,12 +18,26 @@ export interface InstallArgs {
writeClaudeMd: boolean;
quiet: boolean;
reinstall: boolean;
local?: boolean;
projectDir?: string;
}
export async function installGlobal(args: InstallArgs): Promise<void> {
const paths = resolveInstallPaths();
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(", ")}`);
@ -59,10 +77,18 @@ export async function installGlobal(args: InstallArgs): Promise<void> {
}
}
const hosts = args.hosts.length > 0 ? args.hosts : (["claude"] as HostId[]);
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,
});
@ -77,9 +103,14 @@ export async function installGlobal(args: InstallArgs): Promise<void> {
}
log.plain("");
log.success("gstack installed.");
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");
}

View File

@ -1,4 +1,4 @@
import { resolveInstallPaths, isInstalled } from "../lib/paths.js";
import { resolveActiveInstall } from "../lib/paths.js";
import { scanSkills } from "../lib/skills.js";
import { createLogger, colors } from "../lib/logger.js";
@ -8,9 +8,9 @@ export interface ListArgs {
export async function list(args: ListArgs): Promise<void> {
const log = createLogger(args.quiet);
const paths = resolveInstallPaths();
const { paths, mode } = resolveActiveInstall();
if (!isInstalled(paths)) {
if (mode === "none") {
log.error("gstack is not installed. Run `gstack install` first.");
process.exit(1);
}

View File

@ -1,6 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { resolveInstallPaths, isInstalled, readVersion, findGitRoot } from "../lib/paths.js";
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";
@ -14,11 +20,22 @@ export interface StatusArgs {
export async function status(args: StatusArgs): Promise<void> {
const log = createLogger(args.quiet);
const paths = resolveInstallPaths();
const globalPaths = resolveInstallPaths();
const localPaths = findLocalInstall(process.cwd());
if (!isInstalled(paths)) {
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.");
log.dim("Run `gstack install` to install globally, or `gstack install --local` for project-only.");
return;
}
@ -30,6 +47,7 @@ export async function status(args: StatusArgs): Promise<void> {
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"}`);

View File

@ -1,7 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import * as p from "@clack/prompts";
import { resolveInstallPaths, isInstalled, findGitRoot } from "../lib/paths.js";
import {
resolveInstallPaths,
isInstalled,
findGitRoot,
findLocalInstall,
} from "../lib/paths.js";
import {
cleanupHostSymlinks,
removeGstackInstall,
@ -15,19 +20,53 @@ 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.project) {
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();

View File

@ -1,5 +1,5 @@
import * as p from "@clack/prompts";
import { resolveInstallPaths, isInstalled, readVersion } from "../lib/paths.js";
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";
@ -10,12 +10,15 @@ export interface UpgradeArgs {
export async function upgrade(args: UpgradeArgs): Promise<void> {
const log = createLogger(args.quiet);
const paths = resolveInstallPaths();
const { paths, mode } = resolveActiveInstall();
if (!isInstalled(paths)) {
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);

View File

@ -27,6 +27,45 @@ export function resolveInstallPaths(): InstallPaths {
};
}
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);

View File

@ -8,6 +8,7 @@ export interface SetupOptions {
prefix?: boolean;
team?: boolean;
noTeam?: boolean;
local?: boolean;
quiet?: boolean;
}
@ -17,6 +18,7 @@ export async function runSetup(paths: InstallPaths, opts: SetupOptions): Promise
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");

View File

@ -37,7 +37,7 @@ export async function runWizard(): Promise<void> {
);
}
type Mode = "install" | "init" | "uninstall" | "doctor";
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: [
@ -53,6 +53,11 @@ export async function runWizard(): Promise<void> {
? "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" },
],
@ -67,15 +72,23 @@ export async function runWizard(): Promise<void> {
}
if (mode === "uninstall") {
type UninstallTarget = "global" | "project";
const target = await p.select<{ value: UninstallTarget; label: string; hint?: string }[], UninstallTarget>({
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: "This project only",
hint: inRepo ? "" : "not in a git repo",
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",
@ -84,6 +97,7 @@ export async function runWizard(): Promise<void> {
const { uninstall } = await import("./commands/uninstall.js");
await uninstall({
project: target === "project",
local: target === "local",
yes: false,
keepClaudeMd: false,
quiet: false,
@ -126,6 +140,30 @@ export async function runWizard(): Promise<void> {
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.");

View File

@ -332,6 +332,65 @@ describe("cli: uninstall --project", () => {
});
});
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;

View File

@ -3,7 +3,14 @@ import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
import { makeTmpDir, rmTmpDir } from "../helpers.js";
import { findGitRoot, isGitRepo, isInstalled, readVersion } from "../../src/lib/paths.js";
import {
findGitRoot,
findLocalInstall,
isGitRepo,
isInstalled,
readVersion,
resolveProjectInstallPaths,
} from "../../src/lib/paths.js";
import type { InstallPaths } from "../../src/lib/paths.js";
describe("findGitRoot", () => {
@ -82,6 +89,44 @@ describe("isInstalled", () => {
});
});
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()));