magus-cli 0.0.0-placeholder → 7.0.0

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 (331) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +684 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +445 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +174 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +335 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/moved-marketplace.test.ts +86 -0
  96. package/src/__tests__/open-file.test.ts +59 -0
  97. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  98. package/src/__tests__/plugin-checkout.test.ts +66 -0
  99. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  100. package/src/__tests__/plugin-contents.test.ts +371 -0
  101. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  102. package/src/__tests__/plugin-releases.test.ts +98 -0
  103. package/src/__tests__/plugin-requires.test.ts +187 -0
  104. package/src/__tests__/plugin-row-text.test.ts +336 -0
  105. package/src/__tests__/plugin-setup.test.ts +1016 -0
  106. package/src/__tests__/profile-adopt.test.ts +177 -0
  107. package/src/__tests__/profile-command.test.ts +110 -0
  108. package/src/__tests__/profile-materializer.test.ts +238 -0
  109. package/src/__tests__/profile-sync.test.ts +154 -0
  110. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  111. package/src/__tests__/resolve-executable.test.ts +42 -0
  112. package/src/__tests__/resolver.test.ts +351 -0
  113. package/src/__tests__/scope-action.test.ts +134 -0
  114. package/src/__tests__/scope-squares.test.tsx +164 -0
  115. package/src/__tests__/selection-scope.test.ts +100 -0
  116. package/src/__tests__/settings-file.test.ts +179 -0
  117. package/src/__tests__/shell-script-callers.test.ts +92 -0
  118. package/src/__tests__/skill-install-files.test.ts +136 -0
  119. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  120. package/src/__tests__/style-wrap.test.ts +220 -0
  121. package/src/__tests__/styles-manager.test.ts +1098 -0
  122. package/src/__tests__/styles-origins.test.ts +414 -0
  123. package/src/__tests__/styles-screen-state.test.ts +457 -0
  124. package/src/__tests__/styles-status-line.test.ts +72 -0
  125. package/src/__tests__/styles-sync.test.ts +452 -0
  126. package/src/__tests__/symlink-manager.test.ts +180 -0
  127. package/src/__tests__/tabbar-layout.test.ts +100 -0
  128. package/src/__tests__/terminology-filler.test.ts +214 -0
  129. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  130. package/src/__tests__/theme-env.test.ts +281 -0
  131. package/src/__tests__/theme-resolve.test.ts +380 -0
  132. package/src/__tests__/tool-probe.test.ts +1400 -0
  133. package/src/__tests__/toolchain.test.ts +232 -0
  134. package/src/__tests__/ui-version-writers.test.ts +88 -0
  135. package/src/__tests__/update-apply.test.ts +637 -0
  136. package/src/__tests__/update-plan.test.ts +1373 -0
  137. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  138. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  139. package/src/__tests__/version-check-refresh.test.ts +45 -0
  140. package/src/__tests__/version-snapshot.test.ts +212 -0
  141. package/src/__tests__/version-unknown-state.test.ts +388 -0
  142. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  143. package/src/cli/ansi.ts +520 -0
  144. package/src/cli/bootstrap.ts +134 -0
  145. package/src/cli/doctor.ts +218 -0
  146. package/src/cli/hook.ts +129 -0
  147. package/src/cli/install.ts +382 -0
  148. package/src/cli/live.ts +374 -0
  149. package/src/cli/models.ts +297 -0
  150. package/src/cli/profile.ts +197 -0
  151. package/src/cli/prompt.ts +32 -0
  152. package/src/cli/router.ts +207 -0
  153. package/src/cli/update-view.ts +526 -0
  154. package/src/cli/update.ts +613 -0
  155. package/src/cli/upgrade.ts +97 -0
  156. package/src/data/alias-flags.ts +301 -0
  157. package/src/data/cli-tools.ts +146 -0
  158. package/src/data/community-styles.ts +531 -0
  159. package/src/data/gitignore-defaults.ts +33 -0
  160. package/src/data/gitignore-reasons.ts +93 -0
  161. package/src/data/gitignore-templates.ts +25 -0
  162. package/src/data/marketplaces.ts +278 -0
  163. package/src/data/mcp-servers.ts +51 -0
  164. package/src/data/models-presets.ts +329 -0
  165. package/src/data/predefined-profiles.ts +252 -0
  166. package/src/data/settings-catalog.ts +714 -0
  167. package/src/data/skill-repos.ts +91 -0
  168. package/src/data/styles/asd-ste100.md +38 -0
  169. package/src/data/styles/calibrated.md +24 -0
  170. package/src/data/styles/direct.md +23 -0
  171. package/src/data/styles/evidence-first.md +22 -0
  172. package/src/data/styles/explanatory.md +23 -0
  173. package/src/data/styles/index.ts +57 -0
  174. package/src/data/styles/no-slop.md +32 -0
  175. package/src/data/styles/plain-language.md +23 -0
  176. package/src/data/styles/structured.md +30 -0
  177. package/src/data/styles/terminology.md +28 -0
  178. package/src/data/styles/terse.md +20 -0
  179. package/src/main.tsx +110 -0
  180. package/src/markdown.d.ts +13 -0
  181. package/src/opentui.d.ts +203 -0
  182. package/src/services/active-closure.ts +117 -0
  183. package/src/services/alias-settings.ts +60 -0
  184. package/src/services/alias-shell-writer.ts +1244 -0
  185. package/src/services/alias-store.ts +172 -0
  186. package/src/services/binary-signing.ts +78 -0
  187. package/src/services/catalog-cache-store.ts +316 -0
  188. package/src/services/claude-cli.ts +304 -0
  189. package/src/services/claude-runner.ts +31 -0
  190. package/src/services/claude-settings.ts +1589 -0
  191. package/src/services/cli-tool-commands.ts +133 -0
  192. package/src/services/community-fetcher.ts +90 -0
  193. package/src/services/community-styles.ts +1194 -0
  194. package/src/services/content-drift.ts +208 -0
  195. package/src/services/conventions-manager.ts +945 -0
  196. package/src/services/doctor-bins.ts +63 -0
  197. package/src/services/doctor.ts +570 -0
  198. package/src/services/dotenv.ts +181 -0
  199. package/src/services/git-worktree.ts +129 -0
  200. package/src/services/github-budget.ts +274 -0
  201. package/src/services/gitignore-check.ts +63 -0
  202. package/src/services/gitignore-detector.ts +167 -0
  203. package/src/services/gitignore-fixer.ts +280 -0
  204. package/src/services/gitignore-resolver.ts +208 -0
  205. package/src/services/gitignore-service.ts +167 -0
  206. package/src/services/hook-registration.ts +218 -0
  207. package/src/services/install-plan.ts +107 -0
  208. package/src/services/local-marketplace.ts +587 -0
  209. package/src/services/manifest.ts +250 -0
  210. package/src/services/marketplace-catalog-git.ts +173 -0
  211. package/src/services/marketplace-catalog.ts +129 -0
  212. package/src/services/marketplace-fetcher.ts +400 -0
  213. package/src/services/marketplace-refresh.ts +253 -0
  214. package/src/services/marketplace-sync.ts +145 -0
  215. package/src/services/mate-availability.ts +133 -0
  216. package/src/services/mate-catalog.ts +265 -0
  217. package/src/services/mcp-registry.ts +171 -0
  218. package/src/services/models-core.ts +969 -0
  219. package/src/services/models-manager.ts +606 -0
  220. package/src/services/plugin-checkout.ts +186 -0
  221. package/src/services/plugin-manager.ts +878 -0
  222. package/src/services/plugin-mcp-config.ts +242 -0
  223. package/src/services/plugin-releases.ts +47 -0
  224. package/src/services/plugin-requires.ts +227 -0
  225. package/src/services/plugin-setup.ts +965 -0
  226. package/src/services/profile-adopt.ts +206 -0
  227. package/src/services/profile-materializer.ts +133 -0
  228. package/src/services/profile-sync.ts +158 -0
  229. package/src/services/profiles.ts +225 -0
  230. package/src/services/resolver.ts +344 -0
  231. package/src/services/settings-file.ts +69 -0
  232. package/src/services/settings-manager.ts +307 -0
  233. package/src/services/skills-manager.ts +1111 -0
  234. package/src/services/skillsmp-client.ts +193 -0
  235. package/src/services/styles-manager.ts +1294 -0
  236. package/src/services/symlink-manager.ts +189 -0
  237. package/src/services/terminology-filler.ts +266 -0
  238. package/src/services/tool-probe.ts +1637 -0
  239. package/src/services/toolchain.ts +431 -0
  240. package/src/services/update-engine.ts +691 -0
  241. package/src/services/update-plan.ts +863 -0
  242. package/src/services/version-check.ts +127 -0
  243. package/src/services/version-snapshot.ts +189 -0
  244. package/src/tui.tsx +112 -0
  245. package/src/types/bun.d.ts +27 -0
  246. package/src/types/gitignore.ts +68 -0
  247. package/src/types/index.ts +447 -0
  248. package/src/ui/App.tsx +538 -0
  249. package/src/ui/adapters/catalogNotice.ts +122 -0
  250. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  251. package/src/ui/adapters/modelsAdapter.ts +213 -0
  252. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  253. package/src/ui/adapters/settingsAdapter.ts +168 -0
  254. package/src/ui/adapters/skillsAdapter.ts +229 -0
  255. package/src/ui/adapters/stylesAdapter.ts +403 -0
  256. package/src/ui/components/CategoryHeader.tsx +64 -0
  257. package/src/ui/components/EmptyFilterState.tsx +32 -0
  258. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  259. package/src/ui/components/ScopeIndicator.tsx +63 -0
  260. package/src/ui/components/ScrollableList.tsx +95 -0
  261. package/src/ui/components/SearchInput.tsx +57 -0
  262. package/src/ui/components/StyledText.tsx +75 -0
  263. package/src/ui/components/TabBar.tsx +101 -0
  264. package/src/ui/components/layout/FooterHints.tsx +78 -0
  265. package/src/ui/components/layout/Panel.tsx +63 -0
  266. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  267. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  268. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  269. package/src/ui/components/layout/index.ts +9 -0
  270. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  271. package/src/ui/components/modals/InputModal.tsx +75 -0
  272. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  273. package/src/ui/components/modals/MessageModal.tsx +66 -0
  274. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  275. package/src/ui/components/modals/SelectModal.tsx +90 -0
  276. package/src/ui/components/modals/index.ts +6 -0
  277. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  278. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  279. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  280. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  281. package/src/ui/components/primitives/MetaText.tsx +40 -0
  282. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  283. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  284. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  285. package/src/ui/components/primitives/index.ts +9 -0
  286. package/src/ui/hooks/index.ts +2 -0
  287. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  288. package/src/ui/hooks/useKeyboard.ts +24 -0
  289. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  290. package/src/ui/registry.ts +33 -0
  291. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  292. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  293. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  294. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  295. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  296. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  297. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  298. package/src/ui/renderers/pluginRowText.ts +70 -0
  299. package/src/ui/renderers/pluginStatusText.ts +56 -0
  300. package/src/ui/renderers/profileRenderers.tsx +406 -0
  301. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  302. package/src/ui/renderers/skillRenderers.tsx +479 -0
  303. package/src/ui/renderers/styleRenderers.tsx +813 -0
  304. package/src/ui/screens/AliasScreen.tsx +1558 -0
  305. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  306. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  307. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  308. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  309. package/src/ui/screens/McpScreen.tsx +256 -0
  310. package/src/ui/screens/ModelsScreen.tsx +542 -0
  311. package/src/ui/screens/PluginsScreen.tsx +1464 -0
  312. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  313. package/src/ui/screens/SkillsScreen.tsx +853 -0
  314. package/src/ui/screens/StylesScreen.tsx +1064 -0
  315. package/src/ui/screens/index.ts +11 -0
  316. package/src/ui/state/AppContext.tsx +233 -0
  317. package/src/ui/state/DimensionsContext.tsx +154 -0
  318. package/src/ui/state/reducer.ts +810 -0
  319. package/src/ui/state/types.ts +503 -0
  320. package/src/ui/theme-mode.ts +352 -0
  321. package/src/ui/theme-resolve.ts +147 -0
  322. package/src/ui/theme.ts +175 -0
  323. package/src/utils/clipboard.ts +58 -0
  324. package/src/utils/command-utils.ts +144 -0
  325. package/src/utils/config-dir.ts +45 -0
  326. package/src/utils/file-locking.ts +146 -0
  327. package/src/utils/fuzzy-search.ts +138 -0
  328. package/src/utils/go-module.ts +36 -0
  329. package/src/utils/open-file.ts +84 -0
  330. package/src/utils/run.ts +67 -0
  331. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,1244 @@
