@gonxped31/agent-kit 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/bin/agk.mjs +47 -312
- package/lib/cli.mjs +90 -0
- package/lib/kit.mjs +17 -8
- package/lib/merge.mjs +225 -0
- package/lib/preset-commands.mjs +320 -0
- package/lib/presets.mjs +221 -0
- package/lib/setup.mjs +205 -0
- package/lib/ui.mjs +18 -0
- package/lib/update.mjs +97 -0
- package/package.json +1 -1
- package/template/BOOTSTRAP.md +3 -0
- package/template/dot-claude/skills/agent-setup/SKILL.md +4 -0
package/README.md
CHANGED
|
@@ -72,6 +72,34 @@ file as the kit wrote it, so `agk update` can tell the files you changed:
|
|
|
72
72
|
|
|
73
73
|
It commits nothing: review the changes and commit them on a branch, like any change.
|
|
74
74
|
|
|
75
|
+
### Presets: your own setup, in every new repo
|
|
76
|
+
|
|
77
|
+
Once a repo is set up the way you like (your skills, rules, check steps, CI), save
|
|
78
|
+
that as a preset and start new repos from it:
|
|
79
|
+
|
|
80
|
+
agk preset save web # lists what the repo has beyond the kit
|
|
81
|
+
agk preset save web --only 1-3,6 # keeps the parts you pick
|
|
82
|
+
agk setup shop --preset web # the kit, plus your preset
|
|
83
|
+
|
|
84
|
+
- **What's saved**: setup files the kit doesn't have (in `.claude/`, `.github/`,
|
|
85
|
+
`.githooks/`, `scripts/hooks/`, `.gitignore`, `.mcp.json`), and what you added to the
|
|
86
|
+
kit's files: settings rules, check steps, `CLAUDE.md` bullets. Additions are kept
|
|
87
|
+
apart from the files, so they still apply when the kit's files change. Project code
|
|
88
|
+
is never looked at. In a terminal, `save` asks which parts to keep.
|
|
89
|
+
- **Where**: `~/.config/agk/presets/<name>/` on every OS (`$XDG_CONFIG_HOME` moves it).
|
|
90
|
+
A preset named `default` is used by every `agk setup`, unless `--no-preset`.
|
|
91
|
+
- **Defaults**: `agk preset save web --github [--public]` makes `agk setup --preset
|
|
92
|
+
web` create the GitHub repo too. `--no-github` and `--private` override it.
|
|
93
|
+
- **Updates**: improve the preset (save it again), then `agk update` in each repo. A
|
|
94
|
+
repo cloned on a machine without the preset still updates the kit's files.
|
|
95
|
+
- **Sharing**: a preset is a folder; make it a git repo and push it. Someone else
|
|
96
|
+
runs `agk preset add <its URL>`: agk shows the code it runs and the permissions it
|
|
97
|
+
gives Claude, asks first, and pins the commit. `agk preset pull web` fetches later
|
|
98
|
+
changes the same way.
|
|
99
|
+
- **Copying a repo's setup**: `agk preset add <URL of a repo agk set up>` lists what
|
|
100
|
+
that repo has beyond the kit, and keeps the parts you pick as a preset of your own.
|
|
101
|
+
- `agk preset list` shows your presets; `agk preset remove web` deletes one.
|
|
102
|
+
|
|
75
103
|
Your own habits, the ones that hold in every repo, go in `~/.claude/CLAUDE.md`, not in
|
|
76
104
|
the kit.
|
|
77
105
|
|
package/bin/agk.mjs
CHANGED
|
@@ -1,323 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// agk: sets up git repos for Claude Code, and brings the kit's later changes into them.
|
|
3
|
-
// - `agk setup` copies template
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// - `agk
|
|
7
|
-
//
|
|
3
|
+
// - `agk setup` (lib/setup.mjs) copies template/, and a preset if any, into a folder; the agent
|
|
4
|
+
// there follows BOOTSTRAP.md.
|
|
5
|
+
// - `agk update` (lib/update.mjs) brings in the kit's and the preset's later changes.
|
|
6
|
+
// - `agk preset ...` (lib/preset-commands.mjs) saves, shares and fetches presets: a user's own
|
|
7
|
+
// setup, layered over the kit's (lib/presets.mjs).
|
|
8
8
|
// Installed with `npm install -g @gonxped31/agent-kit` (or `npm link` in a clone); template/ is next
|
|
9
|
-
// to this file.
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
15
|
-
import { Failure, blank, clip, count, details, header, line, list, ok, out, showFailure, step, warn } from "../lib/ui.mjs";
|
|
16
|
-
|
|
17
|
-
const NOTHING_CHANGED = "Nothing was changed.";
|
|
18
|
-
|
|
19
|
-
const git = (cwd, ...args) => spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
20
|
-
const noGit = () => new Failure("git isn't installed, or isn't on the PATH", { hint: "The kit needs it: https://git-scm.com" });
|
|
21
|
-
|
|
22
|
-
/** Runs a command without blocking (so the spinner moves). `error` is set if it couldn't start. */
|
|
23
|
-
function run(cmd, args, options = {}) {
|
|
24
|
-
return new Promise((resolve) => {
|
|
25
|
-
const child = spawn(cmd, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
26
|
-
let stdout = "";
|
|
27
|
-
let stderr = "";
|
|
28
|
-
child.stdout.on("data", (d) => (stdout += d));
|
|
29
|
-
child.stderr.on("data", (d) => (stderr += d));
|
|
30
|
-
child.on("error", (error) => resolve({ status: null, stdout, stderr, error }));
|
|
31
|
-
child.on("close", (status) => resolve({ status, stdout, stderr }));
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function gh(...args) {
|
|
36
|
-
const env = { ...process.env, GH_PROMPT_DISABLED: "1" };
|
|
37
|
-
const fake = process.env.AGK_GH;
|
|
38
|
-
return fake?.endsWith(".mjs") ? run(process.execPath, [fake, ...args], { env }) : run(fake || "gh", args, { env });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** The last lines of what a command said, to show under a failure. */
|
|
42
|
-
const said = (r) => (r.stderr || r.stdout).trim().split(/\r?\n/).filter(Boolean).slice(-3);
|
|
43
|
-
|
|
44
|
-
/** A command's options and its one folder (default: the current one). */
|
|
45
|
-
function parse(cmd, args, allowed) {
|
|
46
|
-
const options = args.filter((a) => a.startsWith("-"));
|
|
47
|
-
const folders = args.filter((a) => !a.startsWith("-"));
|
|
48
|
-
const bad = options.find((o) => !allowed.includes(o));
|
|
49
|
-
if (bad) throw new Failure(`Unknown option '${bad}'`, { hint: "Run agk help for the options." });
|
|
50
|
-
if (folders.length > 1) throw new Failure(`${cmd} takes one folder, got ${folders.length}: ${folders.join(", ")}`);
|
|
51
|
-
const folder = path.resolve(folders[0] ?? ".");
|
|
52
|
-
return { options, folder, shown: path.relative(process.cwd(), folder) || "." };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** The nearest folder at or above `dir` that exists. */
|
|
56
|
-
function nearestExisting(dir) {
|
|
57
|
-
while (!fs.existsSync(dir)) dir = path.dirname(dir);
|
|
58
|
-
return dir;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* What setup would do in `root`, changing nothing: whether to create it and run git init, and
|
|
63
|
-
* what to do with each of the kit's files. The folder is a repo's root (set it up as it is),
|
|
64
|
-
* outside any repo (git init), or inside one: part of that project (refused: the hooks and
|
|
65
|
-
* CLAUDE.md work only at a repo's root), or a new project that merely sits under it, such as
|
|
66
|
-
* under a home folder that's a repo.
|
|
67
|
-
*/
|
|
68
|
-
function inspect(root, shown) {
|
|
69
|
-
if (fs.existsSync(root) && !fs.statSync(root).isDirectory()) throw new Failure(`${root} is a file, not a folder`);
|
|
70
|
-
const base = nearestExisting(root);
|
|
71
|
-
const probe = git(base, "rev-parse", "--show-toplevel");
|
|
72
|
-
if (probe.error) throw noGit();
|
|
73
|
-
const plan = { exists: fs.existsSync(root), init: probe.status !== 0, note: "", origin: "" };
|
|
74
|
-
if (!plan.init) {
|
|
75
|
-
// Compare real paths: git resolves symlinks, and path.relative ignores case on Windows.
|
|
76
|
-
const top = fs.realpathSync.native(probe.stdout.trim());
|
|
77
|
-
const here = path.join(fs.realpathSync.native(base), path.relative(base, root));
|
|
78
|
-
if (path.relative(top, here) !== "") {
|
|
79
|
-
if (plan.exists && git(root, "ls-files", "-z").stdout) {
|
|
80
|
-
throw new Failure(`${root} holds files of the repo at ${top}`, {
|
|
81
|
-
hint: `The kit sets up a whole repo, from its root: run agk setup there. ${NOTHING_CHANGED}`,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
plan.init = true;
|
|
85
|
-
plan.note = `It's inside the repo at ${top} but holds none of its files, so it gets a repo of its own.`;
|
|
86
|
-
} else {
|
|
87
|
-
plan.origin = git(root, "remote", "get-url", "origin").stdout?.trim() ?? "";
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const place = shown === "." ? "here" : `in ${shown}`;
|
|
92
|
-
if (plan.exists && setUpAlready(root)) {
|
|
93
|
-
throw new Failure(`The kit is set up ${place} already`, { hint: `To bring in its changes since, run agk update. ${NOTHING_CHANGED}` });
|
|
94
|
-
}
|
|
95
|
-
// A file the folder has already stays as it is: the kit's version goes next to it, to merge.
|
|
96
|
-
plan.files = kitFiles().map((f) => {
|
|
97
|
-
const here = hashAt(root, f.to);
|
|
98
|
-
return { ...f, state: here === null ? "new" : here === f.hash ? "same" : "differs" };
|
|
99
|
-
});
|
|
100
|
-
if (plan.files.some((f) => f.to === SETUP_ONLY && f.state === "differs")) {
|
|
101
|
-
throw new Failure(`There's a ${SETUP_ONLY} ${place} already`, {
|
|
102
|
-
hint: `The setup's instructions go in that file, for Claude Code to follow: rename yours first.\n${NOTHING_CHANGED}`,
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
const inTheWay = plan.files.filter((f) => f.state === "differs" && fs.existsSync(path.join(root, kitCopy(f.to))));
|
|
106
|
-
if (inTheWay.length) {
|
|
107
|
-
throw new Failure(`${count(inTheWay.length, "file")} in the way ${place}`, {
|
|
108
|
-
details: clip(inTheWay.map((f) => kitCopy(f.to))),
|
|
109
|
-
hint: `The kit's version of a file you have goes next to it, as <file>.kit: move these aside first.\n${NOTHING_CHANGED}`,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
return plan;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** The GitHub repo name for a folder: GitHub allows letters, digits, ".", "-" and "_". */
|
|
116
|
-
function repoName(root) {
|
|
117
|
-
const name = path.basename(root).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
118
|
-
if (!name || /^\.+$/.test(name)) throw new Failure(`Can't make a GitHub repo name from the folder name "${path.basename(root)}"`);
|
|
119
|
-
return name;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** The GitHub account gh is logged in to, once it's clear `name` is free there. */
|
|
123
|
-
async function checkGitHub(name) {
|
|
124
|
-
const user = await gh("api", "user", "--jq", ".login");
|
|
125
|
-
if (user.error) {
|
|
126
|
-
throw new Failure("--github needs gh, GitHub's command line tool, and it isn't installed", {
|
|
127
|
-
hint: `Install it from https://cli.github.com and run gh auth login, or leave out --github. ${NOTHING_CHANGED}`,
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
if (user.status !== 0) {
|
|
131
|
-
throw new Failure("gh couldn't reach your GitHub account", {
|
|
132
|
-
details: said(user),
|
|
133
|
-
hint: `Run gh auth login (or check your connection), then run this again. ${NOTHING_CHANGED}`,
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
const login = user.stdout.trim();
|
|
137
|
-
const view = await gh("repo", "view", `${login}/${name}`, "--json", "url");
|
|
138
|
-
if (view.status === 0) {
|
|
139
|
-
throw new Failure(`github.com/${login}/${name} already exists`, {
|
|
140
|
-
hint: `Name the folder differently, or leave out --github. ${NOTHING_CHANGED}`,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
if (!/could not resolve to a repository/i.test(view.stderr)) {
|
|
144
|
-
throw new Failure(`gh couldn't check whether github.com/${login}/${name} exists`, { details: said(view), hint: NOTHING_CHANGED });
|
|
145
|
-
}
|
|
146
|
-
return login;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const each = (n) => (n === 1 ? "it" : "each");
|
|
150
|
-
|
|
151
|
-
async function setup(args) {
|
|
152
|
-
const { options, folder: root, shown } = parse("setup", args, ["--github", "--public"]);
|
|
153
|
-
const github = options.includes("--github");
|
|
154
|
-
const visibility = options.includes("--public") ? "public" : "private";
|
|
155
|
-
if (visibility === "public" && !github) throw new Failure("--public goes with --github");
|
|
156
|
-
header(VERSION, "Setting up a repo for Claude Code");
|
|
157
|
-
|
|
158
|
-
// 1. Look, changing nothing: anything that would stop the setup stops it here.
|
|
159
|
-
const plan = inspect(root, shown);
|
|
160
|
-
let repo = "";
|
|
161
|
-
if (github) {
|
|
162
|
-
if (plan.origin) {
|
|
163
|
-
throw new Failure(`This repo already has a remote origin: ${plan.origin}`, { hint: `Leave out --github. ${NOTHING_CHANGED}` });
|
|
164
|
-
}
|
|
165
|
-
const name = repoName(root);
|
|
166
|
-
await step("Checking GitHub", async () => {
|
|
167
|
-
repo = `${await checkGitHub(name)}/${name}`;
|
|
168
|
-
return `GitHub: github.com/${repo} is free`;
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// 2. Set up the folder.
|
|
173
|
-
if (!plan.exists) {
|
|
174
|
-
fs.mkdirSync(root, { recursive: true });
|
|
175
|
-
ok(`Created the folder ${out.cyan(shown)}`);
|
|
176
|
-
}
|
|
177
|
-
if (plan.init) {
|
|
178
|
-
const r = git(root, "init", "-q", "-b", "main");
|
|
179
|
-
if (r.status !== 0) throw new Failure("git init failed", { details: said(r) });
|
|
180
|
-
ok("Created a git repo on main");
|
|
181
|
-
if (plan.note) warn(plan.note);
|
|
182
|
-
}
|
|
183
|
-
const fresh = plan.files.filter((f) => f.state === "new");
|
|
184
|
-
const same = plan.files.filter((f) => f.state === "same");
|
|
185
|
-
const differs = plan.files.filter((f) => f.state === "differs");
|
|
186
|
-
for (const f of fresh) put(root, f.to, f.text);
|
|
187
|
-
for (const f of differs) put(root, kitCopy(f.to), f.text);
|
|
188
|
-
writeRecord(root, plan.files);
|
|
189
|
-
const total = plan.files.length;
|
|
190
|
-
if (fresh.length === total) {
|
|
191
|
-
ok(`Copied the kit's ${total} files ${out.dim("(CLAUDE.md, BOOTSTRAP.md, .claude/, scripts/ and more)")}`);
|
|
192
|
-
} else {
|
|
193
|
-
ok(`Copied ${fresh.length} of the kit's ${total} files`);
|
|
194
|
-
if (same.length) ok(`${count(same.length, "file")} already here, the same as the kit's`);
|
|
195
|
-
if (differs.length) {
|
|
196
|
-
warn(`${count(differs.length, "file")} already here, different: the kit's version is next to ${each(differs.length)}, as <file>.kit`);
|
|
197
|
-
details(differs.map((f) => f.to));
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// 3. Create the GitHub repo. Its first push waits for the bootstrap's first commit.
|
|
202
|
-
if (github) {
|
|
203
|
-
await step(`Creating github.com/${repo} (${visibility})`, async () => {
|
|
204
|
-
const r = await gh("repo", "create", repo, `--${visibility}`, "--source", root, "--remote", "origin");
|
|
205
|
-
if (r.status !== 0) {
|
|
206
|
-
throw new Failure("GitHub didn't create the repo", {
|
|
207
|
-
details: said(r),
|
|
208
|
-
hint: `The local setup is done. To try again, from the folder:\ngh repo create ${repo} --${visibility} --source . --remote origin`,
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
return `Created github.com/${repo} (${visibility}) as remote origin`;
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const cd = shown === "." ? [] : [`cd ${/\s/.test(shown) ? `"${shown}"` : shown}`];
|
|
216
|
-
const merging = differs.length ? ", merges the .kit files into yours" : "";
|
|
217
|
-
const push = github ? `\nThen it pushes main to github.com/${repo}, once you agree.` : "";
|
|
218
|
-
list("Next", [
|
|
219
|
-
...cd,
|
|
220
|
-
`Open Claude Code (${out.cyan("claude")}) and say ${out.cyan('"follow BOOTSTRAP.md"')}\nIt asks a few questions${merging}, fills in the setup and makes the first commit.${push}`,
|
|
221
|
-
]);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Compares each of the kit's files with the repo's and with the record of what the kit wrote:
|
|
226
|
-
* - the kit's file hasn't changed, or the repo has it already: nothing to do;
|
|
227
|
-
* - the repo didn't change it: updated (or added, if the kit's is new);
|
|
228
|
-
* - the repo changed it: the kit's version goes next to it, as <file>.kit, to merge;
|
|
229
|
-
* - the repo deleted it: it stays deleted.
|
|
230
|
-
* A file the kit no longer has is removed, unless the repo changed it.
|
|
231
|
-
* Without a record (agk 0.1.0 kept none), a file that differs from the kit's counts as changed.
|
|
232
|
-
*/
|
|
233
|
-
async function update(args) {
|
|
234
|
-
const { folder, shown } = parse("update", args, []);
|
|
235
|
-
header(VERSION, "Bringing the kit's changes into a repo");
|
|
236
|
-
if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) throw new Failure(`${shown} isn't a folder`);
|
|
237
|
-
const probe = git(folder, "rev-parse", "--show-toplevel");
|
|
238
|
-
if (probe.error) throw noGit();
|
|
239
|
-
if (probe.status !== 0) {
|
|
240
|
-
throw new Failure(`${shown === "." ? "This folder" : shown} isn't in a git repo`, {
|
|
241
|
-
hint: "agk update works in a repo the kit set up. To set one up, run agk setup.",
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
const root = path.resolve(probe.stdout.trim());
|
|
245
|
-
const record = readRecord(root);
|
|
246
|
-
if (!record && !setUpAlready(root)) {
|
|
247
|
-
throw new Failure("The kit isn't set up in this repo", { hint: `To set it up, run agk setup. ${NOTHING_CHANGED}` });
|
|
248
|
-
}
|
|
249
|
-
if (record && newer(record.version, VERSION)) {
|
|
250
|
-
throw new Failure(`This repo has the kit of agk v${record.version}, newer than this agk (v${VERSION})`, {
|
|
251
|
-
hint: `Update agk first: npm install -g ${PACKAGE} (or git pull, in a clone). ${NOTHING_CHANGED}`,
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const files = kitFiles().filter((f) => f.to !== SETUP_ONLY);
|
|
256
|
-
const wrote = record?.files ?? {};
|
|
257
|
-
const c = { updated: [], added: [], merge: [], deleted: [], removed: [], kept: [] };
|
|
258
|
-
for (const f of files) {
|
|
259
|
-
const here = hashAt(root, f.to);
|
|
260
|
-
const before = wrote[f.to] ?? null;
|
|
261
|
-
if ((record && f.hash === before) || here === f.hash) continue;
|
|
262
|
-
if (here === null) (before === null ? c.added : c.deleted).push(f.to);
|
|
263
|
-
else (here === before ? c.updated : c.merge).push(f.to);
|
|
264
|
-
}
|
|
265
|
-
const kit = new Map(files.map((f) => [f.to, f]));
|
|
266
|
-
for (const [rel, before] of Object.entries(wrote)) {
|
|
267
|
-
const here = kit.has(rel) || rel === SETUP_ONLY ? null : hashAt(root, rel);
|
|
268
|
-
if (here !== null) (here === before ? c.removed : c.kept).push(rel);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
for (const rel of [...c.updated, ...c.added]) put(root, rel, kit.get(rel).text);
|
|
272
|
-
for (const rel of c.merge) put(root, kitCopy(rel), kit.get(rel).text);
|
|
273
|
-
for (const rel of c.removed) remove(root, rel);
|
|
274
|
-
const recorded = writeRecord(root, files);
|
|
275
|
-
|
|
276
|
-
if (!record) warn(`No ${RECORD} (agk 0.1.0 kept none), so a file that differs from the kit's counts as yours`);
|
|
277
|
-
const report = (paths, say, text) => {
|
|
278
|
-
if (!paths.length) return;
|
|
279
|
-
say(text(count(paths.length, "file"), paths.length));
|
|
280
|
-
details(paths);
|
|
281
|
-
};
|
|
282
|
-
report(c.updated, ok, (n) => `Updated ${n} you hadn't changed`);
|
|
283
|
-
report(c.added, ok, (n) => `Added ${n} new in the kit`);
|
|
284
|
-
report(c.removed, ok, (n) => `Removed ${n} the kit no longer has`);
|
|
285
|
-
const how = record ? "changed both here and in the kit" : "differ from the kit's";
|
|
286
|
-
report(c.merge, warn, (n, k) => `${n} ${how}: the kit's version is next to ${each(k)}, as <file>.kit`);
|
|
287
|
-
report(c.kept, warn, (n, k) => `Kept ${n} you changed, though the kit no longer has ${k === 1 ? "it" : "them"}`);
|
|
288
|
-
report(c.deleted, warn, (n, k) => `Left ${n} deleted, as you had ${k === 1 ? "it" : "them"}, though the kit changed ${k === 1 ? "it" : "them"}`);
|
|
289
|
-
const touched = c.updated.length + c.added.length + c.removed.length + c.merge.length;
|
|
290
|
-
if (!touched && !recorded) {
|
|
291
|
-
ok(`Up to date with the kit (v${VERSION})`);
|
|
292
|
-
blank();
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
if (recorded) ok(`Recorded the kit's files (v${VERSION}) in ${RECORD}`);
|
|
296
|
-
const merge = `Open Claude Code (${out.cyan("claude")}) and say ${out.cyan('"merge the .kit files"')}\nIt brings what's new in each into your file, then deletes the .kit file.\ndoctor reports them until then.`;
|
|
297
|
-
list("Next", [
|
|
298
|
-
...(c.merge.length ? [merge] : []),
|
|
299
|
-
`Review the changes (${out.cyan("git status")}), then commit them on a branch, as any change`,
|
|
300
|
-
]);
|
|
301
|
-
}
|
|
9
|
+
// to this file. AGK_GH=<path> runs that instead of gh (the kit's tests only).
|
|
10
|
+
import { VERSION } from "../lib/kit.mjs";
|
|
11
|
+
import { presetCommand } from "../lib/preset-commands.mjs";
|
|
12
|
+
import { setup } from "../lib/setup.mjs";
|
|
13
|
+
import { Failure, blank, header, line, out, showFailure } from "../lib/ui.mjs";
|
|
14
|
+
import { update } from "../lib/update.mjs";
|
|
302
15
|
|
|
303
16
|
function help() {
|
|
304
17
|
header(VERSION, "Sets up git repos for Claude Code");
|
|
305
18
|
const row = (a, b) => line(` ${out.cyan(a.padEnd(30))}${b}`);
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
19
|
+
const section = (title, rows) => {
|
|
20
|
+
line(out.bold(title));
|
|
21
|
+
for (const [a, b] of rows) row(a, b);
|
|
22
|
+
blank();
|
|
23
|
+
};
|
|
24
|
+
section("Usage", [
|
|
25
|
+
["agk setup [folder] [options]", "Set up folder (default: this one): git init if needed,"],
|
|
26
|
+
["", "then the kit's files. For a file it has already, the"],
|
|
27
|
+
["", "kit's version goes next to it, as <file>.kit, to merge"],
|
|
28
|
+
["agk update [folder]", "Bring in the kit's changes: updates the files the repo"],
|
|
29
|
+
["", "hasn't changed, and puts the kit's next to the others"],
|
|
30
|
+
["agk preset <command>", "Your own setup on top of the kit's (below)"],
|
|
31
|
+
["agk help", "Show this help"],
|
|
32
|
+
["agk --version", "Print the version"],
|
|
33
|
+
]);
|
|
34
|
+
section("Setup options", [
|
|
35
|
+
["--github", "Also create the repo on GitHub (with gh), as remote origin"],
|
|
36
|
+
["--public, --private", "With --github: its visibility (default: private)"],
|
|
37
|
+
["--preset <name>", "Set up with that preset"],
|
|
38
|
+
["--no-preset", "Leave out your default preset"],
|
|
39
|
+
["--no-github", "No GitHub repo, even if the preset asks for one"],
|
|
40
|
+
]);
|
|
41
|
+
section("Presets", [
|
|
42
|
+
["preset save <name> [folder]", "Save what a repo has beyond the kit, as a preset"],
|
|
43
|
+
["", "(--list, --only 1,3-5, --all; --github [--public]: defaults)"],
|
|
44
|
+
["preset list", "Your presets"],
|
|
45
|
+
["preset add <git-url|folder>", "Add a shared preset, or copy the setup of a repo agk"],
|
|
46
|
+
["", "set up (--name <name>, --yes)"],
|
|
47
|
+
["preset pull <name>", "Fetch a shared preset's changes, for you to review"],
|
|
48
|
+
["preset remove <name>", "Delete a preset (repos set up with it keep their files)"],
|
|
49
|
+
]);
|
|
319
50
|
line(out.bold("Examples"));
|
|
320
|
-
for (const e of ["agk setup", "agk setup my-app --github", "agk update"
|
|
51
|
+
for (const e of ["agk setup", "agk setup my-app --github", "agk update", "agk preset save web", "agk setup shop --preset web"]) {
|
|
52
|
+
line(` ${out.dim("$")} ${e}`);
|
|
53
|
+
}
|
|
321
54
|
blank();
|
|
322
55
|
}
|
|
323
56
|
|
|
@@ -328,6 +61,8 @@ async function main([cmd, ...args]) {
|
|
|
328
61
|
return wantsHelp ? help() : setup(args);
|
|
329
62
|
case "update":
|
|
330
63
|
return wantsHelp ? help() : update(args);
|
|
64
|
+
case "preset":
|
|
65
|
+
return wantsHelp ? help() : presetCommand(args);
|
|
331
66
|
case undefined:
|
|
332
67
|
case "help":
|
|
333
68
|
case "--help":
|
package/lib/cli.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// What agk's commands share: running git and gh, reading arguments, asking the user.
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Failure, ask, interactive } from "./ui.mjs";
|
|
5
|
+
|
|
6
|
+
export const NOTHING_CHANGED = "Nothing was changed.";
|
|
7
|
+
|
|
8
|
+
export const git = (cwd, ...args) => spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
9
|
+
export const noGit = () => new Failure("git isn't installed, or isn't on the PATH", { hint: "The kit needs it: https://git-scm.com" });
|
|
10
|
+
|
|
11
|
+
/** Runs a command without blocking (so the spinner moves). `error` is set if it couldn't start. */
|
|
12
|
+
export function run(cmd, args, options = {}) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const child = spawn(cmd, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
15
|
+
let stdout = "";
|
|
16
|
+
let stderr = "";
|
|
17
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
18
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
19
|
+
child.on("error", (error) => resolve({ status: null, stdout, stderr, error }));
|
|
20
|
+
child.on("close", (status) => resolve({ status, stdout, stderr }));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** gh, or the command in AGK_GH (run with node if it ends in .mjs: the kit's tests only). */
|
|
25
|
+
export function gh(...args) {
|
|
26
|
+
const env = { ...process.env, GH_PROMPT_DISABLED: "1" };
|
|
27
|
+
const fake = process.env.AGK_GH;
|
|
28
|
+
return fake?.endsWith(".mjs") ? run(process.execPath, [fake, ...args], { env }) : run(fake || "gh", args, { env });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The last lines of what a command said, to show under a failure. */
|
|
32
|
+
export const said = (r) => (r.stderr || r.stdout).trim().split(/\r?\n/).filter(Boolean).slice(-3);
|
|
33
|
+
|
|
34
|
+
export const each = (n) => (n === 1 ? "it" : "each");
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A command's arguments: `flags` take no value, `values` take one (`--preset web` or
|
|
38
|
+
* `--preset=web`), and up to `max` positional arguments, described by `what` when there are more.
|
|
39
|
+
*/
|
|
40
|
+
export function parse(cmd, args, { flags = [], values = [], max = 1, what = "one folder" } = {}) {
|
|
41
|
+
const opts = new Set();
|
|
42
|
+
const vals = {};
|
|
43
|
+
const pos = [];
|
|
44
|
+
for (let i = 0; i < args.length; i++) {
|
|
45
|
+
const a = args[i];
|
|
46
|
+
if (!a.startsWith("-") || a === "-") {
|
|
47
|
+
pos.push(a);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const [name, inline] = a.split(/=(.*)/s);
|
|
51
|
+
if (values.includes(name)) {
|
|
52
|
+
const value = inline ?? args[++i];
|
|
53
|
+
if (value === undefined || value === "" || value.startsWith("-")) throw new Failure(`${name} needs a value`, { hint: "Run agk help for the options." });
|
|
54
|
+
vals[name] = value;
|
|
55
|
+
} else if (flags.includes(name) && inline === undefined) {
|
|
56
|
+
opts.add(name);
|
|
57
|
+
} else {
|
|
58
|
+
throw new Failure(`Unknown option '${a}'`, { hint: "Run agk help for the options." });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (pos.length > max) throw new Failure(`${cmd} takes ${what}, got ${pos.length}: ${pos.join(", ")}`);
|
|
62
|
+
return { opts, vals, pos };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Both of two options that exclude each other: refused. */
|
|
66
|
+
export function exclusive(opts, a, b) {
|
|
67
|
+
if (opts.has(a) && opts.has(b)) throw new Failure(`${a} and ${b} don't go together`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const shownPath = (folder) => path.relative(process.cwd(), folder) || ".";
|
|
71
|
+
|
|
72
|
+
/** The root of the git repo `folder` is in. */
|
|
73
|
+
export function repoRoot(folder, command) {
|
|
74
|
+
const shown = shownPath(folder);
|
|
75
|
+
const probe = git(folder, "rev-parse", "--show-toplevel");
|
|
76
|
+
if (probe.error) throw noGit();
|
|
77
|
+
if (probe.status !== 0) {
|
|
78
|
+
throw new Failure(`${shown === "." ? "This folder" : shown} isn't in a git repo`, {
|
|
79
|
+
hint: `agk ${command} works in a repo the kit set up. To set one up, run agk setup.`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return path.resolve(probe.stdout.trim());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The user's OK: --yes, or a yes in a terminal. Without a terminal, --yes is needed. */
|
|
86
|
+
export async function confirm(question, opts) {
|
|
87
|
+
if (opts.has("--yes")) return true;
|
|
88
|
+
if (!interactive) throw new Failure(`${question.replace(/\?$/, "")}: that needs your OK`, { hint: "Run it in a terminal to answer, or add --yes." });
|
|
89
|
+
return /^y(es)?$/i.test(await ask(`${question} [y/N]`));
|
|
90
|
+
}
|
package/lib/kit.mjs
CHANGED
|
@@ -25,15 +25,15 @@ const RENAMED = { "CLAUDE.template.md": "CLAUDE.md", "dot-claude": ".claude" };
|
|
|
25
25
|
/** Where the kit puts its version of a file a repo already has, or changed: next to it. */
|
|
26
26
|
export const kitCopy = (rel) => `${rel}.kit`;
|
|
27
27
|
|
|
28
|
-
function walk(dir, rel = "") {
|
|
28
|
+
export function walk(dir, rel = "") {
|
|
29
29
|
return fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap((e) => {
|
|
30
30
|
const child = path.join(rel, e.name);
|
|
31
31
|
return e.isDirectory() ? walk(dir, child) : [child];
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const lf = (file) => fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
36
|
-
const hash = (text) => crypto.createHash("sha256").update(text).digest("hex");
|
|
35
|
+
export const lf = (file) => fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
36
|
+
export const hash = (text) => crypto.createHash("sha256").update(text).digest("hex");
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* The kit's files, sorted by `to`: where each is in template/ (`from`), its path in a repo (`to`,
|
|
@@ -75,7 +75,7 @@ export function remove(root, rel) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/** A path in a repo, as the record may hold one: relative, inside the repo, and not in .git. */
|
|
78
|
-
const inRepo = (rel) => !/^[\\/]|^[a-z]:|\\/i.test(rel) && !rel.split("/").some((p) => p === ".." || p === "") && !/^\.git(\/|$)/.test(rel);
|
|
78
|
+
export const inRepo = (rel) => !/^[\\/]|^[a-z]:|\\/i.test(rel) && !rel.split("/").some((p) => p === ".." || p === "") && !/^\.git(\/|$)/.test(rel);
|
|
79
79
|
|
|
80
80
|
/** A repo's record, or null if it has none. */
|
|
81
81
|
export function readRecord(root) {
|
|
@@ -86,11 +86,14 @@ export function readRecord(root) {
|
|
|
86
86
|
record = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
87
87
|
} catch {}
|
|
88
88
|
const files = record?.files;
|
|
89
|
+
const preset = record?.preset;
|
|
89
90
|
const valid =
|
|
90
91
|
typeof record?.version === "string" &&
|
|
91
92
|
files &&
|
|
92
93
|
typeof files === "object" &&
|
|
93
|
-
Object.entries(files).every(([rel, h]) => inRepo(rel) && typeof h === "string")
|
|
94
|
+
Object.entries(files).every(([rel, h]) => inRepo(rel) && typeof h === "string") &&
|
|
95
|
+
(preset === undefined ||
|
|
96
|
+
(typeof preset?.name === "string" && Array.isArray(preset.files ?? []) && (preset.files ?? []).every((rel) => typeof rel === "string" && inRepo(rel))));
|
|
94
97
|
if (!valid) {
|
|
95
98
|
throw new Failure(`${RECORD} isn't what agk wrote`, {
|
|
96
99
|
hint: `agk reads it to tell the files you changed. Restore it from git, or delete it to update\nas if the repo had none. Nothing was changed.`,
|
|
@@ -99,12 +102,18 @@ export function readRecord(root) {
|
|
|
99
102
|
return record;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Records the files agk wrote (all but the setup's instructions) in a repo, and the preset they
|
|
107
|
+
* came with. `keep` holds entries to keep as they are (a preset's files, when it's missing).
|
|
108
|
+
* False if the record held all that already.
|
|
109
|
+
*/
|
|
110
|
+
export function writeRecord(root, files, { preset = null, keep = {} } = {}) {
|
|
111
|
+
const wrote = { ...keep, ...Object.fromEntries(files.filter((f) => f.to !== SETUP_ONLY).map((f) => [f.to, f.hash])) };
|
|
104
112
|
const record = {
|
|
105
113
|
about: "Written by agk setup and agk update: the kit's files as it wrote them, so agk update can tell the ones you changed.",
|
|
106
114
|
version: VERSION,
|
|
107
|
-
|
|
115
|
+
...(preset && { preset }),
|
|
116
|
+
files: Object.fromEntries(Object.entries(wrote).sort(([a], [b]) => (a < b ? -1 : 1))),
|
|
108
117
|
};
|
|
109
118
|
const text = `${JSON.stringify(record, null, 2)}\n`;
|
|
110
119
|
const file = path.join(root, RECORD);
|