@aiwg/cli 2026.7.20 → 2026.7.23

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 (62) hide show
  1. package/README.md +18 -7
  2. package/dist/src/api/index.d.ts +2 -0
  3. package/dist/src/api/index.js +2 -0
  4. package/dist/src/artifacts/browser-export.js +7 -0
  5. package/dist/src/artifacts/citation-parser.js +96 -35
  6. package/dist/src/artifacts/index-builder.js +54 -17
  7. package/dist/src/artifacts/state-transfer.js +27 -0
  8. package/dist/src/artifacts/stats.js +8 -0
  9. package/dist/src/cli/cli-extension-loader.js +73 -0
  10. package/dist/src/cli/handlers/index.js +3 -1
  11. package/dist/src/cli/handlers/sessions.js +1265 -0
  12. package/dist/src/cli/handlers/skill-lint.js +49 -45
  13. package/dist/src/cli/handlers/use.js +143 -60
  14. package/dist/src/cli/handlers/utilities.js +22 -8
  15. package/dist/src/cli/skill-usage.js +146 -24
  16. package/dist/src/config/aiwg-config.js +12 -0
  17. package/dist/src/config/cli.js +16 -3
  18. package/dist/src/extensions/commands/definitions.js +29 -0
  19. package/dist/src/extensions/manifest.js +29 -0
  20. package/dist/src/security/threat-assessment-config.js +296 -0
  21. package/dist/src/sessions/adapters/claude.js +385 -0
  22. package/dist/src/sessions/adapters/codex.js +548 -0
  23. package/dist/src/sessions/adapters/copilot.js +226 -0
  24. package/dist/src/sessions/adapters/cursor.js +528 -0
  25. package/dist/src/sessions/adapters/factory.js +386 -0
  26. package/dist/src/sessions/adapters/generic.js +225 -0
  27. package/dist/src/sessions/adapters/hermes.js +341 -0
  28. package/dist/src/sessions/adapters/openclaw.js +381 -0
  29. package/dist/src/sessions/adapters/opencode.js +454 -0
  30. package/dist/src/sessions/adapters/openhuman.js +315 -0
  31. package/dist/src/sessions/adapters/warp.js +160 -0
  32. package/dist/src/sessions/adapters/windsurf.js +212 -0
  33. package/dist/src/sessions/batch-contracts.js +121 -0
  34. package/dist/src/sessions/batch-import.js +265 -0
  35. package/dist/src/sessions/candidates.js +210 -0
  36. package/dist/src/sessions/contracts.js +337 -0
  37. package/dist/src/sessions/discovery.js +51 -0
  38. package/dist/src/sessions/fixtures.js +12 -0
  39. package/dist/src/sessions/import-lease.js +152 -0
  40. package/dist/src/sessions/importer.js +464 -0
  41. package/dist/src/sessions/index.js +31 -0
  42. package/dist/src/sessions/knowledge-shard.js +61 -0
  43. package/dist/src/sessions/optional-backends.js +238 -0
  44. package/dist/src/sessions/origin.js +117 -0
  45. package/dist/src/sessions/policy.js +192 -0
  46. package/dist/src/sessions/ports.js +2 -0
  47. package/dist/src/sessions/promotion.js +367 -0
  48. package/dist/src/sessions/readers.js +176 -0
  49. package/dist/src/sessions/repository.js +1892 -0
  50. package/dist/src/sessions/timeline.js +148 -0
  51. package/dist/src/sessions/workspace-discovery.js +319 -0
  52. package/dist/src/skills/adapters/agent-skills.js +59 -0
  53. package/dist/src/skills/adapters/local.js +19 -1
  54. package/dist/src/skills/agent-skills.js +249 -0
  55. package/dist/src/skills/cli.js +463 -7
  56. package/dist/src/skills/deployer.js +554 -0
  57. package/dist/src/skills/doctor.js +105 -0
  58. package/dist/src/skills/exporter.js +382 -0
  59. package/dist/src/skills/importer.js +921 -0
  60. package/dist/src/skills/registry.js +19 -0
  61. package/dist/src/skills/validator.js +323 -0
  62. package/package.json +2 -2
@@ -1,12 +1,16 @@
1
1
  import { mkdir, readFile, rename, stat, writeFile, appendFile, readdir } from 'fs/promises';
2
- import { existsSync } from 'fs';
2
+ import { createReadStream, existsSync } from 'fs';
3
3
  import { createHash } from 'crypto';
