@moda-ai/cli 1.10.1 → 1.10.2

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 (2) hide show
  1. package/dist/skills.js +138 -1
  2. package/package.json +1 -1
package/dist/skills.js CHANGED
@@ -27,6 +27,7 @@ import { CliInputError } from './errors.js';
27
27
  const SKILLS_REQUEST_TIMEOUT_MS = 15_000;
28
28
  const VALID_STATUSES = new Set(['approved', 'proposed', 'all']);
29
29
  const SKILLS_MANIFEST_PATH = join('.moda', 'skills.yml');
30
+ const SKILL_PROPOSALS_MANIFEST_PATH = join('.moda', 'skill-proposals.yml');
30
31
  /**
31
32
  * Fetch a tenant's generated skills from the ingest worker. Uses the persisted
32
33
  * API key + ingest URL (`~/.moda/config.json`), mirroring snapshot auth.
@@ -342,11 +343,96 @@ function getGitCommit(cwd) {
342
343
  return undefined;
343
344
  }
344
345
  }
346
+ async function fetchSkillProposals(flags, profileOptions = {}) {
347
+ const params = new URLSearchParams();
348
+ if (flags['generation-run-id'])
349
+ params.set('generationRunId', flags['generation-run-id']);
350
+ const status = flags.status ?? 'ready_for_pr';
351
+ if (status !== 'all')
352
+ params.set('status', status);
353
+ if (flags['tenant-id'])
354
+ params.set('tenantId', flags['tenant-id']);
355
+ const suffix = params.toString() ? `?${params.toString()}` : '';
356
+ const response = (await callControlAPI(`/skills/proposals${suffix}`, { method: 'GET' }, profileOptions));
357
+ return Array.isArray(response.proposals) ? response.proposals : [];
358
+ }
359
+ async function applySkillProposal(proposalId, flags, profileOptions = {}, options = {}) {
360
+ const cwd = options.cwd ?? process.cwd();
361
+ const params = new URLSearchParams();
362
+ if (flags['tenant-id'])
363
+ params.set('tenantId', flags['tenant-id']);
364
+ const suffix = params.toString() ? `?${params.toString()}` : '';
365
+ const proposal = (await callControlAPI(`/skills/proposals/${encodeURIComponent(proposalId)}${suffix}`, { method: 'GET' }, profileOptions));
366
+ if (!proposal.skillMd?.trim()) {
367
+ throw new CliInputError(`Proposal ${proposalId} does not include SKILL.md content`);
368
+ }
369
+ const safeId = safeSkillId(proposal.skillId);
370
+ const relativePath = `.claude/skills/${safeId}/SKILL.md`;
371
+ const writeStatus = writeIfChanged(join(cwd, relativePath), proposal.skillMd);
372
+ if (writeStatus === 'failed') {
373
+ throw new Error(`Failed to write ${relativePath}`);
374
+ }
375
+ writeSkillProposalManifest(cwd, proposal, relativePath);
376
+ let acknowledged = true;
377
+ let ackError;
378
+ try {
379
+ await callControlAPI(`/skills/proposals/${encodeURIComponent(proposalId)}/mark-applied${suffix}`, {
380
+ method: 'POST',
381
+ body: JSON.stringify({ appliedBy: 'moda-cli' }),
382
+ }, profileOptions);
383
+ }
384
+ catch (err) {
385
+ acknowledged = false;
386
+ ackError = err instanceof Error ? err.message : String(err);
387
+ }
388
+ return {
389
+ proposalId,
390
+ skillId: proposal.skillId,
391
+ path: relativePath,
392
+ status: writeStatus,
393
+ acknowledged,
394
+ ackError,
395
+ };
396
+ }
397
+ function safeSkillId(skillId) {
398
+ const safeId = basename(skillId || '');
399
+ if (!safeId || !/^[A-Za-z0-9_-]+$/.test(safeId)) {
400
+ throw new CliInputError(`Invalid skill_id in proposal: ${JSON.stringify(skillId).slice(0, 80)}`);
401
+ }
402
+ return safeId;
403
+ }
404
+ function writeSkillProposalManifest(cwd, proposal, relativePath) {
405
+ const manifestPath = join(cwd, SKILL_PROPOSALS_MANIFEST_PATH);
406
+ mkdirSync(dirname(manifestPath), { recursive: true });
407
+ const existing = existsSync(manifestPath) ? readFileSync(manifestPath, 'utf8') : 'applied_proposals:\n';
408
+ const entry = [
409
+ ` - proposal_id: "${escapeYamlString(proposal.proposalId)}"`,
410
+ ` skill_id: "${escapeYamlString(proposal.skillId)}"`,
411
+ ` skill_family_id: "${escapeYamlString(proposal.skillFamilyId)}"`,
412
+ ` skill_version: ${Number(proposal.skillVersion || 0)}`,
413
+ ` generation_run_id: "${escapeYamlString(proposal.generationRunId)}"`,
414
+ ` clustering_run_id: "${escapeYamlString(proposal.clusteringRunId)}"`,
415
+ ` topic_id: "${escapeYamlString(proposal.topicId)}"`,
416
+ ` replay_set_id: "${escapeYamlString(proposal.replaySetId)}"`,
417
+ ` delta_pass_rate: ${Number(proposal.deltaPassRate || 0)}`,
418
+ ` path: "${escapeYamlString(relativePath)}"`,
419
+ ` applied_at: "${new Date().toISOString()}"`,
420
+ ].join('\n');
421
+ const prefix = existing.endsWith('\n') ? existing : `${existing}\n`;
422
+ writeFileSync(manifestPath, `${prefix}${entry}\n`);
423
+ }
424
+ function escapeYamlString(value) {
425
+ return String(value ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
426
+ }
427
+ function formatProposalRate(value) {
428
+ return `${(Number(value || 0) * 100).toFixed(1)}%`;
429
+ }
345
430
  /**
346
431
  * `moda skills <subcommand>` dispatcher.
347
432
  */
