runwork 0.11.0 → 0.13.1

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 (42) hide show
  1. package/dist/agents/__tests__/intro-skill.test.js +32 -0
  2. package/dist/agents/claude-code.d.ts +21 -0
  3. package/dist/agents/claude-code.js +58 -2
  4. package/dist/agents/claude-desktop-plugin-tree.d.ts +15 -0
  5. package/dist/agents/claude-desktop-plugin-tree.js +37 -1
  6. package/dist/agents/conversation-skills.d.ts +18 -0
  7. package/dist/agents/conversation-skills.js +229 -0
  8. package/dist/agents/intro-skill.d.ts +9 -0
  9. package/dist/agents/intro-skill.js +41 -0
  10. package/dist/agents/registry-data.d.ts +35 -0
  11. package/dist/agents/registry-data.js +58 -1
  12. package/dist/agents/runtime-detection.d.ts +82 -0
  13. package/dist/agents/runtime-detection.js +271 -0
  14. package/dist/agents/session-start-hook.d.ts +37 -0
  15. package/dist/agents/session-start-hook.js +159 -0
  16. package/dist/agents/types.d.ts +12 -0
  17. package/dist/api/client.d.ts +47 -0
  18. package/dist/api/client.js +32 -0
  19. package/dist/commands/__tests__/setup-persona.test.d.ts +1 -0
  20. package/dist/commands/__tests__/setup-persona.test.js +31 -0
  21. package/dist/commands/doctor.js +89 -2
  22. package/dist/commands/inbox.d.ts +11 -0
  23. package/dist/commands/inbox.js +60 -0
  24. package/dist/commands/info.d.ts +1 -1
  25. package/dist/commands/info.js +7 -2
  26. package/dist/commands/resume.d.ts +2 -0
  27. package/dist/commands/resume.js +265 -0
  28. package/dist/commands/save-convo.d.ts +8 -0
  29. package/dist/commands/save-convo.js +20 -0
  30. package/dist/commands/setup.d.ts +7 -0
  31. package/dist/commands/setup.js +22 -0
  32. package/dist/commands/share-convo.d.ts +24 -0
  33. package/dist/commands/share-convo.js +167 -0
  34. package/dist/commands/sync.js +96 -10
  35. package/dist/generated/bundled-types.js +33 -33
  36. package/dist/generated/version.d.ts +1 -1
  37. package/dist/generated/version.js +1 -1
  38. package/dist/index.js +8 -0
  39. package/dist/types.d.ts +13 -0
  40. package/dist/utils/app-info.d.ts +4 -0
  41. package/dist/utils/app-info.js +17 -0
  42. package/package.json +1 -1
@@ -1,5 +1,12 @@
1
1
  import { Command } from 'commander';
2
2
  import { ApiClient } from '../api/client.js';
