avenic 1.0.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 +234 -0
- package/package.json +30 -0
- package/scripts/skills.mjs +13 -0
- package/scripts/watchdog.mjs +50 -0
- package/src/cli/dispatcher.mjs +439 -0
- package/src/cli/self-update.mjs +27 -0
- package/src/cli/skills-cli.mjs +1060 -0
- package/src/cli/watchdog.mjs +30 -0
- package/vendor/core-src/index.mjs +143 -0
- package/vendor/core-src/runtime/adapters/claude.mjs +81 -0
- package/vendor/core-src/runtime/adapters/codex.mjs +142 -0
- package/vendor/core-src/runtime/adapters/index.mjs +9 -0
- package/vendor/core-src/runtime/adapters/opencode.mjs +76 -0
- package/vendor/core-src/runtime/agents.mjs +39 -0
- package/vendor/core-src/runtime/config.mjs +227 -0
- package/vendor/core-src/runtime/gitignore.mjs +69 -0
- package/vendor/core-src/runtime/process.mjs +41 -0
- package/vendor/core-src/runtime/project-root.mjs +42 -0
- package/vendor/core-src/runtime/sessions.mjs +312 -0
- package/vendor/core-src/skills/catalog.mjs +130 -0
- package/vendor/core-src/skills/direct.mjs +209 -0
- package/vendor/core-src/skills/git.mjs +93 -0
- package/vendor/core-src/skills/ids.mjs +40 -0
- package/vendor/core-src/skills/install.mjs +357 -0
- package/vendor/core-src/skills/packs.mjs +256 -0
- package/vendor/core-src/skills/paths.mjs +92 -0
- package/vendor/core-src/skills/sources.mjs +246 -0
- package/vendor/core-src/skills/ui.mjs +21 -0
- package/vendor/core-src/skills/vendor.mjs +57 -0
- package/vendor/core-src/util/fail.mjs +3 -0
- package/vendor/core-src/util/fs.mjs +14 -0
- package/vendor/core-src/util/json.mjs +14 -0
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import {
|
|
8
|
+
AGENTS,
|
|
9
|
+
addDirectSkills,
|
|
10
|
+
addSkillsToPacks,
|
|
11
|
+
assertSafeId,
|
|
12
|
+
assertSafeSkillName,
|
|
13
|
+
buildCatalog,
|
|
14
|
+
catalogDisplayName,
|
|
15
|
+
cloneHead,
|
|
16
|
+
cloneRevision,
|
|
17
|
+
createInstallContext,
|
|
18
|
+
createTempDirectory,
|
|
19
|
+
deriveSourceId,
|
|
20
|
+
discoverSourceSkills,
|
|
21
|
+
ensureCatalog,
|
|
22
|
+
fail,
|
|
23
|
+
findSource,
|
|
24
|
+
installCopies,
|
|
25
|
+
installPacks,
|
|
26
|
+
installedPackIds,
|
|
27
|
+
isCatalogDirectory,
|
|
28
|
+
isInside,
|
|
29
|
+
loadDefaultCatalogSpec,
|
|
30
|
+
loadKnownCatalogs,
|
|
31
|
+
loadPacks,
|
|
32
|
+
loadSources,
|
|
33
|
+
parsePackArguments,
|
|
34
|
+
previousManagedState,
|
|
35
|
+
printTree,
|
|
36
|
+
pruneCatalogSkills,
|
|
37
|
+
readDirectState,
|
|
38
|
+
readJson,
|
|
39
|
+
registerCatalog,
|
|
40
|
+
registerKnownCatalog,
|
|
41
|
+
registerSource,
|
|
42
|
+
remoteHead,
|
|
43
|
+
removeAllManagedSkills,
|
|
44
|
+
removeDirectSkills,
|
|
45
|
+
removeEmptyDirectory,
|
|
46
|
+
removeExternalSkills,
|
|
47
|
+
removeInstallationFiles,
|
|
48
|
+
removeSkillDirectories,
|
|
49
|
+
removeTempDirectory,
|
|
50
|
+
replaceStagedFiles,
|
|
51
|
+
resolveInstallSource,
|
|
52
|
+
resolvePack,
|
|
53
|
+
resolvePacks,
|
|
54
|
+
saveSources,
|
|
55
|
+
setDefaultCatalogSpec,
|
|
56
|
+
skillCoveredByPacks,
|
|
57
|
+
skillsInstallationStatus,
|
|
58
|
+
stageSource,
|
|
59
|
+
stateRoot,
|
|
60
|
+
uninstallPacks,
|
|
61
|
+
writeInstallMetadata,
|
|
62
|
+
writeJson,
|
|
63
|
+
} from "#core";
|
|
64
|
+
import { updateAvenic } from "./self-update.mjs";
|
|
65
|
+
|
|
66
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
67
|
+
|
|
68
|
+
function takeOption(argumentsList, option) {
|
|
69
|
+
const index = argumentsList.indexOf(option);
|
|
70
|
+
if (index === -1) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const value = argumentsList[index + 1];
|
|
74
|
+
if (!value || value.startsWith("--")) {
|
|
75
|
+
fail(`${option} requires a value`);
|
|
76
|
+
}
|
|
77
|
+
argumentsList.splice(index, 2);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseScopeArguments(argumentsList) {
|
|
82
|
+
const globalFlags = new Set(["-g", "--global"]);
|
|
83
|
+
const global = argumentsList.some((argument) => globalFlags.has(argument));
|
|
84
|
+
return {
|
|
85
|
+
argumentsList: argumentsList.filter((argument) => !globalFlags.has(argument)),
|
|
86
|
+
global,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function commandInstall(explicitPacks = [], options = {}) {
|
|
91
|
+
const io = options.io ?? console;
|
|
92
|
+
const context = createInstallContext(options.global ?? false, options);
|
|
93
|
+
const { resolvedPacks } = await installPacks(context, explicitPacks, {
|
|
94
|
+
io,
|
|
95
|
+
onPlan: (resolved) => {
|
|
96
|
+
printTree(resolved.groups, "Skill Installation Plan", [
|
|
97
|
+
`Packs: ${resolved.packs.map((pack) => pack.name).join(" + ")}`,
|
|
98
|
+
`Scope: ${context.label}`,
|
|
99
|
+
`Root: ${context.root}`,
|
|
100
|
+
`Duplicate selections removed: ${resolved.duplicateSelections}`,
|
|
101
|
+
], io);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
io.log(`\nInstallation complete: ${resolvedPacks.names.length} unique Skills`);
|
|
105
|
+
io.log(`Config: ${context.configFile}`);
|
|
106
|
+
io.log(`Lock: ${context.lockFile}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function commandAddDirect(argumentsList, options = {}) {
|
|
110
|
+
const io = options.io ?? console;
|
|
111
|
+
const context = createInstallContext(options.global ?? false, options);
|
|
112
|
+
if (!options.global && isCatalogDirectory(options.cwd ?? process.cwd())) {
|
|
113
|
+
fail("Run installation from a work project, not from the Avenic catalog");
|
|
114
|
+
}
|
|
115
|
+
const [sourceReference, ...skillNames] = argumentsList;
|
|
116
|
+
if (!sourceReference) {
|
|
117
|
+
fail("Usage: avenic skills add <owner/repo> [skill...] [-g]");
|
|
118
|
+
}
|
|
119
|
+
const unknownOption = argumentsList.find((argument) => argument.startsWith("-"));
|
|
120
|
+
if (unknownOption) {
|
|
121
|
+
fail(`Unknown option: ${unknownOption}`);
|
|
122
|
+
}
|
|
123
|
+
const result = await addDirectSkills(context, sourceReference, skillNames, { io });
|
|
124
|
+
if (result.alreadyInstalled) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
io.log(`\nInstalled direct Skills: ${result.names.join(", ")}`);
|
|
128
|
+
io.log(`Source: ${result.sourceId} @ ${result.revision.slice(0, 8)}`);
|
|
129
|
+
io.log(`Config: ${context.configFile}`);
|
|
130
|
+
io.log(`Lock: ${context.lockFile}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function commandUninstall(packArguments, options = {}) {
|
|
134
|
+
const io = options.io ?? console;
|
|
135
|
+
const context = createInstallContext(options.global ?? false, options);
|
|
136
|
+
const current = await installedPackIds(context);
|
|
137
|
+
if (!current) {
|
|
138
|
+
io.log(`No managed ${context.label.toLowerCase()} Skills installation found`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (packArguments.length === 0) {
|
|
143
|
+
const managed = await previousManagedState(context);
|
|
144
|
+
const directState = await readDirectState(context);
|
|
145
|
+
const directNames = directState.directSources.flatMap((source) => source.skills);
|
|
146
|
+
if (directNames.length > 0) {
|
|
147
|
+
await removeDirectSkills(context, directNames);
|
|
148
|
+
await removeSkillDirectories(context, directNames, io);
|
|
149
|
+
}
|
|
150
|
+
const total = await removeAllManagedSkills(context, managed, io);
|
|
151
|
+
await removeInstallationFiles(context);
|
|
152
|
+
if (options.global) {
|
|
153
|
+
await removeEmptyDirectory(stateRoot(options.environment));
|
|
154
|
+
}
|
|
155
|
+
io.log(`Uninstalled all managed ${context.label.toLowerCase()} Skills: ${total}`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = await uninstallPacks(context, packArguments, {
|
|
160
|
+
io,
|
|
161
|
+
onPlan: (resolved, removedPacks) => {
|
|
162
|
+
printTree(resolved.groups, "Skill Uninstall Plan", [
|
|
163
|
+
`Remove Packs: ${removedPacks.join(" + ")}`,
|
|
164
|
+
`Keep Packs: ${resolved.packs.map((pack) => pack.name).join(" + ")}`,
|
|
165
|
+
`Scope: ${context.label}`,
|
|
166
|
+
`Root: ${context.root}`,
|
|
167
|
+
], io);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
if (result.current === null) return;
|
|
171
|
+
if (result.skippedCommon) {
|
|
172
|
+
io.log("Skipped: common is always included");
|
|
173
|
+
}
|
|
174
|
+
if (result.absent.length > 0) {
|
|
175
|
+
io.log(`Already absent: ${result.absent.join(", ")}`);
|
|
176
|
+
}
|
|
177
|
+
if (!result.changed) {
|
|
178
|
+
io.log("No Pack changes");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
io.log(`\nUninstall complete: ${result.removed.join(", ")}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function commandUninstallSkill(skillArguments, options = {}) {
|
|
185
|
+
const io = options.io ?? console;
|
|
186
|
+
if (skillArguments.length === 0) {
|
|
187
|
+
fail("Usage: remove <skill...> [-g]");
|
|
188
|
+
}
|
|
189
|
+
const skillNames = [...new Set(skillArguments)];
|
|
190
|
+
const context = createInstallContext(options.global ?? false, options);
|
|
191
|
+
const result = await removeExternalSkills(context, skillNames, { io });
|
|
192
|
+
io.log(
|
|
193
|
+
result.removedDirectories > 0 || result.directRemoved.length > 0
|
|
194
|
+
? `Removed external Skills: ${skillNames.join(", ")}`
|
|
195
|
+
: `Already absent: ${skillNames.join(", ")}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function commandTree(packArguments, options = {}) {
|
|
200
|
+
const io = options.io ?? console;
|
|
201
|
+
const catalogInfo = await resolveInstallSource(options);
|
|
202
|
+
const sourceConfig = await loadSources(catalogInfo.catalogRoot);
|
|
203
|
+
const catalog = await buildCatalog(sourceConfig, path.join(catalogInfo.catalogRoot, "skills"));
|
|
204
|
+
if (packArguments.length === 0) {
|
|
205
|
+
printTree(catalog.groups, "All catalog Skills", [], io);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const packs = await loadPacks(catalogInfo.catalogRoot);
|
|
209
|
+
const resolvedPacks = resolvePacks(
|
|
210
|
+
catalog,
|
|
211
|
+
sourceConfig,
|
|
212
|
+
packs,
|
|
213
|
+
parsePackArguments(packArguments),
|
|
214
|
+
);
|
|
215
|
+
printTree(resolvedPacks.groups, "Pack Preview", [
|
|
216
|
+
`Packs: ${resolvedPacks.packs.map((pack) => pack.name).join(" + ")}`,
|
|
217
|
+
`Duplicate selections removed: ${resolvedPacks.duplicateSelections}`,
|
|
218
|
+
], io);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function commandPacks(options = {}) {
|
|
222
|
+
const io = options.io ?? console;
|
|
223
|
+
const catalogInfo = await resolveInstallSource(options);
|
|
224
|
+
const sourceConfig = await loadSources(catalogInfo.catalogRoot);
|
|
225
|
+
const catalog = await buildCatalog(sourceConfig, path.join(catalogInfo.catalogRoot, "skills"));
|
|
226
|
+
const packs = await loadPacks(catalogInfo.catalogRoot);
|
|
227
|
+
io.log("\nAvailable Packs\n");
|
|
228
|
+
for (const pack of packs.values()) {
|
|
229
|
+
const own = resolvePack(catalog, sourceConfig, pack);
|
|
230
|
+
const effective = resolvePacks(catalog, sourceConfig, packs, [pack.id]);
|
|
231
|
+
const count = pack.id === "common" ? `${own.names.length}` : `${own.names.length} + common = ${effective.names.length}`;
|
|
232
|
+
io.log(`- ${pack.id.padEnd(12)} ${count.padStart(18)} Skills ${pack.name}`);
|
|
233
|
+
if (pack.description) {
|
|
234
|
+
io.log(` ${pack.description}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
io.log();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function commandStatus(options = {}) {
|
|
241
|
+
const io = options.io ?? console;
|
|
242
|
+
const context = createInstallContext(options.global ?? false, options);
|
|
243
|
+
const status = await skillsInstallationStatus(context);
|
|
244
|
+
if (!status) {
|
|
245
|
+
fail(`${context.label} scope has no lock file; install a Pack first`);
|
|
246
|
+
}
|
|
247
|
+
printTree(status.groups, `Current ${context.label} Skills`, [
|
|
248
|
+
`Packs: ${status.packs.map((pack) => pack.name ?? pack.id ?? pack).join(" + ")}`,
|
|
249
|
+
], io);
|
|
250
|
+
for (const targetConfig of status.targets) {
|
|
251
|
+
io.log(`${targetConfig.complete ? "✓" : "!"} ${targetConfig.label}: ${targetConfig.present}/${targetConfig.total}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function commandDoctor(catalogRoot, io = console) {
|
|
256
|
+
const skillsRoot = path.join(catalogRoot, "skills");
|
|
257
|
+
const sourceConfig = await loadSources(catalogRoot);
|
|
258
|
+
const catalog = await buildCatalog(sourceConfig, skillsRoot);
|
|
259
|
+
const configuredSources = new Set(sourceConfig.sources.map((source) => source.id));
|
|
260
|
+
const topLevelEntries = await readdir(skillsRoot, { withFileTypes: true });
|
|
261
|
+
for (const entry of topLevelEntries.filter((item) => item.isDirectory())) {
|
|
262
|
+
if (!configuredSources.has(entry.name)) {
|
|
263
|
+
fail(`Unregistered source directory under skills/: ${entry.name}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const packs = await loadPacks(catalogRoot);
|
|
267
|
+
for (const pack of packs.values()) {
|
|
268
|
+
resolvePack(catalog, sourceConfig, pack);
|
|
269
|
+
}
|
|
270
|
+
for (const source of sourceConfig.sources) {
|
|
271
|
+
for (const skillName of Object.keys(source.skillPaths ?? {})) {
|
|
272
|
+
const skill = catalog.byName.get(skillName);
|
|
273
|
+
if (!skill || skill.source.id !== source.id) {
|
|
274
|
+
fail(`Unused Skill path mapping: ${source.id} -> ${skillName}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
io.log(
|
|
279
|
+
`OK: ${catalog.byName.size} Skills, ${sourceConfig.sources.length} sources, ${packs.size} Packs`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function commandUpdate(argumentsList, catalogRoot, io = console) {
|
|
284
|
+
const checkOnly = argumentsList.includes("--check");
|
|
285
|
+
const remainingArguments = argumentsList.filter((argument) => argument !== "--check");
|
|
286
|
+
const unknownOption = remainingArguments.find((argument) => argument.startsWith("-"));
|
|
287
|
+
if (unknownOption) {
|
|
288
|
+
fail(`Unknown option: ${unknownOption}`);
|
|
289
|
+
}
|
|
290
|
+
if (remainingArguments.length > 1) {
|
|
291
|
+
fail("Usage: update [source] [--check]");
|
|
292
|
+
}
|
|
293
|
+
const [target] = remainingArguments;
|
|
294
|
+
const sourceConfig = await loadSources(catalogRoot);
|
|
295
|
+
const catalog = await buildCatalog(sourceConfig, path.join(catalogRoot, "skills"));
|
|
296
|
+
const sources = target
|
|
297
|
+
? sourceConfig.sources.filter((source) => source.id === target)
|
|
298
|
+
: sourceConfig.sources;
|
|
299
|
+
if (sources.length === 0) {
|
|
300
|
+
fail(`Unknown source: ${target}`);
|
|
301
|
+
}
|
|
302
|
+
if (checkOnly) {
|
|
303
|
+
for (const source of sources) {
|
|
304
|
+
const latest = remoteHead(source);
|
|
305
|
+
const status = latest === source.revision ? "up to date" : "update available";
|
|
306
|
+
io.log(`${source.id}: ${status} ${source.revision.slice(0, 8)} -> ${latest.slice(0, 8)}`);
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const tempDirectory = await createTempDirectory(catalogRoot);
|
|
312
|
+
const stageDirectory = path.join(tempDirectory, "stage");
|
|
313
|
+
const revisions = new Map();
|
|
314
|
+
try {
|
|
315
|
+
for (const source of sources) {
|
|
316
|
+
io.log(`\nFetching upstream: ${source.name}`);
|
|
317
|
+
const cloneDirectory = path.join(tempDirectory, "clone", source.id);
|
|
318
|
+
const revision = await cloneHead(source, cloneDirectory);
|
|
319
|
+
const group = catalog.groups.find((item) => item.source.id === source.id);
|
|
320
|
+
await stageSource(
|
|
321
|
+
source,
|
|
322
|
+
cloneDirectory,
|
|
323
|
+
stageDirectory,
|
|
324
|
+
group.skills.map((skill) => skill.name),
|
|
325
|
+
);
|
|
326
|
+
revisions.set(source.id, revision);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const replacements = [];
|
|
330
|
+
for (const source of sources) {
|
|
331
|
+
const group = catalog.groups.find((item) => item.source.id === source.id);
|
|
332
|
+
for (const skill of group.skills) {
|
|
333
|
+
const relativePath = path.join("skills", source.id, skill.name);
|
|
334
|
+
replacements.push({
|
|
335
|
+
relativePath,
|
|
336
|
+
staged: path.join(stageDirectory, source.id, skill.name),
|
|
337
|
+
target: path.join(catalogRoot, relativePath),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
if (source.licenseFile) {
|
|
341
|
+
replacements.push({
|
|
342
|
+
relativePath: source.licenseFile,
|
|
343
|
+
staged: path.join(stageDirectory, source.licenseFile),
|
|
344
|
+
target: path.join(catalogRoot, source.licenseFile),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
await replaceStagedFiles(replacements, tempDirectory);
|
|
349
|
+
for (const source of sources) {
|
|
350
|
+
const previous = source.revision;
|
|
351
|
+
source.revision = revisions.get(source.id);
|
|
352
|
+
io.log(`${source.id}: ${previous.slice(0, 8)} -> ${source.revision.slice(0, 8)}`);
|
|
353
|
+
}
|
|
354
|
+
await saveSources(catalogRoot, sourceConfig);
|
|
355
|
+
io.log("\nUpdate complete. Run doctor, review git diff, test, then commit.\n");
|
|
356
|
+
} finally {
|
|
357
|
+
await removeTempDirectory(tempDirectory);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function commandAdd(argumentsList, catalogRoot, io = console) {
|
|
362
|
+
const skillsRoot = path.join(catalogRoot, "skills");
|
|
363
|
+
const packsRoot = path.join(catalogRoot, "packs");
|
|
364
|
+
const sourcesFile = path.join(catalogRoot, "sources.lock.json");
|
|
365
|
+
const packsValue = takeOption(argumentsList, "--pack");
|
|
366
|
+
const packIds = packsValue
|
|
367
|
+
? packsValue.split(",").map((value) => value.trim()).filter(Boolean)
|
|
368
|
+
: ["common"];
|
|
369
|
+
const [sourceReference, ...requestedSkillNames] = argumentsList;
|
|
370
|
+
const discoverAll = requestedSkillNames.length === 0;
|
|
371
|
+
if (!sourceReference) {
|
|
372
|
+
fail("Usage: skill-add <source-id|owner/repo> [skill...] [--pack <pack,pack>]");
|
|
373
|
+
}
|
|
374
|
+
const unknownOption = argumentsList.find((argument) => argument.startsWith("-"));
|
|
375
|
+
if (unknownOption) {
|
|
376
|
+
fail(`Unknown option: ${unknownOption}`);
|
|
377
|
+
}
|
|
378
|
+
requestedSkillNames.forEach(assertSafeSkillName);
|
|
379
|
+
const packs = await loadPacks(catalogRoot);
|
|
380
|
+
for (const packId of packIds) {
|
|
381
|
+
if (!packs.has(packId)) {
|
|
382
|
+
fail(`Unknown Pack: ${packId}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// A fresh catalog starts with zero registered sources; loadSources rejects
|
|
387
|
+
// that, so the first source registers against an empty config instead.
|
|
388
|
+
const lockData = await readJson(sourcesFile);
|
|
389
|
+
const sourceConfig =
|
|
390
|
+
Array.isArray(lockData.sources) && lockData.sources.length > 0
|
|
391
|
+
? await loadSources(catalogRoot)
|
|
392
|
+
: { schemaVersion: lockData.schemaVersion ?? 1, sources: [] };
|
|
393
|
+
const sourcesFileBefore = await readFile(sourcesFile, "utf8");
|
|
394
|
+
const packFilesBefore = new Map(
|
|
395
|
+
await Promise.all(
|
|
396
|
+
packIds.map(async (packId) => [packId, await readFile(path.join(packsRoot, `${packId}.json`), "utf8")]),
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
let source = findSource(sourceConfig, sourceReference);
|
|
400
|
+
let registeredSource = false;
|
|
401
|
+
if (!source) {
|
|
402
|
+
// owner/repo, URLs, and SSH refs always contain "/"; local paths may use
|
|
403
|
+
// the platform separator instead (Windows: "\").
|
|
404
|
+
if (!sourceReference.includes("/") && !sourceReference.includes("\\")) {
|
|
405
|
+
fail(`Unknown source: ${sourceReference}`);
|
|
406
|
+
}
|
|
407
|
+
source = await registerSource(catalogRoot, sourceConfig, {
|
|
408
|
+
id: deriveSourceId(sourceReference),
|
|
409
|
+
name: sourceReference.replace(/\.git$/i, ""),
|
|
410
|
+
repository: sourceReference,
|
|
411
|
+
}, io);
|
|
412
|
+
registeredSource = true;
|
|
413
|
+
}
|
|
414
|
+
const sourceId = source.id;
|
|
415
|
+
let skillNames = requestedSkillNames;
|
|
416
|
+
let mappingsChanged = false;
|
|
417
|
+
let tempDirectory;
|
|
418
|
+
let cloneDirectory;
|
|
419
|
+
const stagedSkillPaths = new Map();
|
|
420
|
+
const catalogBeforeInstall = await buildCatalog(sourceConfig, skillsRoot);
|
|
421
|
+
const sourceGroup = catalogBeforeInstall.groups.find((group) => group.source.id === sourceId);
|
|
422
|
+
const knownSkillNames = sourceGroup?.skills.map((skill) => skill.name) ?? [];
|
|
423
|
+
const duplicateSkillNames = discoverAll ? knownSkillNames : requestedSkillNames;
|
|
424
|
+
if (
|
|
425
|
+
duplicateSkillNames.length > 0 &&
|
|
426
|
+
duplicateSkillNames.every(
|
|
427
|
+
(skillName) =>
|
|
428
|
+
catalogBeforeInstall.byName.get(skillName)?.source.id === sourceId &&
|
|
429
|
+
skillCoveredByPacks(packs, packIds, sourceId, skillName),
|
|
430
|
+
)
|
|
431
|
+
) {
|
|
432
|
+
io.log(
|
|
433
|
+
`Already installed: ${sourceId} (${duplicateSkillNames.length} Skill${duplicateSkillNames.length === 1 ? "" : "s"})`,
|
|
434
|
+
);
|
|
435
|
+
io.log(`Packs: ${packIds.join(", ")}`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
if (discoverAll) {
|
|
440
|
+
tempDirectory = await createTempDirectory(catalogRoot);
|
|
441
|
+
cloneDirectory = path.join(tempDirectory, "clone", source.id);
|
|
442
|
+
io.log(`Fetching locked source: ${source.name} @ ${source.revision.slice(0, 8)}`);
|
|
443
|
+
await cloneRevision(source, cloneDirectory);
|
|
444
|
+
const discovered = await discoverSourceSkills(source, cloneDirectory);
|
|
445
|
+
skillNames = discovered.names;
|
|
446
|
+
mappingsChanged = discovered.mappingsChanged;
|
|
447
|
+
io.log(`Discovered ${skillNames.length} Skill${skillNames.length === 1 ? "" : "s"}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const catalog = await buildCatalog(sourceConfig, skillsRoot);
|
|
451
|
+
const newSkillNames = [];
|
|
452
|
+
for (const skillName of skillNames) {
|
|
453
|
+
const existing = catalog.byName.get(skillName);
|
|
454
|
+
if (existing && existing.source.id !== sourceId) {
|
|
455
|
+
fail(`Skill ${skillName} already belongs to source ${existing.source.id}`);
|
|
456
|
+
}
|
|
457
|
+
if (!existing) {
|
|
458
|
+
newSkillNames.push(skillName);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (newSkillNames.length > 0 && !discoverAll) {
|
|
463
|
+
tempDirectory ??= await createTempDirectory(catalogRoot);
|
|
464
|
+
cloneDirectory ??= path.join(tempDirectory, "clone", source.id);
|
|
465
|
+
if (!existsSync(cloneDirectory)) {
|
|
466
|
+
io.log(`Fetching locked source: ${source.name} @ ${source.revision.slice(0, 8)}`);
|
|
467
|
+
await cloneRevision(source, cloneDirectory);
|
|
468
|
+
}
|
|
469
|
+
const discovered = await discoverSourceSkills(source, cloneDirectory);
|
|
470
|
+
mappingsChanged ||= discovered.mappingsChanged;
|
|
471
|
+
for (const skillName of newSkillNames) {
|
|
472
|
+
if (!discovered.names.includes(skillName)) {
|
|
473
|
+
fail(`Skill not found upstream: ${skillName}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (newSkillNames.length > 0) {
|
|
479
|
+
tempDirectory ??= await createTempDirectory(catalogRoot);
|
|
480
|
+
cloneDirectory ??= path.join(tempDirectory, "clone", source.id);
|
|
481
|
+
const stageDirectory = path.join(tempDirectory, "stage");
|
|
482
|
+
if (!existsSync(cloneDirectory)) {
|
|
483
|
+
io.log(`Fetching locked source: ${source.name} @ ${source.revision.slice(0, 8)}`);
|
|
484
|
+
await cloneRevision(source, cloneDirectory);
|
|
485
|
+
}
|
|
486
|
+
await stageSource(source, cloneDirectory, stageDirectory, newSkillNames);
|
|
487
|
+
const replacements = newSkillNames.map((skillName) => ({
|
|
488
|
+
relativePath: path.join("skills", source.id, skillName),
|
|
489
|
+
staged: path.join(stageDirectory, source.id, skillName),
|
|
490
|
+
target: path.join(skillsRoot, source.id, skillName),
|
|
491
|
+
}));
|
|
492
|
+
for (const replacement of replacements) {
|
|
493
|
+
stagedSkillPaths.set(replacement.target, existsSync(replacement.target));
|
|
494
|
+
}
|
|
495
|
+
await replaceStagedFiles(replacements, tempDirectory);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const packChanges = await addSkillsToPacks(catalogRoot, packIds, sourceId, skillNames);
|
|
499
|
+
if (mappingsChanged) {
|
|
500
|
+
await saveSources(catalogRoot, sourceConfig);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (packChanges.added.length > 0) {
|
|
504
|
+
io.log(
|
|
505
|
+
discoverAll && skillNames.length > 1
|
|
506
|
+
? `Added all Skills: ${sourceId} (${skillNames.length})`
|
|
507
|
+
: `Added: ${sourceId} -> ${skillNames.join(", ")}`,
|
|
508
|
+
);
|
|
509
|
+
} else {
|
|
510
|
+
io.log("No Pack changes");
|
|
511
|
+
}
|
|
512
|
+
if (packChanges.inherited.length > 0) {
|
|
513
|
+
io.log(`Inherited from common: ${packChanges.inherited.join(", ")}`);
|
|
514
|
+
}
|
|
515
|
+
} catch (error) {
|
|
516
|
+
try {
|
|
517
|
+
await writeFile(sourcesFile, sourcesFileBefore, "utf8");
|
|
518
|
+
for (const [packId, contents] of packFilesBefore) {
|
|
519
|
+
await writeFile(path.join(packsRoot, `${packId}.json`), contents, "utf8");
|
|
520
|
+
}
|
|
521
|
+
for (const [skillPath, existed] of stagedSkillPaths) {
|
|
522
|
+
if (!existed) {
|
|
523
|
+
await rm(skillPath, { recursive: true, force: true });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (registeredSource) {
|
|
527
|
+
if (source.licenseFile) {
|
|
528
|
+
await rm(path.join(catalogRoot, source.licenseFile), { force: true });
|
|
529
|
+
}
|
|
530
|
+
await rm(path.join(skillsRoot, source.id), { recursive: true, force: true });
|
|
531
|
+
}
|
|
532
|
+
} catch (rollbackError) {
|
|
533
|
+
fail(`Add failed and rollback failed: ${rollbackError.message}`);
|
|
534
|
+
}
|
|
535
|
+
throw error;
|
|
536
|
+
} finally {
|
|
537
|
+
if (tempDirectory) {
|
|
538
|
+
await removeTempDirectory(tempDirectory);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
io.log(`Packs: ${packIds.join(", ")}`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function commandRemove(argumentsList, catalogRoot, io = console) {
|
|
545
|
+
const packsRoot = path.join(catalogRoot, "packs");
|
|
546
|
+
const packsValue = takeOption(argumentsList, "--pack");
|
|
547
|
+
const [sourceReference, ...requestedSkillNames] = argumentsList;
|
|
548
|
+
if (!sourceReference || requestedSkillNames.length === 0) {
|
|
549
|
+
fail("Usage: remove <source-id|owner/repo> <skill...> [--pack <pack,pack>]");
|
|
550
|
+
}
|
|
551
|
+
const unknownOption = argumentsList.find((argument) => argument.startsWith("-"));
|
|
552
|
+
if (unknownOption) {
|
|
553
|
+
fail(`Unknown option: ${unknownOption}`);
|
|
554
|
+
}
|
|
555
|
+
const skillNames = [...new Set(requestedSkillNames)];
|
|
556
|
+
skillNames.forEach(assertSafeSkillName);
|
|
557
|
+
const sourceConfig = await loadSources(catalogRoot);
|
|
558
|
+
const source = findSource(sourceConfig, sourceReference);
|
|
559
|
+
if (!source) {
|
|
560
|
+
io.log(`Already absent: ${sourceReference} -> ${skillNames.join(", ")}`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const packs = await loadPacks(catalogRoot);
|
|
564
|
+
const packIds = packsValue
|
|
565
|
+
? packsValue.split(",").map((value) => value.trim()).filter(Boolean)
|
|
566
|
+
: [...packs.keys()];
|
|
567
|
+
for (const packId of packIds) {
|
|
568
|
+
assertSafeId(packId, "Pack id");
|
|
569
|
+
if (!packs.has(packId)) {
|
|
570
|
+
fail(`Unknown Pack: ${packId}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const removedMemberships = [];
|
|
575
|
+
for (const packId of packIds) {
|
|
576
|
+
const pack = packs.get(packId);
|
|
577
|
+
let changed = false;
|
|
578
|
+
for (const selection of pack.sources.filter((item) => item.source === source.id)) {
|
|
579
|
+
const present = skillNames.filter((skillName) => selection.skills.includes(skillName));
|
|
580
|
+
const before = selection.skills.length;
|
|
581
|
+
selection.skills = selection.skills.filter((skillName) => !skillNames.includes(skillName));
|
|
582
|
+
for (const skillName of present) {
|
|
583
|
+
removedMemberships.push({ packId, skillName });
|
|
584
|
+
}
|
|
585
|
+
changed ||= selection.skills.length !== before;
|
|
586
|
+
}
|
|
587
|
+
pack.sources = pack.sources.filter((selection) => selection.skills.length > 0);
|
|
588
|
+
if (changed) {
|
|
589
|
+
await writeJson(path.join(packsRoot, `${packId}.json`), pack);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const candidates = skillNames.map((skillName) => ({ sourceId: source.id, skillName }));
|
|
594
|
+
const pruned = await pruneCatalogSkills(catalogRoot, sourceConfig, packs, candidates);
|
|
595
|
+
if (removedMemberships.length === 0 && pruned.removed.length === 0) {
|
|
596
|
+
io.log(`Already absent: ${source.id} -> ${skillNames.join(", ")}`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
io.log(`Removed from Packs: ${removedMemberships.length}`);
|
|
600
|
+
io.log(`Removed vendored Skills: ${pruned.removed.map((item) => item.skillName).join(", ") || "None"}`);
|
|
601
|
+
if (pruned.removedSources.length > 0) {
|
|
602
|
+
io.log(`Removed empty sources: ${pruned.removedSources.join(", ")}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function commandPackRemove(argumentsList, catalogRoot, io = console) {
|
|
607
|
+
const packsRoot = path.join(catalogRoot, "packs");
|
|
608
|
+
if (argumentsList.length === 0) {
|
|
609
|
+
fail("Usage: pack-remove <pack...>");
|
|
610
|
+
}
|
|
611
|
+
const packIds = [...new Set(parsePackArguments(argumentsList))];
|
|
612
|
+
packIds.forEach((packId) => assertSafeId(packId, "Pack id"));
|
|
613
|
+
if (packIds.includes("common")) {
|
|
614
|
+
fail("The common Pack cannot be removed");
|
|
615
|
+
}
|
|
616
|
+
const sourceConfig = await loadSources(catalogRoot);
|
|
617
|
+
const packs = await loadPacks(catalogRoot);
|
|
618
|
+
const existing = packIds.filter((packId) => packs.has(packId));
|
|
619
|
+
const absent = packIds.filter((packId) => !packs.has(packId));
|
|
620
|
+
if (absent.length > 0) {
|
|
621
|
+
io.log(`Already absent: ${absent.join(", ")}`);
|
|
622
|
+
}
|
|
623
|
+
if (existing.length === 0) {
|
|
624
|
+
io.log("No Pack changes");
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const candidates = [];
|
|
629
|
+
for (const packId of existing) {
|
|
630
|
+
const pack = packs.get(packId);
|
|
631
|
+
for (const selection of pack.sources) {
|
|
632
|
+
for (const skillName of selection.skills) {
|
|
633
|
+
candidates.push({ sourceId: selection.source, skillName });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
packs.delete(packId);
|
|
637
|
+
}
|
|
638
|
+
for (const packId of existing) {
|
|
639
|
+
await rm(path.join(packsRoot, `${packId}.json`));
|
|
640
|
+
}
|
|
641
|
+
const pruned = await pruneCatalogSkills(catalogRoot, sourceConfig, packs, candidates);
|
|
642
|
+
io.log(`Removed Packs: ${existing.join(", ")}`);
|
|
643
|
+
io.log(`Removed orphan Skills: ${pruned.removed.length}`);
|
|
644
|
+
if (pruned.removedSources.length > 0) {
|
|
645
|
+
io.log(`Removed empty sources: ${pruned.removedSources.join(", ")}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function commandSourceAdd(argumentsList, catalogRoot, io = console) {
|
|
650
|
+
const name = takeOption(argumentsList, "--name");
|
|
651
|
+
const skillRoot = takeOption(argumentsList, "--skill-root");
|
|
652
|
+
const licenseSource = takeOption(argumentsList, "--license");
|
|
653
|
+
const [id, repository] = argumentsList;
|
|
654
|
+
if (!id || !repository || argumentsList.length !== 2) {
|
|
655
|
+
fail("Usage: source-add <id> <repository> [--name <name>] [--skill-root <path>] [--license <path>]");
|
|
656
|
+
}
|
|
657
|
+
const sourceConfig = await loadSources(catalogRoot);
|
|
658
|
+
await registerSource(catalogRoot, sourceConfig, {
|
|
659
|
+
id,
|
|
660
|
+
licenseSource,
|
|
661
|
+
name,
|
|
662
|
+
repository,
|
|
663
|
+
skillRoot,
|
|
664
|
+
}, io);
|
|
665
|
+
io.log(`Next: avenic catalog skill-add ${id} <skill-name> --pack <pack>`);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function commandPackAdd(argumentsList, catalogRoot, io = console) {
|
|
669
|
+
const packsRoot = path.join(catalogRoot, "packs");
|
|
670
|
+
const name = takeOption(argumentsList, "--name");
|
|
671
|
+
const description = takeOption(argumentsList, "--description");
|
|
672
|
+
const [id] = argumentsList;
|
|
673
|
+
if (!id || argumentsList.length !== 1) {
|
|
674
|
+
fail("Usage: pack-add <id> [--name <name>] [--description <text>]");
|
|
675
|
+
}
|
|
676
|
+
assertSafeId(id, "Pack id");
|
|
677
|
+
const packFile = path.join(packsRoot, `${id}.json`);
|
|
678
|
+
if (!isInside(packsRoot, packFile) || existsSync(packFile)) {
|
|
679
|
+
fail(`Pack already exists: ${id}`);
|
|
680
|
+
}
|
|
681
|
+
const displayName =
|
|
682
|
+
name ??
|
|
683
|
+
id
|
|
684
|
+
.split(/[-_.]+/)
|
|
685
|
+
.filter(Boolean)
|
|
686
|
+
.map((part) => `${part[0].toUpperCase()}${part.slice(1)}`)
|
|
687
|
+
.join(" ");
|
|
688
|
+
await writeJson(packFile, {
|
|
689
|
+
schemaVersion: 1,
|
|
690
|
+
id,
|
|
691
|
+
name: displayName,
|
|
692
|
+
description: description ?? `Custom ${displayName} workflows.`,
|
|
693
|
+
sources: [],
|
|
694
|
+
});
|
|
695
|
+
io.log(`Created Pack: ${id}`);
|
|
696
|
+
io.log(`File: ${packFile}`);
|
|
697
|
+
io.log(`Next: avenic catalog skill-add <source> <skill-name> --pack ${id}`);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function runMaintenanceCommand(command, argumentsList, catalogRoot, io) {
|
|
701
|
+
switch (command) {
|
|
702
|
+
case "doctor":
|
|
703
|
+
await commandDoctor(catalogRoot, io);
|
|
704
|
+
break;
|
|
705
|
+
case "update":
|
|
706
|
+
await commandUpdate(argumentsList, catalogRoot, io);
|
|
707
|
+
break;
|
|
708
|
+
case "skill-add":
|
|
709
|
+
await commandAdd([...argumentsList], catalogRoot, io);
|
|
710
|
+
break;
|
|
711
|
+
case "remove":
|
|
712
|
+
await commandRemove([...argumentsList], catalogRoot, io);
|
|
713
|
+
break;
|
|
714
|
+
case "pack-add":
|
|
715
|
+
await commandPackAdd([...argumentsList], catalogRoot, io);
|
|
716
|
+
break;
|
|
717
|
+
case "pack-remove":
|
|
718
|
+
await commandPackRemove([...argumentsList], catalogRoot, io);
|
|
719
|
+
break;
|
|
720
|
+
case "source-add":
|
|
721
|
+
await commandSourceAdd([...argumentsList], catalogRoot, io);
|
|
722
|
+
break;
|
|
723
|
+
default:
|
|
724
|
+
fail(`Unknown command: ${command}`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function commandCatalogSync(options = {}) {
|
|
729
|
+
const io = options.io ?? console;
|
|
730
|
+
const spec = await loadDefaultCatalogSpec(options.environment);
|
|
731
|
+
const catalogInfo = await ensureCatalog(spec, {
|
|
732
|
+
environment: options.environment,
|
|
733
|
+
io,
|
|
734
|
+
});
|
|
735
|
+
io.log("Catalog sync\n");
|
|
736
|
+
io.log(`Catalog ${catalogInfo.spec}`);
|
|
737
|
+
io.log(`Cache ${catalogInfo.catalogRoot}`);
|
|
738
|
+
io.log(`Revision ${catalogInfo.revision}`);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function commandCatalogAdd(argumentsList, options = {}) {
|
|
742
|
+
const io = options.io ?? console;
|
|
743
|
+
const [spec] = argumentsList;
|
|
744
|
+
if (!spec || argumentsList.length !== 1) {
|
|
745
|
+
fail("Usage: avenic catalog add <spec>");
|
|
746
|
+
}
|
|
747
|
+
const result = await registerCatalog(spec, { environment: options.environment, io });
|
|
748
|
+
io.log(`Default catalog: ${spec}`);
|
|
749
|
+
if (result.previewFailed) {
|
|
750
|
+
io.log("\nSpec saved. Catalog preview unavailable:");
|
|
751
|
+
io.log(` ${String(result.error.message).split("\n")[0]}`);
|
|
752
|
+
} else {
|
|
753
|
+
io.log(`\nPacks · ${result.packs.length}`);
|
|
754
|
+
result.packs.forEach((pack, packIndex) => {
|
|
755
|
+
const lastPack = packIndex === result.packs.length - 1;
|
|
756
|
+
const label = pack.name && pack.name !== pack.id ? `${pack.id} (${pack.name})` : pack.id;
|
|
757
|
+
const purpose = pack.description ? ` — ${pack.description}` : "";
|
|
758
|
+
io.log(`${lastPack ? "└──" : "├──"} ${label}${purpose}`);
|
|
759
|
+
});
|
|
760
|
+
io.log("\nInstall: avenic skills install [pack...]");
|
|
761
|
+
}
|
|
762
|
+
io.log("Run: avenic catalog sync");
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Seed the registry with the current spec on first use, so upgrading users
|
|
766
|
+
// see their catalog in `catalog list`/`catalog select` immediately.
|
|
767
|
+
async function ensureKnownCatalogs(options = {}) {
|
|
768
|
+
let known = await loadKnownCatalogs(options.environment);
|
|
769
|
+
if (known.length === 0) {
|
|
770
|
+
const current = await loadDefaultCatalogSpec(options.environment);
|
|
771
|
+
await registerKnownCatalog(options.environment, current);
|
|
772
|
+
known = await loadKnownCatalogs(options.environment);
|
|
773
|
+
}
|
|
774
|
+
return known;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function commandCatalogList(options = {}) {
|
|
778
|
+
const io = options.io ?? console;
|
|
779
|
+
const current = await loadDefaultCatalogSpec(options.environment);
|
|
780
|
+
const known = await ensureKnownCatalogs(options);
|
|
781
|
+
io.log("\nRegistered catalogs\n");
|
|
782
|
+
for (const entry of known) {
|
|
783
|
+
const marker = entry.spec === current ? ">" : " ";
|
|
784
|
+
io.log(`${marker} ${entry.name}${entry.spec !== entry.name ? ` ${entry.spec}` : ""}`);
|
|
785
|
+
}
|
|
786
|
+
io.log("\n> = current. Switch: avenic catalog select");
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Arrow-key picker over the registered catalogs. Resolves to the chosen spec,
|
|
790
|
+
// or null when cancelled. Repaints the whole frame on every keypress with
|
|
791
|
+
// saved-cursor positioning: some terminals mishandle relative moveCursor
|
|
792
|
+
// repaints (frames pile up instead of replacing each other), so each paint
|
|
793
|
+
// restores the cursor saved at picker start, clears to the bottom of the
|
|
794
|
+
// screen, and rewrites the full frame. Runs in raw mode; callers must ensure
|
|
795
|
+
// the process owns a TTY.
|
|
796
|
+
function promptCatalogChoice(entries, currentIndex) {
|
|
797
|
+
const output = process.stdout;
|
|
798
|
+
const input = process.stdin;
|
|
799
|
+
const count = entries.length;
|
|
800
|
+
let selected = currentIndex >= 0 ? currentIndex : 0;
|
|
801
|
+
return new Promise((resolve) => {
|
|
802
|
+
const paint = () => {
|
|
803
|
+
const width = Math.max(
|
|
804
|
+
"Select a catalog:".length,
|
|
805
|
+
"↑/↓ select · Enter confirm · Esc cancel".length,
|
|
806
|
+
...entries.map((entry) => entry.name.length),
|
|
807
|
+
);
|
|
808
|
+
const lines = ["Select a catalog:"];
|
|
809
|
+
for (let index = 0; index < count; index += 1) {
|
|
810
|
+
lines.push(`${index === selected ? ">" : " "} ${entries[index].name}`);
|
|
811
|
+
}
|
|
812
|
+
lines.push("↑/↓ select · Enter confirm · Esc cancel");
|
|
813
|
+
output.write("\x1b[u"); // restore the cursor saved at picker start
|
|
814
|
+
readline.clearScreenDown(output);
|
|
815
|
+
output.write(lines.map((line) => line.padEnd(width)).join("\n"));
|
|
816
|
+
};
|
|
817
|
+
const finish = (result) => {
|
|
818
|
+
input.setRawMode(false);
|
|
819
|
+
input.pause();
|
|
820
|
+
input.removeAllListeners("keypress");
|
|
821
|
+
output.write("\n"); // keep the last frame visible; resume on a fresh line
|
|
822
|
+
resolve(result);
|
|
823
|
+
};
|
|
824
|
+
input.on("keypress", (value, key) => {
|
|
825
|
+
if (key.name === "up") {
|
|
826
|
+
selected = (selected - 1 + count) % count;
|
|
827
|
+
paint();
|
|
828
|
+
} else if (key.name === "down") {
|
|
829
|
+
selected = (selected + 1) % count;
|
|
830
|
+
paint();
|
|
831
|
+
} else if (key.name === "return" || key.name === "enter") {
|
|
832
|
+
finish(entries[selected].spec);
|
|
833
|
+
} else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
834
|
+
finish(null);
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
readline.emitKeypressEvents(input);
|
|
838
|
+
input.setRawMode(true);
|
|
839
|
+
input.resume();
|
|
840
|
+
output.write("\x1b[s"); // remember where the frame starts
|
|
841
|
+
paint();
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function commandCatalogSelect(argumentsList, options = {}) {
|
|
846
|
+
const io = options.io ?? console;
|
|
847
|
+
const [target] = argumentsList;
|
|
848
|
+
if (argumentsList.length > 1) {
|
|
849
|
+
fail("Usage: avenic catalog select [name|spec]");
|
|
850
|
+
}
|
|
851
|
+
const current = await loadDefaultCatalogSpec(options.environment);
|
|
852
|
+
const known = await ensureKnownCatalogs(options);
|
|
853
|
+
if (target) {
|
|
854
|
+
const entry = known.find((candidate) => candidate.spec === target)
|
|
855
|
+
?? known.find((candidate) => candidate.name === target);
|
|
856
|
+
if (!entry) {
|
|
857
|
+
fail(`Unknown catalog: ${target}\nAdd one first: avenic catalog add <spec>`);
|
|
858
|
+
}
|
|
859
|
+
await setDefaultCatalogSpec(options.environment, entry.spec);
|
|
860
|
+
io.log(`Current catalog: ${entry.spec}`);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
864
|
+
// No terminal (pipes, scripts): print the plain list instead.
|
|
865
|
+
await commandCatalogList(options);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const currentIndex = known.findIndex((entry) => entry.spec === current);
|
|
869
|
+
// The picker paints its own title as the first frame line.
|
|
870
|
+
const chosen = await promptCatalogChoice(known, currentIndex);
|
|
871
|
+
if (chosen === null) {
|
|
872
|
+
io.log("No change.");
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
await setDefaultCatalogSpec(options.environment, chosen);
|
|
876
|
+
io.log(`Current catalog: ${chosen}`);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async function commandCatalogDefault(options = {}) {
|
|
880
|
+
const io = options.io ?? console;
|
|
881
|
+
io.log(`Default catalog: ${await loadDefaultCatalogSpec(options.environment)}`);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export async function dispatchCatalog(argumentsList, options = {}) {
|
|
885
|
+
const io = options.io ?? console;
|
|
886
|
+
const scope = parseScopeArguments(argumentsList);
|
|
887
|
+
// The entry point (dispatchSkills) strips scope flags before delegating here,
|
|
888
|
+
// so the global flag must survive the delegation to keep validation intact.
|
|
889
|
+
const global = scope.global || options.global;
|
|
890
|
+
const [command, ...remainingArguments] = scope.argumentsList;
|
|
891
|
+
if (command === "sync") {
|
|
892
|
+
if (global || remainingArguments.length > 0) {
|
|
893
|
+
fail("Usage: avenic catalog sync");
|
|
894
|
+
}
|
|
895
|
+
await commandCatalogSync(options);
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
if (command === "add") {
|
|
899
|
+
if (global) {
|
|
900
|
+
fail("avenic catalog add does not accept a global scope");
|
|
901
|
+
}
|
|
902
|
+
await commandCatalogAdd(remainingArguments, options);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (command === "default") {
|
|
906
|
+
if (global || remainingArguments.length > 0) {
|
|
907
|
+
fail("Usage: avenic catalog default");
|
|
908
|
+
}
|
|
909
|
+
await commandCatalogDefault(options);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (command === "select") {
|
|
913
|
+
if (global) {
|
|
914
|
+
fail("avenic catalog select does not accept a global scope");
|
|
915
|
+
}
|
|
916
|
+
await commandCatalogSelect(remainingArguments, options);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (command === "list") {
|
|
920
|
+
if (global || remainingArguments.length > 0) {
|
|
921
|
+
fail("Usage: avenic catalog list");
|
|
922
|
+
}
|
|
923
|
+
await commandCatalogList(options);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const maintenanceCommands = new Set([
|
|
927
|
+
"doctor",
|
|
928
|
+
"update",
|
|
929
|
+
"skill-add",
|
|
930
|
+
"remove",
|
|
931
|
+
"pack-add",
|
|
932
|
+
"pack-remove",
|
|
933
|
+
"source-add",
|
|
934
|
+
]);
|
|
935
|
+
if (!command || !maintenanceCommands.has(command)) {
|
|
936
|
+
fail("Usage: avenic catalog <sync|add|select|list|default|doctor|update|skill-add|remove|pack-add|pack-remove|source-add>");
|
|
937
|
+
}
|
|
938
|
+
if (global) {
|
|
939
|
+
fail(`${command} does not accept a global scope`);
|
|
940
|
+
}
|
|
941
|
+
const cwd = options.cwd ?? process.cwd();
|
|
942
|
+
if (!isCatalogDirectory(cwd)) {
|
|
943
|
+
fail(`${command} must run inside the Avenic Git clone`);
|
|
944
|
+
}
|
|
945
|
+
return runMaintenanceCommand(command, remainingArguments, cwd, io);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export async function dispatchSkills(argumentsList, options = {}) {
|
|
949
|
+
const io = options.io ?? console;
|
|
950
|
+
const scope = parseScopeArguments(argumentsList);
|
|
951
|
+
const [firstArgument, ...remainingArguments] = scope.argumentsList;
|
|
952
|
+
const command = firstArgument ?? "install";
|
|
953
|
+
const commandOptions = { ...options, global: scope.global || options.global };
|
|
954
|
+
if (command === "add") {
|
|
955
|
+
await commandAddDirect(remainingArguments, commandOptions);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
// "remove" pairs with "add": it removes externally installed Skills, and
|
|
959
|
+
// precedes the catalog maintenance set below for the same reason "add" does.
|
|
960
|
+
if (command === "remove") {
|
|
961
|
+
await commandUninstallSkill(remainingArguments, commandOptions);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
if (command === "install") {
|
|
965
|
+
await commandInstall(remainingArguments, commandOptions);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (command === "skills") {
|
|
969
|
+
await dispatchSkills(remainingArguments, { ...options, global: scope.global || options.global });
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (command === "catalog") {
|
|
973
|
+
await dispatchCatalog(remainingArguments, {
|
|
974
|
+
io,
|
|
975
|
+
cwd: options.cwd ?? process.cwd(),
|
|
976
|
+
environment: options.environment ?? process.env,
|
|
977
|
+
global: scope.global || options.global,
|
|
978
|
+
});
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const maintenanceCommands = new Set([
|
|
982
|
+
"doctor",
|
|
983
|
+
"update",
|
|
984
|
+
"skill-add",
|
|
985
|
+
"remove",
|
|
986
|
+
"pack-add",
|
|
987
|
+
"pack-remove",
|
|
988
|
+
"source-add",
|
|
989
|
+
]);
|
|
990
|
+
if (maintenanceCommands.has(command)) {
|
|
991
|
+
if (scope.global) {
|
|
992
|
+
fail(`${command} does not accept a global scope`);
|
|
993
|
+
}
|
|
994
|
+
const cwd = options.cwd ?? process.cwd();
|
|
995
|
+
if (!isCatalogDirectory(cwd)) {
|
|
996
|
+
// Outside a catalog clone, doctor and update fall back to their runtime
|
|
997
|
+
// meanings (environment check and CLI self-update); the other maintenance
|
|
998
|
+
// commands only make sense inside the catalog Git clone.
|
|
999
|
+
if (command === "doctor" || command === "update") {
|
|
1000
|
+
const { runCli } = await import("./dispatcher.mjs");
|
|
1001
|
+
return runCli({ argumentsList: [command, ...remainingArguments] });
|
|
1002
|
+
}
|
|
1003
|
+
fail(`${command} must run inside the Avenic Git clone`);
|
|
1004
|
+
}
|
|
1005
|
+
return runMaintenanceCommand(command, remainingArguments, cwd, io);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
1009
|
+
const { printHelp } = await import("./dispatcher.mjs");
|
|
1010
|
+
printHelp(io);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (command === "self-update") {
|
|
1014
|
+
if (scope.global || remainingArguments.length > 0) {
|
|
1015
|
+
fail("Usage: self-update");
|
|
1016
|
+
}
|
|
1017
|
+
await updateAvenic(packageRoot);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (command === "uninstall" && remainingArguments.length === 0) {
|
|
1021
|
+
await commandUninstall([], commandOptions);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Known subcommands resolve the catalog themselves; handle them before any
|
|
1026
|
+
// network work so typos in the command position never trigger a fetch.
|
|
1027
|
+
switch (command) {
|
|
1028
|
+
case "packs":
|
|
1029
|
+
await commandPacks(commandOptions);
|
|
1030
|
+
return;
|
|
1031
|
+
case "tree":
|
|
1032
|
+
await commandTree(remainingArguments, commandOptions);
|
|
1033
|
+
return;
|
|
1034
|
+
case "uninstall":
|
|
1035
|
+
await commandUninstall(remainingArguments, commandOptions);
|
|
1036
|
+
return;
|
|
1037
|
+
case "status":
|
|
1038
|
+
await commandStatus(commandOptions);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Agent runtime commands (claude/codex/opencode lifecycle, sessions) are
|
|
1043
|
+
// handled by the runtime dispatcher; delegate before the Pack fallback so
|
|
1044
|
+
// typos in the agent position never trigger a network fetch. The bare
|
|
1045
|
+
// `avenic status` overview stays reachable through the main entry.
|
|
1046
|
+
if (Object.hasOwn(AGENTS, command) || command === "sessions") {
|
|
1047
|
+
const { runCli } = await import("./dispatcher.mjs");
|
|
1048
|
+
return runCli({ argumentsList: scope.argumentsList });
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Anything else is a Pack id (ids are user-defined, so the catalog is the
|
|
1052
|
+
// only source of truth for telling Packs from typos).
|
|
1053
|
+
const catalogInfo = await resolveInstallSource(commandOptions);
|
|
1054
|
+
const packs = await loadPacks(catalogInfo.catalogRoot);
|
|
1055
|
+
if (packs.has(command)) {
|
|
1056
|
+
await commandInstall([command, ...remainingArguments], commandOptions);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
fail(`Unknown command or Pack: ${command}`);
|
|
1060
|
+
}
|