@claude-flow/cli 3.48.0 → 3.50.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 (58) hide show
  1. package/.claude/helpers/helpers.manifest.json +4 -4
  2. package/.claude/helpers/hook-handler.cjs +20 -4
  3. package/.claude/helpers/{memory.js → memory.cjs} +1 -1
  4. package/.claude/helpers/{router.js → router.cjs} +1 -1
  5. package/.claude/helpers/{session.js → session.cjs} +1 -1
  6. package/README.md +1 -53
  7. package/catalog-manifest.json +4 -4
  8. package/dist/src/commands/doctor.d.ts +19 -1
  9. package/dist/src/commands/doctor.js +88 -9
  10. package/dist/src/commands/hooks.js +7 -4
  11. package/dist/src/commands/index.js +2 -0
  12. package/dist/src/commands/init.js +20 -0
  13. package/dist/src/commands/memory.js +30 -7
  14. package/dist/src/commands/mods.d.ts +13 -0
  15. package/dist/src/commands/mods.js +126 -0
  16. package/dist/src/commands/plugins.js +44 -7
  17. package/dist/src/commands/policy.js +5 -2
  18. package/dist/src/commands/session.js +128 -21
  19. package/dist/src/commands/swarm.js +11 -11
  20. package/dist/src/index.js +10 -1
  21. package/dist/src/init/executor.js +11 -5
  22. package/dist/src/init/helper-companions.d.ts +3 -0
  23. package/dist/src/init/helper-companions.js +34 -0
  24. package/dist/src/init/helper-integrity.d.ts +21 -0
  25. package/dist/src/init/helper-integrity.js +62 -0
  26. package/dist/src/init/helper-refresh.d.ts +18 -11
  27. package/dist/src/init/helper-refresh.js +56 -13
  28. package/dist/src/init/helpers-generator.js +22 -10
  29. package/dist/src/mcp-tools/hooks-tools.d.ts +16 -3
  30. package/dist/src/mcp-tools/hooks-tools.js +87 -14
  31. package/dist/src/mcp-tools/memory-tools.d.ts +7 -0
  32. package/dist/src/mcp-tools/memory-tools.js +43 -6
  33. package/dist/src/mcp-tools/session-tools.d.ts +15 -0
  34. package/dist/src/mcp-tools/session-tools.js +236 -50
  35. package/dist/src/memory/memory-initializer.js +6 -3
  36. package/dist/src/mods/claude-installs.d.ts +29 -0
  37. package/dist/src/mods/claude-installs.js +83 -0
  38. package/dist/src/mods/install.d.ts +64 -0
  39. package/dist/src/mods/install.js +135 -0
  40. package/dist/src/mods/policy-projection.d.ts +39 -0
  41. package/dist/src/mods/policy-projection.js +65 -0
  42. package/dist/src/mods/probe.d.ts +28 -0
  43. package/dist/src/mods/probe.js +118 -0
  44. package/dist/src/plugins/manager.d.ts +37 -10
  45. package/dist/src/plugins/manager.js +106 -20
  46. package/dist/src/plugins/trust-policy.d.ts +61 -0
  47. package/dist/src/plugins/trust-policy.js +84 -0
  48. package/dist/src/services/policy-runtime.d.ts +6 -0
  49. package/dist/src/services/policy-runtime.js +42 -2
  50. package/node_modules/@claude-flow/codex/package.json +1 -1
  51. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts +2 -6
  52. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts.map +1 -1
  53. package/node_modules/@claude-flow/security/dist/policy/engine.js +35 -1
  54. package/node_modules/@claude-flow/security/dist/policy/engine.js.map +1 -1
  55. package/node_modules/@claude-flow/security/dist/policy/types.d.ts +18 -0
  56. package/node_modules/@claude-flow/security/dist/policy/types.d.ts.map +1 -1
  57. package/node_modules/@claude-flow/security/package.json +1 -1
  58. package/package.json +2 -2
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `ruflo mods` — opt into running ruflo as a Claude Code mod (ADR-404,
3
+ * Claude Code function hooks, early access).
4
+ *
5
+ * install/uninstall edit one settings file and record what they added;
6
+ * status/doctor report what can be known from outside a session; sync-policy
7
+ * rewrites the policy projection the mod's tool check reads. Classic hooks
8
+ * are never removed: they stay the default and the fallback.
9
+ */
10
+ import { output } from '../output.js';
11
+ import { installMod, uninstallMod } from '../mods/install.js';
12
+ import { probeMods } from '../mods/probe.js';
13
+ function projectRoot(ctx) {
14
+ return ctx.flags.projectRoot ?? ctx.flags['project-root'] ?? ctx.cwd ?? process.cwd();
15
+ }
16
+ function printFindings(findings) {
17
+ for (const f of findings) {
18
+ const mark = f.status === 'pass' ? output.success('✓') : f.status === 'warn' ? output.warning('!') : output.error('✗');
19
+ output.writeln(`${mark} ${f.name}: ${f.message}`);
20
+ if (f.fix && f.status !== 'pass')
21
+ output.writeln(output.dim(` fix: ${f.fix}`));
22
+ }
23
+ }
24
+ const rootOption = { name: 'project-root', description: 'Project root (default: current directory)', type: 'string' };
25
+ const installSub = {
26
+ name: 'install',
27
+ description: 'Enable the ruflo-mods plugin for this project (opt-in; classic hooks stay as fallback)',
28
+ options: [
29
+ rootOption,
30
+ { name: 'scope', description: 'local (.claude/settings.local.json, default) | project (.claude/settings.json)', type: 'string', default: 'local' },
31
+ { name: 'dry-run', description: 'Show the settings that would be written', type: 'boolean', default: false },
32
+ ],
33
+ action: async (ctx) => {
34
+ const scope = ctx.flags.scope ?? 'local';
35
+ if (scope !== 'local' && scope !== 'project') {
36
+ output.printError(`--scope must be local or project, got ${scope}`);
37
+ return { success: false, exitCode: 1 };
38
+ }
39
+ const root = projectRoot(ctx);
40
+ const result = installMod(root, scope, (ctx.flags.dryRun === true || ctx.flags['dry-run'] === true));
41
+ if (result.dryRun) {
42
+ output.writeln(`Would write ${result.settingsFile}:`);
43
+ output.printJson(result.next);
44
+ return { success: true, data: result };
45
+ }
46
+ await syncPolicy(root, true);
47
+ output.printSuccess(`ruflo-mods enabled in ${result.settingsFile}${result.backup ? ` (backup: ${result.backup})` : ''}`);
48
+ output.writeln('Restart Claude Code, then run /ruflo-mods in a session to see what the mod owns.');
49
+ output.writeln(output.dim('Early access: Claude Code loads it only where function hooks are on. Run `ruflo mods doctor`.'));
50
+ return { success: true, data: result };
51
+ },
52
+ };
53
+ const uninstallSub = {
54
+ name: 'uninstall',
55
+ description: 'Remove what `ruflo mods install` added (classic hooks take every event back)',
56
+ options: [rootOption, { name: 'dry-run', description: 'Show what would be removed', type: 'boolean', default: false }],
57
+ action: async (ctx) => {
58
+ const result = uninstallMod(projectRoot(ctx), (ctx.flags.dryRun === true || ctx.flags['dry-run'] === true));
59
+ if (!result.removed) {
60
+ output.printWarning('No install record (.claude-flow/mods/install.json): nothing ruflo added to remove.');
61
+ return { success: true, data: result };
62
+ }
63
+ output.printSuccess(`${result.dryRun ? 'Would remove' : 'Removed'} ruflo-mods from ${result.settingsFile}`);
64
+ return { success: true, data: result };
65
+ },
66
+ };
67
+ function findingsCommand(name, description) {
68
+ return {
69
+ name,
70
+ description,
71
+ options: [rootOption, { name: 'json', description: 'Output as JSON', type: 'boolean', default: false }],
72
+ action: async (ctx) => {
73
+ const findings = probeMods({ projectRoot: projectRoot(ctx) });
74
+ if (ctx.flags.json)
75
+ output.printJson(findings);
76
+ else
77
+ printFindings(findings);
78
+ const failed = findings.some((f) => f.status === 'fail');
79
+ // status reports; doctor gates (warnings are expected while early access is off).
80
+ return { success: !failed, exitCode: name === 'doctor' && failed ? 1 : 0, data: findings };
81
+ },
82
+ };
83
+ }
84
+ async function syncPolicy(root, quiet) {
85
+ try {
86
+ const { loadPolicyState } = await import('../services/policy-runtime.js');
87
+ const { syncPolicyProjection } = await import('../mods/policy-projection.js');
88
+ const result = syncPolicyProjection(root, loadPolicyState(root));
89
+ if (!quiet || result.action !== 'unchanged')
90
+ output.writeln(`policy projection: ${result.action} (${result.path})`);
91
+ return true;
92
+ }
93
+ catch (error) {
94
+ output.printWarning(`policy projection not synced: ${error.message}`);
95
+ return false;
96
+ }
97
+ }
98
+ const syncPolicySub = {
99
+ name: 'sync-policy',
100
+ description: 'Rewrite the Claude Code policy projection from .claude-flow/policy/state.json',
101
+ options: [rootOption],
102
+ action: async (ctx) => {
103
+ const ok = await syncPolicy(projectRoot(ctx), false);
104
+ return { success: ok, exitCode: ok ? 0 : 1 };
105
+ },
106
+ };
107
+ const statusSub = findingsCommand('status', 'Show whether the mod is enabled, can load, and what it owns');
108
+ export const modsCommand = {
109
+ name: 'mods',
110
+ description: 'Run ruflo as a Claude Code mod (function hooks, early access, ADR-404)',
111
+ subcommands: [
112
+ installSub,
113
+ uninstallSub,
114
+ statusSub,
115
+ findingsCommand('doctor', 'Check the mod path; exits 1 only on a failure'),
116
+ syncPolicySub,
117
+ ],
118
+ examples: [
119
+ { command: 'ruflo mods install', description: 'Enable for this project (settings.local.json)' },
120
+ { command: 'ruflo mods doctor', description: 'Function hooks on? Refused by policy? Handshake supported?' },
121
+ { command: 'ruflo mods uninstall', description: 'Remove only what install added' },
122
+ ],
123
+ action: statusSub.action,
124
+ };
125
+ export default modsCommand;
126
+ //# sourceMappingURL=mods.js.map
@@ -8,6 +8,7 @@
8
8
  import { output } from '../output.js';
