@eric0117/agentforge 0.1.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -0
  3. package/dist/add-agent.js +145 -0
  4. package/dist/add-skill.js +185 -0
  5. package/dist/agent-prompt.js +211 -0
  6. package/dist/agentforge-config.js +106 -0
  7. package/dist/agents/claude.js +46 -0
  8. package/dist/agents/codex.js +67 -0
  9. package/dist/agents/cursor.js +54 -0
  10. package/dist/agents/index.js +15 -0
  11. package/dist/agents/io.js +252 -0
  12. package/dist/agents/types.js +1 -0
  13. package/dist/cli.js +374 -0
  14. package/dist/confirm.js +20 -0
  15. package/dist/doctor.js +223 -0
  16. package/dist/enter.js +85 -0
  17. package/dist/init.js +272 -0
  18. package/dist/lang-prompt.js +88 -0
  19. package/dist/list-skills.js +120 -0
  20. package/dist/logo.js +181 -0
  21. package/dist/path-prompt.js +148 -0
  22. package/dist/remove-agent.js +63 -0
  23. package/dist/remove-skill.js +88 -0
  24. package/dist/rename.js +222 -0
  25. package/dist/skill-prompt.js +199 -0
  26. package/dist/skills-data.js +727 -0
  27. package/dist/sync-skills.js +59 -0
  28. package/dist/templates/CLAUDE.md.tpl +141 -0
  29. package/dist/templates/context-handoff.SKILL.md.tpl +222 -0
  30. package/dist/templates/cross-repo-impact.SKILL.md.tpl +241 -0
  31. package/dist/templates/feature-retro.SKILL.md.tpl +312 -0
  32. package/dist/templates/feature-start.SKILL.md.tpl +631 -0
  33. package/dist/templates/history.SKILL.md.tpl +165 -0
  34. package/dist/templates/incident-context.SKILL.md.tpl +260 -0
  35. package/dist/templates/pr-create.SKILL.md.tpl +403 -0
  36. package/dist/templates/pr-review-analyze.SKILL.md.tpl +303 -0
  37. package/dist/templates/pre-deploy-check.SKILL.md.tpl +350 -0
  38. package/dist/templates/project-router.SKILL.md.tpl +55 -0
  39. package/dist/templates/release-coordinate.SKILL.md.tpl +209 -0
  40. package/package.json +54 -0