348
433
  export async function runSkillsCommand(args) {
349
434
  const sub = args.positional;
435
+ const positionals = args.positionals ?? (sub ? [sub] : []);
350
436
  if (sub === 'gen') {
351
437
  const result = await generateSkills(args.flags);
352
438
  if (args.flags.json === 'true') {
@@ -371,13 +457,63 @@ export async function runSkillsCommand(args) {
371
457
  }
372
458
  return Number(result.sessions_failed ?? 0) > 0 ? 1 : 0;
373
459
  }
460
+ if (sub === 'proposals' && positionals[1] === 'list') {
461
+ const proposals = await fetchSkillProposals(args.flags, { profile: args.flags.profile });
462
+ if (args.flags.json === 'true') {
463
+ console.log(JSON.stringify({ proposals }));
464
+ }
465
+ else if (proposals.length === 0) {
466
+ console.log('No skill proposals found.');
467
+ }
468
+ else {
469
+ for (const proposal of proposals) {
470
+ console.log([
471
+ proposal.proposalId,
472
+ proposal.proposalStatus,
473
+ `${proposal.skillId}@v${proposal.skillVersion}`,
474
+ `delta=${formatProposalRate(proposal.deltaPassRate)}`,
475
+ `baseline=${formatProposalRate(proposal.baselinePassRate)}`,
476
+ `candidate=${formatProposalRate(proposal.candidatePassRate)}`,
477
+ proposal.name,
478
+ ].filter(Boolean).join('\t'));
479
+ }
480
+ }
481
+ return 0;
482
+ }
483
+ if (sub === 'proposal' && positionals[1] === 'apply') {
484
+ const proposalId = positionals[2] || args.flags['proposal-id'];
485
+ if (!proposalId) {
486
+ throw new CliInputError('A proposal id is required', 'Usage: moda skills proposal apply <proposal-id>');
487
+ }
488
+ const result = await applySkillProposal(proposalId, args.flags, { profile: args.flags.profile });
489
+ if (args.flags.json === 'true') {
490
+ console.log(JSON.stringify(result));
491
+ }
492
+ else {
493
+ console.log([
494
+ `${result.status} ${result.path}`,
495
+ result.acknowledged ? 'acknowledged: true' : 'acknowledged: false',
496
+ ].join('\n'));
497
+ }
498
+ if (!result.acknowledged) {
499
+ // The local SKILL.md was written, but the server still lists this
500
+ // proposal as ready_for_pr. Warn (to stderr so JSON stdout stays clean)
501
+ // and exit non-zero so the failed acknowledgement isn't silently treated
502
+ // as success — re-running `apply` re-attempts the acknowledgement.
503
+ console.error(`Warning: wrote ${result.path} but failed to acknowledge proposal ${proposalId} on the server` +
504
+ (result.ackError ? ` (${result.ackError})` : '') +
505
+ '. The proposal is still listed as ready — re-run `moda skills proposal apply` to acknowledge it.');
506
+ return 1;
507
+ }
508
+ return 0;
509
+ }
374
510
  if (sub === 'sync') {
375
511
  const result = await runSkillSync(args.flags, { profile: args.flags.profile });
376
512
  console.log(JSON.stringify({ synced: result.synced, dryRun: result.dryRun }));
377
513
  return 0;
378
514
  }
379
515
  if (sub !== 'pull') {
380
- throw new CliInputError(sub ? `Unknown skills subcommand '${sub}'` : 'A skills subcommand is required', 'Usage: moda skills gen [--source=all|sdk|coding-agent] [--max-sessions=N] [--start-at=ISO] [--end-at=ISO] [--replay] [--reprocess-segments] [--force-reprocess] [--wait] | moda skills pull [--status=approved|proposed|all] | moda skills sync [--dry-run]');
516
+ throw new CliInputError(sub ? `Unknown skills subcommand '${sub}'` : 'A skills subcommand is required', 'Usage: moda skills gen [--source=all|sdk|coding-agent] [--max-sessions=N] [--start-at=ISO] [--end-at=ISO] [--replay] [--replay-set-id=ID] [--reprocess-segments] [--force-reprocess] [--wait] | moda skills proposals list [--status=ready_for_pr] | moda skills proposal apply <proposal-id> | moda skills pull [--status=approved|proposed|all] | moda skills sync [--dry-run]');
381
517
  }
382
518
  const status = (args.flags.status ?? 'approved').toLowerCase();
383
519
  if (!VALID_STATUSES.has(status)) {
@@ -418,6 +554,7 @@ async function generateSkills(flags) {
418
554
  start_at: flags['start-at'] || undefined,
419
555
  end_at: flags['end-at'] || undefined,
420
556
  clustering_run_id: flags['clustering-run-id'] || undefined,
557
+ replay_set_id: flags['replay-set-id'] || flags['eval-set-id'] || undefined,
421
558
  run_clustering: flags['no-clustering'] !== 'true',
422
559
  source: normalizeSkillSource(flags.source),
423
560
  clustering_timeout_seconds: parsePositiveInt(flags['clustering-timeout-seconds'], 0),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.10.1",
3
+ "version": "1.10.2",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {