@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,256 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+
6
+ describe('validateSuiteName', () => {
7
+ let validateSuiteName: typeof import('./suite').validateSuiteName;
8
+
9
+ beforeEach(async () => {
10
+ vi.resetModules();
11
+ const mod = await import('./suite');
12
+ validateSuiteName = mod.validateSuiteName;
13
+ });
14
+
15
+ it('returns true for valid names', () => {
16
+ expect(validateSuiteName('my-suite')).toBe(true);
17
+ expect(validateSuiteName('test_suite')).toBe(true);
18
+ expect(validateSuiteName('suite123')).toBe(true);
19
+ expect(validateSuiteName('ab')).toBe(true);
20
+ });
21
+
22
+ it('rejects empty names', () => {
23
+ expect(validateSuiteName('')).toContain('empty');
24
+ expect(validateSuiteName(' ')).toContain('empty');
25
+ });
26
+
27
+ it('rejects names with invalid characters', () => {
28
+ expect(validateSuiteName('my suite')).toContain('alphanumeric');
29
+ expect(validateSuiteName('my@suite')).toContain('alphanumeric');
30
+ expect(validateSuiteName('my.suite')).toContain('alphanumeric');
31
+ });
32
+
33
+ it('rejects names that are too short', () => {
34
+ expect(validateSuiteName('a')).toContain('at least 2');
35
+ });
36
+
37
+ it('rejects names that are too long', () => {
38
+ const longName = 'a'.repeat(51);
39
+ expect(validateSuiteName(longName)).toContain('50');
40
+ });
41
+ });
42
+
43
+ describe('suite CRUD operations', () => {
44
+ let tempDir: string;
45
+ let saveSuite: typeof import('./suite').saveSuite;
46
+ let getSuite: typeof import('./suite').getSuite;
47
+ let listSuites: typeof import('./suite').listSuites;
48
+ let deleteSuite: typeof import('./suite').deleteSuite;
49
+ let exportSuite: typeof import('./suite').exportSuite;
50
+ let exportAllSuites: typeof import('./suite').exportAllSuites;
51
+ let importSuites: typeof import('./suite').importSuites;
52
+
53
+ beforeEach(async () => {
54
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-suite-test-'));
55
+ process.env.WORKSPACE_MANAGER_CACHE_DIR = tempDir;
56
+
57
+ vi.resetModules();
58
+ const mod = await import('./suite');
59
+ saveSuite = mod.saveSuite;
60
+ getSuite = mod.getSuite;
61
+ listSuites = mod.listSuites;
62
+ deleteSuite = mod.deleteSuite;
63
+ exportSuite = mod.exportSuite;
64
+ exportAllSuites = mod.exportAllSuites;
65
+ importSuites = mod.importSuites;
66
+ });
67
+
68
+ afterEach(async () => {
69
+ delete process.env.WORKSPACE_MANAGER_CACHE_DIR;
70
+ await fs.rm(tempDir, { recursive: true, force: true });
71
+ });
72
+
73
+ it('saveSuite creates and getSuite retrieves a suite', async () => {
74
+ const suite = {
75
+ name: 'test-suite',
76
+ description: 'A test suite',
77
+ entries: [{ repoName: 'repo1', directoryName: 'repo1' }],
78
+ createdAt: '2024-01-01T00:00:00Z',
79
+ updatedAt: '2024-01-01T00:00:00Z',
80
+ };
81
+
82
+ await saveSuite(suite);
83
+ const retrieved = await getSuite('test-suite');
84
+
85
+ expect(retrieved).toBeDefined();
86
+ expect(retrieved!.name).toBe('test-suite');
87
+ expect(retrieved!.description).toBe('A test suite');
88
+ expect(retrieved!.entries).toHaveLength(1);
89
+ });
90
+
91
+ it('listSuites returns suites sorted by name', async () => {
92
+ await saveSuite({
93
+ name: 'z-suite',
94
+ description: '',
95
+ entries: [],
96
+ createdAt: '2024-01-01T00:00:00Z',
97
+ updatedAt: '2024-01-01T00:00:00Z',
98
+ });
99
+ await saveSuite({
100
+ name: 'a-suite',
101
+ description: '',
102
+ entries: [],
103
+ createdAt: '2024-01-01T00:00:00Z',
104
+ updatedAt: '2024-01-01T00:00:00Z',
105
+ });
106
+
107
+ const suites = await listSuites();
108
+ expect(suites).toHaveLength(2);
109
+ expect(suites[0].name).toBe('a-suite');
110
+ expect(suites[1].name).toBe('z-suite');
111
+ });
112
+
113
+ it('saveSuite upserts existing suites', async () => {
114
+ const suite = {
115
+ name: 'test-suite',
116
+ description: 'original',
117
+ entries: [],
118
+ createdAt: '2024-01-01T00:00:00Z',
119
+ updatedAt: '2024-01-01T00:00:00Z',
120
+ };
121
+
122
+ await saveSuite(suite);
123
+ await saveSuite({ ...suite, description: 'updated' });
124
+
125
+ const suites = await listSuites();
126
+ expect(suites).toHaveLength(1);
127
+ expect(suites[0].description).toBe('updated');
128
+ });
129
+
130
+ it('deleteSuite removes an existing suite', async () => {
131
+ await saveSuite({
132
+ name: 'to-delete',
133
+ description: '',
134
+ entries: [],
135
+ createdAt: '2024-01-01T00:00:00Z',
136
+ updatedAt: '2024-01-01T00:00:00Z',
137
+ });
138
+
139
+ const result = await deleteSuite('to-delete');
140
+ expect(result).toBe(true);
141
+
142
+ const retrieved = await getSuite('to-delete');
143
+ expect(retrieved).toBeUndefined();
144
+ });
145
+
146
+ it('deleteSuite returns false for non-existent suite', async () => {
147
+ const result = await deleteSuite('non-existent');
148
+ expect(result).toBe(false);
149
+ });
150
+
151
+ it('exportSuite exports a single suite', async () => {
152
+ await saveSuite({
153
+ name: 'export-me',
154
+ description: 'test',
155
+ entries: [{ repoName: 'r1', directoryName: 'r1' }],
156
+ createdAt: '2024-01-01T00:00:00Z',
157
+ updatedAt: '2024-01-01T00:00:00Z',
158
+ });
159
+
160
+ const exported = await exportSuite('export-me');
161
+ expect(exported).toBeDefined();
162
+ expect(exported!.version).toBe(1);
163
+ expect(exported!.suites).toHaveLength(1);
164
+ expect(exported!.suites[0].name).toBe('export-me');
165
+ });
166
+
167
+ it('exportAllSuites exports all suites', async () => {
168
+ await saveSuite({
169
+ name: 'suite-a',
170
+ description: '',
171
+ entries: [],
172
+ createdAt: '2024-01-01T00:00:00Z',
173
+ updatedAt: '2024-01-01T00:00:00Z',
174
+ });
175
+ await saveSuite({
176
+ name: 'suite-b',
177
+ description: '',
178
+ entries: [],
179
+ createdAt: '2024-01-01T00:00:00Z',
180
+ updatedAt: '2024-01-01T00:00:00Z',
181
+ });
182
+
183
+ const exported = await exportAllSuites();
184
+ expect(exported.version).toBe(1);
185
+ expect(exported.suites).toHaveLength(2);
186
+ });
187
+
188
+ it('importSuites imports with overwrite=true', async () => {
189
+ await saveSuite({
190
+ name: 'existing',
191
+ description: 'old',
192
+ entries: [],
193
+ createdAt: '2024-01-01T00:00:00Z',
194
+ updatedAt: '2024-01-01T00:00:00Z',
195
+ });
196
+
197
+ const result = await importSuites({
198
+ version: 1,
199
+ suites: [{
200
+ name: 'existing',
201
+ description: 'new',
202
+ entries: [{ repoName: 'r', directoryName: 'r' }],
203
+ createdAt: '2024-02-01T00:00:00Z',
204
+ updatedAt: '2024-02-01T00:00:00Z',
205
+ }],
206
+ }, true);
207
+
208
+ expect(result.imported).toContain('existing');
209
+ expect(result.skipped).toHaveLength(0);
210
+
211
+ const retrieved = await getSuite('existing');
212
+ expect(retrieved!.description).toBe('new');
213
+ });
214
+
215
+ it('importSuites skips with overwrite=false', async () => {
216
+ await saveSuite({
217
+ name: 'existing',
218
+ description: 'old',
219
+ entries: [],
220
+ createdAt: '2024-01-01T00:00:00Z',
221
+ updatedAt: '2024-01-01T00:00:00Z',
222
+ });
223
+
224
+ const result = await importSuites({
225
+ version: 1,
226
+ suites: [{
227
+ name: 'existing',
228
+ description: 'new',
229
+ entries: [],
230
+ createdAt: '2024-02-01T00:00:00Z',
231
+ updatedAt: '2024-02-01T00:00:00Z',
232
+ }],
233
+ }, false);
234
+
235
+ expect(result.skipped).toContain('existing');
236
+
237
+ const retrieved = await getSuite('existing');
238
+ expect(retrieved!.description).toBe('old');
239
+ });
240
+
241
+ it('importSuites reports validation errors', async () => {
242
+ const result = await importSuites({
243
+ version: 1,
244
+ suites: [{
245
+ name: '',
246
+ description: '',
247
+ entries: [],
248
+ createdAt: '2024-01-01T00:00:00Z',
249
+ updatedAt: '2024-01-01T00:00:00Z',
250
+ }],
251
+ }, true);
252
+
253
+ expect(result.errors).toHaveLength(1);
254
+ expect(result.imported).toHaveLength(0);
255
+ });
256
+ });
@@ -0,0 +1,144 @@
1
+ import * as fs from 'fs/promises';
2
+ import { WorkspaceSuite, SuitesStore } from '../types';
3
+ import { CACHE_DIR, SUITES_FILE } from './config';
4
+ import { logWarning } from './logger';
5
+
6
+ const SUITE_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
7
+
8
+ async function ensureCacheDir(): Promise<void> {
9
+ try {
10
+ await fs.mkdir(CACHE_DIR, { recursive: true });
11
+ } catch {
12
+ logWarning('Failed to create cache directory');
13
+ }
14
+ }
15
+
16
+ export async function loadSuitesStore(): Promise<SuitesStore> {
17
+ try {
18
+ const content = await fs.readFile(SUITES_FILE, 'utf-8');
19
+ return JSON.parse(content);
20
+ } catch {
21
+ return { version: 1, suites: [] };
22
+ }
23
+ }
24
+
25
+ export async function saveSuitesStore(store: SuitesStore): Promise<void> {
26
+ await ensureCacheDir();
27
+ await fs.writeFile(SUITES_FILE, JSON.stringify(store, null, 2), 'utf-8');
28
+ }
29
+
30
+ export async function listSuites(): Promise<WorkspaceSuite[]> {
31
+ const store = await loadSuitesStore();
32
+ return store.suites.sort((a, b) => a.name.localeCompare(b.name));
33
+ }
34
+
35
+ export async function getSuite(name: string): Promise<WorkspaceSuite | undefined> {
36
+ const store = await loadSuitesStore();
37
+ return store.suites.find(s => s.name === name);
38
+ }
39
+
40
+ export async function saveSuite(suite: WorkspaceSuite): Promise<void> {
41
+ const store = await loadSuitesStore();
42
+ const existingIndex = store.suites.findIndex(s => s.name === suite.name);
43
+
44
+ if (existingIndex >= 0) {
45
+ store.suites[existingIndex] = suite;
46
+ } else {
47
+ store.suites.push(suite);
48
+ }
49
+
50
+ await saveSuitesStore(store);
51
+ }
52
+
53
+ export async function deleteSuite(name: string): Promise<boolean> {
54
+ const store = await loadSuitesStore();
55
+ const index = store.suites.findIndex(s => s.name === name);
56
+
57
+ if (index < 0) {
58
+ return false;
59
+ }
60
+
61
+ store.suites.splice(index, 1);
62
+ await saveSuitesStore(store);
63
+ return true;
64
+ }
65
+
66
+ export async function exportSuite(name: string): Promise<SuitesStore | null> {
67
+ const suite = await getSuite(name);
68
+ if (!suite) {
69
+ return null;
70
+ }
71
+ return { version: 1, suites: [suite] };
72
+ }
73
+
74
+ export async function exportAllSuites(): Promise<SuitesStore> {
75
+ const store = await loadSuitesStore();
76
+ return { version: 1, suites: store.suites };
77
+ }
78
+
79
+ export async function importSuites(
80
+ data: SuitesStore,
81
+ overwrite: boolean
82
+ ): Promise<{ imported: string[]; skipped: string[]; errors: string[] }> {
83
+ const result = { imported: [] as string[], skipped: [] as string[], errors: [] as string[] };
84
+ const store = await loadSuitesStore();
85
+
86
+ for (const suite of data.suites) {
87
+ const nameValidation = validateSuiteName(suite.name);
88
+ if (nameValidation !== true) {
89
+ result.errors.push(`${suite.name}: ${nameValidation}`);
90
+ continue;
91
+ }
92
+
93
+ if (!Array.isArray(suite.entries)) {
94
+ result.errors.push(`${suite.name}: missing or invalid entries`);
95
+ continue;
96
+ }
97
+
98
+ const hasInvalidEntries = suite.entries.some(
99
+ e => !e.repoName || typeof e.repoName !== 'string' || !e.directoryName || typeof e.directoryName !== 'string'
100
+ );
101
+ if (hasInvalidEntries) {
102
+ result.errors.push(`${suite.name}: entries contain invalid repoName or directoryName values`);
103
+ continue;
104
+ }
105
+
106
+ const existingIndex = store.suites.findIndex(s => s.name === suite.name);
107
+
108
+ if (existingIndex >= 0 && !overwrite) {
109
+ result.skipped.push(suite.name);
110
+ continue;
111
+ }
112
+
113
+ if (existingIndex >= 0) {
114
+ store.suites[existingIndex] = suite;
115
+ } else {
116
+ store.suites.push(suite);
117
+ }
118
+
119
+ result.imported.push(suite.name);
120
+ }
121
+
122
+ await saveSuitesStore(store);
123
+ return result;
124
+ }
125
+
126
+ export function validateSuiteName(name: string): true | string {
127
+ if (!name || name.trim().length === 0) {
128
+ return 'Suite name cannot be empty';
129
+ }
130
+
131
+ if (!SUITE_NAME_REGEX.test(name)) {
132
+ return 'Suite name must contain only alphanumeric characters, hyphens, and underscores';
133
+ }
134
+
135
+ if (name.length < 2) {
136
+ return 'Suite name must be at least 2 characters long';
137
+ }
138
+
139
+ if (name.length > 50) {
140
+ return 'Suite name must be 50 characters or less';
141
+ }
142
+
143
+ return true;
144
+ }
@@ -0,0 +1,87 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import * as fs from 'fs/promises';
4
+ import { logInfo, logSuccess, logError } from './logger';
5
+ import { colorize } from './colors';
6
+ import { withRetry } from './retry';
7
+
8
+ const execAsync = promisify(exec);
9
+
10
+ export interface SyncResult {
11
+ repo: string;
12
+ status: 'success' | 'failed' | 'skipped';
13
+ message?: string;
14
+ }
15
+
16
+ export async function syncRepository(repoPath: string, repoName: string, silent: boolean = false): Promise<SyncResult> {
17
+ try {
18
+ // Check if directory exists
19
+ try {
20
+ await fs.access(repoPath);
21
+ } catch {
22
+ return {
23
+ repo: repoName,
24
+ status: 'skipped',
25
+ message: 'Directory not found',
26
+ };
27
+ }
28
+
29
+ // Check if it's a git repository
30
+ try {
31
+ await execAsync('git rev-parse --git-dir', { cwd: repoPath });
32
+ } catch {
33
+ return {
34
+ repo: repoName,
35
+ status: 'skipped',
36
+ message: 'Not a git repository',
37
+ };
38
+ }
39
+
40
+ // Check for uncommitted changes
41
+ const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: repoPath });
42
+ if (statusOutput.trim().length > 0) {
43
+ return {
44
+ repo: repoName,
45
+ status: 'skipped',
46
+ message: 'Uncommitted changes',
47
+ };
48
+ }
49
+
50
+ // Get current branch
51
+ const { stdout: branchOutput } = await execAsync('git branch --show-current', { cwd: repoPath });
52
+ const currentBranch = branchOutput.trim();
53
+
54
+ if (!currentBranch) {
55
+ return {
56
+ repo: repoName,
57
+ status: 'skipped',
58
+ message: 'Detached HEAD state',
59
+ };
60
+ }
61
+
62
+ if (!silent) logInfo(`Pulling ${colorize(repoName, 'cyan')} (${currentBranch})...`);
63
+
64
+ // Git pull with retry
65
+ await withRetry(
66
+ () => execAsync('git pull', { cwd: repoPath, timeout: 60000 }),
67
+ undefined,
68
+ `Pull ${repoName}`
69
+ );
70
+
71
+ if (!silent) logSuccess(`Updated ${colorize(repoName, 'cyan')}`);
72
+
73
+ return {
74
+ repo: repoName,
75
+ status: 'success',
76
+ };
77
+ } catch (error) {
78
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
79
+ logError(`Failed to sync ${colorize(repoName, 'cyan')}: ${errorMessage}`);
80
+
81
+ return {
82
+ repo: repoName,
83
+ status: 'failed',
84
+ message: errorMessage,
85
+ };
86
+ }
87
+ }
@@ -0,0 +1,193 @@
1
+ import { spawnSync } from 'child_process';
2
+ import { AgentState, DASHBOARD_DEFAULTS } from '../types/dashboard';
3
+
4
+ const { sessionName } = DASHBOARD_DEFAULTS;
5
+
6
+ export const HIDDEN_WINDOW = `${sessionName}:1`;
7
+
8
+ export interface PaneInfo {
9
+ index: string;
10
+ pid: number;
11
+ }
12
+
13
+ /**
14
+ * Get agent panes in the main dashboard window (excludes sidebar pane 0).
15
+ */
16
+ export function getAgentPanes(): PaneInfo[] {
17
+ const result = spawnSync('tmux', [
18
+ 'list-panes', '-t', sessionName, '-F',
19
+ '#{pane_index} #{pane_pid}',
20
+ ], { stdio: 'pipe' });
21
+
22
+ if (!result.stdout) return [];
23
+ return result.stdout.toString().trim().split('\n')
24
+ .filter(l => l.trim())
25
+ .map(line => {
26
+ const [index, pid] = line.split(' ');
27
+ return { index, pid: parseInt(pid, 10) || 0 };
28
+ })
29
+ .filter(p => p.index !== '0');
30
+ }
31
+
32
+ /**
33
+ * Get panes from the hidden window (window 1) used during zoom.
34
+ */
35
+ export function getHiddenPanes(): PaneInfo[] {
36
+ const result = spawnSync('tmux', [
37
+ 'list-panes', '-t', HIDDEN_WINDOW, '-F',
38
+ '#{pane_index} #{pane_pid}',
39
+ ], { stdio: 'pipe' });
40
+
41
+ if (!result.stdout) return [];
42
+ return result.stdout.toString().trim().split('\n')
43
+ .filter(l => l.trim())
44
+ .map(line => {
45
+ const [index, pid] = line.split(' ');
46
+ return { index, pid: parseInt(pid, 10) || 0 };
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Find the tmux pane matching an agent by PID (direct match only).
52
+ */
53
+ export function findPaneForAgent(agent: AgentState, panes: PaneInfo[]): PaneInfo | null {
54
+ return panes.find(p => p.pid === agent.pid) ?? null;
55
+ }
56
+
57
+ /**
58
+ * Sort agents to match tmux pane order (top-to-bottom) by PID.
59
+ */
60
+ export function sortAgentsByPaneOrder(agents: AgentState[], panes: PaneInfo[]): AgentState[] {
61
+ const pidToIndex = new Map<number, number>();
62
+ panes.forEach((p, i) => pidToIndex.set(p.pid, i));
63
+ return [...agents].sort((a, b) => {
64
+ const aIdx = pidToIndex.get(a.pid) ?? 999;
65
+ const bIdx = pidToIndex.get(b.pid) ?? 999;
66
+ return aIdx - bIdx;
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Sort agents by an explicit PID order array (used during zoom to
72
+ * preserve the original order regardless of current pane positions).
73
+ */
74
+ export function sortAgentsByPidOrder(agents: AgentState[], pidOrder: number[]): AgentState[] {
75
+ const pidToIndex = new Map<number, number>();
76
+ pidOrder.forEach((pid, i) => pidToIndex.set(pid, i));
77
+ return [...agents].sort((a, b) => {
78
+ const aIdx = pidToIndex.get(a.pid) ?? 999;
79
+ const bIdx = pidToIndex.get(b.pid) ?? 999;
80
+ return aIdx - bIdx;
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Restore all panes from the hidden window back to the main window,
86
+ * then reorder them to match the given original PID order.
87
+ */
88
+ export function restoreHiddenPanes(originalPidOrder?: number[]): void {
89
+ // First, bring all hidden panes back (order may be wrong)
90
+ for (let i = 0; i < 20; i++) {
91
+ const winCheck = spawnSync('tmux', [
92
+ 'list-windows', '-t', sessionName, '-F', '#{window_index}',
93
+ ], { stdio: 'pipe' });
94
+ if (!winCheck.stdout || !winCheck.stdout.toString().split('\n').some(l => l.trim() === '1')) break;
95
+
96
+ const hidden = getHiddenPanes();
97
+ if (hidden.length === 0) break;
98
+
99
+ const result = spawnSync('tmux', [
100
+ 'join-pane', '-d',
101
+ '-s', `${HIDDEN_WINDOW}.${hidden[0].index}`,
102
+ '-t', `${sessionName}:0`,
103
+ ], { stdio: 'pipe' });
104
+
105
+ if (result.status !== 0) break;
106
+ }
107
+
108
+ // If we have a desired order, reorder panes by swapping them into place
109
+ if (originalPidOrder && originalPidOrder.length > 0) {
110
+ reorderPanesByPid(originalPidOrder);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Reorder agent panes to match the given PID order using swap-pane.
116
+ * Uses a simple selection sort: for each position, find the pane with
117
+ * the correct PID and swap it into place.
118
+ */
119
+ function reorderPanesByPid(desiredPidOrder: number[]): void {
120
+ for (let i = 0; i < desiredPidOrder.length; i++) {
121
+ const targetPid = desiredPidOrder[i];
122
+ const currentPanes = getAgentPanes();
123
+
124
+ // Find the current index of the pane with the desired PID
125
+ const currentIdx = currentPanes.findIndex(p => p.pid === targetPid);
126
+ if (currentIdx === -1) continue; // pane not found (maybe died)
127
+
128
+ // The pane that should be at position i
129
+ const shouldBeAt = currentPanes[i];
130
+ if (!shouldBeAt) continue;
131
+
132
+ // If already in the right position, skip
133
+ if (currentPanes[currentIdx].index === shouldBeAt.index) continue;
134
+
135
+ // Swap the pane at position i with the pane that has the correct PID
136
+ spawnSync('tmux', [
137
+ 'swap-pane',
138
+ '-s', `${sessionName}:0.${currentPanes[currentIdx].index}`,
139
+ '-t', `${sessionName}:0.${shouldBeAt.index}`,
140
+ ], { stdio: 'pipe' });
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Reset to normal layout: restore hidden panes + rebalance.
146
+ */
147
+ export function resetLayout(): void {
148
+ restoreHiddenPanes();
149
+ spawnSync('tmux', ['select-layout', '-t', sessionName, 'main-vertical'], { stdio: 'pipe' });
150
+ spawnSync('tmux', ['resize-pane', '-t', `${sessionName}:0.0`, '-x', '32'], { stdio: 'pipe' });
151
+ spawnSync('tmux', ['select-pane', '-t', `${sessionName}:0.0`], { stdio: 'pipe' });
152
+ }
153
+
154
+ /**
155
+ * Zoom: hide all other agent panes to a hidden window.
156
+ * First pane: break-pane (creates window 1).
157
+ * Subsequent panes: join-pane into existing window 1.
158
+ */
159
+ export function zoomPane(targetPane: PaneInfo): void {
160
+ const targetPid = targetPane.pid;
161
+ let hiddenWindowExists = false;
162
+
163
+ const winCheck = spawnSync('tmux', [
164
+ 'list-windows', '-t', sessionName, '-F', '#{window_index}',
165
+ ], { stdio: 'pipe' });
166
+ if (winCheck.stdout && winCheck.stdout.toString().split('\n').some(l => l.trim() === '1')) {
167
+ hiddenWindowExists = true;
168
+ }
169
+
170
+ for (let i = 0; i < 20; i++) {
171
+ const currentPanes = getAgentPanes();
172
+ const toHide = currentPanes.find(p => p.pid !== targetPid);
173
+ if (!toHide) break;
174
+
175
+ if (!hiddenWindowExists) {
176
+ spawnSync('tmux', [
177
+ 'break-pane', '-d',
178
+ '-s', `${sessionName}:0.${toHide.index}`,
179
+ ], { stdio: 'pipe' });
180
+ hiddenWindowExists = true;
181
+ } else {
182
+ spawnSync('tmux', [
183
+ 'join-pane', '-d',
184
+ '-s', `${sessionName}:0.${toHide.index}`,
185
+ '-t', HIDDEN_WINDOW,
186
+ ], { stdio: 'pipe' });
187
+ }
188
+ }
189
+
190
+ spawnSync('tmux', ['select-layout', '-t', `${sessionName}:0`, 'main-vertical'], { stdio: 'pipe' });
191
+ spawnSync('tmux', ['resize-pane', '-t', `${sessionName}:0.0`, '-x', '32'], { stdio: 'pipe' });
192
+ spawnSync('tmux', ['select-pane', '-t', `${sessionName}:0.0`], { stdio: 'pipe' });
193
+ }