claudeup 4.18.0 → 4.22.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 (191) hide show
  1. package/bin/claudeup.js +43 -19
  2. package/package.json +14 -19
  3. package/scripts/build-binaries.ts +77 -0
  4. package/scripts/check-optional-deps.ts +49 -0
  5. package/scripts/test-isolated.ts +43 -0
  6. package/src/__tests__/alias-adopt.test.ts +364 -0
  7. package/src/__tests__/alias-parser.test.ts +92 -0
  8. package/src/__tests__/alias-shell-writer.test.ts +7 -0
  9. package/src/__tests__/alias-store.test.ts +77 -0
  10. package/src/__tests__/cli-router.test.ts +65 -0
  11. package/src/__tests__/conventions-integration.test.ts +745 -0
  12. package/src/__tests__/conventions-manager.test.ts +1172 -0
  13. package/src/__tests__/doctor-bins.test.ts +35 -0
  14. package/src/__tests__/doctor.test.ts +450 -0
  15. package/src/__tests__/dual-write-prevention.test.ts +14 -4
  16. package/src/__tests__/file-locking.test.ts +208 -0
  17. package/src/__tests__/gitignore-prerun.test.ts +1 -0
  18. package/src/__tests__/install-command.test.ts +81 -0
  19. package/src/__tests__/install-plan.test.ts +98 -0
  20. package/src/__tests__/manifest.test.ts +136 -0
  21. package/src/__tests__/marketplace-refresh.test.ts +267 -0
  22. package/src/__tests__/plugin-requires.test.ts +133 -0
  23. package/src/__tests__/plugin-setup.test.ts +118 -0
  24. package/src/__tests__/profile-command.test.ts +116 -0
  25. package/src/__tests__/profile-materializer.test.ts +82 -0
  26. package/src/__tests__/profile-sync.test.ts +136 -0
  27. package/src/__tests__/registry-version-resolution.test.ts +74 -0
  28. package/src/__tests__/resolve-executable.test.ts +42 -0
  29. package/src/__tests__/resolver.test.ts +218 -0
  30. package/src/__tests__/symlink-manager.test.ts +99 -0
  31. package/src/__tests__/toolchain.test.ts +56 -0
  32. package/src/cli/claude.ts +21 -0
  33. package/src/cli/doctor.ts +132 -0
  34. package/src/cli/install.ts +360 -0
  35. package/src/cli/profile.ts +166 -0
  36. package/src/cli/router.ts +107 -0
  37. package/src/cli/update.ts +85 -0
  38. package/src/data/alias-flags.ts +11 -1
  39. package/src/data/cli-tools.ts +7 -7
  40. package/src/data/gitignore-defaults.ts +4 -0
  41. package/src/data/marketplaces.ts +12 -0
  42. package/src/data/predefined-profiles.ts +3 -4
  43. package/src/main.tsx +11 -154
  44. package/src/prerunner/index.ts +62 -27
  45. package/src/services/alias-shell-writer.ts +382 -8
  46. package/src/services/alias-store.ts +60 -0
  47. package/src/services/claude-cli.ts +19 -22
  48. package/src/services/claude-settings.ts +99 -26
  49. package/src/services/conventions-manager.ts +865 -0
  50. package/src/services/doctor-bins.ts +49 -0
  51. package/src/services/doctor.ts +509 -0
  52. package/src/services/install-plan.ts +107 -0
  53. package/src/services/manifest.ts +166 -0
  54. package/src/services/marketplace-refresh.ts +173 -0
  55. package/src/services/plugin-manager.ts +7 -1
  56. package/src/services/plugin-requires.ts +215 -0
  57. package/src/services/plugin-setup.ts +61 -4
  58. package/src/services/plugin-version-check.ts +9 -4
  59. package/src/services/profile-materializer.ts +61 -0
  60. package/src/services/profile-sync.ts +150 -0
  61. package/src/services/resolver.ts +293 -0
  62. package/src/services/symlink-manager.ts +146 -0
  63. package/src/services/toolchain.ts +97 -0
  64. package/src/services/version-check.ts +10 -11
  65. package/src/types/index.ts +138 -31
  66. package/src/ui/App.tsx +16 -11
  67. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  68. package/src/ui/components/modals/ConfirmModal.tsx +1 -1
  69. package/src/ui/screens/AliasScreen.tsx +491 -359
  70. package/src/ui/screens/CliToolsScreen.tsx +11 -3
  71. package/src/ui/screens/PluginsScreen.tsx +3 -1
  72. package/src/ui/screens/index.ts +0 -1
  73. package/src/ui/state/reducer.ts +6 -102
  74. package/src/ui/state/types.ts +8 -35
  75. package/src/utils/command-utils.ts +23 -0
  76. package/src/utils/file-locking.ts +144 -0
  77. package/src/data/alias-flags.js +0 -196
  78. package/src/data/cli-tools.js +0 -123
  79. package/src/data/gitignore-defaults.js +0 -24
  80. package/src/data/gitignore-reasons.js +0 -97
  81. package/src/data/gitignore-templates.js +0 -21
  82. package/src/data/marketplaces.js +0 -138
  83. package/src/data/mcp-servers.js +0 -509
  84. package/src/data/predefined-profiles.js +0 -248
  85. package/src/data/settings-catalog.js +0 -625
  86. package/src/data/skill-repos.js +0 -160
  87. package/src/data/statuslines.js +0 -159
  88. package/src/data/statuslines.ts +0 -188
  89. package/src/index.js +0 -4
  90. package/src/index.ts +0 -5
  91. package/src/main.js +0 -170
  92. package/src/prerunner/index.js +0 -252
  93. package/src/services/alias-settings.js +0 -51
  94. package/src/services/alias-shell-writer.js +0 -764
  95. package/src/services/alias-store.js +0 -77
  96. package/src/services/claude-cli.js +0 -189
  97. package/src/services/claude-runner.js +0 -28
  98. package/src/services/claude-settings.js +0 -1223
  99. package/src/services/gitignore-detector.js +0 -155
  100. package/src/services/gitignore-fixer.js +0 -231
  101. package/src/services/gitignore-prerun.js +0 -46
  102. package/src/services/gitignore-resolver.js +0 -143
  103. package/src/services/gitignore-service.js +0 -117
  104. package/src/services/local-marketplace.js +0 -339
  105. package/src/services/marketplace-fetcher.js +0 -96
  106. package/src/services/marketplace-sync.js +0 -94
  107. package/src/services/mcp-registry.js +0 -87
  108. package/src/services/plugin-manager.js +0 -473
  109. package/src/services/plugin-mcp-config.js +0 -177
  110. package/src/services/plugin-setup.js +0 -444
  111. package/src/services/plugin-version-check.js +0 -262
  112. package/src/services/profiles.js +0 -161
  113. package/src/services/settings-manager.js +0 -243
  114. package/src/services/skills-manager.js +0 -393
  115. package/src/services/skillsmp-client.js +0 -87
  116. package/src/services/update-cache.js +0 -52
  117. package/src/services/version-check.js +0 -99
  118. package/src/types/gitignore.js +0 -6
  119. package/src/types/index.js +0 -1
  120. package/src/ui/App.js +0 -346
  121. package/src/ui/adapters/pluginsAdapter.js +0 -139
  122. package/src/ui/adapters/settingsAdapter.js +0 -111
  123. package/src/ui/adapters/skillsAdapter.js +0 -154
  124. package/src/ui/components/CategoryHeader.js +0 -9
  125. package/src/ui/components/EmptyFilterState.js +0 -4
  126. package/src/ui/components/ScopeIndicator.js +0 -30
  127. package/src/ui/components/ScrollableList.js +0 -36
  128. package/src/ui/components/SearchInput.js +0 -19
  129. package/src/ui/components/StyledText.js +0 -39
  130. package/src/ui/components/TabBar.js +0 -19
  131. package/src/ui/components/layout/FooterHints.js +0 -29
  132. package/src/ui/components/layout/Panel.js +0 -6
  133. package/src/ui/components/layout/ProgressBar.js +0 -14
  134. package/src/ui/components/layout/ScopeTabs.js +0 -6
  135. package/src/ui/components/layout/ScreenLayout.js +0 -16
  136. package/src/ui/components/layout/index.js +0 -5
  137. package/src/ui/components/modals/ConfirmModal.js +0 -14
  138. package/src/ui/components/modals/InputModal.js +0 -5
  139. package/src/ui/components/modals/LoadingModal.js +0 -17
  140. package/src/ui/components/modals/MessageModal.js +0 -16
  141. package/src/ui/components/modals/ModalContainer.js +0 -137
  142. package/src/ui/components/modals/SelectModal.js +0 -18
  143. package/src/ui/components/modals/VersionMismatchModal.js +0 -36
  144. package/src/ui/components/modals/index.js +0 -6
  145. package/src/ui/components/primitives/ActionHints.js +0 -13
  146. package/src/ui/components/primitives/DetailSection.js +0 -7
  147. package/src/ui/components/primitives/KeyValueLine.js +0 -8
  148. package/src/ui/components/primitives/ListCategoryRow.js +0 -8
  149. package/src/ui/components/primitives/MetaText.js +0 -8
  150. package/src/ui/components/primitives/ScopeDetail.js +0 -32
  151. package/src/ui/components/primitives/ScopeSquares.js +0 -11
  152. package/src/ui/components/primitives/SelectableRow.js +0 -5
  153. package/src/ui/components/primitives/index.js +0 -8
  154. package/src/ui/hooks/index.js +0 -4
  155. package/src/ui/hooks/useAsyncData.js +0 -77
  156. package/src/ui/hooks/useGitignoreModal.js +0 -74
  157. package/src/ui/hooks/useKeyboard.js +0 -13
  158. package/src/ui/hooks/useKeyboardHandler.js +0 -39
  159. package/src/ui/hooks/useMismatchModal.js +0 -77
  160. package/src/ui/registry.js +0 -1
  161. package/src/ui/renderers/cliToolRenderers.js +0 -54
  162. package/src/ui/renderers/gitignoreRenderers.js +0 -46
  163. package/src/ui/renderers/mcpRenderers.js +0 -26
  164. package/src/ui/renderers/pluginRenderers.js +0 -124
  165. package/src/ui/renderers/profileRenderers.js +0 -177
  166. package/src/ui/renderers/settingsRenderers.js +0 -73
  167. package/src/ui/renderers/skillRenderers.js +0 -138
  168. package/src/ui/screens/AliasScreen.js +0 -1008
  169. package/src/ui/screens/CliToolsScreen.js +0 -338
  170. package/src/ui/screens/EnvVarsScreen.js +0 -152
  171. package/src/ui/screens/GitignoreScreen.js +0 -328
  172. package/src/ui/screens/McpRegistryScreen.js +0 -238
  173. package/src/ui/screens/McpScreen.js +0 -176
  174. package/src/ui/screens/ModelSelectorScreen.js +0 -296
  175. package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
  176. package/src/ui/screens/PluginsScreen.js +0 -766
  177. package/src/ui/screens/ProfilesScreen.js +0 -298
  178. package/src/ui/screens/SkillsScreen.js +0 -549
  179. package/src/ui/screens/StatusLineScreen.js +0 -206
  180. package/src/ui/screens/StatusLineScreen.tsx +0 -416
  181. package/src/ui/screens/index.js +0 -10
  182. package/src/ui/state/AnimationContext.js +0 -34
  183. package/src/ui/state/AppContext.js +0 -162
  184. package/src/ui/state/DimensionsContext.js +0 -71
  185. package/src/ui/state/reducer.js +0 -543
  186. package/src/ui/state/types.js +0 -1
  187. package/src/ui/theme.js +0 -47
  188. package/src/utils/clipboard.js +0 -56
  189. package/src/utils/command-utils.js +0 -19
  190. package/src/utils/fuzzy-search.js +0 -101
  191. package/src/utils/string-utils.js +0 -62
