Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@ jobs:
- run: npm run lint
- run: npm run build
- run: npm test
# Prove the package builds and packs cleanly on every change, without publishing.
- run: npm publish --dry-run
# Prove the package packs cleanly on every change. npm pack rather than
# npm publish --dry-run, since newer npm rejects a publish dry-run whenever
# package.json holds an already released version, which is the normal state
# of every pull request between releases.
- run: npm pack --dry-run
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,36 @@ Each is detected by its config directory rather than a binary on the path, since
an IDE may put nothing on the path. `--json` prints the same result as structured
output for scripts.

### install

`install` copies the gaffa skills into the tools you pick and writes a receipt
next to them, so a later uninstall knows exactly what it wrote.

```
npx @gaffa-dev/cli install
npx @gaffa-dev/cli install --tools=claude-code,codex --scope=personal
```

With no flags it asks which tools (defaulting to the ones it detects) and which
scope. Pass `--tools`, `--scope` and `-y` to run it unattended, for example in
CI. `--scope=project` writes into the working directory so you can commit the
skills with the repo, `--scope=personal` writes into your home config. A second
install refreshes to the current skills and drops any it wrote before that no
longer exist.

Until the skills are published to npm, point install at a local checkout with
`--skills-dir` or `GAFFA_SKILLS_DIR`.

### uninstall

`uninstall` removes the skills a previous install wrote, for a scope. A skill you
have edited since is left in place and reported, so your own changes are never
lost.

```
npx @gaffa-dev/cli uninstall --scope=project
```

## Develop

```
Expand Down Expand Up @@ -57,7 +87,7 @@ git push origin v0.0.2

A plain tag (`v0.0.1`) publishes under `latest`. A prerelease tag (`v0.0.1-rc.1`)
publishes under `next`, so it is opt-in and a bad one can be dropped without
touching anyone on `latest`. Every pull request runs `npm publish --dry-run` on
touching anyone on `latest`. Every pull request runs `npm pack --dry-run` on
Windows, macOS and Linux, so packaging problems show up before a real release.

The final package name is not fixed. `gaffa` is taken on npm, so the skeleton
Expand Down
198 changes: 189 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { sep } from "node:path";
import { createInterface } from "node:readline/promises";
import { runDoctor, processContext } from "./doctor.js";
import { inspectTools, TOOLS, type DoctorContext, type Scope } from "./tools.js";
import { readLocalSource } from "./skills-source.js";
import { install, uninstall, type InstallResult, type UninstallResult } from "./install.js";

const pkg = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
) as { version: string };

const IDS = TOOLS.map((t) => t.id).join(", ");

const HELP = `gaffa - the Gaffa command line tool

Usage
Expand All @@ -14,35 +21,208 @@ Usage
Commands
doctor Report which AI coding tools are installed and whether the
gaffa skills are set up in them. Add --json for machine output.
install Copy the gaffa skills into the tools you pick.
--tools=a,b tool ids, default the installed ones
--scope=project or personal, default project
--skills-dir=PATH where to read the skills from,
or set GAFFA_SKILLS_DIR
-y, --yes take the defaults, do not prompt
uninstall Remove skills a previous install wrote, for a scope. A skill you
edited since is left in place and reported.
--scope=project or personal, default project
-y, --yes take the defaults, do not prompt

Options
-v, --version Print the version and exit
-h, --help Show this help and exit

