runwork 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/agents/__tests__/claude-code-stats.test.d.ts +1 -0
  2. package/dist/agents/__tests__/claude-code-stats.test.js +153 -0
  3. package/dist/agents/__tests__/claude-desktop-stats.test.d.ts +1 -0
  4. package/dist/agents/__tests__/claude-desktop-stats.test.js +280 -0
  5. package/dist/agents/__tests__/codex-stats.test.d.ts +1 -0
  6. package/dist/agents/__tests__/codex-stats.test.js +262 -0
  7. package/dist/agents/__tests__/cursor-stats.test.d.ts +1 -0
  8. package/dist/agents/__tests__/cursor-stats.test.js +183 -0
  9. package/dist/agents/__tests__/graceful-degradation.test.d.ts +1 -0
  10. package/dist/agents/__tests__/graceful-degradation.test.js +123 -0
  11. package/dist/agents/__tests__/instruction-hint.test.d.ts +1 -0
  12. package/dist/agents/__tests__/instruction-hint.test.js +69 -0
  13. package/dist/agents/__tests__/intro-skill.test.d.ts +1 -0
  14. package/dist/agents/__tests__/intro-skill.test.js +135 -0
  15. package/dist/agents/__tests__/schema-validation.test.d.ts +1 -0
  16. package/dist/agents/__tests__/schema-validation.test.js +212 -0
  17. package/dist/agents/claude-code.d.ts +16 -1
  18. package/dist/agents/claude-code.js +344 -21
  19. package/dist/agents/claude-desktop.d.ts +10 -1
  20. package/dist/agents/claude-desktop.js +250 -8
  21. package/dist/agents/cline.d.ts +13 -0
  22. package/dist/agents/cline.js +88 -0
  23. package/dist/agents/codex.d.ts +7 -2
  24. package/dist/agents/codex.js +130 -18
  25. package/dist/agents/cursor.d.ts +6 -1
  26. package/dist/agents/cursor.js +164 -7
  27. package/dist/agents/detect.js +30 -7
  28. package/dist/agents/gemini.d.ts +6 -3
  29. package/dist/agents/gemini.js +57 -20
  30. package/dist/agents/generic-adapter.d.ts +23 -0
  31. package/dist/agents/generic-adapter.js +106 -0
  32. package/dist/agents/intro-skill.d.ts +36 -0
  33. package/dist/agents/intro-skill.js +358 -0
  34. package/dist/agents/registry.d.ts +53 -0
  35. package/dist/agents/registry.js +219 -0
  36. package/dist/agents/types.d.ts +44 -1
  37. package/dist/agents/types.js +11 -4
  38. package/dist/agents/utils/instruction-hint.d.ts +11 -0
  39. package/dist/agents/utils/instruction-hint.js +62 -0
  40. package/dist/agents/utils/json-config.js +3 -3
  41. package/dist/agents/windsurf.d.ts +2 -0
  42. package/dist/agents/windsurf.js +26 -9
  43. package/dist/api/client.d.ts +85 -2
  44. package/dist/api/client.js +40 -10
  45. package/dist/auth/__tests__/login-flow.test.d.ts +1 -0
  46. package/dist/auth/__tests__/login-flow.test.js +125 -0
  47. package/dist/auth/login-flow.d.ts +16 -0
  48. package/dist/auth/login-flow.js +101 -1
  49. package/dist/commands/clone.d.ts +9 -1
  50. package/dist/commands/clone.js +40 -12
  51. package/dist/commands/deploy.js +27 -4
  52. package/dist/commands/dev.js +35 -1
  53. package/dist/commands/doctor.d.ts +2 -0
  54. package/dist/commands/doctor.js +82 -0
  55. package/dist/commands/info.js +6 -2
  56. package/dist/commands/init.d.ts +10 -2
  57. package/dist/commands/init.js +58 -15
  58. package/dist/commands/integrations.js +47 -10
  59. package/dist/commands/login.js +31 -4
  60. package/dist/commands/mcp.js +143 -15
  61. package/dist/commands/setup.js +47 -122
  62. package/dist/commands/skills.js +230 -12
  63. package/dist/commands/sync.d.ts +8 -0
  64. package/dist/commands/sync.js +398 -65
  65. package/dist/commands/welcome.js +6 -3
  66. package/dist/generated/version.d.ts +1 -1
  67. package/dist/generated/version.js +1 -1
  68. package/dist/health/__tests__/checks.test.d.ts +1 -0
  69. package/dist/health/__tests__/checks.test.js +223 -0
  70. package/dist/health/checks.d.ts +20 -0
  71. package/dist/health/checks.js +392 -0
  72. package/dist/health/runner.d.ts +2 -0
  73. package/dist/health/runner.js +26 -0
  74. package/dist/health/types.d.ts +12 -0
  75. package/dist/health/types.js +1 -0
  76. package/dist/index.js +32 -0
  77. package/dist/sync/__tests__/change-detect.test.d.ts +1 -0
  78. package/dist/sync/__tests__/change-detect.test.js +123 -0
  79. package/dist/sync/__tests__/hash.test.d.ts +1 -0
  80. package/dist/sync/__tests__/hash.test.js +18 -0
  81. package/dist/sync/change-detect.d.ts +19 -0
  82. package/dist/sync/change-detect.js +136 -0
  83. package/dist/sync/conflict-ui.d.ts +5 -0
  84. package/dist/sync/conflict-ui.js +78 -0
  85. package/dist/sync/executor.d.ts +21 -0
  86. package/dist/sync/executor.js +98 -0
  87. package/dist/sync/hash.d.ts +1 -0
  88. package/dist/sync/hash.js +4 -0
  89. package/dist/sync/types.d.ts +24 -0
  90. package/dist/sync/types.js +1 -0
  91. package/dist/types.d.ts +12 -0
  92. package/dist/ui/__tests__/banner.test.js +17 -1
  93. package/dist/ui/banner.d.ts +1 -1
  94. package/dist/ui/banner.js +27 -2
  95. package/dist/utils/__tests__/resolve.test.d.ts +1 -0
  96. package/dist/utils/__tests__/resolve.test.js +95 -0
  97. package/dist/utils/__tests__/sqlite.test.d.ts +1 -0
  98. package/dist/utils/__tests__/sqlite.test.js +77 -0
  99. package/dist/utils/agent-guidance.d.ts +32 -0
  100. package/dist/utils/agent-guidance.js +106 -0
  101. package/dist/utils/fs.js +2 -2
  102. package/dist/utils/help-json.d.ts +56 -0
  103. package/dist/utils/help-json.js +85 -0
  104. package/dist/utils/resolve.d.ts +12 -0
  105. package/dist/utils/resolve.js +52 -0
  106. package/dist/utils/setup-state.d.ts +3 -0
  107. package/dist/utils/setup-state.js +19 -0
  108. package/dist/utils/sqlite.d.ts +7 -0
  109. package/dist/utils/sqlite.js +24 -0
  110. package/dist/utils/which.d.ts +6 -0
  111. package/dist/utils/which.js +18 -0
  112. package/package.json +7 -3
  113. package/dist/agents/copilot-cli.d.ts +0 -10
  114. package/dist/agents/copilot-cli.js +0 -46
  115. package/dist/agents/copilot-vscode.d.ts +0 -10
  116. package/dist/agents/copilot-vscode.js +0 -37
