@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/lib/merge.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Additions: what a repo added to one of the kit's files, kept apart from the file, so a preset can
|
|
2
|
+
// add it to whatever version of the file the kit has next. Each is a file of the same kind:
|
|
3
|
+
// - JSON: an object holding only the added keys and list entries;
|
|
4
|
+
// - Markdown: the added blocks (a bullet with its lines, a paragraph, a table row), each under the
|
|
5
|
+
// `## ` heading it goes under;
|
|
6
|
+
// - other text: the added lines.
|
|
7
|
+
// Code (scripts, hooks) doesn't merge by lines: a preset replaces such a file whole.
|
|
8
|
+
|
|
9
|
+
export function kindOf(rel) {
|
|
10
|
+
if (/\.json$/i.test(rel)) return "json";
|
|
11
|
+
if (/\.md$/i.test(rel)) return "markdown";
|
|
12
|
+
if (/\.(mjs|cjs|js|ts|sh|bash|ps1|py)$/i.test(rel) || rel.startsWith(".githooks/")) return "code";
|
|
13
|
+
return "lines";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ---- JSON
|
|
17
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
18
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
19
|
+
|
|
20
|
+
/** What `repo` has that `kit` hasn't: { keys (the path), value, array (an entry of the list at keys) }. */
|
|
21
|
+
function jsonItems(repo, kit, keys = []) {
|
|
22
|
+
if (isObject(repo) && isObject(kit)) {
|
|
23
|
+
return Object.entries(repo).flatMap(([k, v]) => (k in kit ? jsonItems(v, kit[k], [...keys, k]) : [{ keys: [...keys, k], value: v }]));
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(repo) && Array.isArray(kit)) {
|
|
26
|
+
return keys.length ? repo.filter((v) => !kit.some((w) => same(v, w))).map((value) => ({ keys, value, array: true })) : [];
|
|
27
|
+
}
|
|
28
|
+
return same(repo, kit) || !keys.length ? [] : [{ keys, value: repo }];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildJson(items) {
|
|
32
|
+
const root = {};
|
|
33
|
+
for (const { keys, value, array } of items) {
|
|
34
|
+
let node = root;
|
|
35
|
+
for (const k of keys.slice(0, -1)) node = node[k] ??= {};
|
|
36
|
+
const last = keys[keys.length - 1];
|
|
37
|
+
if (array) (node[last] ??= []).push(value);
|
|
38
|
+
else node[last] = value;
|
|
39
|
+
}
|
|
40
|
+
return `${JSON.stringify(root, null, 2)}\n`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Objects merge key by key, lists get the entries they lack, anything else takes the addition. */
|
|
44
|
+
function mergeValue(base, add) {
|
|
45
|
+
if (isObject(base) && isObject(add)) {
|
|
46
|
+
const merged = { ...base };
|
|
47
|
+
for (const [k, v] of Object.entries(add)) merged[k] = k in base ? mergeValue(base[k], v) : v;
|
|
48
|
+
return merged;
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(base) && Array.isArray(add)) return [...base, ...add.filter((v) => !base.some((w) => same(v, w)))];
|
|
51
|
+
return add;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- Markdown
|
|
55
|
+
const START = /^([-*+]|\d+[.)])\s|^\||^#{1,6}\s|^(```|~~~)/;
|
|
56
|
+
const FENCE = /^\s*(```|~~~)/;
|
|
57
|
+
const isItem = (block) => /^([-*+]|\d+[.)])\s/.test(block);
|
|
58
|
+
const key = (block) => block.split("\n").map((l) => l.trimEnd()).join("\n");
|
|
59
|
+
|
|
60
|
+
/** The text before the first `## ` heading (heading ""), then each `## ` section. */
|
|
61
|
+
function sections(text) {
|
|
62
|
+
const out = [{ heading: "", lines: [] }];
|
|
63
|
+
let fence = false;
|
|
64
|
+
for (const line of text.replace(/\n$/, "").split("\n")) {
|
|
65
|
+
if (FENCE.test(line)) fence = !fence;
|
|
66
|
+
if (!fence && /^## /.test(line)) out.push({ heading: line.trim(), lines: [] });
|
|
67
|
+
else out[out.length - 1].lines.push(line);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A section's blocks: a list item with its indented lines, a paragraph, a table row, a code block. */
|
|
73
|
+
function blocks(lines) {
|
|
74
|
+
const out = [];
|
|
75
|
+
let current = null;
|
|
76
|
+
let fence = false;
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
if (fence) {
|
|
79
|
+
current.push(line);
|
|
80
|
+
if (FENCE.test(line)) fence = false;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!line.trim()) {
|
|
84
|
+
current = null;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!current || (!/^\s/.test(line) && START.test(line))) {
|
|
88
|
+
current = [line];
|
|
89
|
+
out.push(current);
|
|
90
|
+
} else {
|
|
91
|
+
current.push(line);
|
|
92
|
+
}
|
|
93
|
+
if (FENCE.test(line)) fence = true;
|
|
94
|
+
}
|
|
95
|
+
return out.map((b) => b.join("\n"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function markdownItems(repo, kit, skipTop) {
|
|
99
|
+
const kitBlocks = new Map(sections(kit).map((s) => [s.heading, new Set(blocks(s.lines).map(key))]));
|
|
100
|
+
return sections(repo)
|
|
101
|
+
.filter((s) => !(skipTop && s.heading === ""))
|
|
102
|
+
.flatMap((s) => {
|
|
103
|
+
const have = kitBlocks.get(s.heading) ?? new Set();
|
|
104
|
+
return blocks(s.lines)
|
|
105
|
+
.filter((b) => !have.has(key(b)))
|
|
106
|
+
.map((text) => ({ heading: s.heading, text }));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function joinBlocks(texts) {
|
|
111
|
+
return texts.reduce((all, b, i) => (i === 0 ? b : `${all}${isItem(texts[i - 1]) && isItem(b) ? "\n" : "\n\n"}${b}`), "");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildMarkdown(items) {
|
|
115
|
+
const groups = new Map();
|
|
116
|
+
for (const { heading, text } of items) {
|
|
117
|
+
if (!groups.has(heading)) groups.set(heading, []);
|
|
118
|
+
groups.get(heading).push(text);
|
|
119
|
+
}
|
|
120
|
+
const top = groups.get("") ?? [];
|
|
121
|
+
groups.delete("");
|
|
122
|
+
const parts = [...(top.length ? [joinBlocks(top)] : []), ...[...groups].map(([heading, texts]) => `${heading}\n\n${joinBlocks(texts)}`)];
|
|
123
|
+
return `${parts.join("\n\n")}\n`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Adds a block at the end of a section, keeping lists tight and other blocks a blank line apart. */
|
|
127
|
+
function append(section, block) {
|
|
128
|
+
const lines = section.lines;
|
|
129
|
+
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
130
|
+
const last = blocks(lines).pop();
|
|
131
|
+
if (!lines.length) {
|
|
132
|
+
if (section.heading) lines.push("");
|
|
133
|
+
} else if (!(last && isItem(last) && isItem(block))) {
|
|
134
|
+
lines.push("");
|
|
135
|
+
}
|
|
136
|
+
lines.push(...block.split("\n"));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function mergeMarkdown(baseText, addText) {
|
|
140
|
+
const base = sections(baseText);
|
|
141
|
+
const changed = new Set();
|
|
142
|
+
for (const add of sections(addText)) {
|
|
143
|
+
const added = blocks(add.lines);
|
|
144
|
+
if (!added.length) continue;
|
|
145
|
+
let target = base.find((s) => s.heading === add.heading);
|
|
146
|
+
if (!target) {
|
|
147
|
+
target = { heading: add.heading, lines: [] };
|
|
148
|
+
base.push(target);
|
|
149
|
+
}
|
|
150
|
+
const have = new Set(blocks(target.lines).map(key));
|
|
151
|
+
for (const b of added.filter((b) => !have.has(key(b)))) {
|
|
152
|
+
append(target, b);
|
|
153
|
+
have.add(key(b));
|
|
154
|
+
changed.add(target);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// A section that grew, or that a new section now follows, ends with a blank line.
|
|
158
|
+
base.forEach((s, i) => {
|
|
159
|
+
if (i < base.length - 1 && s.lines.length && s.lines[s.lines.length - 1].trim()) s.lines.push("");
|
|
160
|
+
});
|
|
161
|
+
const text = base.flatMap((s) => (s.heading ? [s.heading, ...s.lines] : s.lines)).join("\n");
|
|
162
|
+
return `${text.replace(/\n+$/, "")}\n`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- Lines
|
|
166
|
+
const lineSet = (text) => new Set(text.split("\n").map((l) => l.trimEnd()));
|
|
167
|
+
const lineItems = (repo, kit) => {
|
|
168
|
+
const have = lineSet(kit);
|
|
169
|
+
return [...new Set(repo.split("\n").map((l) => l.trimEnd()))].filter((l) => l.trim() && !have.has(l)).map((text) => ({ text }));
|
|
170
|
+
};
|
|
171
|
+
function mergeLines(base, add) {
|
|
172
|
+
const have = lineSet(base);
|
|
173
|
+
const extra = [...new Set(add.split("\n").map((l) => l.trimEnd()))].filter((l) => l.trim() && !have.has(l));
|
|
174
|
+
return extra.length ? `${base.replace(/\n*$/, "\n")}${extra.join("\n")}\n` : base;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- By kind
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* What a repo's version of a kit file adds to the kit's: a list of items, or null for code, which
|
|
181
|
+
* doesn't merge. CLAUDE.md's text before its first `## ` heading is the project's name and
|
|
182
|
+
* description, so it never counts.
|
|
183
|
+
*/
|
|
184
|
+
export function additionsOf(rel, repoText, kitText) {
|
|
185
|
+
switch (kindOf(rel)) {
|
|
186
|
+
case "json":
|
|
187
|
+
try {
|
|
188
|
+
return jsonItems(JSON.parse(repoText), JSON.parse(kitText));
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
case "markdown":
|
|
193
|
+
return markdownItems(repoText, kitText, rel === "CLAUDE.md");
|
|
194
|
+
case "lines":
|
|
195
|
+
return lineItems(repoText, kitText);
|
|
196
|
+
default:
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The additions file for chosen items of one file. */
|
|
202
|
+
export function buildAdditions(rel, items) {
|
|
203
|
+
const kind = kindOf(rel);
|
|
204
|
+
if (kind === "json") return buildJson(items);
|
|
205
|
+
if (kind === "markdown") return buildMarkdown(items);
|
|
206
|
+
return `${items.map((i) => i.text).join("\n")}\n`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A file with an additions file merged in. Throws on JSON that doesn't parse. */
|
|
210
|
+
export function mergeAdditions(rel, baseText, addText) {
|
|
211
|
+
const kind = kindOf(rel);
|
|
212
|
+
if (kind === "json") return `${JSON.stringify(mergeValue(JSON.parse(baseText), JSON.parse(addText)), null, 2)}\n`;
|
|
213
|
+
if (kind === "markdown") return mergeMarkdown(baseText, addText);
|
|
214
|
+
return mergeLines(baseText, addText);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const clipText = (s, n = 72) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
218
|
+
|
|
219
|
+
/** One line saying what an addition is. */
|
|
220
|
+
export function describeAddition(rel, item) {
|
|
221
|
+
const kind = kindOf(rel);
|
|
222
|
+
if (kind === "json") return clipText(`${item.keys.join(".")}${item.array ? "[]" : ""}: ${JSON.stringify(item.value)}`);
|
|
223
|
+
if (kind === "markdown") return clipText(`${item.heading ? `${item.heading.replace(/^##\s*/, "")}: ` : ""}${item.text.split("\n")[0]}`);
|
|
224
|
+
return clipText(item.text);
|
|
225
|
+
}
|
|
@@ -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
|
+
}
|