runwork 0.6.2 → 0.8.3

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
@@ -29,16 +29,41 @@ export interface AgentAdapter {
29
29
  supportsSkills(): boolean;
30
30
  /** Whether this agent supports MCP config at the given scope */
31
31
  supportsMcpScope(scope: 'project' | 'user'): boolean;
32
+ /** True when this agent's skill files overlap with MCP skill_* tools (skip writing app skills when MCP is configured) */
33
+ mcpProvidesSkills?: boolean;
32
34
  /** Write team-managed instructions (separate marker block from auto-generated hint) */
33
35
  writeTeamInstructions?(instructions: string, scope: 'project' | 'user'): Promise<void>;
34
36
  /** Write agent-specific config overrides from workspace admin (model, permissions) */
35
37
  writeAgentConfig?(config: AgentConfigOverride, scope: 'project' | 'user'): Promise<void>;
38
+ /** Remove all Runwork-managed config, skills, MCP entries, and instruction hints.
39
+ * The manifest lists exactly which resources were synced so only those are removed. */
40
+ cleanup?(scope: 'project' | 'user', manifest?: CleanupManifest): Promise<void>;
36
41
  /** Read passive usage stats from local agent files (best-effort, no hooks) */
37
42
  readUsageStats?(lastSyncAt: string | null): Promise<AgentUsageStats | null>;
38
43
  /** Read which skills were used locally since last sync (best-effort) */
39
44
  readSkillUsage?(lastSyncAt: string | null): Promise<SkillUsageEntry[] | null>;
40
45
  /** Get the agent's version string (best-effort) */
41
46
  readVersion?(): Promise<string | null>;
47
+ /**
48
+ * Build an installable plugin archive for this agent. Implementations
49
+ * generate their agent-specific plugin layout into a temporary directory
50
+ * and zip it to `outputPath`. `teamInstructions` is the merged string of
51
+ * workspace team instructions + per-agent extra instructions (or null if
52
+ * none are configured) — adapters should embed them in the archive so
53
+ * uploaded zips match what a normal sync would write. Only adapters whose
54
+ * target agent has a plugin upload flow implement this (e.g. Claude Desktop).
55
+ */
56
+ buildPluginZip?(skills: SkillFile[], mcpServers: McpServerEntry[], teamInstructions: string | null, outputPath: string): Promise<void>;
57
+ }
58
+ /**
59
+ * Exact list of resources that Runwork synced to this agent.
60
+ * Cleanup must only remove these specific items, nothing else.
61
+ */
62
+ export interface CleanupManifest {
63
+ /** Skill directory/file names that were written (kebab-case slugs) */
64
+ skillFilenames: string[];
65
+ /** MCP server entry names that were registered */
66
+ mcpServerNames: string[];
42
67
  }
43
68
  export interface AgentConfigOverride {
44
69
  extraInstructions?: string;
@@ -48,6 +73,8 @@ export interface AgentConfigOverride {
48
73
  deny?: string[];
49
74
  defaultMode?: string;
50
75
  };
76
+ /** Domains to allowlist in the agent's sandbox/network config (e.g. for Cursor CLI sandbox) */
77
+ networkAllowlist?: string[];
51
78
  }
