@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.
@@ -0,0 +1,221 @@
1
+ // Presets: a user's own setup, layered over the kit's. One folder per preset, on the user's machine:
2
+ //
3
+ // ~/.config/agk/presets/<name>/ ($XDG_CONFIG_HOME/agk/presets when it's set)
4
+ // preset.json { name, savedWith, from, defaults: { github, visibility } }
5
+ // files/ whole files, at their path in a repo (one can replace a kit file)
6
+ // additions/ additions to the kit's files, at the same paths (see merge.mjs)
7
+ //
8
+ // A preset added from a git repo is a clone of it; git config agk.source in the clone names the
9
+ // repo, and its HEAD is the version in use until `agk preset pull`.
10
+ import { spawnSync } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { RECORD, SETUP_ONLY, hash, inRepo, lf, walk } from "./kit.mjs";
15
+ import { additionsOf, buildAdditions, describeAddition, kindOf, mergeAdditions } from "./merge.mjs";
16
+ import { Failure } from "./ui.mjs";
17
+
18
+ export function presetsDir() {
19
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "agk", "presets");
20
+ }
21
+
22
+ export const validName = (name) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && name.length <= 64;
23
+ /** A preset under this name is used by agk setup unless told otherwise. */
24
+ export const DEFAULT = "default";
25
+
26
+ const gitOut = (dir, ...args) => {
27
+ const r = spawnSync("git", args, { cwd: dir, encoding: "utf8" });
28
+ return r.status === 0 ? r.stdout.trim() : null;
29
+ };
30
+ const isRepo = (dir) => fs.existsSync(path.join(dir, ".git"));
31
+
32
+ /** The files under a preset's subfolder, as { to, text }. */
33
+ function filesUnder(dir) {
34
+ if (!fs.existsSync(dir)) return [];
35
+ return walk(dir).map((rel) => ({ to: rel.split(path.sep).join("/"), text: lf(path.join(dir, rel)) }));
36
+ }
37
+
38
+ /** The preset in `dir`, named `name`. Throws a Failure if it can't be used. */
39
+ export function loadPresetAt(dir, name) {
40
+ let meta;
41
+ try {
42
+ meta = JSON.parse(fs.readFileSync(path.join(dir, "preset.json"), "utf8"));
43
+ } catch (e) {
44
+ throw new Failure(`The preset ${name} has no readable preset.json`, { details: [e.message], hint: `It's in ${dir}.` });
45
+ }
46
+ const files = filesUnder(path.join(dir, "files"));
47
+ const additions = filesUnder(path.join(dir, "additions"));
48
+ const bad = [...files, ...additions].find((f) => !inRepo(f.to) || f.to === SETUP_ONLY || f.to === RECORD || f.to.endsWith(".kit"));
49
+ if (bad) throw new Failure(`The preset ${name} has a file agk won't write: ${bad.to}`, { hint: `It's in ${dir}.` });
50
+ const repo = isRepo(dir);
51
+ return {
52
+ name,
53
+ dir,
54
+ meta: meta && typeof meta === "object" ? meta : {},
55
+ files,
56
+ additions,
57
+ source: repo ? gitOut(dir, "config", "--get", "agk.source") : null,
58
+ commit: repo ? gitOut(dir, "rev-parse", "--short", "HEAD") : null,
59
+ };
60
+ }
61
+
62
+ /** The preset named `name`, or null if there's none. */
63
+ export function loadPreset(name) {
64
+ const dir = path.join(presetsDir(), name);
65
+ return validName(name) && fs.existsSync(path.join(dir, "preset.json")) ? loadPresetAt(dir, name) : null;
66
+ }
67
+
68
+ /** Every preset, by name. One that can't be read comes with its `error`. */
69
+ export function listPresets() {
70
+ const dir = presetsDir();
71
+ if (!fs.existsSync(dir)) return [];
72
+ return fs
73
+ .readdirSync(dir, { withFileTypes: true })
74
+ .filter((e) => e.isDirectory() && validName(e.name) && fs.existsSync(path.join(dir, e.name, "preset.json")))
75
+ .map((e) => {
76
+ try {
77
+ return loadPresetAt(path.join(dir, e.name), e.name);
78
+ } catch (error) {
79
+ return { name: e.name, error };
80
+ }
81
+ })
82
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
83
+ }
84
+
85
+ /**
86
+ * The files agk writes: the kit's, with a preset's files and additions on top. Each is
87
+ * { to, text, hash, preset } (preset: the preset added or changed it). A preset's addition to a
88
+ * file that isn't there is skipped, with a warning.
89
+ */
90
+ export function compose(kit, preset) {
91
+ const map = new Map(kit.map((f) => [f.to, { to: f.to, text: f.text, preset: false }]));
92
+ const warnings = [];
93
+ if (preset) {
94
+ for (const f of preset.files) map.set(f.to, { to: f.to, text: f.text, preset: true, replaces: map.has(f.to) });
95
+ for (const a of preset.additions) {
96
+ const base = map.get(a.to);
97
+ if (!base) {
98
+ warnings.push(`Preset ${preset.name} adds to ${a.to}, which the kit doesn't have: skipped`);
99
+ continue;
100
+ }
101
+ try {
102
+ map.set(a.to, { ...base, text: mergeAdditions(a.to, base.text, a.text), preset: true, extends: true });
103
+ } catch (e) {
104
+ warnings.push(`Preset ${preset.name}'s additions to ${a.to} don't parse (${e.message}): skipped`);
105
+ }
106
+ }
107
+ }
108
+ const files = [...map.values()].map((f) => ({ ...f, hash: hash(f.text) })).sort((a, b) => (a.to < b.to ? -1 : 1));
109
+ return { files, warnings };
110
+ }
111
+
112
+ /** What a preset brings, in words: "2 files of its own, 3 of the kit's extended, 1 replaced". */
113
+ export function presetSummary(files) {
114
+ const own = files.filter((f) => f.preset && !f.replaces && !f.extends).length;
115
+ const extended = files.filter((f) => f.extends && !f.replaces).length;
116
+ const replaced = files.filter((f) => f.replaces).length;
117
+ const parts = [
118
+ own && `${own} file${own === 1 ? "" : "s"} of its own`,
119
+ extended && `${extended} of the kit's extended`,
120
+ replaced && `${replaced} replaced`,
121
+ ].filter(Boolean);
122
+ return parts.length ? parts.join(", ") : "nothing beyond the kit";
123
+ }
124
+
125
+ /** What the record says about the preset used. */
126
+ export const presetRecord = (preset, files) => ({
127
+ name: preset.name,
128
+ ...(preset.source && { source: preset.source }),
129
+ ...(preset.commit && { commit: preset.commit }),
130
+ files: files.filter((f) => f.preset).map((f) => f.to),
131
+ });
132
+
133
+ // ---- Saving: what a repo has beyond the kit
134
+
135
+ /** Where a repo's agent setup lives: files elsewhere are the project's own, never a preset's. */
136
+ const IN_SETUP = (rel) =>
137
+ /^(\.claude|\.githooks|\.github|scripts\/hooks)\//.test(rel) || rel === ".gitignore" || rel === ".mcp.json";
138
+ /** Setup files that are never a preset's: the record, personal settings, merges in waiting. */
139
+ const NEVER = (rel) => rel === RECORD || rel === ".claude/settings.local.json" || rel.endsWith(".kit");
140
+
141
+ /**
142
+ * What a repo has beyond the kit, as candidates for a preset:
143
+ * - { type: "file", to, text }: a setup file the kit doesn't have;
144
+ * - { type: "addition", to, item }: something added to one of the kit's files;
145
+ * - { type: "replace", to, text }: a kit file changed in a way additions can't hold (code).
146
+ * Files come from git: tracked, and untracked but not ignored.
147
+ */
148
+ export function candidates(root, kit) {
149
+ const r = spawnSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: root, encoding: "utf8" });
150
+ const listed = r.status === 0 ? [...new Set(r.stdout.split("\0").filter(Boolean))].sort() : [];
151
+ const kitPaths = new Set(kit.map((f) => f.to));
152
+ const isFile = (rel) => fs.existsSync(path.join(root, rel)) && fs.statSync(path.join(root, rel)).isFile();
153
+ const out = [];
154
+ for (const rel of listed) {
155
+ if (!kitPaths.has(rel) && IN_SETUP(rel) && !NEVER(rel) && isFile(rel)) out.push({ type: "file", to: rel, text: lf(path.join(root, rel)) });
156
+ }
157
+ for (const f of kit) {
158
+ if (f.to === SETUP_ONLY || !isFile(f.to)) continue;
159
+ const here = lf(path.join(root, f.to));
160
+ if (here === f.text) continue;
161
+ const items = additionsOf(f.to, here, f.text);
162
+ if (items?.length) out.push(...items.map((item) => ({ type: "addition", to: f.to, item })));
163
+ // CLAUDE.md always differs (the bootstrap fills it in), and the project's own text is no preset's.
164
+ else if (f.to !== "CLAUDE.md") out.push({ type: "replace", to: f.to, text: here });
165
+ }
166
+ return out;
167
+ }
168
+
169
+ export function describeCandidate(c) {
170
+ if (c.type === "file") return `file ${c.to}`;
171
+ if (c.type === "replace") return `replaces ${c.to} (the kit's version)`;
172
+ return `addition ${c.to} ${describeAddition(c.to, c.item)}`;
173
+ }
174
+
175
+ /** Writes a preset from chosen candidates, replacing what the folder had (but a .git). */
176
+ export function writePreset(dir, meta, chosen) {
177
+ fs.mkdirSync(dir, { recursive: true });
178
+ for (const e of fs.readdirSync(dir)) if (e !== ".git") fs.rmSync(path.join(dir, e), { recursive: true, force: true });
179
+ const write = (rel, text) => {
180
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
181
+ fs.writeFileSync(path.join(dir, rel), text);
182
+ };
183
+ write("preset.json", `${JSON.stringify(meta, null, 2)}\n`);
184
+ const additions = new Map();
185
+ for (const c of chosen) {
186
+ if (c.type === "addition") {
187
+ if (!additions.has(c.to)) additions.set(c.to, []);
188
+ additions.get(c.to).push(c.item);
189
+ } else {
190
+ write(path.posix.join("files", c.to), c.text);
191
+ }
192
+ }
193
+ for (const [to, items] of additions) write(path.posix.join("additions", to), buildAdditions(to, items));
194
+ return { files: chosen.filter((c) => c.type !== "addition").length, additions: additions.size };
195
+ }
196
+
197
+ // ---- Trust: what a preset lets run, or lets Claude do
198
+
199
+ /** The Claude hooks a settings file adds, and the commands it lets Claude run without asking. */
200
+ function settingsPowers(text) {
201
+ try {
202
+ const s = JSON.parse(text);
203
+ const hooks = Object.values(s.hooks ?? {}).flatMap((entries) => (Array.isArray(entries) ? entries : []).flatMap((e) => (e.hooks ?? []).map((h) => h.command).filter(Boolean)));
204
+ return { hooks, allow: Array.isArray(s.permissions?.allow) ? s.permissions.allow : [] };
205
+ } catch {
206
+ return { hooks: [], allow: [] };
207
+ }
208
+ }
209
+
210
+ /** What in a preset can run code or widen what Claude may do, to show before using it. */
211
+ export function powersOf(preset) {
212
+ const all = [...preset.files, ...preset.additions];
213
+ const code = preset.files.filter((f) => kindOf(f.to) === "code" || f.to.startsWith(".claude/hooks/")).map((f) => f.to);
214
+ const settings = all.filter((f) => f.to === ".claude/settings.json").map((f) => settingsPowers(f.text));
215
+ return {
216
+ code,
217
+ hooks: settings.flatMap((s) => s.hooks),
218
+ allow: settings.flatMap((s) => s.allow),
219
+ mcp: all.some((f) => f.to === ".mcp.json"),
220
+ };
221
+ }
package/lib/setup.mjs ADDED
@@ -0,0 +1,205 @@
1
+ // agk setup: copies the kit (and a preset, if any) into a folder, then the agent there follows
2
+ // BOOTSTRAP.md. Where the folder has one of the files already, the kit's version goes next to it
3
+ // as <file>.kit, for the agent to merge. With --github it also creates the repo on GitHub, via gh.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { NOTHING_CHANGED, each, exclusive, gh, git, noGit, parse, said, shownPath } from "./cli.mjs";
7
+ import { SETUP_ONLY, VERSION, hashAt, kitCopy, kitFiles, put, setUpAlready, writeRecord } from "./kit.mjs";
8
+ import { DEFAULT, compose, loadPreset, presetRecord, presetSummary } from "./presets.mjs";
9
+ import { Failure, clip, count, details, header, list, ok, out, step, warn } from "./ui.mjs";
10
+
11
+ /** The nearest folder at or above `dir` that exists. */
12
+ function nearestExisting(dir) {
13
+ while (!fs.existsSync(dir)) dir = path.dirname(dir);
14
+ return dir;
15
+ }
16
+
17
+ /**
18
+ * What setup would do in `root`, changing nothing: whether to create it and run git init, and
19
+ * what to do with each file. The folder is a repo's root (set it up as it is), outside any repo
20
+ * (git init), or inside one: part of that project (refused: the hooks and CLAUDE.md work only at
21
+ * a repo's root), or a new project that merely sits under it, such as under a home folder that's
22
+ * a repo.
23
+ */
24
+ function inspect(root, shown, files) {
25
+ if (fs.existsSync(root) && !fs.statSync(root).isDirectory()) throw new Failure(`${root} is a file, not a folder`);
26
+ const base = nearestExisting(root);
27
+ const probe = git(base, "rev-parse", "--show-toplevel");
28
+ if (probe.error) throw noGit();
29
+ const plan = { exists: fs.existsSync(root), init: probe.status !== 0, note: "", origin: "" };
30
+ if (!plan.init) {
31
+ // Compare real paths: git resolves symlinks, and path.relative ignores case on Windows.
32
+ const top = fs.realpathSync.native(probe.stdout.trim());
33
+ const here = path.join(fs.realpathSync.native(base), path.relative(base, root));
34
+ if (path.relative(top, here) !== "") {
35
+ if (plan.exists && git(root, "ls-files", "-z").stdout) {
36
+ throw new Failure(`${root} holds files of the repo at ${top}`, {
37
+ hint: `The kit sets up a whole repo, from its root: run agk setup there. ${NOTHING_CHANGED}`,
38
+ });
39
+ }
40
+ plan.init = true;
41
+ plan.note = `It's inside the repo at ${top} but holds none of its files, so it gets a repo of its own.`;
42
+ } else {
43
+ plan.origin = git(root, "remote", "get-url", "origin").stdout?.trim() ?? "";
44
+ }
45
+ }
46
+
47
+ const place = shown === "." ? "here" : `in ${shown}`;
48
+ if (plan.exists && setUpAlready(root)) {
49
+ throw new Failure(`The kit is set up ${place} already`, { hint: `To bring in its changes since, run agk update. ${NOTHING_CHANGED}` });
50
+ }
51
+ // A file the folder has already stays as it is: the kit's version goes next to it, to merge.
52
+ plan.files = files.map((f) => {
53
+ const here = hashAt(root, f.to);
54
+ return { ...f, state: here === null ? "new" : here === f.hash ? "same" : "differs" };
55
+ });
56
+ if (plan.files.some((f) => f.to === SETUP_ONLY && f.state === "differs")) {
57
+ throw new Failure(`There's a ${SETUP_ONLY} ${place} already`, {
58
+ hint: `The setup's instructions go in that file, for Claude Code to follow: rename yours first.\n${NOTHING_CHANGED}`,
59
+ });
60
+ }
61
+ const inTheWay = plan.files.filter((f) => f.state === "differs" && fs.existsSync(path.join(root, kitCopy(f.to))));
62
+ if (inTheWay.length) {
63
+ throw new Failure(`${count(inTheWay.length, "file")} in the way ${place}`, {
64
+ details: clip(inTheWay.map((f) => kitCopy(f.to))),
65
+ hint: `The kit's version of a file you have goes next to it, as <file>.kit: move these aside first.\n${NOTHING_CHANGED}`,
66
+ });
67
+ }
68
+ return plan;
69
+ }
70
+
71
+ /** The GitHub repo name for a folder: GitHub allows letters, digits, ".", "-" and "_". */
72
+ function repoName(root) {
73
+ const name = path.basename(root).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
74
+ if (!name || /^\.+$/.test(name)) throw new Failure(`Can't make a GitHub repo name from the folder name "${path.basename(root)}"`);
75
+ return name;
76
+ }
77
+
78
+ /** The GitHub account gh is logged in to, once it's clear `name` is free there. */
79
+ async function checkGitHub(name, leaveOut) {
80
+ const user = await gh("api", "user", "--jq", ".login");
81
+ if (user.error) {
82
+ throw new Failure("--github needs gh, GitHub's command line tool, and it isn't installed", {
83
+ hint: `Install it from https://cli.github.com and run gh auth login, or ${leaveOut}. ${NOTHING_CHANGED}`,
84
+ });
85
+ }
86
+ if (user.status !== 0) {
87
+ throw new Failure("gh couldn't reach your GitHub account", {
88
+ details: said(user),
89
+ hint: `Run gh auth login (or check your connection), then run this again. ${NOTHING_CHANGED}`,
90
+ });
91
+ }
92
+ const login = user.stdout.trim();
93
+ const view = await gh("repo", "view", `${login}/${name}`, "--json", "url");
94
+ if (view.status === 0) {
95
+ throw new Failure(`github.com/${login}/${name} already exists`, {
96
+ hint: `Name the folder differently, or ${leaveOut}. ${NOTHING_CHANGED}`,
97
+ });
98
+ }
99
+ if (!/could not resolve to a repository/i.test(view.stderr)) {
100
+ throw new Failure(`gh couldn't check whether github.com/${login}/${name} exists`, { details: said(view), hint: NOTHING_CHANGED });
101
+ }
102
+ return login;
103
+ }
104
+
105
+ /** The preset to use: --preset's, else the default one unless --no-preset. */
106
+ function choosePreset(opts, vals) {
107
+ const name = vals["--preset"];
108
+ if (name) {
109
+ const preset = loadPreset(name);
110
+ if (!preset) throw new Failure(`There's no preset named ${name}`, { hint: `agk preset list shows yours. ${NOTHING_CHANGED}` });
111
+ return { preset, note: "" };
112
+ }
113
+ if (opts.has("--no-preset")) return { preset: null, note: "" };
114
+ const preset = loadPreset(DEFAULT);
115
+ return { preset, note: preset ? out.dim(" (your default; --no-preset leaves it out)") : "" };
116
+ }
117
+
118
+ export async function setup(args) {
119
+ const { opts, vals, pos } = parse("setup", args, {
120
+ flags: ["--github", "--no-github", "--public", "--private", "--no-preset"],
121
+ values: ["--preset"],
122
+ });
123
+ exclusive(opts, "--github", "--no-github");
124
+ exclusive(opts, "--public", "--private");
125
+ if (vals["--preset"] && opts.has("--no-preset")) throw new Failure("--preset and --no-preset don't go together");
126
+ const root = path.resolve(pos[0] ?? ".");
127
+ const shown = shownPath(root);
128
+ header(VERSION, "Setting up a repo for Claude Code");
129
+
130
+ // 1. Look, changing nothing: anything that would stop the setup stops it here.
131
+ const { preset, note } = choosePreset(opts, vals);
132
+ const defaults = preset?.meta.defaults ?? {};
133
+ const github = opts.has("--github") || (!opts.has("--no-github") && defaults.github === true);
134
+ const visibility = opts.has("--public") || (!opts.has("--private") && defaults.visibility === "public") ? "public" : "private";
135
+ for (const flag of ["--public", "--private"]) if (opts.has(flag) && !github) throw new Failure(`${flag} goes with --github`);
136
+ const { files, warnings } = compose(kitFiles(), preset);
137
+ const plan = inspect(root, shown, files);
138
+ let repo = "";
139
+ if (github) {
140
+ // Said in every hint: how to do without GitHub, whether --github or the preset asked for it.
141
+ const leaveOut = opts.has("--github") ? "leave out --github" : `add --no-github (preset ${preset.name} asks for GitHub)`;
142
+ if (plan.origin) {
143
+ throw new Failure(`This repo already has a remote origin: ${plan.origin}`, { hint: `To set it up anyway, ${leaveOut}. ${NOTHING_CHANGED}` });
144
+ }
145
+ const name = repoName(root);
146
+ await step("Checking GitHub", async () => {
147
+ repo = `${await checkGitHub(name, leaveOut)}/${name}`;
148
+ return `GitHub: github.com/${repo} is free`;
149
+ });
150
+ }
151
+
152
+ // 2. Set up the folder.
153
+ if (preset) ok(`Preset ${out.cyan(preset.name)}: ${presetSummary(plan.files)}${note}`);
154
+ for (const w of warnings) warn(w);
155
+ if (!plan.exists) {
156
+ fs.mkdirSync(root, { recursive: true });
157
+ ok(`Created the folder ${out.cyan(shown)}`);
158
+ }
159
+ if (plan.init) {
160
+ const r = git(root, "init", "-q", "-b", "main");
161
+ if (r.status !== 0) throw new Failure("git init failed", { details: said(r) });
162
+ ok("Created a git repo on main");
163
+ if (plan.note) warn(plan.note);
164
+ }
165
+ const fresh = plan.files.filter((f) => f.state === "new");
166
+ const same = plan.files.filter((f) => f.state === "same");
167
+ const differs = plan.files.filter((f) => f.state === "differs");
168
+ for (const f of fresh) put(root, f.to, f.text);
169
+ for (const f of differs) put(root, kitCopy(f.to), f.text);
170
+ writeRecord(root, plan.files, { preset: preset && presetRecord(preset, plan.files) });
171
+ const total = plan.files.length;
172
+ const whose = preset ? "" : "the kit's ";
173
+ if (fresh.length === total) {
174
+ ok(preset ? `Copied all ${total} files` : `Copied the kit's ${total} files ${out.dim("(CLAUDE.md, BOOTSTRAP.md, .claude/, scripts/ and more)")}`);
175
+ } else {
176
+ ok(`Copied ${fresh.length} of ${whose}${total} files`);
177
+ if (same.length) ok(`${count(same.length, "file")} already here, the same as the ${preset ? "setup's" : "kit's"}`);
178
+ if (differs.length) {
179
+ warn(`${count(differs.length, "file")} already here, different: the kit's version is next to ${each(differs.length)}, as <file>.kit`);
180
+ details(differs.map((f) => f.to));
181
+ }
182
+ }
183
+
184
+ // 3. Create the GitHub repo. Its first push waits for the bootstrap's first commit.
185
+ if (github) {
186
+ await step(`Creating github.com/${repo} (${visibility})`, async () => {
187
+ const r = await gh("repo", "create", repo, `--${visibility}`, "--source", root, "--remote", "origin");
188
+ if (r.status !== 0) {
189
+ throw new Failure("GitHub didn't create the repo", {
190
+ details: said(r),
191
+ hint: `The local setup is done. To try again, from the folder:\ngh repo create ${repo} --${visibility} --source . --remote origin`,
192
+ });
193
+ }
194
+ return `Created github.com/${repo} (${visibility}) as remote origin`;
195
+ });
196
+ }
197
+
198
+ const cd = shown === "." ? [] : [`cd ${/\s/.test(shown) ? `"${shown}"` : shown}`];
199
+ const merging = differs.length ? ", merges the .kit files into yours" : "";
200
+ const push = github ? `\nThen it pushes main to github.com/${repo}, once you agree.` : "";
201
+ list("Next", [
202
+ ...cd,
203
+ `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}`,
204
+ ]);
205
+ }
package/lib/ui.mjs CHANGED
@@ -7,8 +7,12 @@
7
7
  // - The spinner moves only in a terminal. Elsewhere (a pipe, Claude Code) each step prints one