1
+ /**
2
+ * Render the managed `claude` alias and splice it into shell rc files.
3
+ *
4
+ * Two layers:
5
+ * - Pure render functions (POSIX vs fish) that take a config and emit text.
6
+ * - I/O helpers that detect installed shells and replace the managed block
7
+ * idempotently using sentinel comment markers.
8
+ */
9
+
10
+ import { existsSync } from "node:fs";
11
+ import { readFile, stat, writeFile } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import {
15
+ ALIAS_FLAGS,
16
+ type AliasFlag,
17
+ getFlagById,
18
+ } from "../data/alias-flags.js";
19
+ import {
20
+ type AliasConfig,
21
+ CHANNELS_FLAG_ID,
22
+ type FlagValue,
23
+ defaultValueFor,
24
+ derivedChannelValues,
25
+ listValues,
26
+ } from "./alias-store.js";
27
+
28
+ export type ShellKind = "zsh" | "bash" | "fish";
29
+
30
+ export interface ShellTarget {
31
+ kind: ShellKind;
32
+ /** Absolute path to the rc file. */
33
+ path: string;
34
+ /** True if the file currently exists on disk. */
35
+ exists: boolean;
36
+ /** True if `$SHELL` points at this shell — the default write target. */
37
+ isDefault: boolean;
38
+ }
39
+
40
+ const BLOCK_BEGIN = "# >>> magus managed alias >>>";
41
+ const BLOCK_END = "# <<< magus managed alias <<<";
42
+
43
+ /**
44
+ * Probe the user's home dir for the three supported shell rc files.
45
+ *
46
+ * Path discovery is intentionally simple: we don't follow `$ZDOTDIR` or
47
+ * fish's `$XDG_CONFIG_HOME` overrides yet. If users hit those, we expose
48
+ * a path override later — for now the conventional locations cover the
49
+ * default install on macOS / Linux.
50
+ */
51
+ export async function detectShells(
52
+ home: string = homedir(),
53
+ shellEnv: string = process.env.SHELL ?? "",
54
+ ): Promise<ShellTarget[]> {
55
+ const candidates: Array<{ kind: ShellKind; path: string }> = [
56
+ { kind: "zsh", path: join(home, ".zshrc") },
57
+ { kind: "bash", path: join(home, ".bashrc") },
58
+ { kind: "fish", path: join(home, ".config", "fish", "config.fish") },
59
+ ];
60
+
61
+ const defaultKind = inferDefaultShell(shellEnv);
62
+
63
+ const results: ShellTarget[] = [];
64
+ for (const c of candidates) {
65
+ let exists = existsSync(c.path);
66
+ if (exists) {
67
+ // existsSync can lie on broken symlinks — check stat too
68
+ try {
69
+ await stat(c.path);
70
+ } catch {
71
+ exists = false;
72
+ }
73
+ }
74
+ results.push({
75
+ kind: c.kind,
76
+ path: c.path,
77
+ exists,
78
+ isDefault: c.kind === defaultKind,
79
+ });
80
+ }
81
+ return results;
82
+ }
83
+
84
+ function inferDefaultShell(shellEnv: string): ShellKind | null {
85
+ if (shellEnv.endsWith("/zsh")) return "zsh";
86
+ if (shellEnv.endsWith("/bash")) return "bash";
87
+ if (shellEnv.endsWith("/fish")) return "fish";
88
+ return null;
89
+ }
90
+
91
+ // ─── Rendering ────────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * One emitted argv token in the alias body. A token can be:
95
+ *
96
+ * - A plain `literal` token like `--ide` or `low` (gets single-quoted in
97
+ * the outer alias body).
98
+ * - A `composite` token, where the actual argv value is the concatenation
99
+ * of multiple parts. Used by templated flags so a value like
100
+ * `dev-{folder}-{day}` becomes ONE shell argument with two embedded
101
+ * substitutions, not three separate tokens.
102
+ *
103
+ * Composite parts are rendered together with no separating whitespace and
104
+ * with the appropriate close-outer / reopen-outer dance per shell.
105
+ */
106
+ export type Segment =
107
+ | { kind: "literal"; text: string }
108
+ | { kind: "composite"; parts: SegmentPart[] };
109
+
110
+ export type SegmentPart =
111
+ | { kind: "literal"; text: string }
112
+ | { kind: "raw"; posix: string; fish: string };
113
+
114
+ /**
115
+ * Build the alias body as a list of segments. Pure function.
116
+ *
117
+ * Validation:
118
+ * - `xorGroup` collisions are resolved by emitting only the first enabled
119
+ * flag in the group. The UI is responsible for warning the user; the
120
+ * renderer never silently keeps both.
121
+ * - `requires` is enforced: a flag whose dependency is disabled is dropped.
122
+ */
123
+ export function renderArgs(config: AliasConfig): Segment[] {
124
+ const out: Segment[] = [];
125
+ const seenXor = new Set<string>();
126
+
127
+ for (const flag of ALIAS_FLAGS) {
128
+ if (flag.requires) {
129
+ const dep = config.flags[flag.requires];
130
+ if (!isFlagEnabled(dep)) continue;
131
+ }
132
+ if (flag.xorGroup) {
133
+ if (seenXor.has(flag.xorGroup)) continue;
134
+ }
135
+ const value = config.flags[flag.id];
136
+ if (!value) continue;
137
+
138
+ // `--channels` is the union (own ∪ dev-load-if-enabled), computed here at
139
+ // render time — the union is NOT stored, so own/derived stay distinct in
140
+ // the UI. A dev-loaded channel must run, so derived values force `--channels`
141
+ // to emit even when the user's own channels list is disabled/empty.
142
+ const renderValue =
143
+ flag.id === CHANNELS_FLAG_ID
144
+ ? channelsUnionValue(config.flags, value)
145
+ : value;
146
+
147
+ const rendered = renderFlag(flag, renderValue);
148
+ if (rendered.length === 0) continue;
149
+ out.push(...rendered);
150
+ if (flag.xorGroup) seenXor.add(flag.xorGroup);
151
+ }
152
+
153
+ return out;
154
+ }
155
+
156
+ /**
157
+ * Convenience: render each segment to its literal-token approximation.
158
+ * Composite segments are stringified by joining their literal parts with
159
+ * `<sub>` placeholders for raw parts. Used by tests and validation summaries
160
+ * where the actual shell substitution doesn't matter.
161
+ */
162
+ export function renderArgsAsTokens(config: AliasConfig): string[] {
163
+ return renderArgs(config).map((seg) => {
164
+ if (seg.kind === "literal") return seg.text;
165
+ return seg.parts
166
+ .map((p) => (p.kind === "literal" ? p.text : `<${p.posix}>`))
167
+ .join("");
168
+ });
169
+ }
170
+
171
+ function isFlagEnabled(value: FlagValue | undefined): boolean {
172
+ if (!value) return false;
173
+ switch (value.kind) {
174
+ case "boolean":
175
+ return value.enabled;
176
+ case "tri-state":
177
+ return value.state !== "unset";
178
+ case "select":
179
+ case "text":
180
+ case "optional-text":
181
+ case "text-list":
182
+ case "multi-with-custom":
183
+ return value.enabled;
184
+ }
185
+ }
186
+
187
+ function lit(text: string): Segment {
188
+ return { kind: "literal", text };
189
+ }
190
+
191
+ /**
192
+ * Wrap a templated value as a composite segment if it contains any tokens,
193
+ * otherwise return it as a plain literal.
194
+ */
195
+ function templatedSegment(value: string): Segment {
196
+ const parts = expandTemplateValue(value);
197
+ if (parts.length === 1 && parts[0].kind === "literal") {
198
+ return { kind: "literal", text: parts[0].text };
199
+ }
200
+ return { kind: "composite", parts };
201
+ }
202
+
203
+ /**
204
+ * Build the effective `--channels` value for rendering: the user's own
205
+ * channels unioned with the dev-load flag's values (when dev-load is enabled),
206
+ * deduped, own-first. Enabled when EITHER source contributes a value, so a
207
+ * dev-loaded channel still forces `--channels` to emit even if the user never
208
+ * enabled their own channels list. The union lives here, at render time — it
209
+ * is never written back to `channels.values`, so provenance is preserved.
210
+ */
211
+ function channelsUnionValue(
212
+ flags: Record<string, FlagValue>,
213
+ own: FlagValue,
214
+ ): FlagValue {
215
+ const ownValues =
216
+ own.kind === "text-list" && own.enabled ? listValues(own) : [];
217
+ const derived = derivedChannelValues(flags);
218
+ const merged = dedupe([...ownValues, ...derived]).filter((v) => v.length > 0);
219
+ return { kind: "text-list", enabled: merged.length > 0, values: merged };
220
+ }
221
+
222
+ function renderFlag(flag: AliasFlag, value: FlagValue): Segment[] {
223
+ switch (value.kind) {
224
+ case "boolean":
225
+ return value.enabled ? [lit(flag.flag)] : [];
226
+ case "tri-state":
227
+ if (value.state === "on") return [lit(flag.flag)];
228
+ if (value.state === "off" && flag.triStateOff)
229
+ return [lit(flag.triStateOff)];
230
+ return [];
231
+ case "select":
232
+ if (!value.enabled) return [];
233
+ // Empty value means "bare flag, default variant" (e.g. `--tmux` alone).
234
+ return value.value === ""
235
+ ? [lit(flag.flag)]
236
+ : [lit(flag.flag), lit(value.value)];
237
+ case "text":
238
+ if (!value.enabled || !value.value) return [];
239
+ return [
240
+ lit(flag.flag),
241
+ flag.templated ? templatedSegment(value.value) : lit(value.value),
242
+ ];
243
+ case "optional-text":
244
+ if (!value.enabled) return [];
245
+ if (!value.value) return [lit(flag.flag)];
246
+ return [
247
+ lit(flag.flag),
248
+ flag.templated ? templatedSegment(value.value) : lit(value.value),
249
+ ];
250
+ case "text-list": {
251
+ if (!value.enabled || value.values.length === 0) return [];
252
+ const out: Segment[] = [];
253
+ for (const v of value.values) {
254
+ if (!v) continue;
255
+ out.push(lit(flag.flag), flag.templated ? templatedSegment(v) : lit(v));
256
+ }
257
+ return out;
258
+ }
259
+ case "multi-with-custom": {
260
+ if (!value.enabled) return [];
261
+ const tokens = dedupe([...value.picked, ...value.custom]).filter(
262
+ (t) => t.length > 0,
263
+ );
264
+ if (tokens.length === 0) return [lit(flag.flag)];
265
+ return [lit(flag.flag), lit(tokens.join(","))];
266
+ }
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Per-token shell-substitution code. POSIX and fish forms diverge slightly:
272
+ *
273
+ * - POSIX: classic `$(cmd)` with double quotes around `$PWD` so paths with
274
+ * spaces survive word-splitting in `basename`. The double quotes are
275
+ * tolerable inside the outer-double-quoted segment because we're already
276
+ * committed to a "embedded substitution" layer.
277
+ * - fish: `(cmd)` and fish handles variable expansion without the
278
+ * word-splitting concern, so `$PWD` doesn't need inner quoting.
279
+ *
280
+ * Note for POSIX: when this segment is emitted as `'"$(basename "$PWD")"'`,
281
+ * the shell sees `"$(basename "$PWD")"` — outer `"…"` enclosing an inner
282
+ * `"$PWD"`. POSIX (and bash/zsh) handle nested double quotes inside command
283
+ * substitution correctly: the inner `"$PWD"` is parsed within the `$(...)`
284
+ * subshell scope, not the outer shell's scope.
285
+ */
286
+ const TOKEN_TO_SUB: Record<string, { posix: string; fish: string }> = {
287
+ "{folder}": {
288
+ posix: '$(basename "$PWD")',
289
+ fish: "(basename $PWD)",
290
+ },
291
+ "{day}": { posix: "$(date +%d)", fish: "(date +%d)" },
292
+ "{month}": { posix: "$(date +%m)", fish: "(date +%m)" },
293
+ "{year}": { posix: "$(date +%Y)", fish: "(date +%Y)" },
294
+ };
295
+
296
+ const TEMPLATE_RE = /\{(folder|day|month|year)\}/g;
297
+
298
+ /**
299
+ * Split a templated value into alternating literal and raw parts.
300
+ * Empty literal pieces are dropped. If the value contains no tokens, returns
301
+ * a single literal part covering the full string.
302
+ */
303
+ export function expandTemplateValue(value: string): SegmentPart[] {
304
+ const out: SegmentPart[] = [];
305
+ let last = 0;
306
+ for (const match of value.matchAll(TEMPLATE_RE)) {
307
+ const start = match.index;
308
+ if (start > last) {
309
+ out.push({ kind: "literal", text: value.slice(last, start) });
310
+ }
311
+ const sub = TOKEN_TO_SUB[match[0]];
312
+ if (sub) {
313
+ out.push({ kind: "raw", posix: sub.posix, fish: sub.fish });
314
+ }
315
+ last = start + match[0].length;
316
+ }
317
+ if (last < value.length) {
318
+ out.push({ kind: "literal", text: value.slice(last) });
319
+ }
320
+ if (out.length === 0) {
321
+ out.push({ kind: "literal", text: value });
322
+ }
323
+ return out;
324
+ }
325
+
326
+ function dedupe(arr: string[]): string[] {
327
+ return Array.from(new Set(arr));
328
+ }
329
+
330
+ /**
331
+ * Fish quoting. Fish single quotes are simpler — only `\` and `'` need escapes.
332
+ */
333
+ function quoteFish(s: string): string {
334
+ return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
335
+ }
336
+
337
+ export interface RenderedAlias {
338
+ shell: ShellKind;
339
+ /** The full block including BEGIN/END markers, ending with a newline. */
340
+ block: string;
341
+ }
342
+
343
+ export function renderAlias(
344
+ config: AliasConfig,
345
+ shell: ShellKind,
346
+ ): RenderedAlias {
347
+ const args = renderArgs(config);
348
+ const name = config.aliasName;
349
+ if (shell === "fish") {
350
+ return { shell, block: renderFishBlock(name, args) };
351
+ }
352
+ return { shell, block: renderPosixBlock(name, args) };
353
+ }
354
+
355
+ function renderPosixBlock(name: string, segments: Segment[]): string {
356
+ // The alias body lives inside outer single quotes:
357
+ // alias <name>='claude … '
358
+ // Each segment becomes one shell-arg inside that body. Composite segments
359
+ // (templated values) interleave literal text with double-quoted shell
360
+ // substitutions via close-outer + double-quote + reopen-outer.
361
+ const body =
362
+ segments.length === 0
363
+ ? ""
364
+ : ` ${segments.map(renderPosixSegment).join(" ")}`;
365
+ const line = `alias ${name}='claude${body}'`;
366
+ return `${BLOCK_BEGIN}\n${line}\n${BLOCK_END}\n`;
367
+ }
368
+
369
+ function renderPosixSegment(seg: Segment): string {
370
+ if (seg.kind === "literal") return quoteInsidePosixAlias(seg.text);
371
+ return seg.parts
372
+ .map((p) =>
373
+ p.kind === "literal"
374
+ ? quoteInsidePosixAlias(p.text)
375
+ : embedPosixSubstitution(p.posix),
376
+ )
377
+ .join("");
378
+ }
379
+
380
+ /**
381
+ * Embed shell substitution code inside the outer single-quoted alias body
382
+ * so it fires when the alias is expanded, not when the alias is defined.
383
+ *
384
+ * The outer alias body is `'…'`. To inject `$(cmd)` and have it evaluate at
385
+ * expansion time, we close the outer `'`, switch to double quotes (which
386
+ * interpolate), emit the substitution, close the double quotes, reopen the
387
+ * outer `'`. Net source text: `'"$(cmd)"'`.
388
+ *
389
+ * Any inner double quotes in the substitution code (e.g. `"$PWD"` for
390
+ * spaces-in-paths protection) survive because POSIX command substitution
391
+ * has its own quoting context inside `$(...)`.
392
+ */
393
+ function embedPosixSubstitution(posixCode: string): string {
394
+ return `'"${posixCode}"'`;
395
+ }
396
+
397
+ const POSIX_BARE = /^[A-Za-z0-9_\-=,:.\/!@%+]+$/;
398
+
399
+ function quoteInsidePosixAlias(token: string): string {
400
+ if (POSIX_BARE.test(token)) return token;
401
+ // No internal single quotes guaranteed by upstream validation.
402
+ return `'\\''${token}'\\''`;
403
+ }
404
+
405
+ /**
406
+ * Tokens with embedded single quotes can't be cleanly nested inside an outer
407
+ * single-quoted alias body without compounding escape layers that make the
408
+ * file unreadable. The UI surfaces this as an error before write.
409
+ *
410
+ * Composite segments are not checked here — their literal parts are checked
411
+ * via the same recursion. A `'` in a templated value's literal portion would
412
+ * still be unrenderable, so we walk the parts.
413
+ */
414
+ export function findUnquotableTokens(segments: Segment[]): string[] {
415
+ const out: string[] = [];
416
+ for (const seg of segments) {
417
+ if (seg.kind === "literal") {
418
+ if (seg.text.includes("'")) out.push(seg.text);
419
+ continue;
420
+ }
421
+ for (const p of seg.parts) {
422
+ if (p.kind === "literal" && p.text.includes("'")) out.push(p.text);
423
+ }
424
+ }
425
+ return out;
426
+ }
427
+
428
+ function renderFishBlock(name: string, segments: Segment[]): string {
429
+ // Fish: `alias <name> 'claude --foo bar'` — outer single quotes, no `=`.
430
+ const body =
431
+ segments.length === 0
432
+ ? ""
433
+ : ` ${segments.map(renderFishSegment).join(" ")}`;
434
+ const line = `alias ${name} 'claude${body}'`;
435
+ return `${BLOCK_BEGIN}\n${line}\n${BLOCK_END}\n`;
436
+ }
437
+
438
+ function renderFishSegment(seg: Segment): string {
439
+ if (seg.kind === "literal") return quoteFish(seg.text);
440
+ return seg.parts
441
+ .map((p) =>
442
+ p.kind === "literal" ? quoteFish(p.text) : embedFishSubstitution(p.fish),
443
+ )
444
+ .join("");
445
+ }
446
+
447
+ /**
448
+ * Fish substitution embedding mirrors POSIX. Outer `'…'` is fish's literal
449
+ * single-quote (no interpolation), so close-and-double-quote-reopen is the
450
+ * same dance: `'"(cmd)"'` becomes literal-' + `(cmd)` evaluated at expansion
451
+ * time + literal-'.
452
+ */
453
+ function embedFishSubstitution(fishCode: string): string {
454
+ return `'"${fishCode}"'`;
455
+ }
456
+
457
+ // ─── Splicing ─────────────────────────────────────────────────────────────
458
+
459
+ /**
460
+ * Replace the existing managed block in `existing` with `block`, or append
461
+ * `block` to the end if no block is present. Pure function — no I/O.
462
+ */
463
+ export function spliceManagedBlock(existing: string, block: string): string {
464
+ const beginIdx = existing.indexOf(BLOCK_BEGIN);
465
+ if (beginIdx === -1) {
466
+ if (existing.length === 0) return block;
467
+ const sep = existing.endsWith("\n") ? "" : "\n";
468
+ return `${existing + sep}\n${block}`;
469
+ }
470
+ const endIdx = existing.indexOf(BLOCK_END, beginIdx);
471
+ if (endIdx === -1) {
472
+ // Malformed block — replace from BEGIN to EOF to be safe.
473
+ return existing.slice(0, beginIdx) + block;
474
+ }
475
+ // Consume the trailing newline of the END marker if present.
476
+ let cutEnd = endIdx + BLOCK_END.length;
477
+ if (existing[cutEnd] === "\n") cutEnd += 1;
478
+ return existing.slice(0, beginIdx) + block + existing.slice(cutEnd);
479
+ }
480
+
481
+ export interface WriteResult {
482
+ shell: ShellKind;
483
+ path: string;
484
+ /** "created" if the file did not exist; "updated" otherwise. */
485
+ action: "created" | "updated";
486
+ }
487
+
488
+ export interface WriteOptions {
489
+ /**
490
+ * Line range [start, end] (zero-based, inclusive) of a hand-written alias
491
+ * to REPLACE in place with the managed block — the adoption path. Only
492
+ * honored when the file has no managed block yet (a first write after
493
+ * adoption); subsequent writes find the markers and ignore this. Guarantees
494
+ * the adopted line is removed so no duplicate alias survives.
495
+ */
496
+ adoptLineRange?: [number, number];
497
+ }
498
+
499
+ export async function writeAliasToShell(
500
+ config: AliasConfig,
501
+ target: ShellTarget,
502
+ options: WriteOptions = {},
503
+ ): Promise<WriteResult> {
504
+ const args = renderArgs(config);
505
+ const unquotable = findUnquotableTokens(args);
506
+ if (unquotable.length > 0) {
507
+ throw new Error(
508
+ `Cannot render alias: ${unquotable.length} value${
509
+ unquotable.length === 1 ? "" : "s"
510
+ } contain a single quote, which can't be embedded in a shell alias. Edit the offending value(s) and try again: ${unquotable
511
+ .map((t) => JSON.stringify(t))
512
+ .join(", ")}`,
513
+ );
514
+ }
515
+ const rendered = renderAlias(config, target.kind);
516
+ const existing = target.exists ? await readFile(target.path, "utf8") : "";
517
+
518
+ // Adoption: when a line range was supplied AND the file has no managed block
519
+ // yet, replace the adopted alias line in place — the managed block takes its
520
+ // slot, so the original can't survive as a duplicate. Once a managed block
521
+ // exists, marker-based splicing is position-independent and takes over.
522
+ const hasBlock = existing.includes(BLOCK_BEGIN);
523
+ const next =
524
+ options.adoptLineRange && !hasBlock
525
+ ? spliceManagedBlockAtRange(
526
+ existing,
527
+ rendered.block,
528
+ options.adoptLineRange,
529
+ )
530
+ : spliceManagedBlock(existing, rendered.block);
531
+
532
+ await writeFile(target.path, next, "utf8");
533
+ return {
534
+ shell: target.kind,
535
+ path: target.path,
536
+ action: target.exists ? "updated" : "created",
537
+ };
538
+ }
539
+
540
+ // ─── Validation summary (UI helper) ───────────────────────────────────────
541
+
542
+ export interface FlagValidationIssue {
543
+ flagId: string;
544
+ reason: string;
545
+ }
546
+
547
+ /**
548
+ * Summarize which flags are in conflict given the current config.
549
+ * The UI uses this to grey out / annotate rows. The renderer enforces the
550
+ * same rules independently — this helper is purely for display.
551
+ */
552
+ export function validateConfig(config: AliasConfig): FlagValidationIssue[] {
553
+ const issues: FlagValidationIssue[] = [];
554
+
555
+ // xor groups
556
+ const groups = new Map<string, string[]>();
557
+ for (const flag of ALIAS_FLAGS) {
558
+ if (!flag.xorGroup) continue;
559
+ if (!isFlagEnabled(config.flags[flag.id])) continue;
560
+ const list = groups.get(flag.xorGroup) ?? [];
561
+ list.push(flag.id);
562
+ groups.set(flag.xorGroup, list);
563
+ }
564
+ for (const [group, ids] of groups) {
565
+ if (ids.length > 1) {
566
+ const winner = ids[0];
567
+ const losers = ids.slice(1);
568
+ for (const loser of losers) {
569
+ issues.push({
570
+ flagId: loser,
571
+ reason: `Mutually exclusive with --${winner}; will be dropped on write.`,
572
+ });
573
+ }
574
+ }
575
+ }
576
+
577
+ // requires
578
+ for (const flag of ALIAS_FLAGS) {
579
+ if (!flag.requires) continue;
580
+ if (!isFlagEnabled(config.flags[flag.id])) continue;
581
+ if (!isFlagEnabled(config.flags[flag.requires])) {
582
+ const dep = getFlagById(flag.requires);
583
+ issues.push({
584
+ flagId: flag.id,
585
+ reason: `Requires ${dep.flag}; will be dropped on write.`,
586
+ });
587
+ }
588
+ }
589
+
590
+ return issues;
591
+ }
592
+
593
+ // ─── Parser ──────────────────────────────────────────────────────────────
594
+ //
595
+ // Reverses what the writer emits. Read shell rc text, extract the managed
596
+ // block, find the `alias <name>='claude <body>'` line, tokenize the body
597
+ // into argv strings (undoing POSIX nested-quote escapes and template
598
+ // substitution embeds), then map argv to a FlagValue per the catalog.
599
+ //
600
+ // Only the POSIX dialect (zsh/bash) is supported on parse today. Fish round-
601
+ // trip isn't symmetric to POSIX in our writer's output anyway (fish's auto-
602
+ // concat of adjacent quoted strings makes parsing more involved), and the
603
+ // vast majority of users are on zsh.
604
+
605
+ export interface ParseResult {
606
+ aliasName: string | null;
607
+ args: string[];
608
+ }
609
+
610
+ /**
611
+ * Find the managed block in shell rc text, parse the alias line, and return
612
+ * the alias name plus the argv tokens after `claude`.
613
+ *
614
+ * Returns `null` when no managed block exists, the block is malformed, or
615
+ * the alias line can't be parsed. Callers treat null as "no managed alias
616
+ * on disk — start from defaults".
617
+ */
618
+ export function parseManagedBlock(rcText: string): ParseResult | null {
619
+ const beginIdx = rcText.indexOf(BLOCK_BEGIN);
620
+ if (beginIdx === -1) return null;
621
+ const endIdx = rcText.indexOf(BLOCK_END, beginIdx);
622
+ if (endIdx === -1) return null;
623
+
624
+ // Lines inside the block, sans markers.
625
+ const inner = rcText.slice(beginIdx + BLOCK_BEGIN.length, endIdx);
626
+ // Find the `alias <name>=...` line (skip blank lines, comments, the markers).
627
+ const aliasLine = inner
628
+ .split("\n")
629
+ .map((l) => l.trim())
630
+ .find((l) => /^alias\s+[A-Za-z_][A-Za-z0-9_-]*\s*=/.test(l));
631
+ if (!aliasLine) return null;
632
+
633
+ return parseAliasLine(aliasLine);
634
+ }
635
+
636
+ const ALIAS_LINE_RE = /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*'(.*)'\s*$/;
637
+
638
+ /**
639
+ * Parse a single `alias <name>='<body>'` line. Body is the raw text inside
640
+ * the outer single quotes; the tokenizer below undoes the writer's escape
641
+ * scheme.
642
+ */
643
+ export function parseAliasLine(line: string): ParseResult | null {
644
+ const match = ALIAS_LINE_RE.exec(line);
645
+ if (!match) return null;
646
+ const [, aliasName, body] = match;
647
+ // First token is the command itself (claude). Drop it; we only want argv.
648
+ const tokens = tokenizePosixAliasBody(body);
649
+ if (tokens.length === 0 || tokens[0] !== "claude") return null;
650
+ return { aliasName, args: tokens.slice(1) };
651
+ }
652
+
653
+ /**
654
+ * Tokenize the body of a POSIX alias inside the outer single quotes.
655
+ *
656
+ * Our writer emits three shapes inside the outer `'…'`:
657
+ * 1. Bare tokens (matching POSIX_BARE) — emitted as-is.
658
+ * 2. Quoted literals: `'\''text without single quotes'\''` — these survive
659
+ * the outer single quotes by closing them, emitting a literal `'`,
660
+ * single-quoting the inner content, then a literal `'`, and reopening.
661
+ * 3. Substitution embeds: `'"$(cmd)"'` — close outer ', open ", embed,
662
+ * close ", reopen outer '. Result is one shell arg with the
663
+ * substitution that fires on alias expansion.
664
+ *
665
+ * Reading the body left-to-right, we expect to see:
666
+ * - bare chars (collect into the current token's value)
667
+ * - whitespace (delimits tokens)
668
+ * - `'\''<text>'\''` (collect inner text verbatim into the current token)
669
+ * - `'"<sub>"'` (record as a raw substitution and unparse it back into
670
+ * the closest matching template token via `unparseSubstitution`)
671
+ *
672
+ * Tokens emitted as `composite` segments (template values) re-tokenize into
673
+ * a single shell arg whose pieces are interleaved literal+raw. We rebuild
674
+ * those as `{token}` strings to match what the UI stores.
675
+ */
676
+ export function tokenizePosixAliasBody(body: string): string[] {
677
+ const tokens: string[] = [];
678
+ let current = "";
679
+ let inToken = false;
680
+ let i = 0;
681
+ const len = body.length;
682
+
683
+ const flush = () => {
684
+ if (inToken) {
685
+ tokens.push(current);
686
+ current = "";
687
+ inToken = false;
688
+ }
689
+ };
690
+
691
+ while (i < len) {
692
+ const ch = body[i];
693
+
694
+ if (ch === " " || ch === "\t") {
695
+ flush();
696
+ i += 1;
697
+ continue;
698
+ }
699
+
700
+ // Quoted literal: '\''…'\''
701
+ if (body.startsWith(`'\\''`, i)) {
702
+ // Skip the opening sequence.
703
+ i += 4;
704
+ const end = body.indexOf(`'\\''`, i);
705
+ if (end === -1) return tokens; // malformed — bail, return what we have
706
+ current += body.slice(i, end);
707
+ inToken = true;
708
+ i = end + 4;
709
+ continue;
710
+ }
711
+
712
+ // Substitution embed: '"<sub>"'
713
+ if (body.startsWith(`'"`, i)) {
714
+ const inner = i + 2;
715
+ const close = body.indexOf(`"'`, inner);
716
+ if (close === -1) return tokens;
717
+ const subCode = body.slice(inner, close);
718
+ const token = unparseSubstitution(subCode);
719
+ current += token;
720
+ inToken = true;
721
+ i = close + 2;
722
+ continue;
723
+ }
724
+
725
+ // Bare character.
726
+ current += ch;
727
+ inToken = true;
728
+ i += 1;
729
+ }
730
+ flush();
731
+ return tokens;
732
+ }
733
+
734
+ /**
735
+ * Map a POSIX substitution code back to its template token, if one matches.
736
+ * Falls through to a `$(...)` literal when no token matches — that way we
737
+ * preserve hand-edited substitutions as opaque values rather than dropping
738
+ * them.
739
+ *
740
+ * Mirror of TOKEN_TO_SUB in the writer.
741
+ */
742
+ function unparseSubstitution(posixCode: string): string {
743
+ switch (posixCode) {
744
+ case '$(basename "$PWD")':
745
+ return "{folder}";
746
+ case "$(date +%d)":
747
+ return "{day}";
748
+ case "$(date +%m)":
749
+ return "{month}";
750
+ case "$(date +%Y)":
751
+ return "{year}";
752
+ default:
753
+ // Unknown substitution: pass through the raw code so the user can see
754
+ // it in the UI and decide whether to keep it. The writer can't faithfully
755
+ // round-trip it, but at least we don't silently drop it.
756
+ return posixCode;
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Apply parsed argv tokens to a FlagValue map. Walks the catalog in order,
762
+ * consuming tokens that match each flag's pattern. Tokens that don't match
763
+ * any flag are dropped silently (e.g. the user hand-added a flag we don't
764
+ * know about). Returns a fresh `Record<flagId, FlagValue>` suitable for
765
+ * splicing into an AliasConfig.
766
+ *
767
+ * Ordering: the writer emits flags in catalog order, so a left-to-right
768
+ * scan is sufficient. We pre-index by flag string for `--foo` lookups.
769
+ */
770
+ export function argsToFlagValues(args: string[]): Record<string, FlagValue> {
771
+ return argsToFlagValuesWithLeftovers(args).flags;
772
+ }
773
+
774
+ /**
775
+ * Result of {@link argsToFlagValuesWithLeftovers}: the parsed flag map plus the
776
+ * argv tokens that didn't map to any catalog flag.
777
+ *
778
+ * `leftovers` is the lossless gate for alias adoption: a hand-written alias is
779
+ * safe to absorb into the managed block ONLY when `leftovers` is empty, i.e.
780
+ * every token was recognized. Any leftover token would be silently lost if we
781
+ * deleted the original line and re-rendered from the parsed flags, so a
782
+ * non-empty `leftovers` means "import the flags for the user to see, but do NOT
783
+ * take ownership / do NOT remove their line".
784
+ */
785
+ export interface ParsedFlagsWithLeftovers {
786
+ flags: Record<string, FlagValue>;
787
+ /** argv tokens that matched no catalog flag (and so would be dropped). */
788
+ leftovers: string[];
789
+ }
790
+
791
+ /**
792
+ * Like {@link argsToFlagValues}, but reports every token it couldn't map to a
793
+ * catalog flag. Unknown `--flag value` pairs contribute BOTH tokens to
794
+ * `leftovers` (the flag and its consumed value) so the adoption gate sees the
795
+ * full extent of what wouldn't round-trip.
796
+ */
797
+ export function argsToFlagValuesWithLeftovers(
798
+ args: string[],
799
+ ): ParsedFlagsWithLeftovers {
800
+ // Index every flag by its primary `flag` string AND its triStateOff variant.
801
+ const byFlag = new Map<string, { flag: AliasFlag; off?: boolean }>();
802
+ for (const flag of ALIAS_FLAGS) {
803
+ byFlag.set(flag.flag, { flag, off: false });
804
+ if (flag.triStateOff) {
805
+ byFlag.set(flag.triStateOff, { flag, off: true });
806
+ }
807
+ }
808
+
809
+ const out: Record<string, FlagValue> = {};
810
+ const leftovers: string[] = [];
811
+ let i = 0;
812
+ while (i < args.length) {
813
+ const tok = args[i];
814
+ const entry = byFlag.get(tok);
815
+ if (!entry) {
816
+ // Unknown token — record it. If the next token looks like its value
817
+ // (doesn't start with `-`), consume and record that too so unknown
818
+ // flag/value pairs don't cascade-corrupt the rest of the parse, and so
819
+ // the adoption gate sees the full unrecognized span.
820
+ leftovers.push(tok);
821
+ i += 1;
822
+ if (i < args.length && !args[i].startsWith("-")) {
823
+ leftovers.push(args[i]);
824
+ i += 1;
825
+ }
826
+ continue;
827
+ }
828
+ const { flag, off } = entry;
829
+ const peek = (): string | undefined => args[i + 1];
830
+ const takeValue = (): string => {
831
+ const v = args[i + 1];
832
+ i += 2;
833
+ return v;
834
+ };
835
+
836
+ switch (flag.kind) {
837
+ case "boolean":
838
+ out[flag.id] = { kind: "boolean", enabled: true };
839
+ i += 1;
840
+ break;
841
+ case "tri-state":
842
+ out[flag.id] = { kind: "tri-state", state: off ? "off" : "on" };
843
+ i += 1;
844
+ break;
845
+ case "select": {
846
+ // The writer emits `--flag` alone for "bare" empty value, or
847
+ // `--flag <value>` for a selection. We can't tell without peeking
848
+ // at whether the next token is a known value.
849
+ const opts = flag.options ?? [];
850
+ const next = peek();
851
+ const valid = next !== undefined && opts.some((o) => o.value === next);
852
+ if (valid) {
853
+ out[flag.id] = { kind: "select", enabled: true, value: takeValue() };
854
+ } else {
855
+ out[flag.id] = { kind: "select", enabled: true, value: "" };
856
+ i += 1;
857
+ }
858
+ break;
859
+ }
860
+ case "text":
861
+ // `text` always emits a value; if there's no next token we treat as
862
+ // disabled (defensive).
863
+ if (peek() !== undefined) {
864
+ out[flag.id] = { kind: "text", enabled: true, value: takeValue() };
865
+ } else {
866
+ out[flag.id] = { kind: "text", enabled: false, value: "" };
867
+ i += 1;
868
+ }
869
+ break;
870
+ case "optional-text": {
871
+ // Optional-text: next token is the value if it doesn't look like a
872
+ // flag. We can't perfectly distinguish a deliberately-bare emission
873
+ // from one followed by an unrelated flag, but the writer's emission
874
+ // of bare is `flag.flag` alone with the next token being another
875
+ // catalog flag — so checking the lookup tells us.
876
+ const next = peek();
877
+ if (next === undefined || byFlag.has(next)) {
878
+ out[flag.id] = { kind: "optional-text", enabled: true, value: "" };
879
+ i += 1;
880
+ } else {
881
+ out[flag.id] = {
882
+ kind: "optional-text",
883
+ enabled: true,
884
+ value: takeValue(),
885
+ };
886
+ }
887
+ break;
888
+ }
889
+ case "text-list": {
890
+ // Each instance of the flag emits one value. Collect repeats.
891
+ const values: string[] = [];
892
+ // Consume the first pair.
893
+ if (peek() !== undefined) values.push(takeValue());
894
+ else i += 1;
895
+ // Consume additional pairs of the same flag.
896
+ while (i < args.length && args[i] === flag.flag) {
897
+ if (i + 1 < args.length) {
898
+ values.push(args[i + 1]);
899
+ i += 2;
900
+ } else {
901
+ i += 1;
902
+ break;
903
+ }
904
+ }
905
+ out[flag.id] = {
906
+ kind: "text-list",
907
+ enabled: true,
908
+ values,
909
+ };
910
+ break;
911
+ }
912
+ case "multi-with-custom": {
913
+ // Either bare `--debug` (no filter) or `--debug a,b,c`.
914
+ const next = peek();
915
+ if (next === undefined || byFlag.has(next)) {
916
+ out[flag.id] = {
917
+ kind: "multi-with-custom",
918
+ enabled: true,
919
+ picked: [],
920
+ custom: [],
921
+ };
922
+ i += 1;
923
+ } else {
924
+ const tokens = takeValue()
925
+ .split(",")
926
+ .map((s) => s.trim())
927
+ .filter((s) => s.length > 0);
928
+ const picklist = new Set(flag.picklist ?? []);
929
+ const picked: string[] = [];
930
+ const custom: string[] = [];
931
+ for (const t of tokens) {
932
+ if (picklist.has(t)) picked.push(t);
933
+ else custom.push(t);
934
+ }
935
+ out[flag.id] = {
936
+ kind: "multi-with-custom",
937
+ enabled: true,
938
+ picked,
939
+ custom,
940
+ };
941
+ }
942
+ break;
943
+ }
944
+ }
945
+ }
946
+
947
+ // Restore channels provenance: the writer emits `--channels` as
948
+ // (own ∪ dev-load), so a naive parse folds derived values back into
949
+ // channels.values. Subtract the dev-load values (when that flag was
950
+ // emitted/enabled) so channels.values holds only the user's OWN channels —
951
+ // the derived ones come from the dev-load flag at display/render time.
952
+ const channels = out[CHANNELS_FLAG_ID];
953
+ const derived = derivedChannelValues(out);
954
+ if (channels && channels.kind === "text-list" && derived.length > 0) {
955
+ const ownOnly = channels.values.filter((v) => !derived.includes(v));
956
+ out[CHANNELS_FLAG_ID] = {
957
+ kind: "text-list",
958
+ // Keep enabled only if the user has their own channels; a channels list
959
+ // that was purely derived shouldn't appear enabled after the subtraction.
960
+ enabled: ownOnly.length > 0,
961
+ values: ownOnly,
962
+ };
963
+ }
964
+
965
+ return { flags: out, leftovers };
966
+ }
967
+
968
+ /**
969
+ * High-level entry point: read shell rc text, parse the managed block, and
970
+ * return a populated flag map plus the parsed alias name. Returns null when
971
+ * there's no managed block to parse from.
972
+ */
973
+ export function parseAliasFromRc(rcText: string): {
974
+ aliasName: string;
975
+ flags: Record<string, FlagValue>;
976
+ } | null {
977
+ const parsed = parseManagedBlock(rcText);
978
+ if (!parsed || !parsed.aliasName) return null;
979
+ return {
980
+ aliasName: parsed.aliasName,
981
+ flags: argsToFlagValues(parsed.args),
982
+ };
983
+ }
984
+
985
+ // ─── Adoption (detect & offer to import a hand-written alias) ───────────────
986
+ //
987
+ // When the screen opens and there is NO managed block, the user may still have
988
+ // a hand-written `alias c='claude …'` line elsewhere in the rc file. Rather
989
+ // than ignore it (and look broken), we DETECT it and OFFER to adopt it: import
990
+ // its flags into the editor and, on the next write, replace that line in place
991
+ // with the managed block so ownership transfers cleanly with no duplicate.
992
+ //
993
+ // Safety is built on a single invariant: we only mark an alias `lossless` —
994
+ // and therefore safe to remove — when every token round-trips. Two checks:
995
+ // 1. Leftover-empty: every argv token mapped to a catalog flag.
996
+ // 2. Fixpoint: re-rendering the parsed flags and re-parsing yields the same
997
+ // flag map (catches peek-based mis-maps that consume a token into the
998
+ // wrong bucket without surfacing as a leftover).
999
+ // Both are order- and quote-insensitive by construction. A NON-lossless alias
1000
+ // is still offered (the user sees its recognized flags) but its original line
1001
+ // is never removed — we keep what we couldn't fully understand.
1002
+
1003
+ /**
1004
+ * A hand-written `claude`-wrapping alias found OUTSIDE the managed block.
1005
+ */
1006
+ export interface AdoptableAlias {
1007
+ /** The alias name (e.g. `c`). */
1008
+ name: string;
1009
+ /** Parsed flag values from the alias body. */
1010
+ flags: Record<string, FlagValue>;
1011
+ /**
1012
+ * Zero-based line indices [start, end] of the alias line in the rc file.
1013
+ * Single-line today (start === end), but kept as a range so the splice
1014
+ * helper has the exact span to replace.
1015
+ */
1016
+ lineRange: [number, number];
1017
+ /** argv tokens that didn't map to any catalog flag (data we'd drop). */
1018
+ leftovers: string[];
1019
+ /**
1020
+ * True when the alias round-trips exactly: leftovers empty AND the parsed
1021
+ * flags survive a render→reparse fixpoint. Only a lossless alias may have
1022
+ * its original line removed on adoption.
1023
+ */
1024
+ lossless: boolean;
1025
+ /** The raw alias line, verbatim (for display / preservation). */
1026
+ rawLine: string;
1027
+ /**
1028
+ * Other top-level `claude`-wrapping alias lines we found but did NOT pick.
1029
+ * Surfaced so the UI can note "also found N other claude aliases" rather
1030
+ * than silently choosing the first.
1031
+ */
1032
+ others: string[];
1033
+ }
1034
+
1035
+ const ANY_ALIAS_LINE_RE =
1036
+ /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(['"])(.*)\2\s*$/;
1037
+
1038
+ /**
1039
+ * Tokenize a general (hand-written) POSIX alias body into argv strings.
1040
+ *
1041
+ * Unlike {@link tokenizePosixAliasBody} — which only undoes OUR writer's escape
1042
+ * scheme — this handles the shapes a human would type: bare words, single-
1043
+ * quoted runs (literal, no escapes), and double-quoted runs (we treat the
1044
+ * content literally; we do NOT expand `$VAR` or `$(cmd)`). Adjacent quoted and
1045
+ * bare pieces with no whitespace between them concatenate into one argv token,
1046
+ * matching shell word-joining (`foo' bar'` → `foo bar`).
1047
+ *
1048
+ * Returns `null` for anything we can't cleanly tokenize: unterminated quotes,
1049
+ * or a body containing a shell substitution (`$(`, backtick) or variable (`$`)
1050
+ * — those can't be faithfully represented as a static flag value, so the
1051
+ * caller treats the whole alias as not-adoptable rather than guessing.
1052
+ */
1053
+ export function tokenizeGeneralAliasBody(body: string): string[] | null {
1054
+ const tokens: string[] = [];
1055
+ let current = "";
1056
+ let inToken = false;
1057
+ let i = 0;
1058
+ const len = body.length;
1059
+
1060
+ const flush = () => {
1061
+ if (inToken) {
1062
+ tokens.push(current);
1063
+ current = "";
1064
+ inToken = false;
1065
+ }
1066
+ };
1067
+
1068
+ while (i < len) {
1069
+ const ch = body[i];
1070
+
1071
+ if (ch === " " || ch === "\t") {
1072
+ flush();
1073
+ i += 1;
1074
+ continue;
1075
+ }
1076
+
1077
+ // Bail on dynamic content — we can't round-trip a variable/substitution.
1078
+ if (ch === "$" || ch === "`") return null;
1079
+
1080
+ if (ch === "'") {
1081
+ // Single-quoted run: literal until the next single quote.
1082
+ const end = body.indexOf("'", i + 1);
1083
+ if (end === -1) return null; // unterminated
1084
+ current += body.slice(i + 1, end);
1085
+ inToken = true;
1086
+ i = end + 1;
1087
+ continue;
1088
+ }
1089
+
1090
+ if (ch === '"') {
1091
+ // Double-quoted run: literal until the next double quote. We reject any
1092
+ // `$` or backtick inside (caught at the top of the loop on the next
1093
+ // pass only if unquoted — so scan the run explicitly here).
1094
+ const end = body.indexOf('"', i + 1);
1095
+ if (end === -1) return null; // unterminated
1096
+ const inner = body.slice(i + 1, end);
1097
+ if (inner.includes("$") || inner.includes("`")) return null;
1098
+ current += inner;
1099
+ inToken = true;
1100
+ i = end + 1;
1101
+ continue;
1102
+ }
1103
+
1104
+ // Bare character.
1105
+ current += ch;
1106
+ inToken = true;
1107
+ i += 1;
1108
+ }
1109
+ flush();
1110
+ return tokens;
1111
+ }
1112
+
1113
+ /**
1114
+ * Parse a single hand-written `alias <name>=<quote><body><quote>` line into an
1115
+ * AdoptableAlias (sans `lineRange`/`others`, which the scanner fills in), or
1116
+ * null when the line isn't a `claude`-wrapping alias we can tokenize.
1117
+ */
1118
+ function parseAdoptableLine(
1119
+ line: string,
1120
+ ): Omit<AdoptableAlias, "lineRange" | "others"> | null {
1121
+ const match = ANY_ALIAS_LINE_RE.exec(line.trim());
1122
+ if (!match) return null;
1123
+ const [, name, , body] = match;
1124
+ const tokens = tokenizeGeneralAliasBody(body);
1125
+ if (!tokens || tokens.length === 0 || tokens[0] !== "claude") return null;
1126
+
1127
+ const args = tokens.slice(1);
1128
+ const { flags, leftovers } = argsToFlagValuesWithLeftovers(args);
1129
+
1130
+ // Fixpoint: render the parsed flags back to tokens and re-parse. If the
1131
+ // result differs, a token was mis-bucketed (e.g. a select value mistaken
1132
+ // for bare). Only an exact fixpoint with no leftovers is lossless.
1133
+ const config: AliasConfig = { aliasName: name, flags: withDefaults(flags) };
1134
+ const reparsed = argsToFlagValues(renderArgsAsTokens(config));
1135
+ const fixpoint = flagsDeepEqual(withDefaults(flags), withDefaults(reparsed));
1136
+ const lossless = leftovers.length === 0 && fixpoint;
1137
+
1138
+ return { name, flags, leftovers, lossless, rawLine: line };
1139
+ }
1140
+
1141
+ /**
1142
+ * Scan rc text for a top-level, hand-written `claude`-wrapping alias OUTSIDE
1143
+ * the managed block. Returns the first such alias (with any others noted), or
1144
+ * null when none is found.
1145
+ *
1146
+ * "Top-level" = the line's indentation is zero. We deliberately skip indented
1147
+ * lines: an alias nested in a function or `if` block can't be replaced by the
1148
+ * managed block without dragging the block into that scope.
1149
+ *
1150
+ * Lines inside an existing managed block are ignored — adoption is only for
1151
+ * the no-managed-block case (the screen's mount checks that separately, but we
1152
+ * guard here too so the function is correct in isolation).
1153
+ */
1154
+ export function findAdoptableAlias(rcText: string): AdoptableAlias | null {
1155
+ const lines = rcText.split("\n");
1156
+
1157
+ // Compute the [begin, end] line span of the managed block, if present, so we
1158
+ // can exclude any alias inside it.
1159
+ let blockStart = -1;
1160
+ let blockEnd = -1;
1161
+ for (let i = 0; i < lines.length; i++) {
1162
+ if (lines[i].includes(BLOCK_BEGIN)) blockStart = i;
1163
+ else if (lines[i].includes(BLOCK_END)) {
1164
+ blockEnd = i;
1165
+ break;
1166
+ }
1167
+ }
1168
+ const insideBlock = (i: number) =>
1169
+ blockStart !== -1 && blockEnd !== -1 && i >= blockStart && i <= blockEnd;
1170
+
1171
+ let picked: AdoptableAlias | null = null;
1172
+ const others: string[] = [];
1173
+
1174
+ for (let i = 0; i < lines.length; i++) {
1175
+ const line = lines[i];
1176
+ if (insideBlock(i)) continue;
1177
+ // Top-level only: no leading whitespace.
1178
+ if (/^\s/.test(line)) continue;
1179
+ const parsed = parseAdoptableLine(line);
1180
+ if (!parsed) continue;
1181
+ if (picked === null) {
1182
+ picked = { ...parsed, lineRange: [i, i], others: [] };
1183
+ } else {
1184
+ others.push(line.trim());
1185
+ }
1186
+ }
1187
+
1188
+ if (picked) picked.others = others;
1189
+ return picked;
1190
+ }
1191
+
1192
+ /**
1193
+ * Replace a line range in `existing` (inclusive, zero-based) with `block`.
1194
+ * Used to absorb an adopted hand-written alias: the original line is removed
1195
+ * and the managed block takes its place, guaranteeing no duplicate alias.
1196
+ * Pure function — no I/O. Falls back to {@link spliceManagedBlock} semantics
1197
+ * (append) when the range is out of bounds.
1198
+ */
1199
+ export function spliceManagedBlockAtRange(
1200
+ existing: string,
1201
+ block: string,
1202
+ range: [number, number],
1203
+ ): string {
1204
+ const lines = existing.split("\n");
1205
+ const [start, end] = range;
1206
+ if (start < 0 || end >= lines.length || start > end) {
1207
+ return spliceManagedBlock(existing, block);
1208
+ }
1209
+ // `block` ends with a newline; splice it as its own line(s) where the old
1210
+ // alias line was. Rejoin and drop the duplicate trailing newline `block`
1211
+ // would introduce when followed by more lines.
1212
+ const before = lines.slice(0, start);
1213
+ const after = lines.slice(end + 1);
1214
+ const blockLines = block.replace(/\n$/, "").split("\n");
1215
+ return [...before, ...blockLines, ...after].join("\n");
1216
+ }
1217
+
1218
+ /** Fill any catalog flags missing from a partial map with their defaults. */
1219
+ function withDefaults(
1220
+ partial: Record<string, FlagValue>,
1221
+ ): Record<string, FlagValue> {
1222
+ const out: Record<string, FlagValue> = {};
1223
+ for (const flag of ALIAS_FLAGS) {
1224
+ out[flag.id] = partial[flag.id] ?? defaultValueFor(flag);
1225
+ }
1226
+ return out;
1227
+ }
1228
+
1229
+ /** Deep-equal two flag maps via per-key JSON compare (small maps). */
1230
+ function flagsDeepEqual(
1231
+ a: Record<string, FlagValue>,
1232
+ b: Record<string, FlagValue>,
1233
+ ): boolean {
1234
+ const aKeys = Object.keys(a).sort();
1235
+ const bKeys = Object.keys(b).sort();
1236
+ if (aKeys.length !== bKeys.length) return false;
1237
+ for (let i = 0; i < aKeys.length; i++) {
1238
+ if (aKeys[i] !== bKeys[i]) return false;
1239
+ }
1240
+ for (const k of aKeys) {
1241
+ if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
1242
+ }
1243
+ return true;
1244
+ }