@nemus-cli/nemus 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +275 -0
  4. package/assets/README.md +53 -0
  5. package/assets/banner.png +0 -0
  6. package/assets/banner.svg +66 -0
  7. package/assets/favicon-32.png +0 -0
  8. package/assets/favicon-64.png +0 -0
  9. package/assets/icon-256.png +0 -0
  10. package/assets/icon.svg +23 -0
  11. package/assets/logo-256.png +0 -0
  12. package/assets/logo-512.png +0 -0
  13. package/assets/logo-wordmark.svg +35 -0
  14. package/assets/logo.svg +32 -0
  15. package/bin/workspace.js +130 -0
  16. package/dist/cli/ai-prompt.js +421 -0
  17. package/dist/cli/dashboard/AgentList.js +61 -0
  18. package/dist/cli/dashboard/DashboardSidebar.js +280 -0
  19. package/dist/cli/dashboard/HelpFooter.js +73 -0
  20. package/dist/cli/dashboard/StatusBadge.js +50 -0
  21. package/dist/cli/dashboard/index.js +11 -0
  22. package/dist/cli/dashboard/types.js +2 -0
  23. package/dist/cli/tui.js +48 -0
  24. package/dist/commands/ai-prompt.js +22 -0
  25. package/dist/commands/analyze-deps.js +138 -0
  26. package/dist/commands/archive.js +113 -0
  27. package/dist/commands/branch/create.js +119 -0
  28. package/dist/commands/branch/index.js +104 -0
  29. package/dist/commands/branch/merge.js +90 -0
  30. package/dist/commands/branch/rebase.js +84 -0
  31. package/dist/commands/branch/switch.js +165 -0
  32. package/dist/commands/cache/index.js +80 -0
  33. package/dist/commands/cache/manager.js +198 -0
  34. package/dist/commands/cleanup.js +153 -0
  35. package/dist/commands/configure-claude.js +55 -0
  36. package/dist/commands/configure.js +231 -0
  37. package/dist/commands/create.js +204 -0
  38. package/dist/commands/dashboard/hook-handler.js +162 -0
  39. package/dist/commands/dashboard/index.js +258 -0
  40. package/dist/commands/dashboard/launcher.js +40 -0
  41. package/dist/commands/dashboard/session-picker.js +106 -0
  42. package/dist/commands/dashboard/sidebar.js +39 -0
  43. package/dist/commands/dashboard/workspace-picker.js +95 -0
  44. package/dist/commands/delete.js +194 -0
  45. package/dist/commands/deprecated-aliases.js +44 -0
  46. package/dist/commands/diff.js +129 -0
  47. package/dist/commands/doctor.js +141 -0
  48. package/dist/commands/generate-docs.js +77 -0
  49. package/dist/commands/ghq-status.js +72 -0
  50. package/dist/commands/go.js +154 -0
  51. package/dist/commands/history.js +124 -0
  52. package/dist/commands/list.js +191 -0
  53. package/dist/commands/mcp/index.js +69 -0
  54. package/dist/commands/migrate.js +296 -0
  55. package/dist/commands/remove-repo.js +217 -0
  56. package/dist/commands/report-bug.js +112 -0
  57. package/dist/commands/run.js +147 -0
  58. package/dist/commands/save-context.js +171 -0
  59. package/dist/commands/sessions.js +129 -0
  60. package/dist/commands/status.js +138 -0
  61. package/dist/commands/suite/create.js +212 -0
  62. package/dist/commands/suite/delete.js +64 -0
  63. package/dist/commands/suite/export.js +126 -0
  64. package/dist/commands/suite/import.js +128 -0
  65. package/dist/commands/suite/index.js +94 -0
  66. package/dist/commands/suite/list.js +49 -0
  67. package/dist/commands/suite/use.js +227 -0
  68. package/dist/commands/sync.js +117 -0
  69. package/dist/commands/update.js +203 -0
  70. package/dist/mcp/install.js +550 -0
  71. package/dist/mcp/server.js +398 -0
  72. package/dist/mcp/tools.js +922 -0
  73. package/dist/pi-extensions/permission-sync.ts.template +41 -0
  74. package/dist/pi-extensions/permissions-gate.ts.template +69 -0
  75. package/dist/pi-extensions/pr-fix-trigger.ts.template +124 -0
  76. package/dist/pi-extensions/review-reminder.ts.template +58 -0
  77. package/dist/pi-extensions/workspace-input-status.ts.template +919 -0
  78. package/dist/program.js +136 -0
  79. package/dist/scripts/workspace-table.py +394 -0
  80. package/dist/types/dashboard.js +11 -0
  81. package/dist/types/index.js +2 -0
  82. package/dist/utils/agent-config.js +263 -0
  83. package/dist/utils/agent-state.js +178 -0
  84. package/dist/utils/banner.js +24 -0
  85. package/dist/utils/branch-operations.js +232 -0
  86. package/dist/utils/bug-report.js +355 -0
  87. package/dist/utils/cache.js +168 -0
  88. package/dist/utils/claude-integration.js +617 -0
  89. package/dist/utils/claude-sessions.js +260 -0
  90. package/dist/utils/cleanup-operations.js +120 -0
  91. package/dist/utils/colors.js +26 -0
  92. package/dist/utils/command-helpers.js +44 -0
  93. package/dist/utils/config.js +150 -0
  94. package/dist/utils/context-file.js +33 -0
  95. package/dist/utils/dashboard-hooks.js +172 -0
  96. package/dist/utils/dependency-analyzer.js +234 -0
  97. package/dist/utils/diff-operations.js +80 -0
  98. package/dist/utils/doc-generator.js +184 -0
  99. package/dist/utils/ghq-integration.js +332 -0
  100. package/dist/utils/git-operations.js +237 -0
  101. package/dist/utils/git-status.js +187 -0
  102. package/dist/utils/github.js +68 -0
  103. package/dist/utils/health-checks.js +345 -0
  104. package/dist/utils/history.js +150 -0
  105. package/dist/utils/hooks.js +100 -0
  106. package/dist/utils/logger.js +40 -0
  107. package/dist/utils/permission-sync.js +681 -0
  108. package/dist/utils/pi-extensions.js +224 -0
  109. package/dist/utils/progress.js +92 -0
  110. package/dist/utils/prompts.js +279 -0
  111. package/dist/utils/repo-resolver.js +260 -0
  112. package/dist/utils/retry.js +65 -0
  113. package/dist/utils/run-operations.js +30 -0
  114. package/dist/utils/suite.js +159 -0
  115. package/dist/utils/sync-operations.js +107 -0
  116. package/dist/utils/tmux-dashboard.js +184 -0
  117. package/dist/utils/validation.js +153 -0
  118. package/dist/utils/version-check.js +121 -0
  119. package/dist/utils/workspace-meta.js +185 -0
  120. package/install-shell-integration.sh +532 -0
  121. package/install.sh +401 -0
  122. package/package.json +90 -0
  123. package/remove-shell-block.awk +44 -0
  124. package/scripts/postinstall.js +85 -0
  125. package/skills/analyze-deps.md +23 -0
  126. package/skills/archive-workspace.md +26 -0
  127. package/skills/branch-create.md +18 -0
  128. package/skills/create-workspace.md +39 -0
  129. package/skills/delete-workspace.md +37 -0
  130. package/skills/list-org-repos.md +26 -0
  131. package/skills/list-suites.md +24 -0
  132. package/skills/list-workspaces.md +27 -0
  133. package/skills/refresh-workspace-docs.md +89 -0
  134. package/skills/remove-repo.md +25 -0
  135. package/skills/run-command.md +23 -0
  136. package/skills/save-context.md +68 -0
  137. package/skills/search-repos.md +31 -0
  138. package/skills/snapshot-list.md +18 -0
  139. package/skills/snapshot-save.md +12 -0
  140. package/skills/switch-branch.md +18 -0
  141. package/skills/update-cache.md +21 -0
  142. package/skills/update-workspace.md +66 -0
  143. package/skills/workspace-cleanup.md +18 -0
  144. package/skills/workspace-diff.md +23 -0
  145. package/skills/workspace-doctor.md +23 -0
  146. package/skills/workspace-info.md +45 -0
  147. package/skills/workspace-manager/SKILL.md +201 -0
  148. package/skills/workspace-manager/references/ai-prompt.md +44 -0
  149. package/skills/workspace-manager/references/analyze-deps.md +45 -0
  150. package/skills/workspace-manager/references/archive-workspace.md +38 -0
  151. package/skills/workspace-manager/references/branch-create.md +31 -0
  152. package/skills/workspace-manager/references/branch-merge.md +22 -0
  153. package/skills/workspace-manager/references/branch-rebase.md +22 -0
  154. package/skills/workspace-manager/references/cache-clear.md +18 -0
  155. package/skills/workspace-manager/references/cache-info.md +22 -0
  156. package/skills/workspace-manager/references/configure-claude.md +31 -0
  157. package/skills/workspace-manager/references/configure.md +35 -0
  158. package/skills/workspace-manager/references/create-workspace.md +45 -0
  159. package/skills/workspace-manager/references/dashboard.md +61 -0
  160. package/skills/workspace-manager/references/delete-workspace.md +33 -0
  161. package/skills/workspace-manager/references/generate-docs.md +36 -0
  162. package/skills/workspace-manager/references/ghq-status.md +28 -0
  163. package/skills/workspace-manager/references/go.md +33 -0
  164. package/skills/workspace-manager/references/history.md +41 -0
  165. package/skills/workspace-manager/references/list-org-repos.md +26 -0
  166. package/skills/workspace-manager/references/list-suites.md +21 -0
  167. package/skills/workspace-manager/references/list-workspaces.md +27 -0
  168. package/skills/workspace-manager/references/mcp.md +49 -0
  169. package/skills/workspace-manager/references/remove-repo.md +26 -0
  170. package/skills/workspace-manager/references/run-command.md +44 -0
  171. package/skills/workspace-manager/references/search-repos.md +24 -0
  172. package/skills/workspace-manager/references/sessions.md +27 -0
  173. package/skills/workspace-manager/references/suite-create.md +22 -0
  174. package/skills/workspace-manager/references/suite-delete.md +21 -0
  175. package/skills/workspace-manager/references/suite-export.md +19 -0
  176. package/skills/workspace-manager/references/suite-import.md +19 -0
  177. package/skills/workspace-manager/references/suite-use.md +27 -0
  178. package/skills/workspace-manager/references/switch-branch.md +25 -0
  179. package/skills/workspace-manager/references/update-cache.md +22 -0
  180. package/skills/workspace-manager/references/update-workspace.md +50 -0
  181. package/skills/workspace-manager/references/workspace-cleanup.md +33 -0
  182. package/skills/workspace-manager/references/workspace-diff.md +27 -0
  183. package/skills/workspace-manager/references/workspace-doctor.md +27 -0
  184. package/skills/workspace-manager/references/workspace-info.md +33 -0
  185. package/skills/workspace-manager/references/workspace-status.md +43 -0
  186. package/skills/workspace-manager/references/workspace-sync.md +26 -0
  187. package/skills/workspace-status.md +26 -0
  188. package/skills/workspace-sync.md +23 -0
  189. package/src/cli/ai-prompt.test.ts +667 -0
  190. package/src/cli/ai-prompt.ts +421 -0
  191. package/src/cli/dashboard/AgentList.tsx +44 -0
  192. package/src/cli/dashboard/DashboardSidebar.tsx +291 -0
  193. package/src/cli/dashboard/HelpFooter.tsx +22 -0
  194. package/src/cli/dashboard/StatusBadge.tsx +23 -0
  195. package/src/cli/dashboard/index.ts +5 -0
  196. package/src/cli/dashboard/types.ts +16 -0
  197. package/src/cli/tui.tsx +51 -0
  198. package/src/cli/workspace-cli.test.ts +30 -0
  199. package/src/commands/ai-prompt.ts +21 -0
  200. package/src/commands/analyze-deps.ts +119 -0
  201. package/src/commands/archive.ts +114 -0
  202. package/src/commands/branch/create.ts +94 -0
  203. package/src/commands/branch/index.ts +74 -0
  204. package/src/commands/branch/merge.ts +61 -0
  205. package/src/commands/branch/rebase.ts +54 -0
  206. package/src/commands/branch/switch.ts +150 -0
  207. package/src/commands/cache/index.ts +51 -0
  208. package/src/commands/cache/manager.ts +168 -0
  209. package/src/commands/cleanup.ts +132 -0
  210. package/src/commands/configure-claude.ts +55 -0
  211. package/src/commands/configure.ts +204 -0
  212. package/src/commands/create.ts +196 -0
  213. package/src/commands/dashboard/hook-handler.ts +138 -0
  214. package/src/commands/dashboard/index.ts +248 -0
  215. package/src/commands/dashboard/launcher.ts +42 -0
  216. package/src/commands/dashboard/session-picker.ts +73 -0
  217. package/src/commands/dashboard/sidebar.js +39 -0
  218. package/src/commands/dashboard/workspace-picker.ts +62 -0
  219. package/src/commands/delete.test.ts +250 -0
  220. package/src/commands/delete.ts +171 -0
  221. package/src/commands/deprecated-aliases.ts +55 -0
  222. package/src/commands/diff.ts +106 -0
  223. package/src/commands/doctor.ts +110 -0
  224. package/src/commands/generate-docs.ts +45 -0
  225. package/src/commands/ghq-status.ts +70 -0
  226. package/src/commands/go.ts +125 -0
  227. package/src/commands/history.ts +136 -0
  228. package/src/commands/list.test.ts +219 -0
  229. package/src/commands/list.ts +176 -0
  230. package/src/commands/mcp/index.ts +39 -0
  231. package/src/commands/migrate.ts +277 -0
  232. package/src/commands/remove-repo.ts +194 -0
  233. package/src/commands/report-bug.ts +88 -0
  234. package/src/commands/run.ts +125 -0
  235. package/src/commands/save-context.test.ts +113 -0
  236. package/src/commands/save-context.ts +141 -0
  237. package/src/commands/sessions.ts +99 -0
  238. package/src/commands/status.ts +111 -0
  239. package/src/commands/suite/create.ts +240 -0
  240. package/src/commands/suite/delete.ts +65 -0
  241. package/src/commands/suite/export.ts +97 -0
  242. package/src/commands/suite/import.ts +100 -0
  243. package/src/commands/suite/index.ts +66 -0
  244. package/src/commands/suite/list.ts +54 -0
  245. package/src/commands/suite/use.ts +232 -0
  246. package/src/commands/sync.ts +97 -0
  247. package/src/commands/update.ts +197 -0
  248. package/src/mcp/install.ts +529 -0
  249. package/src/mcp/server.ts +579 -0
  250. package/src/mcp/tools.test.ts +545 -0
  251. package/src/mcp/tools.ts +1019 -0
  252. package/src/mcp/update-workspace.test.ts +220 -0
  253. package/src/pi-extensions/permission-sync.ts.template +41 -0
  254. package/src/pi-extensions/permissions-gate.ts.template +69 -0
  255. package/src/pi-extensions/pr-fix-trigger.ts.template +124 -0
  256. package/src/pi-extensions/review-reminder.ts.template +58 -0
  257. package/src/pi-extensions/workspace-input-status.ts.template +919 -0
  258. package/src/program.ts +112 -0
  259. package/src/scripts/workspace-table.py +394 -0
  260. package/src/types/dashboard.ts +29 -0
  261. package/src/types/index.ts +109 -0
  262. package/src/types/ink.d.ts +171 -0
  263. package/src/utils/agent-config.test.ts +303 -0
  264. package/src/utils/agent-config.ts +264 -0
  265. package/src/utils/agent-state.test.ts +166 -0
  266. package/src/utils/agent-state.ts +135 -0
  267. package/src/utils/banner.ts +24 -0
  268. package/src/utils/branch-operations.ts +237 -0
  269. package/src/utils/bug-report.test.ts +174 -0
  270. package/src/utils/bug-report.ts +355 -0
  271. package/src/utils/cache.ts +150 -0
  272. package/src/utils/claude-integration.test.ts +381 -0
  273. package/src/utils/claude-integration.ts +649 -0
  274. package/src/utils/claude-sessions.test.ts +262 -0
  275. package/src/utils/claude-sessions.ts +251 -0
  276. package/src/utils/cleanup-operations.ts +94 -0
  277. package/src/utils/colors.ts +25 -0
  278. package/src/utils/command-helpers.ts +46 -0
  279. package/src/utils/config.test.ts +203 -0
  280. package/src/utils/config.ts +133 -0
  281. package/src/utils/context-file.ts +36 -0
  282. package/src/utils/dashboard-hooks.test.ts +168 -0
  283. package/src/utils/dashboard-hooks.ts +163 -0
  284. package/src/utils/dependency-analyzer.ts +243 -0
  285. package/src/utils/diff-operations.ts +59 -0
  286. package/src/utils/doc-generator.ts +187 -0
  287. package/src/utils/ghq-integration.test.ts +195 -0
  288. package/src/utils/ghq-integration.ts +317 -0
  289. package/src/utils/git-operations.test.ts +265 -0
  290. package/src/utils/git-operations.ts +233 -0
  291. package/src/utils/git-status.ts +171 -0
  292. package/src/utils/github.ts +74 -0
  293. package/src/utils/health-checks.ts +359 -0
  294. package/src/utils/history.ts +140 -0
  295. package/src/utils/hooks.ts +92 -0
  296. package/src/utils/logger.ts +37 -0
  297. package/src/utils/permission-sync.test.ts +593 -0
  298. package/src/utils/permission-sync.ts +720 -0
  299. package/src/utils/pi-extensions.test.ts +182 -0
  300. package/src/utils/pi-extensions.ts +226 -0
  301. package/src/utils/progress.ts +73 -0
  302. package/src/utils/prompts.test.ts +169 -0
  303. package/src/utils/prompts.ts +296 -0
  304. package/src/utils/repo-resolver.test.ts +313 -0
  305. package/src/utils/repo-resolver.ts +294 -0
  306. package/src/utils/retry.test.ts +124 -0
  307. package/src/utils/retry.ts +85 -0
  308. package/src/utils/run-operations.ts +38 -0
  309. package/src/utils/suite.test.ts +256 -0
  310. package/src/utils/suite.ts +144 -0
  311. package/src/utils/sync-operations.ts +87 -0
  312. package/src/utils/tmux-dashboard.ts +193 -0
  313. package/src/utils/validation.test.ts +139 -0
  314. package/src/utils/validation.ts +128 -0
  315. package/src/utils/version-check.test.ts +108 -0
  316. package/src/utils/version-check.ts +96 -0
  317. package/src/utils/workspace-meta.ts +173 -0
  318. package/sync-permissions.sh +137 -0
  319. package/tsconfig.json +20 -0
  320. package/uninstall.sh +162 -0
