@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,296 @@
1
+ import inquirer from 'inquirer';
2
+ import autocompletePrompt from 'inquirer-autocomplete-prompt';
3
+ import * as fuzzy from 'fuzzy';
4
+ import { GitHubRepo } from '../types';
5
+ import { validateWorkspaceName, checkWorkspaceExists, sanitizeWorkspaceName } from './validation';
6
+ import { colorize } from './colors';
7
+
8
+ // Register autocomplete prompt type
9
+ inquirer.registerPrompt('autocomplete', autocompletePrompt);
10
+
11
+ export interface RepoSelection {
12
+ repo: GitHubRepo;
13
+ directoryName: string;
14
+ }
15
+
16
+ export const promptInstanceSuffix = async (
17
+ repoName: string,
18
+ existingDirectoryNames: string[]
19
+ ): Promise<string> => {
20
+ const { suffix } = await inquirer.prompt([
21
+ {
22
+ type: 'input',
23
+ name: 'suffix',
24
+ message: `"${repoName}" already exists. Enter a suffix for this instance:`,
25
+ validate: (input: string) => {
26
+ if (!input || input.trim().length === 0) {
27
+ return 'Suffix cannot be empty';
28
+ }
29
+
30
+ const trimmed = input.trim();
31
+
32
+ // Validate characters (alphanumeric, hyphens, underscores)
33
+ if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
34
+ return 'Suffix can only contain letters, numbers, hyphens, and underscores';
35
+ }
36
+
37
+ const candidateName = `${repoName}-${trimmed}`;
38
+ if (existingDirectoryNames.includes(candidateName)) {
39
+ return `"${candidateName}" already exists. Choose a different suffix`;
40
+ }
41
+
42
+ return true;
43
+ },
44
+ },
45
+ ]);
46
+
47
+ return suffix.trim();
48
+ };
49
+
50
+ export const promptRepositorySelection = async (
51
+ repos: GitHubRepo[],
52
+ existingDirectoryNames: string[] = []
53
+ ): Promise<RepoSelection[]> => {
54
+ const selectedEntries: RepoSelection[] = [];
55
+ const repoMap = new Map(repos.map(repo => [repo.name, repo]));
56
+
57
+ // Track all directory names (existing + newly selected) for collision detection
58
+ const allDirectoryNames = [...existingDirectoryNames];
59
+
60
+ console.log('\n' + colorize('Repository Selection', 'bright'));
61
+ console.log('Type to search repositories. Use arrow keys to navigate, Enter to select.');
62
+ console.log('Type "done" when finished selecting repositories.\n');
63
+
64
+ while (true) {
65
+ try {
66
+ const { repoName } = await inquirer.prompt([
67
+ {
68
+ type: 'autocomplete',
69
+ name: 'repoName',
70
+ message: `Search and select repository (${colorize(String(selectedEntries.length), 'cyan')} selected):`,
71
+ source: async (_answersSoFar: any, input: string | undefined) => {
72
+ const searchInput = input || '';
73
+
74
+ // Always include done option
75
+ const doneOption = { name: colorize('done - Finish selection', 'green'), value: 'done' };
76
+
77
+ // If no input or "done" typed, show done + top repos
78
+ if (!searchInput || searchInput.toLowerCase().startsWith('done')) {
79
+ return [
80
+ doneOption,
81
+ ...repos.slice(0, 15).map(repo => ({
82
+ name: `${repo.name}${repo.description ? ` - ${colorize(repo.description, 'gray')}` : ''}`,
83
+ value: repo.name,
84
+ }))
85
+ ];
86
+ }
87
+
88
+ // Perform fuzzy search
89
+ const results = fuzzy.filter(searchInput, repos, {
90
+ extract: (repo) => `${repo.name} ${repo.description || ''}`,
91
+ });
92
+
93
+ const suggestions = results.slice(0, 15).map(result => ({
94
+ name: `${result.original.name}${result.original.description ? ` - ${colorize(result.original.description, 'gray')}` : ''}`,
95
+ value: result.original.name,
96
+ }));
97
+
98
+ return [doneOption, ...suggestions];
99
+ },
100
+ pageSize: 16,
101
+ } as any,
102
+ ]);
103
+
104
+ if (repoName === 'done') {
105
+ if (selectedEntries.length === 0) {
106
+ console.log(colorize('\nYou must select at least one repository\n', 'yellow'));
107
+ continue;
108
+ }
109
+ break;
110
+ }
111
+
112
+ const repo = repoMap.get(repoName);
113
+ if (!repo) continue;
114
+
115
+ let directoryName = repo.name;
116
+
117
+ // Check if this directory name already exists (in workspace or in current selection)
118
+ if (allDirectoryNames.includes(directoryName)) {
119
+ const suffix = await promptInstanceSuffix(repo.name, allDirectoryNames);
120
+ directoryName = `${repo.name}-${suffix}`;
121
+ }
122
+
123
+ selectedEntries.push({ repo, directoryName });
124
+ allDirectoryNames.push(directoryName);
125
+
126
+ const displayName = directoryName !== repo.name
127
+ ? `${directoryName} (${repo.name})`
128
+ : repo.name;
129
+ console.log(colorize(`Added: ${displayName}`, 'green'));
130
+ } catch (error) {
131
+ // Handle Ctrl+C
132
+ console.log('\n');
133
+ process.exit(0);
134
+ }
135
+ }
136
+
137
+ console.log(`\n${colorize(`Selected ${selectedEntries.length} repositories:`, 'cyan')}`);
138
+ selectedEntries.forEach(entry => {
139
+ const displayName = entry.directoryName !== entry.repo.name
140
+ ? `${entry.directoryName} (${entry.repo.name})`
141
+ : entry.repo.name;
142
+ console.log(` - ${displayName}`);
143
+ });
144
+ console.log('');
145
+
146
+ return selectedEntries;
147
+ };
148
+
149
+ export const promptWorkspaceName = async (): Promise<string> => {
150
+ const { workspaceName } = await inquirer.prompt([
151
+ {
152
+ type: 'input',
153
+ name: 'workspaceName',
154
+ message: 'Enter workspace name:',
155
+ validate: (input: string) => {
156
+ const validationResult = validateWorkspaceName(input);
157
+ if (validationResult !== true) {
158
+ return validationResult;
159
+ }
160
+ return true;
161
+ },
162
+ filter: (input: string) => sanitizeWorkspaceName(input),
163
+ },
164
+ ]);
165
+
166
+ return workspaceName;
167
+ };
168
+
169
+ export const confirmWorkspaceCreation = async (
170
+ workspaceName: string,
171
+ repoCount: number,
172
+ workspacePath: string
173
+ ): Promise<boolean> => {
174
+ console.log('\n' + '='.repeat(60));
175
+ console.log('Workspace Configuration');
176
+ console.log('='.repeat(60));
177
+ console.log(`Name: ${colorize(workspaceName, 'cyan')}`);
178
+ console.log(`Location: ${colorize(workspacePath, 'gray')}`);
179
+ console.log(`Repositories: ${colorize(String(repoCount), 'yellow')}`);
180
+ console.log('='.repeat(60) + '\n');
181
+
182
+ const { confirmed } = await inquirer.prompt([
183
+ {
184
+ type: 'confirm',
185
+ name: 'confirmed',
186
+ message: 'Create workspace with these settings?',
187
+ default: true,
188
+ },
189
+ ]);
190
+
191
+ return confirmed;
192
+ };
193
+
194
+ export const promptWorkspaceSelection = async (workspaces: Array<{ name: string; path: string; metadata: any }>): Promise<string> => {
195
+ if (workspaces.length === 0) {
196
+ throw new Error('No workspaces found. Create one first with: workspace create');
197
+ }
198
+
199
+ const choices = workspaces.map(ws => ({
200
+ name: ws.metadata
201
+ ? `${ws.name} (${ws.metadata.repositories.length} repos)`
202
+ : ws.name,
203
+ value: ws.name,
204
+ short: ws.name,
205
+ }));
206
+
207
+ const { workspaceName } = await inquirer.prompt([
208
+ {
209
+ type: 'list',
210
+ name: 'workspaceName',
211
+ message: 'Select workspace to update:',
212
+ choices,
213
+ pageSize: 15,
214
+ },
215
+ ]);
216
+
217
+ return workspaceName;
218
+ };
219
+
220
+ export const promptMultiWorkspaceSelection = async (
221
+ workspaces: Array<{ name: string; path: string; metadata: any }>
222
+ ): Promise<string[]> => {
223
+ const selectedNames: string[] = [];
224
+ const workspaceNames = workspaces.map(ws => ws.name);
225
+
226
+ console.log('\n' + colorize('Workspace Selection', 'bright'));
227
+ console.log('Type to search workspaces. Use arrow keys to navigate, Enter to select.');
228
+ console.log('Type "done" when finished selecting workspaces.\n');
229
+
230
+ while (true) {
231
+ try {
232
+ const availableNames = workspaceNames.filter(name => !selectedNames.includes(name));
233
+
234
+ if (availableNames.length === 0) {
235
+ console.log(colorize('All workspaces selected.', 'yellow'));
236
+ break;
237
+ }
238
+
239
+ const { workspaceName } = await inquirer.prompt([
240
+ {
241
+ type: 'autocomplete',
242
+ name: 'workspaceName',
243
+ message: `Search and select workspace (${colorize(String(selectedNames.length), 'cyan')} selected):`,
244
+ source: async (_answersSoFar: any, input: string | undefined) => {
245
+ const searchInput = input || '';
246
+
247
+ const doneOption = { name: colorize('done - Finish selection', 'green'), value: 'done' };
248
+
249
+ if (!searchInput || searchInput.toLowerCase().startsWith('done')) {
250
+ return [
251
+ doneOption,
252
+ ...availableNames.slice(0, 15).map(name => ({
253
+ name,
254
+ value: name,
255
+ }))
256
+ ];
257
+ }
258
+
259
+ const results = fuzzy.filter(searchInput, availableNames);
260
+
261
+ const suggestions = results.slice(0, 15).map(result => ({
262
+ name: result.original,
263
+ value: result.original,
264
+ }));
265
+
266
+ return [doneOption, ...suggestions];
267
+ },
268
+ pageSize: 16,
269
+ } as any,
270
+ ]);
271
+
272
+ if (workspaceName === 'done') {
273
+ if (selectedNames.length === 0) {
274
+ console.log(colorize('\nYou must select at least one workspace\n', 'yellow'));
275
+ continue;
276
+ }
277
+ break;
278
+ }
279
+
280
+ selectedNames.push(workspaceName);
281
+ console.log(colorize(`Added: ${workspaceName}`, 'green'));
282
+ } catch (error) {
283
+ // Handle Ctrl+C
284
+ console.log('\n');
285
+ process.exit(0);
286
+ }
287
+ }
288
+
289
+ console.log(`\n${colorize(`Selected ${selectedNames.length} workspace${selectedNames.length === 1 ? '' : 's'}:`, 'cyan')}`);
290
+ selectedNames.forEach(name => {
291
+ console.log(` - ${name}`);
292
+ });
293
+ console.log('');
294
+
295
+ return selectedNames;
296
+ };
@@ -0,0 +1,313 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { fuzzyFindRepo, resolveRepoNames, levenshtein, damerauLevenshtein, parseRepoSpec, resolveRepoSpecs } from './repo-resolver';
3
+
4
+ const repos = [
5
+ { name: 'acme-app' },
6
+ { name: 'partnerships-api' },
7
+ { name: 'platform-app' },
8
+ { name: 'acme-db' },
9
+ { name: 'payments-service' },
10
+ { name: 'analytics-service' },
11
+ { name: 'notifications-service' },
12
+ { name: 'workspace-manager' },
13
+ { name: 'casper' },
14
+ ];
15
+
16
+ // ── levenshtein ───────────────────────────────────────────────────────────────
17
+
18
+ describe('levenshtein', () => {
19
+ it('returns 0 for identical strings', () => {
20
+ expect(levenshtein('abc', 'abc')).toBe(0);
21
+ });
22
+
23
+ it('returns length of string when other is empty', () => {
24
+ expect(levenshtein('abc', '')).toBe(3);
25
+ expect(levenshtein('', 'abc')).toBe(3);
26
+ });
27
+
28
+ it('counts single substitution', () => {
29
+ expect(levenshtein('cat', 'bat')).toBe(1);
30
+ });
31
+
32
+ it('counts single insertion', () => {
33
+ expect(levenshtein('cat', 'cats')).toBe(1);
34
+ });
35
+
36
+ it('counts single deletion', () => {
37
+ expect(levenshtein('cats', 'cat')).toBe(1);
38
+ });
39
+ });
40
+
41
+ // ── damerauLevenshtein ──────────────────────────────────────────
42
+
43
+ describe('damerauLevenshtein', () => {
44
+ it('returns 0 for identical strings', () => {
45
+ expect(damerauLevenshtein('casper', 'casper')).toBe(0);
46
+ });
47
+
48
+ it('counts an adjacent transposition as a single edit', () => {
49
+ expect(damerauLevenshtein('capser', 'casper')).toBe(1); // plain levenshtein = 2
50
+ expect(damerauLevenshtein('casepr', 'casper')).toBe(1);
51
+ });
52
+
53
+ it('still counts substitution / insertion / deletion as 1', () => {
54
+ expect(damerauLevenshtein('cat', 'bat')).toBe(1);
55
+ expect(damerauLevenshtein('cat', 'cats')).toBe(1);
56
+ expect(damerauLevenshtein('cats', 'cat')).toBe(1);
57
+ });
58
+
59
+ it('handles empty strings', () => {
60
+ expect(damerauLevenshtein('', 'abc')).toBe(3);
61
+ expect(damerauLevenshtein('abc', '')).toBe(3);
62
+ });
63
+ });
64
+
65
+ // ── fuzzyFindRepo ─────────────────────────────────────────────────────────────
66
+
67
+ describe('fuzzyFindRepo', () => {
68
+ describe('exact matches', () => {
69
+ it('finds an exact case-sensitive match (score 100)', () => {
70
+ const result = fuzzyFindRepo('acme-app', repos);
71
+ expect(result).not.toBeNull();
72
+ expect(result!.repo.name).toBe('acme-app');
73
+ expect(result!.score).toBe(100);
74
+ expect(result!.exact).toBe(true);
75
+ });
76
+
77
+ it('finds case-insensitive exact match (score 95)', () => {
78
+ const result = fuzzyFindRepo('Acme-App', repos);
79
+ expect(result).not.toBeNull();
80
+ expect(result!.repo.name).toBe('acme-app');
81
+ expect(result!.score).toBe(95);
82
+ expect(result!.exact).toBe(true);
83
+ });
84
+
85
+ it('strips leading/trailing whitespace without creating stray hyphens', () => {
86
+ const result = fuzzyFindRepo(' acme-app ', repos);
87
+ expect(result).not.toBeNull();
88
+ expect(result!.repo.name).toBe('acme-app');
89
+ expect(result!.score).toBe(90); // normalised match (trim removes the spaces)
90
+ expect(result!.exact).toBe(true);
91
+ });
92
+
93
+ it('normalises underscores to hyphens (score 90)', () => {
94
+ const result = fuzzyFindRepo('acme_app', repos);
95
+ expect(result).not.toBeNull();
96
+ expect(result!.repo.name).toBe('acme-app');
97
+ expect(result!.score).toBe(90);
98
+ expect(result!.exact).toBe(true);
99
+ });
100
+
101
+ it('normalises spaces to hyphens (score 90)', () => {
102
+ const result = fuzzyFindRepo('acme app', repos);
103
+ expect(result).not.toBeNull();
104
+ expect(result!.repo.name).toBe('acme-app');
105
+ expect(result!.score).toBe(90);
106
+ expect(result!.exact).toBe(true);
107
+ });
108
+ });
109
+
110
+ describe('prefix matches', () => {
111
+ it('matches input as prefix of repo name — "partnerships" → "partnerships-api"', () => {
112
+ const result = fuzzyFindRepo('partnerships', repos);
113
+ expect(result).not.toBeNull();
114
+ expect(result!.repo.name).toBe('partnerships-api');
115
+ expect(result!.score).toBe(80);
116
+ expect(result!.exact).toBe(false);
117
+ });
118
+
119
+ it('matches input as prefix — "platform" → "platform-app"', () => {
120
+ const result = fuzzyFindRepo('platform', repos);
121
+ expect(result).not.toBeNull();
122
+ expect(result!.repo.name).toBe('platform-app');
123
+ });
124
+
125
+ it('matches input as prefix — "payments" → "payments-service"', () => {
126
+ const result = fuzzyFindRepo('payments', repos);
127
+ expect(result).not.toBeNull();
128
+ expect(result!.repo.name).toBe('payments-service');
129
+ });
130
+ });
131
+
132
+ describe('substring matches', () => {
133
+ it('matches input as substring — "analytics" → "analytics-service"', () => {
134
+ const result = fuzzyFindRepo('analytics', repos);
135
+ expect(result).not.toBeNull();
136
+ expect(result!.repo.name).toBe('analytics-service');
137
+ });
138
+
139
+ it('matches "-service" repos via partial name — "notifications" → "notifications-service"', () => {
140
+ const result = fuzzyFindRepo('notifications', repos);
141
+ expect(result).not.toBeNull();
142
+ expect(result!.repo.name).toBe('notifications-service');
143
+ });
144
+ });
145
+
146
+ describe('typo / Levenshtein tolerance', () => {
147
+ it('tolerates a single typo — "acme-aps" → "acme-app"', () => {
148
+ const result = fuzzyFindRepo('acme-aps', repos);
149
+ expect(result).not.toBeNull();
150
+ expect(result!.repo.name).toBe('acme-app');
151
+ });
152
+
153
+ it('tolerates a single typo — "platfrom-app" → "platform-app"', () => {
154
+ const result = fuzzyFindRepo('platfrom-app', repos);
155
+ expect(result).not.toBeNull();
156
+ expect(result!.repo.name).toBe('platform-app');
157
+ });
158
+
159
+ it('tolerates an adjacent transposition on a short name — "capser" → "casper"', () => {
160
+ // Regression: plain Levenshtein scores this typo as distance 2, which
161
+ // exceeded maxDist=1 for a 6-char name. Damerau counts the swap as 1.
162
+ const result = fuzzyFindRepo('capser', repos);
163
+ expect(result).not.toBeNull();
164
+ expect(result!.repo.name).toBe('casper');
165
+ });
166
+
167
+ it('tolerates a trailing transposition — "casepr" → "casper"', () => {
168
+ const result = fuzzyFindRepo('casepr', repos);
169
+ expect(result).not.toBeNull();
170
+ expect(result!.repo.name).toBe('casper');
171
+ });
172
+
173
+ it('tolerates a missing character — "acme-b" → "acme-db" (prefix wins)', () => {
174
+ const result = fuzzyFindRepo('acme-d', repos);
175
+ expect(result).not.toBeNull();
176
+ expect(result!.repo.name).toBe('acme-db');
177
+ });
178
+ });
179
+
180
+ describe('no match', () => {
181
+ it('returns null for completely unrelated input', () => {
182
+ const result = fuzzyFindRepo('xyzzy-totally-unrelated', repos);
183
+ expect(result).toBeNull();
184
+ });
185
+
186
+ it('returns null for empty input', () => {
187
+ expect(fuzzyFindRepo('', repos)).toBeNull();
188
+ });
189
+
190
+ it('returns null for whitespace-only input (normalises to empty string)', () => {
191
+ expect(fuzzyFindRepo(' ', repos)).toBeNull();
192
+ });
193
+
194
+ it('returns null for underscore-only input (normalises to empty string)', () => {
195
+ expect(fuzzyFindRepo('_', repos)).toBeNull();
196
+ });
197
+
198
+ it('returns null for hyphen-only input (normalises to empty string)', () => {
199
+ expect(fuzzyFindRepo('-', repos)).toBeNull();
200
+ });
201
+
202
+ it('returns null for empty repo list', () => {
203
+ expect(fuzzyFindRepo('acme-app', [])).toBeNull();
204
+ });
205
+ });
206
+ });
207
+
208
+ // ── resolveRepoNames ──────────────────────────────────────────────────────────
209
+
210
+ describe('resolveRepoNames', () => {
211
+ it('resolves a mix of exact and fuzzy matches', () => {
212
+ const { resolved, notFound } = resolveRepoNames(
213
+ ['acme-app', 'partnerships', 'platfrom-app'],
214
+ repos,
215
+ );
216
+ expect(resolved.map(r => r.repo.name)).toEqual([
217
+ 'acme-app',
218
+ 'partnerships-api',
219
+ 'platform-app',
220
+ ]);
221
+ expect(notFound).toEqual([]);
222
+ });
223
+
224
+ it('puts unresolvable names in notFound', () => {
225
+ const { resolved, notFound } = resolveRepoNames(
226
+ ['acme-app', 'xyzzy-nope'],
227
+ repos,
228
+ );
229
+ expect(resolved).toHaveLength(1);
230
+ expect(notFound).toEqual(['xyzzy-nope']);
231
+ });
232
+
233
+ it('marks fuzzy (non-exact) matches correctly', () => {
234
+ const { resolved } = resolveRepoNames(['partnerships'], repos);
235
+ expect(resolved[0].exact).toBe(false);
236
+ expect(resolved[0].input).toBe('partnerships');
237
+ expect(resolved[0].repo.name).toBe('partnerships-api');
238
+ });
239
+
240
+ it('marks exact matches correctly', () => {
241
+ const { resolved } = resolveRepoNames(['acme-app'], repos);
242
+ expect(resolved[0].exact).toBe(true);
243
+ });
244
+
245
+ it('deduplicates when two inputs resolve to the same repo', () => {
246
+ // 'partnerships' and 'partnerships-api' both fuzzy-resolve to 'partnerships-api'
247
+ const { resolved, notFound } = resolveRepoNames(
248
+ ['partnerships', 'partnerships-api'],
249
+ repos,
250
+ );
251
+ expect(resolved).toHaveLength(1);
252
+ expect(resolved[0].repo.name).toBe('partnerships-api');
253
+ expect(notFound).toEqual([]);
254
+ });
255
+
256
+ it('returns empty resolved and all inputs in notFound when no repos match', () => {
257
+ const { resolved, notFound } = resolveRepoNames(['abc', 'xyz'], repos);
258
+ expect(resolved).toHaveLength(0);
259
+ expect(notFound).toEqual(['abc', 'xyz']);
260
+ });
261
+ });
262
+
263
+ // ── parseRepoSpec / resolveRepoSpecs (instance suffixes) ────────────────────────
264
+
265
+ describe('parseRepoSpec', () => {
266
+ it('parses a bare repo name', () => {
267
+ expect(parseRepoSpec('casper')).toEqual({ name: 'casper' });
268
+ });
269
+ it('parses name:suffix into name + suffix', () => {
270
+ expect(parseRepoSpec('casper:cas-101')).toEqual({ name: 'casper', suffix: 'cas-101' });
271
+ });
272
+ it('trims whitespace around name and suffix', () => {
273
+ expect(parseRepoSpec(' casper : cas-101 ')).toEqual({ name: 'casper', suffix: 'cas-101' });
274
+ });
275
+ it('treats an empty suffix as no suffix', () => {
276
+ expect(parseRepoSpec('casper:')).toEqual({ name: 'casper' });
277
+ });
278
+ });
279
+
280
+ describe('resolveRepoSpecs', () => {
281
+ it('adds the same repo twice under distinct suffixes', () => {
282
+ const { resolved, notFound, invalidSuffix } = resolveRepoSpecs(
283
+ ['platform-app', 'platform-app:hotfix'],
284
+ repos,
285
+ );
286
+ expect(notFound).toEqual([]);
287
+ expect(invalidSuffix).toEqual([]);
288
+ expect(resolved.map(r => r.directoryName)).toEqual(['platform-app', 'platform-app-hotfix']);
289
+ });
290
+
291
+ it('appends the suffix to the RESOLVED (fuzzy) repo name', () => {
292
+ const { resolved } = resolveRepoSpecs(['platfrom-app:cas-101'], repos);
293
+ expect(resolved[0].repo.name).toBe('platform-app');
294
+ expect(resolved[0].directoryName).toBe('platform-app-cas-101');
295
+ expect(resolved[0].exact).toBe(false);
296
+ });
297
+
298
+ it('dedupes identical target directories within a batch', () => {
299
+ const { resolved } = resolveRepoSpecs(['acme-db', 'acme-db'], repos);
300
+ expect(resolved).toHaveLength(1);
301
+ });
302
+
303
+ it('flags invalid suffixes', () => {
304
+ const { invalidSuffix, resolved } = resolveRepoSpecs(['acme-db:bad/slash'], repos);
305
+ expect(invalidSuffix).toEqual(['acme-db:bad/slash']);
306
+ expect(resolved).toEqual([]);
307
+ });
308
+
309
+ it('reports unmatched repo names', () => {
310
+ const { notFound } = resolveRepoSpecs(['totally-not-a-repo'], repos);
311
+ expect(notFound).toEqual(['totally-not-a-repo']);
312
+ });
313
+ });