runwork 0.6.2 → 0.8.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 (80) hide show
  1. package/dist/__tests__/install-scripts.test.d.ts +1 -0
  2. package/dist/__tests__/install-scripts.test.js +247 -0
  3. package/dist/agents/__tests__/claude-code-plugin.test.d.ts +1 -0
  4. package/dist/agents/__tests__/claude-code-plugin.test.js +47 -0
  5. package/dist/agents/__tests__/claude-code-stats.test.js +143 -78
  6. package/dist/agents/__tests__/claude-desktop-plugin-tree.test.d.ts +1 -0
  7. package/dist/agents/__tests__/claude-desktop-plugin-tree.test.js +161 -0
  8. package/dist/agents/__tests__/claude-desktop-plugin.test.d.ts +1 -0
  9. package/dist/agents/__tests__/claude-desktop-plugin.test.js +117 -0
  10. package/dist/agents/__tests__/claude-desktop-rpm.test.d.ts +1 -0
  11. package/dist/agents/__tests__/claude-desktop-rpm.test.js +126 -0
  12. package/dist/agents/__tests__/codex-instructions.test.d.ts +1 -0
  13. package/dist/agents/__tests__/codex-instructions.test.js +57 -0
  14. package/dist/agents/__tests__/codex-stats.test.js +9 -0
  15. package/dist/agents/__tests__/graceful-degradation.test.js +3 -3
  16. package/dist/agents/__tests__/intro-skill.test.js +11 -2
  17. package/dist/agents/__tests__/skill-slug.test.d.ts +1 -0
  18. package/dist/agents/__tests__/skill-slug.test.js +77 -0
  19. package/dist/agents/claude-code.d.ts +3 -1
  20. package/dist/agents/claude-code.js +233 -58
  21. package/dist/agents/claude-desktop-plugin-tree.d.ts +44 -0
  22. package/dist/agents/claude-desktop-plugin-tree.js +92 -0
  23. package/dist/agents/claude-desktop.d.ts +20 -1
  24. package/dist/agents/claude-desktop.js +238 -61
  25. package/dist/agents/cline.d.ts +3 -1
  26. package/dist/agents/cline.js +44 -2
  27. package/dist/agents/codex.d.ts +3 -1
  28. package/dist/agents/codex.js +85 -14
  29. package/dist/agents/cursor.d.ts +12 -1
  30. package/dist/agents/cursor.js +98 -25
  31. package/dist/agents/gemini.d.ts +3 -1
  32. package/dist/agents/gemini.js +42 -4
  33. package/dist/agents/generic-adapter.d.ts +3 -1
  34. package/dist/agents/generic-adapter.js +59 -4
  35. package/dist/agents/intro-skill.js +77 -31
  36. package/dist/agents/types.d.ts +42 -2
  37. package/dist/agents/types.js +61 -6
  38. package/dist/agents/utils/instruction-hint.d.ts +10 -0
  39. package/dist/agents/utils/instruction-hint.js +46 -0
  40. package/dist/agents/utils/json-config.d.ts +6 -1
  41. package/dist/agents/utils/json-config.js +31 -4
  42. package/dist/agents/windsurf.d.ts +3 -1
  43. package/dist/agents/windsurf.js +37 -2
  44. package/dist/commands/__tests__/sync-telemetry.test.d.ts +1 -0
  45. package/dist/commands/__tests__/sync-telemetry.test.js +281 -0
  46. package/dist/commands/__tests__/upgrade.test.d.ts +1 -0
  47. package/dist/commands/__tests__/upgrade.test.js +174 -0
  48. package/dist/commands/build-plugin.d.ts +2 -0
  49. package/dist/commands/build-plugin.js +135 -0
  50. package/dist/commands/init.d.ts +8 -2
  51. package/dist/commands/init.js +19 -7
  52. package/dist/commands/sync-telemetry.d.ts +33 -0
  53. package/dist/commands/sync-telemetry.js +186 -0
  54. package/dist/commands/sync.d.ts +1 -0
  55. package/dist/commands/sync.js +84 -118
  56. package/dist/commands/uninstall.d.ts +2 -0
  57. package/dist/commands/uninstall.js +145 -0
  58. package/dist/commands/upgrade.d.ts +12 -0
  59. package/dist/commands/upgrade.js +67 -16
  60. package/dist/generated/bundled-types.js +36 -36
  61. package/dist/generated/version.d.ts +1 -1
  62. package/dist/generated/version.js +1 -1
  63. package/dist/health/__tests__/cli-distribution-checks.test.d.ts +1 -0
  64. package/dist/health/__tests__/cli-distribution-checks.test.js +199 -0
  65. package/dist/health/checks.d.ts +2 -0
  66. package/dist/health/checks.js +131 -18
  67. package/dist/health/runner.js +6 -2
  68. package/dist/index.js +4 -0
  69. package/dist/sync/executor.d.ts +2 -0
  70. package/dist/sync/executor.js +8 -4
  71. package/dist/types.d.ts +2 -0
  72. package/dist/ui/banner.js +15 -6
  73. package/dist/utils/__tests__/sqlite.test.js +84 -72
  74. package/dist/utils/sqlite-adapter.d.ts +48 -0
  75. package/dist/utils/sqlite-adapter.js +147 -0
  76. package/dist/utils/sqlite.d.ts +12 -5
  77. package/dist/utils/sqlite.js +12 -22
  78. package/dist/utils/zip.d.ts +6 -0
  79. package/dist/utils/zip.js +28 -3
  80. package/package.json +2 -4
