@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,593 @@
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 {
6
+ isReusablePermission,
7
+ mergePermissions,
8
+ installPermissionSyncHook,
9
+ uninstallPermissionSyncHook,
10
+ readClaudeSettings,
11
+ installWorkspaceSkills,
12
+ uninstallWorkspaceSkills,
13
+ repairStaleHooks,
14
+ } from './permission-sync';
15
+
16
+ describe('isReusablePermission', () => {
17
+ it('keeps generic bash permissions', () => {
18
+ expect(isReusablePermission('Bash(npm test:*)')).toBe(true);
19
+ expect(isReusablePermission('Bash(git push:*)')).toBe(true);
20
+ expect(isReusablePermission('Bash(gh pr create:*)')).toBe(true);
21
+ });
22
+
23
+ it('keeps mcp tool permissions', () => {
24
+ expect(isReusablePermission('mcp__workspace-manager__list-workspaces')).toBe(true);
25
+ });
26
+
27
+ it('keeps Skill and WebFetch permissions', () => {
28
+ expect(isReusablePermission('Skill(commit)')).toBe(true);
29
+ expect(isReusablePermission('WebFetch(domain:github.com)')).toBe(true);
30
+ });
31
+
32
+ it('skips entries with /Users/ absolute paths', () => {
33
+ expect(
34
+ isReusablePermission('Bash(git -C /Users/john/repos/my-project pull)')
35
+ ).toBe(false);
36
+ });
37
+
38
+ it('skips entries with /home/ absolute paths', () => {
39
+ expect(
40
+ isReusablePermission('Bash(git -C /home/john/repos/my-project pull)')
41
+ ).toBe(false);
42
+ });
43
+
44
+ it('skips heredoc commit messages', () => {
45
+ expect(
46
+ isReusablePermission(
47
+ 'Bash(git commit -m "$(cat <<\'EOF\'\nfix: some message\nEOF\n)")'
48
+ )
49
+ ).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe('mergePermissions', () => {
54
+ let tempDir: string;
55
+ let projectSettings: string;
56
+ let globalSettings: string;
57
+
58
+ beforeEach(() => {
59
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'perm-sync-test-'));
60
+ const projectDir = path.join(tempDir, 'project', '.claude');
61
+ const globalDir = path.join(tempDir, 'global');
62
+ fs.mkdirSync(projectDir, { recursive: true });
63
+ fs.mkdirSync(globalDir, { recursive: true });
64
+ projectSettings = path.join(projectDir, 'settings.local.json');
65
+ globalSettings = path.join(globalDir, 'settings.json');
66
+ });
67
+
68
+ afterEach(() => {
69
+ fs.rmSync(tempDir, { recursive: true, force: true });
70
+ });
71
+
72
+ it('merges new permissions into an empty global file', () => {
73
+ fs.writeFileSync(
74
+ projectSettings,
75
+ JSON.stringify({
76
+ permissions: { allow: ['Bash(npm test:*)', 'Bash(git push:*)'] },
77
+ })
78
+ );
79
+
80
+ const added = mergePermissions(projectSettings, globalSettings);
81
+ expect(added).toBe(2);
82
+
83
+ const result = JSON.parse(fs.readFileSync(globalSettings, 'utf-8'));
84
+ expect(result.permissions.allow).toEqual(['Bash(npm test:*)', 'Bash(git push:*)']);
85
+ });
86
+
87
+ it('merges into existing global permissions without duplicates', () => {
88
+ fs.writeFileSync(
89
+ projectSettings,
90
+ JSON.stringify({
91
+ permissions: { allow: ['Bash(npm test:*)', 'Bash(git push:*)'] },
92
+ })
93
+ );
94
+ fs.writeFileSync(
95
+ globalSettings,
96
+ JSON.stringify({
97
+ permissions: { allow: ['Bash(npm test:*)'] },
98
+ })
99
+ );
100
+
101
+ const added = mergePermissions(projectSettings, globalSettings);
102
+ expect(added).toBe(1);
103
+
104
+ const result = JSON.parse(fs.readFileSync(globalSettings, 'utf-8'));
105
+ expect(result.permissions.allow).toEqual(['Bash(npm test:*)', 'Bash(git push:*)']);
106
+ });
107
+
108
+ it('filters out non-reusable permissions', () => {
109
+ fs.writeFileSync(
110
+ projectSettings,
111
+ JSON.stringify({
112
+ permissions: {
113
+ allow: [
114
+ 'Bash(npm test:*)',
115
+ 'Bash(git -C /Users/john/repo pull)',
116
+ 'Bash(git commit -m "$(cat <<\'EOF\'\nfix\nEOF\n)")',
117
+ ],
118
+ },
119
+ })
120
+ );
121
+
122
+ const added = mergePermissions(projectSettings, globalSettings);
123
+ expect(added).toBe(1);
124
+
125
+ const result = JSON.parse(fs.readFileSync(globalSettings, 'utf-8'));
126
+ expect(result.permissions.allow).toEqual(['Bash(npm test:*)']);
127
+ });
128
+
129
+ it('returns 0 when project file does not exist', () => {
130
+ const added = mergePermissions('/nonexistent/path', globalSettings);
131
+ expect(added).toBe(0);
132
+ });
133
+
134
+ it('returns 0 when project has no allow entries', () => {
135
+ fs.writeFileSync(projectSettings, JSON.stringify({}));
136
+ const added = mergePermissions(projectSettings, globalSettings);
137
+ expect(added).toBe(0);
138
+ });
139
+
140
+ it('preserves other fields in global settings', () => {
141
+ fs.writeFileSync(
142
+ projectSettings,
143
+ JSON.stringify({ permissions: { allow: ['Bash(npm test:*)'] } })
144
+ );
145
+ fs.writeFileSync(
146
+ globalSettings,
147
+ JSON.stringify({
148
+ someOtherField: 'value',
149
+ permissions: { allow: [], deny: ['Bash(rm -rf:*)'] },
150
+ })
151
+ );
152
+
153
+ mergePermissions(projectSettings, globalSettings);
154
+
155
+ const result = JSON.parse(fs.readFileSync(globalSettings, 'utf-8'));
156
+ expect(result.someOtherField).toBe('value');
157
+ expect(result.permissions.deny).toEqual(['Bash(rm -rf:*)']);
158
+ expect(result.permissions.allow).toEqual(['Bash(npm test:*)']);
159
+ });
160
+
161
+ it('skips allow entries that conflict with global deny list', () => {
162
+ fs.writeFileSync(
163
+ projectSettings,
164
+ JSON.stringify({
165
+ permissions: {
166
+ allow: ['Bash(npm test:*)', 'Bash(rm -rf:*)', 'Bash(git push:*)'],
167
+ },
168
+ })
169
+ );
170
+ fs.writeFileSync(
171
+ globalSettings,
172
+ JSON.stringify({
173
+ permissions: { allow: [], deny: ['Bash(rm -rf:*)'] },
174
+ })
175
+ );
176
+
177
+ const added = mergePermissions(projectSettings, globalSettings);
178
+ expect(added).toBe(2);
179
+
180
+ const result = JSON.parse(fs.readFileSync(globalSettings, 'utf-8'));
181
+ expect(result.permissions.allow).toEqual(['Bash(npm test:*)', 'Bash(git push:*)']);
182
+ expect(result.permissions.allow).not.toContain('Bash(rm -rf:*)');
183
+ });
184
+
185
+ it('syncs deny entries from project to global', () => {
186
+ fs.writeFileSync(
187
+ projectSettings,
188
+ JSON.stringify({
189
+ permissions: {
190
+ allow: ['Bash(npm test:*)'],
191
+ deny: ['Bash(rm -rf:*)', 'Bash(shutdown:*)'],
192
+ },
193
+ })
194
+ );
195
+
196
+ const added = mergePermissions(projectSettings, globalSettings);
197
+ expect(added).toBe(3); // 1 allow + 2 deny
198
+
199
+ const result = JSON.parse(fs.readFileSync(globalSettings, 'utf-8'));
200
+ expect(result.permissions.allow).toEqual(['Bash(npm test:*)']);
201
+ expect(result.permissions.deny).toEqual(['Bash(rm -rf:*)', 'Bash(shutdown:*)']);
202
+ });
203
+
204
+ it('deduplicates deny entries already in global', () => {
205
+ fs.writeFileSync(
206
+ projectSettings,
207
+ JSON.stringify({
208
+ permissions: { deny: ['Bash(rm -rf:*)', 'Bash(shutdown:*)'] },
209
+ })
210
+ );
211
+ fs.writeFileSync(
212
+ globalSettings,
213
+ JSON.stringify({
214
+ permissions: { allow: [], deny: ['Bash(rm -rf:*)'] },
215
+ })
216
+ );
217
+
218
+ const added = mergePermissions(projectSettings, globalSettings);
219
+ expect(added).toBe(1); // only shutdown is new
220
+
221
+ const result = JSON.parse(fs.readFileSync(globalSettings, 'utf-8'));
222
+ expect(result.permissions.deny).toEqual(['Bash(rm -rf:*)', 'Bash(shutdown:*)']);
223
+ });
224
+
225
+ it('filters non-reusable deny entries', () => {
226
+ fs.writeFileSync(
227
+ projectSettings,
228
+ JSON.stringify({
229
+ permissions: {
230
+ deny: [
231
+ 'Bash(rm -rf:*)',
232
+ 'Bash(git -C /Users/john/repo force-push)',
233
+ ],
234
+ },
235
+ })
236
+ );
237
+
238
+ const added = mergePermissions(projectSettings, globalSettings);
239
+ expect(added).toBe(1);
240
+
241
+ const result = JSON.parse(fs.readFileSync(globalSettings, 'utf-8'));
242
+ expect(result.permissions.deny).toEqual(['Bash(rm -rf:*)']);
243
+ });
244
+
245
+ it('cleans up empty deny array in output', () => {
246
+ fs.writeFileSync(
247
+ projectSettings,
248
+ JSON.stringify({
249
+ permissions: { allow: ['Bash(npm test:*)'] },
250
+ })
251
+ );
252
+
253
+ mergePermissions(projectSettings, globalSettings);
254
+
255
+ const result = JSON.parse(fs.readFileSync(globalSettings, 'utf-8'));
256
+ expect(result.permissions.deny).toBeUndefined();
257
+ });
258
+
259
+ it('releases lock file after merge', () => {
260
+ fs.writeFileSync(
261
+ projectSettings,
262
+ JSON.stringify({
263
+ permissions: { allow: ['Bash(npm test:*)'] },
264
+ })
265
+ );
266
+
267
+ mergePermissions(projectSettings, globalSettings);
268
+
269
+ const lockPath = globalSettings + '.lock';
270
+ // Lock directory should not exist after merge completes
271
+ expect(fs.existsSync(lockPath)).toBe(false);
272
+ });
273
+ });
274
+
275
+ describe('installPermissionSyncHook', () => {
276
+ let tempDir: string;
277
+ let settingsPath: string;
278
+
279
+ beforeEach(() => {
280
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-test-'));
281
+ settingsPath = path.join(tempDir, '.claude', 'settings.json');
282
+ });
283
+
284
+ afterEach(() => {
285
+ fs.rmSync(tempDir, { recursive: true, force: true });
286
+ });
287
+
288
+ it('creates settings.json with the hook when file does not exist', () => {
289
+ installPermissionSyncHook(settingsPath);
290
+
291
+ expect(fs.existsSync(settingsPath)).toBe(true);
292
+
293
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
294
+ expect(settings.hooks).toBeDefined();
295
+ expect(settings.hooks.Stop).toHaveLength(1);
296
+ expect(settings.hooks.Stop[0].hooks).toHaveLength(1);
297
+ expect(settings.hooks.Stop[0].hooks[0].type).toBe('command');
298
+ expect(settings.hooks.Stop[0].hooks[0].command).toContain('sync-permissions');
299
+ });
300
+
301
+ it('is idempotent — calling twice does not duplicate', () => {
302
+ installPermissionSyncHook(settingsPath);
303
+ installPermissionSyncHook(settingsPath);
304
+
305
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
306
+ expect(settings.hooks.Stop).toHaveLength(1);
307
+ });
308
+
309
+ it('preserves existing hooks and settings', () => {
310
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
311
+ fs.writeFileSync(
312
+ settingsPath,
313
+ JSON.stringify({
314
+ someKey: 'someValue',
315
+ hooks: {
316
+ Stop: [{ hooks: [{ type: 'command', command: 'echo existing' }] }],
317
+ },
318
+ })
319
+ );
320
+
321
+ installPermissionSyncHook(settingsPath);
322
+
323
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
324
+ expect(settings.someKey).toBe('someValue');
325
+ expect(settings.hooks.Stop).toHaveLength(2);
326
+ expect(settings.hooks.Stop[0].hooks[0].command).toBe('echo existing');
327
+ expect(settings.hooks.Stop[1].hooks[0].command).toContain('sync-permissions');
328
+ });
329
+
330
+ it('updates stale hook path instead of duplicating', () => {
331
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
332
+ fs.writeFileSync(
333
+ settingsPath,
334
+ JSON.stringify({
335
+ hooks: {
336
+ Stop: [
337
+ { hooks: [{ type: 'command', command: 'bash "/old/stale/path/sync-permissions.sh"' }] },
338
+ ],
339
+ },
340
+ })
341
+ );
342
+
343
+ installPermissionSyncHook(settingsPath);
344
+
345
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
346
+ // Should still be exactly 1 entry, not 2
347
+ expect(settings.hooks.Stop).toHaveLength(1);
348
+ // The command should now point to the current path, not the stale one
349
+ expect(settings.hooks.Stop[0].hooks[0].command).not.toContain('/old/stale/path/');
350
+ expect(settings.hooks.Stop[0].hooks[0].command).toContain('sync-permissions');
351
+ });
352
+ });
353
+
354
+ describe('uninstallPermissionSyncHook', () => {
355
+ let tempDir: string;
356
+ let settingsPath: string;
357
+
358
+ beforeEach(() => {
359
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-test-'));
360
+ settingsPath = path.join(tempDir, '.claude', 'settings.json');
361
+ });
362
+
363
+ afterEach(() => {
364
+ fs.rmSync(tempDir, { recursive: true, force: true });
365
+ });
366
+
367
+ it('removes the sync-permissions hook entry', () => {
368
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
369
+ fs.writeFileSync(
370
+ settingsPath,
371
+ JSON.stringify({
372
+ hooks: {
373
+ Stop: [{ hooks: [{ type: 'command', command: 'bash "/some/path/sync-permissions.sh"' }] }],
374
+ },
375
+ })
376
+ );
377
+
378
+ uninstallPermissionSyncHook(settingsPath);
379
+
380
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
381
+ expect(settings.hooks).toBeUndefined();
382
+ });
383
+
384
+ it('preserves other hook entries', () => {
385
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
386
+ fs.writeFileSync(
387
+ settingsPath,
388
+ JSON.stringify({
389
+ hooks: {
390
+ Stop: [
391
+ { hooks: [{ type: 'command', command: 'echo other' }] },
392
+ { hooks: [{ type: 'command', command: 'bash "/some/path/sync-permissions.sh"' }] },
393
+ ],
394
+ },
395
+ })
396
+ );
397
+
398
+ uninstallPermissionSyncHook(settingsPath);
399
+
400
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
401
+ expect(settings.hooks.Stop).toHaveLength(1);
402
+ expect(settings.hooks.Stop[0].hooks[0].command).toBe('echo other');
403
+ });
404
+
405
+ it('handles missing settings file gracefully', () => {
406
+ expect(() => uninstallPermissionSyncHook(settingsPath)).not.toThrow();
407
+ });
408
+ });
409
+
410
+ describe('installWorkspaceSkills', () => {
411
+ let tmpDir: string;
412
+
413
+ beforeEach(() => {
414
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-skills-test-'));
415
+ });
416
+
417
+ afterEach(() => {
418
+ fs.rmSync(tmpDir, { recursive: true, force: true });
419
+ });
420
+
421
+ it('installs skills into a specified directory', () => {
422
+ const targetDir = path.join(tmpDir, 'claude-skills');
423
+ fs.mkdirSync(targetDir, { recursive: true });
424
+
425
+ // installWorkspaceSkills uses an internal source dir that may be empty in test.
426
+ // Instead, test with the installed package by checking the function doesn't throw.
427
+ expect(() => installWorkspaceSkills(targetDir)).not.toThrow();
428
+ });
429
+
430
+ it('is idempotent — installing twice does not throw', () => {
431
+ const targetDir = path.join(tmpDir, 'skills');
432
+ fs.mkdirSync(targetDir, { recursive: true });
433
+
434
+ installWorkspaceSkills(targetDir);
435
+ expect(() => installWorkspaceSkills(targetDir)).not.toThrow();
436
+ });
437
+
438
+ it('creates SKILL.md files in subdirectories when source has .md files', () => {
439
+ // Simulate skill source by directly creating skills in a target dir
440
+ const claudeSkills = path.join(tmpDir, '.claude', 'skills');
441
+ const piSkills = path.join(tmpDir, '.pi', 'agent', 'skills');
442
+ fs.mkdirSync(claudeSkills, { recursive: true });
443
+ fs.mkdirSync(piSkills, { recursive: true });
444
+
445
+ // Manually create a skill in Claude's dir and verify Pi can have the same
446
+ const skillDir = path.join(claudeSkills, 'test-skill');
447
+ fs.mkdirSync(skillDir);
448
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Test Skill\nDescription here');
449
+
450
+ // Copy to Pi dir to simulate multi-agent install
451
+ const piSkillDir = path.join(piSkills, 'test-skill');
452
+ fs.mkdirSync(piSkillDir);
453
+ fs.writeFileSync(path.join(piSkillDir, 'SKILL.md'), '# Test Skill\nDescription here');
454
+
455
+ // Verify both have identical content
456
+ const claudeContent = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
457
+ const piContent = fs.readFileSync(path.join(piSkillDir, 'SKILL.md'), 'utf-8');
458
+ expect(claudeContent).toBe(piContent);
459
+ });
460
+ });
461
+
462
+ describe('uninstallWorkspaceSkills', () => {
463
+ let tmpDir: string;
464
+
465
+ beforeEach(() => {
466
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-skills-test-'));
467
+ });
468
+
469
+ afterEach(() => {
470
+ fs.rmSync(tmpDir, { recursive: true, force: true });
471
+ });
472
+
473
+ it('removes installed skills from a directory', () => {
474
+ const targetDir = path.join(tmpDir, 'skills');
475
+ fs.mkdirSync(targetDir, { recursive: true });
476
+
477
+ // installWorkspaceSkills may install 0 skills in test env (no source .md files).
478
+ // Directly test uninstall doesn't throw.
479
+ installWorkspaceSkills(targetDir);
480
+ expect(() => uninstallWorkspaceSkills(targetDir)).not.toThrow();
481
+ });
482
+
483
+ it('does not remove non-workspace-manager skills', () => {
484
+ const targetDir = path.join(tmpDir, 'skills');
485
+ fs.mkdirSync(targetDir, { recursive: true });
486
+
487
+ // Add a custom skill
488
+ const customSkillDir = path.join(targetDir, 'my-custom-skill');
489
+ fs.mkdirSync(customSkillDir, { recursive: true });
490
+ fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# Custom Skill');
491
+
492
+ uninstallWorkspaceSkills(targetDir);
493
+
494
+ // Custom skill should still be there
495
+ expect(fs.existsSync(customSkillDir)).toBe(true);
496
+ expect(fs.readFileSync(path.join(customSkillDir, 'SKILL.md'), 'utf-8')).toBe('# Custom Skill');
497
+ });
498
+
499
+ it('handles empty directory gracefully', () => {
500
+ const targetDir = path.join(tmpDir, 'empty-skills');
501
+ fs.mkdirSync(targetDir, { recursive: true });
502
+
503
+ expect(() => uninstallWorkspaceSkills(targetDir)).not.toThrow();
504
+ });
505
+ });
506
+
507
+ describe('repairStaleHooks', () => {
508
+ let tmpDir: string;
509
+
510
+ beforeEach(() => {
511
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repair-hooks-test-'));
512
+ });
513
+
514
+ afterEach(() => {
515
+ fs.rmSync(tmpDir, { recursive: true, force: true });
516
+ });
517
+
518
+ it('returns 0 when settings file does not exist', () => {
519
+ const missing = path.join(tmpDir, 'nope.json');
520
+ expect(repairStaleHooks(missing)).toBe(0);
521
+ });
522
+
523
+ it('returns 0 when there are no workspace-manager hooks', () => {
524
+ const settingsPath = path.join(tmpDir, 'settings.json');
525
+ fs.writeFileSync(settingsPath, JSON.stringify({
526
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] },
527
+ }));
528
+ expect(repairStaleHooks(settingsPath)).toBe(0);
529
+ });
530
+
531
+ it('leaves valid hook paths untouched (no-op)', () => {
532
+ const settingsPath = path.join(tmpDir, 'settings.json');
533
+ // Point at a real existing script so it should NOT be repaired
534
+ installPermissionSyncHook(settingsPath);
535
+ const before = fs.readFileSync(settingsPath, 'utf-8');
536
+ expect(repairStaleHooks(settingsPath)).toBe(0);
537
+ expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(before);
538
+ });
539
+
540
+ it('repairs a stale Stop hook pointing to a non-existent script', () => {
541
+ const settingsPath = path.join(tmpDir, 'settings.json');
542
+ fs.writeFileSync(settingsPath, JSON.stringify({
543
+ hooks: {
544
+ Stop: [{
545
+ hooks: [{
546
+ type: 'command',
547
+ command: 'bash "/Users/x/.volta/tmp/image/packages/.tmpABC/lib/node_modules/@acme/workspace-manager/sync-permissions.sh"',
548
+ }],
549
+ }],
550
+ },
551
+ }));
552
+
553
+ const repaired = repairStaleHooks(settingsPath);
554
+ expect(repaired).toBe(1);
555
+
556
+ const updated = readClaudeSettings(settingsPath);
557
+ const cmd = updated.hooks!.Stop![0].hooks![0].command;
558
+ // Should no longer reference the dead volta temp path
559
+ expect(cmd).not.toContain('.volta/tmp');
560
+ // Should reference a real, existing sync-permissions.sh
561
+ const match = cmd.match(/"([^"]+sync-permissions\.sh)"/);
562
+ expect(match).not.toBeNull();
563
+ expect(fs.existsSync(match![1])).toBe(true);
564
+ });
565
+
566
+ it('repairs a stale Stop hook while leaving unrelated hooks untouched', () => {
567
+ const settingsPath = path.join(tmpDir, 'settings.json');
568
+ fs.writeFileSync(settingsPath, JSON.stringify({
569
+ hooks: {
570
+ Stop: [{ hooks: [{ type: 'command', command: 'bash "/dead/sync-permissions.sh"' }] }],
571
+ SessionStart: [{ hooks: [{ type: 'command', command: 'echo unrelated' }] }],
572
+ },
573
+ }));
574
+
575
+ expect(repairStaleHooks(settingsPath)).toBe(1);
576
+
577
+ const updated = readClaudeSettings(settingsPath);
578
+ const cmd = updated.hooks!.Stop![0].hooks![0].command;
579
+ expect(cmd).not.toContain('/dead/');
580
+ const match = cmd.match(/"([^"]+sync-permissions\.sh)"/);
581
+ expect(match).not.toBeNull();
582
+ expect(fs.existsSync(match![1])).toBe(true);
583
+ // Unrelated hooks are left as-is
584
+ expect(updated.hooks!.SessionStart![0].hooks![0].command).toBe('echo unrelated');
585
+ });
586
+
587
+ it('does not throw on malformed settings', () => {
588
+ const settingsPath = path.join(tmpDir, 'settings.json');
589
+ fs.writeFileSync(settingsPath, 'not valid json {{{');
590
+ expect(() => repairStaleHooks(settingsPath)).not.toThrow();
591
+ expect(repairStaleHooks(settingsPath)).toBe(0);
592
+ });
593
+ });