@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/lib/setup.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// agk setup: copies the kit (and a preset, if any) into a folder, then the agent there follows
|
|
2
|
+
// BOOTSTRAP.md. Where the folder has one of the files already, the kit's version goes next to it
|
|
3
|
+
// as <file>.kit, for the agent to merge. With --github it also creates the repo on GitHub, via gh.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { NOTHING_CHANGED, each, exclusive, gh, git, noGit, parse, said, shownPath } from "./cli.mjs";
|
|
7
|
+
import { SETUP_ONLY, VERSION, hashAt, kitCopy, kitFiles, put, setUpAlready, writeRecord } from "./kit.mjs";
|
|
8
|
+
import { DEFAULT, compose, loadPreset, presetRecord, presetSummary } from "./presets.mjs";
|
|
9
|
+
import { Failure, clip, count, details, header, list, ok, out, step, warn } from "./ui.mjs";
|
|
10
|
+
|
|
11
|
+
/** The nearest folder at or above `dir` that exists. */
|
|
12
|
+
function nearestExisting(dir) {
|
|
13
|
+
while (!fs.existsSync(dir)) dir = path.dirname(dir);
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What setup would do in `root`, changing nothing: whether to create it and run git init, and
|
|
19
|
+
* what to do with each file. The folder is a repo's root (set it up as it is), outside any repo
|
|
20
|
+
* (git init), or inside one: part of that project (refused: the hooks and CLAUDE.md work only at
|
|
21
|
+
* a repo's root), or a new project that merely sits under it, such as under a home folder that's
|
|
22
|
+
* a repo.
|
|
23
|
+
*/
|
|
24
|
+
function inspect(root, shown, files) {
|
|
25
|
+
if (fs.existsSync(root) && !fs.statSync(root).isDirectory()) throw new Failure(`${root} is a file, not a folder`);
|
|
26
|
+
const base = nearestExisting(root);
|
|
27
|
+
const probe = git(base, "rev-parse", "--show-toplevel");
|
|
28
|
+
if (probe.error) throw noGit();
|
|
29
|
+
const plan = { exists: fs.existsSync(root), init: probe.status !== 0, note: "", origin: "" };
|
|
30
|
+
if (!plan.init) {
|
|
31
|
+
// Compare real paths: git resolves symlinks, and path.relative ignores case on Windows.
|
|
32
|
+
const top = fs.realpathSync.native(probe.stdout.trim());
|
|
33
|
+
const here = path.join(fs.realpathSync.native(base), path.relative(base, root));
|
|
34
|
+
if (path.relative(top, here) !== "") {
|
|
35
|
+
if (plan.exists && git(root, "ls-files", "-z").stdout) {
|
|
36
|
+
throw new Failure(`${root} holds files of the repo at ${top}`, {
|
|
37
|
+
hint: `The kit sets up a whole repo, from its root: run agk setup there. ${NOTHING_CHANGED}`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
plan.init = true;
|
|
41
|
+
plan.note = `It's inside the repo at ${top} but holds none of its files, so it gets a repo of its own.`;
|
|
42
|
+
} else {
|
|
43
|
+
plan.origin = git(root, "remote", "get-url", "origin").stdout?.trim() ?? "";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const place = shown === "." ? "here" : `in ${shown}`;
|
|
48
|
+
if (plan.exists && setUpAlready(root)) {
|
|
49
|
+
throw new Failure(`The kit is set up ${place} already`, { hint: `To bring in its changes since, run agk update. ${NOTHING_CHANGED}` });
|
|
50
|
+
}
|
|
51
|
+
// A file the folder has already stays as it is: the kit's version goes next to it, to merge.
|
|
52
|
+
plan.files = files.map((f) => {
|
|
53
|
+
const here = hashAt(root, f.to);
|
|
54
|
+
return { ...f, state: here === null ? "new" : here === f.hash ? "same" : "differs" };
|
|
55
|
+
});
|
|
56
|
+
if (plan.files.some((f) => f.to === SETUP_ONLY && f.state === "differs")) {
|
|
57
|
+
throw new Failure(`There's a ${SETUP_ONLY} ${place} already`, {
|
|
58
|
+
hint: `The setup's instructions go in that file, for Claude Code to follow: rename yours first.\n${NOTHING_CHANGED}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const inTheWay = plan.files.filter((f) => f.state === "differs" && fs.existsSync(path.join(root, kitCopy(f.to))));
|
|
62
|
+
if (inTheWay.length) {
|
|
63
|
+
throw new Failure(`${count(inTheWay.length, "file")} in the way ${place}`, {
|
|
64
|
+
details: clip(inTheWay.map((f) => kitCopy(f.to))),
|
|
65
|
+
hint: `The kit's version of a file you have goes next to it, as <file>.kit: move these aside first.\n${NOTHING_CHANGED}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return plan;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The GitHub repo name for a folder: GitHub allows letters, digits, ".", "-" and "_". */
|
|
72
|
+
function repoName(root) {
|
|
73
|
+
const name = path.basename(root).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
74
|
+
if (!name || /^\.+$/.test(name)) throw new Failure(`Can't make a GitHub repo name from the folder name "${path.basename(root)}"`);
|
|
75
|
+
return name;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The GitHub account gh is logged in to, once it's clear `name` is free there. */
|
|
79
|
+
async function checkGitHub(name, leaveOut) {
|
|
80
|
+
const user = await gh("api", "user", "--jq", ".login");
|
|
81
|
+
if (user.error) {
|
|
82
|
+
throw new Failure("--github needs gh, GitHub's command line tool, and it isn't installed", {
|
|
83
|
+
hint: `Install it from https://cli.github.com and run gh auth login, or ${leaveOut}. ${NOTHING_CHANGED}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (user.status !== 0) {
|
|
87
|
+
throw new Failure("gh couldn't reach your GitHub account", {
|
|
88
|
+
details: said(user),
|
|
89
|
+
hint: `Run gh auth login (or check your connection), then run this again. ${NOTHING_CHANGED}`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const login = user.stdout.trim();
|
|
93
|
+
const view = await gh("repo", "view", `${login}/${name}`, "--json", "url");
|
|
94
|
+
if (view.status === 0) {
|
|
95
|
+
throw new Failure(`github.com/${login}/${name} already exists`, {
|
|
96
|
+
hint: `Name the folder differently, or ${leaveOut}. ${NOTHING_CHANGED}`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (!/could not resolve to a repository/i.test(view.stderr)) {
|
|
100
|
+
throw new Failure(`gh couldn't check whether github.com/${login}/${name} exists`, { details: said(view), hint: NOTHING_CHANGED });
|
|
101
|
+
}
|
|
102
|
+
return login;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The preset to use: --preset's, else the default one unless --no-preset. */
|
|
106
|
+
function choosePreset(opts, vals) {
|
|
107
|
+
const name = vals["--preset"];
|
|
108
|
+
if (name) {
|
|
109
|
+
const preset = loadPreset(name);
|
|
110
|
+
if (!preset) throw new Failure(`There's no preset named ${name}`, { hint: `agk preset list shows yours. ${NOTHING_CHANGED}` });
|
|
111
|
+
return { preset, note: "" };
|
|
112
|
+
}
|
|
113
|
+
if (opts.has("--no-preset")) return { preset: null, note: "" };
|
|
114
|
+
const preset = loadPreset(DEFAULT);
|
|
115
|
+
return { preset, note: preset ? out.dim(" (your default; --no-preset leaves it out)") : "" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function setup(args) {
|
|
119
|
+
const { opts, vals, pos } = parse("setup", args, {
|
|
120
|
+
flags: ["--github", "--no-github", "--public", "--private", "--no-preset"],
|
|
121
|
+
values: ["--preset"],
|
|
122
|
+
});
|
|
123
|
+
exclusive(opts, "--github", "--no-github");
|
|
124
|
+
exclusive(opts, "--public", "--private");
|
|
125
|
+
if (vals["--preset"] && opts.has("--no-preset")) throw new Failure("--preset and --no-preset don't go together");
|
|
126
|
+
const root = path.resolve(pos[0] ?? ".");
|
|
127
|
+
const shown = shownPath(root);
|
|
128
|
+
header(VERSION, "Setting up a repo for Claude Code");
|
|
129
|
+
|
|
130
|
+
// 1. Look, changing nothing: anything that would stop the setup stops it here.
|
|
131
|
+
const { preset, note } = choosePreset(opts, vals);
|
|
132
|
+
const defaults = preset?.meta.defaults ?? {};
|
|
133
|
+
const github = opts.has("--github") || (!opts.has("--no-github") && defaults.github === true);
|
|
134
|
+
const visibility = opts.has("--public") || (!opts.has("--private") && defaults.visibility === "public") ? "public" : "private";
|
|
135
|
+
for (const flag of ["--public", "--private"]) if (opts.has(flag) && !github) throw new Failure(`${flag} goes with --github`);
|
|
136
|
+
const { files, warnings } = compose(kitFiles(), preset);
|
|
137
|
+
const plan = inspect(root, shown, files);
|
|
138
|
+
let repo = "";
|
|
139
|
+
if (github) {
|
|
140
|
+
// Said in every hint: how to do without GitHub, whether --github or the preset asked for it.
|
|
141
|
+
const leaveOut = opts.has("--github") ? "leave out --github" : `add --no-github (preset ${preset.name} asks for GitHub)`;
|
|
142
|
+
if (plan.origin) {
|
|
143
|
+
throw new Failure(`This repo already has a remote origin: ${plan.origin}`, { hint: `To set it up anyway, ${leaveOut}. ${NOTHING_CHANGED}` });
|
|
144
|
+
}
|
|
145
|
+
const name = repoName(root);
|
|
146
|
+
await step("Checking GitHub", async () => {
|
|
147
|
+
repo = `${await checkGitHub(name, leaveOut)}/${name}`;
|
|
148
|
+
return `GitHub: github.com/${repo} is free`;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 2. Set up the folder.
|
|
153
|
+
if (preset) ok(`Preset ${out.cyan(preset.name)}: ${presetSummary(plan.files)}${note}`);
|
|
154
|
+
for (const w of warnings) warn(w);
|
|
155
|
+
if (!plan.exists) {
|
|
156
|
+
fs.mkdirSync(root, { recursive: true });
|
|
157
|
+
ok(`Created the folder ${out.cyan(shown)}`);
|
|
158
|
+
}
|
|
159
|
+
if (plan.init) {
|
|
160
|
+
const r = git(root, "init", "-q", "-b", "main");
|
|
161
|
+
if (r.status !== 0) throw new Failure("git init failed", { details: said(r) });
|
|
162
|
+
ok("Created a git repo on main");
|
|
163
|
+
if (plan.note) warn(plan.note);
|
|
164
|
+
}
|
|
165
|
+
const fresh = plan.files.filter((f) => f.state === "new");
|
|
166
|
+
const same = plan.files.filter((f) => f.state === "same");
|
|
167
|
+
const differs = plan.files.filter((f) => f.state === "differs");
|
|
168
|
+
for (const f of fresh) put(root, f.to, f.text);
|
|
169
|
+
for (const f of differs) put(root, kitCopy(f.to), f.text);
|
|
170
|
+
writeRecord(root, plan.files, { preset: preset && presetRecord(preset, plan.files) });
|
|
171
|
+
const total = plan.files.length;
|
|
172
|
+
const whose = preset ? "" : "the kit's ";
|
|
173
|
+
if (fresh.length === total) {
|
|
174
|
+
ok(preset ? `Copied all ${total} files` : `Copied the kit's ${total} files ${out.dim("(CLAUDE.md, BOOTSTRAP.md, .claude/, scripts/ and more)")}`);
|
|
175
|
+
} else {
|
|
176
|
+
ok(`Copied ${fresh.length} of ${whose}${total} files`);
|
|
177
|
+
if (same.length) ok(`${count(same.length, "file")} already here, the same as the ${preset ? "setup's" : "kit's"}`);
|
|
178
|
+
if (differs.length) {
|
|
179
|
+
warn(`${count(differs.length, "file")} already here, different: the kit's version is next to ${each(differs.length)}, as <file>.kit`);
|
|
180
|
+
details(differs.map((f) => f.to));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 3. Create the GitHub repo. Its first push waits for the bootstrap's first commit.
|
|
185
|
+
if (github) {
|
|
186
|
+
await step(`Creating github.com/${repo} (${visibility})`, async () => {
|
|
187
|
+
const r = await gh("repo", "create", repo, `--${visibility}`, "--source", root, "--remote", "origin");
|
|
188
|
+
if (r.status !== 0) {
|
|
189
|
+
throw new Failure("GitHub didn't create the repo", {
|
|
190
|
+
details: said(r),
|
|
191
|
+
hint: `The local setup is done. To try again, from the folder:\ngh repo create ${repo} --${visibility} --source . --remote origin`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return `Created github.com/${repo} (${visibility}) as remote origin`;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const cd = shown === "." ? [] : [`cd ${/\s/.test(shown) ? `"${shown}"` : shown}`];
|
|
199
|
+
const merging = differs.length ? ", merges the .kit files into yours" : "";
|
|
200
|
+
const push = github ? `\nThen it pushes main to github.com/${repo}, once you agree.` : "";
|
|
201
|
+
list("Next", [
|
|
202
|
+
...cd,
|
|
203
|
+
`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}`,
|
|
204
|
+
]);
|
|
205
|
+
}
|
package/lib/ui.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// How agk prints: colors, symbols and a spinner, with plain fallbacks.
|
|
2
|
+
// - Color is on for a terminal, unless NO_COLOR is set or TERM is dumb. FORCE_COLOR=1 forces it
|
|
3
|
+
// on, FORCE_COLOR=0 off.
|
|
4
|
+
// - Symbols are Unicode where the terminal can show them, ASCII elsewhere. On Windows that's only
|
|
5
|
+
// in Windows Terminal, VS Code and the like: an old console lacks the glyphs, and a pipe may be
|
|
6
|
+
// decoded with an old code page.
|
|
7
|
+
// - The spinner moves only in a terminal. Elsewhere (a pipe, Claude Code) each step prints one
|
|
8
|
+
// line when it's done.
|
|
9
|
+
|
|
10
|
+
import readline from "node:readline";
|
|
11
|
+
|
|
12
|
+
const env = process.env;
|
|
13
|
+
const tty = Boolean(process.stdout.isTTY) && env.TERM !== "dumb";
|
|
14
|
+
/** True when someone can answer a question: both ends are a terminal. */
|
|
15
|
+
export const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
16
|
+
|
|
17
|
+
function colorOn(stream) {
|
|
18
|
+
if (env.FORCE_COLOR) return env.FORCE_COLOR !== "0";
|
|
19
|
+
return Boolean(stream.isTTY) && !env.NO_COLOR && env.TERM !== "dumb";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const modernWindowsTerminal = Boolean(
|
|
23
|
+
env.WT_SESSION || env.TERM_PROGRAM === "vscode" || env.TERMINAL_EMULATOR === "JetBrains-JediTerm" || env.ConEmuTask,
|
|
24
|
+
);
|
|
25
|
+
const unicode = process.platform === "win32" ? tty && modernWindowsTerminal : env.TERM !== "linux";
|
|
26
|
+
|
|
27
|
+
const sym = unicode
|
|
28
|
+
? { ok: "✔", fail: "✖", warn: "▲", more: "…", frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] }
|
|
29
|
+
: { ok: "+", fail: "x", warn: "!", more: "...", frames: ["-", "\\", "|", "/"] };
|
|
30
|
+
|
|
31
|
+
function palette(on) {
|
|
32
|
+
const wrap = (open, close) => (s) => (on ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
|
|
33
|
+
return { bold: wrap(1, 22), dim: wrap(2, 22), red: wrap(31, 39), green: wrap(32, 39), yellow: wrap(33, 39), cyan: wrap(36, 39) };
|
|
34
|
+
}
|
|
35
|
+
/** Styles for stdout, and for stderr (failures). */
|
|
36
|
+
export const out = palette(colorOn(process.stdout));
|
|
37
|
+
const err = palette(colorOn(process.stderr));
|
|
38
|
+
|
|
39
|
+
const say = (text = "") => process.stdout.write(`${text}\n`);
|
|
40
|
+
export const blank = () => say();
|
|
41
|
+
/** A line indented under the header. */
|
|
42
|
+
export const line = (text) => say(` ${text}`);
|
|
43
|
+
export const ok = (text) => line(`${out.green(sym.ok)} ${text}`);
|
|
44
|
+
export const warn = (text) => line(`${out.yellow(sym.warn)} ${text}`);
|
|
45
|
+
/** The items of the line above, indented under it (the first 8 of them). */
|
|
46
|
+
export const details = (items) => {
|
|
47
|
+
for (const item of clip(items)) line(` ${item}`);
|
|
48
|
+
};
|
|
49
|
+
/** Asks a question in the terminal and returns the answer, trimmed. */
|
|
50
|
+
export function ask(question) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
// terminal: false: no raw mode, so it also reads a piped answer (the kit's tests).
|
|
53
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
54
|
+
process.stdout.write(` ${out.bold(question)} `);
|
|
55
|
+
// Resolve before close(): it emits "close" at once, and a closed input answers "".
|
|
56
|
+
rl.once("line", (answer) => {
|
|
57
|
+
resolve(answer.trim());
|
|
58
|
+
rl.close();
|
|
59
|
+
});
|
|
60
|
+
rl.once("close", () => resolve(""));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** "1 file", "2 files". */
|
|
64
|
+
export const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
65
|
+
|
|
66
|
+
export function header(version, subtitle) {
|
|
67
|
+
blank();
|
|
68
|
+
line(`${out.bold(out.cyan("agk"))} ${out.dim(`v${version}`)}${subtitle ? ` ${subtitle}` : ""}`);
|
|
69
|
+
blank();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A numbered list under a bold title. An item's later lines are dimmed, as its explanation. */
|
|
73
|
+
export function list(title, items) {
|
|
74
|
+
blank();
|
|
75
|
+
line(out.bold(title));
|
|
76
|
+
items.forEach((item, i) => {
|
|
77
|
+
const [first, ...rest] = item.split("\n");
|
|
78
|
+
line(` ${out.cyan(`${i + 1}.`)} ${first}`);
|
|
79
|
+
for (const r of rest) line(` ${out.dim(r)}`);
|
|
80
|
+
});
|
|
81
|
+
blank();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What went wrong, for the user: a message, the lines that show it, and what to do next. */
|
|
85
|
+
export class Failure extends Error {
|
|
86
|
+
constructor(message, { details = [], hint = "" } = {}) {
|
|
87
|
+
super(message);
|
|
88
|
+
this.details = details;
|
|
89
|
+
this.hint = hint;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function showFailure(e) {
|
|
94
|
+
const lines = [` ${err.red(sym.fail)} ${err.bold(e.message)}`, ...e.details.map((d) => ` ${d}`)];
|
|
95
|
+
if (e.hint) lines.push(...e.hint.split("\n").map((h) => ` ${h}`));
|
|
96
|
+
process.stderr.write(`${lines.join("\n")}\n\n`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Up to `max` items, then "... and N more". */
|
|
100
|
+
export function clip(items, max = 8) {
|
|
101
|
+
return items.length <= max ? items : [...items.slice(0, max), `${sym.more} and ${items.length - max} more`];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let cursorHidden = false;
|
|
105
|
+
function showCursor() {
|
|
106
|
+
if (cursorHidden) process.stdout.write("\x1b[?25h");
|
|
107
|
+
cursorHidden = false;
|
|
108
|
+
}
|
|
109
|
+
process.on("exit", showCursor);
|
|
110
|
+
process.on("SIGINT", () => {
|
|
111
|
+
if (tty) process.stdout.write("\r\x1b[2K");
|
|
112
|
+
showCursor();
|
|
113
|
+
process.exit(130);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Runs `work` (async) behind a spinner, then marks the step done with the text `work` returns,
|
|
118
|
+
* or `text`. If `work` throws, the spinner is cleared and the error goes on.
|
|
119
|
+
*/
|
|
120
|
+
export async function step(text, work) {
|
|
121
|
+
if (!tty) {
|
|
122
|
+
ok((await work()) ?? text);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
let frame = 0;
|
|
126
|
+
const draw = () => process.stdout.write(`\r\x1b[2K ${out.cyan(sym.frames[frame++ % sym.frames.length])} ${text}${out.dim(sym.more)}`);
|
|
127
|
+
process.stdout.write("\x1b[?25l");
|
|
128
|
+
cursorHidden = true;
|
|
129
|
+
draw();
|
|
130
|
+
const timer = setInterval(draw, 80);
|
|
131
|
+
const stop = () => {
|
|
132
|
+
clearInterval(timer);
|
|
133
|
+
process.stdout.write("\r\x1b[2K");
|
|
134
|
+
showCursor();
|
|
135
|
+
};
|
|
136
|
+
let done;
|
|
137
|
+
try {
|
|
138
|
+
done = await work();
|
|
139
|
+
} catch (e) {
|
|
140
|
+
stop();
|
|
141
|
+
throw e;
|
|
142
|
+
}
|
|
143
|
+
stop();
|
|
144
|
+
ok(done ?? text);
|
|
145
|
+
}
|
package/lib/update.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// agk update: brings the kit's (and the preset's) later changes into a repo the kit set up.
|
|
2
|
+
//
|
|
3
|
+
// It compares each file agk writes now with the repo's file and with the record of what agk wrote:
|
|
4
|
+
// - agk's file hasn't changed, or the repo has it already: nothing to do;
|
|
5
|
+
// - the repo didn't change it: updated (or added, if it's new);
|
|
6
|
+
// - the repo changed it: agk's version goes next to it, as <file>.kit, to merge;
|
|
7
|
+
// - the repo deleted it: it stays deleted.
|
|
8
|
+
// A file agk no longer writes is removed, unless the repo changed it.
|
|
9
|
+
// Without a record (agk 0.1.0 kept none), a file that differs from the kit's counts as changed.
|
|
10
|
+
// If the repo's preset isn't on this machine, the files it added or changed stay as they are.
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { NOTHING_CHANGED, each, parse, repoRoot, shownPath } from "./cli.mjs";
|
|
14
|
+
import { PACKAGE, RECORD, SETUP_ONLY, VERSION, hashAt, kitCopy, kitFiles, newer, put, readRecord, remove, setUpAlready, writeRecord } from "./kit.mjs";
|
|
15
|
+
import { compose, loadPreset, presetRecord } from "./presets.mjs";
|
|
16
|
+
import { Failure, blank, count, details, header, list, ok, out, warn } from "./ui.mjs";
|
|
17
|
+
|
|
18
|
+
export async function update(args) {
|
|
19
|
+
const { pos } = parse("update", args);
|
|
20
|
+
const folder = path.resolve(pos[0] ?? ".");
|
|
21
|
+
const shown = shownPath(folder);
|
|
22
|
+
header(VERSION, "Bringing the kit's changes into a repo");
|
|
23
|
+
if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) throw new Failure(`${shown} isn't a folder`);
|
|
24
|
+
const root = repoRoot(folder, "update");
|
|
25
|
+
const record = readRecord(root);
|
|
26
|
+
if (!record && !setUpAlready(root)) {
|
|
27
|
+
throw new Failure("The kit isn't set up in this repo", { hint: `To set it up, run agk setup. ${NOTHING_CHANGED}` });
|
|
28
|
+
}
|
|
29
|
+
if (record && newer(record.version, VERSION)) {
|
|
30
|
+
throw new Failure(`This repo has the kit of agk v${record.version}, newer than this agk (v${VERSION})`, {
|
|
31
|
+
hint: `Update agk first: npm install -g ${PACKAGE} (or git pull, in a clone). ${NOTHING_CHANGED}`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The preset the repo was set up with, if it's on this machine. If not, its files are left out.
|
|
36
|
+
const used = record?.preset;
|
|
37
|
+
const preset = used ? loadPreset(used.name) : null;
|
|
38
|
+
const skip = new Set(used && !preset ? used.files ?? [] : []);
|
|
39
|
+
const composed = compose(kitFiles(), preset);
|
|
40
|
+
const files = composed.files.filter((f) => f.to !== SETUP_ONLY && !skip.has(f.to));
|
|
41
|
+
const wrote = record?.files ?? {};
|
|
42
|
+
const c = { updated: [], added: [], merge: [], deleted: [], removed: [], kept: [] };
|
|
43
|
+
for (const f of files) {
|
|
44
|
+
const here = hashAt(root, f.to);
|
|
45
|
+
const before = wrote[f.to] ?? null;
|
|
46
|
+
if ((record && f.hash === before) || here === f.hash) continue;
|
|
47
|
+
if (here === null) (before === null ? c.added : c.deleted).push(f.to);
|
|
48
|
+
else (here === before ? c.updated : c.merge).push(f.to);
|
|
49
|
+
}
|
|
50
|
+
const ours = new Map(files.map((f) => [f.to, f]));
|
|
51
|
+
for (const [rel, before] of Object.entries(wrote)) {
|
|
52
|
+
const here = ours.has(rel) || skip.has(rel) || rel === SETUP_ONLY ? null : hashAt(root, rel);
|
|
53
|
+
if (here !== null) (here === before ? c.removed : c.kept).push(rel);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const rel of [...c.updated, ...c.added]) put(root, rel, ours.get(rel).text);
|
|
57
|
+
for (const rel of c.merge) put(root, kitCopy(rel), ours.get(rel).text);
|
|
58
|
+
for (const rel of c.removed) remove(root, rel);
|
|
59
|
+
const keep = Object.fromEntries([...skip].filter((rel) => rel in wrote).map((rel) => [rel, wrote[rel]]));
|
|
60
|
+
const recorded = writeRecord(root, files, { preset: preset ? presetRecord(preset, files) : used ?? null, keep });
|
|
61
|
+
|
|
62
|
+
if (!record) warn(`No ${RECORD} (agk 0.1.0 kept none), so a file that differs from the kit's counts as yours`);
|
|
63
|
+
if (used && !preset) {
|
|
64
|
+
warn(
|
|
65
|
+
`This repo uses preset ${used.name}, which isn't on this machine: its ${count(skip.size, "file")} stay as they are` +
|
|
66
|
+
(used.source ? `\n To get it: agk preset add ${used.source}` : ""),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
for (const w of composed.warnings) warn(w);
|
|
70
|
+
const source = preset ? `the kit or preset ${preset.name}` : "the kit";
|
|
71
|
+
const report = (paths, say, text) => {
|
|
72
|
+
if (!paths.length) return;
|
|
73
|
+
say(text(count(paths.length, "file"), paths.length));
|
|
74
|
+
details(paths);
|
|
75
|
+
};
|
|
76
|
+
const them = (k) => (k === 1 ? "it" : "them");
|
|
77
|
+
report(c.updated, ok, (n) => `Updated ${n} you hadn't changed`);
|
|
78
|
+
report(c.added, ok, (n) => `Added ${n} new in ${source}`);
|
|
79
|
+
report(c.removed, ok, (n) => `Removed ${n} ${source} no longer has`);
|
|
80
|
+
const how = record ? `changed both here and in ${source}` : "differ from the kit's";
|
|
81
|
+
report(c.merge, warn, (n, k) => `${n} ${how}: the ${preset ? "new" : "kit's"} version is next to ${each(k)}, as <file>.kit`);
|
|
82
|
+
report(c.kept, warn, (n, k) => `Kept ${n} you changed, though ${source} no longer has ${them(k)}`);
|
|
83
|
+
report(c.deleted, warn, (n, k) => `Left ${n} deleted, as you had ${them(k)}, though ${source} changed ${them(k)}`);
|
|
84
|
+
const touched = c.updated.length + c.added.length + c.removed.length + c.merge.length;
|
|
85
|
+
if (!touched && !recorded) {
|
|
86
|
+
ok(`Up to date with ${preset ? `the kit (v${VERSION}) and preset ${preset.name}` : `the kit (v${VERSION})`}`);
|
|
87
|
+
blank();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (recorded) ok(preset ? `Recorded the files (v${VERSION}, preset ${preset.name}) in ${RECORD}` : `Recorded the kit's files (v${VERSION}) in ${RECORD}`);
|
|
91
|
+
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.`;
|
|
92
|
+
list("Next", [
|
|
93
|
+
...(c.merge.length ? [merge] : []),
|
|
94
|
+
`Review the changes (${out.cyan("git status")}), then commit them on a branch, as any change`,
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonxped31/agent-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Sets up git repos for Claude Code, and keeps them up to date: a short CLAUDE.md, git and todo skills, hooks and checks.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"claude",
|
|
8
|
+
"claude-code",
|
|
9
|
+
"agents",
|
|
10
|
+
"git-hooks",
|
|
11
|
+
"cli"
|
|
12
|
+
],
|
|
13
|
+
"bin": {
|
|
14
|
+
"agk": "bin/agk.mjs"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"lib/",
|
|
19
|
+
"template/"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test test/kit.test.mjs"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Refuses commits on main (except a repo's very first commit), then runs the fast checks.
|
|
3
|
+
# Turned on once per clone: git config core.hooksPath .githooks
|
|
4
|
+
if git rev-parse -q --verify HEAD >/dev/null && [ "$(git symbolic-ref --short -q HEAD)" = "main" ]; then
|
|
5
|
+
echo "pre-commit: no commits on main. Work on a branch; main moves through node scripts/merge.mjs (git skill)." >&2
|
|
6
|
+
exit 1
|
|
7
|
+
fi
|
|
8
|
+
exec node scripts/check.mjs --fast
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Bootstrap: set up this repo for agents
|
|
2
|
+
|
|
3
|
+
agent-kit's `agk setup` copied this file and the setup files into the repo, and ran
|
|
4
|
+
`git init` if it wasn't one yet. Where the repo had one of those files already, it left
|
|
5
|
+
that file as it was and put the kit's version next to it, as `<file>.kit`. Follow these
|
|
6
|
+
steps once, in order. Step 6 deletes this file.
|
|
7
|
+
|
|
8
|
+
## 1. Look before asking
|
|
9
|
+
|
|
10
|
+
Find out on your own:
|
|
11
|
+
- Does it have commits (`git log --oneline -3`)? If so, its main branch must be called
|
|
12
|
+
`main` (the hook and `scripts/merge.mjs` assume it). If it's called something else,
|
|
13
|
+
ask the user before renaming it.
|
|
14
|
+
- Remotes (`git remote -v`). None is fine: the `git` skill handles both.
|
|
15
|
+
`agk setup --github` adds an empty GitHub repo as `origin`.
|
|
16
|
+
- The stack, from what's there: `package.json`, `pyproject.toml`, `go.mod`,
|
|
17
|
+
`Cargo.toml`, a README.
|
|
18
|
+
- The preset, if `.claude/agk.json` names one: the user's own setup, added to the
|
|
19
|
+
kit's. Keep what it added, and don't ask what it answers already (check steps say
|
|
20
|
+
how to test, for example).
|
|
21
|
+
- Uncommitted work (`git status --short`) in `scripts/`, `docs/` or `.claude/` that
|
|
22
|
+
isn't the kit's (`.claude/agk.json` lists the kit's files): step 6 stages those
|
|
23
|
+
folders whole, so ask the user to commit it first.
|
|
24
|
+
- Git hooks of its own (`git config core.hooksPath`, `.husky/`): step 5 points git at
|
|
25
|
+
`.githooks/` instead, which turns them off. Ask the user; to keep both,
|
|
26
|
+
`.githooks/pre-commit` can run theirs.
|
|
27
|
+
|
|
28
|
+
## 2. Ask, in one message
|
|
29
|
+
|
|
30
|
+
Ask only what step 1 didn't answer. "Don't know yet" is a good answer: it becomes a
|
|
31
|
+
todo (step 4), never a guess.
|
|
32
|
+
|
|
33
|
+
1. The project's name, and what it is in 2-3 sentences.
|
|
34
|
+
2. The stack, if it's chosen: language, framework, how to test and lint.
|
|
35
|
+
3. Does `main` deploy anywhere when it changes?
|
|
36
|
+
4. What must never break?
|
|
37
|
+
5. What do you need to see before a change counts as done (tests, screenshots, a demo)?
|
|
38
|
+
6. Is there anything agents must never read or change (large data, secrets, generated files)?
|
|
39
|
+
|
|
40
|
+
## 3. Fill in the setup
|
|
41
|
+
|
|
42
|
+
- Each `<file>.kit` (`node scripts/doctor.mjs` lists them) is the kit's version of a
|
|
43
|
+
file the repo had. Merge it into that file: keep what the repo has, add what the
|
|
44
|
+
kit's brings (for `CLAUDE.md` its sections, for `.claude/settings.json` its hooks
|
|
45
|
+
and rules), and ask the user where they conflict. Then delete the `.kit` file.
|
|
46
|
+
- `CLAUDE.md`: replace every `{{...}}`. Write only the rules the user gave, one bullet
|
|
47
|
+
each. Keep it short: it's in context on every turn.
|
|
48
|
+
- If `main` deploys, add to "Working here": "`main` deploys to <where> on every merge."
|
|
49
|
+
- Never read or change → `deny` rules in `.claude/settings.json`, e.g.
|
|
50
|
+
`Read(./data/**)` and `Edit(./data/**)`.
|
|
51
|
+
- If the stack is known, do the "stack chosen" row of step 4 now instead of adding it.
|
|
52
|
+
|
|
53
|
+
## 4. Add a todo for everything deferred
|
|
54
|
+
|
|
55
|
+
`T` stands for `node .claude/skills/todo/scripts/todo.mjs`. One todo per deferred
|
|
56
|
+
part. Its detail holds the trigger, what to do and why, and must stand alone: this
|
|
57
|
+
file will be gone.
|
|
58
|
+
|
|
59
|
+
T add 'Stack chosen: checks, ignores, deny rules' -p 1 --tag setup
|
|
60
|
+
T detail <ID> 'Trigger: ... Do: ... Why: ...'
|
|
61
|
+
|
|
62
|
+
Single quotes, not double: the `todo` skill says why, and what to do about apostrophes.
|
|
63
|
+
|
|
64
|
+
| Trigger | Do |
|
|
65
|
+
| :--- | :--- |
|
|
66
|
+
| The stack is chosen | Add its checks to `scripts/check.json` (`{ "name": "test", "run": "npm test", "slow": true }`; `slow` steps skip the pre-commit hook but run in `check` and `merge`). Add a `.gitignore`, `deny` reads of dependency and build folders, and allow the check commands in `.claude/settings.json`. |
|
|
67
|
+
| The first screen exists (if there's a UI) | A skill that screenshots it at phone and desktop sizes and flags overflow and console errors. |
|
|
68
|
+
| A remote is added (or `agk setup --github` added one) | CI that runs `node scripts/check.mjs`. |
|
|
69
|
+
| A "don't know yet" from step 2 | Ask again then. |
|
|
70
|
+
|
|
71
|
+
Add any others the answers suggest, e.g. large data files coming: `deny` reads of
|
|
72
|
+
them, and a script that queries them with short output.
|
|
73
|
+
|
|
74
|
+
## 5. Turn on the hooks and check
|
|
75
|
+
|
|
76
|
+
git config core.hooksPath .githooks
|
|
77
|
+
node scripts/check.mjs
|
|
78
|
+
|
|
79
|
+
Fix whatever `check` reports (it runs `doctor`) until it prints OK.
|
|
80
|
+
|
|
81
|
+
## 6. Delete this file and commit
|
|
82
|
+
|
|
83
|
+
1. Delete `BOOTSTRAP.md`.
|
|
84
|
+
2. Stage by name, adding `todos` if step 4 added any and `.gitignore` if you wrote one:
|
|
85
|
+
|
|
86
|
+
git add CLAUDE.md .claude/settings.json .claude/skills .claude/agk.json .githooks scripts docs .gitattributes
|
|
87
|
+
git add --chmod=+x .githooks/pre-commit
|
|
88
|
+
|
|
89
|
+
Not all of `.claude`: Claude Code may keep the user's personal `settings.local.json`
|
|
90
|
+
there, and a machine's git ignore can miss it. `.claude/agk.json` records the kit's
|
|
91
|
+
files, for `agk update`. The `--chmod` line is for Windows, which doesn't record the
|
|
92
|
+
executable bit.
|
|
93
|
+
3. If the repo had no commits, commit on `main`: the hook allows a repo's first commit
|
|
94
|
+
there. Otherwise `git switch -c agent-setup`, commit, and merge with
|
|
95
|
+
`node scripts/merge.mjs` once the user approves.
|
|
96
|
+
Message: `Agent setup: <what was set up>`.
|
|
97
|
+
4. If there's a remote (`git remote`), push `main` once the user agrees:
|
|
98
|
+
`git push -u origin main`.
|
|
99
|
+
|
|
100
|
+
## 7. Report
|
|
101
|
+
|
|
102
|
+
In a few lines: what was set up, the todos added (`T list`), and two notes for the user:
|
|
103
|
+
- The git hooks are turned on per clone. After cloning this repo elsewhere, run
|
|
104
|
+
`git config core.hooksPath .githooks` (`doctor` says so when they're off).
|
|
105
|
+
- Personal habits that hold in every repo belong in `~/.claude/CLAUDE.md`, not here.
|
|
106
|
+
- `agk update` brings in the kit's later changes.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# {{NAME}}
|
|
2
|
+
|
|
3
|
+
{{WHAT IT IS, in 2-3 sentences}}
|
|
4
|
+
|
|
5
|
+
This file holds what must stay true on **every** change. Everything else loads on
|
|
6
|
+
demand: skills by their descriptions, a folder's rules from its own `CLAUDE.md`, and
|
|
7
|
+
the docs below.
|
|
8
|
+
|
|
9
|
+
| Need | Where |
|
|
10
|
+
| :--- | :--- |
|
|
11
|
+
| Session habits: one task per session, `/clear`, reading narrowly | `docs/workflow.md` |
|
|
12
|
+
|
|
13
|
+
## Always true
|
|
14
|
+
|
|
15
|
+
{{RULES: what must never break, one bullet each. Remove this section if there are none yet.}}
|
|
16
|
+
|
|
17
|
+
## Working here
|
|
18
|
+
|
|
19
|
+
- **Every task starts on its own branch.** `main` moves only through
|
|
20
|
+
`node scripts/merge.mjs`, after the user approves (`git` skill).
|
|
21
|
+
- **Deferred work is a todo with its trigger** ("when X happens, do Y"), never a note
|
|
22
|
+
in chat (`todo` skill).
|
|
23
|
+
- **Before finishing a task, check the todos**
|
|
24
|
+
(`node .claude/skills/todo/scripts/todo.mjs list --all`) for items the work
|
|
25
|
+
triggers: do them, or tell the user they're due.
|
|
26
|
+
- **New rules, skills, hooks or docs for agents** follow the `agent-setup` skill: a
|
|
27
|
+
check beats a written rule, and nothing is written twice.
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
| Command | What it does |
|
|
32
|
+
| :--- | :--- |
|
|
33
|
+
| `node scripts/check.mjs` | Every check in `scripts/check.json`: only what failed, then OK or FAIL (`--verbose` for full output) |
|
|
34
|
+
| `node scripts/doctor.mjs` | The agent setup: size limits, dead paths, hooks turned on |
|
|
35
|
+
|
|
36
|
+
**Before calling a change done:** `node scripts/check.mjs` passes.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Session workflow
|
|
2
|
+
|
|
3
|
+
Habits that keep sessions cheap and focused.
|
|
4
|
+
|
|
5
|
+
- **One task per session.** `todo next`, finish it, `todo done`, stop.
|
|
6
|
+
- **`/clear` between unrelated tasks.** Old context costs tokens on every turn.
|
|
7
|
+
- **`/compact` with a focus** when a long task must go on, e.g.
|
|
8
|
+
`/compact keep the failing test names and the files changed`.
|
|
9
|
+
- **`/context` and `/cost`** show what's using the window, and the money, before you
|
|
10
|
+
guess.
|
|
11
|
+
- **Change `CLAUDE.md` and MCP config between sessions**, not in the middle of one:
|
|
12
|
+
either change breaks prompt caching for the rest of the session.
|
|
13
|
+
- **Read narrowly.** Grep, or Read with `offset` and `limit`; a hook refuses
|
|
14
|
+
whole-file reads over 1500 lines.
|
|
15
|
+
- **Run `node scripts/check.mjs`** instead of reading raw test or lint output.
|