mirror of https://github.com/garrytan/gstack.git
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:
parent
f6299c4d67
commit
1a9970b354
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}`);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
Loading…
Reference in New Issue