@gonxped31/agent-kit 0.0.0-stage → 0.2.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 +87 -2
- package/bin/agk.mjs +348 -0
- package/lib/kit.mjs +125 -0
- package/lib/ui.mjs +127 -0
- package/package.json +28 -4
- package/template/.gitattributes +5 -0
- package/template/.githooks/pre-commit +8 -0
- package/template/BOOTSTRAP.md +103 -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 +69 -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,88 @@
|
|
|
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
|
+
Your own habits, the ones that hold in every repo, go in `~/.claude/CLAUDE.md`, not in
|
|
76
|
+
the kit.
|
|
77
|
+
|
|
78
|
+
## Working on the kit
|
|
79
|
+
|
|
80
|
+
`npm test` installs the template into temporary repos and runs its scripts and hooks.
|
|
81
|
+
On GitHub, `.github/workflows/test.yml` runs it on Windows, macOS and Linux, with Node
|
|
82
|
+
18 and the current LTS. In a clone, `npm link` makes `agk` run the clone's files (a
|
|
83
|
+
`git pull` updates it), and `node bin/agk.mjs` runs them without linking. The design
|
|
84
|
+
and its decisions are in `docs/design.md`.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT, in `LICENSE`.
|
package/bin/agk.mjs
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
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` copies template/ into a folder, then the agent there follows BOOTSTRAP.md. Where
|
|
4
|
+
// the folder has one of the kit's files already, the kit's version goes next to it as
|
|
5
|
+
// <file>.kit, for the agent to merge. With --github it also creates the repo on GitHub, via gh.
|
|
6
|
+
// - `agk update` updates the kit's files that a repo hasn't changed, and puts the kit's version
|
|
7
|
+
// next to the others as <file>.kit. The repo's .claude/agk.json says what the kit wrote.
|
|
8
|
+
// Installed with `npm install -g @gonxped31/agent-kit` (or `npm link` in a clone); template/ is next
|
|
9
|
+
// to this file.
|
|
10
|
+
// AGK_GH=<path> runs that instead of gh, with node if it ends in .mjs (the kit's tests only).
|
|
11
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { PACKAGE, RECORD, SETUP_ONLY, VERSION, hashAt, kitCopy, kitFiles, newer, put, readRecord, remove, setUpAlready, writeRecord } from "../lib/kit.mjs";
|
|
15
|
+
import { Failure, blank, clip, count, details, header, line, list, ok, out, showFailure, step, warn } from "../lib/ui.mjs";
|
|
16
|
+
|
|
17
|
+
const NOTHING_CHANGED = "Nothing was changed.";
|
|
18
|
+
|
|
19
|
+
const git = (cwd, ...args) => spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
20
|
+
const noGit = () => new Failure("git isn't installed, or isn't on the PATH", { hint: "The kit needs it: https://git-scm.com" });
|
|
21
|
+
|
|
22
|
+
/** Runs a command without blocking (so the spinner moves). `error` is set if it couldn't start. */
|
|
23
|
+
function run(cmd, args, options = {}) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const child = spawn(cmd, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
26
|
+
let stdout = "";
|
|
27
|
+
let stderr = "";
|
|
28
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
29
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
30
|
+
child.on("error", (error) => resolve({ status: null, stdout, stderr, error }));
|
|
31
|
+
child.on("close", (status) => resolve({ status, stdout, stderr }));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function gh(...args) {
|
|
36
|
+
const env = { ...process.env, GH_PROMPT_DISABLED: "1" };
|
|
37
|
+
const fake = process.env.AGK_GH;
|
|
38
|
+
return fake?.endsWith(".mjs") ? run(process.execPath, [fake, ...args], { env }) : run(fake || "gh", args, { env });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The last lines of what a command said, to show under a failure. */
|
|
42
|
+
const said = (r) => (r.stderr || r.stdout).trim().split(/\r?\n/).filter(Boolean).slice(-3);
|
|
43
|
+
|
|
44
|
+
/** A command's options and its one folder (default: the current one). */
|
|
45
|
+
function parse(cmd, args, allowed) {
|
|
46
|
+
const options = args.filter((a) => a.startsWith("-"));
|
|
47
|
+
const folders = args.filter((a) => !a.startsWith("-"));
|
|
48
|
+
const bad = options.find((o) => !allowed.includes(o));
|
|
49
|
+
if (bad) throw new Failure(`Unknown option '${bad}'`, { hint: "Run agk help for the options." });
|
|
50
|
+
if (folders.length > 1) throw new Failure(`${cmd} takes one folder, got ${folders.length}: ${folders.join(", ")}`);
|
|
51
|
+
const folder = path.resolve(folders[0] ?? ".");
|
|
52
|
+
return { options, folder, shown: path.relative(process.cwd(), folder) || "." };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The nearest folder at or above `dir` that exists. */
|
|
56
|
+
function nearestExisting(dir) {
|
|
57
|
+
while (!fs.existsSync(dir)) dir = path.dirname(dir);
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What setup would do in `root`, changing nothing: whether to create it and run git init, and
|
|
63
|
+
* what to do with each of the kit's files. The folder is a repo's root (set it up as it is),
|
|
64
|
+
* outside any repo (git init), or inside one: part of that project (refused: the hooks and
|
|
65
|
+
* CLAUDE.md work only at a repo's root), or a new project that merely sits under it, such as
|
|
66
|
+
* under a home folder that's a repo.
|
|
67
|
+
*/
|
|
68
|
+
function inspect(root, shown) {
|
|
69
|
+
if (fs.existsSync(root) && !fs.statSync(root).isDirectory()) throw new Failure(`${root} is a file, not a folder`);
|
|
70
|
+
const base = nearestExisting(root);
|
|
71
|
+
const probe = git(base, "rev-parse", "--show-toplevel");
|
|
72
|
+
if (probe.error) throw noGit();
|
|
73
|
+
const plan = { exists: fs.existsSync(root), init: probe.status !== 0, note: "", origin: "" };
|
|
74
|
+
if (!plan.init) {
|
|
75
|
+
// Compare real paths: git resolves symlinks, and path.relative ignores case on Windows.
|
|
76
|
+
const top = fs.realpathSync.native(probe.stdout.trim());
|
|
77
|
+
const here = path.join(fs.realpathSync.native(base), path.relative(base, root));
|
|
78
|
+
if (path.relative(top, here) !== "") {
|
|
79
|
+
if (plan.exists && git(root, "ls-files", "-z").stdout) {
|
|
80
|
+
throw new Failure(`${root} holds files of the repo at ${top}`, {
|
|
81
|
+
hint: `The kit sets up a whole repo, from its root: run agk setup there. ${NOTHING_CHANGED}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
plan.init = true;
|
|
85
|
+
plan.note = `It's inside the repo at ${top} but holds none of its files, so it gets a repo of its own.`;
|
|
86
|
+
} else {
|
|
87
|
+
plan.origin = git(root, "remote", "get-url", "origin").stdout?.trim() ?? "";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const place = shown === "." ? "here" : `in ${shown}`;
|
|
92
|
+
if (plan.exists && setUpAlready(root)) {
|
|
93
|
+
throw new Failure(`The kit is set up ${place} already`, { hint: `To bring in its changes since, run agk update. ${NOTHING_CHANGED}` });
|
|
94
|
+
}
|
|
95
|
+
// A file the folder has already stays as it is: the kit's version goes next to it, to merge.
|
|
96
|
+
plan.files = kitFiles().map((f) => {
|
|
97
|
+
const here = hashAt(root, f.to);
|
|
98
|
+
return { ...f, state: here === null ? "new" : here === f.hash ? "same" : "differs" };
|
|
99
|
+
});
|
|
100
|
+
if (plan.files.some((f) => f.to === SETUP_ONLY && f.state === "differs")) {
|
|
101
|
+
throw new Failure(`There's a ${SETUP_ONLY} ${place} already`, {
|
|
102
|
+
hint: `The setup's instructions go in that file, for Claude Code to follow: rename yours first.\n${NOTHING_CHANGED}`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const inTheWay = plan.files.filter((f) => f.state === "differs" && fs.existsSync(path.join(root, kitCopy(f.to))));
|
|
106
|
+
if (inTheWay.length) {
|
|
107
|
+
throw new Failure(`${count(inTheWay.length, "file")} in the way ${place}`, {
|
|
108
|
+
details: clip(inTheWay.map((f) => kitCopy(f.to))),
|
|
109
|
+
hint: `The kit's version of a file you have goes next to it, as <file>.kit: move these aside first.\n${NOTHING_CHANGED}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return plan;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The GitHub repo name for a folder: GitHub allows letters, digits, ".", "-" and "_". */
|
|
116
|
+
function repoName(root) {
|
|
117
|
+
const name = path.basename(root).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
118
|
+
if (!name || /^\.+$/.test(name)) throw new Failure(`Can't make a GitHub repo name from the folder name "${path.basename(root)}"`);
|
|
119
|
+
return name;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The GitHub account gh is logged in to, once it's clear `name` is free there. */
|
|
123
|
+
async function checkGitHub(name) {
|
|
124
|
+
const user = await gh("api", "user", "--jq", ".login");
|
|
125
|
+
if (user.error) {
|
|
126
|
+
throw new Failure("--github needs gh, GitHub's command line tool, and it isn't installed", {
|
|
127
|
+
hint: `Install it from https://cli.github.com and run gh auth login, or leave out --github. ${NOTHING_CHANGED}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (user.status !== 0) {
|
|
131
|
+
throw new Failure("gh couldn't reach your GitHub account", {
|
|
132
|
+
details: said(user),
|
|
133
|
+
hint: `Run gh auth login (or check your connection), then run this again. ${NOTHING_CHANGED}`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const login = user.stdout.trim();
|
|
137
|
+
const view = await gh("repo", "view", `${login}/${name}`, "--json", "url");
|
|
138
|
+
if (view.status === 0) {
|
|
139
|
+
throw new Failure(`github.com/${login}/${name} already exists`, {
|
|
140
|
+
hint: `Name the folder differently, or leave out --github. ${NOTHING_CHANGED}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (!/could not resolve to a repository/i.test(view.stderr)) {
|
|
144
|
+
throw new Failure(`gh couldn't check whether github.com/${login}/${name} exists`, { details: said(view), hint: NOTHING_CHANGED });
|
|
145
|
+
}
|
|
146
|
+
return login;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const each = (n) => (n === 1 ? "it" : "each");
|
|
150
|
+
|
|
151
|
+
async function setup(args) {
|
|
152
|
+
const { options, folder: root, shown } = parse("setup", args, ["--github", "--public"]);
|
|
153
|
+
const github = options.includes("--github");
|
|
154
|
+
const visibility = options.includes("--public") ? "public" : "private";
|
|
155
|
+
if (visibility === "public" && !github) throw new Failure("--public goes with --github");
|
|
156
|
+
header(VERSION, "Setting up a repo for Claude Code");
|
|
157
|
+
|
|
158
|
+
// 1. Look, changing nothing: anything that would stop the setup stops it here.
|
|
159
|
+
const plan = inspect(root, shown);
|
|
160
|
+
let repo = "";
|
|
161
|
+
if (github) {
|
|
162
|
+
if (plan.origin) {
|
|
163
|
+
throw new Failure(`This repo already has a remote origin: ${plan.origin}`, { hint: `Leave out --github. ${NOTHING_CHANGED}` });
|
|
164
|
+
}
|
|
165
|
+
const name = repoName(root);
|
|
166
|
+
await step("Checking GitHub", async () => {
|
|
167
|
+
repo = `${await checkGitHub(name)}/${name}`;
|
|
168
|
+
return `GitHub: github.com/${repo} is free`;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 2. Set up the folder.
|
|
173
|
+
if (!plan.exists) {
|
|
174
|
+
fs.mkdirSync(root, { recursive: true });
|
|
175
|
+
ok(`Created the folder ${out.cyan(shown)}`);
|
|
176
|
+
}
|
|
177
|
+
if (plan.init) {
|
|
178
|
+
const r = git(root, "init", "-q", "-b", "main");
|
|
179
|
+
if (r.status !== 0) throw new Failure("git init failed", { details: said(r) });
|
|
180
|
+
ok("Created a git repo on main");
|
|
181
|
+
if (plan.note) warn(plan.note);
|
|
182
|
+
}
|
|
183
|
+
const fresh = plan.files.filter((f) => f.state === "new");
|
|
184
|
+
const same = plan.files.filter((f) => f.state === "same");
|
|
185
|
+
const differs = plan.files.filter((f) => f.state === "differs");
|
|
186
|
+
for (const f of fresh) put(root, f.to, f.text);
|
|
187
|
+
for (const f of differs) put(root, kitCopy(f.to), f.text);
|
|
188
|
+
writeRecord(root, plan.files);
|
|
189
|
+
const total = plan.files.length;
|
|
190
|
+
if (fresh.length === total) {
|
|
191
|
+
ok(`Copied the kit's ${total} files ${out.dim("(CLAUDE.md, BOOTSTRAP.md, .claude/, scripts/ and more)")}`);
|
|
192
|
+
} else {
|
|
193
|
+
ok(`Copied ${fresh.length} of the kit's ${total} files`);
|
|
194
|
+
if (same.length) ok(`${count(same.length, "file")} already here, the same as the kit's`);
|
|
195
|
+
if (differs.length) {
|
|
196
|
+
warn(`${count(differs.length, "file")} already here, different: the kit's version is next to ${each(differs.length)}, as <file>.kit`);
|
|
197
|
+
details(differs.map((f) => f.to));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 3. Create the GitHub repo. Its first push waits for the bootstrap's first commit.
|
|
202
|
+
if (github) {
|
|
203
|
+
await step(`Creating github.com/${repo} (${visibility})`, async () => {
|
|
204
|
+
const r = await gh("repo", "create", repo, `--${visibility}`, "--source", root, "--remote", "origin");
|
|
205
|
+
if (r.status !== 0) {
|
|
206
|
+
throw new Failure("GitHub didn't create the repo", {
|
|
207
|
+
details: said(r),
|
|
208
|
+
hint: `The local setup is done. To try again, from the folder:\ngh repo create ${repo} --${visibility} --source . --remote origin`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return `Created github.com/${repo} (${visibility}) as remote origin`;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const cd = shown === "." ? [] : [`cd ${/\s/.test(shown) ? `"${shown}"` : shown}`];
|
|
216
|
+
const merging = differs.length ? ", merges the .kit files into yours" : "";
|
|
217
|
+
const push = github ? `\nThen it pushes main to github.com/${repo}, once you agree.` : "";
|
|
218
|
+
list("Next", [
|
|
219
|
+
...cd,
|
|
220
|
+
`Open Claude Code (${out.cyan("claude")}) and say ${out.cyan('"follow BOOTSTRAP.md"')}\nIt asks a few questions${merging}, fills in the setup and makes the first commit.${push}`,
|
|
221
|
+
]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Compares each of the kit's files with the repo's and with the record of what the kit wrote:
|
|
226
|
+
* - the kit's file hasn't changed, or the repo has it already: nothing to do;
|
|
227
|
+
* - the repo didn't change it: updated (or added, if the kit's is new);
|
|
228
|
+
* - the repo changed it: the kit's version goes next to it, as <file>.kit, to merge;
|
|
229
|
+
* - the repo deleted it: it stays deleted.
|
|
230
|
+
* A file the kit no longer has is removed, unless the repo changed it.
|
|
231
|
+
* Without a record (agk 0.1.0 kept none), a file that differs from the kit's counts as changed.
|
|
232
|
+
*/
|
|
233
|
+
async function update(args) {
|
|
234
|
+
const { folder, shown } = parse("update", args, []);
|
|
235
|
+
header(VERSION, "Bringing the kit's changes into a repo");
|
|
236
|
+
if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) throw new Failure(`${shown} isn't a folder`);
|
|
237
|
+
const probe = git(folder, "rev-parse", "--show-toplevel");
|
|
238
|
+
if (probe.error) throw noGit();
|
|
239
|
+
if (probe.status !== 0) {
|
|
240
|
+
throw new Failure(`${shown === "." ? "This folder" : shown} isn't in a git repo`, {
|
|
241
|
+
hint: "agk update works in a repo the kit set up. To set one up, run agk setup.",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const root = path.resolve(probe.stdout.trim());
|
|
245
|
+
const record = readRecord(root);
|
|
246
|
+
if (!record && !setUpAlready(root)) {
|
|
247
|
+
throw new Failure("The kit isn't set up in this repo", { hint: `To set it up, run agk setup. ${NOTHING_CHANGED}` });
|
|
248
|
+
}
|
|
249
|
+
if (record && newer(record.version, VERSION)) {
|
|
250
|
+
throw new Failure(`This repo has the kit of agk v${record.version}, newer than this agk (v${VERSION})`, {
|
|
251
|
+
hint: `Update agk first: npm install -g ${PACKAGE} (or git pull, in a clone). ${NOTHING_CHANGED}`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const files = kitFiles().filter((f) => f.to !== SETUP_ONLY);
|
|
256
|
+
const wrote = record?.files ?? {};
|
|
257
|
+
const c = { updated: [], added: [], merge: [], deleted: [], removed: [], kept: [] };
|
|
258
|
+
for (const f of files) {
|
|
259
|
+
const here = hashAt(root, f.to);
|
|
260
|
+
const before = wrote[f.to] ?? null;
|
|
261
|
+
if ((record && f.hash === before) || here === f.hash) continue;
|
|
262
|
+
if (here === null) (before === null ? c.added : c.deleted).push(f.to);
|
|
263
|
+
else (here === before ? c.updated : c.merge).push(f.to);
|
|
264
|
+
}
|
|
265
|
+
const kit = new Map(files.map((f) => [f.to, f]));
|
|
266
|
+
for (const [rel, before] of Object.entries(wrote)) {
|
|
267
|
+
const here = kit.has(rel) || rel === SETUP_ONLY ? null : hashAt(root, rel);
|
|
268
|
+
if (here !== null) (here === before ? c.removed : c.kept).push(rel);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const rel of [...c.updated, ...c.added]) put(root, rel, kit.get(rel).text);
|
|
272
|
+
for (const rel of c.merge) put(root, kitCopy(rel), kit.get(rel).text);
|
|
273
|
+
for (const rel of c.removed) remove(root, rel);
|
|
274
|
+
const recorded = writeRecord(root, files);
|
|
275
|
+
|
|
276
|
+
if (!record) warn(`No ${RECORD} (agk 0.1.0 kept none), so a file that differs from the kit's counts as yours`);
|
|
277
|
+
const report = (paths, say, text) => {
|
|
278
|
+
if (!paths.length) return;
|
|
279
|
+
say(text(count(paths.length, "file"), paths.length));
|
|
280
|
+
details(paths);
|
|
281
|
+
};
|
|
282
|
+
report(c.updated, ok, (n) => `Updated ${n} you hadn't changed`);
|
|
283
|
+
report(c.added, ok, (n) => `Added ${n} new in the kit`);
|
|
284
|
+
report(c.removed, ok, (n) => `Removed ${n} the kit no longer has`);
|
|
285
|
+
const how = record ? "changed both here and in the kit" : "differ from the kit's";
|
|
286
|
+
report(c.merge, warn, (n, k) => `${n} ${how}: the kit's version is next to ${each(k)}, as <file>.kit`);
|
|
287
|
+
report(c.kept, warn, (n, k) => `Kept ${n} you changed, though the kit no longer has ${k === 1 ? "it" : "them"}`);
|
|
288
|
+
report(c.deleted, warn, (n, k) => `Left ${n} deleted, as you had ${k === 1 ? "it" : "them"}, though the kit changed ${k === 1 ? "it" : "them"}`);
|
|
289
|
+
const touched = c.updated.length + c.added.length + c.removed.length + c.merge.length;
|
|
290
|
+
if (!touched && !recorded) {
|
|
291
|
+
ok(`Up to date with the kit (v${VERSION})`);
|
|
292
|
+
blank();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (recorded) ok(`Recorded the kit's files (v${VERSION}) in ${RECORD}`);
|
|
296
|
+
const merge = `Open Claude Code (${out.cyan("claude")}) and say ${out.cyan('"merge the .kit files"')}\nIt brings what's new in each into your file, then deletes the .kit file.\ndoctor reports them until then.`;
|
|
297
|
+
list("Next", [
|
|
298
|
+
...(c.merge.length ? [merge] : []),
|
|
299
|
+
`Review the changes (${out.cyan("git status")}), then commit them on a branch, as any change`,
|
|
300
|
+
]);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function help() {
|
|
304
|
+
header(VERSION, "Sets up git repos for Claude Code");
|
|
305
|
+
const row = (a, b) => line(` ${out.cyan(a.padEnd(30))}${b}`);
|
|
306
|
+
line(out.bold("Usage"));
|
|
307
|
+
row("agk setup [folder] [options]", "Set up folder (default: this one): git init if needed,");
|
|
308
|
+
row("", "then the kit's files. For a file it has already, the");
|
|
309
|
+
row("", "kit's version goes next to it, as <file>.kit, to merge");
|
|
310
|
+
row("agk update [folder]", "Bring in the kit's changes: updates the files the repo");
|
|
311
|
+
row("", "hasn't changed, and puts the kit's next to the others");
|
|
312
|
+
row("agk help", "Show this help");
|
|
313
|
+
row("agk --version", "Print the version");
|
|
314
|
+
blank();
|
|
315
|
+
line(out.bold("Setup options"));
|
|
316
|
+
row("--github", "Also create the repo on GitHub (with gh), as remote origin");
|
|
317
|
+
row("--public", "With --github: make it public (default: private)");
|
|
318
|
+
blank();
|
|
319
|
+
line(out.bold("Examples"));
|
|
320
|
+
for (const e of ["agk setup", "agk setup my-app --github", "agk update"]) line(` ${out.dim("$")} ${e}`);
|
|
321
|
+
blank();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function main([cmd, ...args]) {
|
|
325
|
+
const wantsHelp = args.includes("--help") || args.includes("-h");
|
|
326
|
+
switch (cmd) {
|
|
327
|
+
case "setup":
|
|
328
|
+
return wantsHelp ? help() : setup(args);
|
|
329
|
+
case "update":
|
|
330
|
+
return wantsHelp ? help() : update(args);
|
|
331
|
+
case undefined:
|
|
332
|
+
case "help":
|
|
333
|
+
case "--help":
|
|
334
|
+
case "-h":
|
|
335
|
+
return help();
|
|
336
|
+
case "--version":
|
|
337
|
+
case "-v":
|
|
338
|
+
return console.log(VERSION);
|
|
339
|
+
default:
|
|
340
|
+
throw new Failure(`Unknown command '${cmd}'`, { hint: "Run agk help for the commands." });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
main(process.argv.slice(2)).catch((e) => {
|
|
345
|
+
if (!(e instanceof Failure)) throw e;
|
|
346
|
+
showFailure(e);
|
|
347
|
+
process.exitCode = 1;
|
|
348
|
+
});
|
package/lib/kit.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
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
|
+
const lf = (file) => fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
36
|
+
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
|
+
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 valid =
|
|
90
|
+
typeof record?.version === "string" &&
|
|
91
|
+
files &&
|
|
92
|
+
typeof files === "object" &&
|
|
93
|
+
Object.entries(files).every(([rel, h]) => inRepo(rel) && typeof h === "string");
|
|
94
|
+
if (!valid) {
|
|
95
|
+
throw new Failure(`${RECORD} isn't what agk wrote`, {
|
|
96
|
+
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.`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return record;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Records the kit's files (but the setup's instructions) in a repo. False if it held that already. */
|
|
103
|
+
export function writeRecord(root, files) {
|
|
104
|
+
const record = {
|
|
105
|
+
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.",
|
|
106
|
+
version: VERSION,
|
|
107
|
+
files: Object.fromEntries(files.filter((f) => f.to !== SETUP_ONLY).map((f) => [f.to, f.hash])),
|
|
108
|
+
};
|
|
109
|
+
const text = `${JSON.stringify(record, null, 2)}\n`;
|
|
110
|
+
const file = path.join(root, RECORD);
|
|
111
|
+
if (fs.existsSync(file) && lf(file) === text) return false;
|
|
112
|
+
put(root, RECORD, text);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 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). */
|
|
117
|
+
export const setUpAlready = (root) =>
|
|
118
|
+
[RECORD, ".claude/skills/agent-setup/SKILL.md"].some((rel) => fs.existsSync(path.join(root, rel)));
|
|
119
|
+
|
|
120
|
+
/** True if version `a` is newer than `b` (both like 1.2.3). */
|
|
121
|
+
export function newer(a, b) {
|
|
122
|
+
const [x, y] = [a, b].map((v) => v.split(".").map((n) => Number.parseInt(n, 10) || 0));
|
|
123
|
+
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
|
|
124
|
+
return false;
|
|
125
|
+
}
|