package/bin/claudeup.js CHANGED
@@ -1,24 +1,48 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
+ /**
3
+ * claudeup launcher.
4
+ *
5
+ * Prefers a prebuilt, self-contained binary shipped as a per-platform
6
+ * optionalDependency (claudeup-<platform>-<arch>) — that binary embeds the Bun
7
+ * runtime, so it runs without Bun installed. Falls back to running from source
8
+ * via Bun for dev checkouts and unsupported platforms.
9
+ */
2
10
 
3
- // Check Bun version
4
- const bunVersion = process.versions.bun;
5
- if (!bunVersion) {
6
- console.error('Error: claudeup v3.0.0+ requires Bun runtime.');
7
- console.error('Please install Bun: https://bun.sh');
8
- console.error('');
9
- console.error('Quick install:');
10
- console.error(' curl -fsSL https://bun.sh/install | bash');
11
- process.exit(1);
11
+ import { spawnSync } from "node:child_process";
12
+ import { createRequire } from "node:module";
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname, join } from "node:path";
15
+ import { existsSync } from "node:fs";
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const args = process.argv.slice(2);
19
+ const { platform, arch } = process;
20
+
21
+ // 1. Prefer the prebuilt platform binary.
22
+ const pkgName = `claudeup-${platform}-${arch}`;
23
+ let binaryPath = null;
24
+ try {
25
+ const pkgJson = require.resolve(`${pkgName}/package.json`);
26
+ const candidate = join(dirname(pkgJson), "bin", "claudeup");
27
+ if (existsSync(candidate)) binaryPath = candidate;
28
+ } catch {
29
+ // optional dep not installed for this platform — fall through
12
30
  }