8
8
  // line when it's done.
9
9
 
10
+ import readline from "node:readline";
11
+
10
12
  const env = process.env;
11
13
  const tty = Boolean(process.stdout.isTTY) && env.TERM !== "dumb";
14
+ /** True when someone can answer a question: both ends are a terminal. */
15
+ export const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
12
16
 
13
17
  function colorOn(stream) {
14
18
  if (env.FORCE_COLOR) return env.FORCE_COLOR !== "0";
@@ -42,6 +46,20 @@ export const warn = (text) => line(`${out.yellow(sym.warn)} ${text}`);
42
46
  export const details = (items) => {
43
47
  for (const item of clip(items)) line(` ${item}`);
44
48
  };
49
+ /** Asks a question in the terminal and returns the answer, trimmed. */
50
+ export function ask(question) {
51
+ return new Promise((resolve) => {
52
+ // terminal: false: no raw mode, so it also reads a piped answer (the kit's tests).
53
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
54
+ process.stdout.write(` ${out.bold(question)} `);
55
+ // Resolve before close(): it emits "close" at once, and a closed input answers "".
56
+ rl.once("line", (answer) => {
57
+ resolve(answer.trim());
58
+ rl.close();
59
+ });
60
+ rl.once("close", () => resolve(""));
61
+ });
62
+ }
45
63
  /** "1 file", "2 files". */
46
64
  export const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
47
65
 
package/lib/update.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // agk update: brings the kit's (and the preset's) later changes into a repo the kit set up.
2
+ //
3
+ // It compares each file agk writes now with the repo's file and with the record of what agk wrote:
4
+ // - agk's file hasn't changed, or the repo has it already: nothing to do;
5
+ // - the repo didn't change it: updated (or added, if it's new);
6
+ // - the repo changed it: agk's version goes next to it, as <file>.kit, to merge;
7
+ // - the repo deleted it: it stays deleted.
8
+ // A file agk no longer writes is removed, unless the repo changed it.
9
+ // Without a record (agk 0.1.0 kept none), a file that differs from the kit's counts as changed.
10
+ // If the repo's preset isn't on this machine, the files it added or changed stay as they are.
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { NOTHING_CHANGED, each, parse, repoRoot, shownPath } from "./cli.mjs";
14
+ import { PACKAGE, RECORD, SETUP_ONLY, VERSION, hashAt, kitCopy, kitFiles, newer, put, readRecord, remove, setUpAlready, writeRecord } from "./kit.mjs";
15
+ import { compose, loadPreset, presetRecord } from "./presets.mjs";
16
+ import { Failure, blank, count, details, header, list, ok, out, warn } from "./ui.mjs";
17
+
18
+ export async function update(args) {
19
+ const { pos } = parse("update", args);
20
+ const folder = path.resolve(pos[0] ?? ".");
21
+ const shown = shownPath(folder);
22
+ header(VERSION, "Bringing the kit's changes into a repo");
23
+ if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) throw new Failure(`${shown} isn't a folder`);
24
+ const root = repoRoot(folder, "update");
25
+ const record = readRecord(root);
26
+ if (!record && !setUpAlready(root)) {
27
+ throw new Failure("The kit isn't set up in this repo", { hint: `To set it up, run agk setup. ${NOTHING_CHANGED}` });
28
+ }
29
+ if (record && newer(record.version, VERSION)) {
30
+ throw new Failure(`This repo has the kit of agk v${record.version}, newer than this agk (v${VERSION})`, {
31
+ hint: `Update agk first: npm install -g ${PACKAGE} (or git pull, in a clone). ${NOTHING_CHANGED}`,
32
+ });
33
+ }
34
+
35
+ // The preset the repo was set up with, if it's on this machine. If not, its files are left out.
36
+ const used = record?.preset;
37
+ const preset = used ? loadPreset(used.name) : null;
38
+ const skip = new Set(used && !preset ? used.files ?? [] : []);
39
+ const composed = compose(kitFiles(), preset);
40
+ const files = composed.files.filter((f) => f.to !== SETUP_ONLY && !skip.has(f.to));
41
+ const wrote = record?.files ?? {};
42
+ const c = { updated: [], added: [], merge: [], deleted: [], removed: [], kept: [] };
43
+ for (const f of files) {
44
+ const here = hashAt(root, f.to);
45
+ const before = wrote[f.to] ?? null;
46
+ if ((record && f.hash === before) || here === f.hash) continue;
47
+ if (here === null) (before === null ? c.added : c.deleted).push(f.to);
48
+ else (here === before ? c.updated : c.merge).push(f.to);
49
+ }
50
+ const ours = new Map(files.map((f) => [f.to, f]));
51
+ for (const [rel, before] of Object.entries(wrote)) {
52
+ const here = ours.has(rel) || skip.has(rel) || rel === SETUP_ONLY ? null : hashAt(root, rel);
53
+ if (here !== null) (here === before ? c.removed : c.kept).push(rel);
54
+ }
55
+
56
+ for (const rel of [...c.updated, ...c.added]) put(root, rel, ours.get(rel).text);
57
+ for (const rel of c.merge) put(root, kitCopy(rel), ours.get(rel).text);
58
+ for (const rel of c.removed) remove(root, rel);
59
+ const keep = Object.fromEntries([...skip].filter((rel) => rel in wrote).map((rel) => [rel, wrote[rel]]));
60
+ const recorded = writeRecord(root, files, { preset: preset ? presetRecord(preset, files) : used ?? null, keep });
61
+
62
+ if (!record) warn(`No ${RECORD} (agk 0.1.0 kept none), so a file that differs from the kit's counts as yours`);
63
+ if (used && !preset) {
64
+ warn(
65
+ `This repo uses preset ${used.name}, which isn't on this machine: its ${count(skip.size, "file")} stay as they are` +
66
+ (used.source ? `\n To get it: agk preset add ${used.source}` : ""),
67
+ );
68
+ }
69
+ for (const w of composed.warnings) warn(w);
70
+ const source = preset ? `the kit or preset ${preset.name}` : "the kit";
71
+ const report = (paths, say, text) => {
72
+ if (!paths.length) return;
73
+ say(text(count(paths.length, "file"), paths.length));
74
+ details(paths);
75
+ };
76
+ const them = (k) => (k === 1 ? "it" : "them");
77
+ report(c.updated, ok, (n) => `Updated ${n} you hadn't changed`);
78
+ report(c.added, ok, (n) => `Added ${n} new in ${source}`);
79
+ report(c.removed, ok, (n) => `Removed ${n} ${source} no longer has`);
80
+ const how = record ? `changed both here and in ${source}` : "differ from the kit's";
81
+ report(c.merge, warn, (n, k) => `${n} ${how}: the ${preset ? "new" : "kit's"} version is next to ${each(k)}, as <file>.kit`);
82
+ report(c.kept, warn, (n, k) => `Kept ${n} you changed, though ${source} no longer has ${them(k)}`);
83
+ report(c.deleted, warn, (n, k) => `Left ${n} deleted, as you had ${them(k)}, though ${source} changed ${them(k)}`);
84
+ const touched = c.updated.length + c.added.length + c.removed.length + c.merge.length;
85
+ if (!touched && !recorded) {
86
+ ok(`Up to date with ${preset ? `the kit (v${VERSION}) and preset ${preset.name}` : `the kit (v${VERSION})`}`);
87
+ blank();
88
+ return;
89
+ }
90
+ if (recorded) ok(preset ? `Recorded the files (v${VERSION}, preset ${preset.name}) in ${RECORD}` : `Recorded the kit's files (v${VERSION}) in ${RECORD}`);
91
+ 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.`;
92
+ list("Next", [
93
+ ...(c.merge.length ? [merge] : []),
94
+ `Review the changes (${out.cyan("git status")}), then commit them on a branch, as any change`,
95
+ ]);
96
+ }
97
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonxped31/agent-kit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Sets up git repos for Claude Code, and keeps them up to date: a short CLAUDE.md, git and todo skills, hooks and checks.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -15,6 +15,9 @@ Find out on your own:
15
15
  `agk setup --github` adds an empty GitHub repo as `origin`.
16
16
  - The stack, from what's there: `package.json`, `pyproject.toml`, `go.mod`,
17
17
  `Cargo.toml`, a README.
18
+ - The preset, if `.claude/agk.json` names one: the user's own setup, added to the
19
+ kit's. Keep what it added, and don't ask what it answers already (check steps say
20
+ how to test, for example).
18
21
  - Uncommitted work (`git status --short`) in `scripts/`, `docs/` or `.claude/` that
19
22
  isn't the kit's (`.claude/agk.json` lists the kit's files): step 6 stages those
20
23
  folders whole, so ask the user to commit it first.
@@ -67,3 +67,7 @@ agent-kit wrote the files listed in `.claude/agk.json`; change them as the repo
67
67
  `agk update` brings in the kit's later changes. Where the repo changed a file, it puts
68
68
  the kit's version next to it as `<file>.kit`: merge what's new into the file, keeping
69
69
  the repo's changes, then delete the `.kit` file (`doctor` reports it until then).
70
+
71
+ To reuse this repo's setup in new repos, the user can save it as a preset:
72
+ `agk preset save <name> --list` shows what it has beyond the kit. Help them pick what's
73
+ generic, then `--only <numbers>`.