pan-stack-cli 0.2.1 → 0.2.3
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/README.md +37 -731
- package/bin/pan.js +4978 -269
- package/package.json +17 -5
- package/lib/ai-skills.js +0 -829
- package/lib/create.js +0 -105
- package/lib/discover.js +0 -294
- package/lib/git/git-checkout.js +0 -395
- package/lib/git/git-clone.js +0 -206
- package/lib/git/git-fetch-all.js +0 -129
- package/lib/git/git-grv.js +0 -125
- package/lib/git/git-gst.js +0 -165
- package/lib/git/git-mr.js +0 -546
- package/lib/git/git-pull.js +0 -137
- package/lib/git/git-push.js +0 -185
- package/lib/git/git-sync-main.js +0 -334
- package/lib/git/helpers/bulk-report.js +0 -202
- package/lib/git/helpers/cli.js +0 -188
- package/lib/git/helpers/colors.js +0 -19
- package/lib/git/helpers/git.js +0 -136
- package/lib/git/helpers/manifest.js +0 -223
- package/lib/git/helpers/repo-select.js +0 -416
- package/lib/git/helpers/repo-summary.js +0 -161
- package/lib/i18n.js +0 -61
- package/lib/inquirer-theme.js +0 -20
- package/lib/language.js +0 -121
- package/lib/languages.js +0 -14
- package/lib/manifest.js +0 -467
- package/lib/menu-prompt.js +0 -201
- package/lib/menu.js +0 -134
- package/lib/messages.js +0 -647
- package/lib/platform.js +0 -140
- package/lib/run-command.js +0 -334
- package/lib/select.js +0 -0
- package/lib/serve.js +0 -75
- package/lib/skill-registry-client.js +0 -173
- package/lib/stats/aggregate.js +0 -51
- package/lib/stats/identity.js +0 -20
- package/lib/stats/index.js +0 -189
- package/lib/stats/parse.js +0 -46
- package/lib/stats/range.js +0 -38
- package/lib/stats/report.js +0 -264
- package/lib/stats/review.js +0 -157
- package/lib/stats/scan.js +0 -92
- package/lib/stats/serve.js +0 -25
- package/lib/ui/api.js +0 -202
- package/lib/ui/clone.js +0 -115
- package/lib/ui/config.js +0 -182
- package/lib/ui/diff.js +0 -128
- package/lib/ui/export-gate.js +0 -79
- package/lib/ui/gen-react-mobile-code.js +0 -6
- package/lib/ui/gen-react-web-code.js +0 -75
- package/lib/ui/index.js +0 -89
- package/lib/ui/init.js +0 -252
- package/lib/ui/login.js +0 -128
- package/lib/ui/logout.js +0 -22
- package/lib/ui/palettes.js +0 -103
- package/lib/ui/published-html.js +0 -24
- package/lib/ui/sync.js +0 -137
- package/lib/ui/workspace.js +0 -213
package/lib/ai-skills.js
DELETED
|
@@ -1,829 +0,0 @@
|
|
|
1
|
-
// pan ai — enable/disable agent skills at workspace root by renaming
|
|
2
|
-
// SKILL.md / AGENTS.md ↔ *.disabled across .claude / .cursor / .codex.
|
|
3
|
-
const fs = require("fs");
|
|
4
|
-
const path = require("path");
|
|
5
|
-
const { execFileSync } = require("child_process");
|
|
6
|
-
const { findNearestWorkspace } = require("./discover");
|
|
7
|
-
const { t } = require("./i18n");
|
|
8
|
-
const { helpTheme } = require("./inquirer-theme");
|
|
9
|
-
const { menuSelect, menuCheckbox } = require("./menu-prompt");
|
|
10
|
-
const {
|
|
11
|
-
getRegistryBaseUrl,
|
|
12
|
-
fetchRegistry,
|
|
13
|
-
loadLock,
|
|
14
|
-
saveLock,
|
|
15
|
-
diffRegistry,
|
|
16
|
-
downloadAndExtract,
|
|
17
|
-
} = require("./skill-registry-client");
|
|
18
|
-
|
|
19
|
-
const AGENT_KEYS = ["claude", "cursor", "codex"];
|
|
20
|
-
const AGENT_DIRS = {
|
|
21
|
-
claude: ".claude",
|
|
22
|
-
cursor: ".cursor",
|
|
23
|
-
codex: ".codex",
|
|
24
|
-
};
|
|
25
|
-
const DOC_BASES = ["SKILL.md", "AGENTS.md"];
|
|
26
|
-
|
|
27
|
-
// Trả các key agent (claude/cursor/codex) mà workspace ĐÃ có thư mục .claude/.cursor/.codex
|
|
28
|
-
// (không tạo mới, chỉ detect sẵn có) — dùng cho luồng Install để tự chọn agent đích.
|
|
29
|
-
function detectAgentDirs(workspaceRoot) {
|
|
30
|
-
return AGENT_KEYS.filter((key) => {
|
|
31
|
-
const dir = path.join(workspaceRoot, AGENT_DIRS[key]);
|
|
32
|
-
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
37
|
-
|
|
38
|
-
// Chạy `task()` (Promise) trong lúc hiện spinner text xoay tại chỗ (ghi đè bằng \r).
|
|
39
|
-
async function withSpinner(message, task) {
|
|
40
|
-
if (!process.stdout.isTTY) {
|
|
41
|
-
return task();
|
|
42
|
-
}
|
|
43
|
-
let i = 0;
|
|
44
|
-
const timer = setInterval(() => {
|
|
45
|
-
process.stdout.write(`\r${SPINNER_FRAMES[i % SPINNER_FRAMES.length]} ${message}`);
|
|
46
|
-
i += 1;
|
|
47
|
-
}, 80);
|
|
48
|
-
try {
|
|
49
|
-
return await task();
|
|
50
|
-
} finally {
|
|
51
|
-
clearInterval(timer);
|
|
52
|
-
process.stdout.write(`\r${" ".repeat(message.length + 4)}\r`);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function truncateDescription(desc, max = 80) {
|
|
57
|
-
const s = String(desc || "");
|
|
58
|
-
return s.length > max ? `${s.slice(0, max - 3)}...` : s;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function itemLabel(item, versionsSuffix) {
|
|
62
|
-
const prefix = item.type === "agent" ? "[Agent] " : "";
|
|
63
|
-
return `${prefix}${item.name}${versionsSuffix}`;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
let inquirerModulePromise = null;
|
|
67
|
-
async function loadInquirer() {
|
|
68
|
-
if (inquirerModulePromise === null) {
|
|
69
|
-
inquirerModulePromise = import("@inquirer/prompts").catch(() => null);
|
|
70
|
-
}
|
|
71
|
-
return inquirerModulePromise;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function isCancelError(err) {
|
|
75
|
-
if (!err) return false;
|
|
76
|
-
const name = String(err.name || "");
|
|
77
|
-
return (
|
|
78
|
-
name === "ExitPromptError" ||
|
|
79
|
-
name === "CancelPromptError" ||
|
|
80
|
-
/User force closed/i.test(String(err.message || ""))
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function printAiHelp() {
|
|
85
|
-
console.log(`usage: pan ai [options] (alias: pan skills)
|
|
86
|
-
|
|
87
|
-
Enable/disable PAN Stack agent skills at the workspace root
|
|
88
|
-
(.claude/skills, .cursor/skills, .codex/skills).
|
|
89
|
-
|
|
90
|
-
Disable renames SKILL.md → SKILL.md.disabled (and AGENTS.md likewise)
|
|
91
|
-
on every agent folder that has that skill. Enable reverses the rename.
|
|
92
|
-
|
|
93
|
-
options:
|
|
94
|
-
--list List skills and status (no changes)
|
|
95
|
-
--disable <name...> Disable named skill(s)
|
|
96
|
-
--enable <name...> Enable named skill(s)
|
|
97
|
-
--dry-run Print renames only
|
|
98
|
-
-h, --help Show this help
|
|
99
|
-
|
|
100
|
-
interactive (TTY, no --list/--disable/--enable):
|
|
101
|
-
1) Choose Disable | Enable | List | Install | Update (press 'x' to exit)
|
|
102
|
-
2) Multi-select skills (space chọn · a chọn tất cả · i đảo)
|
|
103
|
-
'b' quay lại menu · 'x' thoát · Enter áp dụng
|
|
104
|
-
|
|
105
|
-
Install/Update fetch from the registry server (PAN_SKILL_REGISTRY_URL, default
|
|
106
|
-
http://localhost:4600) and track installed versions in .pan/skills-lock.json.
|
|
107
|
-
`);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function parseAiArgs(argv) {
|
|
111
|
-
const result = {
|
|
112
|
-
help: false,
|
|
113
|
-
list: false,
|
|
114
|
-
dryRun: false,
|
|
115
|
-
disable: [],
|
|
116
|
-
enable: [],
|
|
117
|
-
};
|
|
118
|
-
let mode = null;
|
|
119
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
120
|
-
const a = argv[i];
|
|
121
|
-
if (a === "-h" || a === "--help") {
|
|
122
|
-
result.help = true;
|
|
123
|
-
} else if (a === "--list") {
|
|
124
|
-
result.list = true;
|
|
125
|
-
} else if (a === "--dry-run") {
|
|
126
|
-
result.dryRun = true;
|
|
127
|
-
} else if (a === "--disable") {
|
|
128
|
-
mode = "disable";
|
|
129
|
-
} else if (a === "--enable") {
|
|
130
|
-
mode = "enable";
|
|
131
|
-
} else if (a.startsWith("-")) {
|
|
132
|
-
throw new Error(t("AI_UNKNOWN_FLAG__FLAG", { flag: a }));
|
|
133
|
-
} else if (mode === "disable") {
|
|
134
|
-
result.disable.push(a);
|
|
135
|
-
} else if (mode === "enable") {
|
|
136
|
-
result.enable.push(a);
|
|
137
|
-
} else {
|
|
138
|
-
throw new Error(t("AI_UNEXPECTED_ARG__ARG", { arg: a }));
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return result;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function listSkillDirs(skillsRoot) {
|
|
145
|
-
if (!fs.existsSync(skillsRoot) || !fs.statSync(skillsRoot).isDirectory()) {
|
|
146
|
-
return [];
|
|
147
|
-
}
|
|
148
|
-
return fs
|
|
149
|
-
.readdirSync(skillsRoot, { withFileTypes: true })
|
|
150
|
-
.filter((d) => d.isDirectory())
|
|
151
|
-
.map((d) => d.name)
|
|
152
|
-
.filter((name) => !name.startsWith("."));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function collectFilesForDir(skillDir) {
|
|
156
|
-
const files = [];
|
|
157
|
-
for (const base of DOC_BASES) {
|
|
158
|
-
const activePath = path.join(skillDir, base);
|
|
159
|
-
const disabledPath = path.join(skillDir, `${base}.disabled`);
|
|
160
|
-
const hasActive = fs.existsSync(activePath);
|
|
161
|
-
const hasDisabled = fs.existsSync(disabledPath);
|
|
162
|
-
if (!hasActive && !hasDisabled) continue;
|
|
163
|
-
files.push({
|
|
164
|
-
base,
|
|
165
|
-
activePath: hasActive ? activePath : null,
|
|
166
|
-
disabledPath: hasDisabled ? disabledPath : null,
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
return files;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function computeStatus(files) {
|
|
173
|
-
let activeCount = 0;
|
|
174
|
-
let disabledCount = 0;
|
|
175
|
-
for (const f of files) {
|
|
176
|
-
if (f.activePath) activeCount += 1;
|
|
177
|
-
if (f.disabledPath) disabledCount += 1;
|
|
178
|
-
}
|
|
179
|
-
if (activeCount > 0 && disabledCount > 0) return "mixed";
|
|
180
|
-
if (disabledCount > 0 && activeCount === 0) return "disabled";
|
|
181
|
-
return "active";
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function agentHint(skill) {
|
|
185
|
-
const parts = [];
|
|
186
|
-
for (const key of AGENT_KEYS) {
|
|
187
|
-
const entry = skill.agents[key];
|
|
188
|
-
if (!entry) continue;
|
|
189
|
-
const st = computeStatus(entry.files);
|
|
190
|
-
const short = st === "active" ? "on" : st === "disabled" ? "off" : "mix";
|
|
191
|
-
parts.push(`${key}:${short}`);
|
|
192
|
-
}
|
|
193
|
-
return parts.join(" ");
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* @returns {Array<{
|
|
198
|
-
* name: string,
|
|
199
|
-
* status: 'active'|'disabled'|'mixed',
|
|
200
|
-
* agents: Record<string, { dir: string, files: Array }>,
|
|
201
|
-
* files: Array<{ agent: string, base: string, activePath: string|null, disabledPath: string|null }>
|
|
202
|
-
* }>}
|
|
203
|
-
*/
|
|
204
|
-
function scanSkills(workspaceRoot) {
|
|
205
|
-
const byName = new Map();
|
|
206
|
-
|
|
207
|
-
for (const key of AGENT_KEYS) {
|
|
208
|
-
const skillsRoot = path.join(workspaceRoot, AGENT_DIRS[key], "skills");
|
|
209
|
-
for (const name of listSkillDirs(skillsRoot)) {
|
|
210
|
-
const skillDir = path.join(skillsRoot, name);
|
|
211
|
-
const files = collectFilesForDir(skillDir);
|
|
212
|
-
if (files.length === 0) continue;
|
|
213
|
-
|
|
214
|
-
if (!byName.has(name)) {
|
|
215
|
-
byName.set(name, {
|
|
216
|
-
name,
|
|
217
|
-
agents: {},
|
|
218
|
-
files: [],
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
const skill = byName.get(name);
|
|
222
|
-
skill.agents[key] = { dir: skillDir, files };
|
|
223
|
-
for (const f of files) {
|
|
224
|
-
skill.files.push({
|
|
225
|
-
agent: key,
|
|
226
|
-
base: f.base,
|
|
227
|
-
activePath: f.activePath,
|
|
228
|
-
disabledPath: f.disabledPath,
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const skills = [...byName.values()].map((skill) => {
|
|
235
|
-
skill.status = computeStatus(skill.files);
|
|
236
|
-
return skill;
|
|
237
|
-
});
|
|
238
|
-
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
239
|
-
return skills;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function printSkillList(skills) {
|
|
243
|
-
if (skills.length === 0) {
|
|
244
|
-
console.log(t("AI_NO_SKILLS"));
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
const nameWidth = Math.max(8, ...skills.map((s) => s.name.length));
|
|
248
|
-
const statusWidth = 8;
|
|
249
|
-
console.log(
|
|
250
|
-
`${"NAME".padEnd(nameWidth)} ${"STATUS".padEnd(statusWidth)} AGENTS`
|
|
251
|
-
);
|
|
252
|
-
for (const s of skills) {
|
|
253
|
-
const hint = agentHint(s);
|
|
254
|
-
console.log(
|
|
255
|
-
`${s.name.padEnd(nameWidth)} ${s.status.padEnd(statusWidth)} ${hint}`
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
console.log("");
|
|
259
|
-
console.log(t("AI_LIST_FOOTER__COUNT", { count: String(skills.length) }));
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* @returns {{ name: string, code: 'ok'|'noop'|'skip'|'error', lines: string[] }}
|
|
264
|
-
*/
|
|
265
|
-
function applySkillAction(skill, action, { dryRun = false } = {}) {
|
|
266
|
-
const lines = [];
|
|
267
|
-
let renamed = 0;
|
|
268
|
-
let skipped = 0;
|
|
269
|
-
|
|
270
|
-
for (const f of skill.files) {
|
|
271
|
-
if (action === "disable") {
|
|
272
|
-
if (!f.activePath) {
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
const dest = `${f.activePath}.disabled`;
|
|
276
|
-
if (fs.existsSync(dest)) {
|
|
277
|
-
skipped += 1;
|
|
278
|
-
lines.push(`! ${skill.name} [${f.agent}] ${f.base}: already has .disabled`);
|
|
279
|
-
continue;
|
|
280
|
-
}
|
|
281
|
-
if (dryRun) {
|
|
282
|
-
lines.push(`would rename ${f.activePath} → ${dest}`);
|
|
283
|
-
renamed += 1;
|
|
284
|
-
} else {
|
|
285
|
-
try {
|
|
286
|
-
fs.renameSync(f.activePath, dest);
|
|
287
|
-
lines.push(
|
|
288
|
-
`✓ ${path.relative(process.cwd(), f.activePath)} → ${path.basename(dest)}`
|
|
289
|
-
);
|
|
290
|
-
renamed += 1;
|
|
291
|
-
} catch (err) {
|
|
292
|
-
lines.push(`✗ ${skill.name} [${f.agent}] ${f.base}: ${err.message}`);
|
|
293
|
-
return { name: skill.name, code: "error", lines };
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
} else if (action === "enable") {
|
|
297
|
-
if (!f.disabledPath) {
|
|
298
|
-
continue;
|
|
299
|
-
}
|
|
300
|
-
const dest = f.disabledPath.replace(/\.disabled$/, "");
|
|
301
|
-
if (fs.existsSync(dest)) {
|
|
302
|
-
skipped += 1;
|
|
303
|
-
lines.push(`! ${skill.name} [${f.agent}] ${f.base}: active file exists`);
|
|
304
|
-
continue;
|
|
305
|
-
}
|
|
306
|
-
if (dryRun) {
|
|
307
|
-
lines.push(`would rename ${f.disabledPath} → ${dest}`);
|
|
308
|
-
renamed += 1;
|
|
309
|
-
} else {
|
|
310
|
-
try {
|
|
311
|
-
fs.renameSync(f.disabledPath, dest);
|
|
312
|
-
lines.push(
|
|
313
|
-
`✓ ${path.relative(process.cwd(), f.disabledPath)} → ${path.basename(dest)}`
|
|
314
|
-
);
|
|
315
|
-
renamed += 1;
|
|
316
|
-
} catch (err) {
|
|
317
|
-
lines.push(`✗ ${skill.name} [${f.agent}] ${f.base}: ${err.message}`);
|
|
318
|
-
return { name: skill.name, code: "error", lines };
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
let code = "ok";
|
|
325
|
-
if (renamed === 0 && skipped === 0) code = "noop";
|
|
326
|
-
else if (renamed === 0 && skipped > 0) code = "skip";
|
|
327
|
-
return { name: skill.name, code, lines };
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function skillsForAction(skills, action) {
|
|
331
|
-
if (action === "disable") {
|
|
332
|
-
return skills.filter((s) => s.status === "active" || s.status === "mixed");
|
|
333
|
-
}
|
|
334
|
-
return skills.filter((s) => s.status === "disabled" || s.status === "mixed");
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function findSkillsByNames(skills, names) {
|
|
338
|
-
const map = new Map(skills.map((s) => [s.name, s]));
|
|
339
|
-
const found = [];
|
|
340
|
-
const missing = [];
|
|
341
|
-
for (const raw of names) {
|
|
342
|
-
const name = String(raw || "").trim();
|
|
343
|
-
if (!name) continue;
|
|
344
|
-
if (!map.has(name)) {
|
|
345
|
-
missing.push(name);
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
found.push(map.get(name));
|
|
349
|
-
}
|
|
350
|
-
return { found, missing };
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function printApplyResults(results) {
|
|
354
|
-
for (const r of results) {
|
|
355
|
-
if (r.lines.length === 0) {
|
|
356
|
-
const icon = r.code === "noop" ? "=" : r.code === "skip" ? "!" : "✓";
|
|
357
|
-
console.log(`${icon} ${r.name}`);
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
for (const line of r.lines) {
|
|
361
|
-
console.log(line);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function countSkillStatuses(skills) {
|
|
367
|
-
let active = 0;
|
|
368
|
-
let disabled = 0;
|
|
369
|
-
let mixed = 0;
|
|
370
|
-
for (const s of skills) {
|
|
371
|
-
if (s.status === "active") active += 1;
|
|
372
|
-
else if (s.status === "disabled") disabled += 1;
|
|
373
|
-
else if (s.status === "mixed") mixed += 1;
|
|
374
|
-
}
|
|
375
|
-
return { active, disabled, mixed, total: skills.length };
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
async function promptAction(skills) {
|
|
379
|
-
const { active, disabled, mixed, total } = countSkillStatuses(skills);
|
|
380
|
-
const disableCount = active + mixed;
|
|
381
|
-
const enableCount = disabled + mixed;
|
|
382
|
-
const message = t("AI_SELECT_ACTION__ACTIVE_DISABLED_TOTAL", {
|
|
383
|
-
active: String(active),
|
|
384
|
-
disabled: String(disabled),
|
|
385
|
-
total: String(total),
|
|
386
|
-
});
|
|
387
|
-
// Không thêm item "Thoát" trong danh sách — đã có phím tắt `x`.
|
|
388
|
-
const choices = [
|
|
389
|
-
{
|
|
390
|
-
name: t("AI_CHOICE_DISABLE__COUNT", { count: String(disableCount) }),
|
|
391
|
-
value: "disable",
|
|
392
|
-
short: "disable",
|
|
393
|
-
disabled: disableCount === 0 ? t("AI_NONE") : false,
|
|
394
|
-
},
|
|
395
|
-
{
|
|
396
|
-
name: t("AI_CHOICE_ENABLE__COUNT", { count: String(enableCount) }),
|
|
397
|
-
value: "enable",
|
|
398
|
-
short: "enable",
|
|
399
|
-
disabled: enableCount === 0 ? t("AI_NONE") : false,
|
|
400
|
-
},
|
|
401
|
-
{
|
|
402
|
-
name: t("AI_CHOICE_LIST__COUNT", { count: String(total) }),
|
|
403
|
-
value: "list",
|
|
404
|
-
short: "list",
|
|
405
|
-
},
|
|
406
|
-
{
|
|
407
|
-
name: t("AI_CHOICE_INSTALL"),
|
|
408
|
-
value: "install",
|
|
409
|
-
short: "install",
|
|
410
|
-
},
|
|
411
|
-
{
|
|
412
|
-
name: t("AI_CHOICE_UPDATE"),
|
|
413
|
-
value: "update",
|
|
414
|
-
short: "update",
|
|
415
|
-
},
|
|
416
|
-
];
|
|
417
|
-
const hotkeys = [{ key: "x", label: t("EXIT_LABEL"), value: "exit" }];
|
|
418
|
-
|
|
419
|
-
try {
|
|
420
|
-
// Ưu tiên menu-prompt (phím x thoát ngay); thiếu @inquirer/core → select + item hotkey fallback.
|
|
421
|
-
try {
|
|
422
|
-
return await menuSelect({ message, choices, hotkeys, pageSize: 10 });
|
|
423
|
-
} catch (err) {
|
|
424
|
-
if (String(err && err.message) !== "no-core") throw err;
|
|
425
|
-
const inq = await loadInquirer();
|
|
426
|
-
if (!inq || typeof inq.select !== "function") {
|
|
427
|
-
throw new Error(t("AI_NEED_TTY_OR_FLAGS"));
|
|
428
|
-
}
|
|
429
|
-
// Fallback không có hotkey — vẫn cần một cách thoát → thêm item ẩn ở cuối.
|
|
430
|
-
return await inq.select({
|
|
431
|
-
message,
|
|
432
|
-
choices: [
|
|
433
|
-
...choices,
|
|
434
|
-
new inq.Separator(),
|
|
435
|
-
{ name: `x ${t("EXIT_LABEL")}`, value: "exit", short: "x" },
|
|
436
|
-
],
|
|
437
|
-
pageSize: 10,
|
|
438
|
-
loop: false,
|
|
439
|
-
theme: helpTheme(false),
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
} catch (err) {
|
|
443
|
-
if (isCancelError(err)) {
|
|
444
|
-
throw new Error(t("AI_CANCELLED"));
|
|
445
|
-
}
|
|
446
|
-
throw err;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* @returns {{ action: 'submit'|'exit'|'back', values: string[] }}
|
|
452
|
-
*/
|
|
453
|
-
async function promptSkillNames(candidates, action) {
|
|
454
|
-
if (candidates.length === 0) {
|
|
455
|
-
console.log(t("AI_EMPTY_FOR_ACTION__ACTION", { action }));
|
|
456
|
-
return { action: "submit", values: [] };
|
|
457
|
-
}
|
|
458
|
-
const choices = candidates.map((s) => ({
|
|
459
|
-
name:
|
|
460
|
-
s.status === "mixed"
|
|
461
|
-
? `${s.name} (mixed · ${agentHint(s)})`
|
|
462
|
-
: `${s.name} (${s.status})`,
|
|
463
|
-
value: s.name,
|
|
464
|
-
short: s.name,
|
|
465
|
-
}));
|
|
466
|
-
const hotkeys = [
|
|
467
|
-
{ key: "x", label: t("EXIT_LABEL"), value: "exit" },
|
|
468
|
-
{ key: "b", label: t("BACK_LABEL"), value: "back" },
|
|
469
|
-
];
|
|
470
|
-
|
|
471
|
-
try {
|
|
472
|
-
// Ưu tiên menu-prompt: hỗ trợ hotkey x/b để thoát / quay lại.
|
|
473
|
-
try {
|
|
474
|
-
const res = await menuCheckbox({
|
|
475
|
-
message: t("AI_SELECT_SKILLS"),
|
|
476
|
-
choices,
|
|
477
|
-
hotkeys,
|
|
478
|
-
pageSize: Math.min(Math.max(candidates.length, 8), 20),
|
|
479
|
-
});
|
|
480
|
-
// res = { action, values? }
|
|
481
|
-
if (res.action === "submit") return { action: "submit", values: res.values || [] };
|
|
482
|
-
return { action: res.action, values: [] };
|
|
483
|
-
} catch (err) {
|
|
484
|
-
if (String(err && err.message) !== "no-core") throw err;
|
|
485
|
-
const inq = await loadInquirer();
|
|
486
|
-
if (!inq || typeof inq.checkbox !== "function") {
|
|
487
|
-
throw new Error(t("AI_NEED_TTY_OR_FLAGS"));
|
|
488
|
-
}
|
|
489
|
-
// Fallback không có hotkey → chỉ Enter/Space; Ctrl+C thoát toàn cục.
|
|
490
|
-
const values = await inq.checkbox({
|
|
491
|
-
message: t("AI_SELECT_SKILLS"),
|
|
492
|
-
choices,
|
|
493
|
-
pageSize: Math.min(Math.max(candidates.length, 8), 20),
|
|
494
|
-
theme: helpTheme(true),
|
|
495
|
-
});
|
|
496
|
-
return { action: "submit", values };
|
|
497
|
-
}
|
|
498
|
-
} catch (err) {
|
|
499
|
-
if (isCancelError(err)) {
|
|
500
|
-
throw new Error(t("AI_CANCELLED"));
|
|
501
|
-
}
|
|
502
|
-
throw err;
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// Menu chọn agent đích (claude/cursor/codex) khi workspace chưa có sẵn thư mục nào.
|
|
507
|
-
async function promptAgentDirs() {
|
|
508
|
-
const choices = AGENT_KEYS.map((key) => ({
|
|
509
|
-
name: AGENT_DIRS[key],
|
|
510
|
-
value: key,
|
|
511
|
-
short: key,
|
|
512
|
-
}));
|
|
513
|
-
const hotkeys = [
|
|
514
|
-
{ key: "x", label: t("EXIT_LABEL"), value: "__exit__" },
|
|
515
|
-
{ key: "b", label: t("BACK_LABEL"), value: "__back__" },
|
|
516
|
-
];
|
|
517
|
-
const res = await menuCheckbox({
|
|
518
|
-
message: t("AI_SELECT_AGENT_DIRS"),
|
|
519
|
-
choices,
|
|
520
|
-
hotkeys,
|
|
521
|
-
pageSize: 10,
|
|
522
|
-
});
|
|
523
|
-
return res; // { action: 'submit', values } | { action: '__exit__' } | { action: '__back__' }
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
async function runUpdateFlow(workspace) {
|
|
527
|
-
const baseUrl = getRegistryBaseUrl();
|
|
528
|
-
const { items, lock, diff } = await withSpinner(t("AI_CHECKING_UPDATES"), async () => {
|
|
529
|
-
const items = await fetchRegistry(baseUrl);
|
|
530
|
-
const lock = loadLock(workspace.root);
|
|
531
|
-
const diff = diffRegistry(items, lock);
|
|
532
|
-
return { items, lock, diff };
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
if (diff.updatable.length === 0) {
|
|
536
|
-
console.log(t("AI_NO_UPDATES"));
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
const choices = [
|
|
541
|
-
{ name: t("AI_UPDATE_ALL__COUNT", { count: String(diff.updatable.length) }), value: "__all__" },
|
|
542
|
-
...diff.updatable.map((item) => ({
|
|
543
|
-
name: itemLabel(item, ` (${item.oldVersion} → ${item.version})`),
|
|
544
|
-
value: item.name,
|
|
545
|
-
short: item.name,
|
|
546
|
-
})),
|
|
547
|
-
];
|
|
548
|
-
const hotkeys = [
|
|
549
|
-
{ key: "x", label: t("EXIT_LABEL"), value: "exit" },
|
|
550
|
-
{ key: "b", label: t("BACK_LABEL"), value: "back" },
|
|
551
|
-
];
|
|
552
|
-
|
|
553
|
-
const res = await menuCheckbox({ message: t("AI_UPDATE_SELECT"), choices, hotkeys, pageSize: 15 });
|
|
554
|
-
if (res.action === "exit") {
|
|
555
|
-
console.log(t("EXITED"));
|
|
556
|
-
process.exit(0);
|
|
557
|
-
}
|
|
558
|
-
if (res.action === "back") return;
|
|
559
|
-
const values = res.values || [];
|
|
560
|
-
if (values.length === 0) {
|
|
561
|
-
console.log(t("AI_EMPTY_SELECTION"));
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
const toUpdate = values.includes("__all__")
|
|
566
|
-
? diff.updatable
|
|
567
|
-
: diff.updatable.filter((item) => values.includes(item.name));
|
|
568
|
-
|
|
569
|
-
let count = 0;
|
|
570
|
-
for (const item of toUpdate) {
|
|
571
|
-
const entry = lock.installed[item.name] || {};
|
|
572
|
-
const agents = entry.agents && entry.agents.length ? entry.agents : detectAgentDirs(workspace.root);
|
|
573
|
-
process.stdout.write(` ↻ ${item.name} (${entry.version || "?"} → ${item.version}) ... `);
|
|
574
|
-
try {
|
|
575
|
-
for (const agentKey of agents) {
|
|
576
|
-
const destDir = path.join(workspace.root, AGENT_DIRS[agentKey], "skills");
|
|
577
|
-
await downloadAndExtract(baseUrl, item, destDir);
|
|
578
|
-
}
|
|
579
|
-
lock.installed[item.name] = { version: item.version, type: item.type, agents };
|
|
580
|
-
saveLock(workspace.root, lock);
|
|
581
|
-
console.log("ok");
|
|
582
|
-
count += 1;
|
|
583
|
-
} catch (err) {
|
|
584
|
-
console.log(`✗ ${err.message}`);
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
console.log(t("AI_UPDATE_DONE__COUNT", { count: String(count) }));
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
async function runInstallFlow(workspace) {
|
|
591
|
-
const baseUrl = getRegistryBaseUrl();
|
|
592
|
-
const { items, lock, diff } = await withSpinner(t("AI_CHECKING_UPDATES"), async () => {
|
|
593
|
-
const items = await fetchRegistry(baseUrl);
|
|
594
|
-
const lock = loadLock(workspace.root);
|
|
595
|
-
const diff = diffRegistry(items, lock);
|
|
596
|
-
return { items, lock, diff };
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
if (diff.notInstalled.length === 0 && diff.updatable.length === 0) {
|
|
600
|
-
console.log(t("AI_NOTHING_TO_INSTALL"));
|
|
601
|
-
return;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
const choices = [];
|
|
605
|
-
if (diff.updatable.length > 0) {
|
|
606
|
-
choices.push({
|
|
607
|
-
name: t("AI_UPDATE_AVAILABLE__COUNT", { count: String(diff.updatable.length) }),
|
|
608
|
-
value: "__update__",
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
for (const item of diff.notInstalled) {
|
|
612
|
-
choices.push({
|
|
613
|
-
name: itemLabel(item, ` (v${item.version})`),
|
|
614
|
-
value: item.name,
|
|
615
|
-
short: item.name,
|
|
616
|
-
description: truncateDescription(item.description),
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
const hotkeys = [
|
|
620
|
-
{ key: "x", label: t("EXIT_LABEL"), value: "exit" },
|
|
621
|
-
{ key: "b", label: t("BACK_LABEL"), value: "back" },
|
|
622
|
-
];
|
|
623
|
-
|
|
624
|
-
const res = await menuCheckbox({ message: t("AI_INSTALL_SELECT"), choices, hotkeys, pageSize: 15 });
|
|
625
|
-
if (res.action === "exit") {
|
|
626
|
-
console.log(t("EXITED"));
|
|
627
|
-
process.exit(0);
|
|
628
|
-
}
|
|
629
|
-
if (res.action === "back") return;
|
|
630
|
-
|
|
631
|
-
const values = res.values || [];
|
|
632
|
-
const wantsUpdate = values.includes("__update__");
|
|
633
|
-
const toInstallNames = values.filter((v) => v !== "__update__");
|
|
634
|
-
const toInstall = diff.notInstalled.filter((item) => toInstallNames.includes(item.name));
|
|
635
|
-
|
|
636
|
-
if (toInstall.length === 0 && !wantsUpdate) {
|
|
637
|
-
console.log(t("AI_EMPTY_SELECTION"));
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
if (toInstall.length > 0) {
|
|
642
|
-
let agentDirs = detectAgentDirs(workspace.root);
|
|
643
|
-
if (agentDirs.length > 0) {
|
|
644
|
-
console.log(t("AI_INSTALL_USING_AGENTS__AGENTS", {
|
|
645
|
-
agents: agentDirs.map((k) => AGENT_DIRS[k]).join(", "),
|
|
646
|
-
}));
|
|
647
|
-
} else {
|
|
648
|
-
const agentRes = await promptAgentDirs();
|
|
649
|
-
if (agentRes.action === "__exit__") {
|
|
650
|
-
console.log(t("EXITED"));
|
|
651
|
-
process.exit(0);
|
|
652
|
-
}
|
|
653
|
-
if (agentRes.action === "__back__" || !agentRes.values || agentRes.values.length === 0) {
|
|
654
|
-
console.log(t("AI_EMPTY_SELECTION"));
|
|
655
|
-
agentDirs = [];
|
|
656
|
-
} else {
|
|
657
|
-
agentDirs = agentRes.values;
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
if (agentDirs.length > 0) {
|
|
662
|
-
let count = 0;
|
|
663
|
-
for (const item of toInstall) {
|
|
664
|
-
process.stdout.write(` + ${item.name} (v${item.version}) ... `);
|
|
665
|
-
try {
|
|
666
|
-
for (const agentKey of agentDirs) {
|
|
667
|
-
const destDir = path.join(workspace.root, AGENT_DIRS[agentKey], "skills");
|
|
668
|
-
await downloadAndExtract(baseUrl, item, destDir);
|
|
669
|
-
}
|
|
670
|
-
lock.installed[item.name] = { version: item.version, type: item.type, agents: agentDirs };
|
|
671
|
-
saveLock(workspace.root, lock);
|
|
672
|
-
console.log("ok");
|
|
673
|
-
count += 1;
|
|
674
|
-
} catch (err) {
|
|
675
|
-
console.log(`✗ ${err.message}`);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
console.log(t("AI_INSTALL_DONE__COUNT", { count: String(count) }));
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
if (wantsUpdate) {
|
|
683
|
-
await runUpdateFlow(workspace);
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// Không có .project.yaml: hỏi tạo dự án mới ngay tại cwd ("Không" mặc định đứng trước "Có").
|
|
688
|
-
// @returns workspace mới (đã tạo .project.yaml) hoặc null nếu user chọn "no" / không TTY.
|
|
689
|
-
async function promptCreateWorkspaceHere() {
|
|
690
|
-
if (!process.stdin.isTTY) return null;
|
|
691
|
-
const choices = [
|
|
692
|
-
{ name: t("NO_LABEL"), value: "no" },
|
|
693
|
-
{ name: t("YES_LABEL"), value: "yes" },
|
|
694
|
-
];
|
|
695
|
-
let answer;
|
|
696
|
-
try {
|
|
697
|
-
answer = await menuSelect({ message: t("AI_NO_PROJECT_ASK_CREATE"), choices, pageSize: 5 });
|
|
698
|
-
} catch (err) {
|
|
699
|
-
if (String(err && err.message) !== "no-core") throw err;
|
|
700
|
-
const inq = await loadInquirer();
|
|
701
|
-
if (!inq || typeof inq.select !== "function") return null;
|
|
702
|
-
answer = await inq.select({ message: t("AI_NO_PROJECT_ASK_CREATE"), choices, theme: helpTheme(false) });
|
|
703
|
-
}
|
|
704
|
-
if (answer !== "yes") return null;
|
|
705
|
-
|
|
706
|
-
function gitConfigValue(key) {
|
|
707
|
-
try {
|
|
708
|
-
const v = execFileSync("git", ["config", key], { encoding: "utf8" }).trim();
|
|
709
|
-
return v || "YourName";
|
|
710
|
-
} catch {
|
|
711
|
-
return "YourName";
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
const name = gitConfigValue("user.name");
|
|
715
|
-
const email = gitConfigValue("user.email");
|
|
716
|
-
const managedBy = `${name} - ${email}`;
|
|
717
|
-
const projectName = path.basename(process.cwd());
|
|
718
|
-
const content = `project: ${projectName}\nlanguage: vi\nmanaged_by: ${managedBy}\n`;
|
|
719
|
-
fs.writeFileSync(path.join(process.cwd(), ".project.yaml"), content, "utf8");
|
|
720
|
-
console.log(t("AI_PROJECT_CREATED__PROJECT_MANAGEDBY", { project: projectName, managedBy }));
|
|
721
|
-
return findNearestWorkspace();
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
async function runAiSkills(argv) {
|
|
725
|
-
const args = parseAiArgs(argv);
|
|
726
|
-
if (args.help) {
|
|
727
|
-
printAiHelp();
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
if (args.disable.length && args.enable.length) {
|
|
732
|
-
throw new Error(t("AI_DISABLE_ENABLE_MUTUAL"));
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
let workspace = findNearestWorkspace();
|
|
736
|
-
if (!workspace) {
|
|
737
|
-
workspace = await promptCreateWorkspaceHere();
|
|
738
|
-
if (!workspace) {
|
|
739
|
-
throw new Error(t("AI_NO_WORKSPACE"));
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
let skills = scanSkills(workspace.root);
|
|
744
|
-
|
|
745
|
-
const runNamed = (action, names) => {
|
|
746
|
-
const { found, missing } = findSkillsByNames(skills, names);
|
|
747
|
-
if (missing.length) {
|
|
748
|
-
throw new Error(
|
|
749
|
-
t("AI_UNKNOWN_SKILL__NAME", { name: missing.join(", ") })
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
const results = found.map((s) =>
|
|
753
|
-
applySkillAction(s, action, { dryRun: args.dryRun })
|
|
754
|
-
);
|
|
755
|
-
printApplyResults(results);
|
|
756
|
-
};
|
|
757
|
-
|
|
758
|
-
if (args.list && !args.disable.length && !args.enable.length) {
|
|
759
|
-
printSkillList(skills);
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
if (args.disable.length) {
|
|
764
|
-
runNamed("disable", args.disable);
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
if (args.enable.length) {
|
|
768
|
-
runNamed("enable", args.enable);
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
if (!process.stdin.isTTY) {
|
|
773
|
-
printAiHelp();
|
|
774
|
-
throw new Error(t("AI_NEED_TTY_OR_FLAGS"));
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// Loop: cho phép `b` từ bước chọn skill quay lại menu action.
|
|
778
|
-
while (true) {
|
|
779
|
-
const action = await promptAction(skills);
|
|
780
|
-
if (action === "exit") {
|
|
781
|
-
console.log(t("EXITED"));
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
if (action === "list") {
|
|
785
|
-
printSkillList(skills);
|
|
786
|
-
continue; // quay lại menu action (KHÔNG return — 'list' xong vẫn ở trong pan ai)
|
|
787
|
-
}
|
|
788
|
-
if (action === "install") {
|
|
789
|
-
await runInstallFlow(workspace);
|
|
790
|
-
continue;
|
|
791
|
-
}
|
|
792
|
-
if (action === "update") {
|
|
793
|
-
await runUpdateFlow(workspace);
|
|
794
|
-
continue;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
skills = scanSkills(workspace.root);
|
|
798
|
-
const candidates = skillsForAction(skills, action);
|
|
799
|
-
const res = await promptSkillNames(candidates, action);
|
|
800
|
-
if (res.action === "exit") {
|
|
801
|
-
console.log(t("EXITED"));
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
|
-
if (res.action === "back") {
|
|
805
|
-
continue; // quay lại menu action
|
|
806
|
-
}
|
|
807
|
-
if (!res.values.length) {
|
|
808
|
-
console.log(t("AI_EMPTY_SELECTION"));
|
|
809
|
-
continue; // quay lại menu action
|
|
810
|
-
}
|
|
811
|
-
runNamed(action, res.values);
|
|
812
|
-
skills = scanSkills(workspace.root);
|
|
813
|
-
continue; // quay lại menu action — làm nhiều việc trong 1 lần chạy 'pan ai', 'x' để thoát
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
module.exports = {
|
|
818
|
-
scanSkills,
|
|
819
|
-
applySkillAction,
|
|
820
|
-
printSkillList,
|
|
821
|
-
parseAiArgs,
|
|
822
|
-
runAiSkills,
|
|
823
|
-
printAiHelp,
|
|
824
|
-
AGENT_KEYS,
|
|
825
|
-
AGENT_DIRS,
|
|
826
|
-
detectAgentDirs,
|
|
827
|
-
runInstallFlow,
|
|
828
|
-
runUpdateFlow,
|
|
829
|
-
};
|