4
+ import { createInterface } from 'readline';
4
5
  import path from 'path';
5
6
  import os from 'os';
6
7
  import { readAiwgConfig } from '../config/aiwg-config.js';
7
8
  import { PROJECT_AIWG_LOCATION_FILE, projectAiwgPath } from '../config/project-artifacts.js';
8
9
  const DEFAULT_MAX_BYTES = 1_048_576;
9
10
  const DEFAULT_REPORT_LIMIT = 20;
11
+ const MAX_TRANSCRIPT_LINE_BYTES = 1_048_576;
12
+ const MAX_TRANSCRIPT_BYTES = 256 * 1_048_576;
13
+ const MAX_TRANSCRIPT_RECORDS = 1_000_000;
10
14
  export async function maybeAppendSkillUsage(input) {
11
15
  const env = input.env ?? process.env;
12
16
  const resolved = await resolveSkillUsageSettings(input.cwd, env);
@@ -43,15 +47,38 @@ export async function ingestSkillUsageTranscript(options) {
43
47
  const contextCwd = options.projectRoot ?? options.cwd;
44
48
  const resolved = await resolveSkillUsageSettings(contextCwd, env);
45
49
  if (!resolved.enabled || resolved.scopes.length === 0) {
46
- return { events: [], appended: 0, skipped: 0, paths: [] };
50
+ return {
51
+ events: [], appended: 0, skipped: 0, paths: [],
52
+ receipt: {
53
+ source_generation: '', provider: normalizeProvider(options.provider),
54
+ records_read: 0, events_extracted: 0, events_appended: 0,
55
+ duplicates_skipped: 0, malformed_skipped: 0,
56
+ },
57
+ };
58
+ }
59
+ const provider = normalizeProvider(options.provider);
60
+ if (provider !== 'claude-code') {
61
+ throw new Error(`unsupported skill-usage transcript provider: ${provider}`);
47
62
  }
48
- const raw = await readFile(options.transcriptPath, 'utf8');
49
- const lines = raw.split('\n').map(line => line.trim()).filter(Boolean);
63
+ const sourceStat = await stat(options.transcriptPath);
64
+ const sourceGeneration = digest([
65
+ provider, String(sourceStat.size), String(sourceStat.mtimeMs),
66
+ ].join('\0'));
50
67
  const version = await readAiwgVersion(options.frameworkRoot);
51
68
  const context = await resolvePathContext(contextCwd);
52
69
  const extracted = [];
53
70
  let skipped = 0;
54
- for (const line of lines) {
71
+ let recordsRead = 0;
72
+ let bytesRead = 0;
73
+ for await (const line of streamTranscriptLines(options.transcriptPath)) {
74
+ recordsRead += 1;
75
+ bytesRead += Buffer.byteLength(line) + 1;
76
+ if (recordsRead > MAX_TRANSCRIPT_RECORDS || bytesRead > MAX_TRANSCRIPT_BYTES) {
77
+ throw new Error('skill-usage transcript exceeds bounded ingestion limits');
78
+ }
79
+ if (Buffer.byteLength(line) > MAX_TRANSCRIPT_LINE_BYTES) {
80
+ throw new Error('skill-usage transcript record exceeds bounded line limit');
81
+ }
55
82
  let parsed;
56
83
  try {
57
84
  parsed = JSON.parse(line);
@@ -65,22 +92,50 @@ export async function ingestSkillUsageTranscript(options) {
65
92
  skipped += 1;
66
93
  }
67
94
  else {
68
- extracted.push(...events);
95
+ extracted.push(...events.map(event => ({
96
+ ...event,
97
+ occurredAt: sourceOccurrenceTime(parsed),
98
+ nativeEventId: sourceNativeEventId(parsed),
99
+ position: recordsRead,
100
+ contentDigest: digest(line),
101
+ })));
69
102
  }
70
103
  }
71
104
  const persisted = [];
72
105
  const paths = resolved.scopes
73
106
  .map(scope => resolveUsagePath(scope, context.projectRoot, env))
74
107
  .filter((p) => Boolean(p));
108
+ const knownEventIdsByPath = new Map();
109
+ for (const filePath of paths) {
110
+ const knownEventIds = new Set();
111
+ for (const event of await readJsonlEvents(filePath)) {
112
+ if (event.event_id)
113
+ knownEventIds.add(event.event_id);
114
+ }
115
+ knownEventIdsByPath.set(filePath, knownEventIds);
116
+ }
117
+ let duplicatesSkipped = 0;
75
118
  for (const scope of resolved.scopes) {
76
119
  const filePath = resolveUsagePath(scope, context.projectRoot, env);
77
120
  if (!filePath)
78
121
  continue;
122
+ const knownEventIds = knownEventIdsByPath.get(filePath) ?? new Set();
79
123
  for (const item of extracted) {
124
+ const eventId = digest([
125
+ sourceGeneration,
126
+ item.nativeEventId ?? String(item.position),
127
+ item.contentDigest,
128
+ item.artifact.kind,
129
+ item.artifact.id,
130
+ ].join('\0'));
131
+ if (knownEventIds.has(eventId)) {
132
+ duplicatesSkipped += 1;
133
+ continue;
134
+ }
80
135
  const event = buildUsageEvent({
81
136
  env,
82
137
  source: 'transcript',
83
- provider: normalizeProvider(options.provider),
138
+ provider,
84
139
  artifact: item.artifact,
85
140
  action: item.action,
86
141
  outcome: 'unknown',
@@ -88,8 +143,13 @@ export async function ingestSkillUsageTranscript(options) {
88
143
  cwd: context.projectRoot ?? contextCwd,
89
144
  context,
90
145
  scope,
146
+ occurredAt: item.occurredAt,
147
+ eventId,
148
+ sourceGeneration,
149
+ nativeEventId: item.nativeEventId,
91
150
  });
92
151
  persisted.push(event);
152
+ knownEventIds.add(eventId);
93
153
  if (!options.dryRun) {
94
154
  await appendBoundedJsonl(filePath, event, resolved.maxBytes);
95
155
  }
@@ -100,6 +160,15 @@ export async function ingestSkillUsageTranscript(options) {
100
160
  appended: options.dryRun ? 0 : persisted.length,
101
161
  skipped,
102
162
  paths,
163
+ receipt: {
164
+ source_generation: sourceGeneration,
165
+ provider,
166
+ records_read: recordsRead,
167
+ events_extracted: extracted.length,
168
+ events_appended: options.dryRun ? 0 : persisted.length,
169
+ duplicates_skipped: duplicatesSkipped,
170
+ malformed_skipped: skipped,
171
+ },
103
172
  };
104
173
  }
105
174
  export async function readSkillUsageReport(options) {
@@ -131,6 +200,10 @@ export async function readSkillUsageReport(options) {
131
200
  limit: 5,
132
201
  }),
133
202
  paths,
203
+ window: {
204
+ retained_segments: paths.reduce((count, filePath) => count + retainedUsagePaths(filePath).filter(existsSync).length, 0),
205
+ truncated_before: paths.some(filePath => existsSync(`${filePath}.1`)),
206
+ },
134
207
  };
135
208
  }
136
209
  export async function printSkillUsageReport(options) {
@@ -205,10 +278,17 @@ function classifyCliUsage(command, args) {
205
278
  return { kind: 'command', id: command, action: 'invoke' };
206
279
  }
207
280
  function buildUsageEvent(input) {
281
+ const observedTimestamp = new Date().toISOString();
208
282
  const event = {
209
- schema_version: 1,
283
+ schema_version: input.eventId ? 2 : 1,
210
284
  event_type: 'aiwg.skill_usage',
211
- timestamp: new Date().toISOString(),
285
+ timestamp: input.occurredAt ?? observedTimestamp,
286
+ ...(input.eventId ? {
287
+ observed_timestamp: observedTimestamp,
288
+ event_id: input.eventId,
289
+ source_generation: input.sourceGeneration,
290
+ native_event_id: input.nativeEventId,
291
+ } : {}),
212
292
  invocation_id: input.env.AIWG_INVOCATION_ID,
213
293
  source: input.source,
214
294
  provider: input.provider,
@@ -294,6 +374,25 @@ function normalizeProvider(value) {
294
374
  return 'claude-code';
295
375
  return normalized || 'unknown';
296
376
  }
377
+ function sourceOccurrenceTime(value) {
378
+ if (!isRecord(value))
379
+ return undefined;
380
+ for (const key of ['timestamp', 'created_at', 'createdAt', 'time']) {
381
+ const candidate = value[key];
382
+ if (typeof candidate === 'string' && !Number.isNaN(Date.parse(candidate))) {
383
+ return new Date(candidate).toISOString();
384
+ }
385
+ }
386
+ return undefined;
387
+ }
388
+ function sourceNativeEventId(value) {
389
+ if (!isRecord(value))
390
+ return undefined;
391
+ return firstString(value, ['uuid', 'event_id', 'eventId', 'id', 'message_id']);
392
+ }
393
+ function digest(value) {
394
+ return createHash('sha256').update(value).digest('hex');
395
+ }
297
396
  function isArtifactKind(value) {
298
397
  return value === 'skill' ||
299
398
  value === 'agent' ||
@@ -393,15 +492,20 @@ function resolveUsagePath(scope, projectRoot, env) {
393
492
  }
394
493
  async function appendBoundedJsonl(filePath, event, maxBytes) {
395
494
  await mkdir(path.dirname(filePath), { recursive: true });
396
- await rotateIfNeeded(filePath, maxBytes);
397
- await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8');
398
- }
399
- async function rotateIfNeeded(filePath, maxBytes) {
495
+ const line = JSON.stringify(event) + '\n';
496
+ const lineBytes = Buffer.byteLength(line);
497
+ if (maxBytes > 0 && lineBytes > maxBytes)
498
+ event.oversized_record = true;
499
+ const finalLine = JSON.stringify(event) + '\n';
500
+ await rotateIfNeeded(filePath, maxBytes, Buffer.byteLength(finalLine));
501
+ await appendFile(filePath, finalLine, 'utf8');
502
+ }
503
+ async function rotateIfNeeded(filePath, maxBytes, appendBytes) {
400
504
  if (maxBytes <= 0)
401
505
  return;
402
506
  try {
403
507
  const current = await stat(filePath);
404
- if (current.size <= maxBytes)
508
+ if (current.size === 0 || current.size + appendBytes <= maxBytes)
405
509
  return;
406
510
  const rotated = `${filePath}.1`;
407
511
  if (existsSync(rotated)) {
@@ -415,18 +519,36 @@ async function rotateIfNeeded(filePath, maxBytes) {
415
519
  }
416
520
  }
417
521
  async function readJsonlEvents(filePath) {
522
+ const events = [];
523
+ for (const retainedPath of retainedUsagePaths(filePath)) {
524
+ try {
525
+ for await (const line of streamTranscriptLines(retainedPath)) {
526
+ if (line.trim())
527
+ events.push(JSON.parse(line));
528
+ }
529
+ }
530
+ catch (error) {
531
+ if (error.code !== 'ENOENT')
532
+ throw error;
533
+ }
534
+ }
535
+ return events;
536
+ }
537
+ function retainedUsagePaths(filePath) {
538
+ return [`${filePath}.1`, filePath];
539
+ }
540
+ async function* streamTranscriptLines(filePath) {
541
+ const input = createReadStream(filePath, { encoding: 'utf8' });
542
+ const lines = createInterface({ input, crlfDelay: Infinity });
418
543
  try {
419
- const raw = await readFile(filePath, 'utf8');
420
- return raw
421
- .split('\n')
422
- .map(line => line.trim())
423
- .filter(Boolean)
424
- .map(line => JSON.parse(line));
544
+ for await (const line of lines) {
545
+ if (line.trim())
546
+ yield line;
547
+ }
425
548
  }
426
- catch (error) {
427
- if (error.code === 'ENOENT')
428
- return [];
429
- throw error;
549
+ finally {
550
+ lines.close();
551
+ input.destroy();
430
552
  }
431
553
  }
432
554
  function summarizeEvents(events) {
@@ -15,6 +15,7 @@ import { normalizeNamedCaptures } from '../artifacts/index-builder.js';
15
15
  import { getProviderDefinition, PROVIDER_IDS, resolveProviderPathValue, } from '../providers/provider-definitions.js';
16
16
  import { validateAuthorization, } from '../policy/authorization.js';
17
17
  import { projectAiwgPath, resolveProjectAiwgDir } from './project-artifacts.js';
18
+ import { defaultThreatAssessmentConfig, validateThreatAssessmentConfig, } from '../security/threat-assessment-config.js';
18
19
  const CONFIG_FILENAME = 'aiwg.config';
19
20
  /**
20
21
  * Operations that a workspace may authorize for one member repository.
@@ -633,6 +634,9 @@ export function emptyConfig(providers = ['claude']) {
633
634
  providers,
634
635
  installed: {},
635
636
  scripts: {},
637
+ security: {
638
+ threatAssessment: defaultThreatAssessmentConfig(),
639
+ },
636
640
  delivery: {
637
641
  mode: 'pr-required',
638
642
  default_branch: 'main',
@@ -711,12 +715,20 @@ export async function readAiwgConfig(projectDir) {
711
715
  if (authorizationErrors.length > 0) {
712
716
  throw new Error(`Invalid .aiwg/aiwg.config:\n${authorizationErrors.map(item => item.message).join('\n')}`);
713
717
  }
718
+ const threatAssessmentErrors = validateThreatAssessmentConfig(parsed.security?.threatAssessment);
719
+ if (threatAssessmentErrors.length > 0) {
720
+ throw new Error(`Invalid .aiwg/aiwg.config:\n${threatAssessmentErrors.join('\n')}`);
721
+ }
714
722
  return parsed;
715
723
  }
716
724
  /**
717
725
  * Write aiwg.config, creating the resolved AIWG artifact directory if needed.
718
726
  */
719
727
  export async function writeAiwgConfig(projectDir, config) {
728
+ const threatAssessmentErrors = validateThreatAssessmentConfig(config.security?.threatAssessment);
729
+ if (threatAssessmentErrors.length > 0) {
730
+ throw new Error(`Invalid .aiwg/aiwg.config:\n${threatAssessmentErrors.join('\n')}`);
731
+ }
720
732
  const dir = resolveProjectAiwgDir(projectDir);
721
733
  await mkdir(dir, { recursive: true });
722
734
  const filePath = join(dir, CONFIG_FILENAME);
@@ -152,6 +152,7 @@ const ENUM_RULES = {
152
152
  'remotes.tracker_actor.via': ['tea', 'gh', 'mcp', 'api'],
153
153
  'remotes.transport.protocol': ['ssh', 'https'],
154
154
  'repo_maintainer.tiers.local': ['collaborator', 'maintainer', 'admin'],
155
+ 'security.threatAssessment.mode': ['off', 'audit', 'enforce'],
155
156
  };
156
157
  const BOOLEAN_FIELDS = new Set([
157
158
  'delivery.delete_branch_on_merge',
@@ -214,15 +215,17 @@ async function projectConfigSet(key, raw, args) {
214
215
  }
215
216
  // Coerce booleans for known boolean fields
216
217
  let value = raw;
217
- if (/^externalLinks\.[^.]+$/.test(key)) {
218
+ if (/^externalLinks\.[^.]+$/.test(key) || key === 'security.threatAssessment') {
218
219
  try {
219
220
  value = JSON.parse(raw);
220
221
  }
221
222
  catch {
222
223
  throw new AiwgError({
223
224
  code: 'ERR_INVALID_VALUE',
224
- message: `${key} must be a JSON object containing label and url`,
225
- hint: `Try: aiwg config set --project ${key} '{"label":"Project docs","url":"https://example.com/docs"}'`,
225
+ message: `${key} must be a valid JSON object`,
226
+ hint: key === 'security.threatAssessment'
227
+ ? `Try: aiwg config set --project ${key} '{"schemaVersion":"1","mode":"audit","defaultProfile":"balanced"}'`
228
+ : `Try: aiwg config set --project ${key} '{"label":"Project docs","url":"https://example.com/docs"}'`,
226
229
  exitCode: EXIT_CODES.USAGE,
227
230
  });
228
231
  }
@@ -285,6 +288,16 @@ async function projectConfigSet(key, raw, args) {
285
288
  exitCode: EXIT_CODES.USAGE,
286
289
  });
287
290
  }
291
+ const { validateThreatAssessmentConfig } = await import('../security/threat-assessment-config.js');
292
+ const threatErrors = validateThreatAssessmentConfig(cfg.security?.threatAssessment);
293
+ if (threatErrors.length > 0) {
294
+ throw new AiwgError({
295
+ code: 'ERR_INVALID_VALUE',
296
+ message: `Invalid threat-assessment configuration: ${threatErrors.join('; ')}`,
297
+ hint: 'Use a built-in profile or correct the referenced profile, rule pack, threshold, or regex.',
298
+ exitCode: EXIT_CODES.USAGE,
299
+ });
300
+ }
288
301
  await writeAiwgConfig(projectDir, cfg);
289
302
  console.log(`Set --project ${key} = ${raw}`);
290
303
  }
@@ -907,6 +907,34 @@ export const sessionCommand = {
907
907
  },
908
908
  },
909
909
  };
910
+ // Session Catalog Command (#1903)
911
+ export const sessionsCommand = {
912
+ id: 'sessions',
913
+ type: 'skill',
914
+ name: 'Sessions',
915
+ description: 'Manage the normalized session catalog with versioned JSON, deterministic pagination, previews, and health checks',
916
+ version: '1.0.0',
917
+ capabilities: ['cli', 'session-catalog', 'session-import', 'session-lifecycle', 'doctor'],
918
+ keywords: ['sessions', 'catalog', 'import', 'source', 'tag', 'reindex', 'delete'],
919
+ category: 'project',
920
+ platforms: {
921
+ claude: 'full',
922
+ generic: 'full',
923
+ },
924
+ deployment: {
925
+ pathTemplate: '.{platform}/commands/{id}.md',
926
+ core: true,
927
+ },
928
+ metadata: {
929
+ type: 'skill',
930
+ triggerPhrases: ['list sessions', 'import sessions', 'session catalog', 'sessions doctor'],
931
+ commandHint: {
932
+ template: 'utility',
933
+ argumentHint: '<sources|import|list|show|tag|relocate|reindex|delete|doctor> [--json] [--dry-run]',
934
+ allowedTools: ['Bash'],
935
+ },
936
+ },
937
+ };
910
938
  // Sandbox Management Commands (#917)
911
939
  export const sandboxCommand = {
912
940
  id: 'sandbox',
@@ -3458,6 +3486,7 @@ export const commandDefinitions = [
3458
3486
  feedbackCommand,
3459
3487
  // Session (#884)
3460
3488
  sessionCommand,
3489
+ sessionsCommand,
3461
3490
  ];
3462
3491
  // ============================================
3463
3492
  // Helper Functions
@@ -39,6 +39,7 @@ export const ProjectLocalTypeSchema = z.enum([
39
39
  * underscore + hyphen, optional trailing slash.
40
40
  */
41
41
  const safeRelativePath = z.string().regex(/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*\/?$/, 'must be a relative path (alphanumeric + _-, no leading slash, no ..)');
42
+ const safeModuleFile = z.string().regex(/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*\.mjs$/, 'must be a relative .mjs path (alphanumeric + _-, no leading slash, no ..)');
42
43
  // Single-char ids are allowed; multi-char ids must end with alphanumeric
43
44
  // (no trailing hyphen). This pattern: `[a-z0-9]([a-z0-9-]*[a-z0-9])?`
44
45
  const bundleNamePattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
@@ -129,6 +130,31 @@ export const ProviderConfigSchema = z.object({
129
130
  aliases: z.array(z.string().min(1).max(64)).max(20).optional(),
130
131
  capabilities: ProviderCapabilityOverridesSchema.optional(),
131
132
  }).strict();
133
+ export const CliCommandsSchema = z.object({
134
+ namespace: z.string()
135
+ .min(1)
136
+ .max(64)
137
+ .regex(bundleNamePattern, 'kebab-case alphanumeric, no leading/trailing hyphen'),
138
+ description: z.string().min(1).max(512),
139
+ entry: safeRelativePath.optional(),
140
+ subcommands: z.record(z.string()
141
+ .min(1)
142
+ .max(64)
143
+ .regex(bundleNamePattern, 'kebab-case alphanumeric, no leading/trailing hyphen'), z.object({
144
+ file: safeModuleFile,
145
+ description: z.string().min(1).max(512),
146
+ hook_event: z.enum([
147
+ 'Stop',
148
+ 'SessionStart',
149
+ 'SessionEnd',
150
+ 'PreToolUse',
151
+ 'PostToolUse',
152
+ 'FeatureComplete',
153
+ ]).optional(),
154
+ }).strict()).refine((commands) => Object.keys(commands).length > 0, {
155
+ message: 'at least one CLI subcommand is required',
156
+ }),
157
+ }).strict();
132
158
  // ============================================
133
159
  // Top-level BundleManifestSchema
134
160
  // ============================================
@@ -162,6 +188,9 @@ export const BundleManifestSchema = z.object({
162
188
  // Optional patterns shared with existing extension validation
163
189
  deprecation: DeprecationSchema.optional(),
164
190
  memory: MemoryFootprintSchema.optional(),
191
+ // Expandable CLI namespace contributed by an addon-shaped bundle.
192
+ // The same block is accepted by bundled addon manifests.
193
+ cli_commands: CliCommandsSchema.optional(),
165
194
  })
166
195
  .strict()
167
196
  .refine((m) => {