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.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +234 -0
  3. package/package.json +30 -0
  4. package/scripts/skills.mjs +13 -0
  5. package/scripts/watchdog.mjs +50 -0
  6. package/src/cli/dispatcher.mjs +439 -0
  7. package/src/cli/self-update.mjs +27 -0
  8. package/src/cli/skills-cli.mjs +1060 -0
  9. package/src/cli/watchdog.mjs +30 -0
  10. package/vendor/core-src/index.mjs +143 -0
  11. package/vendor/core-src/runtime/adapters/claude.mjs +81 -0
  12. package/vendor/core-src/runtime/adapters/codex.mjs +142 -0
  13. package/vendor/core-src/runtime/adapters/index.mjs +9 -0
  14. package/vendor/core-src/runtime/adapters/opencode.mjs +76 -0
  15. package/vendor/core-src/runtime/agents.mjs +39 -0
  16. package/vendor/core-src/runtime/config.mjs +227 -0
  17. package/vendor/core-src/runtime/gitignore.mjs +69 -0
  18. package/vendor/core-src/runtime/process.mjs +41 -0
  19. package/vendor/core-src/runtime/project-root.mjs +42 -0
  20. package/vendor/core-src/runtime/sessions.mjs +312 -0
  21. package/vendor/core-src/skills/catalog.mjs +130 -0
  22. package/vendor/core-src/skills/direct.mjs +209 -0
  23. package/vendor/core-src/skills/git.mjs +93 -0
  24. package/vendor/core-src/skills/ids.mjs +40 -0
  25. package/vendor/core-src/skills/install.mjs +357 -0
  26. package/vendor/core-src/skills/packs.mjs +256 -0
  27. package/vendor/core-src/skills/paths.mjs +92 -0
  28. package/vendor/core-src/skills/sources.mjs +246 -0
  29. package/vendor/core-src/skills/ui.mjs +21 -0
  30. package/vendor/core-src/skills/vendor.mjs +57 -0
  31. package/vendor/core-src/util/fail.mjs +3 -0
  32. package/vendor/core-src/util/fs.mjs +14 -0
  33. package/vendor/core-src/util/json.mjs +14 -0
