@dsalazardev/ai-hub-cli 0.1.0 → 0.2.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.
@@ -4,14 +4,17 @@ import { stringify } from 'yaml';
4
4
  import { createBackup } from '../backup/snapshots.js';
5
5
  import { readAiHubRc, writeAiHubRc } from '../paths.js';
6
6
  import { detectPreset } from '../presets/detect.js';
7
- import { materializePreset } from '../presets/materialize.js';
7
+ import { materializeCatalog, materializePreset, materializeUniversalRules, } from '../presets/materialize.js';
8
8
  import { resolvePreset } from '../presets/resolve.js';
9
9
  import { listPresetIds } from '../presets/schema.js';
10
10
  import { loadManifestContext, writeInventory } from '../manifest/manifest.js';
11
11
  import { emptyState, migrateLegacyState, readState, StateNotFoundError, writeState } from '../harness/memory/state.js';
12
12
  import { runSync } from '../sync/sync.js';
13
- import { TOOL_IDS, assertToolIds } from '../sync/targets.js';
13
+ import { TOOL_IDS, assertToolIds, TARGETS } from '../sync/targets.js';
14
14
  import { sha256 } from '../sync/lock.js';
15
+ import { isEngineCheckout } from '../source-checkout.js';
16
+ import { ENGINE_AGENTS_DIR, ENGINE_RULES_DIR, catalogIndex, catalogSeal, expectedRuleIds, hasFullDimension, listCatalogAgents, listCatalogSkills, } from '../catalog.js';
17
+ import { loadEngine } from '../engine/engine.js';
15
18
  import { flagList, flagString } from './args.js';
16
19
  const RC_NAME = '.ai-hubrc.json';
