@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gonxped31
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,116 @@
1
- # Temporary Holding Version
1
+ # agent-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sets up a git repo for Claude Code, new or existing, in two steps: `agk setup` copies
4
+ the kit's files in, then the agent finishes the setup with you. `agk update` brings in
5
+ the kit's later changes.
6
+
7
+ ## What a repo gets
8
+
9
+ - **A short `CLAUDE.md`**: only what must be true on every change, and where
10
+ everything else is.
11
+ - **Three skills**:
12
+ - `git`: a branch per task, files staged by name, `main` only with your approval.
13
+ - `todo`: a work queue the agent drives through a script, including deferred work
14
+ with its trigger ("when X happens, do Y").
15
+ - `agent-setup`: where each kind of knowledge goes, when to add a rule or a skill,
16
+ and the size limits.
17
+ - **Guardrails that don't rely on the agent remembering**:
18
+ - a git hook: no commits on `main`, and the checks before every commit;
19
+ - a Claude hook (Bash and PowerShell): refuses `--no-verify` and bulk staging, and
20
+ asks you before merges, pushes and commands that discard work;
21
+ - a Claude hook that refuses whole-file reads of huge files.
22
+ - **Scripts with short output**:
23
+ - `scripts/check.mjs`: every check, printing only what failed;
24
+ - `scripts/doctor.mjs`: keeps the agent setup within its size limits and catches
25
+ dead paths in the docs;
26
+ - `scripts/merge.mjs`: the only way into `main`.
27
+
28
+ It works with local git alone: no GitHub, no remote needed.
29
+
30
+ ## Requirements
31
+
32
+ - git, and Node 18 or later with its npm. No npm packages.
33
+ - Claude Code: the skills, hooks and settings are in its formats.
34
+ - Only for `agk setup --github`: the [GitHub CLI](https://cli.github.com) (`gh`),
35
+ logged in (`gh auth login`).
36
+
37
+ ## Install
38
+
39
+ npm install -g @gonxped31/agent-kit
40
+
41
+ That puts the `agk` command on your PATH, on Windows, macOS and Linux. The same command
42
+ updates it; `npm uninstall -g @gonxped31/agent-kit` removes it. To run it once without
43
+ installing: `npx @gonxped31/agent-kit setup`.
44
+
45
+ ## Use
46
+
47
+ 1. In the project's folder, run `agk setup` (or `agk setup <folder>` from its
48
+ parent). It runs `git init` if the folder isn't a repo yet, then copies the kit's
49
+ files in. It never overwrites a file: where the repo has one already (its own
50
+ `CLAUDE.md`, say), the kit's version goes next to it as `CLAUDE.md.kit`, and the
51
+ agent merges the two in step 2.
52
+
53
+ With `--github`, it also creates the repo on GitHub as `origin`: private, or public
54
+ with `--public`. It checks `gh`, your login and the name first, so a problem stops
55
+ it before anything changes. The agent pushes `main` after the first commit, once
56
+ you agree.
57
+ 2. Open Claude Code there and say "follow BOOTSTRAP.md". It looks around, asks
58
+ you up to six questions, and fills in the setup. Whatever isn't decided yet (like
59
+ the stack) becomes a todo with its trigger. Then it makes the first commit.
60
+ 3. After cloning the repo somewhere else, run `git config core.hooksPath .githooks`.
61
+ `doctor` reminds you if you forget.
62
+
63
+ ### Updating a repo
64
+
65
+ When the kit changes, run `agk update` in the repo. `.claude/agk.json` records each
66
+ file as the kit wrote it, so `agk update` can tell the files you changed:
67
+
68
+ - a file you haven't changed is updated (or added, or removed, as the kit's is);
69
+ - a file you changed stays as it is, and the kit's new version goes next to it as
70
+ `<file>.kit`. Say "merge the .kit files" to Claude Code: `doctor` fails until it's
71
+ done.
72
+
73
+ It commits nothing: review the changes and commit them on a branch, like any change.
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
+
103
+ Your own habits, the ones that hold in every repo, go in `~/.claude/CLAUDE.md`, not in
104
+ the kit.
105
+
106
+ ## Working on the kit
107
+
108
+ `npm test` installs the template into temporary repos and runs its scripts and hooks.
109
+ On GitHub, `.github/workflows/test.yml` runs it on Windows, macOS and Linux, with Node
110
+ 18 and the current LTS. In a clone, `npm link` makes `agk` run the clone's files (a
111
+ `git pull` updates it), and `node bin/agk.mjs` runs them without linking. The design
112
+ and its decisions are in `docs/design.md`.
113
+
114
+ ## License
115
+
116
+ MIT, in `LICENSE`.
package/bin/agk.mjs ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ // agk: sets up git repos for Claude Code, and brings the kit's later changes into them.
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
+ // Installed with `npm install -g @gonxped31/agent-kit` (or `npm link` in a clone); template/ is next
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";
15
+
16
+ function help() {
17
+ header(VERSION, "Sets up git repos for Claude Code");
18
+ const row = (a, b) => line(` ${out.cyan(a.padEnd(30))}${b}`);
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
+ ]);
50
+ line(out.bold("Examples"));
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
+ }
54
+ blank();
55
+ }
56
+
57
+ async function main([cmd, ...args]) {
58
+ const wantsHelp = args.includes("--help") || args.includes("-h");
59
+ switch (cmd) {
60
+ case "setup":
61
+ return wantsHelp ? help() : setup(args);
62
+ case "update":
63
+ return wantsHelp ? help() : update(args);
64
+ case "preset":
65
+ return wantsHelp ? help() : presetCommand(args);
66
+ case undefined:
67
+ case "help":
68
+ case "--help":
69
+ case "-h":
70
+ return help();
71
+ case "--version":
72
+ case "-v":
73
+ return console.log(VERSION);
74
+ default:
75
+ throw new Failure(`Unknown command '${cmd}'`, { hint: "Run agk help for the commands." });
76
+ }
77
+ }
78
+
79
+ main(process.argv.slice(2)).catch((e) => {
80
+ if (!(e instanceof Failure)) throw e;
81
+ showFailure(e);
82
+ process.exitCode = 1;
83
+ });
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 ADDED
@@ -0,0 +1,134 @@
1
+ // The kit's files, and the record agk keeps of them in each repo it sets up (.claude/agk.json):
2
+ // the hash of each file as the kit wrote it. agk update compares a repo's files with it, to tell
3
+ // the files the repo changed from those it didn't.
4
+ // Text is compared and written with LF endings, whatever endings a checkout has.
5
+ import crypto from "node:crypto";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { Failure } from "./ui.mjs";
10
+
11
+ export const KIT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
+ export const TEMPLATE = path.join(KIT, "template");
13
+ const pkg = JSON.parse(fs.readFileSync(path.join(KIT, "package.json"), "utf8"));
14
+ export const VERSION = pkg.version;
15
+ /** The npm package agk comes in. */
16
+ export const PACKAGE = pkg.name;
17
+ export const RECORD = ".claude/agk.json";
18
+ /** The setup's instructions. The bootstrap deletes the file, so agk update never brings it back. */
19
+ export const SETUP_ONLY = "BOOTSTRAP.md";
20
+ const HOOK = ".githooks/pre-commit";
21
+ // Kept under other names in the kit, so Claude Code working on the kit doesn't load the template's
22
+ // CLAUDE.md and skills as the kit's own. The template's name -> the name in a repo.
23
+ const RENAMED = { "CLAUDE.template.md": "CLAUDE.md", "dot-claude": ".claude" };
24
+
25
+ /** Where the kit puts its version of a file a repo already has, or changed: next to it. */
26
+ export const kitCopy = (rel) => `${rel}.kit`;
27
+
28
+ export function walk(dir, rel = "") {
29
+ return fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap((e) => {
30
+ const child = path.join(rel, e.name);
31
+ return e.isDirectory() ? walk(dir, child) : [child];
32
+ });
33
+ }
34
+
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
+
38
+ /**
39
+ * The kit's files, sorted by `to`: where each is in template/ (`from`), its path in a repo (`to`,
40
+ * with forward slashes), its text and that text's hash.
41
+ */
42
+ export function kitFiles() {
43
+ return walk(TEMPLATE)
44
+ .map((from) => {
45
+ const [first, ...rest] = from.split(path.sep);
46
+ const text = lf(path.join(TEMPLATE, from));
47
+ return { from, to: [RENAMED[first] ?? first, ...rest].join("/"), text, hash: hash(text) };
48
+ })
49
+ .sort((a, b) => (a.to < b.to ? -1 : 1));
50
+ }
51
+
52
+ /** The hash of a repo's file; null if there's nothing there, "not a file" for a folder. */
53
+ export function hashAt(root, rel) {
54
+ const file = path.join(root, rel);
55
+ if (!fs.existsSync(file)) return null;
56
+ return fs.statSync(file).isFile() ? hash(lf(file)) : "not a file";
57
+ }
58
+
59
+ /** Writes a file, making its folders. The git hook is made executable. */
60
+ export function put(root, rel, text) {
61
+ const file = path.join(root, rel);
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ fs.writeFileSync(file, text);
64
+ if (rel === HOOK) fs.chmodSync(file, 0o755);
65
+ }
66
+
67
+ /** Deletes a file, then the folders it leaves empty, up to the repo's root. */
68
+ export function remove(root, rel) {
69
+ fs.rmSync(path.join(root, rel));
70
+ for (let dir = path.dirname(rel); dir !== "."; dir = path.dirname(dir)) {
71
+ const full = path.join(root, dir);
72
+ if (fs.readdirSync(full).length) break;
73
+ fs.rmdirSync(full);
74
+ }
75
+ }
76
+
77
+ /** A path in a repo, as the record may hold one: relative, inside the repo, and not in .git. */
78
+ export const inRepo = (rel) => !/^[\\/]|^[a-z]:|\\/i.test(rel) && !rel.split("/").some((p) => p === ".." || p === "") && !/^\.git(\/|$)/.test(rel);
79
+
80
+ /** A repo's record, or null if it has none. */
81
+ export function readRecord(root) {
82
+ const file = path.join(root, RECORD);
83
+ if (!fs.existsSync(file)) return null;
84
+ let record;
85
+ try {
86
+ record = JSON.parse(fs.readFileSync(file, "utf8"));
87
+ } catch {}
88
+ const files = record?.files;
89
+ const preset = record?.preset;
90
+ const valid =
91
+ typeof record?.version === "string" &&
92
+ files &&
93
+ typeof files === "object" &&
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))));
97
+ if (!valid) {
98
+ throw new Failure(`${RECORD} isn't what agk wrote`, {
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.`,
100
+ });
101
+ }
102
+ return record;
103
+ }
104
+
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])) };
112
+ const record = {
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.",
114
+ version: VERSION,
115
+ ...(preset && { preset }),
116
+ files: Object.fromEntries(Object.entries(wrote).sort(([a], [b]) => (a < b ? -1 : 1))),
117
+ };
118
+ const text = `${JSON.stringify(record, null, 2)}\n`;
119
+ const file = path.join(root, RECORD);
120
+ if (fs.existsSync(file) && lf(file) === text) return false;
121
+ put(root, RECORD, text);
122
+ return true;
123
+ }
124
+
125
+ /** True if the kit has set up `root`: it has a record, or a file only the kit writes (agk 0.1.0 kept no record). */
126
+ export const setUpAlready = (root) =>
127
+ [RECORD, ".claude/skills/agent-setup/SKILL.md"].some((rel) => fs.existsSync(path.join(root, rel)));
128
+
129
+ /** True if version `a` is newer than `b` (both like 1.2.3). */
130
+ export function newer(a, b) {
131
+ const [x, y] = [a, b].map((v) => v.split(".").map((n) => Number.parseInt(n, 10) || 0));
132
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
133
+ return false;
134
+ }
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
+ }