52
79
  export interface AgentUsageStats {
53
80
  hasNewActivity: boolean;
@@ -72,7 +99,20 @@ export declare const RUNWORK_MCP_PREFIX = "Runwork: ";
72
99
  /** Legacy prefix used before human-readable names were introduced */
73
100
  export declare const RUNWORK_MCP_PREFIX_LEGACY = "runwork-";
74
101
  /**
75
- * Build a SKILL.md file with YAML frontmatter.
76
- * Ensures the content always has proper frontmatter for agent discovery.
102
+ * Top-level name for the Runwork workspace tools MCP server.
103
+ * Kept short (no prefix) so agents surface tools as `mcp__Runwork__*`
104
+ * instead of the visually noisy `mcp__Runwork_Workspace_Tools__*`.
105
+ */
106
+ export declare const RUNWORK_WORKSPACE_MCP_NAME = "Runwork";
107
+ /**
108
+ * Append an auth token to a URL as a query parameter.
109
+ * The server extracts this via its standard token extraction (query param fallback).
110
+ */
111
+ export declare function appendTokenToUrl(url: string, token: string): string;
112
+ /**
113
+ * Convert a skill display name to a kebab-case slug safe for slash commands.
114
+ * Spaces in skill names break slash command parsing (e.g. "/Write Cold Email"
115
+ * gets parsed as skill "Write" with args "Cold Email").
77
116
  */
117
+ export declare function toSlug(name: string): string;
78
118
  export declare function buildSkillMd(skill: SkillFile): string;
@@ -2,19 +2,74 @@
2
2
  export const RUNWORK_MCP_PREFIX = 'Runwork: ';
3
3
  /** Legacy prefix used before human-readable names were introduced */
4
4
  export const RUNWORK_MCP_PREFIX_LEGACY = 'runwork-';
5
+ /**
6
+ * Top-level name for the Runwork workspace tools MCP server.
7
+ * Kept short (no prefix) so agents surface tools as `mcp__Runwork__*`
8
+ * instead of the visually noisy `mcp__Runwork_Workspace_Tools__*`.
9
+ */
10
+ export const RUNWORK_WORKSPACE_MCP_NAME = 'Runwork';
11
+ /**
12
+ * Append an auth token to a URL as a query parameter.
13
+ * The server extracts this via its standard token extraction (query param fallback).
14
+ */
15
+ export function appendTokenToUrl(url, token) {
16
+ const parsed = new URL(url);
17
+ parsed.searchParams.set('token', token);
18
+ return parsed.toString();
19
+ }
20
+ /**
21
+ * Convert a skill display name to a kebab-case slug safe for slash commands.
22
+ * Spaces in skill names break slash command parsing (e.g. "/Write Cold Email"
23
+ * gets parsed as skill "Write" with args "Cold Email").
24
+ */
25
+ export function toSlug(name) {
26
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
27
+ }
5
28
  /**
6
29
  * Build a SKILL.md file with YAML frontmatter.
7
30
  * Ensures the content always has proper frontmatter for agent discovery.
31
+ * The name field is always kebab-cased to work as a slash command.
32
+ */
33
+ /**
34
+ * Quote a YAML value if it contains characters that would break parsing
35
+ * (colons, brackets, quotes, leading/trailing spaces, etc.)
8
36
  */
37
+ function yamlQuote(value) {
38
+ if (/[:#\[\]{}|>&*!,?'"]/.test(value) || value !== value.trim()) {
39
+ // Use double quotes, escaping internal double quotes and backslashes
40
+ const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
41
+ return `"${escaped}"`;
42
+ }
43
+ return value;
44
+ }
9
45
  export function buildSkillMd(skill) {
10
46
  const description = skill.description || `${skill.name} skill`;
11
- // If content already has frontmatter, ensure description is present
47
+ const slug = toSlug(skill.name);
48
+ const safeDescription = yamlQuote(description);
12
49
  if (skill.content.trimStart().startsWith('---\n')) {
13
- const hasDescription = /^description:\s*.+$/m.test(skill.content);
50
+ let content = skill.content;
51
+ // Normalize name to kebab-case slug (spaces break slash command parsing)
52
+ content = content.replace(/^(name:\s*)["']?[^"'\n]+["']?\s*$/m, `$1${slug}`);
53
+ // Normalize description: ensure space after colon, strip nested quotes, and quote if needed
54
+ content = content.replace(/^description:\s*(.+)$/m, (_match, rawValue) => {
55
+ // Strip wrapping quotes and escaped quotes from the raw value
56
+ let cleaned = rawValue.trim();
57
+ // Remove outer quotes if present (single or double)
58
+ if ((cleaned.startsWith('"') && cleaned.endsWith('"')) || (cleaned.startsWith("'") && cleaned.endsWith("'"))) {
59
+ cleaned = cleaned.slice(1, -1);
60
+ }
61
+ // Remove escaped quotes left from double-wrapping (e.g. \"value\")
62
+ cleaned = cleaned.replace(/\\"/g, '"');
63
+ // Strip any remaining wrapping quotes after unescaping
64
+ if ((cleaned.startsWith('"') && cleaned.endsWith('"')) || (cleaned.startsWith("'") && cleaned.endsWith("'"))) {
65
+ cleaned = cleaned.slice(1, -1);
66
+ }
67
+ return `description: ${yamlQuote(cleaned)}`;
68
+ });
69
+ const hasDescription = /^description:\s*.+$/m.test(content);
14
70
  if (hasDescription)
15
- return skill.content;
16
- // Inject description into existing frontmatter
17
- return skill.content.replace(/^---\n/, `---\nname: ${skill.name}\ndescription: ${description}\n`);
71
+ return content;
72
+ return content.replace(/^---\n/, `---\nname: ${slug}\ndescription: ${safeDescription}\n`);
18
73
  }
19
- return `---\nname: ${skill.name}\ndescription: ${description}\n---\n\n${skill.content}`;
74
+ return `---\nname: ${slug}\ndescription: ${safeDescription}\n---\n\n${skill.content}`;
20
75
  }
@@ -8,4 +8,14 @@ export declare function writeHintToFile(filePath: string, hint: string): void;
8
8
  * Append or replace team instructions in an agent's instruction file.
9
9
  * Uses separate markers from the auto-generated hint so both coexist.
10
10
  */
11
+ /**
12
+ * Remove the Runwork instruction hint from an agent's instruction file.
13
+ * Returns true if something was removed.
14
+ */
15
+ export declare function removeHintFromFile(filePath: string): boolean;
16
+ /**
17
+ * Remove Runwork team instructions from an agent's instruction file.
18
+ * Returns true if something was removed.
19
+ */
20
+ export declare function removeTeamInstructionsFromFile(filePath: string): boolean;
11
21
  export declare function writeTeamInstructionsToFile(filePath: string, instructions: string): void;
@@ -37,6 +37,52 @@ export function writeHintToFile(filePath, hint) {
37
37
  * Append or replace team instructions in an agent's instruction file.
38
38
  * Uses separate markers from the auto-generated hint so both coexist.
39
39
  */
40
+ /**
41
+ * Remove the Runwork instruction hint from an agent's instruction file.
42
+ * Returns true if something was removed.
43
+ */
44
+ export function removeHintFromFile(filePath) {
45
+ if (!existsSync(filePath))
46
+ return false;
47
+ let content = readFileSync(filePath, 'utf-8');
48
+ const startIdx = content.indexOf(START_MARKER);
49
+ const endIdx = content.indexOf(END_MARKER);
50
+ if (startIdx < 0 || endIdx < 0)
51
+ return false;
52
+ content = content.substring(0, startIdx) + content.substring(endIdx + END_MARKER.length);
53
+ // Clean up extra blank lines left behind
54
+ content = content.replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
55
+ if (content.trim() === '') {
56
+ // File is now empty, could remove it but safer to leave a minimal file
57
+ writeFileSync(filePath, '');
58
+ }
59
+ else {
60
+ writeFileSync(filePath, content);
61
+ }
62
+ return true;
63
+ }
64
+ /**
65
+ * Remove Runwork team instructions from an agent's instruction file.
66
+ * Returns true if something was removed.
67
+ */
68
+ export function removeTeamInstructionsFromFile(filePath) {
69
+ if (!existsSync(filePath))
70
+ return false;
71
+ let content = readFileSync(filePath, 'utf-8');
72
+ const startIdx = content.indexOf(TEAM_START_MARKER);
73
+ const endIdx = content.indexOf(TEAM_END_MARKER);
74
+ if (startIdx < 0 || endIdx < 0)
75
+ return false;
76
+ content = content.substring(0, startIdx) + content.substring(endIdx + TEAM_END_MARKER.length);
77
+ content = content.replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
78
+ if (content.trim() === '') {
79
+ writeFileSync(filePath, '');
80
+ }
81
+ else {
82
+ writeFileSync(filePath, content);
83
+ }
84
+ return true;
85
+ }
40
86
  export function writeTeamInstructionsToFile(filePath, instructions) {
41
87
  mkdirSync(dirname(filePath), { recursive: true });
42
88
  let content = '';
@@ -6,9 +6,14 @@ export declare function readJsonConfig(filePath: string): Record<string, unknown
6
6
  * Write a JSON config file, creating parent directories as needed.
7
7
  */
8
8
  export declare function writeJsonConfig(filePath: string, config: Record<string, unknown>): void;
9
+ /**
10
+ * Remove all Runwork-managed MCP server entries from a JSON config file.
11
+ * Returns true if any entries were removed.
12
+ */
13
+ export declare function removeRunworkMcpServers(filePath: string, topKey: string): boolean;
9
14
  /**
10
15
  * Merge MCP server entries into a JSON config file.
11
- * Removes all existing runwork-* entries, adds new ones.
16
+ * Removes all existing runwork-managed entries, adds new ones.
12
17
  * Preserves user's non-runwork entries.
13
18
  */
14
19
  export declare function mergeJsonMcpServers(filePath: string, servers: Record<string, Record<string, unknown>>, topKey: string): void;
@@ -1,6 +1,11 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
2
  import { dirname } from 'path';
3
- import { RUNWORK_MCP_PREFIX, RUNWORK_MCP_PREFIX_LEGACY } from '../types.js';
3
+ import { RUNWORK_MCP_PREFIX, RUNWORK_MCP_PREFIX_LEGACY, RUNWORK_WORKSPACE_MCP_NAME, } from '../types.js';
4
+ function isRunworkManagedKey(key) {
5
+ return (key === RUNWORK_WORKSPACE_MCP_NAME ||
6
+ key.startsWith(RUNWORK_MCP_PREFIX) ||
7
+ key.startsWith(RUNWORK_MCP_PREFIX_LEGACY));
8
+ }
4
9
  /**
5
10
  * Read a JSON config file. Returns empty object if file doesn't exist.
6
11
  */
@@ -21,17 +26,39 @@ export function writeJsonConfig(filePath, config) {
21
26
  mkdirSync(dirname(filePath), { recursive: true });
22
27
  writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
23
28
  }
29
+ /**
30
+ * Remove all Runwork-managed MCP server entries from a JSON config file.
31
+ * Returns true if any entries were removed.
32
+ */
33
+ export function removeRunworkMcpServers(filePath, topKey) {
34
+ if (!existsSync(filePath))
35
+ return false;
36
+ const config = readJsonConfig(filePath);
37
+ const existing = (config[topKey] || {});
38
+ let removed = false;
39
+ for (const key of Object.keys(existing)) {
40
+ if (isRunworkManagedKey(key)) {
41
+ delete existing[key];
42
+ removed = true;
43
+ }
44
+ }
45
+ if (removed) {
46
+ config[topKey] = existing;
47
+ writeJsonConfig(filePath, config);
48
+ }
49
+ return removed;
50
+ }
24
51
  /**
25
52
  * Merge MCP server entries into a JSON config file.
26
- * Removes all existing runwork-* entries, adds new ones.
53
+ * Removes all existing runwork-managed entries, adds new ones.
27
54
  * Preserves user's non-runwork entries.
28
55
  */
29
56
  export function mergeJsonMcpServers(filePath, servers, topKey) {
30
57
  const config = readJsonConfig(filePath);
31
58
  const existing = (config[topKey] || {});
32
- // Remove all Runwork-managed entries (current and legacy prefixes)
59
+ // Remove all Runwork-managed entries (current, legacy, and the bare "Runwork" name)
33
60
  for (const key of Object.keys(existing)) {
34
- if (key.startsWith(RUNWORK_MCP_PREFIX) || key.startsWith(RUNWORK_MCP_PREFIX_LEGACY)) {
61
+ if (isRunworkManagedKey(key)) {
35
62
  delete existing[key];
36
63
  }
37
64
  }
@@ -1,7 +1,8 @@
1
- import type { AgentAdapter, McpServerEntry, SkillFile } from './types.js';
1
+ import type { AgentAdapter, CleanupManifest, McpServerEntry, SkillFile } from './types.js';
2
2
  export declare class WindsurfAdapter 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;
@@ -9,4 +10,5 @@ export declare class WindsurfAdapter implements AgentAdapter {
9
10
  writeSkills(skills: SkillFile[], scope: 'project' | 'user'): Promise<void>;
10
11
  writeTeamInstructions(instructions: string, scope: 'project' | 'user'): Promise<void>;
11
12
  writeInstructionHint(hint: string, scope: 'project' | 'user'): Promise<void>;
13
+ cleanup(scope: 'project' | 'user', manifest?: CleanupManifest): Promise<void>;
12
14
  }
@@ -1,8 +1,8 @@
1
- import { existsSync, mkdirSync, writeFileSync } from 'fs';
1
+ import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir, platform } from 'os';
4
4
  import { whichBinary } from '../utils/which.js';
5
- import { mergeJsonMcpServers } from './utils/json-config.js';
5
+ import { mergeJsonMcpServers, removeRunworkMcpServers } from './utils/json-config.js';
6
6
  function getWindsurfDataDir() {
7
7
  if (platform() === 'win32') {
8
8
  return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'Codeium', 'windsurf');
@@ -15,6 +15,7 @@ function getConfigPath() {
15
15
  export class WindsurfAdapter {
16
16
  name = 'Windsurf';
17
17
  slug = 'windsurf';
18
+ mcpProvidesSkills = true;
18
19
  async detect() {
19
20
  const os = platform();
20
21
  if (os === 'darwin' && existsSync('/Applications/Windsurf.app'))
@@ -64,4 +65,38 @@ export class WindsurfAdapter {
64
65
  mkdirSync(join(filePath, '..'), { recursive: true });
65
66
  writeFileSync(filePath, content);
66
67
  }
68
+ // ── Cleanup (uninstall) ────────────────────────────────────────────
69
+ async cleanup(scope, manifest) {
70
+ // 1. Remove MCP entries (user scope only)
71
+ if (scope === 'user') {
72
+ removeRunworkMcpServers(getConfigPath(), 'mcpServers');
73
+ }
74
+ // 2. Remove rule files
75
+ const rulesDir = scope === 'project'
76
+ ? join(process.cwd(), '.windsurf', 'rules')
77
+ : join(getWindsurfDataDir(), 'rules');
78
+ if (existsSync(rulesDir)) {
79
+ for (const file of ['runwork.md', 'runwork-team.md']) {
80
+ const filePath = join(rulesDir, file);
81
+ if (existsSync(filePath)) {
82
+ try {
83
+ unlinkSync(filePath);
84
+ }
85
+ catch { /* best-effort */ }
86
+ }
87
+ }
88
+ // Remove only skill .md files that we synced
89
+ if (manifest?.skillFilenames.length) {
90
+ const allowed = new Set(manifest.skillFilenames.map(f => `${f}.md`));
91
+ for (const entry of readdirSync(rulesDir)) {
92
+ if (allowed.has(entry)) {
93
+ try {
94
+ unlinkSync(join(rulesDir, entry));
95
+ }
96
+ catch { /* best-effort */ }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
67
102
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,281 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { collectTelemetryEvents, summarizeTelemetryForDryRun } from '../sync-telemetry.js';
3
+ function mockAdapter(slug, opts = {}) {
4
+ return {
5
+ slug,
6
+ name: slug,
7
+ detect: async () => true,
8
+ supportsSkills: () => false,
9
+ supportsMcpScope: () => false,
10
+ writeSkills: async () => { },
11
+ writeMcpServers: async () => { },
12
+ writeInstructionHint: async () => { },
13
+ ...opts,
14
+ };
15
+ }
16
+ function baseState(overrides = {}) {
17
+ return {
18
+ workspaceId: 'ws-1',
19
+ workspaceName: 'Test',
20
+ configuredAgents: [],
21
+ scope: 'user',
22
+ lastSyncAt: '2026-04-13T00:00:00.000Z',
23
+ mcpServers: [],
24
+ skills: [],
25
+ ...overrides,
26
+ };
27
+ }
28
+ const NOW = '2026-04-14T00:00:00.000Z';
29
+ describe('collectTelemetryEvents', () => {
30
+ it('always emits a sync_completed event with aggregate counts', async () => {
31
+ const result = await collectTelemetryEvents({
32
+ adapters: [],
33
+ state: baseState(),
34
+ syncedSkillsCount: 5,
35
+ syncedMcpServersCount: 2,
36
+ teamInstructionsApplied: true,
37
+ agentConfigsApplied: 1,
38
+ now: NOW,
39
+ });
40
+ const syncCompleted = result.events.find(e => e.eventType === 'local_agent.sync_completed');
41
+ expect(syncCompleted).toBeDefined();
42
+ expect(syncCompleted?.metadata).toMatchObject({
43
+ syncedSkills: 5,
44
+ syncedMcpServers: 2,
45
+ teamInstructionsApplied: true,
46
+ agentConfigsApplied: 1,
47
+ });
48
+ });
49
+ it('emits local_agent.detected only for newly seen agents', async () => {
50
+ const result = await collectTelemetryEvents({
51
+ adapters: [],
52
+ state: baseState({
53
+ configuredAgents: ['claude-code', 'cursor', 'codex'],
54
+ reportedAgentSlugs: ['claude-code'],
55
+ }),
56
+ syncedSkillsCount: 0,
57
+ syncedMcpServersCount: 0,
58
+ teamInstructionsApplied: false,
59
+ agentConfigsApplied: 0,
60
+ now: NOW,
61
+ });
62
+ const detectedSlugs = result.events
63
+ .filter(e => e.eventType === 'local_agent.detected')
64
+ .map(e => e.agentSlug);
65
+ expect(detectedSlugs).toEqual(['cursor', 'codex']);
66
+ expect(result.newlyReportedAgents).toEqual(['cursor', 'codex']);
67
+ });
68
+ it('emits local_agent.usage when adapter reports hasNewActivity', async () => {
69
+ const stats = {
70
+ hasNewActivity: true,
71
+ sessionCount: 3,
72
+ messageCount: 42,
73
+ toolCallCount: 10,
74
+ tokensUsed: 1000,
75
+ aiLinesAdded: 50,
76
+ aiLinesRemoved: 10,
77
+ lastActiveAt: '2026-04-13T23:00:00.000Z',
78
+ modelsUsed: ['sonnet'],
79
+ mcpToolCount: 5,
80
+ extra: { customField: 'value' },
81
+ };
82
+ const adapter = mockAdapter('claude-code', {
83
+ readUsageStats: async () => stats,
84
+ });
85
+ const result = await collectTelemetryEvents({
86
+ adapters: [adapter],
87
+ state: baseState({ configuredAgents: ['claude-code'], reportedAgentSlugs: ['claude-code'] }),
88
+ syncedSkillsCount: 0,
89
+ syncedMcpServersCount: 0,
90
+ teamInstructionsApplied: false,
91
+ agentConfigsApplied: 0,
92
+ now: NOW,
93
+ });
94
+ const usage = result.events.find(e => e.eventType === 'local_agent.usage');
95
+ expect(usage).toBeDefined();
96
+ expect(usage?.agentSlug).toBe('claude-code');
97
+ expect(usage?.metadata).toMatchObject({
98
+ sessionCount: 3,
99
+ messageCount: 42,
100
+ tokensUsed: 1000,
101
+ mcpToolCount: 5,
102
+ customField: 'value',
103
+ });
104
+ expect(result.adapterResults[0].stats).toEqual(stats);
105
+ });
106
+ it('does not emit usage event when hasNewActivity is false', async () => {
107
+ const adapter = mockAdapter('cursor', {
108
+ readUsageStats: async () => ({
109
+ hasNewActivity: false,
110
+ sessionCount: 0,
111
+ messageCount: 0,
112
+ toolCallCount: 0,
113
+ tokensUsed: 0,
114
+ aiLinesAdded: 0,
115
+ aiLinesRemoved: 0,
116
+ lastActiveAt: null,
117
+ }),
118
+ });
119
+ const result = await collectTelemetryEvents({
120
+ adapters: [adapter],
121
+ state: baseState({ configuredAgents: ['cursor'], reportedAgentSlugs: ['cursor'] }),
122
+ syncedSkillsCount: 0,
123
+ syncedMcpServersCount: 0,
124
+ teamInstructionsApplied: false,
125
+ agentConfigsApplied: 0,
126
+ now: NOW,
127
+ });
128
+ expect(result.events.find(e => e.eventType === 'local_agent.usage')).toBeUndefined();
129
+ expect(result.adapterResults[0].stats).toBeTruthy();
130
+ });
131
+ it('marks stats and skills as unsupported when adapter lacks readers', async () => {
132
+ const adapter = mockAdapter('cline');
133
+ const result = await collectTelemetryEvents({
134
+ adapters: [adapter],
135
+ state: baseState({ configuredAgents: ['cline'], reportedAgentSlugs: ['cline'] }),
136
+ syncedSkillsCount: 0,
137
+ syncedMcpServersCount: 0,
138
+ teamInstructionsApplied: false,
139
+ agentConfigsApplied: 0,
140
+ now: NOW,
141
+ });
142
+ expect(result.adapterResults[0].stats).toBe('unsupported');
143
+ expect(result.adapterResults[0].skills).toBe('unsupported');
144
+ });
145
+ it('captures errors thrown by readUsageStats without failing overall collection', async () => {
146
+ const good = mockAdapter('codex', {
147
+ readUsageStats: async () => ({
148
+ hasNewActivity: true,
149
+ sessionCount: 1,
150
+ messageCount: 0,
151
+ toolCallCount: 0,
152
+ tokensUsed: 100,
153
+ aiLinesAdded: 0,
154
+ aiLinesRemoved: 0,
155
+ lastActiveAt: NOW,
156
+ }),
157
+ });
158
+ const broken = mockAdapter('cursor', {
159
+ readUsageStats: async () => {
160
+ throw new Error('db locked');
161
+ },
162
+ });
163
+ const result = await collectTelemetryEvents({
164
+ adapters: [broken, good],
165
+ state: baseState({
166
+ configuredAgents: ['cursor', 'codex'],
167
+ reportedAgentSlugs: ['cursor', 'codex'],
168
+ }),
169
+ syncedSkillsCount: 0,
170
+ syncedMcpServersCount: 0,
171
+ teamInstructionsApplied: false,
172
+ agentConfigsApplied: 0,
173
+ now: NOW,
174
+ });
175
+ const brokenResult = result.adapterResults.find(r => r.slug === 'cursor');
176
+ expect(brokenResult?.error).toContain('db locked');
177
+ expect(brokenResult?.stats).toBeNull();
178
+ const goodResult = result.adapterResults.find(r => r.slug === 'codex');
179
+ expect(goodResult?.error).toBeUndefined();
180
+ expect(result.events
181
+ .filter(e => e.eventType === 'local_agent.usage')
182
+ .map(e => e.agentSlug)).toEqual(['codex']);
183
+ });
184
+ it('emits one skill_used event per skill entry', async () => {
185
+ const entries = [
186
+ { skillName: 'runwork', count: 1, lastUsedAt: '2026-04-13T12:00:00.000Z' },
187
+ { skillName: 'seo-content-strategist', count: 2, lastUsedAt: '2026-04-13T13:00:00.000Z' },
188
+ ];
189
+ const adapter = mockAdapter('claude-code', {
190
+ readSkillUsage: async () => entries,
191
+ });
192
+ const result = await collectTelemetryEvents({
193
+ adapters: [adapter],
194
+ state: baseState({ configuredAgents: ['claude-code'], reportedAgentSlugs: ['claude-code'] }),
195
+ syncedSkillsCount: 0,
196
+ syncedMcpServersCount: 0,
197
+ teamInstructionsApplied: false,
198
+ agentConfigsApplied: 0,
199
+ now: NOW,
200
+ });
201
+ const skillEvents = result.events.filter(e => e.eventType === 'local_agent.skill_used');
202
+ expect(skillEvents).toHaveLength(2);
203
+ expect(skillEvents[0].metadata?.skillName).toBe('runwork');
204
+ expect(skillEvents[1].metadata?.skillName).toBe('seo-content-strategist');
205
+ });
206
+ it('reports health when lastHealthReportAt is missing', async () => {
207
+ const adapter = mockAdapter('codex', {
208
+ readVersion: async () => '0.5.0',
209
+ });
210
+ const result = await collectTelemetryEvents({
211
+ adapters: [adapter],
212
+ state: baseState({ configuredAgents: ['codex'], reportedAgentSlugs: ['codex'] }),
213
+ syncedSkillsCount: 0,
214
+ syncedMcpServersCount: 0,
215
+ teamInstructionsApplied: false,
216
+ agentConfigsApplied: 0,
217
+ now: NOW,
218
+ });
219
+ expect(result.healthReported).toBe(true);
220
+ const health = result.events.find(e => e.eventType === 'local_agent.health');
221
+ expect(health?.metadata?.version).toBe('0.5.0');
222
+ expect(health?.metadata?.installed).toBe(true);
223
+ });
224
+ it('skips health when last reported less than 24h ago', async () => {
225
+ const recent = new Date(Date.now() - 60 * 60 * 1000).toISOString();
226
+ const adapter = mockAdapter('codex', {
227
+ readVersion: async () => '0.5.0',
228
+ });
229
+ const result = await collectTelemetryEvents({
230
+ adapters: [adapter],
231
+ state: baseState({
232
+ configuredAgents: ['codex'],
233
+ reportedAgentSlugs: ['codex'],
234
+ lastHealthReportAt: recent,
235
+ }),
236
+ syncedSkillsCount: 0,
237
+ syncedMcpServersCount: 0,
238
+ teamInstructionsApplied: false,
239
+ agentConfigsApplied: 0,
240
+ now: NOW,
241
+ });
242
+ expect(result.healthReported).toBe(false);
243
+ expect(result.events.find(e => e.eventType === 'local_agent.health')).toBeUndefined();
244
+ });
245
+ });
246
+ describe('summarizeTelemetryForDryRun', () => {
247
+ it('counts adapters with active stats', async () => {
248
+ const result = await collectTelemetryEvents({
249
+ adapters: [
250
+ mockAdapter('codex', {
251
+ readUsageStats: async () => ({
252
+ hasNewActivity: true,
253
+ sessionCount: 1,
254
+ messageCount: 0,
255
+ toolCallCount: 0,
256
+ tokensUsed: 0,
257
+ aiLinesAdded: 0,
258
+ aiLinesRemoved: 0,
259
+ lastActiveAt: NOW,
260
+ }),
261
+ }),
262
+ mockAdapter('cursor', {
263
+ readUsageStats: async () => null,
264
+ }),
265
+ mockAdapter('cline'),
266
+ ],
267
+ state: baseState({
268
+ configuredAgents: ['codex', 'cursor', 'cline'],
269
+ reportedAgentSlugs: ['codex', 'cursor', 'cline'],
270
+ }),
271
+ syncedSkillsCount: 0,
272
+ syncedMcpServersCount: 0,
273
+ teamInstructionsApplied: false,
274
+ agentConfigsApplied: 0,
275
+ now: NOW,
276
+ });
277
+ const summary = summarizeTelemetryForDryRun(result);
278
+ expect(summary).toContain('1 adapter with activity');
279
+ expect(summary).toContain('events would be sent');
280
+ });
281
+ });
@@ -0,0 +1 @@
1
+ export {};