@gonxped31/agent-kit 0.0.0-stage → 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,320 @@
1
+ // agk preset save | list | remove | add | pull. See presets.mjs for what a preset is.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { NOTHING_CHANGED, confirm, git, parse, repoRoot, run, said } from "./cli.mjs";
5
+ import { VERSION, kitFiles, readRecord } from "./kit.mjs";
6
+ import { kindOf } from "./merge.mjs";
7
+ import { DEFAULT, candidates, describeCandidate, listPresets, loadPreset, loadPresetAt, powersOf, presetsDir, validName, writePreset } from "./presets.mjs";
8
+ import { Failure, ask, blank, count, details, header, interactive, line, list, ok, out, step, warn } from "./ui.mjs";
9
+
10
+ function checkName(name, command) {
11
+ if (!name) throw new Failure(`${command} needs a preset name`, { hint: `For example: agk ${command} web` });
12
+ if (!validName(name)) throw new Failure(`"${name}" can't be a preset name`, { hint: "Use letters, digits, dots, dashes and underscores, starting with a letter or digit." });
13
+ }
14
+
15
+ /** Numbers like "1,3-5" (from 1 to n), as indexes. */
16
+ function numbers(spec, n) {
17
+ const picked = new Set();
18
+ for (const part of spec.split(",").map((p) => p.trim()).filter(Boolean)) {
19
+ const m = /^(\d+)(?:-(\d+))?$/.exec(part);
20
+ const [a, b] = m ? [Number(m[1]), Number(m[2] ?? m[1])] : [0, 0];
21
+ if (!m || a < 1 || b > n || a > b) throw new Failure(`"${spec}" isn't a choice: pick numbers from 1 to ${n}, like 1,3-5`);
22
+ for (let i = a; i <= b; i++) picked.add(i - 1);
23
+ }
24
+ return [...picked].sort((x, y) => x - y);
25
+ }
26
+
27
+ /**
28
+ * The candidates the user keeps: --all, --only 1,3-5, or an answer in a terminal. With --list,
29
+ * or with no terminal to ask in, it lists them and keeps none (null).
30
+ */
31
+ async function choose(found, opts, vals, again) {
32
+ if (opts.has("--all")) return found;
33
+ if (vals["--only"]) return numbers(vals["--only"], found.length).map((i) => found[i]);
34
+ line(out.bold("What this repo has beyond the kit"));
35
+ found.forEach((c, i) => line(` ${out.cyan(String(i + 1).padStart(3))} ${describeCandidate(c)}`));
36
+ blank();
37
+ if (opts.has("--list") || !interactive) {
38
+ line(`Nothing is saved yet. To save, pick what's generic (not this project's own):`);
39
+ line(` ${out.cyan(`${again} --only 1,3-5`)} or ${out.cyan(`${again} --all`)}`);
40
+ blank();
41
+ return null;
42
+ }
43
+ const answer = await ask("Keep which? Numbers like 1,3-5, all, or Enter for none:");
44
+ if (!answer) return [];
45
+ return /^all$/i.test(answer) ? found : numbers(answer, found.length).map((i) => found[i]);
46
+ }
47
+
48
+ /** Lines saying what a preset can run, or let Claude do. */
49
+ function showPowers(preset) {
50
+ const p = powersOf(preset);
51
+ const lines = [
52
+ ...p.code.map((f) => `runs code: ${f}`),
53
+ ...p.hooks.map((h) => `adds a Claude hook: ${h}`),
54
+ ...p.allow.map((a) => `lets Claude run without asking: ${a}`),
55
+ ...(p.mcp ? ["adds MCP servers (.mcp.json)"] : []),
56
+ ];
57
+ if (!lines.length) return ok("It runs no code of its own and gives Claude no new permissions");
58
+ warn(`It can run code or widen what Claude may do. Read these before you agree:`);
59
+ details(lines);
60
+ }
61
+
62
+ const filesOf = (preset) => [...preset.files.map((f) => `file ${f.to}`), ...preset.additions.map((a) => `adds to ${a.to}`)];
63
+
64
+ // ---- save
65
+
66
+ async function save(args) {
67
+ const { opts, vals, pos } = parse("preset save", args, {
68
+ flags: ["--list", "--all", "--github", "--public"],
69
+ values: ["--only"],
70
+ max: 2,
71
+ what: "a name and a folder",
72
+ });
73
+ const [name, folder = "."] = pos;
74
+ checkName(name, "preset save");
75
+ if (opts.has("--public") && !opts.has("--github")) throw new Failure("--public goes with --github");
76
+ header(VERSION, `Saving preset ${name}`);
77
+ const root = repoRoot(path.resolve(folder), "preset save");
78
+ const record = readRecord(root);
79
+ if (!record) {
80
+ throw new Failure("This repo has no record of the kit's files", {
81
+ hint: "Run agk update first: it writes one (or agk setup, if the kit isn't set up here). Nothing was saved.",
82
+ });
83
+ }
84
+ if (record.version !== VERSION) {
85
+ throw new Failure(`This repo has the kit of agk v${record.version}, and this agk is v${VERSION}`, {
86
+ hint: "Run agk update first, so the preset holds your changes and not the kit's. Nothing was saved.",
87
+ });
88
+ }
89
+ const existing = loadPreset(name);
90
+ if (existing?.source) {
91
+ throw new Failure(`Preset ${name} is a copy of ${existing.source}`, { hint: "Save yours under another name. Nothing was saved." });
92
+ }
93
+ const found = candidates(root, kitFiles());
94
+ if (!found.length) {
95
+ ok("This repo has nothing beyond the kit to save");
96
+ blank();
97
+ return;
98
+ }
99
+ const again = `agk preset save ${name}${folder === "." ? "" : ` ${folder}`}`;
100
+ const chosen = await choose(found, opts, vals, again);
101
+ if (!chosen) return;
102
+ if (!chosen.length) {
103
+ line("Nothing was saved.");
104
+ blank();
105
+ return;
106
+ }
107
+ const dir = path.join(presetsDir(), name);
108
+ const meta = {
109
+ name,
110
+ savedWith: VERSION,
111
+ from: path.basename(root),
112
+ ...(opts.has("--github") && { defaults: { github: true, visibility: opts.has("--public") ? "public" : "private" } }),
113
+ };
114
+ const n = writePreset(dir, meta, chosen);
115
+ ok(`${existing ? "Replaced" : "Saved"} preset ${out.cyan(name)}: ${count(n.files, "file")}, additions to ${count(n.additions, "file")}`);
116
+ line(` ${out.dim(dir)}`);
117
+ list("Next", [
118
+ `${out.cyan(`agk setup <folder> --preset ${name}`)} sets up a repo with it${name === DEFAULT ? " (agk setup uses it anyway)" : ""}`,
119
+ `To share it, make its folder a git repo and push it.\nOthers then run agk preset add <its URL>.`,
120
+ ]);
121
+ }
122
+
123
+ // ---- list
124
+
125
+ function listCommand(args) {
126
+ parse("preset list", args, { max: 0, what: "nothing" });
127
+ header(VERSION, "Your presets");
128
+ const presets = listPresets();
129
+ if (!presets.length) {
130
+ line("No presets yet. Save one from a repo you've tuned: agk preset save <name>");
131
+ }
132
+ for (const p of presets) {
133
+ if (p.error) {
134
+ warn(`${p.name}: ${p.error.message}`);
135
+ continue;
136
+ }
137
+ const tag = p.name === DEFAULT ? out.dim(" (used by agk setup)") : "";
138
+ const where = p.source ? `from ${p.source} at ${p.commit}` : `saved from ${p.meta.from ?? "?"}`;
139
+ line(`${out.cyan(p.name)}${tag} ${count(p.files.length, "file")}, additions to ${count(p.additions.length, "file")} ${out.dim(where)}`);
140
+ }
141
+ blank();
142
+ line(out.dim(presetsDir()));
143
+ blank();
144
+ }
145
+
146
+ // ---- remove
147
+
148
+ async function removeCommand(args) {
149
+ const { opts, pos } = parse("preset remove", args, { flags: ["--yes"], max: 1, what: "one name" });
150
+ checkName(pos[0], "preset remove");
151
+ const preset = loadPreset(pos[0]);
152
+ header(VERSION, `Removing preset ${pos[0]}`);
153
+ if (!preset) throw new Failure(`There's no preset named ${pos[0]}`, { hint: "agk preset list shows yours." });
154
+ line(out.dim(preset.dir));
155
+ if (!(await confirm(`Delete preset ${preset.name} and its folder?`, opts))) {
156
+ line("Nothing was deleted.");
157
+ blank();
158
+ return;
159
+ }
160
+ fs.rmSync(preset.dir, { recursive: true, force: true });
161
+ ok(`Deleted preset ${preset.name}. Repos set up with it keep their files.`);
162
+ blank();
163
+ }
164
+
165
+ // ---- add
166
+
167
+ async function add(args) {
168
+ const { opts, vals, pos } = parse("preset add", args, {
169
+ flags: ["--yes", "--list", "--all"],
170
+ values: ["--name", "--only"],
171
+ max: 1,
172
+ what: "one git URL or folder",
173
+ });
174
+ const source = pos[0];
175
+ if (!source) throw new Failure("preset add needs a git URL or a folder", { hint: "For example: agk preset add https://github.com/someone/agk-web" });
176
+ if (vals["--name"]) checkName(vals["--name"], "preset add");
177
+ header(VERSION, "Adding a preset");
178
+ const dir = presetsDir();
179
+ fs.mkdirSync(dir, { recursive: true });
180
+ const incoming = path.join(dir, `.incoming-${process.pid}-${Date.now()}`);
181
+ try {
182
+ await step(`Fetching ${source}`, async () => {
183
+ const r = await run("git", ["clone", "-q", source, incoming]);
184
+ if (r.error) throw new Failure("git isn't installed, or isn't on the PATH");
185
+ if (r.status !== 0) throw new Failure(`git couldn't fetch ${source}`, { details: said(r), hint: NOTHING_CHANGED });
186
+ return `Fetched ${source}`;
187
+ });
188
+ if (fs.existsSync(path.join(incoming, "preset.json"))) await addPresetRepo(source, incoming, opts, vals);
189
+ else if (fs.existsSync(path.join(incoming, ".claude", "agk.json"))) await addFromRepo(source, incoming, opts, vals);
190
+ else {
191
+ throw new Failure(`${source} is neither a preset nor a repo set up with agk`, {
192
+ hint: `A preset has a preset.json at its root; a repo set up with agk has .claude/agk.json. ${NOTHING_CHANGED}`,
193
+ });
194
+ }
195
+ } finally {
196
+ fs.rmSync(incoming, { recursive: true, force: true });
197
+ }
198
+ }
199
+
200
+ /** The name for a preset added from `source`: --name, else what it says, else the last part of its URL. */
201
+ function nameFor(source, vals, said) {
202
+ const name = vals["--name"] ?? said ?? source.replace(/[\\/]+$/, "").replace(/\.git$/, "").split(/[\\/:]/).pop();
203
+ if (!validName(name)) throw new Failure(`"${name}" can't be a preset name`, { hint: `Name it with --name. ${NOTHING_CHANGED}` });
204
+ if (fs.existsSync(path.join(presetsDir(), name))) {
205
+ throw new Failure(`You have a preset named ${name} already`, { hint: `Name this one with --name. ${NOTHING_CHANGED}` });
206
+ }
207
+ return name;
208
+ }
209
+
210
+ /** A preset someone shared: kept as a clone, pinned at the commit fetched, after the user's OK. */
211
+ async function addPresetRepo(source, incoming, opts, vals) {
212
+ const preset = loadPresetAt(incoming, "(new)");
213
+ const name = nameFor(source, vals, typeof preset.meta.name === "string" ? preset.meta.name : undefined);
214
+ const commit = git(incoming, "rev-parse", "--short", "HEAD").stdout.trim();
215
+ ok(`Preset ${out.cyan(name)} at ${commit}: ${count(preset.files.length, "file")}, additions to ${count(preset.additions.length, "file")}`);
216
+ details(filesOf(preset));
217
+ showPowers(preset);
218
+ if (!(await confirm(`Add preset ${name}?`, opts))) {
219
+ line(NOTHING_CHANGED);
220
+ blank();
221
+ return;
222
+ }
223
+ const final = path.join(presetsDir(), name);
224
+ fs.renameSync(incoming, final);
225
+ git(final, "config", "agk.source", source);
226
+ ok(`Added preset ${out.cyan(name)}, pinned at ${commit}`);
227
+ list("Next", [
228
+ `${out.cyan(`agk setup <folder> --preset ${name}`)} sets up a repo with it`,
229
+ `Later, ${out.cyan(`agk preset pull ${name}`)} fetches its changes, for you to review`,
230
+ ]);
231
+ }
232
+
233
+ /** Any repo set up with agk: its setup becomes a preset of the user's own, from the parts they pick. */
234
+ async function addFromRepo(source, incoming, opts, vals) {
235
+ const name = nameFor(source, vals);
236
+ const record = readRecord(incoming);
237
+ if (record.version !== VERSION) {
238
+ warn(`It has the kit of agk v${record.version} (this is v${VERSION}): some differences may be the kit's own changes`);
239
+ }
240
+ const found = candidates(incoming, kitFiles());
241
+ if (!found.length) {
242
+ ok("It has nothing beyond the kit to copy");
243
+ blank();
244
+ return;
245
+ }
246
+ const again = `agk preset add ${source}${vals["--name"] ? ` --name ${name}` : ""}`;
247
+ const chosen = await choose(found, opts, vals, again);
248
+ if (!chosen) return;
249
+ if (!chosen.length) {
250
+ line("Nothing was saved.");
251
+ blank();
252
+ return;
253
+ }
254
+ const n = writePreset(path.join(presetsDir(), name), { name, savedWith: VERSION, from: source }, chosen);
255
+ const saved = loadPreset(name);
256
+ ok(`Saved preset ${out.cyan(name)} from ${source}: ${count(n.files, "file")}, additions to ${count(n.additions, "file")}`);
257
+ showPowers(saved);
258
+ list("Next", [`${out.cyan(`agk setup <folder> --preset ${name}`)} sets up a repo with it`]);
259
+ }
260
+
261
+ // ---- pull
262
+
263
+ async function pull(args) {
264
+ const { opts, pos } = parse("preset pull", args, { flags: ["--yes"], max: 1, what: "one name" });
265
+ checkName(pos[0], "preset pull");
266
+ header(VERSION, `Updating preset ${pos[0]}`);
267
+ const preset = loadPreset(pos[0]);
268
+ if (!preset) throw new Failure(`There's no preset named ${pos[0]}`, { hint: "agk preset list shows yours." });
269
+ if (!preset.source) {
270
+ throw new Failure(`Preset ${preset.name} is your own, not a copy`, { hint: `It changes when you save it again: agk preset save ${preset.name}` });
271
+ }
272
+ await step(`Fetching ${preset.source}`, async () => {
273
+ const r = await run("git", ["fetch", "-q", "origin"], { cwd: preset.dir });
274
+ if (r.status !== 0) throw new Failure(`git couldn't fetch ${preset.source}`, { details: said(r), hint: NOTHING_CHANGED });
275
+ return `Fetched ${preset.source}`;
276
+ });
277
+ const ahead = Number(git(preset.dir, "rev-list", "--count", "HEAD..@{u}").stdout.trim() || 0);
278
+ if (!ahead) {
279
+ ok(`Preset ${preset.name} is up to date (${preset.commit})`);
280
+ blank();
281
+ return;
282
+ }
283
+ const changed = git(preset.dir, "diff", "--name-only", "HEAD", "@{u}").stdout.split("\n").filter(Boolean);
284
+ ok(`${count(ahead, "new commit")}, changing ${count(changed.length, "file")}`);
285
+ details(changed);
286
+ const risky = changed.filter((f) => kindOf(f) === "code" || /(^|\/)(settings\.json|\.mcp\.json)$/.test(f) || f.includes(".claude/hooks/"));
287
+ if (risky.length) {
288
+ warn(`${count(risky.length, "change")} can run code or change what Claude may do. To read ${risky.length === 1 ? "it" : "them"}:`);
289
+ details([`git -C "${preset.dir}" diff HEAD @{u}`]);
290
+ }
291
+ if (!(await confirm(`Take these changes?`, opts))) {
292
+ line(NOTHING_CHANGED);
293
+ blank();
294
+ return;
295
+ }
296
+ const r = git(preset.dir, "merge", "-q", "--ff-only", "@{u}");
297
+ if (r.status !== 0) throw new Failure("The preset's history doesn't follow on from yours", { details: said(r), hint: `Remove it and add it again. ${NOTHING_CHANGED}` });
298
+ const now = git(preset.dir, "rev-parse", "--short", "HEAD").stdout.trim();
299
+ ok(`Preset ${out.cyan(preset.name)} is now at ${now}`);
300
+ list("Next", [`Run ${out.cyan("agk update")} in each repo set up with it`]);
301
+ }
302
+
303
+ export async function presetCommand([sub, ...args]) {
304
+ switch (sub) {
305
+ case "save":
306
+ return save(args);
307
+ case "list":
308
+ return listCommand(args);
309
+ case "remove":
310
+ return removeCommand(args);
311
+ case "add":
312
+ return add(args);
313
+ case "pull":
314
+ return pull(args);
315
+ default:
316
+ throw new Failure(sub ? `Unknown preset command '${sub}'` : "agk preset needs a command", {
317
+ hint: "They are save, list, remove, add and pull: agk help says more.",
318
+ });
319
+ }
320
+ }
@@ -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
+ }