17
20
  export async function cliVersion() {
@@ -27,9 +30,103 @@ function projectId(root) {
27
30
  async function exists(file) {
28
31
  return fs.access(file).then(() => true, () => false);
29
32
  }
30
- function manifestFromPreset(id, preset, targets, profilePackageManager, guardMode, includeMcp) {
31
- const rules = [...new Set(preset.rules.map((rule) => path.basename(rule, '.md')))];
33
+ function overallMode(modes) {
34
+ return modes.skills === modes.agents && modes.agents === modes.rules ? modes.skills : 'custom';
35
+ }
36
+ function parseExclusions(args) {
37
+ const skills = new Set();
38
+ const agents = new Set();
39
+ const rules = new Set();
40
+ for (const item of flagList(args, 'exclude')) {
41
+ const separator = item.indexOf(':');
42
+ const kind = separator >= 0 ? item.slice(0, separator) : 'skill';
43
+ const id = separator >= 0 ? item.slice(separator + 1) : item;
44
+ if (id === '') {
45
+ continue;
46
+ }
47
+ if (kind === 'agent') {
48
+ agents.add(id);
49
+ }
50
+ else if (kind === 'rule') {
51
+ if (id === 'core' || id === 'preflight') {
52
+ throw new Error(`la regla base "${id}" no se puede excluir`);
53
+ }
54
+ rules.add(id);
55
+ }
56
+ else {
57
+ skills.add(id);
58
+ }
59
+ }
60
+ return { skills, agents, rules };
61
+ }
62
+ function filterRules(rules, exclusions) {
63
+ return rules.filter((id) => {
64
+ for (const excluded of exclusions) {
65
+ if (excluded === id || (excluded === 'skill-quality' && id.startsWith('skill-quality/'))) {
66
+ return false;
67
+ }
68
+ }
69
+ return true;
70
+ });
71
+ }
72
+ async function buildSelection(args, preset, engine, existing) {
73
+ const fullFlag = Boolean(args.flags.full);
74
+ const presetOnly = Boolean(args.flags['preset-only']);
75
+ if (fullFlag && presetOnly) {
76
+ throw new Error('--full y --preset-only son incompatibles');
77
+ }
78
+ const exclusions = parseExclusions(args);
79
+ const hasExclusions = exclusions.skills.size > 0 || exclusions.agents.size > 0 || exclusions.rules.size > 0;
80
+ if (existing && !fullFlag && !presetOnly && !hasExclusions) {
81
+ return {
82
+ modes: {
83
+ skills: existing.skills.selection ?? 'preset',
84
+ agents: existing.agents.selection ?? 'preset',
85
+ rules: existing.rules.selection ?? 'preset',
86
+ },
87
+ skills: [...existing.skills.publish],
88
+ agents: [...existing.agents.publish],
89
+ rules: [...existing.rules.publish],
90
+ };
91
+ }
92
+ const catalogSkills = await listCatalogSkills(engine);
93
+ const catalogAgents = await listCatalogAgents();
94
+ const defaultMode = presetOnly
95
+ ? 'preset'
96
+ : fullFlag
97
+ ? 'full'
98
+ : existing
99
+ ? (existing.skills.selection ?? 'preset')
100
+ : 'full';
101
+ const modes = {
102
+ skills: exclusions.skills.size > 0 ? 'custom' : defaultMode,
103
+ agents: exclusions.agents.size > 0 ? 'custom' : defaultMode,
104
+ rules: exclusions.rules.size > 0 ? 'custom' : defaultMode,
105
+ };
106
+ const skillsBase = modes.skills === 'preset'
107
+ ? [...preset.skills]
108
+ : modes.skills === 'custom' && existing
109
+ ? [...existing.skills.publish]
110
+ : [...catalogSkills];
111
+ const agentsBase = modes.agents === 'preset'
112
+ ? [...preset.agents]
113
+ : modes.agents === 'custom' && existing
114
+ ? [...existing.agents.publish]
115
+ : [...catalogAgents];
116
+ const rulesBase = modes.rules === 'preset'
117
+ ? expectedRuleIds('preset', preset)
118
+ : modes.rules === 'custom' && existing
119
+ ? [...existing.rules.publish]
120
+ : expectedRuleIds('full', preset);
32
121
  return {
122
+ modes,
123
+ skills: [...new Set(skillsBase.filter((id) => !exclusions.skills.has(id)))].sort(),
124
+ agents: [...new Set(agentsBase.filter((id) => !exclusions.agents.has(id)))].sort(),
125
+ rules: [...new Set(filterRules(rulesBase, exclusions.rules))].sort(),
126
+ };
127
+ }
128
+ function manifestFromSelection(id, preset, targets, profilePackageManager, guardMode, includeMcp, selection) {
129
+ const manifest = {
33
130
  version: 2,
34
131
  project: { id, name: id },
35
132
  preset: { id: preset.id, version: preset.version, source: 'package' },
@@ -49,22 +146,111 @@ function manifestFromPreset(id, preset, targets, profilePackageManager, guardMod
49
146
  ? { dangerPatterns: preset.policy.dangerPatterns }
50
147
  : {}),
51
148
  },
52
- skills: { source: '.ai-hub/skills', publish: preset.skills },
53
- agents: { source: '.ai-hub/agents', publish: preset.agents },
54
- rules: { source: '.ai-hub/rules', publish: rules },
55
- ...(includeMcp ? { mcp: { source: preset.mcp.source } } : {}),
149
+ skills: { source: '.ai-hub/skills', publish: selection.skills, selection: selection.modes.skills },
150
+ agents: { source: '.ai-hub/agents', publish: selection.agents, selection: selection.modes.agents },
151
+ rules: { source: '.ai-hub/rules', publish: selection.rules, selection: selection.modes.rules },
152
+ ...(includeMcp ? { mcp: { source: 'mcp-manifest.yaml' } } : {}),
153
+ };
154
+ if (hasFullDimension(manifest)) {
155
+ manifest.catalog = catalogSeal(manifest);
156
+ }
157
+ return manifest;
158
+ }
159
+ function mergeMaterialized(target, source) {
160
+ target.written.push(...source.written);
161
+ target.kept.push(...source.kept);
162
+ target.collisions.push(...source.collisions);
163
+ }
164
+ async function materializeSelection(root, preset, selection) {
165
+ const result = { written: [], kept: [], collisions: [] };
166
+ mergeMaterialized(result, await materializePreset(root, preset, selection.rules));
167
+ if (selection.modes.rules !== 'preset') {
168
+ mergeMaterialized(result, await materializeUniversalRules(root, preset, selection.rules));
169
+ }
170
+ if (selection.modes.skills !== 'preset' || selection.modes.agents !== 'preset') {
171
+ const presetSkills = new Set(preset.skills);
172
+ const presetAgents = new Set(preset.agents);
173
+ mergeMaterialized(result, await materializeCatalog(root, selection.modes.skills === 'preset'
174
+ ? []
175
+ : selection.skills.filter((id) => !presetSkills.has(id)), selection.modes.agents === 'preset'
176
+ ? []
177
+ : selection.agents.filter((id) => !presetAgents.has(id))));
178
+ }
179
+ return result;
180
+ }
181
+ async function buildAssetPlan(selection, engine, targets, includeMcp) {
182
+ const index = await catalogIndex();
183
+ const engineSkills = engine.commands.map((command) => command.skill);
184
+ const allSkills = [...new Set([...engineSkills, ...selection.skills])];
185
+ const projectSkillFiles = selection.skills.reduce((total, id) => total + (index.skillFiles[id]?.length ?? 0), 0);
186
+ const skillFiles = allSkills.reduce((total, id) => total + (index.skillFiles[id]?.length ?? 0), 0);
187
+ let destinations = 1; // opencode.json
188
+ for (const tool of targets) {
189
+ const target = TARGETS[tool];
190
+ destinations += target.skillShape === 'file' ? allSkills.length : skillFiles;
191
+ if (target.agents) {
192
+ destinations += selection.agents.length;
193
+ }
194
+ destinations += selection.rules.length + 1; // reglas materializadas + preflight
195
+ }
196
+ if (includeMcp) {
197
+ destinations += 3;
198
+ }
199
+ const materializedFiles = projectSkillFiles + selection.agents.length + selection.rules.length + 2;
200
+ const files = materializedFiles + destinations;
201
+ let bytes = 0;
202
+ const stat = async (file) => fs.stat(file).then((value) => value.size, () => 0);
203
+ for (const id of allSkills) {
204
+ for (const file of index.skillFiles[id] ?? []) {
205
+ bytes += await stat(file);
206
+ }
207
+ }
208
+ for (const id of selection.agents) {
209
+ bytes += await stat(path.join(ENGINE_AGENTS_DIR, `${id}.md`));
210
+ }
211
+ for (const id of selection.rules) {
212
+ if (id.startsWith('skill-quality/')) {
213
+ bytes += await stat(path.join(ENGINE_RULES_DIR, `${id}.md`));
214
+ }
215
+ }
216
+ return {
217
+ selection: overallMode(selection.modes),
218
+ selections: selection.modes,
219
+ skills: selection.skills,
220
+ agents: selection.agents,
221
+ rules: selection.rules,
222
+ destinations,
223
+ files,
224
+ bytes,
56
225
  };
57
226
  }
58
227
  export async function writeManifest(root, manifest) {
59
228
  await fs.writeFile(path.join(root, 'manifest.yaml'), stringify(manifest), 'utf8');
60
229
  }
230
+ const GENERATED_IGNORES = [
231
+ '.worktrees/',
232
+ '.claude/',
233
+ '.opencode/',
234
+ '.pi/',
235
+ '.cursor/',
236
+ '.github/skills/',
237
+ '.github/agents/',
238
+ '.github/instructions/',
239
+ '.mcp.json',
240
+ '.cursor/mcp.json',
241
+ '.vscode/mcp.json',
242
+ 'opencode.json',
243
+ ];
61
244
  async function ensureProjectIgnores(root) {
62
245
  const file = path.join(root, '.gitignore');
63
246
  const current = await fs.readFile(file, 'utf8').catch(() => '');
64
- if (!current.split(/\r?\n/).includes('.worktrees/')) {
65
- const next = current === '' ? '.worktrees/\n' : `${current.replace(/\s*$/, '')}\n.worktrees/\n`;
66
- await fs.writeFile(file, next, 'utf8');
247
+ const present = new Set(current.split(/\r?\n/).map((line) => line.trim()));
248
+ const missing = GENERATED_IGNORES.filter((entry) => !present.has(entry));
249
+ if (missing.length === 0) {
250
+ return;
67
251
  }
252
+ const base = current === '' ? '' : `${current.replace(/\s*$/, '')}\n`;
253
+ await fs.writeFile(file, `${base}${missing.join('\n')}\n`, 'utf8');
68
254
  }
69
255
  async function installPostMergeHook(root) {
70
256
  if (!(await exists(path.join(root, '.git')))) {
@@ -102,9 +288,13 @@ async function resolveSelection(args, ctx) {
102
288
  };
103
289
  }
104
290
  export async function cmdInit(args, ctx) {
291
+ const sourceCheckout = await isEngineCheckout(ctx.root);
105
292
  let selection;
106
293
  try {
107
294
  selection = await resolveSelection(args, ctx);
295
+ if (selection.migrate && !sourceCheckout) {
296
+ throw new Error('--migrate solo aplica en el checkout de desarrollo; la instalación no tiene .planning/');
297
+ }
108
298
  }
109
299
  catch (error) {
110
300
  ctx.io.err(error.message);
@@ -118,7 +308,22 @@ export async function cmdInit(args, ctx) {
118
308
  targets: selection.targets,
119
309
  guardMode: 'shadow',
120
310
  };
121
- const write = [RC_NAME, 'manifest.yaml', '.ai-hub/'];
311
+ const engine = await loadEngine();
312
+ let existing;
313
+ if (await exists(path.join(ctx.root, 'manifest.yaml'))) {
314
+ existing = (await loadManifestContext(ctx.root).catch(() => null))?.manifest;
315
+ }
316
+ let spec;
317
+ try {
318
+ spec = await buildSelection(args, selection.preset, engine, existing);
319
+ }
320
+ catch (error) {
321
+ ctx.io.err(error.message);
322
+ return 2;
323
+ }
324
+ const includeMcp = await exists(path.join(ctx.root, 'mcp-manifest.yaml'));
325
+ const assets = await buildAssetPlan(spec, engine, selection.targets, includeMcp);
326
+ const write = [RC_NAME, 'manifest.yaml', '.gitignore', '.ai-hub/'];
122
327
  if (selection.migrate) {
123
328
  write.push('.planning/state.json');
124
329
  }
@@ -127,6 +332,11 @@ export async function cmdInit(args, ctx) {
127
332
  project: id,
128
333
  preset: selection.preset.id,
129
334
  targets: selection.targets,
335
+ ...assets,
336
+ skillCount: 17 + spec.skills.length,
337
+ agentCount: spec.agents.length,
338
+ ruleCount: spec.rules.length + 1,
339
+ mcp: includeMcp,
130
340
  migrate: selection.migrate,
131
341
  write,
132
342
  };
@@ -138,44 +348,100 @@ export async function cmdInit(args, ctx) {
138
348
  }
139
349
  return 0;
140
350
  }
141
- if (await exists(path.join(ctx.root, 'manifest.yaml'))) {
351
+ if (existing) {
142
352
  await createBackup(ctx.root, 'init');
143
353
  }
144
- const materialized = await materializePreset(ctx.root, selection.preset);
145
- const includeMcp = await exists(path.join(ctx.root, selection.preset.mcp.source));
146
- const manifest = manifestFromPreset(id, selection.preset, selection.targets, selection.profilePackageManager, rc.guardMode ?? 'shadow', includeMcp);
354
+ const materialized = await materializeSelection(ctx.root, selection.preset, spec);
355
+ const manifest = manifestFromSelection(id, selection.preset, selection.targets, selection.profilePackageManager, rc.guardMode ?? 'shadow', includeMcp, spec);
147
356
  await writeManifest(ctx.root, manifest);
148
357
  await writeInventory(ctx.root);
358
+ const sync = await runSync(ctx.root, { check: false });
149
359
  const existingRc = readAiHubRc(ctx.cwd);
150
360
  if (!existingRc || existingRc.version === 1 || args.flags.force) {
151
361
  writeAiHubRc(ctx.cwd, rc);
152
362
  }
153
- try {
154
- await readState(ctx.root);
155
- }
156
- catch (error) {
157
- if (error instanceof StateNotFoundError) {
158
- await writeState(ctx.root, emptyState(id, selection.preset.id));
363
+ if (sourceCheckout) {
364
+ try {
365
+ await readState(ctx.root);
159
366
  }
160
- else {
161
- throw error;
367
+ catch (error) {
368
+ if (error instanceof StateNotFoundError) {
369
+ await writeState(ctx.root, emptyState(id, selection.preset.id));
370
+ }
371
+ else {
372
+ throw error;
373
+ }
374
+ }
375
+ if (selection.migrate) {
376
+ await migrateLegacyState(ctx.root, id, selection.preset.id);
162
377
  }
163
- }
164
- if (selection.migrate) {
165
- await migrateLegacyState(ctx.root, id, selection.preset.id);
166
378
  }
167
379
  await ensureProjectIgnores(ctx.root);
168
380
  if (args.flags.hooks) {
169
381
  const hook = await installPostMergeHook(ctx.root);
170
382
  ctx.io.out(`hooks: ${hook.installed ? 'post-merge instalado' : hook.reason}`);
171
383
  }
172
- ctx.io.out(`init: ${selection.preset.id} · ${selection.targets.join(', ')} · ${materialized.written.length} archivos nuevos, ${materialized.kept.length} conservados, ${materialized.collisions.length} colisiones`);
384
+ ctx.io.out(`init: ${selection.preset.id} · skills=${spec.modes.skills} agents=${spec.modes.agents} rules=${spec.modes.rules} · ${spec.skills.length + 17} skills · ${spec.agents.length} agentes · ${spec.rules.length + 1} reglas · ${assets.destinations} destinos · ${sync.written.length} sincronizados · ${materialized.written.length} nuevos, ${materialized.kept.length} conservados, ${materialized.collisions.length} colisiones`);
173
385
  if (materialized.collisions.length > 0) {
174
386
  ctx.io.err(`colisiones (no se sobrescribieron): ${materialized.collisions.join(', ')}`);
175
387
  }
176
388
  ctx.io.out(`plan: ${plan.hash}`);
177
389
  return 0;
178
390
  }
391
+ async function selectionForInstall(args, manifest, preset, engine) {
392
+ const fullFlag = Boolean(args.flags.full);
393
+ const presetOnly = Boolean(args.flags['preset-only']);
394
+ if (fullFlag && presetOnly) {
395
+ throw new Error('--full y --preset-only son incompatibles');
396
+ }
397
+ const exclusions = parseExclusions(args);
398
+ const modes = {
399
+ skills: exclusions.skills.size > 0
400
+ ? 'custom'
401
+ : presetOnly
402
+ ? 'preset'
403
+ : fullFlag
404
+ ? 'full'
405
+ : (manifest.skills.selection ?? 'preset'),
406
+ agents: exclusions.agents.size > 0
407
+ ? 'custom'
408
+ : presetOnly
409
+ ? 'preset'
410
+ : fullFlag
411
+ ? 'full'
412
+ : (manifest.agents.selection ?? 'preset'),
413
+ rules: exclusions.rules.size > 0
414
+ ? 'custom'
415
+ : presetOnly
416
+ ? 'preset'
417
+ : fullFlag
418
+ ? 'full'
419
+ : (manifest.rules.selection ?? 'preset'),
420
+ };
421
+ const catalogSkills = await listCatalogSkills(engine);
422
+ const catalogAgents = await listCatalogAgents();
423
+ const skills = modes.skills === 'full'
424
+ ? catalogSkills
425
+ : modes.skills === 'preset'
426
+ ? [...preset.skills]
427
+ : [...manifest.skills.publish];
428
+ const agents = modes.agents === 'full'
429
+ ? catalogAgents
430
+ : modes.agents === 'preset'
431
+ ? [...preset.agents]
432
+ : [...manifest.agents.publish];
433
+ const rulesBase = modes.rules === 'full'
434
+ ? expectedRuleIds('full', preset)
435
+ : modes.rules === 'preset'
436
+ ? expectedRuleIds('preset', preset)
437
+ : [...manifest.rules.publish];
438
+ return {
439
+ modes,
440
+ skills: [...new Set(skills.filter((id) => !exclusions.skills.has(id)))].sort(),
441
+ agents: [...new Set(agents.filter((id) => !exclusions.agents.has(id)))].sort(),
442
+ rules: [...new Set(filterRules(rulesBase, exclusions.rules))].sort(),
443
+ };
444
+ }
179
445
  export async function cmdInstall(args, ctx) {
180
446
  let context;
181
447
  try {
@@ -185,16 +451,50 @@ export async function cmdInstall(args, ctx) {
185
451
  ctx.io.err(error.message);
186
452
  return 1;
187
453
  }
454
+ let spec;
455
+ try {
456
+ spec = await selectionForInstall(args, context.manifest, context.preset, context.engine);
457
+ }
458
+ catch (error) {
459
+ ctx.io.err(error.message);
460
+ return 2;
461
+ }
462
+ const includeMcp = await exists(path.join(ctx.root, 'mcp-manifest.yaml'));
188
463
  if (args.flags['dry-run']) {
464
+ const assets = await buildAssetPlan(spec, context.engine, context.manifest.targets, includeMcp);
189
465
  ctx.io.out(JSON.stringify({
190
466
  dryRun: true,
191
467
  action: 'install',
192
468
  preset: context.preset.id,
193
469
  targets: context.manifest.targets,
470
+ selection: spec.modes,
471
+ skills: spec.skills.length,
472
+ agents: spec.agents.length,
473
+ rules: spec.rules.length + 1,
474
+ destinations: assets.destinations,
194
475
  }, null, 2));
195
476
  return 0;
196
477
  }
197
- const materialized = await materializePreset(ctx.root, context.preset);
478
+ const materialized = await materializeSelection(ctx.root, context.preset, spec);
479
+ const next = {
480
+ ...context.manifest,
481
+ skills: { ...context.manifest.skills, publish: spec.skills, selection: spec.modes.skills },
482
+ agents: { ...context.manifest.agents, publish: spec.agents, selection: spec.modes.agents },
483
+ rules: { ...context.manifest.rules, publish: spec.rules, selection: spec.modes.rules },
484
+ };
485
+ if (includeMcp) {
486
+ next.mcp = { source: 'mcp-manifest.yaml' };
487
+ }
488
+ else {
489
+ delete next.mcp;
490
+ }
491
+ if (hasFullDimension(next)) {
492
+ next.catalog = catalogSeal(next);
493
+ }
494
+ else {
495
+ delete next.catalog;
496
+ }
497
+ await writeManifest(ctx.root, next);
198
498
  const sync = await runSync(ctx.root, { check: false });
199
499
  await writeInventory(ctx.root);
200
500
  await ensureProjectIgnores(ctx.root);
@@ -202,32 +502,14 @@ export async function cmdInstall(args, ctx) {
202
502
  const hook = await installPostMergeHook(ctx.root);
203
503
  ctx.io.out(`hooks: ${hook.installed ? 'post-merge instalado' : hook.reason}`);
204
504
  }
205
- ctx.io.out(`install: ${materialized.written.length} nuevos · ${materialized.kept.length} conservados · ${sync.written.length} sincronizados`);
505
+ ctx.io.out(`install: skills=${spec.modes.skills} agents=${spec.modes.agents} rules=${spec.modes.rules} · ${spec.skills.length + 17} skills · ${spec.agents.length} agentes · ${spec.rules.length + 1} reglas · ${materialized.written.length} nuevos · ${materialized.kept.length} conservados · ${sync.written.length} sincronizados`);
506
+ if (!Object.values(spec.modes).includes('full')) {
507
+ ctx.io.out('full: no aplica; usa install --full para expandir el catálogo');
508
+ }
206
509
  return 0;
207
510
  }
208
511
  export async function cmdRepair(args, ctx) {
209
- let context;
210
- try {
211
- context = await loadManifestContext(ctx.root);
212
- }
213
- catch (error) {
214
- ctx.io.err(error.message);
215
- return 1;
216
- }
217
- if (args.flags['dry-run']) {
218
- ctx.io.out(JSON.stringify({
219
- dryRun: true,
220
- action: 'repair',
221
- preset: context.preset.id,
222
- targets: context.manifest.targets,
223
- }, null, 2));
224
- return 0;
225
- }
226
- const materialized = await materializePreset(ctx.root, context.preset);
227
- const sync = await runSync(ctx.root, { check: false });
228
- await writeInventory(ctx.root);
229
- ctx.io.out(`repair: ${materialized.written.length} scaffold · ${sync.written.length} archivos · ${sync.removed.length} eliminados`);
230
- return 0;
512
+ return cmdInstall(args, ctx);
231
513
  }
232
514
  export async function availablePresets() {
233
515
  return listPresetIds();
package/dist/cli/run.js CHANGED
@@ -101,8 +101,8 @@ async function cmdCheck(_args, ctx) {
101
101
  }
102
102
  export const COMMANDS = {
103
103
  init: { summary: 'Detecta el stack, elige preset y aplica un plan reproducible', handler: cmdInit },
104
- install: { summary: 'Materializa el preset y sincroniza los destinos', handler: cmdInstall },
105
- add: { summary: 'Añade una skill o un agente del preset', handler: cmdAdd },
104
+ install: { summary: 'Materializa el catálogo o el preset según la selección y sincroniza', handler: cmdInstall },
105
+ add: { summary: 'Añade una skill o cualquier agente del catálogo', handler: cmdAdd },
106
106
  sync: { summary: 'Proyecta las fuentes a los destinos; --check informa deriva', handler: cmdSync },
107
107
  drift: { summary: 'Informa deriva de hash sin escribir', handler: cmdDrift },
108
108
  list: { summary: 'Lista preset, targets, comandos, skills y agentes', handler: cmdList },
@@ -110,7 +110,7 @@ export const COMMANDS = {
110
110
  check: { summary: 'Alias de doctor', handler: cmdCheck },
111
111
  backups: { summary: 'Lista los snapshots disponibles', handler: cmdBackups },
112
112
  restore: { summary: 'Restaura un snapshot', handler: cmdRestore },
113
- uninstall: { summary: 'Elimina una skill o agente añadido', handler: cmdUninstall },
113
+ uninstall: { summary: 'Elimina una skill, agente o regla añadida', handler: cmdUninstall },
114
114
  purge: { summary: 'Elimina el arnés tras crear un snapshot', handler: cmdPurge },
115
115
  upgrade: { summary: 'Actualiza el paquete y re-sincroniza', handler: cmdUpgrade },
116
116
  repair: { summary: 'Reaplica solo lo que falta o está roto', handler: cmdRepair },
@@ -132,6 +132,8 @@ export function printHelp(io) {
132
132
  io.out(` ${name.padEnd(10)} ${COMMANDS[name].summary}`);
133
133
  }
134
134
  io.out('');
135
+ io.out('add agent acepta cualquier agente del catálogo, no solo los del preset.');
136
+ io.out('Publicación: prepublishOnly ejecuta build, manifest:build y npm test (unit + integración).');
135
137
  io.out('Compatibilidad: verify, judge (fuera del catálogo).');
136
138
  }
137
139
  export async function createContext(cwd, io) {
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Ejecuta `fn` sobre `items` con un máximo de promesas en vuelo, preservando
3
+ * el orden de los resultados. Se usa para el I/O masivo de sync y materialización.
4
+ */
5
+ export declare function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Ejecuta `fn` sobre `items` con un máximo de promesas en vuelo, preservando
3
+ * el orden de los resultados. Se usa para el I/O masivo de sync y materialización.
4
+ */
5
+ export async function mapLimit(items, limit, fn) {
6
+ if (items.length === 0) {
7
+ return [];
8
+ }
9
+ const results = new Array(items.length);
10
+ let next = 0;
11
+ const workers = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
12
+ for (;;) {
13
+ const index = next;
14
+ next += 1;
15
+ if (index >= items.length) {
16
+ return;
17
+ }
18
+ results[index] = await fn(items[index], index);
19
+ }
20
+ });
21
+ await Promise.all(workers);
22
+ return results;
23
+ }
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { isEngineCheckout } from '../source-checkout.js';
4
+ import { ENGINE_AGENTS_DIR, catalogIndex } from '../catalog.js';
5
+ import { ENGINE_SKILLS_DIR } from './engine.js';
4
6
  const LEGACY_SOURCE_DIRS = ['core/presets', 'core/skills', 'core/agents'];
5
7
  const LEGACY_TARGET_DIRS = [
6
8
  '.claude',
@@ -16,6 +18,23 @@ const TEXT_FILE = /\.(md|mdc|ya?ml|json|ts|js|mjs|sh|ps1|txt)$/i;
16
18
  function isLegacyName(name) {
17
19
  return name === 'opsx' || name === 'openspec' || name.startsWith('opsx-') || name.startsWith('openspec-');
18
20
  }
21
+ async function scanFile(file, relative, findings) {
22
+ const name = path.basename(file);
23
+ if (isLegacyName(name)) {
24
+ findings.push(`${relative} (nombre legacy)`);
25
+ return;
26
+ }
27
+ if (!TEXT_FILE.test(name)) {
28
+ return;
29
+ }
30
+ const text = await fs.readFile(file, 'utf8').catch(() => '');
31
+ for (const marker of LEGACY_CONTENT) {
32
+ if (text.includes(marker)) {
33
+ findings.push(`${relative} (contiene "${marker}")`);
34
+ break;
35
+ }
36
+ }
37
+ }
19
38
  async function walk(dir, relative, findings) {
20
39
  const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null);
21
40
  if (entries === null) {
@@ -31,27 +50,46 @@ async function walk(dir, relative, findings) {
31
50
  await walk(path.join(dir, entry.name), childRelative, findings);
32
51
  continue;
33
52
  }
34
- if (!entry.isFile() || !TEXT_FILE.test(entry.name)) {
35
- continue;
53
+ if (entry.isFile()) {
54
+ await scanFile(path.join(dir, entry.name), childRelative, findings);
36
55
  }
37
- const text = await fs.readFile(path.join(dir, entry.name), 'utf8').catch(() => '');
38
- for (const marker of LEGACY_CONTENT) {
39
- if (text.includes(marker)) {
40
- findings.push(`${childRelative} (contiene "${marker}")`);
41
- break;
42
- }
56
+ }
57
+ }
58
+ async function scanInstalledCatalog(findings) {
59
+ const index = await catalogIndex();
60
+ for (const id of index.skills) {
61
+ for (const file of index.skillFiles[id] ?? []) {
62
+ const relative = `@engine/skills/shared/${id}/${path
63
+ .relative(path.join(ENGINE_SKILLS_DIR, id), file)
64
+ .split(path.sep)
65
+ .join('/')}`;
66
+ await scanFile(file, relative, findings);
67
+ }
68
+ }
69
+ for (const id of index.agents) {
70
+ await scanFile(path.join(ENGINE_AGENTS_DIR, `${id}.md`), `@engine/agents/${id}.md`, findings);
71
+ }
72
+ for (const id of Object.keys(index.ruleFiles)) {
73
+ for (const file of index.ruleFiles[id] ?? []) {
74
+ await scanFile(file, `@engine/rules/${id}.md`, findings);
43
75
  }
44
76
  }
45
77
  }
46
78
  export async function scanLegacyArtifacts(root) {
47
79
  const findings = [];
48
- // En el checkout del motor los destinos de agente son el taller del mantenedor,
49
- // no salida de instalación: ahí OpenSpec es legítimo. En un consumidor sí se escanean.
50
- const dirs = (await isEngineCheckout(root))
51
- ? LEGACY_SOURCE_DIRS
52
- : [...LEGACY_SOURCE_DIRS, ...LEGACY_TARGET_DIRS];
53
- for (const relative of dirs) {
54
- await walk(path.join(root, ...relative.split('/')), relative, findings);
80
+ const engineCheckout = await isEngineCheckout(root);
81
+ if (engineCheckout) {
82
+ // En el checkout del motor los destinos de agente son el taller del mantenedor.
83
+ for (const relative of LEGACY_SOURCE_DIRS) {
84
+ await walk(path.join(root, ...relative.split('/')), relative, findings);
85
+ }
86
+ }
87
+ else {
88
+ // En una instalación se escanean el proyecto, los destinos y el catálogo del paquete.
89
+ for (const relative of ['.ai-hub', ...LEGACY_TARGET_DIRS]) {
90
+ await walk(path.join(root, ...relative.split('/')), relative, findings);
91
+ }
92
+ await scanInstalledCatalog(findings);
55
93
  }
56
94
  return findings.sort();
57
95
  }