@@ -1,10 +1,21 @@
1
- import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'fs';
2
- import { join } from 'path';
3
- import { homedir, platform } from 'os';
4
- import { buildSkillMd } from './types.js';
5
- import { mergeJsonMcpServers, readJsonConfig, writeJsonConfig } from './utils/json-config.js';
1
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { homedir, platform, tmpdir } from 'os';
4
+ import { mergeJsonMcpServers, readJsonConfig, writeJsonConfig, removeRunworkMcpServers } from './utils/json-config.js';
5
+ import { createZipFromDir } from '../utils/zip.js';
6
+ import { writePluginMcpConfig, writePluginMetadata, writePluginSkills, writePluginTeamInstructions, writePluginTree, } from './claude-desktop-plugin-tree.js';
6
7
  const PLUGIN_NAME = 'runwork';
7
8
  const PLUGIN_VERSION = '1.0.0';
9
+ const PLUGIN_DESCRIPTION = 'Skills and tools from your Runwork workspace';
10
+ const PLUGIN_AUTHOR_NAME = 'Runwork';
11
+ function getPluginMetadata() {
12
+ return {
13
+ pluginName: PLUGIN_NAME,
14
+ pluginVersion: PLUGIN_VERSION,
15
+ description: PLUGIN_DESCRIPTION,
16
+ authorName: PLUGIN_AUTHOR_NAME,
17
+ };
18
+ }
8
19
  function getMcpConfigPath() {
9
20
  const os = platform();
10
21
  if (os === 'darwin') {
@@ -26,50 +37,101 @@ function getCoworkBaseDir() {
26
37
  return join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions');
27
38
  }
28
39
  /**
29
- * Find the cowork_plugins directory by discovering the session/org ID structure.
30
- * Path: local-agent-mode-sessions/<session-id>/<org-id>/cowork_plugins/
40
+ * Walk all `local-agent-mode-sessions/<session>/<org>/` directories and
41
+ * invoke `visit` for each. Stops and returns the first non-null result.
31
42
  */
32
- function findCoworkPluginsDir() {
43
+ function walkOrgDirs(visit) {
33
44
  const baseDir = getCoworkBaseDir();
34
45
  if (!existsSync(baseDir))
35
46
  return null;
36
47
  try {
37
- const entries = readdirSync(baseDir).filter(d => !d.startsWith('.') && d !== 'skills-plugin');
38
- for (const sessionId of entries) {
48
+ const sessionDirs = readdirSync(baseDir).filter(d => !d.startsWith('.') && d !== 'skills-plugin');
49
+ for (const sessionId of sessionDirs) {
39
50
  const sessionPath = join(baseDir, sessionId);
40
- const orgDirs = readdirSync(sessionPath).filter(d => !d.startsWith('.'));
51
+ let orgDirs;
52
+ try {
53
+ orgDirs = readdirSync(sessionPath).filter(d => !d.startsWith('.'));
54
+ }
55
+ catch {
56
+ continue;
57
+ }
41
58
  for (const orgId of orgDirs) {
42
- const pluginsDir = join(sessionPath, orgId, 'cowork_plugins');
43
- if (existsSync(join(pluginsDir, 'installed_plugins.json'))) {
44
- return pluginsDir;
45
- }
59
+ const orgDir = join(sessionPath, orgId);
60
+ const result = visit(orgDir);
61
+ if (result !== null)
62
+ return result;
46
63
  }
47
64
  }
48
65
  }
49
- catch { /* ignore */ }
66
+ catch {
67
+ /* ignore */
68
+ }
50
69
  return null;
51
70
  }
52
- function getPluginJson() {
53
- return {
54
- name: PLUGIN_NAME,
55
- version: PLUGIN_VERSION,
56
- description: 'Skills and tools from your Runwork workspace',
57
- author: { name: 'Runwork' },
58
- };
71
+ /**
72
+ * Find the cowork_plugins directory by discovering the session/org ID structure.
73
+ * Path: local-agent-mode-sessions/<session-id>/<org-id>/cowork_plugins/
74
+ */
75
+ function findCoworkPluginsDir() {
76
+ return walkOrgDirs((orgDir) => {
77
+ const pluginsDir = join(orgDir, 'cowork_plugins');
78
+ return existsSync(join(pluginsDir, 'installed_plugins.json')) ? pluginsDir : null;
79
+ });
80
+ }
81
+ /**
82
+ * Look for a plugin named `pluginName` in any `rpm/manifest.json`. These
83
+ * entries are created by Claude Desktop when the user uploads a plugin zip
84
+ * through Customize > Create plugin > Upload plugin. If found, sync should
85
+ * update the plugin content files inside the rpm plugin directory instead
86
+ * of writing to the legacy `cowork_plugins/` layout.
87
+ *
88
+ * We never modify `rpm/manifest.json` itself — Claude Desktop owns that
89
+ * registry and assigns the plugin id we read from it here.
90
+ */
91
+ export function findRpmPluginByName(pluginName) {
92
+ return walkOrgDirs((orgDir) => {
93
+ const manifestPath = join(orgDir, 'rpm', 'manifest.json');
94
+ if (!existsSync(manifestPath))
95
+ return null;
96
+ try {
97
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
98
+ const entry = manifest.plugins?.find((p) => p.name === pluginName);
99
+ if (!entry?.id)
100
+ return null;
101
+ return {
102
+ pluginPath: join(orgDir, 'rpm', entry.id),
103
+ orgDir,
104
+ };
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ });
59
110
  }
60
111
  function getMarketplaceJson() {
61
112
  return {
62
113
  name: 'runwork',
63
- owner: { name: 'Runwork' },
114
+ owner: { name: PLUGIN_AUTHOR_NAME },
64
115
  plugins: [
65
116
  {
66
117
  name: PLUGIN_NAME,
67
118
  source: `./plugins/${PLUGIN_NAME}`,
68
- description: 'Skills and tools from your Runwork workspace',
119
+ description: PLUGIN_DESCRIPTION,
69
120
  },
70
121
  ],
71
122
  };
72
123
  }
124
+ function getCoworkSettingsPath(pluginsDir) {
125
+ return join(dirname(pluginsDir), 'cowork_settings.json');
126
+ }
127
+ function setCoworkPluginEnabled(pluginsDir, enabled) {
128
+ const settingsPath = getCoworkSettingsPath(pluginsDir);
129
+ const settings = readJsonConfig(settingsPath);
130
+ const enabledPlugins = (settings.enabledPlugins ?? {});
131
+ enabledPlugins[`${PLUGIN_NAME}@runwork`] = enabled;
132
+ settings.enabledPlugins = enabledPlugins;
133
+ writeJsonConfig(settingsPath, settings);
134
+ }
73
135
  export class ClaudeDesktopAdapter {
74
136
  name = 'Claude Desktop (Cowork)';
75
137
  slug = 'claude-desktop';
@@ -86,8 +148,14 @@ export class ClaudeDesktopAdapter {
86
148
  return true;
87
149
  }
88
150
  async writeMcpServers(servers, _scope) {
89
- // Claude Desktop Cowork mode only supports stdio transport.
90
- // Use mcp-remote as a bridge for HTTP/SSE servers.
151
+ // Claude Desktop has two MCP transport paths:
152
+ // 1. claude_desktop_config.json with mcp-remote (stdio bridge): for chat mode
153
+ // 2. .mcp.json inside the plugin directory (native HTTP): for cowork mode connectors
154
+ //
155
+ // For cowork connectors, embed the auth token in the URL query string.
156
+ // This lets Claude Desktop connect without requiring the user to go through
157
+ // an OAuth flow or manually click "Connect" in the Connectors UI.
158
+ // Write to claude_desktop_config.json via mcp-remote (chat mode fallback)
91
159
  const entries = {};
92
160
  for (const s of servers) {
93
161
  const args = ['mcp-remote', s.url];
@@ -100,49 +168,47 @@ export class ClaudeDesktopAdapter {
100
168
  };
101
169
  }
102
170
  mergeJsonMcpServers(getMcpConfigPath(), entries, 'mcpServers');
103
- // Also write .mcp.json inside the Cowork plugin directory
171
+ // Prefer rpm/plugin_<id>/ when the user has uploaded our zip through
172
+ // Claude Desktop's Customize > Create plugin > Upload plugin flow.
173
+ const rpm = findRpmPluginByName(PLUGIN_NAME);
174
+ if (rpm) {
175
+ writePluginMcpConfig(rpm.pluginPath, servers);
176
+ return;
177
+ }
178
+ // Fall back to the legacy cowork_plugins layout (cache + marketplace).
104
179
  const pluginsDir = findCoworkPluginsDir();
105
180
  if (pluginsDir) {
106
181
  const cacheDir = join(pluginsDir, 'cache', 'runwork', PLUGIN_NAME, PLUGIN_VERSION);
107
182
  const marketDir = join(pluginsDir, 'marketplaces', 'runwork', 'plugins', PLUGIN_NAME);
108
- const pluginEntries = {};
109
- for (const s of servers) {
110
- pluginEntries[s.name] = {
111
- type: 'http',
112
- url: s.url,
113
- ...(s.headers ? { headers: s.headers } : {}),
114
- ...(s.description ? { description: s.description } : {}),
115
- };
116
- }
117
183
  for (const dir of [cacheDir, marketDir]) {
118
- mkdirSync(dir, { recursive: true });
119
- writeJsonConfig(join(dir, '.mcp.json'), pluginEntries);
184
+ writePluginMcpConfig(dir, servers);
120
185
  }
121
186
  }
122
187
  }
123
188
  async writeSkills(skills, _scope) {
189
+ // Prefer rpm/plugin_<id>/ when the user has uploaded our zip through
190
+ // Claude Desktop's Customize > Create plugin > Upload plugin flow.
191
+ // Claude Desktop owns rpm/manifest.json (registration, enablement),
192
+ // so we only refresh the plugin content files.
193
+ const rpm = findRpmPluginByName(PLUGIN_NAME);
194
+ if (rpm) {
195
+ writePluginMetadata(rpm.pluginPath, getPluginMetadata());
196
+ writePluginSkills(rpm.pluginPath, skills);
197
+ return;
198
+ }
124
199
  const pluginsDir = findCoworkPluginsDir();
125
200
  if (!pluginsDir) {
126
201
  console.warn(' [Claude Desktop] Cowork plugins directory not found. Open Claude Desktop at least once first.');
127
202
  return;
128
203
  }
129
- // Write to both cache (installed copy) and marketplaces (source)
204
+ // Fall back to legacy cowork_plugins layout: write to both cache
205
+ // (installed copy) and marketplaces (source).
130
206
  const cacheDir = join(pluginsDir, 'cache', 'runwork', PLUGIN_NAME, PLUGIN_VERSION);
131
207
  const marketRoot = join(pluginsDir, 'marketplaces', 'runwork');
132
208
  const marketDir = join(marketRoot, 'plugins', PLUGIN_NAME);
133
- // Create plugin.json in both locations
134
209
  for (const dir of [cacheDir, marketDir]) {
135
- const pluginJsonDir = join(dir, '.claude-plugin');
136
- mkdirSync(pluginJsonDir, { recursive: true });
137
- writeFileSync(join(pluginJsonDir, 'plugin.json'), JSON.stringify(getPluginJson(), null, 2));
138
- }
139
- // Write skill files to both locations
140
- for (const skill of skills) {
141
- for (const dir of [cacheDir, marketDir]) {
142
- const skillDir = join(dir, 'skills', skill.name);
143
- mkdirSync(skillDir, { recursive: true });
144
- writeFileSync(join(skillDir, 'SKILL.md'), buildSkillMd(skill));
145
- }
210
+ writePluginMetadata(dir, getPluginMetadata());
211
+ writePluginSkills(dir, skills);
146
212
  }
147
213
  // Register in installed_plugins.json
148
214
  const installedPath = join(pluginsDir, 'installed_plugins.json');
@@ -172,25 +238,55 @@ export class ClaudeDesktopAdapter {
172
238
  lastUpdated: new Date().toISOString(),
173
239
  };
174
240
  writeJsonConfig(marketplacesPath, marketplaces);
241
+ // Sync should recover a previously disabled plugin so it shows up in Customize.
242
+ setCoworkPluginEnabled(pluginsDir, true);
175
243
  }
176
244
  async writeInstructionHint(_hint, _scope) {
177
245
  // Claude Desktop/Cowork uses project instructions within the app UI, not files.
178
246
  // The plugin system handles skill discovery.
179
247
  }
248
+ async buildPluginZip(skills, mcpServers, teamInstructions, outputPath) {
249
+ // Materialise the plugin tree into a temp dir, zip it, then clean up.
250
+ // Uses the same writePluginTree + writePluginTeamInstructions helpers
251
+ // the sync path uses so the on-disk layout is byte-identical whether
252
+ // the user installs via upload or sync.
253
+ const stagingRoot = mkdtempSync(join(tmpdir(), 'runwork-plugin-'));
254
+ const pluginStagingDir = join(stagingRoot, PLUGIN_NAME);
255
+ try {
256
+ writePluginTree(pluginStagingDir, {
257
+ ...getPluginMetadata(),
258
+ skills,
259
+ mcpServers,
260
+ });
261
+ if (teamInstructions) {
262
+ writePluginTeamInstructions(pluginStagingDir, teamInstructions);
263
+ }
264
+ createZipFromDir(pluginStagingDir, outputPath);
265
+ }
266
+ finally {
267
+ try {
268
+ rmSync(stagingRoot, { recursive: true, force: true });
269
+ }
270
+ catch {
271
+ /* best-effort */
272
+ }
273
+ }
274
+ }
180
275
  async writeTeamInstructions(instructions, _scope) {
181
- // Write team instructions as a skill in the CoWork plugin system.
276
+ // Write team instructions as a skill in the Cowork plugin system.
182
277
  // This makes them available to Claude Desktop sessions via the plugin.
278
+ // Prefer an rpm upload location when present, fall back to the legacy
279
+ // cowork_plugins cache + marketplace layout.
280
+ const rpm = findRpmPluginByName(PLUGIN_NAME);
281
+ if (rpm) {
282
+ writePluginTeamInstructions(rpm.pluginPath, instructions);
283
+ return;
284
+ }
183
285
  const pluginsDir = findCoworkPluginsDir();
184
286
  if (!pluginsDir)
185
287
  return;
186
- const skillContent = `---\nname: runwork-team-instructions\ndescription: Team instructions from your Runwork workspace. Always follow these guidelines.\n---\n\n${instructions}`;
187
- // Write to both cache and marketplace locations
188
- const cacheDir = join(pluginsDir, 'cache', 'runwork', PLUGIN_NAME, PLUGIN_VERSION, 'skills', 'runwork-team-instructions');
189
- const marketDir = join(pluginsDir, 'marketplaces', 'runwork', 'plugins', PLUGIN_NAME, 'skills', 'runwork-team-instructions');
190
- for (const dir of [cacheDir, marketDir]) {
191
- mkdirSync(dir, { recursive: true });
192
- writeFileSync(join(dir, 'SKILL.md'), skillContent);
193
- }
288
+ writePluginTeamInstructions(join(pluginsDir, 'cache', 'runwork', PLUGIN_NAME, PLUGIN_VERSION), instructions);
289
+ writePluginTeamInstructions(join(pluginsDir, 'marketplaces', 'runwork', 'plugins', PLUGIN_NAME), instructions);
194
290
  }
195
291
  // ── Agent config (limited to preferences) ──────────────────────────
196
292
  async writeAgentConfig(config, _scope) {
@@ -224,6 +320,87 @@ export class ClaudeDesktopAdapter {
224
320
  }
225
321
  writeFileSync(configPath, JSON.stringify(desktopConfig, null, 2));
226
322
  }
323
+ // ── Cleanup (uninstall) ────────────────────────────────────────────
324
+ async cleanup(_scope, _manifest) {
325
+ // 1. Remove MCP entries from claude_desktop_config.json
326
+ removeRunworkMcpServers(getMcpConfigPath(), 'mcpServers');
327
+ // 2. Remove the uploaded rpm plugin if the user installed via
328
+ // Customize > Create plugin > Upload plugin. We delete both the
329
+ // plugin directory and the manifest entry so Claude Desktop does
330
+ // not show a stale Runwork plugin after uninstall.
331
+ const rpm = findRpmPluginByName(PLUGIN_NAME);
332
+ if (rpm) {
333
+ if (existsSync(rpm.pluginPath)) {
334
+ try {
335
+ rmSync(rpm.pluginPath, { recursive: true, force: true });
336
+ }
337
+ catch { /* best-effort */ }
338
+ }
339
+ const manifestPath = join(rpm.orgDir, 'rpm', 'manifest.json');
340
+ if (existsSync(manifestPath)) {
341
+ try {
342
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
343
+ if (Array.isArray(manifest.plugins)) {
344
+ manifest.plugins = manifest.plugins.filter((p) => p.name !== PLUGIN_NAME);
345
+ manifest.lastUpdated = Date.now();
346
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
347
+ }
348
+ }
349
+ catch { /* best-effort */ }
350
+ }
351
+ }
352
+ // 3. Remove Cowork plugin directories and registration
353
+ const pluginsDir = findCoworkPluginsDir();
354
+ if (pluginsDir) {
355
+ // Remove cache/runwork and marketplaces/runwork directories
356
+ const cacheRunwork = join(pluginsDir, 'cache', 'runwork');
357
+ const marketRunwork = join(pluginsDir, 'marketplaces', 'runwork');
358
+ for (const dir of [cacheRunwork, marketRunwork]) {
359
+ if (existsSync(dir)) {
360
+ try {
361
+ rmSync(dir, { recursive: true, force: true });
362
+ }
363
+ catch { /* best-effort */ }
364
+ }
365
+ }
366
+ // Remove from installed_plugins.json
367
+ const installedPath = join(pluginsDir, 'installed_plugins.json');
368
+ if (existsSync(installedPath)) {
369
+ try {
370
+ const installed = readJsonConfig(installedPath);
371
+ if (installed.plugins) {
372
+ delete installed.plugins[`${PLUGIN_NAME}@runwork`];
373
+ writeJsonConfig(installedPath, installed);
374
+ }
375
+ }
376
+ catch { /* best-effort */ }
377
+ }
378
+ // Remove from known_marketplaces.json
379
+ const marketplacesPath = join(pluginsDir, 'known_marketplaces.json');
380
+ if (existsSync(marketplacesPath)) {
381
+ try {
382
+ const marketplaces = readJsonConfig(marketplacesPath);
383
+ delete marketplaces['runwork'];
384
+ writeJsonConfig(marketplacesPath, marketplaces);
385
+ }
386
+ catch { /* best-effort */ }
387
+ }
388
+ // Remove the enable flag so cleanup fully unregisters the plugin.
389
+ const coworkSettingsPath = getCoworkSettingsPath(pluginsDir);
390
+ if (existsSync(coworkSettingsPath)) {
391
+ try {
392
+ const settings = readJsonConfig(coworkSettingsPath);
393
+ const enabledPlugins = settings.enabledPlugins;
394
+ if (enabledPlugins && typeof enabledPlugins === 'object' && !Array.isArray(enabledPlugins)) {
395
+ delete enabledPlugins[`${PLUGIN_NAME}@runwork`];
396
+ settings.enabledPlugins = enabledPlugins;
397
+ writeJsonConfig(coworkSettingsPath, settings);
398
+ }
399
+ }
400
+ catch { /* best-effort */ }
401
+ }
402
+ }
403
+ }
227
404
  // ── Usage stats (passive, best-effort) ─────────────────────────────
228
405
  async readUsageStats(lastSyncAt) {
229
406
  try {
@@ -1,7 +1,8 @@
1
- import type { AgentAdapter, AgentConfigOverride, McpServerEntry, SkillFile } from './types.js';
1
+ import type { AgentAdapter, AgentConfigOverride, CleanupManifest, McpServerEntry, SkillFile } from './types.js';
2
2
  export declare class ClineAdapter implements AgentAdapter {
3
3
  name: string;
4
4
  slug: string;
5
+ mcpProvidesSkills: boolean;
5
6
  detect(): Promise<boolean>;
6
7
  supportsMcpScope(scope: 'project' | 'user'): boolean;
7
8
  supportsSkills(): boolean;
@@ -10,4 +11,5 @@ export declare class ClineAdapter implements AgentAdapter {
10
11
  writeInstructionHint(hint: string, scope: 'project' | 'user'): Promise<void>;
11
12
  writeTeamInstructions(instructions: string, scope: 'project' | 'user'): Promise<void>;
12
13
  writeAgentConfig(config: AgentConfigOverride, scope: 'project' | 'user'): Promise<void>;
14
+ cleanup(scope: 'project' | 'user', manifest?: CleanupManifest): Promise<void>;
13
15
  }
@@ -1,12 +1,13 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { whichBinary } from '../utils/which.js';
5
5
  import { buildSkillMd } from './types.js';
6
- import { mergeJsonMcpServers } from './utils/json-config.js';
6
+ import { mergeJsonMcpServers, removeRunworkMcpServers } from './utils/json-config.js';
7
7
  export class ClineAdapter {
8
8
  name = 'Cline';
9
9
  slug = 'cline';
10
+ mcpProvidesSkills = true;
10
11
  async detect() {
11
12
  return !!whichBinary('cline');
12
13
  }
@@ -85,4 +86,45 @@ export class ClineAdapter {
85
86
  mkdirSync(join(globalStatePath, '..'), { recursive: true });
86
87
  writeFileSync(globalStatePath, JSON.stringify(state, null, 2));
87
88
  }
89
+ // ── Cleanup (uninstall) ────────────────────────────────────────────
90
+ async cleanup(scope, manifest) {
91
+ // 1. Remove MCP entries (user scope only)
92
+ if (scope === 'user') {
93
+ const configPath = join(homedir(), '.cline', 'data', 'settings', 'cline_mcp_settings.json');
94
+ removeRunworkMcpServers(configPath, 'mcpServers');
95
+ }
96
+ // 2. Remove rule files (project scope only)
97
+ if (scope === 'project') {
98
+ const rulesDir = join(process.cwd(), '.clinerules');
99
+ if (existsSync(rulesDir)) {
100
+ for (const file of ['runwork.md', 'runwork-team.md']) {
101
+ const filePath = join(rulesDir, file);
102
+ if (existsSync(filePath)) {
103
+ try {
104
+ unlinkSync(filePath);
105
+ }
106
+ catch { /* best-effort */ }
107
+ }
108
+ }
109
+ // Remove only skill .md files that we synced
110
+ if (manifest?.skillFilenames.length) {
111
+ const allowed = new Set(manifest.skillFilenames.map(f => `${f}.md`));
112
+ for (const entry of readdirSync(rulesDir)) {
113
+ if (allowed.has(entry)) {
114
+ try {
115
+ unlinkSync(join(rulesDir, entry));
116
+ }
117
+ catch { /* best-effort */ }
118
+ }
119
+ }
120
+ }
121
+ // Remove dir if empty
122
+ try {
123
+ if (readdirSync(rulesDir).length === 0)
124
+ rmSync(rulesDir, { recursive: true });
125
+ }
126
+ catch { /* best-effort */ }
127
+ }
128
+ }
129
+ }
88
130
  }
@@ -1,7 +1,8 @@
1
- import type { AgentAdapter, AgentConfigOverride, AgentUsageStats, McpServerEntry, SkillFile } from './types.js';
1
+ import type { AgentAdapter, AgentConfigOverride, AgentUsageStats, CleanupManifest, McpServerEntry, SkillFile } from './types.js';
2
2
  export declare class CodexAdapter implements AgentAdapter {
3
3
  name: string;
4
4
  slug: string;
5
+ mcpProvidesSkills: boolean;
5
6
  detect(): Promise<boolean>;
6
7
  supportsMcpScope(_scope: 'project' | 'user'): boolean;
7
8
  supportsSkills(): boolean;
@@ -10,6 +11,7 @@ export declare class CodexAdapter implements AgentAdapter {
10
11
  writeInstructionHint(hint: string, scope: 'project' | 'user'): Promise<void>;
11
12
  writeTeamInstructions(instructions: string, scope: 'project' | 'user'): Promise<void>;
12
13
  writeAgentConfig(config: AgentConfigOverride, scope: 'project' | 'user'): Promise<void>;
14
+ cleanup(scope: 'project' | 'user', manifest?: CleanupManifest): Promise<void>;
13
15
  readUsageStats(lastSyncAt: string | null): Promise<AgentUsageStats | null>;
14
16
  readVersion(): Promise<string | null>;
15
17
  }
@@ -1,14 +1,20 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { parse, stringify } from 'smol-toml';
5
- import { buildSkillMd } from './types.js';
6
- import { writeHintToFile, writeTeamInstructionsToFile } from './utils/instruction-hint.js';
5
+ import { buildSkillMd, appendTokenToUrl, RUNWORK_WORKSPACE_MCP_NAME } from './types.js';
6
+ function isRunworkManagedCodexKey(key) {
7
+ return (key === RUNWORK_WORKSPACE_MCP_NAME ||
8
+ key.startsWith('runwork-') ||
9
+ key.startsWith('Runwork-'));
10
+ }
11
+ import { writeHintToFile, writeTeamInstructionsToFile, removeHintFromFile, removeTeamInstructionsFromFile } from './utils/instruction-hint.js';
7
12
  import { querySqlite } from '../utils/sqlite.js';
8
13
  import { whichBinary } from '../utils/which.js';
9
14
  export class CodexAdapter {
10
15
  name = 'Codex';
11
16
  slug = 'codex';
17
+ mcpProvidesSkills = true;
12
18
  async detect() {
13
19
  return !!whichBinary('codex');
14
20
  }
@@ -29,16 +35,22 @@ export class CodexAdapter {
29
35
  parsed.mcp_servers = {};
30
36
  }
31
37
  const mcpServers = parsed.mcp_servers;
32
- // Remove existing runwork-* entries
38
+ // Remove existing runwork-managed entries. Handles legacy lowercase
39
+ // ("runwork-foo"), sanitized PascalCase ("Runwork-foo"), and the bare
40
+ // workspace-tools name ("Runwork").
33
41
  for (const key of Object.keys(mcpServers)) {
34
- if (key.startsWith('runwork-'))
42
+ if (isRunworkManagedCodexKey(key)) {
35
43
  delete mcpServers[key];
44
+ }
36
45
  }
37
- // Add new entries
46
+ // Add new entries (sanitize names to match Codex's ^[a-zA-Z0-9_-]+$ requirement)
38
47
  for (const s of servers) {
39
- mcpServers[s.name] = {
40
- url: s.url,
41
- ...(s.headers?.Authorization ? { bearer_token_env_var: 'RUNWORK_MCP_TOKEN' } : {}),
48
+ const safeName = s.name.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
49
+ // Embed auth token in URL so Codex doesn't need a separate env var
50
+ const token = s.headers?.Authorization?.replace(/^Bearer\s+/i, '');
51
+ const serverUrl = token ? appendTokenToUrl(s.url, token) : s.url;
52
+ mcpServers[safeName] = {
53
+ url: serverUrl,
42
54
  };
43
55
  }
44
56
  mkdirSync(join(configPath, '..'), { recursive: true });
@@ -49,7 +61,16 @@ export class CodexAdapter {
49
61
  ? join(process.cwd(), '.codex', 'skills')
50
62
  : join(homedir(), '.codex', 'skills');
51
63
  for (const skill of skills) {
52
- const skillDir = join(baseDir, skill.name);
64
+ if (skill.name !== skill.filename) {
65
+ const oldDir = join(baseDir, skill.name);
66
+ if (existsSync(oldDir)) {
67
+ try {
68
+ rmSync(oldDir, { recursive: true, force: true });
69
+ }
70
+ catch { /* best-effort */ }
71
+ }
72
+ }
73
+ const skillDir = join(baseDir, skill.filename);
53
74
  mkdirSync(skillDir, { recursive: true });
54
75
  writeFileSync(join(skillDir, 'SKILL.md'), buildSkillMd(skill));
55
76
  }
@@ -57,13 +78,13 @@ export class CodexAdapter {
57
78
  async writeInstructionHint(hint, scope) {
58
79
  const filePath = scope === 'project'
59
80
  ? join(process.cwd(), 'AGENTS.md')
60
- : join(homedir(), '.codex', 'instructions.md');
81
+ : join(homedir(), '.codex', 'AGENTS.md');
61
82
  writeHintToFile(filePath, hint);
62
83
  }
63
84
  async writeTeamInstructions(instructions, scope) {
64
85
  const filePath = scope === 'project'
65
86
  ? join(process.cwd(), 'AGENTS.md')
66
- : join(homedir(), '.codex', 'instructions.md');
87
+ : join(homedir(), '.codex', 'AGENTS.md');
67
88
  writeTeamInstructionsToFile(filePath, instructions);
68
89
  }
69
90
  // ── Agent config (TOML) ───────────────────────────────────────────
@@ -89,6 +110,51 @@ export class CodexAdapter {
89
110
  mkdirSync(join(configPath, '..'), { recursive: true });
90
111
  writeFileSync(configPath, stringify(parsed));
91
112
  }
113
+ // ── Cleanup (uninstall) ────────────────────────────────────────────
114
+ async cleanup(scope, manifest) {
115
+ // 1. Remove MCP server entries from config.toml
116
+ const configPath = join(homedir(), '.codex', 'config.toml');
117
+ if (existsSync(configPath)) {
118
+ try {
119
+ const parsed = parse(readFileSync(configPath, 'utf-8'));
120
+ if (parsed.mcp_servers && typeof parsed.mcp_servers === 'object') {
121
+ const mcpServers = parsed.mcp_servers;
122
+ for (const key of Object.keys(mcpServers)) {
123
+ // Codex TOML sanitizes names via writeMcpServers, so the JSON-style
124
+ // "Runwork: " prefix is never actually written here. Match the real
125
+ // on-disk forms: "runwork-*" (legacy), "Runwork-*" (previous), and
126
+ // the bare "Runwork" name used for workspace tools.
127
+ if (isRunworkManagedCodexKey(key)) {
128
+ delete mcpServers[key];
129
+ }
130
+ }
131
+ }
132
+ writeFileSync(configPath, stringify(parsed));
133
+ }
134
+ catch { /* best-effort */ }
135
+ }
136
+ // 2. Remove only skill directories that we synced (listed in manifest)
137
+ const skillsDir = scope === 'project'
138
+ ? join(process.cwd(), '.codex', 'skills')
139
+ : join(homedir(), '.codex', 'skills');
140
+ if (existsSync(skillsDir) && manifest?.skillFilenames.length) {
141
+ const allowed = new Set(manifest.skillFilenames);
142
+ for (const entry of readdirSync(skillsDir)) {
143
+ if (!allowed.has(entry))
144
+ continue;
145
+ try {
146
+ rmSync(join(skillsDir, entry), { recursive: true, force: true });
147
+ }
148
+ catch { /* best-effort */ }
149
+ }
150
+ }
151
+ // 3. Remove instruction hints and team instructions
152
+ const instructionFile = scope === 'project'
153
+ ? join(process.cwd(), 'AGENTS.md')
154
+ : join(homedir(), '.codex', 'AGENTS.md');
155
+ removeHintFromFile(instructionFile);
156
+ removeTeamInstructionsFromFile(instructionFile);
157
+ }
92
158
  // ── Usage stats (passive, best-effort) ─────────────────────────────
93
159
  async readUsageStats(lastSyncAt) {
94
160
  try {
@@ -100,9 +166,14 @@ export class CodexAdapter {
100
166
  const dbPath = join(codexDir, 'state_5.sqlite');
101
167
  if (!existsSync(dbPath))
102
168
  return null;
103
- const countResult = querySqlite(dbPath, `SELECT count(*) FROM threads WHERE created_at > ${sinceSec}`);
169
+ // Filter on updated_at, not created_at: long-lived threads can accumulate
170
+ // activity post-sync without ever being "newly created". tokens_used is a
171
+ // lifetime per-thread total — summing active threads here overcounts when
172
+ // a thread was already partially used before lastSyncAt, but it's a far
173
+ // better signal than "0 because no new threads were spawned".
174
+ const countResult = querySqlite(dbPath, `SELECT count(*) FROM threads WHERE updated_at > ${sinceSec}`);
104
175
  const sessionCount = parseInt(countResult) || 0;
105
- const tokensResult = querySqlite(dbPath, `SELECT coalesce(sum(tokens_used), 0) FROM threads WHERE created_at > ${sinceSec}`);
176
+ const tokensResult = querySqlite(dbPath, `SELECT coalesce(sum(tokens_used), 0) FROM threads WHERE updated_at > ${sinceSec}`);
106
177
  const tokensUsed = parseInt(tokensResult) || 0;
107
178
  const latestResult = querySqlite(dbPath, `SELECT max(updated_at) FROM threads`);
108
179
  const latestSec = parseInt(latestResult) || 0;