@@ -0,0 +1,252 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { LANG_INSTRUCTIONS } from "../skills-data.js";
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ export const TEMPLATES = resolve(here, "..", "templates");
7
+ const DIM = "\x1b[2m";
8
+ const GREEN = "\x1b[32m";
9
+ const YELLOW = "\x1b[33m";
10
+ const RESET = "\x1b[0m";
11
+ let VERBOSE = true;
12
+ /**
13
+ * Toggle progress logging for adapter file operations.
14
+ * Used by `init` to quiet per-agent install output and instead print a single
15
+ * summary line per agent.
16
+ */
17
+ export function setVerbose(v) {
18
+ VERBOSE = v;
19
+ }
20
+ function logIf(s) {
21
+ if (VERBOSE)
22
+ console.log(s);
23
+ }
24
+ export function ensureDir(full, label) {
25
+ if (existsSync(full)) {
26
+ logIf(`${DIM} exists:${RESET} ${label}/`);
27
+ }
28
+ else {
29
+ mkdirSync(full, { recursive: true });
30
+ logIf(`${GREEN}+${RESET} created: ${label}/`);
31
+ }
32
+ }
33
+ /**
34
+ * Render a template with the language placeholder substituted.
35
+ */
36
+ export function renderTemplate(templateName, lang) {
37
+ const src = join(TEMPLATES, templateName);
38
+ if (!existsSync(src)) {
39
+ throw new Error(`template missing: ${src}`);
40
+ }
41
+ return readFileSync(src, "utf8").replaceAll("{{OUTPUT_LANGUAGE_INSTRUCTION}}", LANG_INSTRUCTIONS[lang]);
42
+ }
43
+ /**
44
+ * Write content to dst, respecting force / backup semantics:
45
+ * - if dst exists and !force → skip with a message
46
+ * - if dst exists and force → write a .bak alongside, then overwrite
47
+ * - otherwise → write fresh
48
+ */
49
+ export function writeRendered(dst, destRel, content, force) {
50
+ const exists = existsSync(dst);
51
+ if (exists && !force) {
52
+ logIf(`${YELLOW} skipped:${RESET} ${destRel} ${DIM}(exists; use --force / --force-skills / --force-claude to overwrite)${RESET}`);
53
+ return;
54
+ }
55
+ if (exists && force) {
56
+ // Pick a .bak name that doesn't already exist — preserves prior backups
57
+ // from earlier sync-skills runs instead of silently overwriting them.
58
+ let bak = `${dst}.bak`;
59
+ let bakRel = `${destRel}.bak`;
60
+ let n = 2;
61
+ while (existsSync(bak)) {
62
+ bak = `${dst}.bak.${n}`;
63
+ bakRel = `${destRel}.bak.${n}`;
64
+ n++;
65
+ }
66
+ writeFileSync(bak, readFileSync(dst, "utf8"));
67
+ logIf(`${DIM} backup:${RESET} ${bakRel}`);
68
+ }
69
+ // make sure parent dir exists
70
+ mkdirSync(dirname(dst), { recursive: true });
71
+ writeFileSync(dst, content);
72
+ logIf(`${GREEN}+${RESET} wrote: ${destRel}`);
73
+ }
74
+ /**
75
+ * Split a SKILL.md.tpl into its YAML frontmatter and body.
76
+ * Returns { frontmatter: Record, body: string } where frontmatter is parsed
77
+ * loosely (only top-level key: value pairs; multi-line values are joined).
78
+ */
79
+ /** Valid skill id: lowercase kebab-case, must start with a letter. */
80
+ const SKILL_ID_RE = /^[a-z][a-z0-9-]*$/;
81
+ /**
82
+ * Read the workspace master skills directory and parse every `*.md` file.
83
+ * Tolerant of files the user dropped in by hand: invalid file names, missing
84
+ * frontmatter, and `name:`/filename mismatches are surfaced as
85
+ * `skipped` (hard fail) or `warnings` (soft) — the rest still flow through.
86
+ */
87
+ export function readMasterDir(masterDirAbs) {
88
+ if (!existsSync(masterDirAbs)) {
89
+ return { skills: [], skipped: [], warnings: [] };
90
+ }
91
+ // Guard: someone may have created `agentforge/skills` as a file by hand.
92
+ try {
93
+ if (!statSync(masterDirAbs).isDirectory()) {
94
+ return {
95
+ skills: [],
96
+ skipped: [
97
+ {
98
+ file: masterDirAbs,
99
+ reason: "exists but is not a directory — remove it and run `agentforge init --force-skills` to recreate",
100
+ },
101
+ ],
102
+ warnings: [],
103
+ };
104
+ }
105
+ }
106
+ catch (e) {
107
+ return {
108
+ skills: [],
109
+ skipped: [{ file: masterDirAbs, reason: `unreadable: ${e.message}` }],
110
+ warnings: [],
111
+ };
112
+ }
113
+ const allEntries = readdirSync(masterDirAbs, { withFileTypes: true });
114
+ const entries = allEntries
115
+ .filter((e) => e.name.endsWith(".md"))
116
+ .map((e) => e.name)
117
+ .sort();
118
+ const skills = [];
119
+ const skipped = [];
120
+ const warnings = [];
121
+ // Surface broken symlinks separately — they vanish from the normal
122
+ // file scan, which makes them very confusing.
123
+ const dangling = new Set();
124
+ for (const e of allEntries) {
125
+ if (!e.name.endsWith(".md"))
126
+ continue;
127
+ const abs = join(masterDirAbs, e.name);
128
+ try {
129
+ const lst = lstatSync(abs);
130
+ if (lst.isSymbolicLink() && !existsSync(abs)) {
131
+ skipped.push({
132
+ file: e.name,
133
+ reason: "dangling symlink — target doesn't exist",
134
+ });
135
+ dangling.add(e.name);
136
+ }
137
+ }
138
+ catch {
139
+ /* ignore */
140
+ }
141
+ }
142
+ for (const file of entries) {
143
+ if (dangling.has(file))
144
+ continue; // already reported above
145
+ const id = file.slice(0, -3); // strip `.md`
146
+ // (1) filename validity — agent adapters use this id as a directory /
147
+ // filename, so we have to be strict.
148
+ if (!SKILL_ID_RE.test(id)) {
149
+ skipped.push({
150
+ file,
151
+ reason: `invalid skill id "${id}" — must be kebab-case (lowercase letters/digits/hyphens, start with a letter)`,
152
+ });
153
+ continue;
154
+ }
155
+ let raw;
156
+ try {
157
+ raw = readFileSync(join(masterDirAbs, file), "utf8");
158
+ }
159
+ catch (e) {
160
+ skipped.push({ file, reason: `unreadable: ${e.message}` });
161
+ continue;
162
+ }
163
+ const { frontmatter, body } = splitFrontmatter(raw);
164
+ // (2) required frontmatter
165
+ if (!frontmatter["name"] || !frontmatter["description"]) {
166
+ skipped.push({
167
+ file,
168
+ reason: "missing frontmatter `name:` or `description:`",
169
+ });
170
+ continue;
171
+ }
172
+ // (2.5) YAML block scalars (`description: |` / `>`) — our parser only
173
+ // reads the first line of each key, so the multiline body would silently
174
+ // be lost. Reject with a clear hint to use a single-line description.
175
+ if (frontmatter["description"] === "|" ||
176
+ frontmatter["description"] === ">" ||
177
+ frontmatter["description"]?.startsWith("|") ||
178
+ frontmatter["description"]?.startsWith(">")) {
179
+ skipped.push({
180
+ file,
181
+ reason: "`description:` uses a YAML block scalar (`|` / `>`) which agentforge doesn't support — write it as one line on the same row as `description:`",
182
+ });
183
+ continue;
184
+ }
185
+ // (3) frontmatter `name:` must itself be kebab-case — agents echo it.
186
+ if (!SKILL_ID_RE.test(frontmatter["name"])) {
187
+ skipped.push({
188
+ file,
189
+ reason: `frontmatter \`name: ${frontmatter["name"]}\` is not kebab-case (lowercase letters/digits/hyphens, start with a letter)`,
190
+ });
191
+ continue;
192
+ }
193
+ // (4) frontmatter name vs filename mismatch — adapters would emit one as
194
+ // the dir/file and the other inside the frontmatter, so we'd ship two
195
+ // different identities for the same skill. Skip rather than propagate.
196
+ if (frontmatter["name"] !== id) {
197
+ skipped.push({
198
+ file,
199
+ reason: `frontmatter \`name: ${frontmatter["name"]}\` doesn't match the filename id \`${id}\`. Rename the file or the \`name:\` field so they agree, then re-run \`agentforge sync-skills\`.`,
200
+ });
201
+ continue;
202
+ }
203
+ // (4) suspiciously empty body — only HTML comments / whitespace.
204
+ const stripped = body
205
+ .replace(/<!--[\s\S]*?-->/g, "")
206
+ .replace(/\s+/g, "")
207
+ .trim();
208
+ if (stripped.length < 20) {
209
+ warnings.push({
210
+ file,
211
+ warning: "body looks like a placeholder (no real content yet). Fill it in and re-run `agentforge sync-skills`.",
212
+ });
213
+ }
214
+ skills.push({ id, frontmatter, body, raw });
215
+ }
216
+ return { skills, skipped, warnings };
217
+ }
218
+ /**
219
+ * Write a single master skill file. Honors force/backup semantics
220
+ * shared with adapter writes.
221
+ */
222
+ export function writeMasterSkill(masterDirAbs, id, content, force) {
223
+ const dst = join(masterDirAbs, `${id}.md`);
224
+ const destRel = `agentforge/skills/${id}.md`;
225
+ writeRendered(dst, destRel, content, force);
226
+ }
227
+ export function splitFrontmatter(rendered) {
228
+ if (!rendered.startsWith("---")) {
229
+ return { frontmatter: {}, body: rendered };
230
+ }
231
+ const end = rendered.indexOf("\n---", 3);
232
+ if (end < 0)
233
+ return { frontmatter: {}, body: rendered };
234
+ const head = rendered.slice(4, end); // skip leading "---\n"
235
+ const body = rendered.slice(end + 4).replace(/^\n+/, "");
236
+ const fm = {};
237
+ let currentKey = null;
238
+ for (const raw of head.split("\n")) {
239
+ const m = raw.match(/^([A-Za-z_][\w-]*):\s?(.*)$/);
240
+ if (m) {
241
+ currentKey = m[1];
242
+ // Trim trailing whitespace so invisible spaces don't tank later
243
+ // validation (e.g. `name: my-skill ` failing SKILL_ID_RE).
244
+ fm[currentKey] = m[2].trimEnd();
245
+ }
246
+ else if (currentKey && raw.trim() !== "") {
247
+ // continuation line for the previous key
248
+ fm[currentKey] = `${fm[currentKey]} ${raw.trim()}`.trim();
249
+ }
250
+ }
251
+ return { frontmatter: fm, body };
252
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ import { runAddAgent } from "./add-agent.js";
3
+ import { runAddSkill } from "./add-skill.js";
4
+ import { AGENT_IDS } from "./agents/index.js";
5
+ import { runDoctor } from "./doctor.js";
6
+ import { runEnter } from "./enter.js";
7
+ import { runInit } from "./init.js";
8
+ import { runRename } from "./rename.js";
9
+ import { runListSkills } from "./list-skills.js";
10
+ import { runRemoveAgent } from "./remove-agent.js";
11
+ import { runRemoveSkill } from "./remove-skill.js";
12
+ import { runSyncSkills } from "./sync-skills.js";
13
+ const HELP = `agentforge — multi-repo workspace bootstrapper for Claude Code, Cursor, and OpenAI Codex CLI
14
+
15
+ Usage:
16
+
17
+ Workspace setup
18
+ ───────────────
19
+ agentforge init [path] Bootstrap a workspace (creates agentforge/,
20
+ [--force | --force-skills | --force-claude] anvil/, artifacts/, and per-agent
21
+ [--yes] [--lang en|ko|ja] skill directories).
22
+ [--agent claude,cursor,codex | --agent all]
23
+
24
+ agentforge add-agent [agents] [path] Add Claude / Cursor / Codex to an existing
25
+ [--force | --force-skills | --force-claude] workspace.
26
+ [--yes] [--lang en|ko|ja]
27
+
28
+ agentforge remove-agent <agent> [path] Uninstall an agent's skill directory.
29
+ [--yes]
30
+
31
+ Skills
32
+ ──────
33
+ agentforge list-skills [path] Show every skill installed in the workspace.
34
+
35
+ agentforge add-skill [path] Author a new master skill (with optional
36
+ [--from <file>] [--no-edit] [--yes] starter from --from <file>) and install it
37
+ to every agent.
38
+
39
+ agentforge remove-skill <name> [path] Remove a skill from master + every agent.
40
+ [--yes]
41
+
42
+ agentforge sync-skills [path] Propagate master skill edits in
43
+ [--force-claude] agentforge/skills/ to every agent.
44
+ Backs up existing files to .bak.
45
+
46
+ Features
47
+ ────────
48
+ agentforge enter [slug] cd into a feature worktree (anvil/<slug>/)
49
+ and launch \`claude\` there. No args lists
50
+ active features.
51
+
52
+ agentforge rename <old-slug> <new-slug> Rename a feature: moves the worktrees,
53
+ [--yes] [--force] renames any branch named <old-slug>,
54
+ rewrites slug references in CLAUDE.md.
55
+ Refuses dirty worktrees without --force.
56
+
57
+ Diagnostics
58
+ ───────────
59
+ agentforge doctor [path] Check the workspace for misconfiguration.
60
+
61
+ agentforge help Show this message.
62
+ agentforge --help
63
+
64
+ Notes:
65
+ • Workspaces have a master skills folder at <workspace>/agentforge/skills/ and a
66
+ config file at <workspace>/agentforge/config.json. Edits to master files are
67
+ propagated to every installed agent by \`sync-skills\`.
68
+ • Existing per-agent files are preserved by default; --force* variants back up
69
+ the previous content to .bak before overwriting.
70
+ • Skills are triggered by natural language inside your AI session — you don't run
71
+ them via this CLI. See <workspace>/CLAUDE.md (or AGENTS.md / .cursor/rules/CLAUDE.mdc)
72
+ for the trigger phrases.
73
+ `;
74
+ function parseArgs(argv) {
75
+ const out = {
76
+ positional: [],
77
+ forceSkills: false,
78
+ forceClaude: false,
79
+ yes: false,
80
+ help: false,
81
+ noEdit: false,
82
+ };
83
+ for (let i = 0; i < argv.length; i++) {
84
+ const a = argv[i];
85
+ if (a === "--force" || a === "-f") {
86
+ out.forceSkills = true;
87
+ out.forceClaude = true;
88
+ }
89
+ else if (a === "--force-skills")
90
+ out.forceSkills = true;
91
+ else if (a === "--force-claude")
92
+ out.forceClaude = true;
93
+ else if (a === "--yes" || a === "-y")
94
+ out.yes = true;
95
+ else if (a === "--help" || a === "-h")
96
+ out.help = true;
97
+ else if (a === "--lang") {
98
+ const v = argv[++i];
99
+ if (v === "en" || v === "ko" || v === "ja")
100
+ out.lang = v;
101
+ else {
102
+ process.stderr.write(`invalid --lang value: ${v} (use en | ko | ja)\n`);
103
+ process.exit(1);
104
+ }
105
+ }
106
+ else if (a.startsWith("--lang=")) {
107
+ const v = a.slice("--lang=".length);
108
+ if (v === "en" || v === "ko" || v === "ja")
109
+ out.lang = v;
110
+ else {
111
+ process.stderr.write(`invalid --lang value: ${v} (use en | ko | ja)\n`);
112
+ process.exit(1);
113
+ }
114
+ }
115
+ else if (a === "--agent") {
116
+ const v = argv[++i];
117
+ if (out.agents !== undefined) {
118
+ process.stderr.write(`\nerror: --agent specified more than once. Combine them: \`--agent claude,cursor,codex\` or \`--agent all\`.\n\n`);
119
+ process.exit(1);
120
+ }
121
+ out.agents = parseAgents(v);
122
+ }
123
+ else if (a.startsWith("--agent=")) {
124
+ if (out.agents !== undefined) {
125
+ process.stderr.write(`\nerror: --agent specified more than once. Combine them: \`--agent claude,cursor,codex\` or \`--agent all\`.\n\n`);
126
+ process.exit(1);
127
+ }
128
+ out.agents = parseAgents(a.slice("--agent=".length));
129
+ }
130
+ else if (a === "--from") {
131
+ out.fromFile = argv[++i];
132
+ }
133
+ else if (a.startsWith("--from=")) {
134
+ out.fromFile = a.slice("--from=".length);
135
+ }
136
+ else if (a === "--no-edit") {
137
+ out.noEdit = true;
138
+ }
139
+ else if (a.startsWith("--") || /^-[a-zA-Z]/.test(a)) {
140
+ // Unknown flag — refuse instead of silently treating as positional.
141
+ // Without this, `init --bogus /tmp/x` would resolve("--bogus") as the
142
+ // workspace path and produce a baffling error downstream.
143
+ const known = [
144
+ "--force",
145
+ "--force-skills",
146
+ "--force-claude",
147
+ "--yes",
148
+ "--help",
149
+ "--lang",
150
+ "--agent",
151
+ "--from",
152
+ "--no-edit",
153
+ "-f",
154
+ "-y",
155
+ "-h",
156
+ ];
157
+ const guess = nearestCommand(a, known);
158
+ let msg = `\nerror: unknown flag: ${a}\n`;
159
+ if (guess)
160
+ msg += `\nDid you mean: \x1b[36m${guess}\x1b[0m?\n`;
161
+ msg += `\n`;
162
+ process.stderr.write(msg);
163
+ process.exit(1);
164
+ }
165
+ else if (!out.command) {
166
+ out.command = a;
167
+ }
168
+ else {
169
+ // Reject empty-string positional args — they're almost always an unset
170
+ // shell var (`agentforge init "$WS"` with WS unset) and would silently
171
+ // collapse to cwd through resolve("").
172
+ if (a === "") {
173
+ process.stderr.write(`\nerror: empty positional argument. Did you mean to expand a shell variable that wasn't set?\n\n`);
174
+ process.exit(1);
175
+ }
176
+ out.positional.push(a);
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+ function looksLikeAgentSpec(s) {
182
+ if (s === "all")
183
+ return true;
184
+ const parts = s.split(",").map((p) => p.trim());
185
+ if (parts.length === 0)
186
+ return false;
187
+ return parts.every((p) => AGENT_IDS.includes(p));
188
+ }
189
+ function parseAgents(value) {
190
+ if (!value) {
191
+ process.stderr.write(`--agent requires a value (claude | cursor | codex | all)\n`);
192
+ process.exit(1);
193
+ }
194
+ if (value === "all")
195
+ return AGENT_IDS.slice();
196
+ const ids = value
197
+ .split(",")
198
+ .map((s) => s.trim())
199
+ .filter(Boolean);
200
+ const valid = new Set(AGENT_IDS);
201
+ const bad = ids.filter((id) => !valid.has(id));
202
+ if (bad.length > 0 || ids.length === 0) {
203
+ process.stderr.write(`invalid --agent value(s): ${bad.join(", ") || value} (use claude | cursor | codex | all)\n`);
204
+ process.exit(1);
205
+ }
206
+ return Array.from(new Set(ids));
207
+ }
208
+ async function main() {
209
+ const args = parseArgs(process.argv.slice(2));
210
+ if (args.help || !args.command || args.command === "help") {
211
+ process.stdout.write(HELP);
212
+ process.exit(args.help || args.command === "help" ? 0 : 1);
213
+ }
214
+ switch (args.command) {
215
+ case "init":
216
+ await runInit({
217
+ pathArg: args.positional[0],
218
+ forceSkills: args.forceSkills,
219
+ forceClaude: args.forceClaude,
220
+ yes: args.yes,
221
+ lang: args.lang,
222
+ agents: args.agents,
223
+ });
224
+ return;
225
+ case "add-agent": {
226
+ // [agents] [path]: first positional may be agents spec or path
227
+ let agents = args.agents;
228
+ let pathArg;
229
+ const [p0, p1] = args.positional;
230
+ if (p0 != null) {
231
+ if (looksLikeAgentSpec(p0)) {
232
+ if (!agents)
233
+ agents = parseAgents(p0);
234
+ if (p1 != null)
235
+ pathArg = p1;
236
+ }
237
+ else {
238
+ pathArg = p0;
239
+ }
240
+ }
241
+ await runAddAgent({
242
+ agents,
243
+ pathArg,
244
+ forceSkills: args.forceSkills,
245
+ forceClaude: args.forceClaude,
246
+ yes: args.yes,
247
+ lang: args.lang,
248
+ });
249
+ return;
250
+ }
251
+ case "remove-agent": {
252
+ const [p0, p1] = args.positional;
253
+ if (!p0 || !AGENT_IDS.includes(p0)) {
254
+ process.stderr.write(`usage: agentforge remove-agent <claude|cursor|codex> [path]\n`);
255
+ process.exit(1);
256
+ }
257
+ await runRemoveAgent({
258
+ agent: p0,
259
+ pathArg: p1,
260
+ yes: args.yes,
261
+ });
262
+ return;
263
+ }
264
+ case "list-skills":
265
+ await runListSkills({ pathArg: args.positional[0] });
266
+ return;
267
+ case "add-skill":
268
+ await runAddSkill({
269
+ pathArg: args.positional[0],
270
+ fromFile: args.fromFile,
271
+ noEdit: args.noEdit,
272
+ yes: args.yes,
273
+ });
274
+ return;
275
+ case "remove-skill": {
276
+ const [name, pathArg] = args.positional;
277
+ if (!name) {
278
+ process.stderr.write(`usage: agentforge remove-skill <name> [path]\n`);
279
+ process.exit(1);
280
+ }
281
+ await runRemoveSkill({ name, pathArg, yes: args.yes });
282
+ return;
283
+ }
284
+ case "sync-skills":
285
+ await runSyncSkills({
286
+ pathArg: args.positional[0],
287
+ forceSkills: args.forceSkills,
288
+ forceClaude: args.forceClaude,
289
+ });
290
+ return;
291
+ case "doctor":
292
+ await runDoctor({ pathArg: args.positional[0] });
293
+ return;
294
+ case "enter":
295
+ await runEnter({ slug: args.positional[0] });
296
+ return;
297
+ case "rename":
298
+ await runRename({
299
+ oldSlug: args.positional[0],
300
+ newSlug: args.positional[1],
301
+ yes: args.yes,
302
+ force: args.forceSkills || args.forceClaude,
303
+ });
304
+ return;
305
+ }
306
+ const known = [
307
+ "init",
308
+ "add-agent",
309
+ "remove-agent",
310
+ "list-skills",
311
+ "add-skill",
312
+ "remove-skill",
313
+ "sync-skills",
314
+ "enter",
315
+ "rename",
316
+ "doctor",
317
+ "help",
318
+ ];
319
+ const guess = nearestCommand(args.command, known);
320
+ let msg = `unknown command: ${args.command}\n`;
321
+ if (guess)
322
+ msg += `\nDid you mean: \x1b[36m${guess}\x1b[0m?\n`;
323
+ msg += `\nRun \x1b[36magentforge help\x1b[0m for the full command list.\n`;
324
+ process.stderr.write(msg);
325
+ process.exit(1);
326
+ }
327
+ function nearestCommand(input, known) {
328
+ let best = null;
329
+ for (const cmd of known) {
330
+ const d = levenshtein(input.toLowerCase(), cmd);
331
+ if (best === null || d < best.dist)
332
+ best = { cmd, dist: d };
333
+ }
334
+ // Only suggest if reasonably close — 3 edits or 40% of length.
335
+ if (!best)
336
+ return null;
337
+ const threshold = Math.max(2, Math.floor(input.length * 0.4));
338
+ return best.dist <= threshold ? best.cmd : null;
339
+ }
340
+ function levenshtein(a, b) {
341
+ if (a === b)
342
+ return 0;
343
+ const m = a.length;
344
+ const n = b.length;
345
+ if (m === 0)
346
+ return n;
347
+ if (n === 0)
348
+ return m;
349
+ const prev = new Array(n + 1).fill(0).map((_, i) => i);
350
+ const cur = new Array(n + 1).fill(0);
351
+ for (let i = 1; i <= m; i++) {
352
+ cur[0] = i;
353
+ for (let j = 1; j <= n; j++) {
354
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
355
+ cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
356
+ }
357
+ for (let j = 0; j <= n; j++)
358
+ prev[j] = cur[j];
359
+ }
360
+ return prev[n];
361
+ }
362
+ main().catch((err) => {
363
+ const msg = err instanceof Error ? err.message : String(err);
364
+ // readConfig throws "failed to read .../config.json: ..." — reframe.
365
+ if (msg.startsWith("failed to read ") && msg.includes("config.json")) {
366
+ process.stderr.write(`\n\x1b[31m✗\x1b[0m \x1b[1magentforge/config.json is invalid.\x1b[0m\n \x1b[2m${msg.slice("failed to read ".length)}\x1b[0m\n\n` +
367
+ ` Fix the file directly, or re-create the workspace with \x1b[36magentforge init <path> --force\x1b[0m\n` +
368
+ ` (existing files are backed up to .bak before overwriting).\n\n`);
369
+ }
370
+ else {
371
+ console.error(`\nerror: ${msg}`);
372
+ }
373
+ process.exit(1);
374
+ });
@@ -0,0 +1,20 @@
1
+ import * as readline from "node:readline/promises";
2
+ const CYAN = "\x1b[36m";
3
+ const DIM = "\x1b[2m";
4
+ const RESET = "\x1b[0m";
5
+ /** Simple y/N prompt. Non-TTY → returns `def`. */
6
+ export async function confirm(message, def = false) {
7
+ if (!process.stdin.isTTY)
8
+ return def;
9
+ const rl = readline.createInterface({
10
+ input: process.stdin,
11
+ output: process.stdout,
12
+ });
13
+ const hint = def ? "[Y/n]" : "[y/N]";
14
+ const ans = await rl.question(`${CYAN}?${RESET} ${message} ${DIM}${hint}${RESET} `);
15
+ rl.close();
16
+ const t = ans.trim().toLowerCase();
17
+ if (t === "")
18
+ return def;
19
+ return t === "y" || t === "yes";
20
+ }