@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,294 @@
1
+ /**
2
+ * Fuzzy repository name resolver.
3
+ *
4
+ * Resolves user-supplied repo name inputs (which may be partial, mis-spelled,
5
+ * or differently-cased) to actual repository objects from the full list.
6
+ *
7
+ * Scoring ladder (higher = better match):
8
+ * 100 exact case-sensitive match
9
+ * 95 case-insensitive exact match
10
+ * 90 normalised exact match (spaces/underscores → hyphens)
11
+ * 80 input is a hyphen-boundary prefix of repo name e.g. "platform" → "platform-app"
12
+ * 75 repo name is a hyphen-boundary prefix of input
13
+ * 65 input is a substring of repo name
14
+ * 60 repo name is a substring of input
15
+ * 30–50 Levenshtein edit distance ≤ threshold (typo tolerance)
16
+ *
17
+ * A match is only returned when score ≥ MIN_SCORE (30).
18
+ */
19
+
20
+ /** Minimum score required to accept a fuzzy match. */
21
+ const MIN_SCORE = 30;
22
+
23
+ export interface FuzzyMatch<T> {
24
+ repo: T;
25
+ /** 0-100 confidence score. */
26
+ score: number;
27
+ /** True only for exact or case/normalisation-only matches (score ≥ 90). */
28
+ exact: boolean;
29
+ }
30
+
31
+ /**
32
+ * Normalise a repo name for comparison:
33
+ * - lowercase
34
+ * - spaces and underscores → hyphens
35
+ * - collapse repeated hyphens
36
+ */
37
+ function normalise(s: string): string {
38
+ return s
39
+ .trim() // strip leading/trailing whitespace first
40
+ .toLowerCase()
41
+ .replace(/[\s_]+/g, '-') // spaces and underscores → hyphens
42
+ .replace(/-{2,}/g, '-') // collapse repeated hyphens
43
+ .replace(/^-+|-+$/g, ''); // strip any leading/trailing hyphens
44
+ }
45
+
46
+ /**
47
+ * Compute the Levenshtein edit distance between two strings.
48
+ */
49
+ export function levenshtein(a: string, b: string): number {
50
+ const m = a.length;
51
+ const n = b.length;
52
+ // Use two alternating rows to keep memory O(n)
53
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
54
+ let curr = new Array<number>(n + 1);
55
+ for (let i = 1; i <= m; i++) {
56
+ curr[0] = i;
57
+ for (let j = 1; j <= n; j++) {
58
+ curr[j] =
59
+ a[i - 1] === b[j - 1]
60
+ ? prev[j - 1]
61
+ : 1 + Math.min(prev[j - 1], prev[j], curr[j - 1]);
62
+ }
63
+ [prev, curr] = [curr, prev];
64
+ }
65
+ return prev[n];
66
+ }
67
+
68
+ /**
69
+ * Damerau-Levenshtein distance (Optimal String Alignment variant).
70
+ *
71
+ * Like {@link levenshtein} but an ADJACENT TRANSPOSITION counts as a single
72
+ * edit — so "capser" ↔ "casper" is distance 1, not 2. Transposing two
73
+ * neighbouring characters is the most common human typo, so this is what we
74
+ * use for typo-tolerant repo matching. Names are short, so the full O(mn)
75
+ * matrix is negligible.
76
+ */
77
+ export function damerauLevenshtein(a: string, b: string): number {
78
+ const m = a.length;
79
+ const n = b.length;
80
+ if (m === 0) return n;
81
+ if (n === 0) return m;
82
+
83
+ const d: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
84
+ for (let i = 0; i <= m; i++) d[i][0] = i;
85
+ for (let j = 0; j <= n; j++) d[0][j] = j;
86
+
87
+ for (let i = 1; i <= m; i++) {
88
+ for (let j = 1; j <= n; j++) {
89
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
90
+ d[i][j] = Math.min(
91
+ d[i - 1][j] + 1, // deletion
92
+ d[i][j - 1] + 1, // insertion
93
+ d[i - 1][j - 1] + cost, // substitution
94
+ );
95
+ // Adjacent transposition: a[i-1] a[i-2] == b[j-2] b[j-1]
96
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
97
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
98
+ }
99
+ }
100
+ }
101
+ return d[m][n];
102
+ }
103
+
104
+ /**
105
+ * Score how well `originalInput` matches `originalRepo`.
106
+ * All normalisation is done here so the scoring ladder is fully reachable.
107
+ */
108
+ function scoreMatch(originalInput: string, originalRepo: string): number {
109
+ // Exact (case-sensitive)
110
+ if (originalRepo === originalInput) return 100;
111
+
112
+ // Case-insensitive exact
113
+ if (originalRepo.toLowerCase() === originalInput.toLowerCase()) return 95;
114
+
115
+ // Normalised exact (spaces/underscores → hyphens; reachable for e.g. "my_app" or "my app")
116
+ const normInput = normalise(originalInput);
117
+ const normRepo = normalise(originalRepo);
118
+ if (normRepo === normInput) return 90;
119
+
120
+ // Prefix: normInput is a hyphen-boundary prefix of normRepo
121
+ // e.g. "platform" → "platform-app"
122
+ if (normRepo.startsWith(normInput + '-')) return 80;
123
+
124
+ // Prefix: normRepo is a hyphen-boundary prefix of normInput
125
+ if (normInput.startsWith(normRepo + '-')) return 75;
126
+
127
+ // Substring: normInput appears inside normRepo
128
+ if (normRepo.includes(normInput)) return 65;
129
+
130
+ // Substring: normRepo appears inside normInput
131
+ if (normInput.includes(normRepo)) return 60;
132
+
133
+ // Token-level match: split by hyphens and see how many tokens overlap
134
+ const inputTokens = normInput.split('-').filter(Boolean);
135
+ const repoTokens = normRepo.split('-').filter(Boolean);
136
+ if (inputTokens.length > 0 && repoTokens.length > 0) {
137
+ let matched = 0;
138
+ for (const it of inputTokens) {
139
+ if (repoTokens.some(rt => rt === it || rt.startsWith(it) || it.startsWith(rt))) {
140
+ matched++;
141
+ }
142
+ }
143
+ if (matched > 0) {
144
+ const tokenScore = Math.round(
145
+ (matched / Math.max(inputTokens.length, repoTokens.length)) * 55,
146
+ );
147
+ if (tokenScore >= MIN_SCORE) return tokenScore;
148
+ }
149
+ }
150
+
151
+ // Typo tolerance — Damerau-Levenshtein so an adjacent transposition
152
+ // (e.g. "capser" → "casper") counts as a single edit.
153
+ const dist = damerauLevenshtein(normInput, normRepo);
154
+ // Allow more edits for longer names (up to 3, never more)
155
+ const maxDist = Math.min(3, Math.floor(Math.max(normInput.length, normRepo.length) * 0.25));
156
+ if (dist <= maxDist && maxDist > 0) {
157
+ return Math.max(MIN_SCORE, 50 - dist * 10);
158
+ }
159
+
160
+ return 0;
161
+ }
162
+
163
+ /**
164
+ * Find the best-matching repo from `allRepos` for a given user-supplied `input`.
165
+ *
166
+ * @param input The repo name as typed/extracted (may be partial or mis-spelled).
167
+ * @param allRepos Full list of available repositories (must have a `.name` string field).
168
+ * @returns The best match and its score, or `null` if no match is good enough.
169
+ */
170
+ export function fuzzyFindRepo<T extends { name: string }>(
171
+ input: string,
172
+ allRepos: T[],
173
+ ): FuzzyMatch<T> | null {
174
+ if (!input || allRepos.length === 0) return null;
175
+ // Inputs like "_", "-", or " " normalise to ""; String#includes('') is
176
+ // always true, so they would match every repo. Reject them early.
177
+ if (!normalise(input)) return null;
178
+
179
+ let best: FuzzyMatch<T> | null = null;
180
+
181
+ for (const repo of allRepos) {
182
+ const score = scoreMatch(input, repo.name);
183
+ if (score > 0 && (!best || score > best.score)) {
184
+ best = { repo, score, exact: score >= 90 };
185
+ }
186
+ }
187
+
188
+ if (best && best.score >= MIN_SCORE) return best;
189
+ return null;
190
+ }
191
+
192
+ /**
193
+ * Resolve an array of user-supplied repo name inputs against the full repo list.
194
+ *
195
+ * Returns:
196
+ * - `resolved`: array of `{ input, repo, exact }` for each successfully matched name
197
+ * - `notFound`: array of input strings that had no good match
198
+ */
199
+ export function resolveRepoNames<T extends { name: string }>(
200
+ inputs: string[],
201
+ allRepos: T[],
202
+ ): { resolved: Array<{ input: string; repo: T; exact: boolean }>; notFound: string[] } {
203
+ const resolved: Array<{ input: string; repo: T; exact: boolean }> = [];
204
+ const notFound: string[] = [];
205
+ // Deduplicate: if two inputs fuzzy-resolve to the same repo, keep only the first.
206
+ const seen = new Set<string>();
207
+
208
+ for (const input of inputs) {
209
+ const match = fuzzyFindRepo(input, allRepos);
210
+ if (match) {
211
+ if (!seen.has(match.repo.name)) {
212
+ seen.add(match.repo.name);
213
+ resolved.push({ input, repo: match.repo, exact: match.exact });
214
+ }
215
+ // else: duplicate resolution — silently skip (first wins)
216
+ } else {
217
+ notFound.push(input);
218
+ }
219
+ }
220
+
221
+ return { resolved, notFound };
222
+ }
223
+
224
+ /** Suffix charset shared with the MCP update_workspace tool (name-suffix dir). */
225
+ export const VALID_SUFFIX = /^[a-zA-Z0-9_-]+$/;
226
+
227
+ export interface RepoSpec {
228
+ /** Repo name to resolve (may be fuzzy). */
229
+ name: string;
230
+ /** Optional instance suffix — clones the same repo into `<repo>-<suffix>`. */
231
+ suffix?: string;
232
+ }
233
+
234
+ /**
235
+ * Parse a single `--repos` entry that may carry an instance suffix.
236
+ *
237
+ * The same repository can be added to a workspace more than once by giving
238
+ * each copy a distinct suffix, e.g. a git-worktree-style second checkout:
239
+ *
240
+ * "casper" -> { name: "casper" } (dir: casper)
241
+ * "casper:cas-101" -> { name: "casper", suffix: "cas-101"} (dir: casper-cas-101)
242
+ *
243
+ * This mirrors the MCP `update_workspace` tool's `{ name, suffix }` shape so
244
+ * the CLI and agent paths behave identically.
245
+ */
246
+ export function parseRepoSpec(spec: string): RepoSpec {
247
+ const trimmed = spec.trim();
248
+ const idx = trimmed.indexOf(':');
249
+ if (idx > 0) {
250
+ const name = trimmed.slice(0, idx).trim();
251
+ const suffix = trimmed.slice(idx + 1).trim();
252
+ return suffix ? { name, suffix } : { name };
253
+ }
254
+ return { name: trimmed };
255
+ }
256
+
257
+ /**
258
+ * Resolve an array of `--repos` specs (each possibly `name:suffix`) into repo
259
+ * entries with their target directory names. Unlike {@link resolveRepoNames},
260
+ * this dedupes by the final **directory name**, so the same repo can appear
261
+ * multiple times under different suffixes.
262
+ */
263
+ export function resolveRepoSpecs<T extends { name: string }>(
264
+ inputs: string[],
265
+ allRepos: T[],
266
+ ): {
267
+ resolved: Array<{ input: string; repo: T; exact: boolean; suffix?: string; directoryName: string }>;
268
+ notFound: string[];
269
+ invalidSuffix: string[];
270
+ } {
271
+ const resolved: Array<{ input: string; repo: T; exact: boolean; suffix?: string; directoryName: string }> = [];
272
+ const notFound: string[] = [];
273
+ const invalidSuffix: string[] = [];
274
+ const seenDirs = new Set<string>();
275
+
276
+ for (const input of inputs) {
277
+ const spec = parseRepoSpec(input);
278
+ if (spec.suffix !== undefined && !VALID_SUFFIX.test(spec.suffix)) {
279
+ invalidSuffix.push(input);
280
+ continue;
281
+ }
282
+ const match = fuzzyFindRepo(spec.name, allRepos);
283
+ if (!match) {
284
+ notFound.push(spec.name);
285
+ continue;
286
+ }
287
+ const directoryName = spec.suffix ? `${match.repo.name}-${spec.suffix}` : match.repo.name;
288
+ if (seenDirs.has(directoryName)) continue; // duplicate target dir within this batch
289
+ seenDirs.add(directoryName);
290
+ resolved.push({ input, repo: match.repo, exact: match.exact, suffix: spec.suffix, directoryName });
291
+ }
292
+
293
+ return { resolved, notFound, invalidSuffix };
294
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { isRetryableError, withRetry } from './retry';
3
+
4
+ // Mock the logger to avoid console output during tests
5
+ vi.mock('./logger', () => ({
6
+ logWarning: vi.fn(),
7
+ }));
8
+
9
+ describe('isRetryableError', () => {
10
+ it('returns true for timeout errors', () => {
11
+ expect(isRetryableError(new Error('Operation timeout'))).toBe(true);
12
+ });
13
+
14
+ it('returns true for ETIMEDOUT errors', () => {
15
+ expect(isRetryableError(new Error('connect ETIMEDOUT 1.2.3.4:443'))).toBe(true);
16
+ });
17
+
18
+ it('returns true for ECONNREFUSED errors', () => {
19
+ expect(isRetryableError(new Error('connect ECONNREFUSED 127.0.0.1:22'))).toBe(true);
20
+ });
21
+
22
+ it('returns true for ECONNRESET errors', () => {
23
+ expect(isRetryableError(new Error('read ECONNRESET'))).toBe(true);
24
+ });
25
+
26
+ it('returns true for ENOTFOUND errors', () => {
27
+ expect(isRetryableError(new Error('getaddrinfo ENOTFOUND github.com'))).toBe(true);
28
+ });
29
+
30
+ it('returns false for authentication failed', () => {
31
+ expect(isRetryableError(new Error('Authentication failed for repo'))).toBe(false);
32
+ });
33
+
34
+ it('returns false for permission denied', () => {
35
+ expect(isRetryableError(new Error('Permission denied (publickey)'))).toBe(false);
36
+ });
37
+
38
+ it('returns false for 404 errors', () => {
39
+ expect(isRetryableError(new Error('Repository not found: 404'))).toBe(false);
40
+ });
41
+
42
+ it('returns false for 401 errors', () => {
43
+ expect(isRetryableError(new Error('HTTP 401 Unauthorized'))).toBe(false);
44
+ });
45
+
46
+ it('returns false for 403 errors', () => {
47
+ expect(isRetryableError(new Error('HTTP 403 Forbidden'))).toBe(false);
48
+ });
49
+
50
+ it('returns false for repository not found', () => {
51
+ expect(isRetryableError(new Error('repository not found'))).toBe(false);
52
+ });
53
+
54
+ it('returns false for generic errors', () => {
55
+ expect(isRetryableError(new Error('Something went wrong'))).toBe(false);
56
+ });
57
+ });
58
+
59
+ describe('withRetry', () => {
60
+ it('succeeds on first try', async () => {
61
+ const fn = vi.fn().mockResolvedValue('success');
62
+ const result = await withRetry(fn);
63
+ expect(result).toBe('success');
64
+ expect(fn).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ it('succeeds on retry after retryable failure', async () => {
68
+ const fn = vi.fn()
69
+ .mockRejectedValueOnce(new Error('connect ETIMEDOUT'))
70
+ .mockResolvedValueOnce('success');
71
+
72
+ const result = await withRetry(fn, { maxRetries: 1, initialDelayMs: 10 });
73
+ expect(result).toBe('success');
74
+ expect(fn).toHaveBeenCalledTimes(2);
75
+ });
76
+
77
+ it('fails after max retries exhausted', async () => {
78
+ const fn = vi.fn().mockRejectedValue(new Error('connect ETIMEDOUT'));
79
+
80
+ await expect(
81
+ withRetry(fn, { maxRetries: 2, initialDelayMs: 10 })
82
+ ).rejects.toThrow('connect ETIMEDOUT');
83
+ expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries
84
+ });
85
+
86
+ it('does not retry non-retryable errors', async () => {
87
+ const fn = vi.fn().mockRejectedValue(new Error('Authentication failed'));
88
+
89
+ await expect(
90
+ withRetry(fn, { maxRetries: 3, initialDelayMs: 10 })
91
+ ).rejects.toThrow('Authentication failed');
92
+ expect(fn).toHaveBeenCalledTimes(1);
93
+ });
94
+
95
+ it('applies exponential backoff', async () => {
96
+ const fn = vi.fn()
97
+ .mockRejectedValueOnce(new Error('ECONNRESET'))
98
+ .mockRejectedValueOnce(new Error('ECONNRESET'))
99
+ .mockResolvedValueOnce('success');
100
+
101
+ const start = Date.now();
102
+ await withRetry(fn, { maxRetries: 2, initialDelayMs: 50, backoffMultiplier: 2, maxDelayMs: 10000 });
103
+ const elapsed = Date.now() - start;
104
+
105
+ // Should have waited ~50ms + ~100ms = ~150ms (with some tolerance)
106
+ expect(elapsed).toBeGreaterThanOrEqual(100);
107
+ expect(fn).toHaveBeenCalledTimes(3);
108
+ });
109
+
110
+ it('respects maxDelayMs', async () => {
111
+ const fn = vi.fn()
112
+ .mockRejectedValueOnce(new Error('ECONNRESET'))
113
+ .mockRejectedValueOnce(new Error('ECONNRESET'))
114
+ .mockResolvedValueOnce('success');
115
+
116
+ const start = Date.now();
117
+ await withRetry(fn, { maxRetries: 2, initialDelayMs: 100, backoffMultiplier: 100, maxDelayMs: 150 });
118
+ const elapsed = Date.now() - start;
119
+
120
+ // First delay 100ms, second should be capped at 150ms (not 100*100=10000)
121
+ expect(elapsed).toBeLessThan(500);
122
+ expect(fn).toHaveBeenCalledTimes(3);
123
+ });
124
+ });
@@ -0,0 +1,85 @@
1
+ import { logWarning } from './logger';
2
+
3
+ export interface RetryOptions {
4
+ maxRetries: number;
5
+ initialDelayMs: number;
6
+ backoffMultiplier: number;
7
+ maxDelayMs: number;
8
+ }
9
+
10
+ const DEFAULT_OPTIONS: RetryOptions = {
11
+ maxRetries: 1,
12
+ initialDelayMs: 2000,
13
+ backoffMultiplier: 2,
14
+ maxDelayMs: 10000,
15
+ };
16
+
17
+ const NON_RETRYABLE_PATTERNS = [
18
+ 'authentication failed',
19
+ 'permission denied',
20
+ 'repository not found',
21
+ 'could not read from remote repository',
22
+ '401',
23
+ '403',
24
+ '404',
25
+ ];
26
+
27
+ const RETRYABLE_PATTERNS = [
28
+ 'timeout',
29
+ 'etimedout',
30
+ 'econnrefused',
31
+ 'econnreset',
32
+ 'enotfound',
33
+ ];
34
+
35
+ export function isRetryableError(error: unknown): boolean {
36
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
37
+
38
+ for (const pattern of NON_RETRYABLE_PATTERNS) {
39
+ if (message.includes(pattern)) {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ for (const pattern of RETRYABLE_PATTERNS) {
45
+ if (message.includes(pattern)) {
46
+ return true;
47
+ }
48
+ }
49
+
50
+ return false;
51
+ }
52
+
53
+ function sleep(ms: number): Promise<void> {
54
+ return new Promise(resolve => setTimeout(resolve, ms));
55
+ }
56
+
57
+ export async function withRetry<T>(
58
+ operation: () => Promise<T>,
59
+ options?: Partial<RetryOptions>,
60
+ operationName?: string
61
+ ): Promise<T> {
62
+ const opts = { ...DEFAULT_OPTIONS, ...options };
63
+ let lastError: unknown;
64
+ let delay = opts.initialDelayMs;
65
+
66
+ for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
67
+ try {
68
+ return await operation();
69
+ } catch (error) {
70
+ lastError = error;
71
+
72
+ if (attempt >= opts.maxRetries || !isRetryableError(error)) {
73
+ throw error;
74
+ }
75
+
76
+ const name = operationName || 'operation';
77
+ logWarning(`${name} failed (attempt ${attempt + 1}/${opts.maxRetries + 1}), retrying in ${(delay / 1000).toFixed(1)}s...`);
78
+
79
+ await sleep(delay);
80
+ delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs);
81
+ }
82
+ }
83
+
84
+ throw lastError;
85
+ }
@@ -0,0 +1,38 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+
4
+ const execAsync = promisify(exec);
5
+
6
+ const COMMAND_TIMEOUT = 5 * 60 * 1000; // 5 minutes
7
+
8
+ export interface RunResult {
9
+ repo: string;
10
+ status: 'success' | 'failed';
11
+ stdout: string;
12
+ stderr: string;
13
+ error?: string;
14
+ }
15
+
16
+ export async function runInRepo(repoPath: string, repoName: string, command: string): Promise<RunResult> {
17
+ try {
18
+ const { stdout, stderr } = await execAsync(command, {
19
+ cwd: repoPath,
20
+ timeout: COMMAND_TIMEOUT,
21
+ });
22
+
23
+ return {
24
+ repo: repoName,
25
+ status: 'success',
26
+ stdout: stdout.trim(),
27
+ stderr: stderr.trim(),
28
+ };
29
+ } catch (error: any) {
30
+ return {
31
+ repo: repoName,
32
+ status: 'failed',
33
+ stdout: error.stdout?.trim() || '',
34
+ stderr: error.stderr?.trim() || '',
35
+ error: error.message,
36
+ };
37
+ }
38
+ }