diff --git a/AGENTS.md b/AGENTS.md index 4d11045..3f10622 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,8 +49,10 @@ The e2e suite encodes these invariants; a change that makes ## Conventions -- Runtime dependencies: none, and keep it that way. git is invoked via - `spawnSync`. +- Runtime dependencies: only `commander`, for CLI argument parsing in + `src/index.ts`. It is bundled into the compiled binary, so the distributed + artifact still needs no external runtime. Keep the dependency list minimal — + don't add more without good reason. git is invoked via `spawnSync`. - Only `src/index.ts` prints; other modules return data structures. - Where information belongs: code says How, tests say What (spec-style test names), commit bodies say Why, code comments say only Why-not (why the diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index afde773..6836ea5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -11,7 +11,7 @@ and the test suite drives the real git on your machine. ## Commands ```sh -bun install # dependencies (dev-only; the tool itself has none) +bun install # dependencies (commander for CLI parsing, bundled into the binary) bun test # unit + end-to-end suite bun run typecheck # tsc --noEmit bun run build # bun build --compile → dist/ghostq (single binary) diff --git a/README.md b/README.md index e247560..0dd7738 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,23 @@ run `ghostq apply` in it once. ## 📖 Commands + + ``` -ghostq install install the post-checkout hook globally (init.templateDir) -ghostq apply [path] link overlay files into the checkout (idempotent) -ghostq adopt ... move existing gitignored files into the overlay and link them -ghostq status [path] show link states and warnings without changing anything -ghostq prune [path] remove dangling ghostq-managed links (idempotent) -ghostq root print the overlay root -ghostq uninstall remove the hook wiring +Usage: ghostq [options] [command] + +re-link personal, gitignored, per-repo files on clone / worktree add + +Commands: + install install the post-checkout hook globally (init.templateDir) + uninstall remove the hook wiring + apply [path] link overlay files into the checkout (idempotent) + adopt move existing gitignored files into the overlay and link + them + status [path] show link states and warnings without changing anything + prune [path] remove dangling ghostq-managed links (idempotent) + root print the overlay root + help [command] display help for command ``` ### Adopting an existing file diff --git a/bun.lock b/bun.lock index bda7d77..b8c3af7 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "@simochee/ghostq", + "dependencies": { + "commander": "^15.0.0", + }, "devDependencies": { "@tsconfig/bun": "^1.0.10", "@types/bun": "^1.2.0", @@ -60,6 +63,8 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], diff --git a/package.json b/package.json index 48f8665..0252f3b 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,30 @@ { "name": "@simochee/ghostq", "version": "1.1.0", - "description": "Restore your gitignored per-repo files automatically on every clone and worktree via a git hook", - "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/simochee/ghostq.git" }, - "type": "module", - "engines": { - "bun": "1.3" + "devDependencies": { + "@tsconfig/bun": "^1.0.10", + "@types/bun": "^1.2.0", + "typescript": "^7" }, "bin": { "ghostq": "./src/index.ts" }, + "description": "Restore your gitignored per-repo files automatically on every clone and worktree via a git hook", + "engines": { + "bun": "1.3" + }, + "license": "MIT", "scripts": { "build": "bun build --compile ./src/index.ts --outfile dist/ghostq", "test": "bun test", "typecheck": "tsc --noEmit" }, - "devDependencies": { - "@tsconfig/bun": "^1.0.10", - "@types/bun": "^1.2.0", - "typescript": "^7" + "type": "module", + "dependencies": { + "commander": "^15.0.0" } } diff --git a/src/index.ts b/src/index.ts index a088063..018faea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,12 @@ #!/usr/bin/env bun +import { Command } from "commander"; +import pkg from "../package.json" with { type: "json" }; import { type AdoptOutcome, adoptFiles } from "./adopt.ts"; -import { applyFiles, inspectFiles, pruneFiles, type FileState } from "./apply.ts"; +import { applyFiles, type FileState, inspectFiles, pruneFiles } from "./apply.ts"; import { resolveContext } from "./context.ts"; import { install, uninstall } from "./install.ts"; import { overlayRoot } from "./paths.ts"; -const USAGE = `ghostq — re-link personal, gitignored, per-repo files on clone / worktree add - -Usage: - ghostq install install the post-checkout hook globally (init.templateDir) - ghostq apply [path] link overlay files into the checkout (idempotent) - ghostq adopt ... move existing gitignored files into the overlay and link them - ghostq status [path] show link states and warnings without changing anything - ghostq prune [path] remove dangling ghostq-managed links (idempotent) - ghostq root print the overlay root - ghostq uninstall remove the hook wiring - -Overlay root: $GHOSTQ_ROOT or \${XDG_CONFIG_HOME:-~/.config}/ghostq/overlay, -laid out as //// mirroring each repo's remote URL.`; - async function apply(path: string): Promise { const res = await resolveContext(path); if (!res.ok) { @@ -127,39 +115,64 @@ async function status(path: string): Promise { return 0; } -async function main(): Promise { - const [cmd, ...rest] = process.argv.slice(2); - switch (cmd) { - case "install": - return install(); - case "uninstall": - return uninstall(); - case "apply": - return apply(rest[0] ?? process.cwd()); - case "adopt": - if (rest.length === 0) { - console.error("ghostq: adopt requires at least one file"); - return 1; - } - return adopt(rest); - case "status": - return status(rest[0] ?? process.cwd()); - case "prune": - return prune(rest[0] ?? process.cwd()); - case "root": - console.log(overlayRoot()); - return 0; - case undefined: - case "help": - case "-h": - case "--help": - console.log(USAGE); - return cmd === undefined ? 1 : 0; - default: - console.error(`ghostq: unknown command: ${cmd}`); - console.error(USAGE); - return 1; - } -} +const program = new Command(); + +program + .name("ghostq") + .description("re-link personal, gitignored, per-repo files on clone / worktree add") + .version(pkg.version, "-v, --version", "print the version"); + +program + .command("install") + .description("install the post-checkout hook globally (init.templateDir)") + .action(async () => { + process.exit(await install()); + }); + +program + .command("uninstall") + .description("remove the hook wiring") + .action(async () => { + process.exit(await uninstall()); + }); + +program + .command("apply") + .description("link overlay files into the checkout (idempotent)") + .argument("[path]", "repo path (defaults to cwd)") + .action(async (path?: string) => { + process.exit(await apply(path ?? process.cwd())); + }); + +program + .command("adopt") + .description("move existing gitignored files into the overlay and link them") + .argument("", "one or more files to adopt") + .action(async (files: string[]) => { + process.exit(await adopt(files)); + }); + +program + .command("status") + .description("show link states and warnings without changing anything") + .argument("[path]", "repo path (defaults to cwd)") + .action(async (path?: string) => { + process.exit(await status(path ?? process.cwd())); + }); + +program + .command("prune") + .description("remove dangling ghostq-managed links (idempotent)") + .argument("[path]", "repo path (defaults to cwd)") + .action(async (path?: string) => { + process.exit(await prune(path ?? process.cwd())); + }); + +program + .command("root") + .description("print the overlay root") + .action(() => { + console.log(overlayRoot()); + }); -process.exit(await main()); +await program.parseAsync();