@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.
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/dist/add-agent.js +145 -0
- package/dist/add-skill.js +185 -0
- package/dist/agent-prompt.js +211 -0
- package/dist/agentforge-config.js +106 -0
- package/dist/agents/claude.js +46 -0
- package/dist/agents/codex.js +67 -0
- package/dist/agents/cursor.js +54 -0
- package/dist/agents/index.js +15 -0
- package/dist/agents/io.js +252 -0
- package/dist/agents/types.js +1 -0
- package/dist/cli.js +374 -0
- package/dist/confirm.js +20 -0
- package/dist/doctor.js +223 -0
- package/dist/enter.js +85 -0
- package/dist/init.js +272 -0
- package/dist/lang-prompt.js +88 -0
- package/dist/list-skills.js +120 -0
- package/dist/logo.js +181 -0
- package/dist/path-prompt.js +148 -0
- package/dist/remove-agent.js +63 -0
- package/dist/remove-skill.js +88 -0
- package/dist/rename.js +222 -0
- package/dist/skill-prompt.js +199 -0
- package/dist/skills-data.js +727 -0
- package/dist/sync-skills.js +59 -0
- package/dist/templates/CLAUDE.md.tpl +141 -0
- package/dist/templates/context-handoff.SKILL.md.tpl +222 -0
- package/dist/templates/cross-repo-impact.SKILL.md.tpl +241 -0
- package/dist/templates/feature-retro.SKILL.md.tpl +312 -0
- package/dist/templates/feature-start.SKILL.md.tpl +631 -0
- package/dist/templates/history.SKILL.md.tpl +165 -0
- package/dist/templates/incident-context.SKILL.md.tpl +260 -0
- package/dist/templates/pr-create.SKILL.md.tpl +403 -0
- package/dist/templates/pr-review-analyze.SKILL.md.tpl +303 -0
- package/dist/templates/pre-deploy-check.SKILL.md.tpl +350 -0
- package/dist/templates/project-router.SKILL.md.tpl +55 -0
- package/dist/templates/release-coordinate.SKILL.md.tpl +209 -0
- 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
|
+
});
|
package/dist/confirm.js
ADDED
|
@@ -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
|
+
}
|