@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
package/dist/doctor.js ADDED
@@ -0,0 +1,223 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { readMasterDir } from "./agents/io.js";
5
+ import { AGENTS } from "./agents/index.js";
6
+ import { masterDir, readConfig } from "./agentforge-config.js";
7
+ import { LANG_INSTRUCTIONS } from "./skills-data.js";
8
+ const DIM = "\x1b[2m";
9
+ const GREEN = "\x1b[32m";
10
+ const YELLOW = "\x1b[33m";
11
+ const CYAN = "\x1b[36m";
12
+ const BOLD = "\x1b[1m";
13
+ const RED = "\x1b[31m";
14
+ const RESET = "\x1b[0m";
15
+ const OK = `${GREEN}โœ“${RESET}`;
16
+ const WARN = `${YELLOW}โš ${RESET}`;
17
+ const FAIL = `${RED}โœ—${RESET}`;
18
+ const AGENT_FILES = {
19
+ claude: { dir: ".claude/skills", guide: "CLAUDE.md" },
20
+ cursor: { dir: ".cursor/rules", guide: ".cursorrules" },
21
+ codex: { dir: ".agents/skills", guide: "AGENTS.md" },
22
+ };
23
+ export async function runDoctor(opts) {
24
+ const root = resolve(opts.pathArg ?? process.cwd());
25
+ let issues = 0;
26
+ let warnings = 0;
27
+ console.log(`${BOLD}๐Ÿฉบ agentforge doctor${RESET} ${DIM}${root}${RESET}\n`);
28
+ // โ”€โ”€ Workspace โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
29
+ console.log(`${BOLD}Workspace${RESET}`);
30
+ const cfg = readConfig(root);
31
+ if (!cfg) {
32
+ console.log(` ${FAIL} not initialized โ€” no agentforge/config.json`);
33
+ console.log(` ${DIM}run ${CYAN}agentforge init${RESET}${DIM} here to set one up${RESET}\n`);
34
+ printToolsSection();
35
+ process.exit(1);
36
+ }
37
+ console.log(` ${OK} config.json ${DIM}(v${1}, lang=${cfg.lang}, agents=${cfg.agents.join(", ") || "โ€”"})${RESET}`);
38
+ const md = masterDir(root);
39
+ if (!existsSync(md)) {
40
+ console.log(` ${FAIL} master skills directory missing: ${md}`);
41
+ issues++;
42
+ }
43
+ else {
44
+ const { skills, skipped, warnings: masterWarnings } = readMasterDir(md);
45
+ console.log(` ${OK} master skills ${DIM}(${skills.length} valid)${RESET}`);
46
+ if (skipped.length > 0) {
47
+ console.log(` ${FAIL} ${skipped.length} invalid master file(s) โ€” ${DIM}skipped during sync${RESET}`);
48
+ for (const s of skipped) {
49
+ console.log(` ${DIM}- ${s.file}: ${s.reason}${RESET}`);
50
+ }
51
+ issues += skipped.length;
52
+ }
53
+ if (masterWarnings.length > 0) {
54
+ console.log(` ${WARN} ${masterWarnings.length} master file warning(s)`);
55
+ for (const w of masterWarnings) {
56
+ console.log(` ${DIM}- ${w.file}: ${w.warning}${RESET}`);
57
+ }
58
+ warnings += masterWarnings.length;
59
+ }
60
+ }
61
+ // โ”€โ”€ Agents โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
62
+ console.log(`\n${BOLD}Agents${RESET}`);
63
+ if (cfg.agents.length === 0) {
64
+ console.log(` ${WARN} no agents installed โ€” run ${CYAN}agentforge add-agent${RESET}`);
65
+ warnings++;
66
+ }
67
+ else {
68
+ const masterIds = existsSync(md)
69
+ ? new Set(readMasterDir(md).skills.map((s) => s.id))
70
+ : new Set();
71
+ for (const id of cfg.agents) {
72
+ const adapter = AGENTS.find((a) => a.id === id);
73
+ const label = adapter?.label ?? id;
74
+ const { dir, guide } = AGENT_FILES[id];
75
+ const dirAbs = join(root, dir);
76
+ const guideAbs = join(root, guide);
77
+ const dirExists = existsSync(dirAbs);
78
+ const guideExists = existsSync(guideAbs);
79
+ const present = dirExists ? countSkills(id, dirAbs) : new Set();
80
+ const missing = [...masterIds].filter((mid) => !present.has(mid));
81
+ const orphans = [...present].filter((pid) => !masterIds.has(pid));
82
+ if (!dirExists || !guideExists) {
83
+ console.log(` ${FAIL} ${label} ${DIM}${!dirExists ? `${dir} missing` : `${guide} missing`}${RESET}`);
84
+ console.log(` ${DIM}run ${CYAN}agentforge sync-skills${RESET}${DIM} to regenerate${RESET}`);
85
+ issues++;
86
+ continue;
87
+ }
88
+ const noteParts = [`${present.size} skill${present.size === 1 ? "" : "s"}`];
89
+ if (missing.length > 0)
90
+ noteParts.push(`${YELLOW}${missing.length} not propagated${DIM}`);
91
+ if (orphans.length > 0)
92
+ noteParts.push(`${YELLOW}${orphans.length} orphan${DIM}`);
93
+ const mark = missing.length === 0 && orphans.length === 0 ? OK : WARN;
94
+ console.log(` ${mark} ${label} ${DIM}(${noteParts.join(", ")})${RESET}`);
95
+ if (missing.length > 0) {
96
+ console.log(` ${DIM}missing:${RESET} ${missing.join(", ")} ${DIM}โ†’ ${CYAN}agentforge sync-skills${RESET}`);
97
+ warnings++;
98
+ }
99
+ if (orphans.length > 0) {
100
+ console.log(` ${DIM}orphan: ${orphans.join(", ")} โ†’ ${CYAN}agentforge remove-skill <id>${RESET}`);
101
+ warnings++;
102
+ }
103
+ }
104
+ // Whole-agent orphans: agent dir/guide present but agent NOT in config.
105
+ // Happens when the user manually deletes from config or copies an
106
+ // existing tree from elsewhere.
107
+ for (const id of Object.keys(AGENT_FILES)) {
108
+ if (cfg.agents.includes(id))
109
+ continue;
110
+ const { dir, guide } = AGENT_FILES[id];
111
+ const hasDir = existsSync(join(root, dir));
112
+ const hasGuide = existsSync(join(root, guide));
113
+ if (hasDir || hasGuide) {
114
+ const label = AGENTS.find((a) => a.id === id)?.label ?? id;
115
+ const which = [hasDir ? dir : null, hasGuide ? guide : null]
116
+ .filter(Boolean)
117
+ .join(", ");
118
+ console.log(` ${WARN} ${label} ${DIM}files present but not in config: ${which}${RESET}`);
119
+ console.log(` ${DIM}โ†’ ${CYAN}agentforge add-agent ${id}${RESET}${DIM} to re-register, or ${CYAN}agentforge remove-agent ${id}${RESET}${DIM} to clean up${RESET}`);
120
+ warnings++;
121
+ }
122
+ }
123
+ }
124
+ // โ”€โ”€ Lang drift โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
125
+ // config.lang is supposed to reflect what's actually written into master
126
+ // skills (the {{OUTPUT_LANGUAGE_INSTRUCTION}} placeholder is substituted at
127
+ // init time). If someone edited config.lang by hand, the master content
128
+ // now lies โ€” agents will respond in the wrong language.
129
+ const drift = detectLangDrift(md, cfg.lang);
130
+ if (drift) {
131
+ console.log(`\n${BOLD}Language${RESET}`);
132
+ console.log(` ${WARN} config.lang=${BOLD}${cfg.lang}${RESET}${DIM} but master files were written for ${BOLD}${drift}${RESET}${DIM}.${RESET}`);
133
+ console.log(` ${DIM}โ†’ ${CYAN}agentforge init ${root} --lang ${cfg.lang} --force-skills${RESET}${DIM} to rewrite master files, then ${CYAN}agentforge sync-skills${RESET}`);
134
+ warnings++;
135
+ }
136
+ // โ”€โ”€ External tools โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
137
+ printToolsSection();
138
+ // โ”€โ”€ Summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
139
+ console.log(`${BOLD}Summary${RESET}`);
140
+ if (issues === 0 && warnings === 0) {
141
+ console.log(` ${OK} workspace is healthy.`);
142
+ process.exit(0);
143
+ }
144
+ if (issues > 0)
145
+ console.log(` ${FAIL} ${issues} issue${issues === 1 ? "" : "s"}`);
146
+ if (warnings > 0)
147
+ console.log(` ${WARN} ${warnings} warning${warnings === 1 ? "" : "s"}`);
148
+ process.exit(issues > 0 ? 1 : 0);
149
+ }
150
+ /**
151
+ * Walk master skills and decide which lang their substituted
152
+ * {{OUTPUT_LANGUAGE_INSTRUCTION}} block actually matches. Returns the
153
+ * detected lang if it differs from `expected`, or null if they agree (or if
154
+ * we can't tell โ€” e.g. no master files, or no language section).
155
+ */
156
+ function detectLangDrift(md, expected) {
157
+ if (!existsSync(md))
158
+ return null;
159
+ const langs = ["en", "ko", "ja"];
160
+ const entries = readdirSync(md, { withFileTypes: true })
161
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
162
+ .map((e) => e.name)
163
+ .slice(0, 3); // sample a few; checking all 9 would be wasteful
164
+ if (entries.length === 0)
165
+ return null;
166
+ for (const file of entries) {
167
+ let body;
168
+ try {
169
+ body = readFileSync(join(md, file), "utf8");
170
+ }
171
+ catch {
172
+ continue;
173
+ }
174
+ for (const l of langs) {
175
+ if (body.includes(LANG_INSTRUCTIONS[l])) {
176
+ return l === expected ? null : l;
177
+ }
178
+ }
179
+ }
180
+ return null;
181
+ }
182
+ function countSkills(agent, dir) {
183
+ const ids = new Set();
184
+ if (!existsSync(dir))
185
+ return ids;
186
+ const entries = readdirSync(dir, { withFileTypes: true });
187
+ for (const e of entries) {
188
+ if (agent === "claude" && e.isDirectory())
189
+ ids.add(e.name);
190
+ else if (agent === "cursor" && e.isFile() && e.name.endsWith(".mdc"))
191
+ ids.add(e.name.slice(0, -4));
192
+ else if (agent === "codex" && e.isFile() && e.name.endsWith(".md"))
193
+ ids.add(e.name.slice(0, -3));
194
+ }
195
+ return ids;
196
+ }
197
+ function printToolsSection() {
198
+ console.log(`\n${BOLD}External tools${RESET}`);
199
+ printTool("git", ["--version"], "required for worktrees + history");
200
+ printTool("gh", ["--version"], "needed by pr-create / pr-review-analyze / incident-context");
201
+ const editor = process.env["VISUAL"] || process.env["EDITOR"];
202
+ if (editor) {
203
+ console.log(` ${OK} $EDITOR ${DIM}${editor}${RESET}`);
204
+ }
205
+ else {
206
+ console.log(` ${WARN} $EDITOR not set ${DIM}โ€” ${CYAN}agentforge add-skill${RESET}${DIM} falls back to vim/nano${RESET}`);
207
+ }
208
+ console.log(` ${OK} node ${DIM}${process.version}${RESET}\n`);
209
+ }
210
+ function printTool(cmd, args, purpose) {
211
+ try {
212
+ const out = execSync(`${cmd} ${args.join(" ")}`, {
213
+ stdio: ["ignore", "pipe", "ignore"],
214
+ })
215
+ .toString()
216
+ .trim()
217
+ .split("\n")[0];
218
+ console.log(` ${OK} ${cmd} ${DIM}${out}${RESET}`);
219
+ }
220
+ catch {
221
+ console.log(` ${FAIL} ${cmd} not found ${DIM}โ€” ${purpose}${RESET}`);
222
+ }
223
+ }
package/dist/enter.js ADDED
@@ -0,0 +1,85 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { configPath } from "./agentforge-config.js";
5
+ const DIM = "\x1b[2m";
6
+ const CYAN = "\x1b[36m";
7
+ const YELLOW = "\x1b[33m";
8
+ const RED = "\x1b[31m";
9
+ const RESET = "\x1b[0m";
10
+ export async function runEnter(opts) {
11
+ const root = findWorkspaceRoot(process.cwd());
12
+ if (!root) {
13
+ process.stderr.write(`\n${YELLOW}โš ${RESET} Not inside an agentforge workspace.\n` +
14
+ ` ${DIM}cwd: ${process.cwd()}${RESET}\n\n` +
15
+ ` Run ${CYAN}agentforge enter${RESET} from a workspace directory (one containing ${CYAN}agentforge/config.json${RESET}).\n\n`);
16
+ process.exit(1);
17
+ }
18
+ const anvilDir = join(root, "anvil");
19
+ const features = listFeatures(anvilDir);
20
+ if (!opts.slug) {
21
+ if (features.length === 0) {
22
+ process.stderr.write(`\n${YELLOW}โš ${RESET} No active features in ${DIM}${anvilDir}${RESET}.\n\n` +
23
+ ` Start one with ${CYAN}claude${RESET} โ†’ "let's start a new feature".\n\n`);
24
+ process.exit(1);
25
+ }
26
+ process.stderr.write(`\nusage: ${CYAN}agentforge enter <slug>${RESET}\n\nActive features:\n` +
27
+ features.map((f) => ` ${CYAN}${f}${RESET}`).join("\n") +
28
+ `\n\n`);
29
+ process.exit(1);
30
+ }
31
+ const target = join(anvilDir, opts.slug);
32
+ if (!existsSync(target) || !statSync(target).isDirectory()) {
33
+ process.stderr.write(`\n${RED}โœ—${RESET} Feature not found: ${CYAN}${opts.slug}${RESET}\n` +
34
+ ` ${DIM}expected: ${target}${RESET}\n\n`);
35
+ if (features.length > 0) {
36
+ process.stderr.write(`Active features:\n` +
37
+ features.map((f) => ` ${CYAN}${f}${RESET}`).join("\n") +
38
+ `\n\n`);
39
+ }
40
+ process.exit(1);
41
+ }
42
+ process.stdout.write(`${DIM}โ†’ ${target}${RESET}\n${DIM}โ†’ launching claudeโ€ฆ${RESET}\n`);
43
+ const child = spawn("claude", [], { cwd: target, stdio: "inherit" });
44
+ child.on("error", (err) => {
45
+ const e = err;
46
+ if (e.code === "ENOENT") {
47
+ process.stderr.write(`\n${RED}โœ—${RESET} ${CYAN}claude${RESET} command not found on PATH.\n\n` +
48
+ ` Install Claude Code: ${CYAN}https://claude.com/claude-code${RESET}\n\n`);
49
+ }
50
+ else {
51
+ process.stderr.write(`\n${RED}โœ—${RESET} failed to launch claude: ${e.message}\n\n`);
52
+ }
53
+ process.exit(1);
54
+ });
55
+ child.on("exit", (code, signal) => {
56
+ if (signal)
57
+ process.kill(process.pid, signal);
58
+ else
59
+ process.exit(code ?? 0);
60
+ });
61
+ }
62
+ function findWorkspaceRoot(start) {
63
+ let dir = resolve(start);
64
+ while (true) {
65
+ if (existsSync(configPath(dir)))
66
+ return dir;
67
+ const parent = dirname(dir);
68
+ if (parent === dir)
69
+ return null;
70
+ dir = parent;
71
+ }
72
+ }
73
+ function listFeatures(anvilDir) {
74
+ if (!existsSync(anvilDir))
75
+ return [];
76
+ try {
77
+ return readdirSync(anvilDir, { withFileTypes: true })
78
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
79
+ .map((e) => e.name)
80
+ .sort();
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ }
package/dist/init.js ADDED
@@ -0,0 +1,272 @@
1
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { pickAgents } from "./agent-prompt.js";
4
+ import { ensureDir, readMasterDir, renderTemplate, setVerbose, writeRendered, } from "./agents/io.js";
5
+ import { getAgent } from "./agents/index.js";
6
+ import { configPath, masterDir, upsertConfig } from "./agentforge-config.js";
7
+ import { pickLanguage } from "./lang-prompt.js";
8
+ import { printLogo } from "./logo.js";
9
+ import { promptPath } from "./path-prompt.js";
10
+ import { pickSkills } from "./skill-prompt.js";
11
+ import { LANG_LABEL, SKILLS } from "./skills-data.js";
12
+ const BASE_DIRS = ["repos", "anvil", "artifacts"];
13
+ const DIM = "\x1b[2m";
14
+ const GREEN = "\x1b[32m";
15
+ const YELLOW = "\x1b[33m";
16
+ const CYAN = "\x1b[36m";
17
+ const BOLD = "\x1b[1m";
18
+ const RESET = "\x1b[0m";
19
+ export async function runInit(opts) {
20
+ // Pre-flight validation (before logo / prompts) for the explicit-path case.
21
+ if (opts.pathArg) {
22
+ const targetAbs = resolve(opts.pathArg);
23
+ // (a) refuse filesystem root โ€” would try to mkdir /repos, /anvil etc.
24
+ if (targetAbs === dirname(targetAbs)) {
25
+ process.stderr.write(`\nerror: ${targetAbs} is the filesystem root โ€” pick a real directory to host the workspace.\n\n`);
26
+ process.exit(1);
27
+ }
28
+ // (b) refuse if target sits inside another agentforge workspace โ€”
29
+ // workspaces shouldn't nest (repos/ is meant for git checkouts).
30
+ if (!opts.forceSkills && !opts.forceClaude) {
31
+ const outer = findEnclosingWorkspace(targetAbs);
32
+ if (outer && outer !== targetAbs) {
33
+ process.stderr.write(`\n${YELLOW}โš ${RESET} ${targetAbs} sits inside another agentforge workspace:\n` +
34
+ ` ${DIM}${outer}${RESET}\n\n` +
35
+ ` Workspaces shouldn't nest. ${DIM}repos/${RESET} is meant for ${BOLD}git checkouts${RESET}, not nested workspaces.\n` +
36
+ ` Pick a path outside ${DIM}${outer}${RESET}, or pass ${CYAN}--force${RESET} to override.\n\n`);
37
+ process.exit(1);
38
+ }
39
+ }
40
+ // (c) already-initialized? โ€” block re-init (drift trap from earlier).
41
+ if (!opts.forceSkills && !opts.forceClaude && existsSync(configPath(targetAbs))) {
42
+ printAlreadyInitialized(targetAbs);
43
+ process.exit(1);
44
+ }
45
+ // (d) dir exists and has unrelated content โ€” refuse before scrambling
46
+ // workspace files in among the user's project.
47
+ if (!opts.forceSkills && !opts.forceClaude) {
48
+ const intruders = unexpectedEntries(targetAbs);
49
+ if (intruders.length > 0) {
50
+ printNotEmpty(targetAbs, intruders);
51
+ process.exit(1);
52
+ }
53
+ }
54
+ }
55
+ printLogo();
56
+ const targetPath = await resolveTargetPath(opts);
57
+ const targetAbs = resolve(targetPath);
58
+ // Same checks for the interactive path (no --path arg given).
59
+ if (!opts.forceSkills &&
60
+ !opts.forceClaude &&
61
+ existsSync(configPath(targetAbs))) {
62
+ printAlreadyInitialized(targetAbs);
63
+ process.exit(1);
64
+ }
65
+ if (!opts.forceSkills && !opts.forceClaude) {
66
+ const intruders = unexpectedEntries(targetAbs);
67
+ if (intruders.length > 0) {
68
+ printNotEmpty(targetAbs, intruders);
69
+ process.exit(1);
70
+ }
71
+ }
72
+ const agentIds = await resolveAgents(opts);
73
+ const lang = await resolveLanguage(opts);
74
+ const selectedSkills = await selectSkills(opts, lang);
75
+ const root = resolve(targetPath);
76
+ if (!existsSync(root)) {
77
+ mkdirSync(root, { recursive: true });
78
+ }
79
+ // Workspace skeleton โ€” quiet, one summary line.
80
+ setVerbose(false);
81
+ for (const dir of BASE_DIRS) {
82
+ ensureDir(join(root, dir), dir);
83
+ }
84
+ ensureDir(masterDir(root), "agentforge/skills");
85
+ setVerbose(true);
86
+ line(`${GREEN}+${RESET} workspace skeleton ${DIM}(${BASE_DIRS.join("/, ")}/, agentforge/)${RESET}`);
87
+ // Master skills โ€” quiet writes, one summary line.
88
+ setVerbose(false);
89
+ let written = 0;
90
+ for (const s of selectedSkills) {
91
+ const rendered = renderTemplate(s.template, lang);
92
+ const destAbs = join(masterDir(root), `${s.id}.md`);
93
+ const destRel = `agentforge/skills/${s.id}.md`;
94
+ writeRendered(destAbs, destRel, rendered, opts.forceSkills);
95
+ written++;
96
+ }
97
+ upsertConfig(root, { version: 1, lang, agents: agentIds });
98
+ setVerbose(true);
99
+ line(`${GREEN}+${RESET} master skills ${DIM}(${written} skill${written === 1 ? "" : "s"} โ†’ agentforge/skills/, + agentforge/config.json)${RESET}`);
100
+ // Adapter installs โ€” quiet, one line per agent.
101
+ const { skills: masterSkills, skipped } = readMasterDir(masterDir(root));
102
+ if (skipped.length > 0) {
103
+ for (const sk of skipped) {
104
+ line(` ${DIM}skipped master file ${sk.file} โ€” ${sk.reason}${RESET}`);
105
+ }
106
+ }
107
+ setVerbose(false);
108
+ try {
109
+ for (const id of agentIds) {
110
+ const adapter = getAgent(id);
111
+ adapter.install({
112
+ root,
113
+ masterSkills,
114
+ skillCatalog: SKILLS.slice(),
115
+ lang,
116
+ forceSkills: opts.forceSkills,
117
+ forceClaude: opts.forceClaude,
118
+ });
119
+ line(`${GREEN}+${RESET} ${adapter.label.padEnd(18)} ${DIM}(${adapter.outputSummary})${RESET}`);
120
+ }
121
+ }
122
+ finally {
123
+ setVerbose(true);
124
+ }
125
+ printNextSteps(root, agentIds, lang);
126
+ }
127
+ async function resolveTargetPath(opts) {
128
+ if (opts.pathArg)
129
+ return opts.pathArg;
130
+ if (opts.yes)
131
+ return process.cwd();
132
+ return promptPath("Workspace directory", process.cwd());
133
+ }
134
+ async function resolveAgents(opts) {
135
+ if (opts.agents && opts.agents.length > 0)
136
+ return opts.agents;
137
+ if (opts.yes)
138
+ return ["claude"];
139
+ // requireAtLeastOne: interactive picker won't return [] โ€” it re-prompts.
140
+ return pickAgents(opts.lang ?? "en", { requireAtLeastOne: true });
141
+ }
142
+ async function resolveLanguage(opts) {
143
+ if (opts.lang)
144
+ return opts.lang;
145
+ if (opts.yes)
146
+ return "en";
147
+ return pickLanguage();
148
+ }
149
+ async function selectSkills(opts, lang) {
150
+ if (opts.yes)
151
+ return SKILLS.slice();
152
+ const ids = await pickSkills(SKILLS.map((s) => ({
153
+ id: s.id,
154
+ title: s.title,
155
+ description: s.description[lang],
156
+ details: s.details[lang],
157
+ })));
158
+ return SKILLS.filter((s) => ids.includes(s.id));
159
+ }
160
+ function printNextSteps(root, agentIds, lang) {
161
+ const repos = join(root, "repos");
162
+ const repoCount = existsSync(repos)
163
+ ? readdirSync(repos, { withFileTypes: true }).filter((d) => d.isDirectory())
164
+ .length
165
+ : 0;
166
+ console.log("");
167
+ console.log(`${BOLD}${GREEN}โœ“${RESET} workspace ready at ${CYAN}${root}${RESET} ${DIM}(lang: ${LANG_LABEL[lang]})${RESET}`);
168
+ console.log("");
169
+ console.log(`${BOLD}Next steps:${RESET}`);
170
+ if (repoCount === 0) {
171
+ console.log(` cd ${root}/repos && git clone <repo-url> ${DIM}# add repos to work with${RESET}`);
172
+ console.log(` cd ${root} && ${launchHint(agentIds)} ${DIM}# launch your agent${RESET}`);
173
+ }
174
+ else {
175
+ console.log(` ${DIM}(found ${repoCount} repo${repoCount === 1 ? "" : "s"} in repos/)${RESET}`);
176
+ console.log(` cd ${root} && ${launchHint(agentIds)}`);
177
+ }
178
+ console.log("");
179
+ console.log(`${BOLD}Useful commands:${RESET}`);
180
+ console.log(` ${CYAN}agentforge list-skills${RESET} ${DIM}# see what's installed${RESET}`);
181
+ console.log(` ${CYAN}agentforge add-skill${RESET} ${DIM}# create your own skill${RESET}`);
182
+ console.log(` ${CYAN}agentforge sync-skills${RESET} ${DIM}# propagate master edits to all agents${RESET}`);
183
+ console.log(` ${CYAN}agentforge add-agent${RESET} ${DIM}# add another agent later${RESET}`);
184
+ console.log("");
185
+ }
186
+ function launchHint(agentIds) {
187
+ const cmds = agentIds.map((id) => {
188
+ switch (id) {
189
+ case "claude":
190
+ return "claude";
191
+ case "cursor":
192
+ return "cursor .";
193
+ case "codex":
194
+ return "codex";
195
+ }
196
+ });
197
+ return cmds.join(" / ");
198
+ }
199
+ function line(s) {
200
+ console.log(s);
201
+ }
202
+ /** Files / dirs that agentforge owns at the workspace root. Anything else
203
+ * in the target dir means the user picked a path that's already in use. */
204
+ const WORKSPACE_ENTRIES = new Set([
205
+ ...BASE_DIRS,
206
+ "agentforge",
207
+ ".claude",
208
+ ".cursor",
209
+ ".agents",
210
+ "CLAUDE.md",
211
+ "AGENTS.md",
212
+ ".cursorrules",
213
+ ]);
214
+ /**
215
+ * Return entries in `dir` that don't belong to agentforge. Dotfiles other
216
+ * than agentforge's own (`.claude`, `.cursor`, `.agents`, `.cursorrules`) are
217
+ * surfaced too โ€” `.git/`, `.env`, IDE folders etc. mean this is already
218
+ * someone's project.
219
+ */
220
+ function unexpectedEntries(dir) {
221
+ if (!existsSync(dir))
222
+ return [];
223
+ let entries;
224
+ try {
225
+ entries = readdirSync(dir);
226
+ }
227
+ catch {
228
+ return [];
229
+ }
230
+ return entries
231
+ .filter((name) => name !== ".DS_Store") // macOS OS noise
232
+ .filter((name) => !WORKSPACE_ENTRIES.has(name))
233
+ .sort();
234
+ }
235
+ function printNotEmpty(targetAbs, intruders) {
236
+ const preview = intruders.slice(0, 6);
237
+ const extra = intruders.length - preview.length;
238
+ const list = preview
239
+ .map((n) => ` ${DIM}-${RESET} ${n}`)
240
+ .join("\n");
241
+ const more = extra > 0 ? `\n ${DIM}โ€ฆ and ${extra} more${RESET}` : "";
242
+ process.stderr.write(`\n${YELLOW}โš ${RESET} ${targetAbs} isn't empty.\n\n` +
243
+ ` It contains files that don't look like agentforge:\n${list}${more}\n\n` +
244
+ ` agentforge would overlay its workspace structure (${DIM}repos/, anvil/, artifacts/, agentforge/${RESET}) on top.\n` +
245
+ ` Pick an empty directory, or pass ${CYAN}--force${RESET} to proceed anyway (existing files are not touched, only the workspace structure is added).\n\n`);
246
+ }
247
+ /**
248
+ * Walk up from `start` looking for a parent directory that has
249
+ * `agentforge/config.json`. Returns the workspace root if found, else null.
250
+ * Returns `start` itself if `start` is already a workspace.
251
+ */
252
+ function findEnclosingWorkspace(start) {
253
+ let cur = start;
254
+ while (true) {
255
+ if (existsSync(configPath(cur)))
256
+ return cur;
257
+ const parent = dirname(cur);
258
+ if (parent === cur)
259
+ return null; // reached FS root
260
+ cur = parent;
261
+ }
262
+ }
263
+ function printAlreadyInitialized(targetAbs) {
264
+ process.stderr.write(`\n${YELLOW}โš ${RESET} ${targetAbs} is already an agentforge workspace.\n\n` +
265
+ ` Use one of these instead:\n` +
266
+ ` ${CYAN}agentforge add-agent${RESET} ${DIM}# install another agent (Claude / Cursor / Codex)${RESET}\n` +
267
+ ` ${CYAN}agentforge add-skill${RESET} ${DIM}# create a new skill${RESET}\n` +
268
+ ` ${CYAN}agentforge sync-skills${RESET} ${DIM}# propagate master skill edits to every agent${RESET}\n` +
269
+ ` ${CYAN}agentforge list-skills${RESET} ${DIM}# see what's installed${RESET}\n` +
270
+ ` ${CYAN}agentforge doctor${RESET} ${DIM}# diagnose the workspace${RESET}\n\n` +
271
+ ` Or pass ${CYAN}--force${RESET} to overwrite everything (existing files are backed up to .bak).\n\n`);
272
+ }
@@ -0,0 +1,88 @@
1
+ const CYAN = "\x1b[36m";
2
+ const DIM = "\x1b[2m";
3
+ const BOLD = "\x1b[1m";
4
+ const YELLOW = "\x1b[33m";
5
+ const RESET = "\x1b[0m";
6
+ const OPTIONS = [
7
+ { value: "en", label: "English", hint: "" },
8
+ { value: "ko", label: "ํ•œ๊ตญ์–ด", hint: "Korean" },
9
+ { value: "ja", label: "ๆ—ฅๆœฌ่ชž", hint: "Japanese" },
10
+ ];
11
+ async function readKey() {
12
+ return new Promise((resolve) => {
13
+ process.stdin.once("data", (chunk) => {
14
+ resolve(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
15
+ });
16
+ });
17
+ }
18
+ export async function pickLanguage() {
19
+ if (!process.stdin.isTTY)
20
+ return "en";
21
+ let cursor = 0;
22
+ let lastLineCount = 0;
23
+ const HEADER = `${CYAN}?${RESET} ${BOLD}Language${RESET} ${DIM}โ†‘โ†“ move ยท enter confirm${RESET}`;
24
+ const render = () => {
25
+ if (lastLineCount > 0) {
26
+ process.stdout.write(`\x1b[${lastLineCount}A\x1b[J`);
27
+ }
28
+ const lines = [HEADER, ""];
29
+ for (let i = 0; i < OPTIONS.length; i++) {
30
+ const o = OPTIONS[i];
31
+ const prefix = i === cursor ? `${CYAN}โฏ${RESET}` : " ";
32
+ const label = i === cursor ? `${CYAN}${o.label}${RESET}` : o.label;
33
+ const hint = o.hint ? ` ${DIM}โ€” ${o.hint}${RESET}` : "";
34
+ lines.push(`${prefix} ${label}${hint}`);
35
+ }
36
+ const text = lines.join("\n") + "\n";
37
+ process.stdout.write(text);
38
+ lastLineCount = text.split("\n").length - 1;
39
+ };
40
+ const stdin = process.stdin;
41
+ stdin.setRawMode(true);
42
+ stdin.resume();
43
+ stdin.setEncoding("utf8");
44
+ process.stdout.write("\x1b[?25l"); // hide cursor
45
+ const cleanup = (clearUi) => {
46
+ process.stdout.write("\x1b[?25h"); // show cursor
47
+ stdin.setRawMode(false);
48
+ stdin.pause();
49
+ if (clearUi && lastLineCount > 0) {
50
+ process.stdout.write(`\x1b[${lastLineCount}A\x1b[J`);
51
+ }
52
+ };
53
+ render();
54
+ try {
55
+ while (true) {
56
+ const chunk = await readKey();
57
+ if (chunk === "\x1b[A") {
58
+ cursor = (cursor - 1 + OPTIONS.length) % OPTIONS.length;
59
+ render();
60
+ continue;
61
+ }
62
+ if (chunk === "\x1b[B") {
63
+ cursor = (cursor + 1) % OPTIONS.length;
64
+ render();
65
+ continue;
66
+ }
67
+ if (chunk.startsWith("\x1b"))
68
+ continue;
69
+ for (const ch of chunk) {
70
+ if (ch === "\x03") {
71
+ cleanup(true);
72
+ process.stdout.write(`${YELLOW}aborted.${RESET}\n`);
73
+ process.exit(130);
74
+ }
75
+ if (ch === "\r" || ch === "\n") {
76
+ const picked = OPTIONS[cursor];
77
+ cleanup(true);
78
+ process.stdout.write(`${CYAN}?${RESET} ${BOLD}Language${RESET} ${DIM}โ€บ${RESET} ${picked.label}\n`);
79
+ return picked.value;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ catch (err) {
85
+ cleanup(true);
86
+ throw err;
87
+ }
88
+ }