Tool ids: ${IDS}.
Until the skills are on npm, point install at a local checkout with --skills-dir
or GAFFA_SKILLS_DIR.
`;

function main(argv: string[]): number {
interface Flags {
values: Record<string, string>;
bools: Set<string>;
}

function parseFlags(args: string[]): Flags {
const values: Record<string, string> = {};
const bools = new Set<string>();
for (const arg of args) {
if (arg === "-y") {
bools.add("yes");
} else if (arg.startsWith("--")) {
const body = arg.slice(2);
const eq = body.indexOf("=");
if (eq >= 0) values[body.slice(0, eq)] = body.slice(eq + 1);
else bools.add(body);
}
}
return { values, bools };
}

function parseList(value: string | undefined): string[] {
return (value ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}

// Shorten a path for display: under the working directory it reads ./x, under
// home it reads ~/x, otherwise it stays absolute.
function shortPath(path: string, ctx: DoctorContext): string {
if (path === ctx.cwd || path.startsWith(ctx.cwd + sep)) {
const rest = path.slice(ctx.cwd.length + 1);
return rest ? "./" + rest : ".";
}
if (ctx.home && (path === ctx.home || path.startsWith(ctx.home + sep))) {
return "~" + path.slice(ctx.home.length);
}
return path;
}

async function ask(question: string, fallback: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
const answer = (await rl.question(question)).trim();
return answer.length ? answer : fallback;
} finally {
rl.close();
}
}

function validScope(scope: string): scope is Scope {
return scope === "project" || scope === "personal";
}

function formatInstall(results: InstallResult[], version: string, ctx: DoctorContext): string {
if (results.length === 0) return "Nothing to install: no target directories for those tools.\n";
const lines = [`Installed the gaffa skills (version ${version}) into ${results.length} place${results.length === 1 ? "" : "s"}:`];
let anyProject = false;
for (const r of results) {
if (r.scope === "project") anyProject = true;
lines.push(` ${shortPath(r.dir, ctx)} (${r.scope}) ${r.written.join(", ")}`);
if (r.dropped.length) lines.push(` dropped (gone from source): ${r.dropped.join(", ")}`);
if (r.kept.length) lines.push(` left in place, you had edited: ${r.kept.join(", ")}`);
}
if (anyProject) {
lines.push("");
lines.push("For a project install, commit the skills directory and its .gaffa-skills.json to share it.");
}
return lines.join("\n") + "\n";
}

function formatUninstall(results: UninstallResult[], ctx: DoctorContext): string {
const touched = results.filter((r) => r.removed.length || r.kept.length);
if (touched.length === 0) return "Nothing to uninstall: no gaffa receipt found for that scope.\n";
const lines: string[] = [];
for (const r of touched) {
lines.push(`${shortPath(r.dir, ctx)} (${r.scope}): removed ${r.removed.length} file${r.removed.length === 1 ? "" : "s"}`);
if (r.kept.length) lines.push(` left in place, you had edited: ${r.kept.join(", ")}`);
}
return lines.join("\n") + "\n";
}

async function runInstall(flags: Flags): Promise<number> {
const ctx = processContext();
const interactive = Boolean(process.stdin.isTTY) && !flags.bools.has("yes");

const skillsDir = flags.values["skills-dir"] ?? ctx.env["GAFFA_SKILLS_DIR"];
if (!skillsDir) {
process.stderr.write(
"No skills source. Pass --skills-dir or set GAFFA_SKILLS_DIR.\nFetching them from npm arrives with GAF-705.\n",
);
return 1;
}
let source;
try {
source = readLocalSource(skillsDir);
} catch (err) {
process.stderr.write(`${(err as Error).message}\n`);
return 1;
}

let tools = parseList(flags.values["tools"]);
if (tools.length === 0) {
const installed = inspectTools(ctx)
.filter((r) => r.installed)
.map((r) => r.id);
if (interactive) {
tools = parseList(
await ask(`Tools to install into [${installed.join(", ") || "none detected"}]: `, installed.join(",")),
);
} else {
tools = installed;
}
}
const unknown = tools.filter((t) => !TOOLS.some((x) => x.id === t));
if (unknown.length) {
process.stderr.write(`Unknown tool id: ${unknown.join(", ")}\nKnown: ${IDS}.\n`);
return 1;
}
if (tools.length === 0) {
process.stderr.write("No tools to install into. Pass --tools, or install a supported tool first.\n");
return 1;
}

let scope = flags.values["scope"];
if (!scope && interactive) scope = await ask("Scope, project or personal [project]: ", "project");
scope = scope ?? "project";
if (!validScope(scope)) {
process.stderr.write(`Scope must be project or personal, got ${scope}.\n`);
return 1;
}

process.stdout.write(formatInstall(install(ctx, { tools, scope, source }), source.version, ctx));
return 0;
}

async function runUninstall(flags: Flags): Promise<number> {
const ctx = processContext();
const interactive = Boolean(process.stdin.isTTY) && !flags.bools.has("yes");

let scope = flags.values["scope"];
if (!scope && interactive) scope = await ask("Scope to uninstall, project or personal [project]: ", "project");
scope = scope ?? "project";
if (!validScope(scope)) {
process.stderr.write(`Scope must be project or personal, got ${scope}.\n`);
return 1;
}

process.stdout.write(formatUninstall(uninstall(ctx, scope), ctx));
return 0;
}

async function main(argv: string[]): Promise<number> {
const args = argv.slice(2);

if (args.includes("-v") || args.includes("--version")) {
process.stdout.write(`${pkg.version}\n`);
return 0;
}

if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
process.stdout.write(HELP);
return 0;
}

if (args[0] === "doctor") {
const json = args.includes("--json");
process.stdout.write(runDoctor(processContext(), json));
const [command, ...rest] = args;
const flags = parseFlags(rest);

if (command === "doctor") {
process.stdout.write(runDoctor(processContext(), rest.includes("--json")));
return 0;
}
if (command === "install") return runInstall(flags);
if (command === "uninstall") return runUninstall(flags);

process.stderr.write(
`Unknown command: ${args.join(" ")}\nRun "gaffa --help" for usage.\n`,
);
process.stderr.write(`Unknown command: ${args.join(" ")}\nRun "gaffa --help" for usage.\n`);
return 1;
}

process.exit(main(process.argv));
main(process.argv)
.then((code) => process.exit(code))
.catch((err) => {
process.stderr.write(`${(err as Error).message}\n`);
process.exit(1);
});
Loading
Loading