@@ -0,0 +1,262 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ vi.mock('fs', () => ({
3
+ existsSync: vi.fn(),
4
+ readdirSync: vi.fn(),
5
+ readFileSync: vi.fn(),
6
+ statSync: vi.fn(),
7
+ writeFileSync: vi.fn(),
8
+ mkdirSync: vi.fn(),
9
+ }));
10
+ vi.mock('os', () => ({
11
+ homedir: vi.fn(() => '/mock/home'),
12
+ platform: vi.fn(() => 'darwin'),
13
+ }));
14
+ vi.mock('child_process', () => ({
15
+ execFileSync: vi.fn(),
16
+ }));
17
+ vi.mock('../../utils/sqlite.js', () => ({
18
+ querySqlite: vi.fn(() => ''),
19
+ }));
20
+ vi.mock('smol-toml', () => ({
21
+ parse: vi.fn(() => ({})),
22
+ stringify: vi.fn(() => ''),
23
+ }));
24
+ // Mock utility imports so the module loads cleanly
25
+ vi.mock('../utils/json-config.js', () => ({
26
+ mergeJsonMcpServers: vi.fn(),
27
+ }));
28
+ vi.mock('../utils/instruction-hint.js', () => ({
29
+ writeHintToFile: vi.fn(),
30
+ }));
31
+ vi.mock('../registry.js', () => ({
32
+ resolvePlatformString: vi.fn((s) => s),
33
+ resolveToAbsolute: vi.fn((s) => s),
34
+ }));
35
+ import { existsSync, readFileSync } from 'fs';
36
+ import { querySqlite } from '../../utils/sqlite.js';
37
+ import { CodexAdapter } from '../codex.js';
38
+ const CODEX_DIR = '/mock/home/.codex';
39
+ const DB_PATH = `${CODEX_DIR}/state_5.sqlite`;
40
+ const HISTORY_PATH = `${CODEX_DIR}/history.jsonl`;
41
+ const VERSION_PATH = `${CODEX_DIR}/version.json`;
42
+ function createCodexAdapter() {
43
+ return new CodexAdapter();
44
+ }
45
+ describe('CodexAdapter (codex).readUsageStats', () => {
46
+ let adapter;
47
+ beforeEach(() => {
48
+ vi.restoreAllMocks();
49
+ adapter = createCodexAdapter();
50
+ });
51
+ it('queries threads table for session count since lastSyncAt', async () => {
52
+ existsSync.mockImplementation((p) => {
53
+ if (p === CODEX_DIR)
54
+ return true;
55
+ if (p === DB_PATH)
56
+ return true;
57
+ return false;
58
+ });
59
+ querySqlite.mockImplementation((_db, sql) => {
60
+ if (sql.includes('count(*)'))
61
+ return '3';
62
+ if (sql.includes('sum(tokens_used)'))
63
+ return '15000';
64
+ if (sql.includes('max(updated_at)'))
65
+ return '1712100000';
66
+ if (sql.includes('DISTINCT cli_version'))
67
+ return '1.0.0\n0.9.0';
68
+ return '';
69
+ });
70
+ const syncAt = new Date(1711000000000).toISOString();
71
+ const result = await adapter.readUsageStats(syncAt);
72
+ expect(result).not.toBeNull();
73
+ expect(result.sessionCount).toBe(3);
74
+ expect(result.hasNewActivity).toBe(true);
75
+ // Verify querySqlite was called with the DB path
76
+ expect(querySqlite).toHaveBeenCalledWith(DB_PATH, expect.stringContaining('count(*)'));
77
+ });
78
+ it('sums tokens_used from threads', async () => {
79
+ existsSync.mockImplementation((p) => {
80
+ if (p === CODEX_DIR)
81
+ return true;
82
+ if (p === DB_PATH)
83
+ return true;
84
+ return false;
85
+ });
86
+ querySqlite.mockImplementation((_db, sql) => {
87
+ if (sql.includes('count(*)'))
88
+ return '2';
89
+ if (sql.includes('sum(tokens_used)'))
90
+ return '25000';
91
+ if (sql.includes('max(updated_at)'))
92
+ return '1712100000';
93
+ if (sql.includes('DISTINCT cli_version'))
94
+ return '';
95
+ return '';
96
+ });
97
+ const result = await adapter.readUsageStats(null);
98
+ expect(result).not.toBeNull();
99
+ expect(result.tokensUsed).toBe(25000);
100
+ });
101
+ it('gets latest activity timestamp', async () => {
102
+ existsSync.mockImplementation((p) => {
103
+ if (p === CODEX_DIR)
104
+ return true;
105
+ if (p === DB_PATH)
106
+ return true;
107
+ return false;
108
+ });
109
+ querySqlite.mockImplementation((_db, sql) => {
110
+ if (sql.includes('count(*)'))
111
+ return '1';
112
+ if (sql.includes('sum(tokens_used)'))
113
+ return '5000';
114
+ if (sql.includes('max(updated_at)'))
115
+ return '1712100000';
116
+ if (sql.includes('DISTINCT cli_version'))
117
+ return '';
118
+ return '';
119
+ });
120
+ const result = await adapter.readUsageStats(null);
121
+ expect(result).not.toBeNull();
122
+ // 1712100000 seconds -> milliseconds
123
+ expect(result.lastActiveAt).toBe(new Date(1712100000 * 1000).toISOString());
124
+ });
125
+ it('gets CLI versions from threads', async () => {
126
+ existsSync.mockImplementation((p) => {
127
+ if (p === CODEX_DIR)
128
+ return true;
129
+ if (p === DB_PATH)
130
+ return true;
131
+ return false;
132
+ });
133
+ querySqlite.mockImplementation((_db, sql) => {
134
+ if (sql.includes('count(*)'))
135
+ return '2';
136
+ if (sql.includes('sum(tokens_used)'))
137
+ return '0';
138
+ if (sql.includes('max(updated_at)'))
139
+ return '1712100000';
140
+ if (sql.includes('DISTINCT cli_version'))
141
+ return '1.2.0\n1.1.0';
142
+ return '';
143
+ });
144
+ const result = await adapter.readUsageStats(null);
145
+ expect(result).not.toBeNull();
146
+ expect(result.extra).toEqual({ cliVersions: ['1.2.0', '1.1.0'] });
147
+ });
148
+ it('counts messages from history.jsonl since lastSyncAt', async () => {
149
+ existsSync.mockImplementation((p) => {
150
+ if (p === CODEX_DIR)
151
+ return true;
152
+ if (p === DB_PATH)
153
+ return true;
154
+ if (p === HISTORY_PATH)
155
+ return true;
156
+ return false;
157
+ });
158
+ const historyContent = [
159
+ '{"session_id":"old","ts":1700000000,"text":"old prompt"}',
160
+ '{"session_id":"new","ts":1712000000,"text":"recent prompt"}',
161
+ '{"session_id":"newest","ts":1712100000,"text":"newest prompt"}',
162
+ ].join('\n');
163
+ readFileSync.mockReturnValue(historyContent);
164
+ querySqlite.mockImplementation((_db, sql) => {
165
+ if (sql.includes('count(*)'))
166
+ return '2';
167
+ if (sql.includes('sum(tokens_used)'))
168
+ return '0';
169
+ if (sql.includes('max(updated_at)'))
170
+ return '1712100000';
171
+ if (sql.includes('DISTINCT cli_version'))
172
+ return '';
173
+ return '';
174
+ });
175
+ // sinceSec = 1711000000 (in seconds)
176
+ const syncAt = new Date(1711000000000).toISOString();
177
+ const result = await adapter.readUsageStats(syncAt);
178
+ expect(result).not.toBeNull();
179
+ // Two entries have ts > 1711000 (in seconds): 1712000000 and 1712100000
180
+ expect(result.messageCount).toBe(2);
181
+ });
182
+ it('returns null when ~/.codex does not exist', async () => {
183
+ existsSync.mockReturnValue(false);
184
+ const result = await adapter.readUsageStats(null);
185
+ expect(result).toBeNull();
186
+ });
187
+ it('returns null when state_5.sqlite does not exist', async () => {
188
+ existsSync.mockImplementation((p) => {
189
+ if (p === CODEX_DIR)
190
+ return true;
191
+ return false;
192
+ });
193
+ const result = await adapter.readUsageStats(null);
194
+ expect(result).toBeNull();
195
+ });
196
+ it('handles empty threads result', async () => {
197
+ existsSync.mockImplementation((p) => {
198
+ if (p === CODEX_DIR)
199
+ return true;
200
+ if (p === DB_PATH)
201
+ return true;
202
+ return false;
203
+ });
204
+ querySqlite.mockReturnValue('');
205
+ const result = await adapter.readUsageStats(null);
206
+ expect(result).toBeNull();
207
+ });
208
+ it('handles malformed history.jsonl lines', async () => {
209
+ existsSync.mockImplementation((p) => {
210
+ if (p === CODEX_DIR)
211
+ return true;
212
+ if (p === DB_PATH)
213
+ return true;
214
+ if (p === HISTORY_PATH)
215
+ return true;
216
+ return false;
217
+ });
218
+ const historyContent = [
219
+ '{{{invalid json',
220
+ '{"session_id":"good","ts":1712000000,"text":"valid"}',
221
+ 'not json at all',
222
+ ].join('\n');
223
+ readFileSync.mockReturnValue(historyContent);
224
+ querySqlite.mockImplementation((_db, sql) => {
225
+ if (sql.includes('count(*)'))
226
+ return '1';
227
+ if (sql.includes('sum(tokens_used)'))
228
+ return '0';
229
+ if (sql.includes('max(updated_at)'))
230
+ return '1712100000';
231
+ if (sql.includes('DISTINCT cli_version'))
232
+ return '';
233
+ return '';
234
+ });
235
+ const result = await adapter.readUsageStats(null);
236
+ expect(result).not.toBeNull();
237
+ // Only the valid line with ts > 0 counts
238
+ expect(result.messageCount).toBe(1);
239
+ });
240
+ });
241
+ describe('CodexAdapter (codex).readVersion', () => {
242
+ let adapter;
243
+ beforeEach(() => {
244
+ vi.restoreAllMocks();
245
+ adapter = createCodexAdapter();
246
+ });
247
+ it('reads version from version.json', async () => {
248
+ existsSync.mockImplementation((p) => {
249
+ if (p === VERSION_PATH)
250
+ return true;
251
+ return false;
252
+ });
253
+ readFileSync.mockReturnValue(JSON.stringify({ latest_version: '0.117.0', last_checked_at: '2026-03-30' }));
254
+ const version = await adapter.readVersion();
255
+ expect(version).toBe('0.117.0');
256
+ });
257
+ it('returns null when version.json does not exist', async () => {
258
+ existsSync.mockReturnValue(false);
259
+ const version = await adapter.readVersion();
260
+ expect(version).toBeNull();
261
+ });
262
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,183 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ vi.mock('fs', () => ({
3
+ existsSync: vi.fn(),
4
+ readdirSync: vi.fn(),
5
+ readFileSync: vi.fn(),
6
+ statSync: vi.fn(),
7
+ writeFileSync: vi.fn(),
8
+ mkdirSync: vi.fn(),
9
+ }));
10
+ vi.mock('os', () => ({
11
+ homedir: vi.fn(() => '/mock/home'),
12
+ platform: vi.fn(() => 'darwin'),
13
+ }));
14
+ vi.mock('child_process', () => ({
15
+ execFileSync: vi.fn(),
16
+ }));
17
+ vi.mock('../../utils/sqlite.js', () => ({
18
+ querySqlite: vi.fn(() => ''),
19
+ }));
20
+ // Mock utility imports so the module loads cleanly
21
+ vi.mock('../utils/json-config.js', () => ({
22
+ mergeJsonMcpServers: vi.fn(),
23
+ }));
24
+ vi.mock('../utils/instruction-hint.js', () => ({
25
+ writeHintToFile: vi.fn(),
26
+ }));
27
+ import { existsSync } from 'fs';
28
+ import { execFileSync } from 'child_process';
29
+ import { querySqlite } from '../../utils/sqlite.js';
30
+ import { CursorAdapter } from '../cursor.js';
31
+ const GLOBAL_DB_PATH = '/mock/home/Library/Application Support/Cursor/User/globalStorage/state.vscdb';
32
+ const TRACKING_DB_PATH = '/mock/home/.cursor/ai-tracking/ai-code-tracking.db';
33
+ // Fixture data: composerData entries (one per line, as querySqlite returns)
34
+ function makeComposerLine(overrides = {}) {
35
+ return JSON.stringify({
36
+ createdAt: 1712000000000,
37
+ totalLinesAdded: 50,
38
+ totalLinesRemoved: 10,
39
+ unifiedMode: 'agent',
40
+ ...overrides,
41
+ });
42
+ }
43
+ describe('CursorAdapter.readUsageStats', () => {
44
+ let adapter;
45
+ beforeEach(() => {
46
+ vi.restoreAllMocks();
47
+ adapter = new CursorAdapter();
48
+ });
49
+ it('parses composerData entries and counts sessions since lastSyncAt', async () => {
50
+ existsSync.mockImplementation((p) => {
51
+ if (p === GLOBAL_DB_PATH)
52
+ return true;
53
+ return false;
54
+ });
55
+ const oldLine = makeComposerLine({ createdAt: 1700000000000 });
56
+ const newLine = makeComposerLine({ createdAt: 1712000000000 });
57
+ querySqlite.mockReturnValue(`${oldLine}\n${newLine}`);
58
+ const syncAt = new Date(1711000000000).toISOString();
59
+ const result = await adapter.readUsageStats(syncAt);
60
+ expect(result).not.toBeNull();
61
+ expect(result.sessionCount).toBe(1);
62
+ });
63
+ it('calculates totalLinesAdded and totalLinesRemoved from composerData', async () => {
64
+ existsSync.mockImplementation((p) => {
65
+ if (p === GLOBAL_DB_PATH)
66
+ return true;
67
+ return false;
68
+ });
69
+ const line1 = makeComposerLine({ createdAt: 1712000000000, totalLinesAdded: 100, totalLinesRemoved: 20 });
70
+ const line2 = makeComposerLine({ createdAt: 1712100000000, totalLinesAdded: 50, totalLinesRemoved: 5 });
71
+ querySqlite.mockReturnValue(`${line1}\n${line2}`);
72
+ const result = await adapter.readUsageStats(null);
73
+ expect(result).not.toBeNull();
74
+ expect(result.aiLinesAdded).toBe(150);
75
+ expect(result.aiLinesRemoved).toBe(25);
76
+ });
77
+ it('breaks down agentic vs chat sessions', async () => {
78
+ existsSync.mockImplementation((p) => {
79
+ if (p === GLOBAL_DB_PATH)
80
+ return true;
81
+ return false;
82
+ });
83
+ const agentLine = makeComposerLine({ createdAt: 1712000000000, unifiedMode: 'agent' });
84
+ const chatLine = makeComposerLine({ createdAt: 1712100000000, unifiedMode: 'chat' });
85
+ const isAgenticLine = makeComposerLine({ createdAt: 1712200000000, unifiedMode: undefined, isAgentic: true });
86
+ querySqlite.mockReturnValue(`${agentLine}\n${chatLine}\n${isAgenticLine}`);
87
+ const result = await adapter.readUsageStats(null);
88
+ expect(result).not.toBeNull();
89
+ expect(result.extra.agenticSessions).toBe(2);
90
+ expect(result.extra.chatSessions).toBe(1);
91
+ });
92
+ it('reads scored_commits for AI contribution percentage', async () => {
93
+ existsSync.mockImplementation((p) => {
94
+ if (p === GLOBAL_DB_PATH)
95
+ return true;
96
+ if (p === TRACKING_DB_PATH)
97
+ return true;
98
+ return false;
99
+ });
100
+ const composerLine = makeComposerLine({ createdAt: 1712000000000 });
101
+ querySqlite.mockImplementation((dbPath) => {
102
+ if (dbPath === GLOBAL_DB_PATH)
103
+ return composerLine;
104
+ if (dbPath === TRACKING_DB_PATH)
105
+ return '5|72.5';
106
+ return '';
107
+ });
108
+ const result = await adapter.readUsageStats(null);
109
+ expect(result).not.toBeNull();
110
+ expect(result.extra.aiCommitCount).toBe(5);
111
+ expect(result.extra.avgAiContributionPercent).toBe(73); // Math.round(72.5)
112
+ });
113
+ it('returns null when Cursor not installed (state.vscdb does not exist)', async () => {
114
+ existsSync.mockReturnValue(false);
115
+ querySqlite.mockReturnValue('');
116
+ const result = await adapter.readUsageStats(null);
117
+ expect(result).toBeNull();
118
+ });
119
+ it('returns null when cursorDiskKV has no composerData entries', async () => {
120
+ existsSync.mockImplementation((p) => {
121
+ if (p === GLOBAL_DB_PATH)
122
+ return true;
123
+ return false;
124
+ });
125
+ querySqlite.mockReturnValue('');
126
+ const result = await adapter.readUsageStats(null);
127
+ expect(result).toBeNull();
128
+ });
129
+ it('handles malformed composerData JSON values', async () => {
130
+ existsSync.mockImplementation((p) => {
131
+ if (p === GLOBAL_DB_PATH)
132
+ return true;
133
+ return false;
134
+ });
135
+ const badLine = '{{{not valid json';
136
+ const goodLine = makeComposerLine({ createdAt: 1712000000000 });
137
+ querySqlite.mockReturnValue(`${badLine}\n${goodLine}`);
138
+ const result = await adapter.readUsageStats(null);
139
+ expect(result).not.toBeNull();
140
+ expect(result.sessionCount).toBe(1);
141
+ });
142
+ it('handles empty scored_commits (no AI% in result)', async () => {
143
+ existsSync.mockImplementation((p) => {
144
+ if (p === GLOBAL_DB_PATH)
145
+ return true;
146
+ if (p === TRACKING_DB_PATH)
147
+ return true;
148
+ return false;
149
+ });
150
+ const composerLine = makeComposerLine({ createdAt: 1712000000000 });
151
+ querySqlite.mockImplementation((dbPath) => {
152
+ if (dbPath === GLOBAL_DB_PATH)
153
+ return composerLine;
154
+ if (dbPath === TRACKING_DB_PATH)
155
+ return '0|';
156
+ return '';
157
+ });
158
+ const result = await adapter.readUsageStats(null);
159
+ expect(result).not.toBeNull();
160
+ // aiCommitCount is 0, so it should not appear in extra
161
+ expect(result.extra.aiCommitCount).toBeUndefined();
162
+ expect(result.extra.avgAiContributionPercent).toBeUndefined();
163
+ });
164
+ });
165
+ describe('CursorAdapter.readVersion', () => {
166
+ let adapter;
167
+ beforeEach(() => {
168
+ vi.restoreAllMocks();
169
+ adapter = new CursorAdapter();
170
+ });
171
+ it('returns version from cursor --version', async () => {
172
+ execFileSync.mockReturnValue(Buffer.from('0.49.2\n'));
173
+ const version = await adapter.readVersion();
174
+ expect(version).toBe('0.49.2');
175
+ });
176
+ it('returns null when cursor command fails', async () => {
177
+ execFileSync.mockImplementation(() => {
178
+ throw new Error('command not found');
179
+ });
180
+ const version = await adapter.readVersion();
181
+ expect(version).toBeNull();
182
+ });
183
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('fs', () => ({
3
+ existsSync: vi.fn(() => false),
4
+ readFileSync: vi.fn(() => { throw new Error('ENOENT'); }),
5
+ readdirSync: vi.fn(() => []),
6
+ statSync: vi.fn(() => { throw new Error('ENOENT'); }),
7
+ mkdirSync: vi.fn(),
8
+ writeFileSync: vi.fn(),
9
+ }));
10
+ vi.mock('os', () => ({
11
+ homedir: vi.fn(() => '/nonexistent/home'),
12
+ platform: vi.fn(() => 'darwin'),
13
+ }));
14
+ vi.mock('child_process', () => ({
15
+ execFileSync: vi.fn(() => { throw new Error('ENOENT'); }),
16
+ }));
17
+ vi.mock('../../utils/sqlite.js', () => ({
18
+ querySqlite: vi.fn(() => ''),
19
+ }));
20
+ vi.mock('smol-toml', () => ({
21
+ parse: vi.fn(() => ({})),
22
+ stringify: vi.fn(() => ''),
23
+ }));
24
+ vi.mock('better-sqlite3', () => ({
25
+ default: vi.fn(() => { throw new Error('not available'); }),
26
+ }));
27
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
28
+ import { homedir } from 'os';
29
+ import { execFileSync } from 'child_process';
30
+ import { ClaudeCodeAdapter } from '../claude-code.js';
31
+ import { ClaudeDesktopAdapter } from '../claude-desktop.js';
32
+ import { CursorAdapter } from '../cursor.js';
33
+ import { CodexAdapter } from '../codex.js';
34
+ const adapters = [
35
+ { name: 'claude-code', adapter: new ClaudeCodeAdapter() },
36
+ { name: 'claude-desktop', adapter: new ClaudeDesktopAdapter() },
37
+ { name: 'cursor', adapter: new CursorAdapter() },
38
+ { name: 'codex', adapter: new CodexAdapter() },
39
+ ];
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ // Default: everything fails
43
+ vi.mocked(homedir).mockReturnValue('/nonexistent/home');
44
+ vi.mocked(existsSync).mockReturnValue(false);
45
+ vi.mocked(readFileSync).mockImplementation(() => { throw new Error('ENOENT'); });
46
+ vi.mocked(readdirSync).mockReturnValue([]);
47
+ vi.mocked(statSync).mockImplementation(() => { throw new Error('ENOENT'); });
48
+ vi.mocked(execFileSync).mockImplementation(() => { throw new Error('ENOENT'); });
49
+ });
50
+ describe('Graceful degradation: all adapters return null on errors', () => {
51
+ for (const { name, adapter } of adapters) {
52
+ describe(`${name} adapter`, () => {
53
+ it('readUsageStats returns null when homedir points to nonexistent path', async () => {
54
+ vi.mocked(homedir).mockReturnValue('/totally/nonexistent/path');
55
+ vi.mocked(existsSync).mockReturnValue(false);
56
+ const result = await adapter.readUsageStats(null);
57
+ expect(result).toBeNull();
58
+ });
59
+ it('readUsageStats returns null when existsSync throws', async () => {
60
+ vi.mocked(existsSync).mockImplementation(() => {
61
+ throw new Error('Unexpected filesystem error');
62
+ });
63
+ const result = await adapter.readUsageStats(null);
64
+ expect(result).toBeNull();
65
+ });
66
+ it('readUsageStats returns null when readFileSync throws EACCES', async () => {
67
+ vi.mocked(existsSync).mockReturnValue(true);
68
+ vi.mocked(readFileSync).mockImplementation(() => {
69
+ const err = new Error('EACCES: permission denied');
70
+ err.code = 'EACCES';
71
+ throw err;
72
+ });
73
+ vi.mocked(readdirSync).mockImplementation(() => {
74
+ const err = new Error('EACCES: permission denied');
75
+ err.code = 'EACCES';
76
+ throw err;
77
+ });
78
+ const result = await adapter.readUsageStats(null);
79
+ expect(result).toBeNull();
80
+ });
81
+ it('readUsageStats never throws even on unexpected errors', async () => {
82
+ vi.mocked(existsSync).mockImplementation(() => {
83
+ throw new TypeError('Cannot read properties of undefined');
84
+ });
85
+ // Must not throw, must return null
86
+ await expect(adapter.readUsageStats(null)).resolves.toBeNull();
87
+ });
88
+ if (adapter.readVersion) {
89
+ it('readVersion returns null when command/file not found', async () => {
90
+ vi.mocked(execFileSync).mockImplementation(() => {
91
+ throw new Error('ENOENT: command not found');
92
+ });
93
+ vi.mocked(existsSync).mockReturnValue(false);
94
+ const result = await adapter.readVersion();
95
+ expect(result).toBeNull();
96
+ });
97
+ }
98
+ });
99
+ }
100
+ // Claude Code is the only adapter with readSkillUsage
101
+ describe('claude-code readSkillUsage', () => {
102
+ const ccAdapter = new ClaudeCodeAdapter();
103
+ it('readSkillUsage returns null when skills dir does not exist', async () => {
104
+ vi.mocked(existsSync).mockReturnValue(false);
105
+ const result = await ccAdapter.readSkillUsage(null);
106
+ expect(result).toBeNull();
107
+ });
108
+ it('readSkillUsage returns null when readdirSync throws', async () => {
109
+ vi.mocked(existsSync).mockReturnValue(true);
110
+ vi.mocked(readdirSync).mockImplementation(() => {
111
+ throw new Error('EACCES: permission denied');
112
+ });
113
+ const result = await ccAdapter.readSkillUsage(null);
114
+ expect(result).toBeNull();
115
+ });
116
+ it('readSkillUsage never throws even on unexpected errors', async () => {
117
+ vi.mocked(existsSync).mockImplementation(() => {
118
+ throw new TypeError('Unexpected error');
119
+ });
120
+ await expect(ccAdapter.readSkillUsage(null)).resolves.toBeNull();
121
+ });
122
+ });
123
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { writeHintToFile } from '../utils/instruction-hint.js';
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { tmpdir } from 'os';
6
+ let testDir;
7
+ beforeEach(() => {
8
+ testDir = join(tmpdir(), `runwork-hint-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
9
+ mkdirSync(testDir, { recursive: true });
10
+ });
11
+ afterEach(() => {
12
+ // Clean up
13
+ try {
14
+ const { rmSync } = require('fs');
15
+ rmSync(testDir, { recursive: true, force: true });
16
+ }
17
+ catch { /* ignore */ }
18
+ });
19
+ describe('writeHintToFile', () => {
20
+ it('creates new file with hint', () => {
21
+ const filePath = join(testDir, 'CLAUDE.md');
22
+ writeHintToFile(filePath, '<!-- runwork:start -->\n## Runwork\nHello\n<!-- runwork:end -->');
23
+ const content = readFileSync(filePath, 'utf-8');
24
+ expect(content).toContain('## Runwork');
25
+ expect(content).toContain('Hello');
26
+ });
27
+ it('appends to existing file', () => {
28
+ const filePath = join(testDir, 'CLAUDE.md');
29
+ writeFileSync(filePath, '# My Instructions\n\nDo stuff.\n');
30
+ writeHintToFile(filePath, '<!-- runwork:start -->\n## Runwork\nHello\n<!-- runwork:end -->');
31
+ const content = readFileSync(filePath, 'utf-8');
32
+ expect(content).toContain('# My Instructions');
33
+ expect(content).toContain('Do stuff.');
34
+ expect(content).toContain('## Runwork');
35
+ expect(content).toContain('Hello');
36
+ });
37
+ it('replaces existing runwork section', () => {
38
+ const filePath = join(testDir, 'CLAUDE.md');
39
+ writeFileSync(filePath, [
40
+ '# My Instructions',
41
+ '',
42
+ '<!-- runwork:start -->',
43
+ '## Runwork',
44
+ 'Old content',
45
+ '<!-- runwork:end -->',
46
+ '',
47
+ '# Other stuff',
48
+ ].join('\n'));
49
+ writeHintToFile(filePath, '<!-- runwork:start -->\n## Runwork\nNew content\n<!-- runwork:end -->');
50
+ const content = readFileSync(filePath, 'utf-8');
51
+ expect(content).toContain('New content');
52
+ expect(content).not.toContain('Old content');
53
+ expect(content).toContain('# My Instructions');
54
+ expect(content).toContain('# Other stuff');
55
+ });
56
+ it('creates parent directories', () => {
57
+ const filePath = join(testDir, 'nested', 'dir', 'CLAUDE.md');
58
+ writeHintToFile(filePath, '<!-- runwork:start -->\nTest\n<!-- runwork:end -->');
59
+ expect(existsSync(filePath)).toBe(true);
60
+ expect(readFileSync(filePath, 'utf-8')).toContain('Test');
61
+ });
62
+ it('preserves content before and after markers', () => {
63
+ const filePath = join(testDir, 'CLAUDE.md');
64
+ writeFileSync(filePath, 'BEFORE\n<!-- runwork:start -->\nold\n<!-- runwork:end -->\nAFTER');
65
+ writeHintToFile(filePath, '<!-- runwork:start -->\nnew\n<!-- runwork:end -->');
66
+ const content = readFileSync(filePath, 'utf-8');
67
+ expect(content).toBe('BEFORE\n<!-- runwork:start -->\nnew\n<!-- runwork:end -->\nAFTER');
68
+ });
69
+ });
@@ -0,0 +1 @@
1
+ export {};