@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,1019 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import fuzzy from 'fuzzy';
5
+ import { resolveRepoNames } from '../utils/repo-resolver';
6
+ import { getCloneUrl } from '../utils/config';
7
+ import { formatContextFile, appendToContextFile } from '../utils/context-file';
8
+ import { listWorkspaces, loadMetadata, createMetadata, saveMetadata, archiveWorkspace, unarchiveWorkspace } from '../utils/workspace-meta';
9
+ import { getAllReposStatus } from '../utils/git-status';
10
+ import { getRepoDiff, RepoDiff } from '../utils/diff-operations';
11
+ import { syncRepository, SyncResult } from '../utils/sync-operations';
12
+ import { runInRepo, RunResult } from '../utils/run-operations';
13
+ import { listSuites, getSuite, saveSuite, deleteSuite, exportSuite, exportAllSuites, importSuites, validateSuiteName } from '../utils/suite';
14
+ import { fetchOrgRepos } from '../utils/github';
15
+ import { cloneRepositories, reportCloneResults } from '../utils/git-operations';
16
+ import { generateClaudeContext } from '../utils/claude-integration';
17
+ import { runAllHealthChecks, calculateHealthScore } from '../utils/health-checks';
18
+ import { analyzeDependencies, detectCircularDependencies } from '../utils/dependency-analyzer';
19
+ import { createBranch, switchBranch, mergeBranch, rebaseBranch } from '../utils/branch-operations';
20
+ import { removeNodeModules, removeBuildArtifacts } from '../utils/cleanup-operations';
21
+ import { runPostCloneHooks } from '../utils/hooks';
22
+ import { SuiteEntry, WorkspaceSuite, SuitesStore } from '../types';
23
+ import { resolveWorkspaceNameConflict, sanitizeWorkspaceName, safeWorkspacePath } from '../utils/validation';
24
+
25
+ /**
26
+ * Redirects stdout to stderr for the duration of a function call.
27
+ * MCP uses stdout exclusively for JSON-RPC, so any console.log from
28
+ * utility functions (logInfo, logSuccess, etc.) would corrupt the protocol.
29
+ */
30
+ async function withStdoutProtection<T>(fn: () => Promise<T>): Promise<T> {
31
+ const originalWrite = process.stdout.write;
32
+ process.stdout.write = process.stderr.write.bind(process.stderr);
33
+ try {
34
+ return await fn();
35
+ } finally {
36
+ process.stdout.write = originalWrite;
37
+ }
38
+ }
39
+
40
+ export async function handleListWorkspaces(includeArchived: boolean = false) {
41
+ return withStdoutProtection(async () => {
42
+ const workspaces = await listWorkspaces(includeArchived);
43
+ return workspaces.map(ws => ({
44
+ name: ws.name,
45
+ path: ws.path,
46
+ repoCount: ws.metadata?.repositories.filter(r => r.status === 'success').length ?? 0,
47
+ createdAt: ws.metadata?.createdAt ?? null,
48
+ archivedAt: ws.metadata?.archivedAt ?? null,
49
+ }));
50
+ });
51
+ }
52
+
53
+ export async function handleArchiveWorkspace(workspace: string) {
54
+ return withStdoutProtection(async () => {
55
+ await archiveWorkspace(workspace);
56
+ return {
57
+ workspace,
58
+ status: 'archived',
59
+ message: `Workspace "${workspace}" has been archived. It will be auto-deleted in 30 days.`,
60
+ };
61
+ });
62
+ }
63
+
64
+ export async function handleUnarchiveWorkspace(workspace: string) {
65
+ return withStdoutProtection(async () => {
66
+ await unarchiveWorkspace(workspace);
67
+ return {
68
+ workspace,
69
+ status: 'active',
70
+ message: `Workspace "${workspace}" has been unarchived and is now active.`,
71
+ };
72
+ });
73
+ }
74
+
75
+ export async function handleWorkspaceStatus(workspace: string) {
76
+ return withStdoutProtection(async () => {
77
+ const workspacePath = safeWorkspacePath(workspace);
78
+ const metadata = await loadMetadata(workspacePath);
79
+
80
+ if (!metadata) {
81
+ throw new Error(`Workspace not found: ${workspace}`);
82
+ }
83
+
84
+ const repos = metadata.repositories.filter(r => r.status === 'success');
85
+ const repoNames = repos.map(r => r.directoryName);
86
+ const statuses = await getAllReposStatus(workspacePath, repoNames);
87
+
88
+ return {
89
+ workspace,
90
+ repos: statuses,
91
+ };
92
+ });
93
+ }
94
+
95
+ export async function handleWorkspaceDiff(workspace: string, full: boolean = false) {
96
+ return withStdoutProtection(async () => {
97
+ const workspacePath = safeWorkspacePath(workspace);
98
+ const metadata = await loadMetadata(workspacePath);
99
+
100
+ if (!metadata) {
101
+ throw new Error(`Workspace not found: ${workspace}`);
102
+ }
103
+
104
+ const repos = metadata.repositories.filter(r => r.status === 'success');
105
+ const diffs: RepoDiff[] = [];
106
+
107
+ for (const repo of repos) {
108
+ const repoPath = path.join(workspacePath, repo.directoryName);
109
+ const diff = await getRepoDiff(repoPath, repo.directoryName);
110
+ diffs.push(diff);
111
+ }
112
+
113
+ const totalStaged = diffs.reduce((sum, d) => sum + d.staged.files, 0);
114
+ const totalUnstaged = diffs.reduce((sum, d) => sum + d.unstaged.files, 0);
115
+ const reposWithChanges = diffs.filter(d => d.staged.files > 0 || d.unstaged.files > 0).length;
116
+
117
+ return {
118
+ workspace,
119
+ diffs,
120
+ summary: { totalStaged, totalUnstaged, reposWithChanges },
121
+ };
122
+ });
123
+ }
124
+
125
+ export async function handleWorkspaceSync(workspace: string) {
126
+ return withStdoutProtection(async () => {
127
+ const workspacePath = safeWorkspacePath(workspace);
128
+ const metadata = await loadMetadata(workspacePath);
129
+
130
+ if (!metadata) {
131
+ throw new Error(`Workspace not found: ${workspace}`);
132
+ }
133
+
134
+ const repos = metadata.repositories.filter(r => r.status === 'success');
135
+ const results: SyncResult[] = [];
136
+
137
+ for (const repo of repos) {
138
+ const repoPath = path.join(workspacePath, repo.directoryName);
139
+ const result = await syncRepository(repoPath, repo.directoryName, true);
140
+ results.push(result);
141
+ }
142
+
143
+ const successful = results.filter(r => r.status === 'success').length;
144
+ const failed = results.filter(r => r.status === 'failed').length;
145
+ const skipped = results.filter(r => r.status === 'skipped').length;
146
+
147
+ return {
148
+ workspace,
149
+ results,
150
+ summary: { successful, failed, skipped },
151
+ };
152
+ });
153
+ }
154
+
155
+ export async function handleRunCommand(workspace: string, command: string) {
156
+ return withStdoutProtection(async () => {
157
+ const workspacePath = safeWorkspacePath(workspace);
158
+ const metadata = await loadMetadata(workspacePath);
159
+
160
+ if (!metadata) {
161
+ throw new Error(`Workspace not found: ${workspace}`);
162
+ }
163
+
164
+ const repos = metadata.repositories.filter(r => r.status === 'success');
165
+ const results: RunResult[] = [];
166
+ const concurrency = 3;
167
+
168
+ for (let i = 0; i < repos.length; i += concurrency) {
169
+ const chunk = repos.slice(i, i + concurrency);
170
+ const chunkResults = await Promise.all(
171
+ chunk.map(repo => {
172
+ const repoPath = path.join(workspacePath, repo.directoryName);
173
+ return runInRepo(repoPath, repo.directoryName, command);
174
+ })
175
+ );
176
+ results.push(...chunkResults);
177
+ }
178
+
179
+ const successful = results.filter(r => r.status === 'success').length;
180
+ const failed = results.filter(r => r.status === 'failed').length;
181
+
182
+ return {
183
+ workspace,
184
+ command,
185
+ results,
186
+ summary: { successful, failed },
187
+ };
188
+ });
189
+ }
190
+
191
+ export async function handleListSuites() {
192
+ return withStdoutProtection(async () => {
193
+ const suites = await listSuites();
194
+ return suites.map(s => ({
195
+ name: s.name,
196
+ description: s.description,
197
+ repoCount: s.entries.length,
198
+ hasHooks: (s.postCloneHooks?.length ?? 0) > 0,
199
+ createdAt: s.createdAt,
200
+ updatedAt: s.updatedAt,
201
+ }));
202
+ });
203
+ }
204
+
205
+ export async function handleWorkspaceInfo(workspace: string) {
206
+ return withStdoutProtection(async () => {
207
+ const workspacePath = safeWorkspacePath(workspace);
208
+ const metadata = await loadMetadata(workspacePath);
209
+
210
+ if (!metadata) {
211
+ throw new Error(`Workspace not found: ${workspace}`);
212
+ }
213
+
214
+ return {
215
+ ...metadata,
216
+ path: workspacePath,
217
+ };
218
+ });
219
+ }
220
+
221
+ export async function handleUpdateWorkspace(workspace: string, repos: Array<string | { name: string; suffix: string }>) {
222
+ return withStdoutProtection(async () => {
223
+ if (!workspace || workspace.trim().length === 0) {
224
+ throw new Error('Workspace name is required');
225
+ }
226
+ if (!repos || repos.length === 0) {
227
+ throw new Error('At least one repository name is required');
228
+ }
229
+
230
+ const workspacePath = safeWorkspacePath(workspace);
231
+ const metadata = await loadMetadata(workspacePath);
232
+
233
+ if (!metadata) {
234
+ throw new Error(`Workspace not found: ${workspace}`);
235
+ }
236
+
237
+ const existingDirectoryNames = metadata.repositories.map(r => r.directoryName);
238
+
239
+ // Fetch all repos to resolve names to full repo objects
240
+ const allRepos = await fetchOrgRepos();
241
+ const repoEntries: Array<{ repo: typeof allRepos[0]; directoryName: string }> = [];
242
+ const notFound: string[] = [];
243
+ const alreadyExists: string[] = [];
244
+ const fuzzyMatched: Array<{ requested: string; resolved: string }> = [];
245
+
246
+ for (const repoInput of repos) {
247
+ const repoName = typeof repoInput === 'string' ? repoInput : repoInput.name;
248
+ const suffix = typeof repoInput === 'string' ? undefined : repoInput.suffix;
249
+
250
+ const { resolved, notFound: missing } = resolveRepoNames([repoName], allRepos);
251
+ if (missing.length > 0) {
252
+ notFound.push(repoName);
253
+ continue;
254
+ }
255
+ const found = resolved[0].repo;
256
+ if (!resolved[0].exact) {
257
+ fuzzyMatched.push({ requested: repoName, resolved: found.name });
258
+ }
259
+
260
+ if (suffix && !/^[a-zA-Z0-9_-]+$/.test(suffix)) {
261
+ throw new Error(`Invalid suffix "${suffix}" for repo "${repoName}". Only alphanumeric characters, hyphens, and underscores are allowed.`);
262
+ }
263
+
264
+ const directoryName = suffix ? `${found.name}-${suffix}` : found.name;
265
+
266
+ if (existingDirectoryNames.includes(directoryName)) {
267
+ alreadyExists.push(suffix ? `${repoName} (as ${directoryName})` : repoName);
268
+ continue;
269
+ }
270
+
271
+ existingDirectoryNames.push(directoryName);
272
+ repoEntries.push({ repo: found, directoryName });
273
+ }
274
+
275
+ if (repoEntries.length === 0) {
276
+ const reasons: string[] = [];
277
+ if (notFound.length > 0) reasons.push(`not found: ${notFound.join(', ')}`);
278
+ if (alreadyExists.length > 0) reasons.push(`already in workspace: ${alreadyExists.join(', ')}`);
279
+ throw new Error(`No new repos to add (${reasons.join('; ')})`);
280
+ }
281
+
282
+ // Clone new repositories
283
+ const cloneResults = await cloneRepositories(repoEntries, workspacePath);
284
+
285
+ // Update metadata
286
+ const newRepoMetadata = cloneResults.map(result => ({
287
+ name: result.repo.name,
288
+ directoryName: result.directoryName,
289
+ owner: result.repo.owner.login,
290
+ clonedAt: result.clonedAt || new Date().toISOString(),
291
+ cloneUrl: getCloneUrl(result.repo),
292
+ status: result.status as 'success' | 'failed',
293
+ error: result.error,
294
+ }));
295
+
296
+ metadata.repositories.push(...newRepoMetadata);
297
+ metadata.lastModified = new Date().toISOString();
298
+ await saveMetadata(workspacePath, metadata);
299
+
300
+ const successful = cloneResults.filter(r => r.status === 'success');
301
+ const failed = cloneResults.filter(r => r.status === 'failed');
302
+
303
+ // Regenerate the agent context (CLAUDE.md/AGENTS.md) + .mcp.json so the
304
+ // newly added repos surface — otherwise an investigate-first workspace's
305
+ // context keeps saying "0 repositories". Resolve the FULL set (existing +
306
+ // new) from the catalog so pre-existing repos keep their descriptions/URLs
307
+ // on regenerate (matches the remove-repo path). Best-effort.
308
+ const contextRepoObjects = metadata.repositories
309
+ .filter(r => r.status === 'success')
310
+ .map(r => allRepos.find(ar => ar.name === r.name))
311
+ .filter((r): r is NonNullable<typeof r> => r != null);
312
+ // Only regenerate when this update actually added a repo — a failure-only
313
+ // update (all new clones failed) must not rewrite the existing context.
314
+ try {
315
+ if (successful.length > 0 && contextRepoObjects.length > 0) {
316
+ await generateClaudeContext(workspacePath, workspace, contextRepoObjects, metadata);
317
+ }
318
+ } catch { /* context regen is best-effort */ }
319
+
320
+ return {
321
+ workspace,
322
+ path: workspacePath,
323
+ added: cloneResults.map(r => ({
324
+ name: r.repo.name,
325
+ directoryName: r.directoryName,
326
+ status: r.status,
327
+ error: r.error,
328
+ })),
329
+ summary: {
330
+ successful: successful.length,
331
+ failed: failed.length,
332
+ totalRepos: metadata.repositories.filter(r => r.status === 'success').length,
333
+ },
334
+ alreadyExists: alreadyExists.length > 0 ? alreadyExists : undefined,
335
+ notFound: notFound.length > 0 ? notFound : undefined,
336
+ fuzzyMatched: fuzzyMatched.length > 0 ? fuzzyMatched : undefined,
337
+ };
338
+ });
339
+ }
340
+
341
+ export async function handleUpdateCache() {
342
+ return withStdoutProtection(async () => {
343
+ const repos = await fetchOrgRepos({ forceRefresh: true });
344
+ return {
345
+ repos: repos.length,
346
+ message: `Cache updated with ${repos.length} repositories`,
347
+ };
348
+ });
349
+ }
350
+
351
+ export async function handleCreateWorkspace(workspace: string, repos: string[]) {
352
+ return withStdoutProtection(async () => {
353
+ if (!workspace || workspace.trim().length === 0) {
354
+ throw new Error('Workspace name is required');
355
+ }
356
+ if (!repos || repos.length === 0) {
357
+ throw new Error('At least one repository name is required');
358
+ }
359
+
360
+ const sanitizedWorkspace = sanitizeWorkspaceName(workspace);
361
+ const workspacePath = safeWorkspacePath(sanitizedWorkspace);
362
+
363
+ // Auto-resolve workspace name conflict instead of throwing
364
+ let finalWorkspaceName = sanitizedWorkspace;
365
+ let wsExists = false;
366
+ try {
367
+ await fs.access(workspacePath);
368
+ wsExists = true;
369
+ } catch {
370
+ // Directory doesn't exist — good, use the original name
371
+ }
372
+ if (wsExists) {
373
+ // Workspace already exists — resolve to a unique name using repo names as hints
374
+ // (errors from resolveWorkspaceNameConflict intentionally propagate)
375
+ finalWorkspaceName = await resolveWorkspaceNameConflict(sanitizedWorkspace, repos);
376
+ }
377
+
378
+ // Fetch all repos to resolve names to full repo objects
379
+ const allRepos = await fetchOrgRepos();
380
+ const repoEntries: Array<{ repo: typeof allRepos[0]; directoryName: string }> = [];
381
+ const notFound: string[] = [];
382
+
383
+ const { resolved, notFound: missing } = resolveRepoNames(repos, allRepos);
384
+ notFound.push(...missing);
385
+ const fuzzyMatched: Array<{ requested: string; resolved: string }> = [];
386
+ for (const r of resolved) {
387
+ repoEntries.push({ repo: r.repo, directoryName: r.repo.name });
388
+ if (!r.exact) fuzzyMatched.push({ requested: r.input, resolved: r.repo.name });
389
+ }
390
+
391
+ if (repoEntries.length === 0) {
392
+ throw new Error(`None of the specified repos were found: ${notFound.join(', ')}`);
393
+ }
394
+
395
+ // Create workspace directory
396
+ const finalWorkspacePath = safeWorkspacePath(finalWorkspaceName);
397
+ await fs.mkdir(finalWorkspacePath, { recursive: true });
398
+
399
+ // Clone repositories
400
+ const cloneResults = await cloneRepositories(repoEntries, finalWorkspacePath);
401
+
402
+ // Save metadata
403
+ const metadata = createMetadata(finalWorkspaceName, cloneResults);
404
+ await saveMetadata(finalWorkspacePath, metadata);
405
+
406
+ // Generate Claude context
407
+ const successfulRepos = cloneResults
408
+ .filter(r => r.status === 'success')
409
+ .map(r => r.repo);
410
+
411
+ if (successfulRepos.length > 0) {
412
+ await generateClaudeContext(finalWorkspacePath, finalWorkspaceName, successfulRepos, metadata);
413
+ }
414
+
415
+ const successful = cloneResults.filter(r => r.status === 'success');
416
+ const failed = cloneResults.filter(r => r.status === 'failed');
417
+
418
+ // Write workspace path to temp file so shell integration can cd to it
419
+ if (successful.length > 0) {
420
+ const tempFile = path.join(os.homedir(), '.workspace-last-created');
421
+ await fs.writeFile(tempFile, finalWorkspacePath, 'utf-8').catch(() => {});
422
+ }
423
+
424
+ return {
425
+ workspace: finalWorkspaceName,
426
+ originalWorkspaceName: finalWorkspaceName !== sanitizedWorkspace ? sanitizedWorkspace : undefined,
427
+ path: finalWorkspacePath,
428
+ repos: cloneResults.map(r => ({
429
+ name: r.repo.name,
430
+ directoryName: r.directoryName,
431
+ status: r.status,
432
+ error: r.error,
433
+ })),
434
+ summary: {
435
+ successful: successful.length,
436
+ failed: failed.length,
437
+ },
438
+ notFound: notFound.length > 0 ? notFound : undefined,
439
+ fuzzyMatched: fuzzyMatched.length > 0 ? fuzzyMatched : undefined,
440
+ };
441
+ });
442
+ }
443
+
444
+ export async function handleSearchRepos(query: string, limit: number = 20) {
445
+ return withStdoutProtection(async () => {
446
+ const allRepos = await fetchOrgRepos();
447
+ const results = fuzzy.filter(query, allRepos, {
448
+ extract: (repo: typeof allRepos[0]) => `${repo.name} ${repo.description || ''}`,
449
+ });
450
+
451
+ return results.slice(0, limit).map(r => ({
452
+ name: r.original.name,
453
+ description: r.original.description,
454
+ url: r.original.url,
455
+ isPrivate: r.original.isPrivate,
456
+ score: r.score,
457
+ }));
458
+ });
459
+ }
460
+
461
+ export async function handleListOrgRepos(forceRefresh: boolean = false) {
462
+ return withStdoutProtection(async () => {
463
+ const repos = await fetchOrgRepos({ forceRefresh });
464
+ return {
465
+ repos: repos.map(r => ({
466
+ name: r.name,
467
+ description: r.description,
468
+ url: r.url,
469
+ isPrivate: r.isPrivate,
470
+ })),
471
+ total: repos.length,
472
+ };
473
+ });
474
+ }
475
+
476
+ export async function handleDeleteWorkspace(workspaces: string | string[]) {
477
+ return withStdoutProtection(async () => {
478
+ const names = Array.isArray(workspaces) ? workspaces : [workspaces];
479
+ const results: Array<{ workspace: string; path: string; deleted: boolean; error?: string }> = [];
480
+
481
+ for (const workspace of names) {
482
+ const workspacePath = safeWorkspacePath(workspace);
483
+
484
+ try {
485
+ await fs.access(workspacePath);
486
+ } catch {
487
+ results.push({ workspace, path: workspacePath, deleted: false, error: `Workspace directory not found: ${workspace}` });
488
+ continue;
489
+ }
490
+
491
+ try {
492
+ await fs.rm(workspacePath, { recursive: true, force: true });
493
+ results.push({ workspace, path: workspacePath, deleted: true });
494
+ } catch (err) {
495
+ results.push({ workspace, path: workspacePath, deleted: false, error: err instanceof Error ? err.message : 'Unknown error' });
496
+ }
497
+ }
498
+
499
+ if (names.length === 1) {
500
+ const r = results[0];
501
+ if (!r.deleted) throw new Error(r.error!);
502
+ return { deleted: r.workspace, path: r.path };
503
+ }
504
+
505
+ return { results };
506
+ });
507
+ }
508
+
509
+ export async function handleRemoveRepo(workspace: string, repo: string) {
510
+ return withStdoutProtection(async () => {
511
+ const workspacePath = safeWorkspacePath(workspace);
512
+ const metadata = await loadMetadata(workspacePath);
513
+
514
+ if (!metadata) {
515
+ throw new Error(`Workspace not found: ${workspace}`);
516
+ }
517
+
518
+ const repoIndex = metadata.repositories.findIndex(r => r.directoryName === repo);
519
+ if (repoIndex === -1) {
520
+ throw new Error(`Repository not found in workspace: ${repo}`);
521
+ }
522
+
523
+ const repoPath = path.join(workspacePath, repo);
524
+ await fs.rm(repoPath, { recursive: true, force: true });
525
+
526
+ metadata.repositories.splice(repoIndex, 1);
527
+ await saveMetadata(workspacePath, metadata);
528
+
529
+ const successfulRepos = metadata.repositories
530
+ .filter(r => r.status === 'success');
531
+
532
+ // Best-effort regeneration of Claude context
533
+ try {
534
+ if (successfulRepos.length > 0) {
535
+ const allRepos = await fetchOrgRepos();
536
+ const repoObjects = successfulRepos
537
+ .map(r => allRepos.find(ar => ar.name === r.name))
538
+ .filter((r): r is NonNullable<typeof r> => r != null);
539
+
540
+ if (repoObjects.length > 0) {
541
+ await generateClaudeContext(workspacePath, workspace, repoObjects, metadata);
542
+ }
543
+ }
544
+ } catch {
545
+ // Claude context regeneration is non-critical
546
+ }
547
+
548
+ return {
549
+ removed: repo,
550
+ workspace,
551
+ remainingRepos: successfulRepos.length,
552
+ };
553
+ });
554
+ }
555
+
556
+ export async function handleWorkspaceDoctor(workspace: string) {
557
+ return withStdoutProtection(async () => {
558
+ const workspacePath = safeWorkspacePath(workspace);
559
+ const metadata = await loadMetadata(workspacePath);
560
+
561
+ if (!metadata) {
562
+ throw new Error(`Workspace not found: ${workspace}`);
563
+ }
564
+
565
+ const results = await runAllHealthChecks(workspacePath, metadata);
566
+ const score = calculateHealthScore(results);
567
+
568
+ return {
569
+ workspace,
570
+ score,
571
+ checks: results,
572
+ };
573
+ });
574
+ }
575
+
576
+ export async function handleAnalyzeDeps(workspace: string) {
577
+ return withStdoutProtection(async () => {
578
+ const workspacePath = safeWorkspacePath(workspace);
579
+ const metadata = await loadMetadata(workspacePath);
580
+
581
+ if (!metadata) {
582
+ throw new Error(`Workspace not found: ${workspace}`);
583
+ }
584
+
585
+ const repos = metadata.repositories.filter(r => r.status === 'success');
586
+ const repoNames = repos.map(r => r.directoryName);
587
+
588
+ const analyses = await analyzeDependencies(workspacePath, repoNames);
589
+ const circularDeps = detectCircularDependencies(analyses);
590
+
591
+ // Convert Map to plain object for JSON serialization
592
+ const analysesObj: Record<string, {
593
+ repoName: string;
594
+ dependencies: string[];
595
+ dependents: string[];
596
+ missingDependencies: string[];
597
+ }> = {};
598
+ for (const [key, value] of analyses) {
599
+ analysesObj[key] = value;
600
+ }
601
+
602
+ return {
603
+ workspace,
604
+ analyses: analysesObj,
605
+ circularDependencies: circularDeps,
606
+ };
607
+ });
608
+ }
609
+
610
+ export async function handleBranchCreate(workspace: string, branchName: string, baseBranch?: string) {
611
+ return withStdoutProtection(async () => {
612
+ const workspacePath = safeWorkspacePath(workspace);
613
+ const metadata = await loadMetadata(workspacePath);
614
+
615
+ if (!metadata) {
616
+ throw new Error(`Workspace not found: ${workspace}`);
617
+ }
618
+
619
+ const repos = metadata.repositories.filter(r => r.status === 'success');
620
+ const results = [];
621
+
622
+ for (const repo of repos) {
623
+ const repoPath = path.join(workspacePath, repo.directoryName);
624
+ const result = await createBranch(repoPath, repo.directoryName, branchName, baseBranch);
625
+ results.push(result);
626
+ }
627
+
628
+ const successful = results.filter(r => r.success).length;
629
+ const failed = results.filter(r => !r.success).length;
630
+
631
+ return {
632
+ workspace,
633
+ branchName,
634
+ baseBranch: baseBranch || 'current',
635
+ results,
636
+ summary: { successful, failed },
637
+ };
638
+ });
639
+ }
640
+
641
+ export async function handleSwitchBranch(workspace: string, branch: string) {
642
+ return withStdoutProtection(async () => {
643
+ const workspacePath = safeWorkspacePath(workspace);
644
+ const metadata = await loadMetadata(workspacePath);
645
+
646
+ if (!metadata) {
647
+ throw new Error(`Workspace not found: ${workspace}`);
648
+ }
649
+
650
+ const repos = metadata.repositories.filter(r => r.status === 'success');
651
+ const results = [];
652
+
653
+ for (const repo of repos) {
654
+ const repoPath = path.join(workspacePath, repo.directoryName);
655
+ const result = await switchBranch(repoPath, repo.directoryName, branch);
656
+ results.push(result);
657
+ }
658
+
659
+ const successful = results.filter(r => r.status === 'success').length;
660
+ const failed = results.filter(r => r.status === 'failed').length;
661
+ const skipped = results.filter(r => r.status === 'skipped').length;
662
+
663
+ return {
664
+ workspace,
665
+ branch,
666
+ results,
667
+ summary: { successful, failed, skipped },
668
+ };
669
+ });
670
+ }
671
+
672
+ export async function handleWorkspaceCleanup(
673
+ workspace: string,
674
+ includeNodeModules: boolean = true,
675
+ includeBuildArtifacts: boolean = true
676
+ ) {
677
+ return withStdoutProtection(async () => {
678
+ const workspacePath = safeWorkspacePath(workspace);
679
+ const metadata = await loadMetadata(workspacePath);
680
+
681
+ if (!metadata) {
682
+ throw new Error(`Workspace not found: ${workspace}`);
683
+ }
684
+
685
+ const repos = metadata.repositories.filter(r => r.status === 'success');
686
+ const results: Array<{
687
+ repo: string;
688
+ nodeModules?: import('../utils/cleanup-operations').CleanupResult;
689
+ buildArtifacts?: import('../utils/cleanup-operations').CleanupResult;
690
+ }> = [];
691
+
692
+ for (const repo of repos) {
693
+ const repoPath = path.join(workspacePath, repo.directoryName);
694
+ const repoResult: typeof results[0] = { repo: repo.directoryName };
695
+
696
+ if (includeNodeModules) {
697
+ repoResult.nodeModules = await removeNodeModules(repoPath);
698
+ }
699
+ if (includeBuildArtifacts) {
700
+ repoResult.buildArtifacts = await removeBuildArtifacts(repoPath);
701
+ }
702
+
703
+ results.push(repoResult);
704
+ }
705
+
706
+ // Calculate total space freed
707
+ let totalFreedMB = 0;
708
+ for (const r of results) {
709
+ if (r.nodeModules) {
710
+ totalFreedMB += parseFloat(r.nodeModules.spaceFreed) || 0;
711
+ }
712
+ if (r.buildArtifacts) {
713
+ totalFreedMB += parseFloat(r.buildArtifacts.spaceFreed) || 0;
714
+ }
715
+ }
716
+
717
+ return {
718
+ workspace,
719
+ results,
720
+ totalSpaceFreed: `${totalFreedMB.toFixed(2)} MB`,
721
+ };
722
+ });
723
+ }
724
+
725
+ // --- Branch merge/rebase ---
726
+
727
+ export async function handleBranchMerge(
728
+ workspace: string,
729
+ sourceBranch: string,
730
+ targetBranch: string,
731
+ strategy?: 'no-ff' | 'ff-only' | 'squash'
732
+ ) {
733
+ return withStdoutProtection(async () => {
734
+ const workspacePath = safeWorkspacePath(workspace);
735
+ const metadata = await loadMetadata(workspacePath);
736
+
737
+ if (!metadata) {
738
+ throw new Error(`Workspace not found: ${workspace}`);
739
+ }
740
+
741
+ const repos = metadata.repositories.filter(r => r.status === 'success');
742
+ const results = [];
743
+
744
+ const options = strategy ? {
745
+ noFf: strategy === 'no-ff',
746
+ ffOnly: strategy === 'ff-only',
747
+ squash: strategy === 'squash',
748
+ } : undefined;
749
+
750
+ for (const repo of repos) {
751
+ const repoPath = path.join(workspacePath, repo.directoryName);
752
+ const result = await mergeBranch(repoPath, repo.directoryName, sourceBranch, targetBranch, options);
753
+ results.push(result);
754
+ }
755
+
756
+ const successful = results.filter(r => r.success).length;
757
+ const failed = results.filter(r => !r.success).length;
758
+
759
+ return {
760
+ workspace,
761
+ sourceBranch,
762
+ targetBranch,
763
+ strategy: strategy || 'default',
764
+ results,
765
+ summary: { successful, failed },
766
+ };
767
+ });
768
+ }
769
+
770
+ export async function handleBranchRebase(workspace: string, targetBranch: string) {
771
+ return withStdoutProtection(async () => {
772
+ const workspacePath = safeWorkspacePath(workspace);
773
+ const metadata = await loadMetadata(workspacePath);
774
+
775
+ if (!metadata) {
776
+ throw new Error(`Workspace not found: ${workspace}`);
777
+ }
778
+
779
+ const repos = metadata.repositories.filter(r => r.status === 'success');
780
+ const results = [];
781
+
782
+ for (const repo of repos) {
783
+ const repoPath = path.join(workspacePath, repo.directoryName);
784
+ const result = await rebaseBranch(repoPath, repo.directoryName, targetBranch);
785
+ results.push(result);
786
+ }
787
+
788
+ const successful = results.filter(r => r.success).length;
789
+ const failed = results.filter(r => !r.success).length;
790
+
791
+ return {
792
+ workspace,
793
+ targetBranch,
794
+ results,
795
+ summary: { successful, failed },
796
+ };
797
+ });
798
+ }
799
+
800
+ // --- Suite operations ---
801
+
802
+ export async function handleSuiteCreate(name: string, repos: string[], description?: string) {
803
+ return withStdoutProtection(async () => {
804
+ const validation = validateSuiteName(name);
805
+ if (validation !== true) {
806
+ throw new Error(`Invalid suite name: ${validation}`);
807
+ }
808
+
809
+ if (!repos || repos.length === 0) {
810
+ throw new Error('At least one repository name is required');
811
+ }
812
+
813
+ const existing = await getSuite(name);
814
+ if (existing) {
815
+ throw new Error(`Suite already exists: ${name}. Delete it first or use a different name.`);
816
+ }
817
+
818
+ const entries: SuiteEntry[] = repos.map(repoName => ({
819
+ repoName,
820
+ directoryName: repoName,
821
+ }));
822
+
823
+ const suite: WorkspaceSuite = {
824
+ name,
825
+ description: description || '',
826
+ entries,
827
+ createdAt: new Date().toISOString(),
828
+ updatedAt: new Date().toISOString(),
829
+ };
830
+
831
+ await saveSuite(suite);
832
+
833
+ return {
834
+ name: suite.name,
835
+ description: suite.description,
836
+ repoCount: entries.length,
837
+ repos: repos,
838
+ };
839
+ });
840
+ }
841
+
842
+ export async function handleSuiteDelete(name: string) {
843
+ return withStdoutProtection(async () => {
844
+ const deleted = await deleteSuite(name);
845
+ if (!deleted) {
846
+ throw new Error(`Suite not found: ${name}`);
847
+ }
848
+ return { deleted: name };
849
+ });
850
+ }
851
+
852
+ export async function handleSuiteExport(name?: string) {
853
+ return withStdoutProtection(async () => {
854
+ if (name) {
855
+ const result = await exportSuite(name);
856
+ if (!result) {
857
+ throw new Error(`Suite not found: ${name}`);
858
+ }
859
+ return result;
860
+ }
861
+ return await exportAllSuites();
862
+ });
863
+ }
864
+
865
+ export async function handleSuiteImport(content: string, overwrite: boolean = false) {
866
+ return withStdoutProtection(async () => {
867
+ let parsed: SuitesStore;
868
+ try {
869
+ parsed = JSON.parse(content);
870
+ } catch {
871
+ throw new Error('Invalid JSON content');
872
+ }
873
+
874
+ if (!parsed.suites || !Array.isArray(parsed.suites)) {
875
+ throw new Error('Invalid suites format: expected { version: 1, suites: [...] }');
876
+ }
877
+
878
+ const result = await importSuites(parsed, overwrite);
879
+ return result;
880
+ });
881
+ }
882
+
883
+ export async function handleSuiteUse(suiteName: string, workspace: string) {
884
+ return withStdoutProtection(async () => {
885
+ const suite = await getSuite(suiteName);
886
+ if (!suite) {
887
+ throw new Error(`Suite not found: ${suiteName}`);
888
+ }
889
+
890
+ const sanitizedSuiteWorkspace = sanitizeWorkspaceName(workspace);
891
+ const workspacePath = safeWorkspacePath(sanitizedSuiteWorkspace);
892
+
893
+ // Auto-resolve workspace name conflict using suite repo names as hints
894
+ let finalSuiteWorkspaceName = sanitizedSuiteWorkspace;
895
+ let suiteWsExists = false;
896
+ try {
897
+ await fs.access(workspacePath);
898
+ suiteWsExists = true;
899
+ } catch {
900
+ // Directory doesn't exist — good
901
+ }
902
+ if (suiteWsExists) {
903
+ // Workspace already exists — resolve to a unique name
904
+ // (errors from resolveWorkspaceNameConflict intentionally propagate)
905
+ const suiteRepoNames = suite.entries.map(e => e.repoName);
906
+ finalSuiteWorkspaceName = await resolveWorkspaceNameConflict(sanitizedSuiteWorkspace, suiteRepoNames);
907
+ }
908
+
909
+ // Resolve suite entries to GitHub repos
910
+ const allRepos = await fetchOrgRepos();
911
+ const repoEntries: Array<{ repo: typeof allRepos[0]; directoryName: string }> = [];
912
+ const notFound: string[] = [];
913
+
914
+ for (const entry of suite.entries) {
915
+ const found = allRepos.find(r => r.name.toLowerCase() === entry.repoName.toLowerCase());
916
+ if (found) {
917
+ repoEntries.push({ repo: found, directoryName: entry.directoryName });
918
+ } else {
919
+ notFound.push(entry.repoName);
920
+ }
921
+ }
922
+
923
+ if (repoEntries.length === 0) {
924
+ throw new Error(`None of the suite repos were found: ${notFound.join(', ')}`);
925
+ }
926
+
927
+ // Create workspace directory
928
+ const finalSuiteWorkspacePath = safeWorkspacePath(finalSuiteWorkspaceName);
929
+ await fs.mkdir(finalSuiteWorkspacePath, { recursive: true });
930
+
931
+ // Clone repositories
932
+ const cloneResults = await cloneRepositories(repoEntries, finalSuiteWorkspacePath);
933
+
934
+ // Save metadata
935
+ const metadata = createMetadata(finalSuiteWorkspaceName, cloneResults);
936
+ await saveMetadata(finalSuiteWorkspacePath, metadata);
937
+
938
+ // Generate Claude context
939
+ const successfulRepos = cloneResults
940
+ .filter(r => r.status === 'success')
941
+ .map(r => r.repo);
942
+
943
+ if (successfulRepos.length > 0) {
944
+ await generateClaudeContext(finalSuiteWorkspacePath, finalSuiteWorkspaceName, successfulRepos, metadata);
945
+ }
946
+
947
+ // Run post-clone hooks if suite has them
948
+ if (suite.postCloneHooks && suite.postCloneHooks.length > 0) {
949
+ try {
950
+ const repoMetadata = metadata.repositories.filter(r => r.status === 'success');
951
+ await runPostCloneHooks(finalSuiteWorkspacePath, repoMetadata, suite.postCloneHooks);
952
+ } catch {
953
+ // Post-clone hooks are non-critical
954
+ }
955
+ }
956
+
957
+ const successful = cloneResults.filter(r => r.status === 'success');
958
+ const failed = cloneResults.filter(r => r.status === 'failed');
959
+
960
+ return {
961
+ workspace: finalSuiteWorkspaceName,
962
+ originalWorkspaceName: finalSuiteWorkspaceName !== sanitizedSuiteWorkspace ? sanitizedSuiteWorkspace : undefined,
963
+ suite: suiteName,
964
+ path: finalSuiteWorkspacePath,
965
+ repos: cloneResults.map(r => ({
966
+ name: r.repo.name,
967
+ directoryName: r.directoryName,
968
+ status: r.status,
969
+ error: r.error,
970
+ })),
971
+ summary: { successful: successful.length, failed: failed.length },
972
+ notFound: notFound.length > 0 ? notFound : undefined,
973
+ };
974
+ });
975
+ }
976
+
977
+
978
+ export async function handleSaveContext(workspace: string, content: string, append?: boolean) {
979
+ return withStdoutProtection(async () => {
980
+ if (!workspace || workspace.trim().length === 0) {
981
+ throw new Error('Workspace name is required');
982
+ }
983
+ if (!content || content.trim().length === 0) {
984
+ throw new Error('Content is required');
985
+ }
986
+
987
+ const workspacePath = safeWorkspacePath(workspace);
988
+ const metadata = await loadMetadata(workspacePath);
989
+ if (!metadata) {
990
+ throw new Error(`Workspace not found: ${workspace}`);
991
+ }
992
+
993
+ const contextPath = path.join(workspacePath, 'CONTEXT.md');
994
+ const timestamp = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, 'Z');
995
+
996
+ let fileContent: string;
997
+ if (append) {
998
+ let existing: string | null = null;
999
+ try {
1000
+ existing = await fs.readFile(contextPath, 'utf-8');
1001
+ } catch {
1002
+ // File doesn't exist yet
1003
+ }
1004
+ fileContent = appendToContextFile(existing, workspace, content, timestamp);
1005
+ } else {
1006
+ fileContent = formatContextFile(workspace, content, timestamp);
1007
+ }
1008
+
1009
+ await fs.writeFile(contextPath, fileContent, 'utf-8');
1010
+
1011
+ return {
1012
+ saved: true,
1013
+ path: contextPath,
1014
+ workspace,
1015
+ append: append ?? false,
1016
+ timestamp,
1017
+ };
1018
+ });
1019
+ }