runwork 0.12.0 → 0.13.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.
@@ -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) {
@@ -306,7 +323,28 @@ export async function syncFromState(state, statePath, credentials, opts) {
306
323
  }
307
324
  catch { /* ignore */ }
308
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
+ };
309
346
  for (const adapter of adapters) {
347
+ let adapterFailedAnyScope = false;
310
348
  for (const scope of scopes) {
311
349
  try {
312
350
  // Write skills: for project scope, filter to only the current app's skill.
@@ -316,6 +354,11 @@ export async function syncFromState(state, statePath, credentials, opts) {
316
354
  if (adapter.supportsSkills()) {
317
355
  const skipAppSkills = adapter.mcpProvidesSkills && mcpEntries.length > 0;
318
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;
319
362
  // App-sourced skills are already available via MCP skill_* tools
320
363
  if (skipAppSkills && s.source === 'app')
321
364
  return false;
@@ -325,29 +368,57 @@ export async function syncFromState(state, statePath, credentials, opts) {
325
368
  }
326
369
  return true;
327
370
  });
328
- const allSkillFiles = [introSkill, ...scopeSkills.map(s => ({
329
- name: s.name,
330
- filename: s.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
331
- content: s.content,
332
- description: s.source === 'app'
333
- ? (buildAppSkillDescription(s.name, registries) || `${s.name} - Runwork workspace application`)
334
- : `${s.name} - Runwork workspace skill`,
335
- }))];
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];
336
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`);
337
389
  }
338
390
  // Write MCP configs
339
391
  if (adapter.supportsMcpScope(scope) && mcpEntries.length > 0) {
340
392
  await adapter.writeMcpServers(mcpEntries, scope);
393
+ summary.mcpServerWrites++;
341
394
  console.log(` [${adapter.name}] Updated ${mcpEntries.length} MCP server${mcpEntries.length > 1 ? 's' : ''} (${scope})`);
342
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
+ }
343
402
  // Write instruction hint
344
403
  await adapter.writeInstructionHint(instructionHint, scope);
404
+ summary.instructionHintWrites++;
345
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
+ }
346
413
  }
347
414
  catch (err) {
415
+ adapterFailedAnyScope = true;
348
416
  console.warn(` [${adapter.name}] Failed (${scope}): ${err instanceof Error ? err.message : err}`);
349
417
  }
350
418
  }
419
+ summary.adaptersProcessed++;
420
+ if (adapterFailedAnyScope)
421
+ summary.adaptersFailed++;
351
422
  }
352
423
  // Pull team config from server. A failure here must not stop the
353
424
  // unified user-scope write below, which applies network and minimum-permission
@@ -569,6 +640,20 @@ export async function syncFromState(state, statePath, credentials, opts) {
569
640
  catch {
570
641
  // Telemetry failures are non-fatal
571
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(', ')}.`);
572
657
  }
573
658
  export const syncCommand = new Command('sync')
574
659
  .description('Sync skills bidirectionally and refresh MCP configs from workspace')