@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
package/dist/rename.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { configPath, requireWorkspace } from "./agentforge-config.js";
|
|
5
|
+
const BOLD = "\x1b[1m";
|
|
6
|
+
const DIM = "\x1b[2m";
|
|
7
|
+
const CYAN = "\x1b[36m";
|
|
8
|
+
const GREEN = "\x1b[32m";
|
|
9
|
+
const YELLOW = "\x1b[33m";
|
|
10
|
+
const RED = "\x1b[31m";
|
|
11
|
+
const RESET = "\x1b[0m";
|
|
12
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
13
|
+
export async function runRename(opts) {
|
|
14
|
+
if (!opts.oldSlug || !opts.newSlug) {
|
|
15
|
+
fail(`usage: ${CYAN}agentforge rename <old-slug> <new-slug>${RESET}\n`);
|
|
16
|
+
}
|
|
17
|
+
if (opts.oldSlug === opts.newSlug) {
|
|
18
|
+
fail(`old and new slug are the same: ${opts.oldSlug}\n`);
|
|
19
|
+
}
|
|
20
|
+
if (!SLUG_RE.test(opts.newSlug)) {
|
|
21
|
+
fail(`invalid new slug: ${opts.newSlug}\n must match /^[a-z0-9][a-z0-9-]*$/ (lowercase, digits, hyphens; start with letter/digit)\n`);
|
|
22
|
+
}
|
|
23
|
+
const root = findWorkspaceRoot(opts.pathArg ?? process.cwd());
|
|
24
|
+
if (!root) {
|
|
25
|
+
fail(`Not inside an agentforge workspace (looked for ${CYAN}agentforge/config.json${RESET} upward from cwd).\n`);
|
|
26
|
+
}
|
|
27
|
+
// Make sure config is valid — also gives a friendly error if not initialized.
|
|
28
|
+
requireWorkspace(root);
|
|
29
|
+
const oldDir = join(root, "anvil", opts.oldSlug);
|
|
30
|
+
const newDir = join(root, "anvil", opts.newSlug);
|
|
31
|
+
if (!existsSync(oldDir) || !statSync(oldDir).isDirectory()) {
|
|
32
|
+
fail(`feature not found: ${CYAN}${opts.oldSlug}${RESET}\n expected: ${oldDir}\n`);
|
|
33
|
+
}
|
|
34
|
+
if (existsSync(newDir)) {
|
|
35
|
+
fail(`target already exists: ${CYAN}${opts.newSlug}${RESET}\n ${newDir}\n Pick a different new slug.\n`);
|
|
36
|
+
}
|
|
37
|
+
// Discover worktrees inside the feature
|
|
38
|
+
const repos = readdirSync(oldDir, { withFileTypes: true })
|
|
39
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
40
|
+
.map((e) => e.name)
|
|
41
|
+
.sort();
|
|
42
|
+
console.log(`${BOLD}${CYAN}▸${RESET} renaming feature ${CYAN}${opts.oldSlug}${RESET} → ${CYAN}${opts.newSlug}${RESET}`);
|
|
43
|
+
console.log(` ${DIM}root: ${root}${RESET}`);
|
|
44
|
+
console.log(` ${DIM}worktrees: ${repos.length}${RESET}`);
|
|
45
|
+
console.log("");
|
|
46
|
+
const plan = [];
|
|
47
|
+
for (const repo of repos) {
|
|
48
|
+
const wt = join(oldDir, repo);
|
|
49
|
+
let currentBranch = "";
|
|
50
|
+
let dirty = false;
|
|
51
|
+
try {
|
|
52
|
+
currentBranch = execGit(wt, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
console.log(` ${YELLOW}skip:${RESET} ${repo} — not a git worktree (or git unavailable)`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const status = execGit(wt, ["status", "--porcelain"]);
|
|
60
|
+
dirty = status.trim().length > 0;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// treat unreadable as dirty for safety
|
|
64
|
+
dirty = true;
|
|
65
|
+
}
|
|
66
|
+
const canonical = resolveCanonical(root, repo, wt);
|
|
67
|
+
plan.push({
|
|
68
|
+
repo,
|
|
69
|
+
canonical,
|
|
70
|
+
currentBranch,
|
|
71
|
+
dirty,
|
|
72
|
+
renameBranch: currentBranch === opts.oldSlug,
|
|
73
|
+
});
|
|
74
|
+
console.log(` ${repo} ${DIM}branch=${currentBranch}${RESET}` +
|
|
75
|
+
(dirty ? ` ${YELLOW}(dirty)${RESET}` : "") +
|
|
76
|
+
(currentBranch === opts.oldSlug
|
|
77
|
+
? ` ${DIM}→ branch will be renamed to ${opts.newSlug}${RESET}`
|
|
78
|
+
: ` ${DIM}→ branch stays (per-repo convention)${RESET}`));
|
|
79
|
+
}
|
|
80
|
+
console.log("");
|
|
81
|
+
// Refuse on dirty unless --force
|
|
82
|
+
const dirtyRepos = plan.filter((p) => p.dirty);
|
|
83
|
+
if (dirtyRepos.length > 0 && !opts.force) {
|
|
84
|
+
fail(`${dirtyRepos.length} worktree(s) have uncommitted changes:\n` +
|
|
85
|
+
dirtyRepos.map((p) => ` - ${p.repo}`).join("\n") +
|
|
86
|
+
`\n\nCommit/stash first, or pass ${CYAN}--force${RESET} to proceed anyway.\n`);
|
|
87
|
+
}
|
|
88
|
+
// Confirm before destructive action unless --yes
|
|
89
|
+
if (!opts.yes) {
|
|
90
|
+
console.log(`${YELLOW}!${RESET} This will:\n` +
|
|
91
|
+
` • move ${plan.length} worktree dir(s) anvil/${opts.oldSlug}/… → anvil/${opts.newSlug}/…\n` +
|
|
92
|
+
` • rename ${plan.filter((p) => p.renameBranch).length} branch(es) named "${opts.oldSlug}" to "${opts.newSlug}"\n` +
|
|
93
|
+
` • move anvil/${opts.oldSlug}/CLAUDE.md → anvil/${opts.newSlug}/CLAUDE.md (and rewrite slug references inside)\n` +
|
|
94
|
+
` • delete the now-empty anvil/${opts.oldSlug}/\n` +
|
|
95
|
+
`\nRe-run with ${CYAN}--yes${RESET} to proceed.\n`);
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
// Create the new feature dir
|
|
99
|
+
mkdirSync(newDir, { recursive: true });
|
|
100
|
+
// Move each worktree
|
|
101
|
+
for (const p of plan) {
|
|
102
|
+
const fromWt = join(oldDir, p.repo);
|
|
103
|
+
const toWt = join(newDir, p.repo);
|
|
104
|
+
try {
|
|
105
|
+
execGit(p.canonical, ["worktree", "move", fromWt, toWt]);
|
|
106
|
+
console.log(`${GREEN}+${RESET} moved worktree: anvil/${opts.oldSlug}/${p.repo} → anvil/${opts.newSlug}/${p.repo}`);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
fail(`failed to move worktree for ${p.repo}: ${err.message}\n` +
|
|
110
|
+
` Some worktrees may have already moved — inspect anvil/ and re-run after cleanup.\n`);
|
|
111
|
+
}
|
|
112
|
+
if (p.renameBranch) {
|
|
113
|
+
try {
|
|
114
|
+
execGit(toWt, ["branch", "-m", opts.oldSlug, opts.newSlug]);
|
|
115
|
+
console.log(`${GREEN}+${RESET} renamed branch: ${p.repo} ${opts.oldSlug} → ${opts.newSlug}`);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
console.log(`${YELLOW}!${RESET} branch rename failed for ${p.repo}: ${err.message} (continuing)`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Move CLAUDE.md + rewrite slug references inside
|
|
123
|
+
const oldClaudeMd = join(oldDir, "CLAUDE.md");
|
|
124
|
+
const newClaudeMd = join(newDir, "CLAUDE.md");
|
|
125
|
+
if (existsSync(oldClaudeMd)) {
|
|
126
|
+
let body = readFileSync(oldClaudeMd, "utf8");
|
|
127
|
+
body = body.split(opts.oldSlug).join(opts.newSlug);
|
|
128
|
+
writeFileSync(newClaudeMd, body);
|
|
129
|
+
unlinkSync(oldClaudeMd);
|
|
130
|
+
console.log(`${GREEN}+${RESET} moved CLAUDE.md (and rewrote ${opts.oldSlug} → ${opts.newSlug} inside)`);
|
|
131
|
+
}
|
|
132
|
+
// Move any other top-level files (HANDOFF.md, PLAN.md, etc.) and remove the
|
|
133
|
+
// lock file
|
|
134
|
+
for (const entry of readdirSync(oldDir, { withFileTypes: true })) {
|
|
135
|
+
const src = join(oldDir, entry.name);
|
|
136
|
+
if (entry.name === ".agentforge.lock") {
|
|
137
|
+
try {
|
|
138
|
+
unlinkSync(src);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
/* ignore */
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (entry.isFile()) {
|
|
146
|
+
const dst = join(newDir, entry.name);
|
|
147
|
+
let content = readFileSync(src, "utf8");
|
|
148
|
+
content = content.split(opts.oldSlug).join(opts.newSlug);
|
|
149
|
+
writeFileSync(dst, content);
|
|
150
|
+
unlinkSync(src);
|
|
151
|
+
console.log(`${GREEN}+${RESET} moved ${entry.name}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Remove old dir if now empty
|
|
155
|
+
try {
|
|
156
|
+
rmdirSync(oldDir);
|
|
157
|
+
console.log(`${GREEN}+${RESET} removed empty anvil/${opts.oldSlug}/`);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
console.log(`${YELLOW}!${RESET} could not remove anvil/${opts.oldSlug}/ — inspect manually: ${err.message}`);
|
|
161
|
+
}
|
|
162
|
+
// Append to activity log
|
|
163
|
+
appendLog(root, {
|
|
164
|
+
skill: "agentforge-rename",
|
|
165
|
+
action: "renamed",
|
|
166
|
+
oldSlug: opts.oldSlug,
|
|
167
|
+
newSlug: opts.newSlug,
|
|
168
|
+
repos: plan.map((p) => p.repo).join(","),
|
|
169
|
+
});
|
|
170
|
+
console.log("");
|
|
171
|
+
console.log(`${BOLD}${GREEN}✓${RESET} rename complete`);
|
|
172
|
+
console.log(` ${DIM}next: cd anvil/${opts.newSlug}/${RESET}`);
|
|
173
|
+
}
|
|
174
|
+
function execGit(cwd, args) {
|
|
175
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" });
|
|
176
|
+
}
|
|
177
|
+
function resolveCanonical(root, repo, worktree) {
|
|
178
|
+
// Prefer repos/<repo> if it exists; otherwise ask git for the main worktree
|
|
179
|
+
const candidate = join(root, "repos", repo);
|
|
180
|
+
if (existsSync(candidate))
|
|
181
|
+
return candidate;
|
|
182
|
+
try {
|
|
183
|
+
const out = execGit(worktree, ["worktree", "list", "--porcelain"]);
|
|
184
|
+
// The first "worktree <path>" line is the main worktree
|
|
185
|
+
const m = out.match(/^worktree (.+)$/m);
|
|
186
|
+
if (m)
|
|
187
|
+
return m[1];
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* ignore */
|
|
191
|
+
}
|
|
192
|
+
return candidate; // best-effort; git worktree move will error if wrong
|
|
193
|
+
}
|
|
194
|
+
function findWorkspaceRoot(start) {
|
|
195
|
+
let dir = resolve(start);
|
|
196
|
+
while (true) {
|
|
197
|
+
if (existsSync(configPath(dir)))
|
|
198
|
+
return dir;
|
|
199
|
+
const parent = dirname(dir);
|
|
200
|
+
if (parent === dir)
|
|
201
|
+
return null;
|
|
202
|
+
dir = parent;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function appendLog(root, fields) {
|
|
206
|
+
const logPath = join(root, "agentforge", "log.jsonl");
|
|
207
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
208
|
+
const entry = {
|
|
209
|
+
ts: new Date().toISOString(),
|
|
210
|
+
...fields,
|
|
211
|
+
};
|
|
212
|
+
try {
|
|
213
|
+
writeFileSync(logPath, `${JSON.stringify(entry)}\n`, { flag: "a" });
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
/* non-fatal */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function fail(msg) {
|
|
220
|
+
process.stderr.write(`\n${RED}✗${RESET} ${msg}\n`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
const CYAN = "\x1b[36m";
|
|
2
|
+
const GREEN = "\x1b[32m";
|
|
3
|
+
const DIM = "\x1b[2m";
|
|
4
|
+
const BOLD = "\x1b[1m";
|
|
5
|
+
const YELLOW = "\x1b[33m";
|
|
6
|
+
const RESET = "\x1b[0m";
|
|
7
|
+
function wrap(text, width) {
|
|
8
|
+
const out = [];
|
|
9
|
+
for (const paragraph of text.split("\n")) {
|
|
10
|
+
if (paragraph === "") {
|
|
11
|
+
out.push("");
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
let line = "";
|
|
15
|
+
for (const word of paragraph.split(/\s+/)) {
|
|
16
|
+
if (line.length === 0) {
|
|
17
|
+
line = word;
|
|
18
|
+
}
|
|
19
|
+
else if (line.length + 1 + word.length <= width) {
|
|
20
|
+
line += " " + word;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
out.push(line);
|
|
24
|
+
line = word;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (line)
|
|
28
|
+
out.push(line);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
async function readKey() {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
process.stdin.once("data", (chunk) => {
|
|
35
|
+
resolve(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export async function pickSkills(items) {
|
|
40
|
+
if (!process.stdin.isTTY) {
|
|
41
|
+
return items.map((i) => i.id);
|
|
42
|
+
}
|
|
43
|
+
let cursor = 0;
|
|
44
|
+
const selected = new Set(items.map((i) => i.id));
|
|
45
|
+
let view = "list";
|
|
46
|
+
let lastLineCount = 0;
|
|
47
|
+
const HEADER_LIST = `${CYAN}?${RESET} ${BOLD}Skills to install${RESET} ${DIM}↑↓ move · space toggle · → details · enter confirm${RESET}`;
|
|
48
|
+
const headerDetails = (title, idx, total) => `${CYAN}?${RESET} ${BOLD}${title}${RESET} ${DIM}(${idx}/${total}) ↑↓ next · ← back${RESET}`;
|
|
49
|
+
const renderList = () => {
|
|
50
|
+
const lines = [HEADER_LIST, ""];
|
|
51
|
+
for (let i = 0; i < items.length; i++) {
|
|
52
|
+
const item = items[i];
|
|
53
|
+
const isSel = selected.has(item.id);
|
|
54
|
+
const isCur = i === cursor;
|
|
55
|
+
const marker = isSel ? `${GREEN}◉${RESET}` : `${DIM}◯${RESET}`;
|
|
56
|
+
const prefix = isCur ? `${CYAN}❯${RESET}` : " ";
|
|
57
|
+
const title = isCur ? `${CYAN}${item.title}${RESET}` : item.title;
|
|
58
|
+
lines.push(`${prefix} ${marker} ${title} ${DIM}— ${item.description}${RESET}`);
|
|
59
|
+
}
|
|
60
|
+
return lines.join("\n") + "\n";
|
|
61
|
+
};
|
|
62
|
+
const renderDetails = () => {
|
|
63
|
+
const item = items[cursor];
|
|
64
|
+
const isSel = selected.has(item.id);
|
|
65
|
+
const status = isSel
|
|
66
|
+
? `${GREEN}◉ selected${RESET}`
|
|
67
|
+
: `${DIM}◯ not selected${RESET}`;
|
|
68
|
+
const width = Math.min((process.stdout.columns || 80) - 4, 78);
|
|
69
|
+
const bar = `${DIM}${"─".repeat(width)}${RESET}`;
|
|
70
|
+
const lines = [
|
|
71
|
+
headerDetails(item.title, cursor + 1, items.length),
|
|
72
|
+
` ${bar}`,
|
|
73
|
+
"",
|
|
74
|
+
];
|
|
75
|
+
for (const w of wrap(item.details, width - 2)) {
|
|
76
|
+
lines.push(` ${w}`);
|
|
77
|
+
}
|
|
78
|
+
lines.push("");
|
|
79
|
+
lines.push(` ${status} ${DIM}space toggle · enter confirm${RESET}`);
|
|
80
|
+
return lines.join("\n") + "\n";
|
|
81
|
+
};
|
|
82
|
+
const clear = () => {
|
|
83
|
+
if (lastLineCount > 0) {
|
|
84
|
+
process.stdout.write(`\x1b[${lastLineCount}A\x1b[J`);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const render = () => {
|
|
88
|
+
clear();
|
|
89
|
+
const text = view === "list" ? renderList() : renderDetails();
|
|
90
|
+
process.stdout.write(text);
|
|
91
|
+
lastLineCount = text.split("\n").length - 1;
|
|
92
|
+
};
|
|
93
|
+
// Setup raw mode
|
|
94
|
+
const stdin = process.stdin;
|
|
95
|
+
stdin.setRawMode(true);
|
|
96
|
+
stdin.resume();
|
|
97
|
+
stdin.setEncoding("utf8");
|
|
98
|
+
process.stdout.write("\x1b[?25l"); // hide cursor
|
|
99
|
+
const cleanup = (clearUi) => {
|
|
100
|
+
process.stdout.write("\x1b[?25h"); // show cursor
|
|
101
|
+
stdin.setRawMode(false);
|
|
102
|
+
stdin.pause();
|
|
103
|
+
if (clearUi)
|
|
104
|
+
clear();
|
|
105
|
+
};
|
|
106
|
+
render();
|
|
107
|
+
try {
|
|
108
|
+
while (true) {
|
|
109
|
+
const chunk = await readKey();
|
|
110
|
+
// Whole-chunk escape sequences (arrow keys)
|
|
111
|
+
if (chunk === "\x1b[A") {
|
|
112
|
+
cursor = (cursor - 1 + items.length) % items.length;
|
|
113
|
+
render();
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (chunk === "\x1b[B") {
|
|
117
|
+
cursor = (cursor + 1) % items.length;
|
|
118
|
+
render();
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (chunk === "\x1b[C") {
|
|
122
|
+
if (view === "list") {
|
|
123
|
+
view = "details";
|
|
124
|
+
render();
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (chunk === "\x1b[D") {
|
|
129
|
+
if (view === "details") {
|
|
130
|
+
view = "list";
|
|
131
|
+
render();
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (chunk === "\x1b") {
|
|
136
|
+
// bare ESC
|
|
137
|
+
if (view === "details") {
|
|
138
|
+
view = "list";
|
|
139
|
+
render();
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (chunk.startsWith("\x1b")) {
|
|
144
|
+
continue; // ignore other escapes
|
|
145
|
+
}
|
|
146
|
+
// Per-char
|
|
147
|
+
let handled = false;
|
|
148
|
+
for (const ch of chunk) {
|
|
149
|
+
if (ch === "\x03") {
|
|
150
|
+
// Ctrl+C
|
|
151
|
+
cleanup(true);
|
|
152
|
+
process.stdout.write(`${YELLOW}aborted.${RESET}\n`);
|
|
153
|
+
process.exit(130);
|
|
154
|
+
}
|
|
155
|
+
if (ch === "\r" || ch === "\n") {
|
|
156
|
+
cleanup(true);
|
|
157
|
+
const picked = items.filter((i) => selected.has(i.id));
|
|
158
|
+
const summary = picked.length > 0
|
|
159
|
+
? picked.map((p) => p.title).join(", ")
|
|
160
|
+
: `${DIM}none${RESET}`;
|
|
161
|
+
process.stdout.write(`${CYAN}?${RESET} ${BOLD}Skills to install${RESET} ${DIM}›${RESET} ${summary}\n`);
|
|
162
|
+
return picked.map((p) => p.id);
|
|
163
|
+
}
|
|
164
|
+
if (ch === " ") {
|
|
165
|
+
const item = items[cursor];
|
|
166
|
+
if (selected.has(item.id))
|
|
167
|
+
selected.delete(item.id);
|
|
168
|
+
else
|
|
169
|
+
selected.add(item.id);
|
|
170
|
+
render();
|
|
171
|
+
handled = true;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
// letters could shortcut a/n: select all / select none
|
|
175
|
+
if (ch === "a") {
|
|
176
|
+
for (const it of items)
|
|
177
|
+
selected.add(it.id);
|
|
178
|
+
render();
|
|
179
|
+
handled = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (ch === "n") {
|
|
183
|
+
selected.clear();
|
|
184
|
+
render();
|
|
185
|
+
handled = true;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (!handled) {
|
|
190
|
+
// unknown — re-render in case of partial state
|
|
191
|
+
render();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
cleanup(true);
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
}
|