@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,262 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import * as path from 'path';
3
+
4
+ // Mock fs/promises before importing the module
5
+ vi.mock('fs/promises', () => ({
6
+ readdir: vi.fn(),
7
+ access: vi.fn(),
8
+ stat: vi.fn(),
9
+ open: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('./config', () => ({
13
+ WORKSPACES_DIR: '/Users/test/workspaces',
14
+ }));
15
+
16
+ vi.mock('./agent-config', () => ({
17
+ getActiveAgents: vi.fn().mockReturnValue([
18
+ { type: 'claude', sessionProjectsDir: '/Users/test/.claude/projects' },
19
+ ]),
20
+ }));
21
+
22
+ import * as fs from 'fs/promises';
23
+ import {
24
+ getWorkspaceSessions,
25
+ pathToProjectDirName,
26
+ extractWorkspaceName,
27
+ relativeTime,
28
+ } from './claude-sessions';
29
+
30
+ describe('pathToProjectDirName', () => {
31
+ it('converts absolute path to Claude project dir format', () => {
32
+ expect(pathToProjectDirName('/Users/test/workspaces')).toBe('-Users-test-workspaces');
33
+ });
34
+
35
+ it('handles paths with multiple segments', () => {
36
+ expect(pathToProjectDirName('/Users/yotam/Work/workspaces')).toBe('-Users-yotam-Work-workspaces');
37
+ });
38
+ });
39
+
40
+ describe('extractWorkspaceName', () => {
41
+ const prefix = '-Users-test-workspaces';
42
+
43
+ it('extracts simple workspace name', () => {
44
+ expect(extractWorkspaceName('-Users-test-workspaces-my-project', prefix)).toBe('my-project');
45
+ });
46
+
47
+ it('extracts hyphenated workspace name', () => {
48
+ expect(extractWorkspaceName('-Users-test-workspaces-auto-virtual-card-fix', prefix)).toBe('auto-virtual-card-fix');
49
+ });
50
+
51
+ it('returns null for trailing dash (no workspace name)', () => {
52
+ expect(extractWorkspaceName('-Users-test-workspaces-', prefix)).toBe(null);
53
+ });
54
+
55
+ it('returns null when projDir equals prefix', () => {
56
+ // prefix + 1 slices past the dir, giving empty string
57
+ expect(extractWorkspaceName('-Users-test-workspaces', prefix)).toBe(null);
58
+ });
59
+ });
60
+
61
+ describe('pathToProjectDirName (Pi)', () => {
62
+ it('converts absolute path to Pi session dir format', () => {
63
+ expect(pathToProjectDirName('/Users/test/workspaces', 'pi')).toBe('--Users-test-workspaces--');
64
+ });
65
+
66
+ it('handles paths with multiple segments', () => {
67
+ expect(pathToProjectDirName('/Users/yotam/Work/workspaces', 'pi')).toBe('--Users-yotam-Work-workspaces--');
68
+ });
69
+ });
70
+
71
+ describe('extractWorkspaceName (Pi)', () => {
72
+ const prefix = '--Users-test-workspaces--';
73
+
74
+ it('extracts simple workspace name', () => {
75
+ expect(extractWorkspaceName('--Users-test-workspaces-my-project--', prefix, 'pi')).toBe('my-project');
76
+ });
77
+
78
+ it('extracts hyphenated workspace name', () => {
79
+ expect(extractWorkspaceName('--Users-test-workspaces-auto-virtual-card-fix--', prefix, 'pi')).toBe('auto-virtual-card-fix');
80
+ });
81
+
82
+ it('returns null for empty workspace name', () => {
83
+ expect(extractWorkspaceName('--Users-test-workspaces--', prefix, 'pi')).toBe(null);
84
+ });
85
+
86
+ it('returns null for malformed dir without trailing --', () => {
87
+ expect(extractWorkspaceName('--Users-test-workspaces-my-project', prefix, 'pi')).toBe(null);
88
+ });
89
+
90
+ it('extracts single-char workspace name', () => {
91
+ expect(extractWorkspaceName('--Users-test-workspaces-x--', prefix, 'pi')).toBe('x');
92
+ });
93
+ });
94
+
95
+ describe('relativeTime', () => {
96
+ it('returns "just now" for times less than a minute ago', () => {
97
+ expect(relativeTime(new Date(Date.now() - 30_000))).toBe('just now');
98
+ });
99
+
100
+ it('returns minutes for times less than an hour ago', () => {
101
+ expect(relativeTime(new Date(Date.now() - 5 * 60_000))).toBe('5m ago');
102
+ });
103
+
104
+ it('returns hours for times less than a day ago', () => {
105
+ expect(relativeTime(new Date(Date.now() - 3 * 3600_000))).toBe('3h ago');
106
+ });
107
+
108
+ it('returns days for times less than a month ago', () => {
109
+ expect(relativeTime(new Date(Date.now() - 7 * 86400_000))).toBe('7d ago');
110
+ });
111
+
112
+ it('returns months for times more than 30 days ago', () => {
113
+ expect(relativeTime(new Date(Date.now() - 60 * 86400_000))).toBe('2mo ago');
114
+ });
115
+ });
116
+
117
+ describe('getWorkspaceSessions', () => {
118
+ beforeEach(() => {
119
+ vi.clearAllMocks();
120
+ });
121
+
122
+ it('returns empty array when claude projects dir does not exist', async () => {
123
+ vi.mocked(fs.readdir).mockRejectedValueOnce(new Error('ENOENT'));
124
+
125
+ const sessions = await getWorkspaceSessions();
126
+ expect(sessions).toEqual([]);
127
+ });
128
+
129
+ it('returns empty array when no workspace project dirs exist', async () => {
130
+ vi.mocked(fs.readdir).mockResolvedValueOnce([
131
+ '-Users-test-other-project' as any,
132
+ '-Users-test-standalone' as any,
133
+ ]);
134
+
135
+ const sessions = await getWorkspaceSessions();
136
+ expect(sessions).toEqual([]);
137
+ });
138
+
139
+ it('skips workspaces that no longer exist on disk', async () => {
140
+ // Projects dir listing
141
+ vi.mocked(fs.readdir).mockResolvedValueOnce([
142
+ '-Users-test-workspaces-deleted-workspace' as any,
143
+ ]);
144
+
145
+ // Workspace does not exist on disk
146
+ vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT'));
147
+
148
+ const sessions = await getWorkspaceSessions();
149
+ expect(sessions).toEqual([]);
150
+ });
151
+
152
+ it('returns sessions sorted by last active time', async () => {
153
+ const now = Date.now();
154
+
155
+ // Projects dir listing - two workspace project dirs
156
+ vi.mocked(fs.readdir)
157
+ .mockResolvedValueOnce([
158
+ '-Users-test-workspaces-workspace-a' as any,
159
+ '-Users-test-workspaces-workspace-b' as any,
160
+ ])
161
+ // workspace-a session files
162
+ .mockResolvedValueOnce([
163
+ 'session-a.jsonl' as any,
164
+ ])
165
+ // workspace-b session files
166
+ .mockResolvedValueOnce([
167
+ 'session-b.jsonl' as any,
168
+ ]);
169
+
170
+ // Both workspaces exist
171
+ vi.mocked(fs.access).mockResolvedValue(undefined);
172
+
173
+ // File stats - workspace-b is newer
174
+ const olderTime = new Date(now - 3600_000); // 1 hour ago
175
+ const newerTime = new Date(now - 60_000); // 1 minute ago
176
+
177
+ vi.mocked(fs.stat)
178
+ .mockResolvedValueOnce({ mtime: olderTime } as any)
179
+ .mockResolvedValueOnce({ mtime: newerTime } as any);
180
+
181
+ // Mock file open/read for timestamp extraction
182
+ const mockHandleA = {
183
+ stat: vi.fn().mockResolvedValue({ size: 100 }),
184
+ read: vi.fn().mockImplementation((buffer: Buffer) => {
185
+ const content = JSON.stringify({ timestamp: olderTime.toISOString() }) + '\n';
186
+ buffer.write(content);
187
+ return Promise.resolve({ bytesRead: content.length });
188
+ }),
189
+ close: vi.fn().mockResolvedValue(undefined),
190
+ };
191
+ const mockHandleB = {
192
+ stat: vi.fn().mockResolvedValue({ size: 100 }),
193
+ read: vi.fn().mockImplementation((buffer: Buffer) => {
194
+ const content = JSON.stringify({ timestamp: newerTime.toISOString() }) + '\n';
195
+ buffer.write(content);
196
+ return Promise.resolve({ bytesRead: content.length });
197
+ }),
198
+ close: vi.fn().mockResolvedValue(undefined),
199
+ };
200
+
201
+ vi.mocked(fs.open)
202
+ .mockResolvedValueOnce(mockHandleA as any)
203
+ .mockResolvedValueOnce(mockHandleB as any);
204
+
205
+ const sessions = await getWorkspaceSessions();
206
+
207
+ expect(sessions).toHaveLength(2);
208
+ // Most recent first
209
+ expect(sessions[0].workspaceName).toBe('workspace-b');
210
+ expect(sessions[1].workspaceName).toBe('workspace-a');
211
+ });
212
+
213
+ it('skips project dirs with no jsonl files', async () => {
214
+ vi.mocked(fs.readdir)
215
+ .mockResolvedValueOnce([
216
+ '-Users-test-workspaces-empty-workspace' as any,
217
+ ])
218
+ // No jsonl files in the project dir
219
+ .mockResolvedValueOnce([
220
+ 'some-uuid' as any, // directory, not a jsonl file
221
+ ]);
222
+
223
+ vi.mocked(fs.access).mockResolvedValue(undefined);
224
+
225
+ const sessions = await getWorkspaceSessions();
226
+ expect(sessions).toEqual([]);
227
+ });
228
+
229
+ it('falls back to mtime when jsonl has no timestamp', async () => {
230
+ const mtime = new Date(Date.now() - 7200_000); // 2 hours ago
231
+
232
+ vi.mocked(fs.readdir)
233
+ .mockResolvedValueOnce([
234
+ '-Users-test-workspaces-fallback-ws' as any,
235
+ ])
236
+ .mockResolvedValueOnce([
237
+ 'session-1.jsonl' as any,
238
+ ]);
239
+
240
+ vi.mocked(fs.access).mockResolvedValue(undefined);
241
+ vi.mocked(fs.stat).mockResolvedValueOnce({ mtime } as any);
242
+
243
+ // File open fails - no timestamp extractable
244
+ vi.mocked(fs.open).mockRejectedValueOnce(new Error('read error'));
245
+
246
+ const sessions = await getWorkspaceSessions();
247
+
248
+ expect(sessions).toHaveLength(1);
249
+ expect(sessions[0].workspaceName).toBe('fallback-ws');
250
+ expect(sessions[0].lastActiveAt).toEqual(mtime);
251
+ });
252
+
253
+ it('excludes the workspaces root dir itself', async () => {
254
+ // The prefix dir itself should not match
255
+ vi.mocked(fs.readdir).mockResolvedValueOnce([
256
+ '-Users-test-workspaces' as any, // This is the root, not a workspace
257
+ ]);
258
+
259
+ const sessions = await getWorkspaceSessions();
260
+ expect(sessions).toEqual([]);
261
+ });
262
+ });
@@ -0,0 +1,251 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { WORKSPACES_DIR } from './config';
4
+ import { getActiveAgents, AgentPaths, ConcreteAgentType } from './agent-config';
5
+
6
+ export interface WorkspaceSession {
7
+ workspaceName: string;
8
+ workspacePath: string;
9
+ sessionId: string;
10
+ lastActiveAt: Date;
11
+ /** Human-readable relative time, e.g. "2 hours ago" */
12
+ lastActiveLabel: string;
13
+ /** Which agent created this session */
14
+ agentType: ConcreteAgentType;
15
+ }
16
+
17
+ /**
18
+ * Scan session directories for workspace-related session files.
19
+ * Checks Claude (~/.claude/projects/) and/or Pi (~/.pi/agent/sessions/) based on config.
20
+ * Returns sessions sorted by last activity (most recent first).
21
+ *
22
+ * Note: Pi session discovery has limitations. Pi uses a different naming convention
23
+ * (--path--with--dashes--) vs Claude's (-path-with-dashes). This scanner currently
24
+ * only reliably finds Claude sessions. Pi session discovery may require updates
25
+ * based on Pi's actual session directory structure.
26
+ */
27
+ export async function getWorkspaceSessions(): Promise<WorkspaceSession[]> {
28
+ const agents = getActiveAgents();
29
+ const allSessions: WorkspaceSession[] = [];
30
+
31
+ // Scan each active agent's session directory
32
+ for (const agent of agents) {
33
+ const sessions = await scanProjectsDir(agent.sessionProjectsDir, agent.type);
34
+ allSessions.push(...sessions);
35
+ }
36
+
37
+ // Deduplicate by workspace + session ID, sort by most recent
38
+ const seen = new Set<string>();
39
+ const deduped = allSessions.filter(s => {
40
+ const key = `${s.workspacePath}:${s.sessionId}`;
41
+ if (seen.has(key)) return false;
42
+ seen.add(key);
43
+ return true;
44
+ });
45
+
46
+ deduped.sort((a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime());
47
+ return deduped;
48
+ }
49
+
50
+ /**
51
+ * Scan a single projects/sessions directory for workspace sessions.
52
+ */
53
+ async function scanProjectsDir(projectsDir: string, agentType: ConcreteAgentType): Promise<WorkspaceSession[]> {
54
+ // Only Claude Code and Pi expose a scannable per-project session directory
55
+ // layout. OpenCode stores sessions in SQLite; Codex/Gemini use their own
56
+ // formats that aren't scanned here — skip them.
57
+ if (agentType !== 'claude' && agentType !== 'pi') return [];
58
+
59
+ const workspacesPrefix = pathToProjectDirName(WORKSPACES_DIR, agentType);
60
+
61
+ let projectDirs: string[];
62
+ try {
63
+ projectDirs = await fs.readdir(projectsDir);
64
+ } catch {
65
+ return [];
66
+ }
67
+
68
+ // Filter to only workspace project directories
69
+ const workspaceProjectDirs = projectDirs.filter(dir => {
70
+ if (agentType === 'pi') {
71
+ // Pi dirs: "--{base}-{name}--", so strip trailing -- from prefix to match base + '-'
72
+ const prefixBase = workspacesPrefix.slice(0, -2); // remove trailing --
73
+ return dir.startsWith(prefixBase + '-') && dir !== workspacesPrefix;
74
+ }
75
+ // Claude: prefix + dash separator
76
+ return dir.startsWith(workspacesPrefix + '-') && dir !== workspacesPrefix;
77
+ });
78
+
79
+ const sessions: WorkspaceSession[] = [];
80
+
81
+ await Promise.all(workspaceProjectDirs.map(async (projDir) => {
82
+ const workspaceName = extractWorkspaceName(projDir, workspacesPrefix, agentType);
83
+ if (!workspaceName) return;
84
+
85
+ const workspacePath = path.join(WORKSPACES_DIR, workspaceName);
86
+
87
+ // Check workspace still exists on disk
88
+ try {
89
+ await fs.access(workspacePath);
90
+ } catch {
91
+ return; // Workspace deleted, skip
92
+ }
93
+
94
+ const fullProjDir = path.join(projectsDir, projDir);
95
+ const session = await getMostRecentSession(fullProjDir);
96
+ if (!session) return;
97
+
98
+ sessions.push({
99
+ workspaceName,
100
+ workspacePath,
101
+ sessionId: session.sessionId,
102
+ lastActiveAt: session.lastActiveAt,
103
+ lastActiveLabel: relativeTime(session.lastActiveAt),
104
+ agentType,
105
+ });
106
+ }));
107
+
108
+ // Sort by most recently active first
109
+ sessions.sort((a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime());
110
+
111
+ return sessions;
112
+ }
113
+
114
+ /** @internal exported for testing */
115
+ export function pathToProjectDirName(absPath: string, agentType: 'claude' | 'pi' = 'claude'): string {
116
+ if (agentType === 'pi') {
117
+ // Pi format: --Users-yotambloom-Work-workspaces-- (leading --, single - between segments, trailing --)
118
+ // Strip leading /, then replace remaining / with -
119
+ const inner = absPath.replace(/^\//, '').replace(/\//g, '-');
120
+ return '--' + inner + '--';
121
+ }
122
+ // Claude format: -Users-yotambloom-Work-workspaces (/ replaced by -)
123
+ return absPath.replace(/\//g, '-');
124
+ }
125
+
126
+ /** @internal exported for testing */
127
+ export function extractWorkspaceName(projDir: string, prefix: string, agentType: 'claude' | 'pi' = 'claude'): string | null {
128
+ if (agentType === 'pi') {
129
+ // Pi: prefix is "--Users-...-workspaces--", dir is "--Users-...-workspaces-{name}--"
130
+ // The prefix ends with --, but workspace name is between prefix (minus trailing --) + - and trailing --
131
+ const prefixBase = prefix.slice(0, -2); // remove trailing --
132
+ if (!projDir.startsWith(prefixBase + '-')) return null;
133
+ // Ensure dir is longer than just prefixBase + '--' (which would be the prefix itself)
134
+ if (projDir === prefix) return null;
135
+ const rest = projDir.slice(prefixBase.length + 1); // skip the - separator
136
+ // rest should be "{workspace-name}--" — reject malformed dirs that don't end with --
137
+ if (!rest.endsWith('--')) return null;
138
+ const name = rest.slice(0, -2);
139
+ return name || null;
140
+ }
141
+ // Claude: remove prefix + the separator dash
142
+ const rest = projDir.slice(prefix.length + 1);
143
+ if (!rest) return null;
144
+ return rest;
145
+ }
146
+
147
+ interface SessionInfo {
148
+ sessionId: string;
149
+ lastActiveAt: Date;
150
+ }
151
+
152
+ /**
153
+ * Find the most recently active session in a project directory.
154
+ * Reads .jsonl files and checks the last line's timestamp.
155
+ */
156
+ async function getMostRecentSession(projDir: string): Promise<SessionInfo | null> {
157
+ let entries: string[];
158
+ try {
159
+ entries = await fs.readdir(projDir);
160
+ } catch {
161
+ return null;
162
+ }
163
+
164
+ const jsonlFiles = entries.filter(f => f.endsWith('.jsonl'));
165
+ if (jsonlFiles.length === 0) return null;
166
+
167
+ let bestSession: SessionInfo | null = null;
168
+
169
+ // For performance, check file modification times first to find candidates
170
+ const fileStats = await Promise.all(
171
+ jsonlFiles.map(async (f) => {
172
+ try {
173
+ const stat = await fs.stat(path.join(projDir, f));
174
+ return { file: f, mtime: stat.mtime };
175
+ } catch {
176
+ return null;
177
+ }
178
+ })
179
+ );
180
+
181
+ // Sort by mtime descending, only check the most recent files
182
+ const sorted = fileStats
183
+ .filter((s): s is NonNullable<typeof s> => s !== null)
184
+ .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
185
+
186
+ // Only read the most recent session file to get accurate timestamp
187
+ for (const { file, mtime } of sorted.slice(0, 1)) {
188
+ const sessionId = file.replace('.jsonl', '');
189
+ const timestamp = await getLastTimestamp(path.join(projDir, file));
190
+ bestSession = {
191
+ sessionId,
192
+ lastActiveAt: timestamp || mtime,
193
+ };
194
+ }
195
+
196
+ return bestSession;
197
+ }
198
+
199
+ /**
200
+ * Read the last few lines of a .jsonl file and extract the most recent timestamp.
201
+ */
202
+ async function getLastTimestamp(filePath: string): Promise<Date | null> {
203
+ try {
204
+ const handle = await fs.open(filePath, 'r');
205
+ try {
206
+ const stat = await handle.stat();
207
+ // Read last 4KB to find the last line with a timestamp
208
+ const readSize = Math.min(4096, stat.size);
209
+ const buffer = Buffer.alloc(readSize);
210
+ await handle.read(buffer, 0, readSize, Math.max(0, stat.size - readSize));
211
+ const content = buffer.toString('utf-8');
212
+
213
+ // Find the last line with a timestamp
214
+ const lines = content.split('\n').filter(l => l.trim());
215
+ for (let i = lines.length - 1; i >= 0; i--) {
216
+ try {
217
+ const parsed = JSON.parse(lines[i]);
218
+ if (parsed.timestamp) {
219
+ return new Date(parsed.timestamp);
220
+ }
221
+ } catch {
222
+ continue;
223
+ }
224
+ }
225
+ } finally {
226
+ await handle.close();
227
+ }
228
+ } catch {
229
+ // ignore
230
+ }
231
+ return null;
232
+ }
233
+
234
+ /** @internal exported for testing */
235
+ export function relativeTime(date: Date): string {
236
+ const now = Date.now();
237
+ const diff = now - date.getTime();
238
+
239
+ const minutes = Math.floor(diff / 60000);
240
+ if (minutes < 1) return 'just now';
241
+ if (minutes < 60) return `${minutes}m ago`;
242
+
243
+ const hours = Math.floor(minutes / 60);
244
+ if (hours < 24) return `${hours}h ago`;
245
+
246
+ const days = Math.floor(hours / 24);
247
+ if (days < 30) return `${days}d ago`;
248
+
249
+ const months = Math.floor(days / 30);
250
+ return `${months}mo ago`;
251
+ }
@@ -0,0 +1,94 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import * as fs from 'fs/promises';
4
+ import * as path from 'path';
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ export interface CleanupResult {
9
+ operation: string;
10
+ filesRemoved: number;
11
+ spaceFreed: string;
12
+ success: boolean;
13
+ }
14
+
15
+ export const calculateDirSize = async (dirPath: string): Promise<number> => {
16
+ try {
17
+ const { stdout } = await execAsync(`du -sk "${dirPath}"`);
18
+ const sizeInKB = parseInt(stdout.split('\t')[0], 10);
19
+ return sizeInKB;
20
+ } catch {
21
+ return 0;
22
+ }
23
+ };
24
+
25
+ export const removeNodeModules = async (repoPath: string): Promise<CleanupResult> => {
26
+ const nodeModulesPath = path.join(repoPath, 'node_modules');
27
+
28
+ try {
29
+ const sizeBefore = await calculateDirSize(nodeModulesPath);
30
+ await fs.rm(nodeModulesPath, { recursive: true, force: true });
31
+
32
+ return {
33
+ operation: 'Remove node_modules',
34
+ filesRemoved: 1,
35
+ spaceFreed: `${(sizeBefore / 1024).toFixed(2)} MB`,
36
+ success: true,
37
+ };
38
+ } catch (error) {
39
+ return {
40
+ operation: 'Remove node_modules',
41
+ filesRemoved: 0,
42
+ spaceFreed: '0 MB',
43
+ success: false,
44
+ };
45
+ }
46
+ };
47
+
48
+ export const removeBuildArtifacts = async (repoPath: string): Promise<CleanupResult> => {
49
+ const artifacts = ['dist', 'build', '.next', 'coverage', 'out'];
50
+ let totalSize = 0;
51
+ let removed = 0;
52
+
53
+ for (const artifact of artifacts) {
54
+ const artifactPath = path.join(repoPath, artifact);
55
+ try {
56
+ const size = await calculateDirSize(artifactPath);
57
+ await fs.rm(artifactPath, { recursive: true, force: true });
58
+ totalSize += size;
59
+ removed++;
60
+ } catch {
61
+ // Ignore if doesn't exist
62
+ }
63
+ }
64
+
65
+ return {
66
+ operation: 'Remove build artifacts',
67
+ filesRemoved: removed,
68
+ spaceFreed: `${(totalSize / 1024).toFixed(2)} MB`,
69
+ success: true,
70
+ };
71
+ };
72
+
73
+ export const gitClean = async (repoPath: string, dryRun: boolean = false): Promise<CleanupResult> => {
74
+ try {
75
+ const cmd = dryRun ? 'git clean -fdxn' : 'git clean -fdx';
76
+ const { stdout } = await execAsync(cmd, { cwd: repoPath });
77
+
78
+ const lines = stdout.split('\n').filter(l => l.trim());
79
+
80
+ return {
81
+ operation: 'Git clean',
82
+ filesRemoved: lines.length,
83
+ spaceFreed: 'Unknown',
84
+ success: true,
85
+ };
86
+ } catch {
87
+ return {
88
+ operation: 'Git clean',
89
+ filesRemoved: 0,
90
+ spaceFreed: '0 MB',
91
+ success: false,
92
+ };
93
+ }
94
+ };
@@ -0,0 +1,25 @@
1
+ export const colors = {
2
+ reset: '\x1b[0m',
3
+ bright: '\x1b[1m',
4
+ dim: '\x1b[2m',
5
+
6
+ // Foreground colors
7
+ red: '\x1b[31m',
8
+ green: '\x1b[32m',
9
+ yellow: '\x1b[33m',
10
+ blue: '\x1b[34m',
11
+ magenta: '\x1b[35m',
12
+ cyan: '\x1b[36m',
13
+ white: '\x1b[37m',
14
+ gray: '\x1b[90m',
15
+
16
+ // Background colors
17
+ bgRed: '\x1b[41m',
18
+ bgGreen: '\x1b[42m',
19
+ bgYellow: '\x1b[43m',
20
+ bgBlue: '\x1b[44m',
21
+ };
22
+
23
+ export const colorize = (text: string, color: keyof typeof colors): string => {
24
+ return `${colors[color]}${text}${colors.reset}`;
25
+ };
@@ -0,0 +1,46 @@
1
+ import { Command } from 'commander';
2
+ import { listWorkspaces } from './workspace-meta';
3
+ import { promptWorkspaceSelection } from './prompts';
4
+
5
+ export interface GlobalOpts {
6
+ forceRefresh: boolean;
7
+ yes: boolean;
8
+ }
9
+
10
+ /**
11
+ * Extract global options (--force-refresh, --yes) from the root program.
12
+ * Commander doesn't auto-inherit parent options to subcommands,
13
+ * so we walk up the parent chain to find them.
14
+ */
15
+ export function getGlobalOpts(cmd: Command): GlobalOpts {
16
+ let root = cmd;
17
+ while (root.parent) root = root.parent;
18
+ const opts = root.opts();
19
+ return {
20
+ forceRefresh: opts.forceRefresh ?? false,
21
+ yes: opts.yes ?? false,
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Resolve workspace name: use provided value, or prompt interactively.
27
+ * In non-interactive mode (no TTY), throws if no name provided.
28
+ */
29
+ export async function resolveWorkspace(name?: string): Promise<string> {
30
+ if (name) return name;
31
+ if (!process.stdout.isTTY) {
32
+ throw new Error('Workspace name required in non-interactive mode. Use --workspace <name> or provide as argument.');
33
+ }
34
+ const workspaces = await listWorkspaces();
35
+ if (workspaces.length === 0) {
36
+ throw new Error('No workspaces found. Create one first with: nemus create');
37
+ }
38
+ return promptWorkspaceSelection(workspaces);
39
+ }
40
+
41
+ /**
42
+ * Parse comma-separated list from a flag value.
43
+ */
44
+ export function parseList(value: string): string[] {
45
+ return value.split(',').map(s => s.trim()).filter(Boolean);
46
+ }