3
+ import type { SetupState } from '../types.js';
4
+ /**
5
+ * Resolve the persona to persist in setup.json. Prefers the `--persona` flag
6
+ * (passed by the desktop onboarding flow); otherwise preserves any persona
7
+ * already on disk so re-running `setup` without the flag does not wipe it.
8
+ */
9
+ export declare function resolvePersona(flag: string | undefined, existing: SetupState['persona']): SetupState['persona'];
3
10
  /**
4
11
  * Resolve the workspace for `setup` and persist it as the default in credentials.
5
12
  *
@@ -8,6 +8,26 @@ import { resolveWorkspace, hasProjectConfig } from '../workspace/resolve.js';
8
8
  import { promptSelect, promptConfirm } from '../utils/prompt.js';
9
9
  import { detectAgents, printNoAgentsMessage } from '../agents/detect.js';
10
10
  import { syncFromState } from './sync.js';
11
+ import { loadSetupState } from '../utils/setup-state.js';
12
+ const PERSONA_LABELS = {
13
+ 1: 'novice',
14
+ 2: 'curious',
15
+ 3: 'engineer',
16
+ };
17
+ /**
18
+ * Resolve the persona to persist in setup.json. Prefers the `--persona` flag
19
+ * (passed by the desktop onboarding flow); otherwise preserves any persona
20
+ * already on disk so re-running `setup` without the flag does not wipe it.
21
+ */
22
+ export function resolvePersona(flag, existing) {
23
+ if (flag) {
24
+ const level = Number(flag);
25
+ if (level === 1 || level === 2 || level === 3) {
26
+ return { level, label: PERSONA_LABELS[level] };
27
+ }
28
+ }
29
+ return existing;
30
+ }
11
31
  /**
12
32
  * Resolve the workspace for `setup` and persist it as the default in credentials.
13
33
  *
@@ -49,6 +69,7 @@ export const setupCommand = new Command('setup')
49
69
  .option('--agent <slug>', 'Only configure a specific agent (e.g. claude-code, cursor)')
50
70
  .option('--dry-run', 'Show what would be configured without writing files')
51
71
  .option('-y, --yes', 'Skip all prompts, configure all detected agents with user scope')
72
+ .option('--persona <level>', 'Technical-level persona for agent instructions (1=novice, 2=curious, 3=engineer)')
52
73
  .action(async (opts) => {
53
74
  const credentials = requireAuth();
54
75
  const client = new ApiClient(credentials);
@@ -124,6 +145,7 @@ export const setupCommand = new Command('setup')
124
145
  skills: [],
125
146
  skillHashes: {},
126
147
  lastDetectedAt: new Date().toISOString(),
148
+ persona: resolvePersona(opts.persona, loadSetupState()?.persona),
127
149
  };
128
150
  const scopes = scope === 'both' ? ['project', 'user'] : [scope];
129
151
  for (const s of scopes) {
@@ -0,0 +1,24 @@
1
+ import { Command } from 'commander';
2
+ /**
3
+ * `runwork share-convo` - share the current AI conversation with a teammate.
4
+ *
5
+ * Typically invoked by the `share-conversation` skill from inside an AI agent.
6
+ * The skill is responsible for emitting the markdown transcript and metadata;
7
+ * this command does the upload, host-agent detection, and native-bundle pickup.
8
+ */
9
+ interface ShareConvoOptions {
10
+ to?: string[];
11
+ transcriptFile?: string;
12
+ nativeFile?: string;
13
+ sourceAgent?: string;
14
+ title?: string;
15
+ note?: string;
16
+ personal?: boolean;
17
+ ttlDays?: string;
18
+ metadataJson?: string;
19
+ metadataFile?: string;
20
+ workspace?: string;
21
+ }
22
+ export declare function runShareConvo(opts: ShareConvoOptions, command: Command, isPersonalAlias?: boolean): Promise<void>;
23
+ export declare const shareConvoCommand: Command;
24
+ export {};
@@ -0,0 +1,167 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { createHash } from 'crypto';
4
+ import { requireAuth } from '../auth/store.js';
5
+ import { ApiClient } from '../api/client.js';
6
+ import { resolveWorkspace } from '../workspace/resolve.js';
7
+ import { shouldOutputJson, jsonOut } from '../utils/output.js';
8
+ import { detectCurrentAgent } from '../agents/runtime-detection.js';
9
+ function nativeBundleFormatForAgent(slug) {
10
+ if (slug === 'claude-code' || slug === 'claude-desktop')
11
+ return 'claude-jsonl';
12
+ if (slug === 'codex' || slug === 'codex-app')
13
+ return 'codex-rollout';
14
+ return null;
15
+ }
16
+ function sha256Hex(content) {
17
+ return createHash('sha256').update(content, 'utf8').digest('hex');
18
+ }
19
+ function utf8ByteLength(content) {
20
+ return Buffer.byteLength(content, 'utf8');
21
+ }
22
+ export async function runShareConvo(opts, command, isPersonalAlias = false) {
23
+ const useJson = shouldOutputJson(command.optsWithGlobals().json);
24
+ if (!opts.transcriptFile) {
25
+ console.error('Error: --transcript-file is required. Pass the path to the LLM-emitted markdown transcript.');
26
+ process.exit(1);
27
+ }
28
+ if (!existsSync(opts.transcriptFile)) {
29
+ console.error(`Error: transcript file does not exist: ${opts.transcriptFile}`);
30
+ process.exit(1);
31
+ }
32
+ if (!opts.title) {
33
+ console.error('Error: --title is required.');
34
+ process.exit(1);
35
+ }
36
+ const isPersonal = isPersonalAlias || !!opts.personal || (!opts.to || opts.to.length === 0);
37
+ const recipients = opts.to ?? [];
38
+ if (!isPersonal && recipients.length === 0) {
39
+ console.error('Error: at least one --to <email> is required (or use --personal to save for yourself)');
40
+ process.exit(1);
41
+ }
42
+ if (isPersonal && recipients.length > 0) {
43
+ console.error('Error: --personal and --to cannot be combined; personal shares are self-only');
44
+ process.exit(1);
45
+ }
46
+ const credentials = requireAuth();
47
+ const client = new ApiClient(credentials);
48
+ const { workspaceId } = await resolveWorkspace(client, { workspace: opts.workspace });
49
+ // Read transcript bundle (always required)
50
+ const transcriptContent = readFileSync(opts.transcriptFile, 'utf8');
51
+ const bundles = [
52
+ {
53
+ format: 'transcript',
54
+ content: transcriptContent,
55
+ sizeBytes: utf8ByteLength(transcriptContent),
56
+ sha256: sha256Hex(transcriptContent),
57
+ },
58
+ ];
59
+ // Detect host agent (for source tagging and native bundle pickup)
60
+ const detected = detectCurrentAgent();
61
+ const sourceAgent = opts.sourceAgent ?? detected?.slug ?? 'generic';
62
+ // Resolve native bundle - explicit --native-file wins, else use detection
63
+ let nativeFilePath = null;
64
+ if (opts.nativeFile) {
65
+ if (!existsSync(opts.nativeFile)) {
66
+ console.error(`Error: --native-file path does not exist: ${opts.nativeFile}`);
67
+ process.exit(1);
68
+ }
69
+ nativeFilePath = opts.nativeFile;
70
+ }
71
+ else if (detected?.sessionFilePath && existsSync(detected.sessionFilePath)) {
72
+ nativeFilePath = detected.sessionFilePath;
73
+ }
74
+ if (nativeFilePath) {
75
+ const nativeFormat = nativeBundleFormatForAgent(sourceAgent);
76
+ if (nativeFormat) {
77
+ try {
78
+ const content = readFileSync(nativeFilePath, 'utf8');
79
+ bundles.push({
80
+ format: nativeFormat,
81
+ content,
82
+ sizeBytes: utf8ByteLength(content),
83
+ sha256: sha256Hex(content),
84
+ });
85
+ }
86
+ catch (err) {
87
+ console.error(`Warning: could not read native file ${nativeFilePath}: ${err instanceof Error ? err.message : err}`);
88
+ console.error('Continuing with transcript-only bundle.');
89
+ }
90
+ }
91
+ }
92
+ // Metadata - merge --metadata-json and --metadata-file
93
+ let metadata = {};
94
+ if (opts.metadataFile) {
95
+ try {
96
+ metadata = JSON.parse(readFileSync(opts.metadataFile, 'utf8'));
97
+ }
98
+ catch (err) {
99
+ console.error(`Error: --metadata-file is not valid JSON: ${err instanceof Error ? err.message : err}`);
100
+ process.exit(1);
101
+ }
102
+ }
103
+ if (opts.metadataJson) {
104
+ try {
105
+ metadata = { ...metadata, ...JSON.parse(opts.metadataJson) };
106
+ }
107
+ catch (err) {
108
+ console.error(`Error: --metadata-json is not valid JSON: ${err instanceof Error ? err.message : err}`);
109
+ process.exit(1);
110
+ }
111
+ }
112
+ // Augment metadata with detected host info if not already provided
113
+ if (detected && !metadata.sourceSessionId) {
114
+ metadata.sourceSessionId = detected.sessionId;
115
+ }
116
+ if (!metadata.os) {
117
+ metadata.os = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux';
118
+ }
119
+ const ttlDays = opts.ttlDays ? parseInt(opts.ttlDays, 10) : undefined;
120
+ try {
121
+ const result = await client.createSharedConversation(workspaceId, {
122
+ recipients: isPersonal ? [] : recipients,
123
+ isPersonal,
124
+ sourceAgent,
125
+ title: opts.title,
126
+ note: opts.note,
127
+ bundles,
128
+ metadata,
129
+ ttlDays: ttlDays && Number.isFinite(ttlDays) ? ttlDays : undefined,
130
+ });
131
+ if (useJson) {
132
+ jsonOut(result);
133
+ return;
134
+ }
135
+ if (isPersonal) {
136
+ console.log(`Saved conversation: ${result.share.title}`);
137
+ console.log(`Share ID: ${result.share.id}`);
138
+ console.log(`Expires: ${result.share.expiresAt}`);
139
+ console.log(`\nResume with: runwork resume ${result.share.id}`);
140
+ }
141
+ else {
142
+ console.log(`Shared with ${result.sharedCount} member${result.sharedCount === 1 ? '' : 's'}` +
143
+ (result.invitedCount > 0 ? ` and invited ${result.invitedCount} new user${result.invitedCount === 1 ? '' : 's'}` : '') + '.');
144
+ console.log(`Share ID: ${result.share.id}`);
145
+ if (result.skipped.length > 0) {
146
+ console.log(`\nSkipped: ${result.skipped.map(s => `${s.identifier} (${s.reason})`).join(', ')}`);
147
+ }
148
+ }
149
+ }
150
+ catch (err) {
151
+ console.error(`Failed to share conversation: ${err instanceof Error ? err.message : err}`);
152
+ process.exit(1);
153
+ }
154
+ }
155
+ export const shareConvoCommand = new Command('share-convo')
156
+ .description('Share the current AI conversation with a teammate')
157
+ .option('--to <email>', 'Recipient email (repeatable)', (value, prev = []) => [...prev, value], [])
158
+ .option('--transcript-file <path>', 'Path to the LLM-emitted markdown transcript (required)')
159
+ .option('--native-file <path>', 'Path to the native session file (optional; auto-detected from env vars otherwise)')
160
+ .option('--source-agent <slug>', 'Override host-agent detection (e.g. claude-code, codex, claude-desktop)')
161
+ .option('--title <string>', 'Short title for the conversation (required)')
162
+ .option('--note <string>', 'Optional personal note to recipients')
163
+ .option('--ttl-days <n>', 'Days until expiration (1-30, default 7)')
164
+ .option('--metadata-json <json>', 'Inline JSON object with workMode, openQuestions, suggestedNextStep, etc.')
165
+ .option('--metadata-file <path>', 'Path to a JSON file with the same metadata fields')
166
+ .option('--workspace <id>', 'Workspace ID')
167
+ .action((opts, command) => runShareConvo(opts, command, false));
@@ -11,6 +11,7 @@ import { RUNWORK_AGENT_DEFAULTS, AGENT_DEFAULTS_SCHEMA_VERSION } from '../agents
11
11
  import { resolveAgentDefaults } from '../agents/defaults-merge.js';