@@ -0,0 +1,667 @@
1
+ import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
2
+
3
+ const {
4
+ mockExec,
5
+ mockExecFileSync,
6
+ mockSpawn,
7
+ mockMkdirSync,
8
+ mockWriteFileSync,
9
+ } = vi.hoisted(() => ({
10
+ mockExec: vi.fn(),
11
+ mockExecFileSync: vi.fn(),
12
+ mockSpawn: vi.fn(),
13
+ mockMkdirSync: vi.fn(),
14
+ mockWriteFileSync: vi.fn(),
15
+ }));
16
+
17
+ vi.mock('child_process', () => ({
18
+ exec: mockExec,
19
+ execFileSync: mockExecFileSync,
20
+ spawn: mockSpawn,
21
+ }));
22
+
23
+ vi.mock('fs', () => ({
24
+ mkdirSync: mockMkdirSync,
25
+ writeFileSync: mockWriteFileSync,
26
+ }));
27
+
28
+ vi.mock('../utils/config', () => ({
29
+ WORKSPACES_DIR: '/test-workspaces',
30
+ getUserConfig: vi.fn().mockReturnValue({ installMcp: true, githubOrg: '' }),
31
+ }));
32
+
33
+ vi.mock('../utils/agent-config', () => ({
34
+ getPrimaryAgent: vi.fn().mockReturnValue({
35
+ type: 'claude',
36
+ launchCommand: 'claude',
37
+ resumeCommand: (id: string) => `claude --resume ${id} --fork-session`,
38
+ supportsMcp: true,
39
+ supportsHooks: true,
40
+ }),
41
+ isAgentCliAvailable: vi.fn().mockReturnValue(true),
42
+ }));
43
+
44
+ vi.mock('../utils/logger', () => ({
45
+ logInfo: vi.fn(),
46
+ logError: vi.fn(),
47
+ logStep: vi.fn(),
48
+ }));
49
+
50
+ vi.mock('../utils/colors', () => ({
51
+ colorize: (text: string) => text,
52
+ }));
53
+
54
+ import { main, run, buildExtractionPrompt, buildInvestigationPreamble, isClaudeAvailable, extractIntent, AI_PROMPT_FILE } from './ai-prompt';
55
+ import { logInfo, logError } from '../utils/logger';
56
+
57
+ function setupSpawnMock(exitCode: number = 0) {
58
+ const handlers: Record<string, Function> = {};
59
+ const child = {
60
+ on: vi.fn((event: string, cb: Function) => {
61
+ handlers[event] = cb;
62
+ if (event === 'exit') {
63
+ process.nextTick(() => cb(exitCode));
64
+ }
65
+ }),
66
+ };
67
+ mockSpawn.mockReturnValue(child);
68
+ return { child, handlers };
69
+ }
70
+
71
+ function setupExecMock(success: boolean) {
72
+ mockExec.mockImplementation((_cmd: string, cb: Function) => {
73
+ if (success) {
74
+ cb(null, { stdout: '/usr/local/bin/claude\n', stderr: '' });
75
+ } else {
76
+ cb(new Error('not found'), { stdout: '', stderr: '' });
77
+ }
78
+ });
79
+ }
80
+
81
+ function setupExtractMock(intent: { workspaceName: string; repos: string[]; remainingIntent: string; investigateFirst?: boolean }) {
82
+ mockExecFileSync.mockReturnValue(JSON.stringify({
83
+ type: 'result',
84
+ result: '',
85
+ structured_output: intent,
86
+ }));
87
+ }
88
+
89
+ describe('buildExtractionPrompt', () => {
90
+ it('includes the user prompt', () => {
91
+ const prompt = buildExtractionPrompt('create payments workspace');
92
+ expect(prompt).toContain('create payments workspace');
93
+ });
94
+
95
+ it('asks for workspace name and repo names', () => {
96
+ const prompt = buildExtractionPrompt('test');
97
+ expect(prompt).toContain('workspace name');
98
+ expect(prompt).toContain('repository names');
99
+ });
100
+
101
+ it('explains investigate-first extraction (investigateFirst + empty repos)', () => {
102
+ const prompt = buildExtractionPrompt('search logs and open the repos involved');
103
+ expect(prompt).toContain('investigateFirst');
104
+ expect(prompt.toLowerCase()).toContain('investigate');
105
+ });
106
+ });
107
+
108
+ describe('buildInvestigationPreamble', () => {
109
+ it('embeds the workspace name, task, and the add-repos workflow', () => {
110
+ const out = buildInvestigationPreamble('my-ws', 'look at trace abc and find the bug');
111
+ expect(out).toContain('my-ws');
112
+ expect(out).toContain('look at trace abc and find the bug');
113
+ // Tells the agent to discover then ADD repos to THIS workspace (CLI, always available)
114
+ expect(out).toContain('nemus update --workspace my-ws --repos');
115
+ expect(out.toLowerCase()).toContain('empty workspace');
116
+ });
117
+
118
+ it('is generic — mentions services and stripping env prefixes', () => {
119
+ const out = buildInvestigationPreamble('ws', 'task');
120
+ expect(out.toLowerCase()).toContain('service');
121
+ expect(out).toMatch(/production-|staging-|prefix/i);
122
+ });
123
+
124
+ it('defaults to the gh CLI for discovery (works for Pi / MCP-disabled agents)', () => {
125
+ const out = buildInvestigationPreamble('ws', 'task');
126
+ expect(out).toContain('gh search repos');
127
+ // Must NOT instruct MCP-only tools when MCP isn't available
128
+ expect(out).not.toContain('search-repos" tool');
129
+ });
130
+
131
+ it('uses the MCP discovery tools when useMcpTools is set', () => {
132
+ const out = buildInvestigationPreamble('ws', 'task', { useMcpTools: true });
133
+ expect(out).toContain('search-repos');
134
+ expect(out).toContain('list-org-repos');
135
+ expect(out).not.toContain('gh search repos');
136
+ });
137
+
138
+ it('omits --owner when no org is configured (default)', () => {
139
+ const out = buildInvestigationPreamble('ws', 'task');
140
+ expect(out).not.toContain('--owner');
141
+ expect(out).toContain('gh repo list --limit');
142
+ });
143
+
144
+ it('honours a configured githubOrg in the gh CLI discovery step', () => {
145
+ const out = buildInvestigationPreamble('ws', 'task', { githubOrg: 'acme-corp' });
146
+ expect(out).toContain('--owner acme-corp');
147
+ expect(out).toContain('gh repo list acme-corp');
148
+ });
149
+ });
150
+
151
+ describe('extractIntent', () => {
152
+ beforeEach(() => {
153
+ vi.clearAllMocks();
154
+ });
155
+
156
+ it('parses structured output from Claude', async () => {
157
+ setupExtractMock({
158
+ workspaceName: 'payments',
159
+ repos: ['acme-app', 'partnerships-api'],
160
+ remainingIntent: 'set up feature branch',
161
+ });
162
+
163
+ const result = await extractIntent('create payments workspace with acme-app and partnerships-api then set up feature branch');
164
+
165
+ expect(result.workspaceName).toBe('payments');
166
+ expect(result.repos).toEqual(['acme-app', 'partnerships-api']);
167
+ expect(result.remainingIntent).toBe('set up feature branch');
168
+ });
169
+
170
+ it('normalizes malformed field types instead of throwing (defensive coercion)', async () => {
171
+ // Model returns wrong types: numeric name, string (not array) repos, object remainingIntent.
172
+ mockExecFileSync.mockReturnValue(JSON.stringify({
173
+ type: 'result',
174
+ structured_output: { workspaceName: 123, repos: 'acme-app', remainingIntent: { x: 1 }, investigateFirst: 'yes' },
175
+ }));
176
+
177
+ const result = await extractIntent('do a thing');
178
+ expect(result.workspaceName).toBe(''); // non-string -> ''
179
+ expect(result.repos).toEqual([]); // non-array -> []
180
+ expect(result.remainingIntent).toBe(''); // non-string -> ''
181
+ expect(result.investigateFirst).toBe(false); // non-true -> false
182
+ });
183
+
184
+ it('keeps only string entries in a mixed repos array', async () => {
185
+ mockExecFileSync.mockReturnValue(JSON.stringify({
186
+ type: 'result',
187
+ structured_output: { workspaceName: 'ws', repos: ['a', 5, null, 'b'], remainingIntent: 't' },
188
+ }));
189
+ const result = await extractIntent('x');
190
+ expect(result.repos).toEqual(['a', 'b']);
191
+ });
192
+
193
+ it('throws an actionable error (not cryptic ETIMEDOUT) when the agent times out', async () => {
194
+ const timeoutErr: any = new Error('spawnSync pi ETIMEDOUT');
195
+ timeoutErr.code = 'ETIMEDOUT';
196
+ timeoutErr.killed = true;
197
+ mockExecFileSync.mockImplementation(() => { throw timeoutErr; });
198
+
199
+ await expect(extractIntent('create test workspace')).rejects.toThrow(/did not respond within \d+s/);
200
+ // Should include actionable guidance, not just ETIMEDOUT
201
+ await expect(extractIntent('create test workspace')).rejects.toThrow(/nemus create --workspace/);
202
+ });
203
+
204
+ it('surfaces agent stderr on a non-timeout failure', async () => {
205
+ const failErr: any = new Error('Command failed');
206
+ failErr.stderr = 'Bedrock: AccessDeniedException — token expired';
207
+ mockExecFileSync.mockImplementation(() => { throw failErr; });
208
+
209
+ await expect(extractIntent('create test workspace')).rejects.toThrow(/token expired/);
210
+ });
211
+
212
+ it('does not mislabel a maxBuffer overflow (killed=true, ENOBUFS) as a timeout', async () => {
213
+ const bufErr: any = new Error('stdout maxBuffer length exceeded');
214
+ bufErr.code = 'ENOBUFS';
215
+ bufErr.killed = true; // execFileSync sets killed on maxBuffer too
216
+ mockExecFileSync.mockImplementation(() => { throw bufErr; });
217
+
218
+ // Should NOT say "did not respond within" (that's the timeout message)
219
+ await expect(extractIntent('create test workspace')).rejects.toThrow(/maxBuffer exceeded/);
220
+ await expect(extractIntent('create test workspace')).rejects.not.toThrow(/did not respond within/);
221
+ });
222
+
223
+ it('falls back to parsing result string when structured_output is absent', async () => {
224
+ const intent = { workspaceName: 'test', repos: ['repo1'], remainingIntent: '' };
225
+ mockExecFileSync.mockReturnValue(JSON.stringify({
226
+ type: 'result',
227
+ result: JSON.stringify(intent),
228
+ }));
229
+
230
+ const result = await extractIntent('create test workspace');
231
+
232
+ expect(result.workspaceName).toBe('test');
233
+ expect(result.repos).toEqual(['repo1']);
234
+ });
235
+
236
+ it('calls claude lean (bare + strict-mcp + no-skills) with -p and --json-schema', async () => {
237
+ setupExtractMock({ workspaceName: 'test', repos: ['repo1'], remainingIntent: '' });
238
+
239
+ await extractIntent('create test workspace');
240
+
241
+ const args = mockExecFileSync.mock.calls[0][1] as string[];
242
+ expect(args).toContain('-p');
243
+ expect(args.some((a) => a.includes('create test workspace'))).toBe(true);
244
+ expect(args).toContain('--output-format');
245
+ expect(args).toContain('--json-schema');
246
+ // Lean flags so a heavy ~/.claude can't time out the extraction
247
+ for (const flag of ['--bare', '--strict-mcp-config', '--disable-slash-commands']) {
248
+ expect(args).toContain(flag);
249
+ }
250
+ expect(mockExecFileSync).toHaveBeenCalledWith(
251
+ 'claude',
252
+ expect.any(Array),
253
+ expect.objectContaining({ encoding: 'utf-8', timeout: 120_000 }),
254
+ );
255
+ });
256
+
257
+ it('falls back to plain `claude -p` when the lean flags are rejected', async () => {
258
+ // First call (lean/preferred) fails instantly as if --bare is unknown;
259
+ // second call (plain fallback) returns raw JSON text.
260
+ const unknownFlag: any = new Error("error: unknown option '--bare'");
261
+ unknownFlag.stderr = "error: unknown option '--bare'";
262
+ let call = 0;
263
+ mockExecFileSync.mockImplementation(() => {
264
+ call++;
265
+ if (call === 1) throw unknownFlag;
266
+ return JSON.stringify({ workspaceName: 'test', repos: ['repo1'], remainingIntent: '' });
267
+ });
268
+
269
+ const result = await extractIntent('create test workspace');
270
+
271
+ expect(call).toBe(2); // retried
272
+ expect(result.workspaceName).toBe('test');
273
+ expect(result.repos).toEqual(['repo1']);
274
+ // First attempt had the lean flags; the retry (fallback) did not.
275
+ const firstArgs = mockExecFileSync.mock.calls[0][1] as string[];
276
+ const secondArgs = mockExecFileSync.mock.calls[1][1] as string[];
277
+ expect(firstArgs).toContain('--bare');
278
+ expect(secondArgs).not.toContain('--bare');
279
+ expect(secondArgs).not.toContain('--json-schema');
280
+ expect(secondArgs).toContain('-p');
281
+ });
282
+
283
+ it('does NOT retry the fallback on a timeout (only on fast failures)', async () => {
284
+ const timeoutErr: any = new Error('spawnSync claude ETIMEDOUT');
285
+ timeoutErr.code = 'ETIMEDOUT';
286
+ let call = 0;
287
+ mockExecFileSync.mockImplementation(() => { call++; throw timeoutErr; });
288
+
289
+ await expect(extractIntent('create test workspace')).rejects.toThrow(/did not respond within/);
290
+ expect(call).toBe(1); // no fallback retry on timeout
291
+ });
292
+
293
+ describe('Pi agent', () => {
294
+ let getPrimaryAgentMock: ReturnType<typeof vi.fn>;
295
+
296
+ beforeEach(async () => {
297
+ const agentConfig = await import('../utils/agent-config');
298
+ getPrimaryAgentMock = vi.mocked(agentConfig.getPrimaryAgent);
299
+ getPrimaryAgentMock.mockReturnValue({
300
+ type: 'pi',
301
+ launchCommand: 'pi',
302
+ resumeCommand: (id: string) => `pi --session ${id} --fork`,
303
+ supportsMcp: false,
304
+ supportsHooks: false,
305
+ });
306
+ });
307
+
308
+ afterEach(() => {
309
+ getPrimaryAgentMock.mockReturnValue({
310
+ type: 'claude',
311
+ launchCommand: 'claude',
312
+ resumeCommand: (id: string) => `claude --resume ${id} --fork-session`,
313
+ supportsMcp: true,
314
+ supportsHooks: true,
315
+ });
316
+ });
317
+
318
+ it('calls pi lean (no extensions/skills/tools/context) with -p, no --mode json', async () => {
319
+ const intent = { workspaceName: 'rigs', repos: ['agentic-coding-rigs'], remainingIntent: '' };
320
+ mockExecFileSync.mockReturnValue(JSON.stringify(intent));
321
+
322
+ await extractIntent('create rigs workspace');
323
+
324
+ const args = mockExecFileSync.mock.calls[0][1] as string[];
325
+ // -p with the prompt is still present
326
+ expect(args).toContain('-p');
327
+ expect(args.some((a) => a.includes('create rigs workspace'))).toBe(true);
328
+ // Lean flags must be present so a bloated pi setup can't time out
329
+ for (const flag of ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-context-files', '--no-tools', '--no-session']) {
330
+ expect(args).toContain(flag);
331
+ }
332
+ expect(mockExecFileSync).toHaveBeenCalledWith(
333
+ 'pi',
334
+ expect.any(Array),
335
+ expect.objectContaining({ encoding: 'utf-8', timeout: 120_000 }),
336
+ );
337
+ // Should NOT include --mode json
338
+ expect(args).not.toContain('--mode');
339
+ expect(args).not.toContain('json');
340
+ });
341
+
342
+ it('parses Pi raw JSON output directly', async () => {
343
+ const intent = { workspaceName: 'rigs', repos: ['agentic-coding-rigs'], remainingIntent: 'read the readme' };
344
+ mockExecFileSync.mockReturnValue(JSON.stringify(intent));
345
+
346
+ const result = await extractIntent('create rigs workspace');
347
+
348
+ expect(result.workspaceName).toBe('rigs');
349
+ expect(result.repos).toEqual(['agentic-coding-rigs']);
350
+ expect(result.remainingIntent).toBe('read the readme');
351
+ });
352
+
353
+ it('strips markdown code fences from Pi response', async () => {
354
+ const intent = { workspaceName: 'rigs', repos: ['agentic-coding-rigs'], remainingIntent: '' };
355
+ mockExecFileSync.mockReturnValue('```json\n' + JSON.stringify(intent) + '\n```');
356
+
357
+ const result = await extractIntent('create rigs workspace');
358
+
359
+ expect(result.workspaceName).toBe('rigs');
360
+ expect(result.repos).toEqual(['agentic-coding-rigs']);
361
+ });
362
+
363
+ it('strips code fences without language tag', async () => {
364
+ const intent = { workspaceName: 'test', repos: ['repo1'], remainingIntent: '' };
365
+ mockExecFileSync.mockReturnValue('```\n' + JSON.stringify(intent) + '\n```');
366
+
367
+ const result = await extractIntent('create test workspace');
368
+
369
+ expect(result.workspaceName).toBe('test');
370
+ });
371
+
372
+ it('handles Pi output with extra whitespace', async () => {
373
+ const intent = { workspaceName: 'test', repos: ['repo1'], remainingIntent: '' };
374
+ mockExecFileSync.mockReturnValue('\n\n' + JSON.stringify(intent) + '\n\n');
375
+
376
+ const result = await extractIntent('create test workspace');
377
+
378
+ expect(result.workspaceName).toBe('test');
379
+ });
380
+ });
381
+ });
382
+
383
+ describe('isClaudeAvailable', () => {
384
+ beforeEach(() => {
385
+ vi.clearAllMocks();
386
+ });
387
+
388
+ it('returns true when claude CLI is found', async () => {
389
+ setupExecMock(true);
390
+ const result = await isClaudeAvailable();
391
+ expect(result).toBe(true);
392
+ });
393
+
394
+ it('returns false when claude CLI is not found', async () => {
395
+ setupExecMock(false);
396
+ const result = await isClaudeAvailable();
397
+ expect(result).toBe(false);
398
+ });
399
+ });
400
+
401
+ describe('run', () => {
402
+ beforeEach(() => {
403
+ vi.clearAllMocks();
404
+ });
405
+
406
+ it('returns 1 when claude CLI is not available', async () => {
407
+ setupExecMock(false);
408
+
409
+ const exitCode = await run('create a workspace');
410
+
411
+ expect(exitCode).toBe(1);
412
+ expect(logError).toHaveBeenCalledWith(expect.stringContaining('CLI not found'));
413
+ expect(mockSpawn).not.toHaveBeenCalled();
414
+ });
415
+
416
+ it('extracts intent then spawns nemus create with correct flags', async () => {
417
+ setupExecMock(true);
418
+ setupExtractMock({
419
+ workspaceName: 'payments',
420
+ repos: ['acme-app', 'partnerships-api'],
421
+ remainingIntent: '',
422
+ });
423
+ setupSpawnMock(0);
424
+
425
+ await run('create payments workspace with acme-app and partnerships-api');
426
+
427
+ expect(mockSpawn).toHaveBeenCalledWith(
428
+ 'nemus', ['create', '--workspace', 'payments', '--repos', 'acme-app,partnerships-api', '--prompt', 'create payments workspace with acme-app and partnerships-api', '--yes'],
429
+ { stdio: 'inherit' },
430
+ );
431
+ });
432
+
433
+ it('investigate-first: spawns create --allow-empty (no --repos) when investigateFirst', async () => {
434
+ setupExecMock(true);
435
+ setupExtractMock({
436
+ workspaceName: 'ocr-bug',
437
+ repos: [],
438
+ investigateFirst: true,
439
+ remainingIntent: 'search the logs for the OCR error and open the repos involved',
440
+ });
441
+ setupSpawnMock(0);
442
+
443
+ await run('find which repos are behind the OCR errors');
444
+
445
+ const [, args] = mockSpawn.mock.calls[0];
446
+ expect(args).toContain('--allow-empty');
447
+ expect(args).not.toContain('--repos');
448
+ expect(args.slice(0, 4)).toEqual(['create', '--workspace', 'ocr-bug', '--allow-empty']);
449
+ });
450
+
451
+ it('does NOT go investigate-first (empty) when the model returned repos, even with investigateFirst set', async () => {
452
+ setupExecMock(true);
453
+ setupExtractMock({
454
+ workspaceName: 'ocr-bug',
455
+ repos: ['acme-app', 'ocr-service'],
456
+ investigateFirst: true, // must NOT discard the repos it already found
457
+ remainingIntent: 'fix the OCR 500s',
458
+ });
459
+ setupSpawnMock(0);
460
+
461
+ await run('fix the OCR 500s in acme-app and ocr-service');
462
+
463
+ const [, args] = mockSpawn.mock.calls[0];
464
+ expect(args).not.toContain('--allow-empty');
465
+ expect(args).toContain('--repos');
466
+ expect(args).toContain('acme-app,ocr-service');
467
+ });
468
+
469
+ it('investigate-first: preamble + create use the SAME sanitized workspace name (no drift)', async () => {
470
+ setupExecMock(true);
471
+ setupExtractMock({
472
+ workspaceName: 'OCR Bug', // needs sanitizing -> 'ocr-bug'
473
+ repos: [],
474
+ investigateFirst: true,
475
+ remainingIntent: 'trace the OCR 500s',
476
+ });
477
+ setupSpawnMock(0);
478
+
479
+ await run('investigate the OCR 500s');
480
+
481
+ const [, args] = mockSpawn.mock.calls[0];
482
+ const wsIdx = args.indexOf('--workspace');
483
+ const spawnedName = args[wsIdx + 1];
484
+ expect(spawnedName).toBe('ocr-bug');
485
+ const seeded = mockWriteFileSync.mock.calls.find((c) => c[0] === AI_PROMPT_FILE);
486
+ expect(seeded![1]).toContain(`nemus update --workspace ${spawnedName} --repos`);
487
+ expect(seeded![1]).not.toContain('OCR Bug');
488
+ });
489
+
490
+ it('investigate-first: seeds AI_PROMPT_FILE with the discover-then-add workflow', async () => {
491
+ setupExecMock(true);
492
+ setupExtractMock({
493
+ workspaceName: 'ocr-bug',
494
+ repos: [],
495
+ investigateFirst: true,
496
+ remainingIntent: 'trace the OCR 500s',
497
+ });
498
+ setupSpawnMock(0);
499
+
500
+ await run('investigate the OCR 500s and pull the code');
501
+
502
+ const seeded = mockWriteFileSync.mock.calls.find((c) => c[0] === AI_PROMPT_FILE);
503
+ expect(seeded).toBeDefined();
504
+ expect(seeded![1]).toContain('trace the OCR 500s');
505
+ expect(seeded![1]).toContain('nemus update --workspace ocr-bug --repos');
506
+ });
507
+
508
+ it('ensures WORKSPACES_DIR exists before extracting intent', async () => {
509
+ setupExecMock(true);
510
+ setupExtractMock({ workspaceName: 'test', repos: ['repo'], remainingIntent: '' });
511
+ setupSpawnMock(0);
512
+
513
+ await run('create test workspace');
514
+
515
+ expect(mockMkdirSync).toHaveBeenCalledWith('/test-workspaces', { recursive: true });
516
+ });
517
+
518
+ it('returns 1 when intent extraction fails', async () => {
519
+ setupExecMock(true);
520
+ mockExecFileSync.mockImplementation(() => { throw new Error('timeout'); });
521
+
522
+ const exitCode = await run('create workspace');
523
+
524
+ expect(exitCode).toBe(1);
525
+ expect(logError).toHaveBeenCalledWith('Failed to parse workspace request');
526
+ });
527
+
528
+ it('returns 1 when no repos extracted', async () => {
529
+ setupExecMock(true);
530
+ setupExtractMock({ workspaceName: 'test', repos: [], remainingIntent: '' });
531
+
532
+ const exitCode = await run('create empty workspace');
533
+
534
+ expect(exitCode).toBe(1);
535
+ expect(logError).toHaveBeenCalledWith('Could not determine workspace name or repos from prompt');
536
+ });
537
+
538
+ it('returns 1 when repos is null or missing', async () => {
539
+ setupExecMock(true);
540
+ mockExecFileSync.mockReturnValue(JSON.stringify({
541
+ type: 'result',
542
+ result: '',
543
+ structured_output: { workspaceName: 'test', repos: null, remainingIntent: '' },
544
+ }));
545
+
546
+ const exitCode = await run('create test workspace');
547
+
548
+ expect(exitCode).toBe(1);
549
+ expect(logError).toHaveBeenCalledWith('Could not determine workspace name or repos from prompt');
550
+ });
551
+
552
+ it('saves remaining intent to AI_PROMPT_FILE', async () => {
553
+ setupExecMock(true);
554
+ setupExtractMock({
555
+ workspaceName: 'payments',
556
+ repos: ['acme-app'],
557
+ remainingIntent: 'fix the login bug',
558
+ });
559
+ setupSpawnMock(0);
560
+
561
+ await run('create payments workspace with acme-app and fix the login bug');
562
+
563
+ expect(mockWriteFileSync).toHaveBeenCalledWith(
564
+ AI_PROMPT_FILE,
565
+ 'fix the login bug',
566
+ 'utf-8',
567
+ );
568
+ });
569
+
570
+ it('saves full prompt as remaining intent when remainingIntent is empty', async () => {
571
+ setupExecMock(true);
572
+ setupExtractMock({
573
+ workspaceName: 'payments',
574
+ repos: ['acme-app'],
575
+ remainingIntent: '',
576
+ });
577
+ setupSpawnMock(0);
578
+
579
+ await run('create payments workspace with acme-app');
580
+
581
+ expect(mockWriteFileSync).toHaveBeenCalledWith(
582
+ AI_PROMPT_FILE,
583
+ 'create payments workspace with acme-app',
584
+ 'utf-8',
585
+ );
586
+ });
587
+
588
+ it('propagates non-zero exit code from nemus create', async () => {
589
+ setupExecMock(true);
590
+ setupExtractMock({ workspaceName: 'test', repos: ['repo'], remainingIntent: '' });
591
+ setupSpawnMock(1);
592
+
593
+ const exitCode = await run('create test workspace');
594
+
595
+ expect(exitCode).toBe(1);
596
+ });
597
+
598
+ it('truncates long prompts in log output', async () => {
599
+ const longPrompt = 'a'.repeat(100);
600
+ setupExecMock(true);
601
+ setupExtractMock({ workspaceName: 'test', repos: ['repo'], remainingIntent: '' });
602
+ setupSpawnMock(0);
603
+
604
+ await run(longPrompt);
605
+
606
+ expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('...'));
607
+ });
608
+ });
609
+
610
+ describe('main', () => {
611
+ const originalArgv = process.argv;
612
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
613
+
614
+ beforeEach(() => {
615
+ vi.clearAllMocks();
616
+ process.argv = ['node', 'ai-prompt.js'];
617
+ mockExit.mockClear();
618
+ });
619
+
620
+ afterAll(() => {
621
+ process.argv = originalArgv;
622
+ mockExit.mockRestore();
623
+ });
624
+
625
+ it('exits with error when no prompt is provided', async () => {
626
+ process.argv = ['node', 'ai-prompt.js'];
627
+
628
+ await main();
629
+
630
+ expect(logError).toHaveBeenCalledWith('No prompt provided');
631
+ expect(mockExit).toHaveBeenCalledWith(1);
632
+ expect(mockSpawn).not.toHaveBeenCalled();
633
+ });
634
+
635
+ it('exits with error when prompt is empty string', async () => {
636
+ process.argv = ['node', 'ai-prompt.js', ''];
637
+
638
+ await main();
639
+
640
+ expect(logError).toHaveBeenCalledWith('No prompt provided');
641
+ expect(mockExit).toHaveBeenCalledWith(1);
642
+ });
643
+
644
+ it('exits with error when prompt is whitespace only', async () => {
645
+ process.argv = ['node', 'ai-prompt.js', ' '];
646
+
647
+ await main();
648
+
649
+ expect(logError).toHaveBeenCalledWith('No prompt provided');
650
+ expect(mockExit).toHaveBeenCalledWith(1);
651
+ });
652
+
653
+ it('joins multiple argv words into a single prompt', async () => {
654
+ process.argv = ['node', 'ai-prompt.js', 'create', 'a', 'workspace'];
655
+ setupExecMock(true);
656
+ setupExtractMock({ workspaceName: 'workspace', repos: ['some-repo'], remainingIntent: '' });
657
+ setupSpawnMock(0);
658
+
659
+ await main();
660
+
661
+ expect(mockExecFileSync).toHaveBeenCalledWith(
662
+ 'claude',
663
+ expect.arrayContaining(['-p', expect.stringContaining('create a workspace')]),
664
+ expect.any(Object),
665
+ );
666
+ });
667
+ });