@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,182 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import { installPiExtensions, uninstallPiExtensions, getPiExtensionStatus } from './pi-extensions';
6
+
7
+ describe('Pi extensions', () => {
8
+ let tmpDir: string;
9
+ let extensionsDir: string;
10
+
11
+ beforeEach(() => {
12
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ext-test-'));
13
+ extensionsDir = path.join(tmpDir, '.pi', 'agent', 'extensions');
14
+ });
15
+
16
+ afterEach(() => {
17
+ fs.rmSync(tmpDir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe('installPiExtensions', () => {
21
+ it('creates the extensions directory if it does not exist', () => {
22
+ expect(fs.existsSync(extensionsDir)).toBe(false);
23
+ installPiExtensions(extensionsDir);
24
+ expect(fs.existsSync(extensionsDir)).toBe(true);
25
+ });
26
+
27
+ it('installs all managed extensions', () => {
28
+ installPiExtensions(extensionsDir);
29
+
30
+ const files = fs.readdirSync(extensionsDir);
31
+ expect(files).toContain('ws-permission-sync.ts');
32
+ expect(files).toContain('ws-review-reminder.ts');
33
+ expect(files).toContain('ws-permissions-gate.ts');
34
+ });
35
+
36
+ it('installed extensions contain the managed marker', () => {
37
+ installPiExtensions(extensionsDir);
38
+
39
+ for (const file of fs.readdirSync(extensionsDir)) {
40
+ const content = fs.readFileSync(path.join(extensionsDir, file), 'utf-8');
41
+ expect(content).toContain('auto-generated by workspace-manager');
42
+ }
43
+ });
44
+
45
+ it('permission-sync extension has the script path replaced', () => {
46
+ installPiExtensions(extensionsDir);
47
+
48
+ const content = fs.readFileSync(
49
+ path.join(extensionsDir, 'ws-permission-sync.ts'),
50
+ 'utf-8'
51
+ );
52
+ // Should not contain the placeholder
53
+ expect(content).not.toContain('__SYNC_PERMISSIONS_SCRIPT__');
54
+ // Should contain an actual path
55
+ expect(content).toContain('sync-permissions.sh');
56
+ });
57
+
58
+ it('review-reminder extension references cache directory', () => {
59
+ installPiExtensions(extensionsDir);
60
+
61
+ const content = fs.readFileSync(
62
+ path.join(extensionsDir, 'ws-review-reminder.ts'),
63
+ 'utf-8'
64
+ );
65
+ expect(content).toContain('.workspace-manager-cache');
66
+ expect(content).toContain('last-permission-review');
67
+ });
68
+
69
+ it('permissions-gate extension has dangerous patterns', () => {
70
+ installPiExtensions(extensionsDir);
71
+
72
+ const content = fs.readFileSync(
73
+ path.join(extensionsDir, 'ws-permissions-gate.ts'),
74
+ 'utf-8'
75
+ );
76
+ expect(content).toContain('rm\\s+-rf');
77
+ expect(content).toContain('isToolCallEventType');
78
+ });
79
+
80
+ it('is idempotent — installing twice overwrites cleanly', () => {
81
+ installPiExtensions(extensionsDir);
82
+ const firstFiles = fs.readdirSync(extensionsDir).sort();
83
+
84
+ installPiExtensions(extensionsDir);
85
+ const secondFiles = fs.readdirSync(extensionsDir).sort();
86
+
87
+ expect(firstFiles).toEqual(secondFiles);
88
+ });
89
+
90
+ it('does not touch non-managed files', () => {
91
+ fs.mkdirSync(extensionsDir, { recursive: true });
92
+ fs.writeFileSync(
93
+ path.join(extensionsDir, 'my-custom-extension.ts'),
94
+ '// custom'
95
+ );
96
+
97
+ installPiExtensions(extensionsDir);
98
+
99
+ expect(
100
+ fs.readFileSync(path.join(extensionsDir, 'my-custom-extension.ts'), 'utf-8')
101
+ ).toBe('// custom');
102
+ });
103
+ });
104
+
105
+ describe('uninstallPiExtensions', () => {
106
+ it('removes all managed extensions', () => {
107
+ installPiExtensions(extensionsDir);
108
+ expect(fs.readdirSync(extensionsDir).length).toBeGreaterThan(0);
109
+
110
+ uninstallPiExtensions(extensionsDir);
111
+
112
+ expect(fs.readdirSync(extensionsDir).length).toBe(0);
113
+ });
114
+
115
+ it('does not remove non-managed extensions', () => {
116
+ installPiExtensions(extensionsDir);
117
+ fs.writeFileSync(
118
+ path.join(extensionsDir, 'my-custom-extension.ts'),
119
+ '// custom extension'
120
+ );
121
+
122
+ uninstallPiExtensions(extensionsDir);
123
+
124
+ const remaining = fs.readdirSync(extensionsDir);
125
+ expect(remaining).toEqual(['my-custom-extension.ts']);
126
+ });
127
+
128
+ it('removes files with managed marker even if renamed', () => {
129
+ installPiExtensions(extensionsDir);
130
+
131
+ // Rename a managed extension
132
+ fs.renameSync(
133
+ path.join(extensionsDir, 'ws-permission-sync.ts'),
134
+ path.join(extensionsDir, 'renamed-sync.ts')
135
+ );
136
+
137
+ uninstallPiExtensions(extensionsDir);
138
+
139
+ // renamed file should also be removed (has marker)
140
+ expect(fs.readdirSync(extensionsDir).length).toBe(0);
141
+ });
142
+
143
+ it('handles non-existent directory gracefully', () => {
144
+ expect(() => uninstallPiExtensions('/nonexistent/path')).not.toThrow();
145
+ });
146
+ });
147
+
148
+ describe('getPiExtensionStatus', () => {
149
+ it('reports all missing when nothing is installed', () => {
150
+ fs.mkdirSync(extensionsDir, { recursive: true });
151
+
152
+ const status = getPiExtensionStatus(extensionsDir);
153
+ expect(status.installed).toHaveLength(0);
154
+ expect(status.missing.length).toBeGreaterThan(0);
155
+ expect(status.missing).toContain('ws-permission-sync.ts');
156
+ expect(status.missing).toContain('ws-review-reminder.ts');
157
+ expect(status.missing).toContain('ws-permissions-gate.ts');
158
+ });
159
+
160
+ it('reports all installed after installation', () => {
161
+ installPiExtensions(extensionsDir);
162
+
163
+ const status = getPiExtensionStatus(extensionsDir);
164
+ expect(status.missing).toHaveLength(0);
165
+ expect(status.installed).toContain('ws-permission-sync.ts');
166
+ expect(status.installed).toContain('ws-review-reminder.ts');
167
+ expect(status.installed).toContain('ws-permissions-gate.ts');
168
+ });
169
+
170
+ it('reports partial when some are missing', () => {
171
+ installPiExtensions(extensionsDir);
172
+
173
+ // Delete one
174
+ fs.unlinkSync(path.join(extensionsDir, 'ws-review-reminder.ts'));
175
+
176
+ const status = getPiExtensionStatus(extensionsDir);
177
+ expect(status.installed).not.toContain('ws-review-reminder.ts');
178
+ expect(status.missing).toContain('ws-review-reminder.ts');
179
+ expect(status.installed.length + status.missing.length).toBe(5);
180
+ });
181
+ });
182
+ });
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Pi extension management — install/uninstall Pi extensions that replicate
3
+ * Claude Code hooks and MCP functionality.
4
+ *
5
+ * Pi uses TypeScript extensions instead of:
6
+ * - settings.json hooks (Stop, SessionStart, PostToolUse)
7
+ * - .mcp.json / `claude mcp add` (MCP servers)
8
+ *
9
+ * This module generates and installs extensions from bundled templates.
10
+ */
11
+
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import * as os from 'os';
15
+ import { logSuccess, logInfo, logWarning } from './logger';
16
+
17
+ const PI_EXTENSIONS_DIR = path.join(os.homedir(), '.pi', 'agent', 'extensions');
18
+
19
+ /** Marker comment that identifies workspace-manager managed extensions */
20
+ const MANAGED_MARKER = '(auto-generated by workspace-manager)';
21
+
22
+ /**
23
+ * Extension template definitions.
24
+ * Each maps a template file to the installed extension filename,
25
+ * with optional placeholder replacements.
26
+ */
27
+ interface ExtensionTemplate {
28
+ /** Template filename (relative to pi-extensions/ dir) */
29
+ templateFile: string;
30
+ /** Installed filename under ~/.pi/agent/extensions/ */
31
+ installedName: string;
32
+ /** Placeholder replacements: key => value */
33
+ replacements?: Record<string, string>;
34
+ }
35
+
36
+ function getTemplatesDir(): string {
37
+ // When compiled: dist/utils/ -> dist/pi-extensions/
38
+ const distPath = path.resolve(__dirname, '..', 'pi-extensions');
39
+ // When running from source: src/utils/ -> src/pi-extensions/
40
+ const srcPath = path.resolve(__dirname, '..', '..', 'src', 'pi-extensions');
41
+
42
+ if (fs.existsSync(distPath)) return distPath;
43
+ if (fs.existsSync(srcPath)) return srcPath;
44
+
45
+ throw new Error('Could not find pi-extensions/ directory.');
46
+ }
47
+
48
+ function getSyncPermissionsScript(): string {
49
+ const scriptPath = path.resolve(__dirname, '..', '..', 'sync-permissions.sh');
50
+ if (fs.existsSync(scriptPath)) return scriptPath;
51
+ throw new Error('Could not find sync-permissions.sh');
52
+ }
53
+
54
+ function getExtensionTemplates(options: { workspaceInputStatus?: boolean } = {}): ExtensionTemplate[] {
55
+ let syncScript: string;
56
+ try {
57
+ syncScript = getSyncPermissionsScript();
58
+ } catch {
59
+ logWarning('Could not locate sync-permissions.sh — permission-sync extension will need manual path configuration');
60
+ syncScript = ''; // Empty placeholder — user must configure manually
61
+ }
62
+
63
+ const templates: ExtensionTemplate[] = [
64
+ {
65
+ templateFile: 'permission-sync.ts.template',
66
+ installedName: 'ws-permission-sync.ts',
67
+ replacements: syncScript ? { '__SYNC_PERMISSIONS_SCRIPT__': syncScript } : {},
68
+ },
69
+ {
70
+ templateFile: 'review-reminder.ts.template',
71
+ installedName: 'ws-review-reminder.ts',
72
+ },
73
+ {
74
+ templateFile: 'permissions-gate.ts.template',
75
+ installedName: 'ws-permissions-gate.ts',
76
+ },
77
+ {
78
+ templateFile: 'pr-fix-trigger.ts.template',
79
+ installedName: 'ws-pr-fix-trigger.ts',
80
+ },
81
+ ];
82
+
83
+ // Workspace input-status widget — enabled by default, opt-out via config
84
+ if (options.workspaceInputStatus !== false) {
85
+ templates.push({
86
+ templateFile: 'workspace-input-status.ts.template',
87
+ installedName: 'ws-workspace-input-status.ts',
88
+ });
89
+ }
90
+
91
+ return templates;
92
+ }
93
+
94
+ /**
95
+ * Install all workspace-manager Pi extensions.
96
+ * Idempotent — overwrites existing managed extensions with latest templates.
97
+ *
98
+ * @param extensionsDir Override the extensions directory (for testing).
99
+ */
100
+ export function installPiExtensions(extensionsDir?: string, options: { workspaceInputStatus?: boolean } = {}): void {
101
+ const targetDir = extensionsDir ?? PI_EXTENSIONS_DIR;
102
+
103
+ let templatesDir: string;
104
+ try {
105
+ templatesDir = getTemplatesDir();
106
+ } catch {
107
+ logWarning('Could not find Pi extension templates — skipping');
108
+ return;
109
+ }
110
+
111
+ if (!fs.existsSync(targetDir)) {
112
+ fs.mkdirSync(targetDir, { recursive: true });
113
+ }
114
+
115
+ const templates = getExtensionTemplates(options);
116
+ let installed = 0;
117
+
118
+ for (const tpl of templates) {
119
+ const templatePath = path.join(templatesDir, tpl.templateFile);
120
+ if (!fs.existsSync(templatePath)) {
121
+ logWarning(`Template not found: ${tpl.templateFile} — skipping`);
122
+ continue;
123
+ }
124
+
125
+ let content = fs.readFileSync(templatePath, 'utf-8');
126
+
127
+ // Apply replacements
128
+ if (tpl.replacements) {
129
+ for (const [placeholder, value] of Object.entries(tpl.replacements)) {
130
+ content = content.replace(new RegExp(escapeRegex(placeholder), 'g'), value);
131
+ }
132
+ }
133
+
134
+ const targetPath = path.join(targetDir, tpl.installedName);
135
+ fs.writeFileSync(targetPath, content, 'utf-8');
136
+ installed++;
137
+ }
138
+
139
+ if (installed > 0) {
140
+ logSuccess(`Installed ${installed} Pi extension(s) in ${targetDir}`);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Uninstall workspace-manager managed Pi extensions.
146
+ * Only removes extensions that contain the managed marker or match
147
+ * known installed names.
148
+ *
149
+ * @param extensionsDir Override the extensions directory (for testing).
150
+ */
151
+ /**
152
+ * Uninstall workspace-manager managed Pi extensions.
153
+ * Pass `onlyNames` to remove only specific extension files (for opt-out).
154
+ *
155
+ * @param extensionsDir Override the extensions directory (for testing).
156
+ * @param options.onlyNames If set, only remove files in this list.
157
+ */
158
+ export function uninstallPiExtensions(
159
+ extensionsDir?: string,
160
+ options?: { onlyNames?: string[] },
161
+ ): void {
162
+ const targetDir = extensionsDir ?? PI_EXTENSIONS_DIR;
163
+
164
+ if (!fs.existsSync(targetDir)) return;
165
+
166
+ const templates = getExtensionTemplates({ workspaceInputStatus: true });
167
+ const knownNames = new Set(templates.map(t => t.installedName));
168
+ const onlyNames = options?.onlyNames ? new Set(options.onlyNames) : null;
169
+ let removed = 0;
170
+
171
+ for (const file of fs.readdirSync(targetDir)) {
172
+ if (!file.endsWith('.ts')) continue;
173
+
174
+ // When onlyNames is set, skip files not in that list
175
+ if (onlyNames && !onlyNames.has(file)) continue;
176
+
177
+ const filePath = path.join(targetDir, file);
178
+
179
+ // Remove if it matches a known managed name
180
+ if (knownNames.has(file)) {
181
+ fs.unlinkSync(filePath);
182
+ removed++;
183
+ continue;
184
+ }
185
+
186
+ // Also remove if it contains the managed marker (for renamed files)
187
+ if (!onlyNames) {
188
+ try {
189
+ const content = fs.readFileSync(filePath, 'utf-8');
190
+ if (content.includes(MANAGED_MARKER)) {
191
+ fs.unlinkSync(filePath);
192
+ removed++;
193
+ }
194
+ } catch { /* ignore read errors */ }
195
+ }
196
+ }
197
+
198
+ if (removed > 0) {
199
+ logSuccess(`Removed ${removed} Pi extension(s) from ${targetDir}`);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Check which managed Pi extensions are installed.
205
+ */
206
+ export function getPiExtensionStatus(extensionsDir?: string): { installed: string[]; missing: string[] } {
207
+ const targetDir = extensionsDir ?? PI_EXTENSIONS_DIR;
208
+ const templates = getExtensionTemplates({ workspaceInputStatus: true });
209
+ const installed: string[] = [];
210
+ const missing: string[] = [];
211
+
212
+ for (const tpl of templates) {
213
+ const targetPath = path.join(targetDir, tpl.installedName);
214
+ if (fs.existsSync(targetPath)) {
215
+ installed.push(tpl.installedName);
216
+ } else {
217
+ missing.push(tpl.installedName);
218
+ }
219
+ }
220
+
221
+ return { installed, missing };
222
+ }
223
+
224
+ function escapeRegex(str: string): string {
225
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
226
+ }
@@ -0,0 +1,73 @@
1
+ import * as cliProgress from 'cli-progress';
2
+
3
+ export class ProgressTracker {
4
+ private multibar: cliProgress.MultiBar;
5
+ private bars: Map<string, cliProgress.SingleBar>;
6
+ private startTimes: Map<string, number>;
7
+
8
+ constructor() {
9
+ this.multibar = new cliProgress.MultiBar(
10
+ {
11
+ clearOnComplete: false,
12
+ hideCursor: true,
13
+ format: ' {bar} | {percentage}% | {value}/{total} | {task} | {duration}',
14
+ },
15
+ cliProgress.Presets.shades_classic
16
+ );
17
+ this.bars = new Map();
18
+ this.startTimes = new Map();
19
+ }
20
+
21
+ addTask(taskId: string, total: number, taskName: string): void {
22
+ const bar = this.multibar.create(total, 0, { task: taskName, duration: '0s' });
23
+ this.bars.set(taskId, bar);
24
+ this.startTimes.set(taskId, Date.now());
25
+ }
26
+
27
+ updateTask(taskId: string, current: number, taskName?: string): void {
28
+ const bar = this.bars.get(taskId);
29
+ const startTime = this.startTimes.get(taskId);
30
+
31
+ if (bar && startTime) {
32
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
33
+ const duration = this.formatDuration(elapsed);
34
+
35
+ bar.update(current, {
36
+ task: taskName || 'Processing',
37
+ duration,
38
+ });
39
+ }
40
+ }
41
+
42
+ completeTask(taskId: string): void {
43
+ const bar = this.bars.get(taskId);
44
+ if (bar) {
45
+ bar.stop();
46
+ this.bars.delete(taskId);
47
+ this.startTimes.delete(taskId);
48
+ }
49
+ }
50
+
51
+ stop(): void {
52
+ this.multibar.stop();
53
+ }
54
+
55
+ private formatDuration(seconds: number): string {
56
+ if (seconds < 60) {
57
+ return `${seconds}s`;
58
+ }
59
+ const minutes = Math.floor(seconds / 60);
60
+ const remainingSeconds = seconds % 60;
61
+ return `${minutes}m ${remainingSeconds}s`;
62
+ }
63
+ }
64
+
65
+ export const createSimpleProgressBar = (total: number, taskName: string): cliProgress.SingleBar => {
66
+ return new cliProgress.SingleBar(
67
+ {
68
+ format: ` ${taskName} | {bar} | {percentage}% | {value}/{total} | {duration}`,
69
+ hideCursor: true,
70
+ },
71
+ cliProgress.Presets.shades_classic
72
+ );
73
+ };
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ const { mockPrompt } = vi.hoisted(() => ({
4
+ mockPrompt: vi.fn(),
5
+ }));
6
+
7
+ // Mock inquirer before imports
8
+ vi.mock('inquirer', () => ({
9
+ default: {
10
+ prompt: mockPrompt,
11
+ registerPrompt: vi.fn(),
12
+ },
13
+ }));
14
+
15
+ vi.mock('inquirer-autocomplete-prompt', () => ({
16
+ default: {},
17
+ }));
18
+
19
+ vi.mock('fuzzy', () => ({
20
+ filter: (input: string, list: string[]) => {
21
+ const lower = input.toLowerCase();
22
+ return list
23
+ .filter(item => item.toLowerCase().includes(lower))
24
+ .map(item => ({ original: item, string: item }));
25
+ },
26
+ }));
27
+
28
+ vi.mock('./colors', () => ({
29
+ colorize: (text: string) => text,
30
+ }));
31
+
32
+ vi.mock('./validation', () => ({
33
+ validateWorkspaceName: vi.fn().mockReturnValue(true),
34
+ checkWorkspaceExists: vi.fn().mockResolvedValue(false),
35
+ sanitizeWorkspaceName: vi.fn((input: string) => input),
36
+ }));
37
+
38
+ import { promptMultiWorkspaceSelection } from './prompts';
39
+
40
+ const makeWorkspaces = (...names: string[]) =>
41
+ names.map(name => ({
42
+ name,
43
+ path: `/workspaces/${name}`,
44
+ metadata: { repositories: [], createdAt: new Date().toISOString() },
45
+ }));
46
+
47
+ describe('promptMultiWorkspaceSelection', () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ vi.spyOn(console, 'log').mockImplementation(() => {});
51
+ });
52
+
53
+ it('returns selected workspace names', async () => {
54
+ mockPrompt
55
+ .mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
56
+ .mockResolvedValueOnce({ workspaceName: 'ws-beta' })
57
+ .mockResolvedValueOnce({ workspaceName: 'done' });
58
+
59
+ const result = await promptMultiWorkspaceSelection(
60
+ makeWorkspaces('ws-alpha', 'ws-beta', 'ws-gamma')
61
+ );
62
+
63
+ expect(result).toEqual(['ws-alpha', 'ws-beta']);
64
+ });
65
+
66
+ it('requires at least one selection before accepting done', async () => {
67
+ // First "done" should be rejected, then select one, then done
68
+ mockPrompt
69
+ .mockResolvedValueOnce({ workspaceName: 'done' })
70
+ .mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
71
+ .mockResolvedValueOnce({ workspaceName: 'done' });
72
+
73
+ const result = await promptMultiWorkspaceSelection(
74
+ makeWorkspaces('ws-alpha', 'ws-beta')
75
+ );
76
+
77
+ expect(result).toEqual(['ws-alpha']);
78
+ // Should have prompted 3 times (done rejected, select, done accepted)
79
+ expect(mockPrompt).toHaveBeenCalledTimes(3);
80
+ });
81
+
82
+ it('auto-completes when all workspaces are selected', async () => {
83
+ mockPrompt
84
+ .mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
85
+ .mockResolvedValueOnce({ workspaceName: 'ws-beta' });
86
+
87
+ const result = await promptMultiWorkspaceSelection(
88
+ makeWorkspaces('ws-alpha', 'ws-beta')
89
+ );
90
+
91
+ expect(result).toEqual(['ws-alpha', 'ws-beta']);
92
+ // Only 2 prompts needed - loop breaks when no available names left
93
+ expect(mockPrompt).toHaveBeenCalledTimes(2);
94
+ });
95
+
96
+ it('returns single workspace selection', async () => {
97
+ mockPrompt
98
+ .mockResolvedValueOnce({ workspaceName: 'ws-only' })
99
+ .mockResolvedValueOnce({ workspaceName: 'done' });
100
+
101
+ const result = await promptMultiWorkspaceSelection(
102
+ makeWorkspaces('ws-only', 'ws-other')
103
+ );
104
+
105
+ expect(result).toEqual(['ws-only']);
106
+ });
107
+
108
+ it('exits process on Ctrl+C', async () => {
109
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
110
+ throw new Error('process.exit');
111
+ });
112
+
113
+ mockPrompt.mockRejectedValueOnce(new Error('User force closed'));
114
+
115
+ await expect(
116
+ promptMultiWorkspaceSelection(makeWorkspaces('ws-alpha'))
117
+ ).rejects.toThrow('process.exit');
118
+
119
+ expect(mockExit).toHaveBeenCalledWith(0);
120
+ mockExit.mockRestore();
121
+ });
122
+
123
+ it('prints summary with correct singular form', async () => {
124
+ const logSpy = vi.spyOn(console, 'log');
125
+ mockPrompt
126
+ .mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
127
+ .mockResolvedValueOnce({ workspaceName: 'done' });
128
+
129
+ await promptMultiWorkspaceSelection(makeWorkspaces('ws-alpha', 'ws-beta'));
130
+
131
+ const summaryCalls = logSpy.mock.calls.map(c => c[0]);
132
+ expect(summaryCalls).toContainEqual(
133
+ expect.stringContaining('Selected 1 workspace:')
134
+ );
135
+ });
136
+
137
+ it('prints summary with correct plural form', async () => {
138
+ const logSpy = vi.spyOn(console, 'log');
139
+ mockPrompt
140
+ .mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
141
+ .mockResolvedValueOnce({ workspaceName: 'ws-beta' })
142
+ .mockResolvedValueOnce({ workspaceName: 'done' });
143
+
144
+ await promptMultiWorkspaceSelection(
145
+ makeWorkspaces('ws-alpha', 'ws-beta', 'ws-gamma')
146
+ );
147
+
148
+ const summaryCalls = logSpy.mock.calls.map(c => c[0]);
149
+ expect(summaryCalls).toContainEqual(
150
+ expect.stringContaining('Selected 2 workspaces:')
151
+ );
152
+ });
153
+
154
+ it('prints green confirmation after each selection', async () => {
155
+ const logSpy = vi.spyOn(console, 'log');
156
+ mockPrompt
157
+ .mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
158
+ .mockResolvedValueOnce({ workspaceName: 'ws-beta' })
159
+ .mockResolvedValueOnce({ workspaceName: 'done' });
160
+
161
+ await promptMultiWorkspaceSelection(
162
+ makeWorkspaces('ws-alpha', 'ws-beta', 'ws-gamma')
163
+ );
164
+
165
+ const loggedMessages = logSpy.mock.calls.map(c => c[0]);
166
+ expect(loggedMessages).toContainEqual(expect.stringContaining('Added: ws-alpha'));
167
+ expect(loggedMessages).toContainEqual(expect.stringContaining('Added: ws-beta'));
168
+ });
169
+ });