@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 +21 -0
- package/README.md +115 -2
- package/bin/agk.mjs +83 -0
- package/lib/cli.mjs +90 -0
- package/lib/kit.mjs +134 -0
- 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 +145 -0
- package/lib/update.mjs +97 -0
- package/package.json +28 -4
- package/template/.gitattributes +5 -0
- package/template/.githooks/pre-commit +8 -0
- package/template/BOOTSTRAP.md +106 -0
- package/template/CLAUDE.template.md +36 -0
- package/template/docs/workflow.md +15 -0
- package/template/dot-claude/settings.json +40 -0
- package/template/dot-claude/skills/agent-setup/SKILL.md +73 -0
- package/template/dot-claude/skills/git/SKILL.md +68 -0
- package/template/dot-claude/skills/todo/SKILL.md +74 -0
- package/template/dot-claude/skills/todo/scripts/todo.mjs +696 -0
- package/template/scripts/check.json +5 -0
- package/template/scripts/check.mjs +50 -0
- package/template/scripts/doctor.mjs +177 -0
- package/template/scripts/hooks/block-big-read.mjs +25 -0
- package/template/scripts/hooks/guard-commands.mjs +110 -0
- package/template/scripts/merge.mjs +82 -0
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
|
-
#
|
|
1
|
+
# agent-kit
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|