9
9
  import { createPluginDiscoveryService, searchPlugins, getPluginSearchSuggestions, getFeaturedPlugins, getOfficialPlugins, } from '../plugins/store/index.js';
10
10
  import { getPluginManager } from '../plugins/manager.js';
11
+ import { DEFAULT_PLUGIN_PERMISSIONS } from '../plugins/trust-policy.js';
11
12
  import { getBulkRatings } from '../services/registry-api.js';
12
13
  // List subcommand - Now uses IPFS-based registry
13
14
  const listCommand = {
@@ -185,18 +186,29 @@ const installCommand = {
185
186
  { name: 'version', short: 'v', type: 'string', description: 'Specific version to install' },
186
187
  { name: 'global', short: 'g', type: 'boolean', description: 'Install globally' },
187
188
  { name: 'dev', short: 'd', type: 'boolean', description: 'Install as dev dependency' },
188
- { name: 'verify', type: 'boolean', description: 'Verify checksum (default: true)', default: true },
189
+ {
190
+ name: 'verify',
191
+ type: 'boolean',
192
+ description: 'Verify before registering (default: true). Registry installs must match the registry sha256 checksum. ' +
193
+ 'Hooks and commands are withheld from a plugin that declares trustLevel "untrusted"/"unverified" or ' +
194
+ `permissions beyond the default set (${DEFAULT_PLUGIN_PERMISSIONS.join(', ')}) unless --trust is given. ` +
195
+ '--no-verify skips all of this.',
196
+ default: true,
197
+ },
198
+ { name: 'trust', type: 'boolean', description: 'Register hooks and commands even if the plugin is untrusted or declares elevated permissions', default: false },
189
199
  { name: 'registry', short: 'r', type: 'string', description: 'Registry to use' },
190
200
  ],
191
201
  examples: [
192
202
  { command: 'claude-flow plugins install -n community-analytics', description: 'Install plugin from IPFS' },
193
203
  { command: 'claude-flow plugins install -n ./my-plugin --dev', description: 'Install local plugin' },
204
+ { command: 'claude-flow plugins install -n ./my-plugin --trust', description: 'Install a local plugin that declares elevated permissions, registering its hooks' },
194
205
  ],
195
206
  action: async (ctx) => {
196
207
  const name = ctx.flags.name;
197
208
  const version = ctx.flags.version || 'latest';
198
209
  const registryName = ctx.flags.registry;
199
210
  const verify = ctx.flags.verify !== false;
211
+ const trust = ctx.flags.trust === true;
200
212
  if (!name) {
201
213
  output.printError('Plugin name is required');
202
214
  return { success: false, exitCode: 1 };
@@ -227,7 +239,7 @@ const installCommand = {
227
239
  if (isLocalPath) {
228
240
  // Install from local path
229
241
  spinner.setText(`Installing from ${name}...`);
230
- result = await manager.installFromLocal(name);
242
+ result = await manager.installFromLocal(name, { verify, trust });
231
243
  }
232
244
  else {
233
245
  // First, try to find in registry for metadata
@@ -242,7 +254,13 @@ const installCommand = {
242
254
  }
243
255
  // Install from npm (since IPFS is demo mode)
244
256
  spinner.setText(`Installing ${name} from npm...`);
245
- result = await manager.installFromNpm(name, version !== 'latest' ? version : undefined);
257
+ result = await manager.installFromNpm(name, version !== 'latest' ? version : undefined, {
258
+ verify,
259
+ trust,
260
+ expectedChecksum: plugin?.checksum,
261
+ registryTrustLevel: plugin?.trustLevel,
262
+ registryPermissions: plugin?.permissions,
263
+ });
246
264
  }
247
265
  if (!result.success) {
248
266
  spinner.fail(`Installation failed: ${result.error}`);
@@ -251,20 +269,39 @@ const installCommand = {
251
269
  const installed = result.plugin;
252
270
  spinner.succeed(`Installed ${installed.name}@${installed.version}`);
253
271
  output.writeln();
272
+ if (result.decision?.verificationSkipped) {
273
+ output.printWarning('Verification skipped (--no-verify): hooks and commands were registered without a trust or checksum check.');
274
+ }
275
+ for (const warning of result.warnings ?? []) {
276
+ output.printWarning(warning);
277
+ }
254
278
  const boxContent = [
255
279
  `Plugin: ${installed.name}`,
256
280
  `Version: ${installed.version}`,
257
281
  `Source: ${installed.source}`,
258
282
  `Path: ${installed.path || 'N/A'}`,
259
283
  ``,
284
+ `Trust: ${installed.trustLevel ?? 'not declared'}`,
285
+ `Permissions: ${installed.permissions?.join(', ') || 'none declared'}`,
286
+ `Verification: ${installed.verification ?? 'n/a'}`,
287
+ `Install scripts: ${installed.source === 'local' ? 'n/a (local link)' : installed.scriptsRun ? 'ran' : 'skipped (use --trust to run)'}`,
288
+ ``,
260
289
  `Hooks registered: ${installed.hooks?.length || 0}`,
261
290
  `Commands added: ${installed.commands?.length || 0}`,
262
291
  ];
263
- if (plugin) {
264
- boxContent.push(`Trust: ${plugin.trustLevel}`);
265
- boxContent.push(`Permissions: ${plugin.permissions.join(', ') || 'none'}`);
266
- }
267
292
  output.printBox(boxContent.join('\n'), 'Installation Complete');
293
+ if (installed.withheld) {
294
+ output.writeln();
295
+ output.printWarning(`Withheld ${installed.withheld.hooks.length} hook(s) and ${installed.withheld.commands.length} command(s) ` +
296
+ `from ${installed.name} because it ${installed.withheld.reasons.join('; ')}.`);
297
+ if (installed.withheld.hooks.length > 0) {
298
+ output.writeln(output.dim(` Hooks not registered: ${installed.withheld.hooks.join(', ')}`));
299
+ }
300
+ if (installed.withheld.commands.length > 0) {
301
+ output.writeln(output.dim(` Commands not added: ${installed.withheld.commands.join(', ')}`));
302
+ }
303
+ output.writeln(output.dim(` To register them, uninstall and reinstall with --trust.`));
304
+ }
268
305
  return { success: true, data: installed };
269
306
  }
270
307
  catch (error) {
@@ -86,8 +86,11 @@ export const policyCommand = {
86
86
  const state = loadPolicyState(root);
87
87
  return print({ receipts: state.receipts });
88
88
  }
89
- if (operation === 'verify')
90
- return print(await verifyPolicyLedger(root));
89
+ if (operation === 'verify') {
90
+ const ledger = await verifyPolicyLedger(root);
91
+ const result = print(ledger);
92
+ return ledger.valid ? result : { ...result, success: false, exitCode: 1 };
93
+ }
91
94
  throw new Error(`unknown policy operation: ${operation}`);
92
95
  }
93
96
  catch (error) {
@@ -28,6 +28,33 @@ function formatDate(dateStr) {
28
28
  // Otherwise show date
29
29
  return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
30
30
  }
31
+ /**
32
+ * #3575: the session MCP tools report failure as `{ error }` (or
33
+ * `success: false`) rather than throwing, so every call site must check before
34
+ * printing a success line or reading result fields.
35
+ */
36
+ function toolError(result) {
37
+ if (!result || typeof result !== 'object')
38
+ return 'the tool returned no result';
39
+ const r = result;
40
+ if (typeof r.error === 'string' && r.error.length > 0)
41
+ return r.error;
42
+ if (r.success === false)
43
+ return 'the operation failed';
44
+ return null;
45
+ }
46
+ /** #3573: say what happened to memory instead of printing an invented 0. */
47
+ function memoryCaptureLabel(capture, fallback) {
48
+ if (!capture)
49
+ return fallback ?? 'unknown';
50
+ if (!capture.requested)
51
+ return 'not included';
52
+ if (capture.status === 'error')
53
+ return `not captured (${capture.error || 'error'})`;
54
+ if (capture.status === 'no-store')
55
+ return '0 (no memory store found)';
56
+ return capture.entries;
57
+ }
31
58
  // Format session status
32
59
  function formatStatus(status) {
33
60
  switch (status) {
@@ -186,6 +213,12 @@ const saveCommand = {
186
213
  includeAgents: ctx.flags['include-agents'] !== false,
187
214
  includeTasks: ctx.flags['include-tasks'] !== false
188
215
  });
216
+ const failure = toolError(result);
217
+ if (failure) {
218
+ spinner.fail('Failed to save session');
219
+ output.printError(failure);
220
+ return { success: false, exitCode: 1 };
221
+ }
189
222
  spinner.succeed('Session saved');
190
223
  output.writeln();
191
224
  const stats = result.stats || {};
@@ -201,10 +234,13 @@ const saveCommand = {
201
234
  { property: 'Saved At', value: new Date(result.savedAt).toLocaleString() },
202
235
  { property: 'Agents', value: stats.agentCount ?? stats.agents ?? 0 },
203
236
  { property: 'Tasks', value: stats.taskCount ?? stats.tasks ?? 0 },
204
- { property: 'Memory Entries', value: stats.memoryEntries ?? 0 },
237
+ { property: 'Memory Entries', value: memoryCaptureLabel(result.memoryCapture, stats.memoryEntries) },
205
238
  { property: 'Total Size', value: formatSize(stats.totalSize ?? 0) }
206
239
  ]
207
240
  });
241
+ if (result.memoryCapture?.status === 'error') {
242
+ output.printWarning(`Memory was requested but could not be captured: ${result.memoryCapture.error}`);
243
+ }
208
244
  output.writeln();
209
245
  output.printSuccess(`Session saved: ${result.sessionId}`);
210
246
  output.printInfo(`Restore with: claude-flow session restore ${result.sessionId}`);
@@ -313,8 +349,25 @@ const restoreCommand = {
313
349
  restoreAgents,
314
350
  restoreTasks
315
351
  });
316
- spinner.succeed('Session restored');
352
+ const failure = toolError(result) ?? (result.restored === false ? 'Session not found' : null);
353
+ if (failure) {
354
+ spinner.fail('Failed to restore session');
355
+ output.printError(failure);
356
+ return { success: false, exitCode: 1 };
357
+ }
358
+ const memoryRestore = result.memoryRestore;
359
+ const memoryPartial = !!memoryRestore && memoryRestore.failed > 0;
360
+ if (memoryPartial) {
361
+ spinner.fail('Session restored with memory errors');
362
+ }
363
+ else {
364
+ spinner.succeed('Session restored');
365
+ }
317
366
  output.writeln();
367
+ // #3573: distinguish "not requested" from "the session holds no memory".
368
+ const memoryStatus = result.restoredComponents.memory
369
+ ? (memoryPartial ? output.error('Partial') : output.success('Restored'))
370
+ : restoreMemory ? output.dim('Not in session') : output.dim('Skipped');
318
371
  output.printTable({
319
372
  columns: [
320
373
  { key: 'component', header: 'Component', width: 20 },
@@ -324,8 +377,10 @@ const restoreCommand = {
324
377
  data: [
325
378
  {
326
379
  component: 'Memory',
327
- status: result.restoredComponents.memory ? output.success('Restored') : output.dim('Skipped'),
328
- count: result.restoredComponents.memory ? result.stats.memoryEntries : 0
380
+ status: memoryStatus,
381
+ count: result.restoredComponents.memory
382
+ ? (result.stats.memoryEntriesRestored ?? result.stats.memoryEntries)
383
+ : 0
329
384
  },
330
385
  {
331
386
  component: 'Agents',
@@ -340,11 +395,19 @@ const restoreCommand = {
340
395
  ]
341
396
  });
342
397
  output.writeln();
343
- output.printSuccess(`Session ${sessionId} restored successfully`);
398
+ if (memoryPartial) {
399
+ output.printWarning(`${memoryRestore.failed} memory entries could not be restored` +
400
+ (memoryRestore.errors.length ? `: ${memoryRestore.errors.join('; ')}` : ''));
401
+ }
402
+ else {
403
+ output.printSuccess(`Session ${sessionId} restored successfully`);
404
+ }
344
405
  if (ctx.flags.format === 'json') {
345
406
  output.printJson(result);
346
407
  }
347
- return { success: true, data: result };
408
+ return memoryPartial
409
+ ? { success: false, exitCode: 1, data: result }
410
+ : { success: true, data: result };
348
411
  }
349
412
  catch (error) {
350
413
  spinner.fail('Failed to restore session');
@@ -391,6 +454,11 @@ const deleteCommand = {
391
454
  }
392
455
  try {
393
456
  const result = await callMCPTool('session_delete', { sessionId });
457
+ const failure = toolError(result) ?? (result.deleted === false ? 'Session not found' : null);
458
+ if (failure) {
459
+ output.printError(`Failed to delete session: ${failure}`);
460
+ return { success: false, exitCode: 1 };
461
+ }
394
462
  output.writeln();
395
463
  output.printSuccess(`Session ${sessionId} deleted`);
396
464
  if (ctx.flags.format === 'json') {
@@ -412,7 +480,7 @@ const deleteCommand = {
412
480
  // Export subcommand
413
481
  const exportCommand = {
414
482
  name: 'export',
415
- description: 'Export session to file',
483
+ description: 'Export a saved session to a file. Usage: session export <session-id> [-o file] (or --latest)',
416
484
  options: [
417
485
  {
418
486
  name: 'output',
@@ -420,6 +488,12 @@ const exportCommand = {
420
488
  description: 'Output file path',
421
489
  type: 'string'
422
490
  },
491
+ {
492
+ name: 'latest',
493
+ description: 'Export the most recently saved session when no session id is given',
494
+ type: 'boolean',
495
+ default: false
496
+ },
423
497
  {
424
498
  name: 'format',
425
499
  short: 'f',
@@ -446,14 +520,23 @@ const exportCommand = {
446
520
  let outputPath = ctx.flags.output;
447
521
  const exportFormat = ctx.flags.format;
448
522
  const compress = ctx.flags.compress;
449
- // Get current session if no ID provided
523
+ // #3575: like `session delete`, refuse to guess which session to act on.
524
+ // Exporting the most recent one is available, but only when asked for.
450
525
  if (!sessionId) {
526
+ if (!ctx.flags.latest) {
527
+ output.printError('Session ID is required. Pass a session id, or --latest to export the most recently saved session.');
528
+ return { success: false, exitCode: 1 };
529
+ }
451
530
  try {
452
531
  const current = await callMCPTool('session_current', {});
532
+ if (toolError(current) || !current.sessionId) {
533
+ output.printError('No saved sessions to export.');
534
+ return { success: false, exitCode: 1 };
535
+ }
453
536
  sessionId = current.sessionId;
454
537
  }
455
538
  catch {
456
- output.printError('No active session. Provide a session ID to export.');
539
+ output.printError('No saved sessions to export.');
457
540
  return { success: false, exitCode: 1 };
458
541
  }
459
542
  }
@@ -469,6 +552,12 @@ const exportCommand = {
469
552
  sessionId,
470
553
  includeMemory: ctx.flags['include-memory'] !== false
471
554
  });
555
+ const failure = toolError(result);
556
+ if (failure || result.data === undefined) {
557
+ spinner.fail('Failed to export session');
558
+ output.printError(failure || 'The export returned no session data');
559
+ return { success: false, exitCode: 1 };
560
+ }
472
561
  // Format output
473
562
  let content;
474
563
  if (exportFormat === 'yaml') {
@@ -484,7 +573,8 @@ const exportCommand = {
484
573
  fs.writeFileSync(absolutePath, content, 'utf-8');
485
574
  spinner.succeed('Session exported');
486
575
  output.writeln();
487
- const exportStats = result.stats || {};
576
+ // session_export returns the whole record under `data`; its stats live there.
577
+ const exportStats = result.stats || result.data?.stats || {};
488
578
  output.printTable({
489
579
  columns: [
490
580
  { key: 'property', header: 'Property', width: 18 },
@@ -522,7 +612,7 @@ const exportCommand = {
522
612
  // Import subcommand
523
613
  const importCommand = {
524
614
  name: 'import',
525
- description: 'Import session from file',
615
+ description: 'Import a session from a file written by session export. Usage: session import <file> [--name n] [--activate]',
526
616
  options: [
527
617
  {
528
618
  name: 'name',
@@ -555,21 +645,32 @@ const importCommand = {
555
645
  const spinner = output.createSpinner({ text: 'Importing session...' });
556
646
  spinner.start();
557
647
  try {
558
- const content = fs.readFileSync(absolutePath, 'utf-8');
559
- let data;
560
- // Parse based on extension
648
+ // The YAML written by `session export --format yaml` is display-only;
649
+ // there is no YAML parser here, so say so instead of failing obscurely.
561
650
  if (absolutePath.endsWith('.yaml') || absolutePath.endsWith('.yml')) {
562
- // Simple YAML parsing (basic implementation)
563
- data = JSON.parse(content); // Would need proper YAML parser
564
- }
565
- else {
566
- data = JSON.parse(content);
651
+ try {
652
+ JSON.parse(fs.readFileSync(absolutePath, 'utf-8'));
653
+ }
654
+ catch {
655
+ spinner.fail('Failed to import session');
656
+ output.printError('YAML session import is not supported. Export with --format json and import that file.');
657
+ return { success: false, exitCode: 1 };
658
+ }
567
659
  }
660
+ // #3575: session_import reads the file itself from `inputPath`. Passing a
661
+ // parsed `data` object used to hit its required-argument check, and the
662
+ // CLI then printed "Session imported" before crashing on the error result.
568
663
  const result = await callMCPTool('session_import', {
569
- data,
664
+ inputPath: absolutePath,
570
665
  name: sessionName,
571
666
  activate
572
667
  });
668
+ const failure = toolError(result);
669
+ if (failure) {
670
+ spinner.fail('Failed to import session');
671
+ output.printError(failure);
672
+ return { success: false, exitCode: 1 };
673
+ }
573
674
  spinner.succeed('Session imported');
574
675
  output.writeln();
575
676
  output.printTable({
@@ -619,6 +720,11 @@ const currentCommand = {
619
720
  action: async (ctx) => {
620
721
  try {
621
722
  const result = await callMCPTool('session_current', { includeStats: true });
723
+ if (toolError(result)) {
724
+ output.printWarning('No active session');
725
+ output.printInfo('Save one with "claude-flow session save"');
726
+ return { success: true, data: { active: false } };
727
+ }
622
728
  if (ctx.flags.format === 'json') {
623
729
  output.printJson(result);
624
730
  return { success: true, data: result };
@@ -727,7 +833,8 @@ export const sessionCommand = {
727
833
  { command: 'claude-flow session save -n "checkpoint-1"', description: 'Save current session' },
728
834
  { command: 'claude-flow session restore session-123', description: 'Restore a session' },
729
835
  { command: 'claude-flow session delete session-123', description: 'Delete a session' },
730
- { command: 'claude-flow session export -o backup.json', description: 'Export session to file' },
836
+ { command: 'claude-flow session export session-123 -o backup.json', description: 'Export a session to file' },
837
+ { command: 'claude-flow session export --latest -o backup.json', description: 'Export the most recently saved session' },
731
838
  { command: 'claude-flow session import backup.json', description: 'Import session from file' },
732
839
  { command: 'claude-flow session current', description: 'Show current session' }
733
840
  ],
@@ -196,17 +196,14 @@ function getSwarmStatus(swarmId) {
196
196
  // Ignore
197
197
  }
198
198
  }
199
- // Calculate dynamic progress based on actual state
200
- // If no swarm state, show 0%. Otherwise calculate from completed tasks
199
+ // Progress is completed / total tasks. With no tasks there is nothing to be
200
+ // a fraction of, so it is `null` with a reason — never an invented number
201
+ // (#3572: a hard-coded 5% was shown for a swarm with no tasks, forever).
201
202
  const totalTasks = completedTasks + inProgressTasks + pendingTasks;
202
- let progress = 0;
203
- if (totalTasks > 0) {
204
- progress = Math.round((completedTasks / totalTasks) * 100);
205
- }
206
- else if (swarmState) {
207
- // Swarm initialized but no tasks yet
208
- progress = 5;
209
- }
203
+ const progress = totalTasks > 0
204
+ ? Math.round((completedTasks / totalTasks) * 100)
205
+ : null;
206
+ const progressReason = totalTasks > 0 ? null : 'no tasks';
210
207
  // Determine status
211
208
  let status = 'idle';
212
209
  if (inProgressTasks > 0 || activeAgents > 0) {
@@ -243,6 +240,7 @@ function getSwarmStatus(swarmId) {
243
240
  completed: 0
244
241
  },
245
242
  progress,
243
+ progressReason,
246
244
  tasks: {
247
245
  total: totalTasks,
248
246
  completed: completedTasks,
@@ -738,7 +736,9 @@ const statusCommand = {
738
736
  output.writeln(output.bold(`Swarm Status: ${status.id ?? output.dim('unknown id')}`));
739
737
  output.writeln();
740
738
  // Progress bar
741
- output.writeln(`Overall Progress: ${output.progressBar(status.progress, 100, 40)}`);
739
+ output.writeln(status.progress === null
740
+ ? `Overall Progress: ${output.dim(`n/a (${status.progressReason})`)}`
741
+ : `Overall Progress: ${output.progressBar(status.progress, 100, 40)}`);
742
742
  output.writeln();
743
743
  // Agent status
744
744
  output.writeln(output.bold('Agents'));
package/dist/src/index.js CHANGED
@@ -155,10 +155,19 @@ export class CLI {
155
155
  // loudly (not silent) — the existing project helpers were left intact.
156
156
  this.output.printWarning(`Skipped helper auto-refresh — ${r.blocked}. Reinstall @claude-flow/cli from a trusted source.`);
157
157
  }
158
+ else if (r.healed) {
159
+ // #3565: a critical helper's stamp matched but its on-disk content
160
+ // didn't hash-match the signed manifest — restored, but always
161
+ // surfaced (not verbose-gated like a routine version-bump refresh).
162
+ this.output.printWarning(`Detected tampered critical helper(s) in .claude/helpers (${(r.tampered || []).join(', ')}) — restored verified content from the installed package. If this wasn't expected, find out what modified them.`);
163
+ }
158
164
  else if (r.refreshed && this.output.isVerbose()) {
159
165
  this.output.printDebug(`Refreshed .claude/helpers (${r.from} → ${r.to})`);
160
166
  }
161
- if (r.global?.refreshed && this.output.isVerbose()) {
167
+ if (r.global?.healed) {
168
+ this.output.printWarning(`Detected tampered critical helper(s) in ~/.claude/helpers (${(r.global.tampered || []).join(', ')}) — restored verified content.`);
169
+ }
170
+ else if (r.global?.refreshed && this.output.isVerbose()) {
162
171
  this.output.printDebug(`Refreshed ~/.claude/helpers (${r.global.from} → ${r.global.to})`);
163
172
  }
164
173
  else if (r.global?.blocked && r.global.blocked !== r.blocked) {
@@ -18,6 +18,7 @@ import { generatePreCommitHook, generatePostCommitHook, generateSessionManager,
18
18
  import { getInstalledCliVersion, HELPERS_STAMP_FILE } from './helper-refresh.js';
19
19
  import { generateClaudeMd } from './claudemd-generator.js';
20
20
  import { recordMemoryPackagePath } from './memory-package-resolver.js';
21
+ import { ensureCommonJsCompanions } from './helper-companions.js';
21
22
  import { scanSettingsForRisk, formatRiskFindingsAsWarnings } from './settings-risk-scanner.js';
22
23
  /**
23
24
  * Skills to copy based on configuration
@@ -499,7 +500,7 @@ export async function executeUpgrade(targetDir, upgradeSettings = false) {
499
500
  const sourceHelpersForUpgrade = findSourceHelpersDir();
500
501
  if (sourceHelpersForUpgrade) {
501
502
  // Keep in sync with helper-refresh.ts:CRITICAL_HELPERS.
502
- const criticalHelpers = ['auto-memory-hook.mjs', 'hook-handler.cjs', 'intelligence.cjs', 'statusline.cjs', 'router.js'];
503
+ const criticalHelpers = ['auto-memory-hook.mjs', 'hook-handler.cjs', 'intelligence.cjs', 'statusline.cjs', 'router.cjs'];
503
504
  for (const helperName of criticalHelpers) {
504
505
  const targetPath = path.join(targetDir, '.claude', 'helpers', helperName);
505
506
  const sourcePath = path.join(sourceHelpersForUpgrade, helperName);
@@ -524,7 +525,7 @@ export async function executeUpgrade(targetDir, upgradeSettings = false) {
524
525
  'hook-handler.cjs': generateHookHandler(),
525
526
  'intelligence.cjs': generateIntelligenceStub(),
526
527
  'auto-memory-hook.mjs': generateAutoMemoryHook(),
527
- 'router.js': generateAgentRouter(), // ADR-389
528
+ 'router.cjs': generateAgentRouter(), // ADR-389 / #3555
528
529
  };
529
530
  for (const [helperName, content] of Object.entries(generatedCritical)) {
530
531
  const targetPath = path.join(targetDir, '.claude', 'helpers', helperName);
@@ -541,6 +542,11 @@ export async function executeUpgrade(targetDir, upgradeSettings = false) {
541
542
  catch { }
542
543
  }
543
544
  }
545
+ // #3555: the refreshed hook-handler requires the .cjs companions; older
546
+ // installs only have session.js / memory.js, which can't load in ESM.
547
+ for (const name of await ensureCommonJsCompanions(path.join(targetDir, '.claude', 'helpers'))) {
548
+ result.created.push(`.claude/helpers/${name}`);
549
+ }
544
550
  // Stamp the installed version so the startup auto-refresh treats these as
545
551
  // current (no redundant re-copy on the next command).
546
552
  try {
@@ -1295,9 +1301,9 @@ async function writeHelpers(targetDir, options, result) {
1295
1301
  const helpers = {
1296
1302
  'pre-commit': generatePreCommitHook(),
1297
1303
  'post-commit': generatePostCommitHook(),
1298
- 'session.js': generateSessionManager(),
1299
- 'router.js': generateAgentRouter(),
1300
- 'memory.js': generateMemoryHelper(),
1304
+ 'session.cjs': generateSessionManager(),
1305
+ 'router.cjs': generateAgentRouter(),
1306
+ 'memory.cjs': generateMemoryHelper(),
1301
1307
  'hook-handler.cjs': generateHookHandler(),
1302
1308
  'intelligence.cjs': generateIntelligenceStub(),
1303
1309
  'auto-memory-hook.mjs': generateAutoMemoryHook(),
@@ -0,0 +1,3 @@
1
+ export declare const COMMONJS_COMPANION_HELPERS: readonly ["session.cjs", "memory.cjs"];
2
+ export declare function ensureCommonJsCompanions(helpersDir: string): Promise<string[]>;
3
+ //# sourceMappingURL=helper-companions.d.ts.map