12
12
  import { collectTelemetryEvents, printTelemetryVerbose, summarizeTelemetryForDryRun, } from './sync-telemetry.js';
13
13
  import { generateIntroSkill, generateInstructionHint, buildAppSkillDescription } from '../agents/intro-skill.js';
14
+ import { buildShareConversationSkill, buildSaveConversationSkill, BUILT_IN_SKILL_NAMES } from '../agents/conversation-skills.js';
14
15
  import { computeSyncPlan } from '../sync/change-detect.js';
15
16
  import { printSyncSummary, resolveConflict, resolveConflictNonInteractive } from '../sync/conflict-ui.js';
16
17
  import { executeSyncPlan } from '../sync/executor.js';
@@ -194,11 +195,27 @@ export async function syncFromState(state, statePath, credentials, opts) {
194
195
  }
195
196
  // Read local skills for change detection
196
197
  const localSkills = readLocalSkills(state);
198
+ // Built-in skills (shipped by the CLI from code) are CANONICAL: they
199
+ // are never pushed to the workspace and never pulled from it. The diff
200
+ // engine must not see them at all -- if it did, a workspace skill that
201
+ // happens to share a name (e.g. someone uploaded `share-conversation`
202
+ // manually) would either push the built-in upstream or pull the stale
203
+ // workspace copy on top, both wrong. Filter both sides before the diff.
204
+ const builtInNameSet = new Set(BUILT_IN_SKILL_NAMES);
205
+ const collidingRemote = remoteSkills.filter(s => builtInNameSet.has(s.name));
206
+ if (collidingRemote.length > 0) {
207
+ console.log(`\n Warning: ${collidingRemote.length} workspace skill${collidingRemote.length === 1 ? '' : 's'} ` +
208
+ `shadow${collidingRemote.length === 1 ? 's' : ''} built-in CLI skill${collidingRemote.length === 1 ? '' : 's'}: ` +
209
+ `${collidingRemote.map(s => s.name).join(', ')}. The CLI built-in versions will be used locally; ` +
210
+ `the workspace copies are stale duplicates and can be safely deleted from the dashboard.`);
211
+ }
212
+ const localSkillsForDiff = localSkills.filter(s => !builtInNameSet.has(s.name));
213
+ const remoteSkillsForDiff = remoteSkills.filter(s => !builtInNameSet.has(s.name));
197
214
  // Compute sync plan