@@ -0,0 +1,357 @@
1
+ import { existsSync } from "node:fs";
2
+ import { cp, mkdir, readFile, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { fail } from "../util/fail.mjs";
7
+ import { isInside, removeEmptyDirectory } from "../util/fs.mjs";
8
+ import { readJson, writeJson } from "../util/json.mjs";
9
+ import { ensureCatalog, loadDefaultCatalogSpec, parseCatalogSpec } from "./catalog.mjs";
10
+ import { assertSafeId, assertSafeSkillName } from "./ids.mjs";
11
+ import { loadPacks, resolvePacks } from "./packs.mjs";
12
+ import { buildCatalog, loadSources } from "./sources.mjs";
13
+ import { normalizePackIds, parsePackArguments } from "./packs.mjs";
14
+ import {
15
+ GLOBAL_TARGETS,
16
+ LEGACY_PROFILE_FILE,
17
+ PROJECT_CONFIG_FILE,
18
+ PROJECT_LOCK_FILE,
19
+ PROJECT_TARGETS,
20
+ globalConfigFile,
21
+ globalLockFile,
22
+ migrateLegacyProjectFiles,
23
+ } from "./paths.mjs";
24
+
25
+ export function isCatalogDirectory(directory) {
26
+ return (
27
+ existsSync(path.join(directory, "sources.lock.json")) &&
28
+ existsSync(path.join(directory, "skills")) &&
29
+ existsSync(path.join(directory, "packs"))
30
+ );
31
+ }
32
+
33
+ export function createInstallContext(global, options = {}) {
34
+ const cwd = options.cwd ?? process.cwd();
35
+ const environment = options.environment ?? process.env;
36
+ if (global) {
37
+ return {
38
+ configFile: globalConfigFile(environment),
39
+ environment,
40
+ global: true,
41
+ label: "Global",
42
+ lockFile: globalLockFile(environment),
43
+ root: environment.USERPROFILE || environment.HOME || os.homedir(),
44
+ targets: GLOBAL_TARGETS,
45
+ };
46
+ }
47
+ migrateLegacyProjectFiles(cwd);
48
+ return {
49
+ configFile: path.join(cwd, PROJECT_CONFIG_FILE),
50
+ environment,
51
+ global: false,
52
+ label: "Project",
53
+ legacyProfileFile: path.join(cwd, LEGACY_PROFILE_FILE),
54
+ lockFile: path.join(cwd, PROJECT_LOCK_FILE),
55
+ root: cwd,
56
+ targets: PROJECT_TARGETS.map((target) => ({
57
+ ...target,
58
+ destination: path.join(cwd, ...target.relativePath),
59
+ })),
60
+ };
61
+ }
62
+
63
+ export async function resolveInstallPacks(context, explicitPacks) {
64
+ const requested = parsePackArguments(explicitPacks);
65
+ if (requested.length > 0) {
66
+ return normalizePackIds(requested);
67
+ }
68
+ if (existsSync(context.configFile)) {
69
+ const config = await readJson(context.configFile);
70
+ return normalizePackIds(config.packs ?? (config.pack ? [config.pack] : []));
71
+ }
72
+ if (context.legacyProfileFile && existsSync(context.legacyProfileFile)) {
73
+ return normalizePackIds(
74
+ parsePackArguments([(await readFile(context.legacyProfileFile, "utf8")).trim()]),
75
+ );
76
+ }
77
+ return ["common"];
78
+ }
79
+
80
+ export async function previousManagedState(context) {
81
+ if (!existsSync(context.lockFile)) {
82
+ return new Map();
83
+ }
84
+ const manifest = await readJson(context.lockFile);
85
+ const managed = new Map();
86
+ for (const source of manifest.sources ?? []) {
87
+ for (const skillName of source.skills ?? []) {
88
+ managed.set(skillName, { sourceId: source.id, revision: source.revision });
89
+ }
90
+ }
91
+ return managed;
92
+ }
93
+
94
+ export async function installedPackIds(context) {
95
+ if (existsSync(context.configFile)) {
96
+ const config = await readJson(context.configFile);
97
+ return normalizePackIds(config.packs ?? (config.pack ? [config.pack] : []));
98
+ }
99
+ if (existsSync(context.lockFile)) {
100
+ const lock = await readJson(context.lockFile);
101
+ return normalizePackIds(
102
+ (lock.packs ?? (lock.pack ? [lock.pack] : [])).map((pack) => pack.id ?? pack),
103
+ );
104
+ }
105
+ return null;
106
+ }
107
+
108
+ export async function writeInstallMetadata(context, resolvedPacks, catalogInfo = {}) {
109
+ const packageMetadata = catalogInfo.packageMetadata ?? null;
110
+ const packageSpec = catalogInfo.spec ?? packageMetadata?.agentSkills?.packageSpec ?? packageMetadata?.repository?.url;
111
+ const previousConfig = existsSync(context.configFile) ? await readJson(context.configFile) : {};
112
+ const previousLock = existsSync(context.lockFile) ? await readJson(context.lockFile) : {};
113
+ const config = {
114
+ schemaVersion: 3,
115
+ catalog: packageSpec,
116
+ packs: resolvedPacks.packs.map((pack) => pack.id),
117
+ ...(previousConfig.direct ? { direct: previousConfig.direct } : {}),
118
+ };
119
+ const lock = {
120
+ schemaVersion: 3,
121
+ packs: resolvedPacks.packs.map((pack) => ({
122
+ id: pack.id,
123
+ name: pack.name,
124
+ description: pack.description ?? "",
125
+ })),
126
+ catalog: {
127
+ spec: packageSpec,
128
+ repository: catalogInfo.repository ?? null,
129
+ revision: catalogInfo.revision ?? null,
130
+ },
131
+ ...(previousLock.directSources ? { directSources: previousLock.directSources } : {}),
132
+ agents: context.targets.flatMap((target) => target.agents).filter((agent) => agent !== "universal"),
133
+ sources: resolvedPacks.groups.map((group) => ({
134
+ id: group.source.id,
135
+ name: group.source.name,
136
+ repository: group.source.repository,
137
+ revision: group.source.revision,
138
+ skills: group.skills.map((skill) => skill.name),
139
+ })),
140
+ };
141
+ await mkdir(path.dirname(context.configFile), { recursive: true });
142
+ await writeJson(context.configFile, config);
143
+ await writeJson(context.lockFile, lock);
144
+ if (context.legacyProfileFile && existsSync(context.legacyProfileFile)) {
145
+ await rm(context.legacyProfileFile);
146
+ }
147
+ }
148
+
149
+ export async function installCopies(context, resolvedPacks, io = console) {
150
+ const selectedSkills = resolvedPacks.groups.flatMap((group) => group.skills);
151
+ const selectedNames = new Set(selectedSkills.map((skill) => skill.name));
152
+ const previousState = await previousManagedState(context);
153
+ const staleNames = [...previousState.keys()].filter((name) => !selectedNames.has(name));
154
+ for (const targetConfig of context.targets) {
155
+ const destination = targetConfig.destination;
156
+ await mkdir(destination, { recursive: true });
157
+ const result = { added: 0, updated: 0, unchanged: 0, removed: 0 };
158
+ for (const skill of selectedSkills) {
159
+ const target = path.join(destination, skill.name);
160
+ if (!isInside(destination, target)) {
161
+ fail(`Install path escaped its target: ${target}`);
162
+ }
163
+ const previous = previousState.get(skill.name);
164
+ if (
165
+ existsSync(path.join(target, "SKILL.md")) &&
166
+ previous?.sourceId === skill.source.id &&
167
+ previous?.revision === skill.source.revision
168
+ ) {
169
+ result.unchanged += 1;
170
+ continue;
171
+ }
172
+ const existed = existsSync(target);
173
+ await rm(target, { recursive: true, force: true });
174
+ await cp(skill.directory, target, { recursive: true });
175
+ result[existed ? "updated" : "added"] += 1;
176
+ }
177
+ for (const staleName of staleNames) {
178
+ assertSafeSkillName(staleName);
179
+ const target = path.join(destination, staleName);
180
+ if (!isInside(destination, target)) {
181
+ fail(`Cleanup path escaped its target: ${target}`);
182
+ }
183
+ await rm(target, { recursive: true, force: true });
184
+ result.removed += 1;
185
+ }
186
+ io.log(`✓ ${targetConfig.label}`);
187
+ io.log(` Path: ${destination}`);
188
+ io.log(
189
+ ` Added ${result.added} · Updated ${result.updated} · Unchanged ${result.unchanged} · Removed ${result.removed}`,
190
+ );
191
+ }
192
+ }
193
+
194
+ export async function removeAllManagedSkills(context, managed, io = console) {
195
+ let total = 0;
196
+ for (const targetConfig of context.targets) {
197
+ let removed = 0;
198
+ for (const skillName of managed.keys()) {
199
+ assertSafeSkillName(skillName);
200
+ const target = path.join(targetConfig.destination, skillName);
201
+ if (!isInside(targetConfig.destination, target)) {
202
+ fail(`Uninstall path escaped its target: ${target}`);
203
+ }
204
+ if (existsSync(target)) {
205
+ await rm(target, { recursive: true, force: true });
206
+ removed += 1;
207
+ }
208
+ }
209
+ await removeEmptyDirectory(targetConfig.destination);
210
+ total += removed;
211
+ io.log(`${targetConfig.label}: Removed ${removed}`);
212
+ }
213
+ return total;
214
+ }
215
+
216
+ export async function removeSkillDirectories(context, skillNames, io = console) {
217
+ let total = 0;
218
+ for (const targetConfig of context.targets) {
219
+ let removed = 0;
220
+ for (const skillName of skillNames) {
221
+ const target = path.join(targetConfig.destination, skillName);
222
+ if (!isInside(targetConfig.destination, target)) {
223
+ fail(`Uninstall path escaped its target: ${target}`);
224
+ }
225
+ if (existsSync(target)) {
226
+ await rm(target, { recursive: true, force: true });
227
+ removed += 1;
228
+ }
229
+ }
230
+ total += removed;
231
+ io.log(`${targetConfig.label}: ${removed > 0 ? `Removed ${removed}` : "Unchanged"}`);
232
+ }
233
+ return total;
234
+ }
235
+
236
+ export async function removeInstallationFiles(context) {
237
+ await rm(context.configFile, { force: true });
238
+ await rm(context.lockFile, { force: true });
239
+ if (context.legacyProfileFile) {
240
+ await rm(context.legacyProfileFile, { force: true });
241
+ }
242
+ }
243
+
244
+ // Structured `skills status` data: the manifest's packs and per-target
245
+ // presence counts. Returns null when nothing is installed.
246
+ export async function skillsInstallationStatus(context) {
247
+ if (!existsSync(context.lockFile)) {
248
+ return null;
249
+ }
250
+ const manifest = await readJson(context.lockFile);
251
+ const groups = (manifest.sources ?? []).map((source) => ({
252
+ source,
253
+ skills: (source.skills ?? []).map((name) => ({ name })),
254
+ }));
255
+ const manifestPacks = manifest.packs ?? (manifest.pack ? [manifest.pack] : []);
256
+ const names = groups.flatMap((group) => group.skills.map((skill) => skill.name));
257
+ const targets = context.targets.map((targetConfig) => {
258
+ const directory = targetConfig.destination;
259
+ const present = names.filter((name) => existsSync(path.join(directory, name, "SKILL.md"))).length;
260
+ return { ...targetConfig, present, total: names.length, complete: present === names.length };
261
+ });
262
+ return { groups, packs: manifestPacks, names, targets };
263
+ }
264
+
265
+ async function pinnedCatalogSpec(spec, context) {
266
+ if (!existsSync(context.lockFile)) {
267
+ return spec;
268
+ }
269
+ const lock = await readJson(context.lockFile);
270
+ const { repository, revision } = lock.catalog ?? {};
271
+ if (!repository || !revision) {
272
+ return spec;
273
+ }
274
+ if (parseCatalogSpec(spec).repository !== repository) {
275
+ return spec;
276
+ }
277
+ return `${repository}#${revision}`;
278
+ }
279
+
280
+ // Resolve the catalog for an install context. The project lock pins the
281
+ // catalog commit for cross-device reproducibility; a refresh (bare
282
+ // `avenic skills`) intentionally bypasses the pin to pick up the latest.
283
+ export async function resolveInstallSource(options, { refresh = false } = {}) {
284
+ const spec = await loadDefaultCatalogSpec(options.environment);
285
+ const context = createInstallContext(options.global ?? false, options);
286
+ const pinnedSpec = refresh ? spec : await pinnedCatalogSpec(spec, context);
287
+ const catalogInfo = await ensureCatalog(pinnedSpec, {
288
+ environment: options.environment,
289
+ io: options.io ?? console,
290
+ });
291
+ const packageMetadata = existsSync(path.join(catalogInfo.catalogRoot, "package.json"))
292
+ ? await readJson(path.join(catalogInfo.catalogRoot, "package.json"))
293
+ : null;
294
+ return { ...catalogInfo, packageMetadata };
295
+ }
296
+
297
+ // Install Packs into a scope: resolve the catalog, resolve the Packs,
298
+ // hand the plan to the presentation hook, copy the Skills, and write the
299
+ // config/lock metadata. Bare invocations (no explicit Packs) refresh the
300
+ // configured Packs against the latest catalog.
301
+ export async function installPacks(context, explicitPacks = [], options = {}) {
302
+ const io = options.io ?? console;
303
+ if (!context.global && isCatalogDirectory(context.root)) {
304
+ fail("Run installation from a work project, not from the Avenic catalog");
305
+ }
306
+ const catalogInfo = await resolveInstallSource({
307
+ global: context.global,
308
+ cwd: context.root,
309
+ environment: context.environment,
310
+ io,
311
+ }, { refresh: explicitPacks.length === 0 });
312
+ const sourceConfig = await loadSources(catalogInfo.catalogRoot);
313
+ const catalog = await buildCatalog(sourceConfig, path.join(catalogInfo.catalogRoot, "skills"));
314
+ const packs = await loadPacks(catalogInfo.catalogRoot);
315
+ const packIds = await resolveInstallPacks(context, explicitPacks);
316
+ const resolvedPacks = resolvePacks(catalog, sourceConfig, packs, packIds);
317
+ await options.onPlan?.(resolvedPacks);
318
+ await installCopies(context, resolvedPacks, io);
319
+ await writeInstallMetadata(context, resolvedPacks, catalogInfo);
320
+ return { catalogInfo, packIds, resolvedPacks };
321
+ }
322
+
323
+ // Uninstall Packs from a scope: validate the request, reinstall the
324
+ // remaining Packs (common is always included and can never be removed),
325
+ // and rewrite the metadata. The no-argument full cleanup stays in the CLI.
326
+ export async function uninstallPacks(context, packArguments = [], options = {}) {
327
+ const io = options.io ?? console;
328
+ const requested = parsePackArguments(packArguments);
329
+ requested.forEach((packId) => assertSafeId(packId, "Pack id"));
330
+ const current = await installedPackIds(context);
331
+ if (!current) {
332
+ io.log(`No managed ${context.label.toLowerCase()} skills installation found`);
333
+ return { changed: false, removed: [], absent: requested, skippedCommon: false, current: null };
334
+ }
335
+ const removable = new Set(requested.filter((packId) => packId !== "common"));
336
+ const removed = current.filter((packId) => removable.has(packId));
337
+ const absent = requested.filter((packId) => packId !== "common" && !current.includes(packId));
338
+ const skippedCommon = requested.includes("common");
339
+ if (removed.length === 0) {
340
+ return { changed: false, removed: [], absent, skippedCommon, current };
341
+ }
342
+ const catalogInfo = await resolveInstallSource({
343
+ global: context.global,
344
+ cwd: context.root,
345
+ environment: context.environment,
346
+ io,
347
+ });
348
+ const sourceConfig = await loadSources(catalogInfo.catalogRoot);
349
+ const catalog = await buildCatalog(sourceConfig, path.join(catalogInfo.catalogRoot, "skills"));
350
+ const packs = await loadPacks(catalogInfo.catalogRoot);
351
+ const remaining = current.filter((packId) => !removable.has(packId));
352
+ const resolvedPacks = resolvePacks(catalog, sourceConfig, packs, remaining);
353
+ await options.onPlan?.(resolvedPacks, removed);
354
+ await installCopies(context, resolvedPacks, io);
355
+ await writeInstallMetadata(context, resolvedPacks, catalogInfo);
356
+ return { changed: true, removed, absent, skippedCommon, current, resolvedPacks };
357
+ }
@@ -0,0 +1,256 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fail } from "../util/fail.mjs";
5
+ import { isInside } from "../util/fs.mjs";
6
+ import { readJson, writeJson } from "../util/json.mjs";
7
+ import { assertSafeId, assertSafeSkillName } from "./ids.mjs";
8
+ import { saveSources } from "./sources.mjs";
9
+
10
+ export async function loadPacks(catalogRoot) {
11
+ const packsRoot = path.join(catalogRoot, "packs");
12
+ const files = (await readdir(packsRoot)).filter((file) => file.endsWith(".json")).sort();
13
+ const packs = new Map();
14
+ for (const file of files) {
15
+ const pack = await readJson(path.join(packsRoot, file));
16
+ assertSafeId(pack.id, "Pack id");
17
+ if (file !== `${pack.id}.json`) {
18
+ fail(`Pack filename must match its id: ${file} != ${pack.id}.json`);
19
+ }
20
+ if (!pack.name || !Array.isArray(pack.sources)) {
21
+ fail(`Invalid Pack: ${pack.id}`);
22
+ }
23
+ if (packs.has(pack.id)) {
24
+ fail(`Duplicate Pack: ${pack.id}`);
25
+ }
26
+ packs.set(pack.id, pack);
27
+ }
28
+ return packs;
29
+ }
30
+
31
+ export function resolvePack(catalog, sourceConfig, pack) {
32
+ const sourceById = new Map(sourceConfig.sources.map((source) => [source.id, source]));
33
+ const selectedNames = new Set();
34
+ const groups = [];
35
+ for (const selection of pack.sources) {
36
+ const source = sourceById.get(selection.source);
37
+ if (!source) {
38
+ fail(`Pack ${pack.id} references unknown source: ${selection.source}`);
39
+ }
40
+ if (!Array.isArray(selection.skills) || selection.skills.length === 0) {
41
+ fail(`Pack ${pack.id} has an empty source selection: ${selection.source}`);
42
+ }
43
+ const skills = [];
44
+ for (const name of selection.skills) {
45
+ assertSafeSkillName(name);
46
+ if (selectedNames.has(name)) {
47
+ fail(`Pack ${pack.id} contains duplicate Skill: ${name}`);
48
+ }
49
+ const skill = catalog.byName.get(name);
50
+ if (!skill) {
51
+ fail(`Pack ${pack.id} references missing Skill: ${name}`);
52
+ }
53
+ if (skill.source.id !== source.id) {
54
+ fail(`Pack ${pack.id} records the wrong source for ${name}: ${source.id}`);
55
+ }
56
+ selectedNames.add(name);
57
+ skills.push(skill);
58
+ }
59
+ groups.push({ source, skills });
60
+ }
61
+ return { groups, names: [...selectedNames], pack };
62
+ }
63
+
64
+ export function parsePackArguments(argumentsList) {
65
+ return argumentsList
66
+ .flatMap((value) => value.split(","))
67
+ .map((value) => value.trim().toLowerCase())
68
+ .filter(Boolean);
69
+ }
70
+
71
+ export function normalizePackIds(packIds) {
72
+ const unique = [...new Set(packIds)];
73
+ if (!unique.includes("common")) {
74
+ unique.unshift("common");
75
+ }
76
+ return unique;
77
+ }
78
+
79
+ export function resolvePacks(catalog, sourceConfig, packs, requestedPackIds) {
80
+ const packIds = normalizePackIds(requestedPackIds);
81
+ const selectedPacks = packIds.map((packId) => {
82
+ const pack = packs.get(packId);
83
+ if (!pack) {
84
+ fail(`Unknown Pack: ${packId}`);
85
+ }
86
+ return pack;
87
+ });
88
+ const sourceOrder = new Map(sourceConfig.sources.map((source, index) => [source.id, index]));
89
+ const groupsBySource = new Map();
90
+ const selectedNames = new Set();
91
+ let requestedSkills = 0;
92
+
93
+ for (const pack of selectedPacks) {
94
+ const resolved = resolvePack(catalog, sourceConfig, pack);
95
+ requestedSkills += resolved.names.length;
96
+ for (const group of resolved.groups) {
97
+ let combined = groupsBySource.get(group.source.id);
98
+ if (!combined) {
99
+ combined = { source: group.source, skills: [] };
100
+ groupsBySource.set(group.source.id, combined);
101
+ }
102
+ for (const skill of group.skills) {
103
+ if (!selectedNames.has(skill.name)) {
104
+ selectedNames.add(skill.name);
105
+ combined.skills.push(skill);
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ const groups = [...groupsBySource.values()]
112
+ .filter((group) => group.skills.length > 0)
113
+ .sort((left, right) => sourceOrder.get(left.source.id) - sourceOrder.get(right.source.id));
114
+ return {
115
+ groups,
116
+ names: [...selectedNames],
117
+ packs: selectedPacks,
118
+ duplicateSelections: requestedSkills - selectedNames.size,
119
+ };
120
+ }
121
+
122
+ export function packContainsSkill(pack, sourceId, skillName) {
123
+ return pack.sources.some(
124
+ (selection) => selection.source === sourceId && selection.skills.includes(skillName),
125
+ );
126
+ }
127
+
128
+ export function skillCoveredByPacks(packs, packIds, sourceId, skillName) {
129
+ if (packIds.some((packId) => packContainsSkill(packs.get(packId), sourceId, skillName))) {
130
+ return true;
131
+ }
132
+ const common = packs.get("common");
133
+ return Boolean(common && packContainsSkill(common, sourceId, skillName));
134
+ }
135
+
136
+ export function catalogReferences(packs) {
137
+ const references = new Set();
138
+ for (const pack of packs.values()) {
139
+ for (const selection of pack.sources) {
140
+ for (const skillName of selection.skills) {
141
+ references.add(`${selection.source}\0${skillName}`);
142
+ }
143
+ }
144
+ }
145
+ return references;
146
+ }
147
+
148
+ export async function addSkillsToPacks(catalogRoot, packIds, sourceId, skillNames) {
149
+ const packsRoot = path.join(catalogRoot, "packs");
150
+ const loadedPacks = new Map();
151
+ for (const packId of packIds) {
152
+ assertSafeId(packId, "Pack id");
153
+ const packFile = path.join(packsRoot, `${packId}.json`);
154
+ if (!isInside(packsRoot, packFile) || !existsSync(packFile)) {
155
+ fail(`Unknown Pack: ${packId}`);
156
+ }
157
+ loadedPacks.set(packId, { file: packFile, pack: await readJson(packFile) });
158
+ }
159
+
160
+ const commonEntry = loadedPacks.get("common") ?? {
161
+ file: path.join(packsRoot, "common.json"),
162
+ pack: await readJson(path.join(packsRoot, "common.json")),
163
+ };
164
+ const commonSkills = new Set(
165
+ commonEntry.pack.sources.flatMap((selection) => selection.skills),
166
+ );
167
+ if (loadedPacks.has("common")) {
168
+ skillNames.forEach((skillName) => commonSkills.add(skillName));
169
+ }
170
+
171
+ const added = [];
172
+ const inherited = new Set();
173
+ for (const packId of packIds) {
174
+ const { file: packFile, pack } = loadedPacks.get(packId);
175
+ const applicableSkills =
176
+ packId === "common"
177
+ ? skillNames
178
+ : skillNames.filter((skillName) => {
179
+ if (commonSkills.has(skillName)) {
180
+ inherited.add(skillName);
181
+ return false;
182
+ }
183
+ return true;
184
+ });
185
+ if (applicableSkills.length === 0) {
186
+ continue;
187
+ }
188
+ let selection = pack.sources.find((item) => item.source === sourceId);
189
+ if (!selection) {
190
+ selection = { source: sourceId, skills: [] };
191
+ pack.sources.push(selection);
192
+ }
193
+ let changed = false;
194
+ for (const skillName of applicableSkills) {
195
+ if (!selection.skills.includes(skillName)) {
196
+ selection.skills.push(skillName);
197
+ added.push({ packId, skillName });
198
+ changed = true;
199
+ }
200
+ }
201
+ if (changed) {
202
+ await writeJson(packFile, pack);
203
+ }
204
+ }
205
+ return { added, inherited: [...inherited] };
206
+ }
207
+
208
+ export async function pruneCatalogSkills(catalogRoot, sourceConfig, packs, candidates) {
209
+ const skillsRoot = path.join(catalogRoot, "skills");
210
+ const references = catalogReferences(packs);
211
+ const removed = [];
212
+ const affectedSources = new Set();
213
+ for (const candidate of candidates) {
214
+ const key = `${candidate.sourceId}\0${candidate.skillName}`;
215
+ if (references.has(key)) {
216
+ continue;
217
+ }
218
+ const directory = path.join(skillsRoot, candidate.sourceId, candidate.skillName);
219
+ if (existsSync(directory)) {
220
+ await rm(directory, { recursive: true, force: true });
221
+ removed.push(candidate);
222
+ affectedSources.add(candidate.sourceId);
223
+ }
224
+ const source = sourceConfig.sources.find((item) => item.id === candidate.sourceId);
225
+ if (source?.skillPaths?.[candidate.skillName]) {
226
+ delete source.skillPaths[candidate.skillName];
227
+ if (Object.keys(source.skillPaths).length === 0) {
228
+ delete source.skillPaths;
229
+ }
230
+ affectedSources.add(candidate.sourceId);
231
+ }
232
+ }
233
+
234
+ const removedSources = [];
235
+ for (const sourceId of affectedSources) {
236
+ const source = sourceConfig.sources.find((item) => item.id === sourceId);
237
+ if (!source) continue;
238
+ const directory = path.join(skillsRoot, sourceId);
239
+ const entries = existsSync(directory)
240
+ ? await readdir(directory, { withFileTypes: true })
241
+ : [];
242
+ if (entries.some((entry) => entry.isDirectory())) {
243
+ continue;
244
+ }
245
+ if (source.licenseFile) {
246
+ await rm(path.join(catalogRoot, source.licenseFile), { force: true });
247
+ }
248
+ await rm(directory, { recursive: true, force: true });
249
+ sourceConfig.sources = sourceConfig.sources.filter((item) => item.id !== sourceId);
250
+ removedSources.push(sourceId);
251
+ }
252
+ if (removed.length > 0 || affectedSources.size > 0) {
253
+ await saveSources(catalogRoot, sourceConfig);
254
+ }
255
+ return { removed, removedSources };
256
+ }
@@ -0,0 +1,92 @@
1
+ import { existsSync, renameSync, statSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+
6
+ export const PROJECT_CONFIG_FILE = ".avenic.json";
7
+ export const PROJECT_LOCK_FILE = ".avenic.lock.json";
8
+ export const LEGACY_PROJECT_CONFIG_FILE = ".agent-skills.json";
9
+ export const LEGACY_PROJECT_LOCK_FILE = ".agent-skills.lock.json";
10
+ export const LEGACY_PROFILE_FILE = ".agent-skills-profile"; // 保持旧名:仅历史读取
11
+
12
+ export function migrateLegacyProjectFiles(cwd) {
13
+ for (const [legacy, current] of [
14
+ [LEGACY_PROJECT_CONFIG_FILE, PROJECT_CONFIG_FILE],
15
+ [LEGACY_PROJECT_LOCK_FILE, PROJECT_LOCK_FILE],
16
+ ]) {
17
+ const from = path.join(cwd, legacy);
18
+ const to = path.join(cwd, current);
19
+ if (!existsSync(to) && existsSync(from)) renameSync(from, to); // 同目录原子 rename
20
+ }
21
+ }
22
+
23
+ export const PROJECT_TARGETS = [
24
+ { agents: ["claude-code"], label: "Claude Code", relativePath: [".claude", "skills"] },
25
+ {
26
+ agents: ["codex", "opencode", "universal"],
27
+ label: "Codex / OpenCode / universal agents",
28
+ relativePath: [".agents", "skills"],
29
+ },
30
+ ];
31
+
32
+ // Read an environment variable under its current name, falling back to a
33
+ // deprecated legacy name with a deprecation warning on stderr.
34
+ export function deprecatedEnvironmentValue(environment, primary, legacy) {
35
+ if (environment[primary]) return environment[primary];
36
+ if (environment[legacy]) {
37
+ console.warn(`Environment variable ${legacy} is deprecated; use ${primary}`);
38
+ return environment[legacy];
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ export function stateRoot(environment = process.env) {
44
+ const override = deprecatedEnvironmentValue(environment, "AVENIC_STATE_DIR", "AGENTHOME_STATE_DIR");
45
+ if (override) return override;
46
+ const root = environment.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
47
+ const current = path.join(root, "avenic");
48
+ const legacy = path.join(root, "agent-skills");
49
+ if (existsSync(legacy) && !statSync(current, { throwIfNoEntry: false })?.isDirectory()) {
50
+ // 新路径被同名非目录占用:不能迁移进去,也绝不覆盖删除,降级继续用旧目录
51
+ if (existsSync(current)) return legacy;
52
+ try {
53
+ renameSync(legacy, current); // 同父目录原子 rename,一次性迁移
54
+ } catch {
55
+ return legacy; // 迁移失败降级:继续用旧目录,不丢数据
56
+ }
57
+ }
58
+ return current;
59
+ }
60
+
61
+ export const GLOBAL_TARGETS = [
62
+ {
63
+ agents: ["claude-code"],
64
+ label: "Claude Code",
65
+ destination: path.join(os.homedir(), ".claude", "skills"),
66
+ },
67
+ {
68
+ agents: ["codex", "opencode", "universal"],
69
+ label: "Codex / OpenCode / universal agents",
70
+ destination: path.join(os.homedir(), ".agents", "skills"),
71
+ },
72
+ ];
73
+
74
+ export function catalogCacheRoot(environment = process.env) {
75
+ return path.join(stateRoot(environment), "catalog");
76
+ }
77
+
78
+ export function defaultCatalogFile(environment = process.env) {
79
+ return path.join(stateRoot(environment), "catalog.json");
80
+ }
81
+
82
+ export function knownCatalogsFile(environment = process.env) {
83
+ return path.join(stateRoot(environment), "catalogs.json");
84
+ }
85
+
86
+ export function globalConfigFile(environment = process.env) {
87
+ return path.join(stateRoot(environment), "config.json");
88
+ }
89
+
90
+ export function globalLockFile(environment = process.env) {
91
+ return path.join(stateRoot(environment), "lock.json");
92
+ }