13
31
 
14
- const [major] = bunVersion.split('.').map(Number);
15
- if (major < 1) {
16
- console.error(`Error: claudeup requires Bun >=1.0.0 (current: ${bunVersion})`);
17
- console.error('Please upgrade Bun: bun upgrade');
18
- process.exit(1);
32
+ if (binaryPath) {
33
+ const result = spawnSync(binaryPath, args, { stdio: "inherit" });
34
+ process.exit(result.status ?? 0);
19
35
  }
20
36
 
21
- import('../src/main.tsx').catch((err) => {
22
- console.error('Failed to start claudeup:', err.message);
23
- process.exit(1);
24
- });
37
+ // 2. Fallback: run from source via Bun.
38
+ const here = dirname(fileURLToPath(import.meta.url));
39
+ const mainSrc = join(here, "..", "src", "main.tsx");
40
+ const bunRun = spawnSync("bun", [mainSrc, ...args], { stdio: "inherit" });
41
+ if (bunRun.error) {
42
+ console.error(
43
+ `claudeup: no prebuilt binary for ${platform}-${arch}, and Bun is not installed.`,
44
+ );
45
+ console.error("Install Bun (https://bun.sh) or use a supported platform.");
46
+ process.exit(1);
47
+ }
48
+ process.exit(bunRun.status ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.18.0",
3
+ "version": "4.22.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -10,15 +10,14 @@
10
10
  "scripts": {
11
11
  "start": "bun run src/main.tsx",
12
12
  "dev": "bun --watch src/main.tsx",
13
- "lint": "npm run eslint",
13
+ "typecheck": "tsc --noEmit",
14
+ "lint": "biome check src/",
14
15
  "format": "biome format --write src/",
15
- "eslint": "eslint \"**/*.{js,cjs,ts,mjs,jsx,tsx}\"",
16
- "lintfix": "npm run eslint -- --fix",
17
- "postlint": "template-oss-check",
18
- "template-oss-apply": "template-oss-apply --force",
19
- "snap": "tap",
20
- "test": "tap",
21
- "posttest": "npm run lint"
16
+ "test": "bun test",
17
+ "test:ci": "bun scripts/test-isolated.ts",
18
+ "test:update": "bun test --update-snapshots",
19
+ "build:binaries": "bun scripts/build-binaries.ts",
20
+ "check:optional-deps": "bun scripts/check-optional-deps.ts"
22
21
  },
23
22
  "keywords": [
24
23
  "claude",
@@ -45,7 +44,8 @@
45
44
  },
46
45
  "files": [
47
46
  "bin/",
48
- "src/"
47
+ "src/",
48
+ "scripts/"
49
49
  ],
50
50
  "dependencies": {
51
51
  "@opentui/core": "^0.1.75",
@@ -63,14 +63,9 @@
63
63
  "@types/semver": "^7.5.8",
64
64
  "typescript": "^5.6.3"
65
65
  },
66
- "templateOSS": {
67
- "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.",
68
- "version": "4.25.1"
69
- },
70
- "tap": {
71
- "nyc-arg": [
72
- "--exclude",
73
- "tap-snapshots/**"
74
- ]
66
+ "optionalDependencies": {
67
+ "claudeup-darwin-arm64": "4.22.0",
68
+ "claudeup-darwin-x64": "4.22.0",
69
+ "claudeup-linux-x64": "4.22.0"
75
70
  }
76
71
  }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Build per-platform, self-contained claudeup binaries and their npm packages.
4
+ *
5
+ * Each target is compiled with `bun build --compile --target=bun-<os>-<arch>`,
6
+ * producing a binary that embeds the Bun runtime (runs without Bun installed).
7
+ * The output is a publishable package per platform under dist-binaries/:
8
+ *
9
+ * dist-binaries/claudeup-darwin-arm64/
10
+ * package.json (os/cpu restricted, no `bin` — the launcher execs it)
11
+ * bin/claudeup (the compiled binary)
12
+ *
13
+ * The main `claudeup` package lists these as optionalDependencies; npm installs
14
+ * only the one matching the host os/cpu, and bin/claudeup.js execs it.
15
+ *
16
+ * Hard constraint: claudeup pins @opentui 0.1.x — 0.4.x breaks --compile.
17
+ */
18
+
19
+ import { $ } from "bun";
20
+ import { mkdir, writeFile, rm } from "node:fs/promises";
21
+ import path from "node:path";
22
+ import pkg from "../package.json";
23
+
24
+ interface Target {
25
+ os: "darwin" | "linux";
26
+ cpu: "arm64" | "x64";
27
+ bunTarget: string;
28
+ }
29
+
30
+ const TARGETS: Target[] = [
31
+ { os: "darwin", cpu: "arm64", bunTarget: "bun-darwin-arm64" },
32
+ { os: "darwin", cpu: "x64", bunTarget: "bun-darwin-x64" },
33
+ { os: "linux", cpu: "x64", bunTarget: "bun-linux-x64" },
34
+ ];
35
+
36
+ const version = (pkg as { version: string }).version;
37
+ const root = path.join(import.meta.dir, "..");
38
+ const distRoot = path.join(root, "dist-binaries");
39
+ const entry = path.join(root, "src", "main.tsx");
40
+
41
+ // Optional filter: `bun scripts/build-binaries.ts darwin-arm64`
42
+ const only = process.argv[2];
43
+
44
+ await rm(distRoot, { recursive: true, force: true });
45
+
46
+ for (const t of TARGETS) {
47
+ const id = `${t.os}-${t.cpu}`;
48
+ if (only && only !== id) continue;
49
+
50
+ const pkgName = `claudeup-${id}`;
51
+ const outDir = path.join(distRoot, pkgName);
52
+ const binPath = path.join(outDir, "bin", "claudeup");
53
+ await mkdir(path.join(outDir, "bin"), { recursive: true });
54
+
55
+ console.log(`Building ${pkgName} (${t.bunTarget})…`);
56
+ await $`bun build --compile --target=${t.bunTarget} ${entry} --outfile ${binPath}`;
57
+
58
+ // Platform package: os/cpu-restricted, ships only the binary, declares NO
59
+ // `bin` (the main package's launcher resolves and execs bin/claudeup).
60
+ const platformPkg = {
61
+ name: pkgName,
62
+ version,
63
+ description: `Prebuilt claudeup binary for ${id}`,
64
+ os: [t.os],
65
+ cpu: [t.cpu],
66
+ files: ["bin/"],
67
+ license: (pkg as { license: string }).license,
68
+ repository: (pkg as { repository: unknown }).repository,
69
+ };
70
+ await writeFile(
71
+ path.join(outDir, "package.json"),
72
+ `${JSON.stringify(platformPkg, null, 2)}\n`,
73
+ );
74
+ }
75
+
76
+ console.log(`\nDone. Packages in ${path.relative(process.cwd(), distRoot)}/`);
77
+ console.log("Publish each with `npm publish` (then the main package).");
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * check-optional-deps.ts — assert the per-platform optionalDependencies are pinned
4
+ * to the main package's own version.
5
+ *
6
+ * build-binaries.ts stamps each generated per-platform package with
7
+ * `package.json`'s version, and the release workflow publishes those first so the
8
+ * main package's optionalDependencies already resolve. If the two drift, the main
9
+ * package ships pointing at versions that were never published.
10
+ *
11
+ * That failure is quiet: optionalDependencies are allowed to fail to resolve, so
12
+ * npm/bun install succeeds and the launcher silently falls back to running from
13
+ * source via Bun — meaning every user loses the prebuilt binary and anyone without
14
+ * Bun on PATH gets a broken `claudeup`. Nothing errors, so it can ship unnoticed.
15
+ *
16
+ * Bumping the package version without bumping these is easy to do by hand, which
17
+ * is exactly what happened going 4.20.0 -> 4.21.0.
18
+ */
19
+
20
+ import pkg from "../package.json";
21
+
22
+ const version = (pkg as { version: string }).version;
23
+ const optional = (pkg as { optionalDependencies?: Record<string, string> })
24
+ .optionalDependencies;
25
+
26
+ if (!optional || Object.keys(optional).length === 0) {
27
+ console.log("check:optional-deps — no optionalDependencies declared, nothing to verify");
28
+ process.exit(0);
29
+ }
30
+
31
+ const mismatched = Object.entries(optional).filter(([, v]) => v !== version);
32
+
33
+ if (mismatched.length > 0) {
34
+ console.error(
35
+ `check:optional-deps FAILED — package version is ${version}, but:`,
36
+ );
37
+ for (const [name, v] of mismatched) {
38
+ console.error(` ${name}: ${v} (expected ${version})`);
39
+ }
40
+ console.error(
41
+ "\nThese are published from this same version by build-binaries.ts.\n" +
42
+ "Update optionalDependencies in package.json to match, then re-run.",
43
+ );
44
+ process.exit(1);
45
+ }
46
+
47
+ console.log(
48
+ `check:optional-deps — all ${Object.keys(optional).length} platform packages pinned to ${version}`,
49
+ );
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Run each test file in its own `bun test` process.
4
+ *
5
+ * Some tests use bun's `mock.module`, which is process-global and NOT restored
6
+ * across files (bun 1.3.10). In a single shared process the outcome depends on
7
+ * file order — fine locally, flaky on CI (different order / workers). Per-file
8
+ * isolation removes the cross-file coupling entirely: every file gets a fresh
9
+ * module registry. Slower than `bun test`, but deterministic — which is what CI
10
+ * needs. For fast local iteration use `bun test`.
11
+ */
12
+
13
+ import { readdirSync, statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { spawnSync } from "node:child_process";
16
+
17
+ function findTests(dir: string): string[] {
18
+ const out: string[] = [];
19
+ for (const entry of readdirSync(dir)) {
20
+ const p = join(dir, entry);
21
+ if (statSync(p).isDirectory()) out.push(...findTests(p));
22
+ else if (p.endsWith(".test.ts")) out.push(p);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ const root = join(import.meta.dir, "..");
28
+ const files = findTests(join(root, "src")).sort();
29
+
30
+ let failed = 0;
31
+ for (const file of files) {
32
+ const rel = file.slice(root.length + 1);
33
+ process.stdout.write(`\n── ${rel}\n`);
34
+ const result = spawnSync("bun", ["test", file], {
35
+ stdio: "inherit",
36
+ cwd: root,
37
+ });
38
+ if (result.status !== 0) failed++;
39
+ }
40
+
41
+ const passed = files.length - failed;
42
+ console.log(`\n${passed}/${files.length} test files passed (isolated).`);
43
+ process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,364 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ tokenizeGeneralAliasBody,
7
+ findAdoptableAlias,
8
+ spliceManagedBlockAtRange,
9
+ renderAlias,
10
+ writeAliasToShell,
11
+ argsToFlagValuesWithLeftovers,
12
+ type AdoptableAlias,
13
+ type ShellTarget,
14
+ } from "../services/alias-shell-writer";
15
+ import { defaultAliasConfig } from "../services/alias-store";
16
+
17
+ // ─── tokenizeGeneralAliasBody ──────────────────────────────────────────────
18
+
19
+ describe("tokenizeGeneralAliasBody", () => {
20
+ it("splits bare words on whitespace", () => {
21
+ expect(tokenizeGeneralAliasBody("claude --ide --effort high")).toEqual([
22
+ "claude",
23
+ "--ide",
24
+ "--effort",
25
+ "high",
26
+ ]);
27
+ });
28
+
29
+ it("treats single-quoted runs as literal (spaces preserved)", () => {
30
+ expect(
31
+ tokenizeGeneralAliasBody("claude --append-system-prompt 'be terse'"),
32
+ ).toEqual(["claude", "--append-system-prompt", "be terse"]);
33
+ });
34
+
35
+ it("treats double-quoted runs as literal", () => {
36
+ expect(
37
+ tokenizeGeneralAliasBody('claude --append-system-prompt "be terse"'),
38
+ ).toEqual(["claude", "--append-system-prompt", "be terse"]);
39
+ });
40
+
41
+ it("joins adjacent quoted + bare pieces into one token", () => {
42
+ // `foo' bar'baz` → one shell word `foo barbaz`
43
+ expect(tokenizeGeneralAliasBody("claude foo' bar'baz")).toEqual([
44
+ "claude",
45
+ "foo barbaz",
46
+ ]);
47
+ });
48
+
49
+ it("bails (null) on unterminated quotes", () => {
50
+ expect(tokenizeGeneralAliasBody("claude 'unterminated")).toBeNull();
51
+ expect(tokenizeGeneralAliasBody('claude "unterminated')).toBeNull();
52
+ });
53
+
54
+ it("bails (null) on shell substitutions and variables", () => {
55
+ expect(tokenizeGeneralAliasBody("claude --foo $(date)")).toBeNull();
56
+ expect(tokenizeGeneralAliasBody("claude --foo `date`")).toBeNull();
57
+ expect(tokenizeGeneralAliasBody("claude --foo $HOME")).toBeNull();
58
+ // Even inside double quotes — we don't expand, so we can't round-trip it.
59
+ expect(tokenizeGeneralAliasBody('claude --foo "$HOME/x"')).toBeNull();
60
+ });
61
+ });
62
+
63
+ // ─── argsToFlagValuesWithLeftovers ─────────────────────────────────────────
64
+
65
+ describe("argsToFlagValuesWithLeftovers", () => {
66
+ it("reports no leftovers when every token is recognized", () => {
67
+ const { leftovers } = argsToFlagValuesWithLeftovers([
68
+ "--dangerously-skip-permissions",
69
+ "--effort",
70
+ "high",
71
+ ]);
72
+ expect(leftovers).toEqual([]);
73
+ });
74
+
75
+ it("records an unknown bare flag as a leftover", () => {
76
+ const { leftovers } = argsToFlagValuesWithLeftovers(["--ide", "--unknown"]);
77
+ expect(leftovers).toEqual(["--unknown"]);
78
+ });
79
+
80
+ it("records an unknown flag AND its consumed value", () => {
81
+ const { leftovers } = argsToFlagValuesWithLeftovers([
82
+ "--model",
83
+ "opus",
84
+ "--ide",
85
+ ]);
86
+ expect(leftovers).toEqual(["--model", "opus"]);
87
+ });
88
+
89
+ it("recognizes order-independently (no false leftovers on reorder)", () => {
90
+ // catalog order is ide(154) before effort(173); user wrote them reversed
91
+ const { flags, leftovers } = argsToFlagValuesWithLeftovers([
92
+ "--effort",
93
+ "high",
94
+ "--ide",
95
+ ]);
96
+ expect(leftovers).toEqual([]);
97
+ expect(flags["ide"]).toEqual({ kind: "boolean", enabled: true });
98
+ expect(flags["effort"]).toEqual({
99
+ kind: "select",
100
+ enabled: true,
101
+ value: "high",
102
+ });
103
+ });
104
+ });
105
+
106
+ // ─── findAdoptableAlias ────────────────────────────────────────────────────
107
+
108
+ describe("findAdoptableAlias", () => {
109
+ it("returns null when there's no claude-wrapping alias", () => {
110
+ const rc = "export PATH=$PATH:/usr/local/bin\nalias ll='ls -la'\n";
111
+ expect(findAdoptableAlias(rc)).toBeNull();
112
+ });
113
+
114
+ it("detects a single-quoted hand-written claude alias and parses its flags", () => {
115
+ const rc =
116
+ "# rc\nalias c='claude --dangerously-skip-permissions'\nexport X=1\n";
117
+ const a = findAdoptableAlias(rc);
118
+ expect(a).not.toBeNull();
119
+ expect(a!.name).toBe("c");
120
+ expect(a!.flags["dangerously-skip-permissions"]).toEqual({
121
+ kind: "boolean",
122
+ enabled: true,
123
+ });
124
+ expect(a!.lineRange).toEqual([1, 1]);
125
+ expect(a!.leftovers).toEqual([]);
126
+ expect(a!.lossless).toBe(true);
127
+ });
128
+
129
+ it("detects a double-quoted hand-written claude alias", () => {
130
+ const rc = 'alias cc="claude --ide"\n';
131
+ const a = findAdoptableAlias(rc);
132
+ expect(a).not.toBeNull();
133
+ expect(a!.name).toBe("cc");
134
+ expect(a!.flags["ide"]).toEqual({ kind: "boolean", enabled: true });
135
+ expect(a!.lossless).toBe(true);
136
+ });
137
+
138
+ it("marks an alias with an uncatalogued flag as NOT lossless and lists the leftover", () => {
139
+ const rc = "alias c='claude --ide --model opus'\n";
140
+ const a = findAdoptableAlias(rc);
141
+ expect(a).not.toBeNull();
142
+ // The flags we DID recognize are imported for the user to see.
143
+ expect(a!.flags["ide"]).toEqual({ kind: "boolean", enabled: true });
144
+ // But the alias is not safe to absorb — its original line must be kept.
145
+ expect(a!.lossless).toBe(false);
146
+ expect(a!.leftovers).toEqual(["--model", "opus"]);
147
+ });
148
+
149
+ it("is lossless regardless of flag ordering or quote style (round-trip-safe)", () => {
150
+ const rc = "alias c='claude --effort high --ide'\n";
151
+ const a = findAdoptableAlias(rc);
152
+ expect(a!.lossless).toBe(true);
153
+ expect(a!.leftovers).toEqual([]);
154
+ });
155
+
156
+ it("skips indented (non-top-level) alias lines", () => {
157
+ const rc = "my_func() {\n alias c='claude --ide'\n}\n";
158
+ expect(findAdoptableAlias(rc)).toBeNull();
159
+ });
160
+
161
+ it("ignores an alias inside the managed block", () => {
162
+ const rc =
163
+ "# >>> claudeup managed alias >>>\nalias c='claude --ide'\n# <<< claudeup managed alias <<<\n";
164
+ expect(findAdoptableAlias(rc)).toBeNull();
165
+ });
166
+
167
+ it("picks the first top-level claude alias and notes the others", () => {
168
+ const rc =
169
+ "alias c='claude --ide'\nalias cc='claude --worktree'\n";
170
+ const a = findAdoptableAlias(rc);
171
+ expect(a!.name).toBe("c");
172
+ expect(a!.others).toEqual(["alias cc='claude --worktree'"]);
173
+ });
174
+
175
+ it("bails on a claude alias whose body contains a substitution", () => {
176
+ // Can't round-trip a $(...) — not adoptable at all.
177
+ const rc = "alias c='claude --append-system-prompt $(cat ~/p.txt)'\n";
178
+ expect(findAdoptableAlias(rc)).toBeNull();
179
+ });
180
+ });
181
+
182
+ // ─── spliceManagedBlockAtRange ─────────────────────────────────────────────
183
+
184
+ describe("spliceManagedBlockAtRange", () => {
185
+ const block =
186
+ "# >>> claudeup managed alias >>>\nalias c='claude --ide'\n# <<< claudeup managed alias <<<\n";
187
+
188
+ it("replaces the adopted line in place (no duplicate alias)", () => {
189
+ const rc = "# header\nalias c='claude --dangerously-skip-permissions'\nexport X=1\n";
190
+ const result = spliceManagedBlockAtRange(rc, block, [1, 1]);
191
+ // The old hand-written line is gone; the block sits where it was.
192
+ expect(result).not.toContain(
193
+ "alias c='claude --dangerously-skip-permissions'",
194
+ );
195
+ expect(result).toContain("# >>> claudeup managed alias >>>");
196
+ expect(result).toContain("# header");
197
+ expect(result).toContain("export X=1");
198
+ // Exactly one `alias c=` line.
199
+ const count = (result.match(/^alias c=/gm) ?? []).length;
200
+ expect(count).toBe(1);
201
+ });
202
+
203
+ it("falls back to append when the range is out of bounds", () => {
204
+ const rc = "alias g='git'\n";
205
+ const result = spliceManagedBlockAtRange(rc, block, [99, 99]);
206
+ expect(result).toContain("alias g='git'");
207
+ expect(result).toContain("# >>> claudeup managed alias >>>");
208
+ });
209
+ });
210
+
211
+ // ─── End-to-end: adopt → write yields a single, clean managed block ─────────
212
+
213
+ describe("adoption end-to-end", () => {
214
+ it("lossless adoption: parse → render → splice-at-range = one alias, original gone", () => {
215
+ const rc =
216
+ "# my zshrc\nexport EDITOR=vim\nalias c='claude --dangerously-skip-permissions'\n# eof\n";
217
+ const a = findAdoptableAlias(rc) as AdoptableAlias;
218
+ expect(a.lossless).toBe(true);
219
+
220
+ // Build a config from the adopted name + flags and render the block.
221
+ const config = defaultAliasConfig();
222
+ config.aliasName = a.name;
223
+ config.flags = { ...config.flags, ...a.flags };
224
+ const rendered = renderAlias(config, "zsh");
225
+
226
+ const next = spliceManagedBlockAtRange(rc, rendered.block, a.lineRange);
227
+
228
+ // Original hand-written line removed.
229
+ expect(next).not.toContain(
230
+ "alias c='claude --dangerously-skip-permissions'\n# eof",
231
+ );
232
+ // Managed block present with the same flag.
233
+ expect(next).toContain("# >>> claudeup managed alias >>>");
234
+ expect(next).toContain("--dangerously-skip-permissions");
235
+ // Exactly one `alias c=` and surrounding content intact.
236
+ expect((next.match(/^alias c=/gm) ?? []).length).toBe(1);
237
+ expect(next).toContain("export EDITOR=vim");
238
+ expect(next).toContain("# eof");
239
+ });
240
+ });
241
+
242
+ // ─── writeAliasToShell honors adoptLineRange (file I/O) ─────────────────────
243
+
244
+ describe("writeAliasToShell — adoption path", () => {
245
+ let home: string;
246
+
247
+ beforeEach(async () => {
248
+ home = await mkdtemp(join(tmpdir(), "alias-adopt-write-"));
249
+ });
250
+
251
+ afterEach(async () => {
252
+ await rm(home, { recursive: true, force: true });
253
+ });
254
+
255
+ it("replaces the adopted line in place — never leaves a duplicate alias", async () => {
256
+ const path = join(home, ".zshrc");
257
+ // A real-world rc: hand-written `alias c=` plus surrounding content.
258
+ await writeFile(
259
+ path,
260
+ "export EDITOR=vim\nalias c='claude --dangerously-skip-permissions'\nalias g='git'\n",
261
+ "utf8",
262
+ );
263
+ const rc = await readFile(path, "utf8");
264
+ const a = findAdoptableAlias(rc) as AdoptableAlias;
265
+ expect(a.lossless).toBe(true);
266
+
267
+ const config = defaultAliasConfig();
268
+ config.aliasName = a.name;
269
+ config.flags = { ...config.flags, ...a.flags };
270
+
271
+ const target: ShellTarget = {
272
+ kind: "zsh",
273
+ path,
274
+ exists: true,
275
+ isDefault: true,
276
+ };
277
+ await writeAliasToShell(config, target, { adoptLineRange: a.lineRange });
278
+
279
+ const written = await readFile(path, "utf8");
280
+ // Original hand-written line removed.
281
+ expect(written).not.toContain(
282
+ "alias c='claude --dangerously-skip-permissions'\nalias g=",
283
+ );
284
+ // Managed block now owns the alias.
285
+ expect(written).toContain("# >>> claudeup managed alias >>>");
286
+ expect(written).toContain("--dangerously-skip-permissions");
287
+ // Exactly ONE `alias c=` definition (the whole point).
288
+ expect((written.match(/^alias c=/gm) ?? []).length).toBe(1);
289
+ // Surrounding lines preserved.
290
+ expect(written).toContain("export EDITOR=vim");
291
+ expect(written).toContain("alias g='git'");
292
+ });
293
+
294
+ it("not-lossless: managed block under a DISTINCT name never shadows the surviving original", async () => {
295
+ // The not-lossless UI path keeps the original line and writes the managed
296
+ // block under a name distinct from the original (no adoptLineRange). This
297
+ // test pins the no-shadow outcome at the service layer: two differently-
298
+ // named aliases coexist, and the original keeps ALL its tokens.
299
+ const path = join(home, ".zshrc");
300
+ await writeFile(
301
+ path,
302
+ "export EDITOR=vim\nalias c='claude --dangerously-skip-permissions --model opus'\nalias g='git'\n",
303
+ "utf8",
304
+ );
305
+ const a = findAdoptableAlias(await readFile(path, "utf8")) as AdoptableAlias;
306
+ expect(a.lossless).toBe(false);
307
+ expect(a.leftovers).toEqual(["--model", "opus"]);
308
+
309
+ // Managed block uses a DIFFERENT name (mirrors pickDistinctName → "cm").
310
+ const config = defaultAliasConfig();
311
+ config.aliasName = "cm";
312
+ config.flags = { ...config.flags, ...a.flags };
313
+ const target: ShellTarget = {
314
+ kind: "zsh",
315
+ path,
316
+ exists: true,
317
+ isDefault: true,
318
+ };
319
+ // No adoptLineRange — the original line must be left untouched.
320
+ await writeAliasToShell(config, target, {});
321
+
322
+ const written = await readFile(path, "utf8");
323
+ // The original `alias c=` survives, complete with --model opus.
324
+ expect(written).toContain(
325
+ "alias c='claude --dangerously-skip-permissions --model opus'",
326
+ );
327
+ // The managed block uses the distinct name.
328
+ expect(written).toContain("alias cm='claude --dangerously-skip-permissions'");
329
+ // Exactly one of each name — no duplicate, no same-name shadow.
330
+ expect((written.match(/^alias c=/gm) ?? []).length).toBe(1);
331
+ expect((written.match(/^alias cm=/gm) ?? []).length).toBe(1);
332
+ });
333
+
334
+ it("a SECOND write (range still passed) doesn't double the block — markers win", async () => {
335
+ const path = join(home, ".zshrc");
336
+ await writeFile(
337
+ path,
338
+ "alias c='claude --ide'\n",
339
+ "utf8",
340
+ );
341
+ const a = findAdoptableAlias(await readFile(path, "utf8")) as AdoptableAlias;
342
+ const config = defaultAliasConfig();
343
+ config.aliasName = a.name;
344
+ config.flags = { ...config.flags, ...a.flags };
345
+ const target: ShellTarget = {
346
+ kind: "zsh",
347
+ path,
348
+ exists: true,
349
+ isDefault: true,
350
+ };
351
+ // First write absorbs the line.
352
+ await writeAliasToShell(config, target, { adoptLineRange: a.lineRange });
353
+ // Second write with the SAME stale range — a managed block now exists, so
354
+ // the writer must ignore the range and splice by markers (idempotent).
355
+ await writeAliasToShell(config, target, { adoptLineRange: a.lineRange });
356
+
357
+ const written = await readFile(path, "utf8");
358
+ const beginCount = (
359
+ written.match(/# >>> claudeup managed alias >>>/g) ?? []
360
+ ).length;
361
+ expect(beginCount).toBe(1);
362
+ expect((written.match(/^alias c=/gm) ?? []).length).toBe(1);
363
+ });
364
+ });