198
215
  const plan = computeSyncPlan({
199
216
  storedHashes: state.skillHashes || {},
200
- localSkills,
201
- remoteSkills,
217
+ localSkills: localSkillsForDiff,
218
+ remoteSkills: remoteSkillsForDiff,
202
219
  });
203
220
  // In pull-only mode, move pushes and conflicts to skips
204
221
  if (opts.pullOnly) {
@@ -293,6 +310,7 @@ export async function syncFromState(state, statePath, credentials, opts) {
293
310
  appCount: allSkills.filter(s => s.type === 'app').length,
294
311
  skillCount: remoteSkills.length,
295
312
  mcpServerCount: mcpEntries.length,
313
+ persona: state.persona,
296
314
  });
297
315
  // For project scope: determine which app skill to include (only current app's skill)
298
316
  let projectAppSkillFilter = null;
@@ -305,7 +323,28 @@ export async function syncFromState(state, statePath, credentials, opts) {
305
323
  }
306
324
  catch { /* ignore */ }
307
325
  }
326
+ // Built-in skills shipped by the CLI on every sync. Named upfront so we can
327
+ // surface them by name in the per-adapter sync log -- helps users (and
328
+ // their agents) confirm that /runwork, /share-conversation, etc. landed.
329
+ const builtInSkills = [
330
+ introSkill,
331
+ buildShareConversationSkill(),
332
+ buildSaveConversationSkill(),
333
+ ];
334
+ const builtInNames = builtInSkills.map(s => s.name);
335
+ // Counters surfaced at the end of the sync as a one-line summary so the
336
+ // user can see at a glance how much actually got written this run.
337
+ const summary = {
338
+ adaptersProcessed: 0,
339
+ adaptersFailed: 0,
340
+ skillWrites: 0,
341
+ skillFilesWritten: 0,
342
+ mcpServerWrites: 0,
343
+ instructionHintWrites: 0,
344
+ hookInstallCalls: 0,
345
+ };
308
346
  for (const adapter of adapters) {
347
+ let adapterFailedAnyScope = false;
309
348
  for (const scope of scopes) {
310
349
  try {
311
350
  // Write skills: for project scope, filter to only the current app's skill.
@@ -315,6 +354,11 @@ export async function syncFromState(state, statePath, credentials, opts) {
315
354
  if (adapter.supportsSkills()) {
316
355
  const skipAppSkills = adapter.mcpProvidesSkills && mcpEntries.length > 0;
317
356
  const scopeSkills = remoteSkills.filter(s => {
357
+ // Built-in CLI skills are written from the canonical built-in array
358
+ // below; a workspace skill with the same name is a shadow that
359
+ // would overwrite the built-in if we let it through. Skip it.
360
+ if (builtInNameSet.has(s.name))
361
+ return false;
318
362
  // App-sourced skills are already available via MCP skill_* tools
319
363
  if (skipAppSkills && s.source === 'app')
320
364
  return false;
@@ -324,29 +368,57 @@ export async function syncFromState(state, statePath, credentials, opts) {
324
368
  }
325
369
  return true;
326
370
  });
327
- const allSkillFiles = [introSkill, ...scopeSkills.map(s => ({
328
- name: s.name,
329
- filename: s.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
330
- content: s.content,
331
- description: s.source === 'app'
332
- ? (buildAppSkillDescription(s.name, registries) || `${s.name} - Runwork workspace application`)
333
- : `${s.name} - Runwork workspace skill`,
334
- }))];
371
+ const workspaceSkillFiles = scopeSkills.map(s => ({
372
+ name: s.name,
373
+ filename: s.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
374
+ content: s.content,
375
+ description: s.source === 'app'
376
+ ? (buildAppSkillDescription(s.name, registries) || `${s.name} - Runwork workspace application`)
377
+ : `${s.name} - Runwork workspace skill`,
378
+ }));
379
+ const allSkillFiles = [...builtInSkills, ...workspaceSkillFiles];
335
380
  await adapter.writeSkills(allSkillFiles, scope);
381
+ summary.skillWrites++;
382
+ summary.skillFilesWritten += allSkillFiles.length;
383
+ console.log(` [${adapter.name}] Wrote ${allSkillFiles.length} skills (${scope}): ` +
384
+ `${builtInSkills.length} built-in [${builtInNames.join(', ')}], ` +
385
+ `${workspaceSkillFiles.length} workspace`);
386
+ }
387
+ else {
388
+ console.log(` [${adapter.name}] Skipped skills (${scope}): adapter does not support skills`);
336
389
  }
337
390
  // Write MCP configs
338
391
  if (adapter.supportsMcpScope(scope) && mcpEntries.length > 0) {
339
392
  await adapter.writeMcpServers(mcpEntries, scope);
393
+ summary.mcpServerWrites++;
340
394
  console.log(` [${adapter.name}] Updated ${mcpEntries.length} MCP server${mcpEntries.length > 1 ? 's' : ''} (${scope})`);
341
395
  }
396
+ else if (mcpEntries.length === 0) {
397
+ console.log(` [${adapter.name}] Skipped MCP servers (${scope}): no workspace MCP servers configured`);
398
+ }
399
+ else if (!adapter.supportsMcpScope(scope)) {
400
+ console.log(` [${adapter.name}] Skipped MCP servers (${scope}): adapter does not support MCP at this scope`);
401
+ }
342
402
  // Write instruction hint
343
403
  await adapter.writeInstructionHint(instructionHint, scope);
404
+ summary.instructionHintWrites++;
344
405
  console.log(` [${adapter.name}] Updated instruction hints (${scope})`);
406
+ // Install built-in hooks (Claude Code's SessionStart, etc.). Called
407
+ // after writeSkills so the plugin tree already exists. Skipped by
408
+ // adapters that don't implement it.
409
+ if (adapter.writeBuiltInHooks) {
410
+ await adapter.writeBuiltInHooks(scope);
411
+ summary.hookInstallCalls++;
412
+ }
345
413
  }
346
414
  catch (err) {
415
+ adapterFailedAnyScope = true;
347
416
  console.warn(` [${adapter.name}] Failed (${scope}): ${err instanceof Error ? err.message : err}`);
348
417
  }
349
418
  }
419
+ summary.adaptersProcessed++;
420
+ if (adapterFailedAnyScope)
421
+ summary.adaptersFailed++;
350
422
  }
351
423
  // Pull team config from server. A failure here must not stop the
352
424
  // unified user-scope write below, which applies network and minimum-permission
@@ -568,6 +640,20 @@ export async function syncFromState(state, statePath, credentials, opts) {
568
640
  catch {
569
641
  // Telemetry failures are non-fatal
570
642
  }
643
+ // One-line summary so the user (and any agent reading sync output) can
644
+ // see at a glance what got written this run, without scrolling the whole
645
+ // per-adapter log.
646
+ const summaryParts = [];
647
+ summaryParts.push(`${summary.adaptersProcessed} adapter${summary.adaptersProcessed === 1 ? '' : 's'}`);
648
+ if (summary.adaptersFailed > 0)
649
+ summaryParts.push(`${summary.adaptersFailed} failed`);
650
+ summaryParts.push(`${summary.skillWrites} skill write${summary.skillWrites === 1 ? '' : 's'} (${summary.skillFilesWritten} files)`);
651
+ if (summary.mcpServerWrites > 0)
652
+ summaryParts.push(`${summary.mcpServerWrites} MCP config write${summary.mcpServerWrites === 1 ? '' : 's'}`);
653
+ if (summary.hookInstallCalls > 0)
654
+ summaryParts.push(`${summary.hookInstallCalls} hook install${summary.hookInstallCalls === 1 ? '' : 's'}`);
655
+ summaryParts.push(`${summary.instructionHintWrites} instruction hint write${summary.instructionHintWrites === 1 ? '' : 's'}`);
656
+ console.log(`\n Summary: ${summaryParts.join(', ')}.`);
571
657
  }
572
658
  export const syncCommand = new Command('sync')
573
659
  .description('Sync skills bidirectionally and refresh MCP configs from workspace')