claudeup 6.2.1 → 6.3.2

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 (150) hide show
  1. package/README.md +22 -0
  2. package/bin/claudeup.js +6 -1
  3. package/package.json +6 -6
  4. package/src/__tests__/alias-adopt.test.ts +331 -329
  5. package/src/__tests__/alias-parser.test.ts +384 -376
  6. package/src/__tests__/alias-shell-writer.test.ts +638 -625
  7. package/src/__tests__/alias-store.test.ts +143 -143
  8. package/src/__tests__/catalog-cache-store.test.ts +265 -265
  9. package/src/__tests__/catalog-notice.test.ts +123 -123
  10. package/src/__tests__/cli-apply-seams.test.ts +12 -8
  11. package/src/__tests__/cli-router.test.ts +132 -3
  12. package/src/__tests__/cli-update-view.test.ts +27 -8
  13. package/src/__tests__/community-staleness.test.ts +0 -2
  14. package/src/__tests__/component-badge.test.ts +4 -4
  15. package/src/__tests__/content-drift.test.ts +5 -1
  16. package/src/__tests__/conventions-integration.test.ts +774 -689
  17. package/src/__tests__/conventions-manager.test.ts +1216 -1152
  18. package/src/__tests__/doctor-bins.test.ts +14 -6
  19. package/src/__tests__/doctor.test.ts +438 -425
  20. package/src/__tests__/dual-write-prevention.test.ts +277 -282
  21. package/src/__tests__/enabled-not-installed.test.ts +71 -63
  22. package/src/__tests__/file-locking.test.ts +196 -196
  23. package/src/__tests__/gap-fill-versions.test.ts +318 -311
  24. package/src/__tests__/github-budget.test.ts +170 -164
  25. package/src/__tests__/gitignore-detector.test.ts +160 -152
  26. package/src/__tests__/gitignore-fixer.test.ts +285 -247
  27. package/src/__tests__/gitignore-prerun.test.ts +57 -57
  28. package/src/__tests__/gitignore-resolver.test.ts +173 -146
  29. package/src/__tests__/gitignore-service.test.ts +121 -119
  30. package/src/__tests__/install-command.test.ts +3 -3
  31. package/src/__tests__/install-plan.test.ts +13 -7
  32. package/src/__tests__/manifest.test.ts +16 -5
  33. package/src/__tests__/markdown-renderer.test.ts +0 -1
  34. package/src/__tests__/marketplace-badge.test.ts +111 -90
  35. package/src/__tests__/marketplace-version.test.ts +64 -61
  36. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  37. package/src/__tests__/plugin-requires.test.ts +18 -4
  38. package/src/__tests__/plugin-setup.test.ts +12 -12
  39. package/src/__tests__/profile-command.test.ts +1 -7
  40. package/src/__tests__/profile-materializer.test.ts +31 -8
  41. package/src/__tests__/profile-sync.test.ts +23 -5
  42. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  43. package/src/__tests__/resolve-executable.test.ts +1 -1
  44. package/src/__tests__/resolver.test.ts +40 -15
  45. package/src/__tests__/scope-action.test.ts +52 -50
  46. package/src/__tests__/scope-squares.test.tsx +4 -5
  47. package/src/__tests__/skill-install-files.test.ts +111 -103
  48. package/src/__tests__/symlink-manager.test.ts +27 -10
  49. package/src/__tests__/theme-env.test.ts +281 -0
  50. package/src/__tests__/theme-resolve.test.ts +380 -0
  51. package/src/__tests__/update-apply.test.ts +1 -3
  52. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  53. package/src/__tests__/version-snapshot.test.ts +181 -165
  54. package/src/cli/ansi.ts +23 -15
  55. package/src/cli/doctor.ts +10 -9
  56. package/src/cli/install.ts +3 -3
  57. package/src/cli/profile.ts +5 -4
  58. package/src/cli/prompt.ts +0 -1
  59. package/src/cli/router.ts +69 -2
  60. package/src/cli/update-view.ts +11 -6
  61. package/src/cli/update.ts +9 -4
  62. package/src/cli/upgrade.ts +6 -4
  63. package/src/data/alias-flags.ts +260 -260
  64. package/src/data/gitignore-defaults.ts +20 -20
  65. package/src/data/gitignore-reasons.ts +86 -92
  66. package/src/data/gitignore-templates.ts +14 -14
  67. package/src/data/predefined-profiles.ts +8 -9
  68. package/src/data/skill-repos.ts +43 -35
  69. package/src/main.tsx +29 -6
  70. package/src/opentui.d.ts +1 -3
  71. package/src/prerunner/index.ts +7 -8
  72. package/src/services/alias-settings.ts +20 -23
  73. package/src/services/alias-shell-writer.ts +800 -803
  74. package/src/services/alias-store.ts +77 -77
  75. package/src/services/claude-settings.ts +27 -18
  76. package/src/services/content-drift.ts +13 -3
  77. package/src/services/conventions-manager.ts +692 -617
  78. package/src/services/doctor.ts +464 -403
  79. package/src/services/gitignore-detector.ts +113 -103
  80. package/src/services/gitignore-fixer.ts +204 -189
  81. package/src/services/gitignore-prerun.ts +26 -26
  82. package/src/services/gitignore-resolver.ts +162 -144
  83. package/src/services/gitignore-service.ts +120 -117
  84. package/src/services/install-plan.ts +4 -4
  85. package/src/services/manifest.ts +1 -1
  86. package/src/services/marketplace-catalog-git.ts +5 -2
  87. package/src/services/marketplace-fetcher.ts +241 -235
  88. package/src/services/plugin-manager.ts +17 -20
  89. package/src/services/plugin-mcp-config.ts +2 -2
  90. package/src/services/plugin-requires.ts +9 -6
  91. package/src/services/plugin-setup.ts +7 -9
  92. package/src/services/plugin-version-check.ts +4 -4
  93. package/src/services/profile-materializer.ts +9 -5
  94. package/src/services/profile-sync.ts +12 -4
  95. package/src/services/profiles.ts +3 -3
  96. package/src/services/resolver.ts +10 -6
  97. package/src/services/settings-manager.ts +82 -46
  98. package/src/services/skills-manager.ts +11 -9
  99. package/src/services/symlink-manager.ts +2 -5
  100. package/src/services/toolchain.ts +3 -3
  101. package/src/services/update-cache.ts +1 -1
  102. package/src/services/update-engine.ts +4 -4
  103. package/src/services/update-plan.ts +9 -11
  104. package/src/services/version-snapshot.ts +88 -88
  105. package/src/types/gitignore.ts +28 -28
  106. package/src/ui/App.tsx +36 -36
  107. package/src/ui/adapters/settingsAdapter.ts +2 -2
  108. package/src/ui/components/CategoryHeader.tsx +1 -1
  109. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  110. package/src/ui/components/ScopeIndicator.tsx +3 -1
  111. package/src/ui/components/ScrollableList.tsx +2 -1
  112. package/src/ui/components/StyledText.tsx +1 -1
  113. package/src/ui/components/layout/FooterHints.tsx +5 -3
  114. package/src/ui/components/layout/Panel.tsx +2 -2
  115. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  116. package/src/ui/components/modals/InputModal.tsx +7 -1
  117. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  118. package/src/ui/components/modals/SelectModal.tsx +1 -1
  119. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  120. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  121. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  122. package/src/ui/hooks/index.ts +0 -1
  123. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  124. package/src/ui/hooks/useMismatchModal.ts +4 -10
  125. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  126. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  127. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  128. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  129. package/src/ui/renderers/profileRenderers.tsx +9 -8
  130. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  131. package/src/ui/renderers/skillRenderers.tsx +14 -14
  132. package/src/ui/screens/AliasScreen.tsx +31 -34
  133. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  134. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  135. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  136. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  137. package/src/ui/screens/McpScreen.tsx +10 -10
  138. package/src/ui/screens/PluginsScreen.tsx +17 -21
  139. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  140. package/src/ui/screens/SkillsScreen.tsx +16 -14
  141. package/src/ui/state/AppContext.tsx +4 -8
  142. package/src/ui/state/DimensionsContext.tsx +3 -2
  143. package/src/ui/theme-mode.ts +20 -38
  144. package/src/ui/theme-resolve.ts +148 -0
  145. package/src/utils/clipboard.ts +5 -5
  146. package/src/utils/command-utils.ts +2 -2
  147. package/src/utils/config-dir.ts +1 -3
  148. package/src/utils/file-locking.ts +88 -86
  149. package/src/ui/hooks/useAsyncData.ts +0 -127
  150. package/src/ui/state/AnimationContext.tsx +0 -76
@@ -1,12 +1,12 @@
1
- import { describe, it, expect } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import {
3
- parseManagedBlock,
4
- parseAliasLine,
5
- parseAliasFromRc,
6
- tokenizePosixAliasBody,
7
- argsToFlagValues,
8
- renderAlias,
9
- renderArgsAsTokens,
3
+ argsToFlagValues,
4
+ parseAliasFromRc,
5
+ parseAliasLine,
6
+ parseManagedBlock,
7
+ renderAlias,
8
+ renderArgsAsTokens,
9
+ tokenizePosixAliasBody,
10
10
  } from "../services/alias-shell-writer";
11
11
  import { defaultAliasConfig } from "../services/alias-store";
12
12
 
@@ -17,393 +17,401 @@ import { defaultAliasConfig } from "../services/alias-store";
17
17
  * defaults aren't in the rc file at all).
18
18
  */
19
19
 
20
- function buildAndParse(mutate: (c: ReturnType<typeof defaultAliasConfig>) => void) {
21
- const c = defaultAliasConfig();
22
- c.aliasName = "c";
23
- mutate(c);
24
- const rendered = renderAlias(c, "zsh").block;
25
- const parsed = parseAliasFromRc(rendered);
26
- return { config: c, rendered, parsed };
20
+ function buildAndParse(
21
+ mutate: (c: ReturnType<typeof defaultAliasConfig>) => void,
22
+ ) {
23
+ const c = defaultAliasConfig();
24
+ c.aliasName = "c";
25
+ mutate(c);
26
+ const rendered = renderAlias(c, "zsh").block;
27
+ const parsed = parseAliasFromRc(rendered);
28
+ return { config: c, rendered, parsed };
27
29
  }
28
30
 
29
31
  describe("parseManagedBlock", () => {
30
- it("returns null when no managed block exists", () => {
31
- expect(parseManagedBlock("export PATH=...\n")).toBeNull();
32
- expect(parseManagedBlock("")).toBeNull();
33
- });
34
-
35
- it("returns null when only one marker is present", () => {
36
- expect(parseManagedBlock("# >>> claudeup managed alias >>>\nalias c='claude'\n")).toBeNull();
37
- });
38
-
39
- it("extracts a minimal block with no args", () => {
40
- const text =
41
- "# >>> claudeup managed alias >>>\n" +
42
- "alias c='claude'\n" +
43
- "# <<< claudeup managed alias <<<\n";
44
- const r = parseManagedBlock(text);
45
- expect(r).not.toBeNull();
46
- expect(r?.aliasName).toBe("c");
47
- expect(r?.args).toEqual([]);
48
- });
49
-
50
- it("ignores text outside the block", () => {
51
- const text =
52
- "export FOO=bar\n" +
53
- "# >>> claudeup managed alias >>>\n" +
54
- "alias cc='claude --ide'\n" +
55
- "# <<< claudeup managed alias <<<\n" +
56
- "alias ll='ls -la'\n";
57
- const r = parseManagedBlock(text);
58
- expect(r?.aliasName).toBe("cc");
59
- expect(r?.args).toEqual(["--ide"]);
60
- });
32
+ it("returns null when no managed block exists", () => {
33
+ expect(parseManagedBlock("export PATH=...\n")).toBeNull();
34
+ expect(parseManagedBlock("")).toBeNull();
35
+ });
36
+
37
+ it("returns null when only one marker is present", () => {
38
+ expect(
39
+ parseManagedBlock("# >>> claudeup managed alias >>>\nalias c='claude'\n"),
40
+ ).toBeNull();
41
+ });
42
+
43
+ it("extracts a minimal block with no args", () => {
44
+ const text =
45
+ "# >>> claudeup managed alias >>>\n" +
46
+ "alias c='claude'\n" +
47
+ "# <<< claudeup managed alias <<<\n";
48
+ const r = parseManagedBlock(text);
49
+ expect(r).not.toBeNull();
50
+ expect(r?.aliasName).toBe("c");
51
+ expect(r?.args).toEqual([]);
52
+ });
53
+
54
+ it("ignores text outside the block", () => {
55
+ const text =
56
+ "export FOO=bar\n" +
57
+ "# >>> claudeup managed alias >>>\n" +
58
+ "alias cc='claude --ide'\n" +
59
+ "# <<< claudeup managed alias <<<\n" +
60
+ "alias ll='ls -la'\n";
61
+ const r = parseManagedBlock(text);
62
+ expect(r?.aliasName).toBe("cc");
63
+ expect(r?.args).toEqual(["--ide"]);
64
+ });
61
65
  });
62
66
 
63
67
  describe("parseAliasLine", () => {
64
- it("rejects lines that don't match the managed pattern", () => {
65
- expect(parseAliasLine("alias ll='ls -la'")).toBeNull();
66
- expect(parseAliasLine("alias =foo")).toBeNull();
67
- expect(parseAliasLine("alias c='ls'")).toBeNull(); // not invoking claude
68
- });
69
-
70
- it("parses simple bare-flag lines", () => {
71
- const r = parseAliasLine("alias c='claude --ide --dangerously-skip-permissions'");
72
- expect(r?.aliasName).toBe("c");
73
- expect(r?.args).toEqual(["--ide", "--dangerously-skip-permissions"]);
74
- });
68
+ it("rejects lines that don't match the managed pattern", () => {
69
+ expect(parseAliasLine("alias ll='ls -la'")).toBeNull();
70
+ expect(parseAliasLine("alias =foo")).toBeNull();
71
+ expect(parseAliasLine("alias c='ls'")).toBeNull(); // not invoking claude
72
+ });
73
+
74
+ it("parses simple bare-flag lines", () => {
75
+ const r = parseAliasLine(
76
+ "alias c='claude --ide --dangerously-skip-permissions'",
77
+ );
78
+ expect(r?.aliasName).toBe("c");
79
+ expect(r?.args).toEqual(["--ide", "--dangerously-skip-permissions"]);
80
+ });
75
81
  });
76
82
 
77
83
  describe("tokenizePosixAliasBody — escape forms", () => {
78
- it("splits bare tokens on whitespace", () => {
79
- expect(tokenizePosixAliasBody("claude --ide --debug api,hooks")).toEqual([
80
- "claude",
81
- "--ide",
82
- "--debug",
83
- "api,hooks",
84
- ]);
85
- });
86
-
87
- it("unescapes the close-escape-reopen sequence for spaces", () => {
88
- // `'\''be terse'\''` inside outer quotes becomes the literal token `be terse`.
89
- expect(
90
- tokenizePosixAliasBody("claude --append-system-prompt '\\''be terse'\\''"),
91
- ).toEqual(["claude", "--append-system-prompt", "be terse"]);
92
- });
93
-
94
- it("converts a substitution embed back into its template token", () => {
95
- expect(
96
- tokenizePosixAliasBody(
97
- `claude --remote-control-session-name-prefix dev-'"$(basename "$PWD")"'-'"$(date +%d)"'`,
98
- ),
99
- ).toEqual([
100
- "claude",
101
- "--remote-control-session-name-prefix",
102
- "dev-{folder}-{day}",
103
- ]);
104
- });
105
-
106
- it("passes through unknown substitution code as the raw $(...) form", () => {
107
- expect(
108
- tokenizePosixAliasBody(`claude --remote-control-session-name-prefix '"$(whoami)"'`),
109
- ).toEqual(["claude", "--remote-control-session-name-prefix", "$(whoami)"]);
110
- });
84
+ it("splits bare tokens on whitespace", () => {
85
+ expect(tokenizePosixAliasBody("claude --ide --debug api,hooks")).toEqual([
86
+ "claude",
87
+ "--ide",
88
+ "--debug",
89
+ "api,hooks",
90
+ ]);
91
+ });
92
+
93
+ it("unescapes the close-escape-reopen sequence for spaces", () => {
94
+ // `'\''be terse'\''` inside outer quotes becomes the literal token `be terse`.
95
+ expect(
96
+ tokenizePosixAliasBody(
97
+ "claude --append-system-prompt '\\''be terse'\\''",
98
+ ),
99
+ ).toEqual(["claude", "--append-system-prompt", "be terse"]);
100
+ });
101
+
102
+ it("converts a substitution embed back into its template token", () => {
103
+ expect(
104
+ tokenizePosixAliasBody(
105
+ `claude --remote-control-session-name-prefix dev-'"$(basename "$PWD")"'-'"$(date +%d)"'`,
106
+ ),
107
+ ).toEqual([
108
+ "claude",
109
+ "--remote-control-session-name-prefix",
110
+ "dev-{folder}-{day}",
111
+ ]);
112
+ });
113
+
114
+ it("passes through unknown substitution code as the raw $(...) form", () => {
115
+ expect(
116
+ tokenizePosixAliasBody(
117
+ `claude --remote-control-session-name-prefix '"$(whoami)"'`,
118
+ ),
119
+ ).toEqual(["claude", "--remote-control-session-name-prefix", "$(whoami)"]);
120
+ });
111
121
  });
112
122
 
113
123
  describe("argsToFlagValues", () => {
114
- it("returns an empty object for empty args", () => {
115
- expect(argsToFlagValues([])).toEqual({});
116
- });
117
-
118
- it("marks boolean flags as enabled", () => {
119
- const v = argsToFlagValues(["--ide", "--dangerously-skip-permissions"]);
120
- expect(v["ide"]).toEqual({ kind: "boolean", enabled: true });
121
- expect(v["dangerously-skip-permissions"]).toEqual({
122
- kind: "boolean",
123
- enabled: true,
124
- });
125
- });
126
-
127
- it("recognises tri-state on/off variants", () => {
128
- expect(argsToFlagValues(["--chrome"])).toMatchObject({
129
- chrome: { kind: "tri-state", state: "on" },
130
- });
131
- expect(argsToFlagValues(["--no-chrome"])).toMatchObject({
132
- chrome: { kind: "tri-state", state: "off" },
133
- });
134
- });
135
-
136
- it("reads a select flag with a known value", () => {
137
- expect(argsToFlagValues(["--effort", "high"])).toMatchObject({
138
- effort: { kind: "select", enabled: true, value: "high" },
139
- });
140
- });
141
-
142
- it("treats a bare --tmux (no variant) as the empty-value select", () => {
143
- expect(argsToFlagValues(["--worktree", "--tmux"])).toMatchObject({
144
- worktree: { kind: "boolean", enabled: true },
145
- tmux: { kind: "select", enabled: true, value: "" },
146
- });
147
- });
148
-
149
- it("reads multi-with-custom comma-joined tokens, splitting picked vs custom", () => {
150
- expect(argsToFlagValues(["--debug", "api,hooks,!file"])).toMatchObject({
151
- debug: {
152
- kind: "multi-with-custom",
153
- enabled: true,
154
- picked: ["api", "hooks"],
155
- custom: ["!file"],
156
- },
157
- });
158
- });
159
-
160
- it("collects repeated --dangerously-load-development-channels values", () => {
161
- expect(
162
- argsToFlagValues([
163
- "--dangerously-load-development-channels",
164
- "plugin:claudish@magus",
165
- "--dangerously-load-development-channels",
166
- "plugin:dev@magus",
167
- ]),
168
- ).toMatchObject({
169
- "dangerously-load-development-channels": {
170
- kind: "text-list",
171
- enabled: true,
172
- values: ["plugin:claudish@magus", "plugin:dev@magus"],
173
- },
174
- });
175
- });
176
-
177
- it("reads a text flag's value", () => {
178
- expect(
179
- argsToFlagValues(["--append-system-prompt", "be terse"]),
180
- ).toMatchObject({
181
- "append-system-prompt": {
182
- kind: "text",
183
- enabled: true,
184
- value: "be terse",
185
- },
186
- });
187
- });
188
-
189
- it("recognises an optional-text flag in bare form (followed by another flag)", () => {
190
- expect(
191
- argsToFlagValues(["--remote-control", "--ide"]),
192
- ).toMatchObject({
193
- "remote-control": { kind: "boolean", enabled: true },
194
- ide: { kind: "boolean", enabled: true },
195
- });
196
- });
197
-
198
- it("silently skips unknown flag/value pairs without corrupting the rest", () => {
199
- expect(
200
- argsToFlagValues(["--unknown-future", "value", "--ide"]),
201
- ).toMatchObject({ ide: { kind: "boolean", enabled: true } });
202
- });
124
+ it("returns an empty object for empty args", () => {
125
+ expect(argsToFlagValues([])).toEqual({});
126
+ });
127
+
128
+ it("marks boolean flags as enabled", () => {
129
+ const v = argsToFlagValues(["--ide", "--dangerously-skip-permissions"]);
130
+ expect(v.ide).toEqual({ kind: "boolean", enabled: true });
131
+ expect(v["dangerously-skip-permissions"]).toEqual({
132
+ kind: "boolean",
133
+ enabled: true,
134
+ });
135
+ });
136
+
137
+ it("recognises tri-state on/off variants", () => {
138
+ expect(argsToFlagValues(["--chrome"])).toMatchObject({
139
+ chrome: { kind: "tri-state", state: "on" },
140
+ });
141
+ expect(argsToFlagValues(["--no-chrome"])).toMatchObject({
142
+ chrome: { kind: "tri-state", state: "off" },
143
+ });
144
+ });
145
+
146
+ it("reads a select flag with a known value", () => {
147
+ expect(argsToFlagValues(["--effort", "high"])).toMatchObject({
148
+ effort: { kind: "select", enabled: true, value: "high" },
149
+ });
150
+ });
151
+
152
+ it("treats a bare --tmux (no variant) as the empty-value select", () => {
153
+ expect(argsToFlagValues(["--worktree", "--tmux"])).toMatchObject({
154
+ worktree: { kind: "boolean", enabled: true },
155
+ tmux: { kind: "select", enabled: true, value: "" },
156
+ });
157
+ });
158
+
159
+ it("reads multi-with-custom comma-joined tokens, splitting picked vs custom", () => {
160
+ expect(argsToFlagValues(["--debug", "api,hooks,!file"])).toMatchObject({
161
+ debug: {
162
+ kind: "multi-with-custom",
163
+ enabled: true,
164
+ picked: ["api", "hooks"],
165
+ custom: ["!file"],
166
+ },
167
+ });
168
+ });
169
+
170
+ it("collects repeated --dangerously-load-development-channels values", () => {
171
+ expect(
172
+ argsToFlagValues([
173
+ "--dangerously-load-development-channels",
174
+ "plugin:claudish@magus",
175
+ "--dangerously-load-development-channels",
176
+ "plugin:dev@magus",
177
+ ]),
178
+ ).toMatchObject({
179
+ "dangerously-load-development-channels": {
180
+ kind: "text-list",
181
+ enabled: true,
182
+ values: ["plugin:claudish@magus", "plugin:dev@magus"],
183
+ },
184
+ });
185
+ });
186
+
187
+ it("reads a text flag's value", () => {
188
+ expect(
189
+ argsToFlagValues(["--append-system-prompt", "be terse"]),
190
+ ).toMatchObject({
191
+ "append-system-prompt": {
192
+ kind: "text",
193
+ enabled: true,
194
+ value: "be terse",
195
+ },
196
+ });
197
+ });
198
+
199
+ it("recognises an optional-text flag in bare form (followed by another flag)", () => {
200
+ expect(argsToFlagValues(["--remote-control", "--ide"])).toMatchObject({
201
+ "remote-control": { kind: "boolean", enabled: true },
202
+ ide: { kind: "boolean", enabled: true },
203
+ });
204
+ });
205
+
206
+ it("silently skips unknown flag/value pairs without corrupting the rest", () => {
207
+ expect(
208
+ argsToFlagValues(["--unknown-future", "value", "--ide"]),
209
+ ).toMatchObject({ ide: { kind: "boolean", enabled: true } });
210
+ });
203
211
  });
204
212
 
205
213
  describe("round-trip: render → parse → enabled subset matches", () => {
206
- it("preserves a complex POSIX config end-to-end", () => {
207
- const { parsed } = buildAndParse((c) => {
208
- c.flags["dangerously-skip-permissions"] = {
209
- kind: "boolean",
210
- enabled: true,
211
- };
212
- c.flags["debug"] = {
213
- kind: "multi-with-custom",
214
- enabled: true,
215
- picked: ["api", "hooks"],
216
- custom: ["!file"],
217
- };
218
- c.flags["effort"] = { kind: "select", enabled: true, value: "high" };
219
- c.flags["dangerously-load-development-channels"] = {
220
- kind: "text-list",
221
- enabled: true,
222
- values: ["plugin:claudish@magus"],
223
- };
224
- c.flags["worktree"] = { kind: "boolean", enabled: true };
225
- c.flags["tmux"] = { kind: "select", enabled: true, value: "classic" };
226
- });
227
- expect(parsed).not.toBeNull();
228
- expect(parsed!.aliasName).toBe("c");
229
-
230
- const f = parsed!.flags;
231
- expect(f["dangerously-skip-permissions"]).toEqual({
232
- kind: "boolean",
233
- enabled: true,
234
- });
235
- expect(f["debug"]).toEqual({
236
- kind: "multi-with-custom",
237
- enabled: true,
238
- picked: ["api", "hooks"],
239
- custom: ["!file"],
240
- });
241
- expect(f["effort"]).toEqual({
242
- kind: "select",
243
- enabled: true,
244
- value: "high",
245
- });
246
- expect(f["dangerously-load-development-channels"]).toEqual({
247
- kind: "text-list",
248
- enabled: true,
249
- values: ["plugin:claudish@magus"],
250
- });
251
- expect(f["worktree"]).toEqual({ kind: "boolean", enabled: true });
252
- expect(f["tmux"]).toEqual({
253
- kind: "select",
254
- enabled: true,
255
- value: "classic",
256
- });
257
- });
258
-
259
- it("preserves a templated remote-control prefix", () => {
260
- const { parsed } = buildAndParse((c) => {
261
- c.flags["remote-control-session-name-prefix"] = {
262
- kind: "text",
263
- enabled: true,
264
- value: "dev-{folder}-{day}",
265
- };
266
- });
267
- expect(parsed!.flags["remote-control-session-name-prefix"]).toEqual({
268
- kind: "text",
269
- enabled: true,
270
- value: "dev-{folder}-{day}",
271
- });
272
- });
273
-
274
- it("preserves a quoted text value with spaces", () => {
275
- const { parsed } = buildAndParse((c) => {
276
- c.flags["append-system-prompt"] = {
277
- kind: "text",
278
- enabled: true,
279
- value: "be terse",
280
- };
281
- });
282
- expect(parsed!.flags["append-system-prompt"]).toEqual({
283
- kind: "text",
284
- enabled: true,
285
- value: "be terse",
286
- });
287
- });
288
-
289
- it("preserves a custom aliasName", () => {
290
- const { parsed } = buildAndParse((c) => {
291
- c.aliasName = "cc";
292
- c.flags["ide"] = { kind: "boolean", enabled: true };
293
- });
294
- expect(parsed!.aliasName).toBe("cc");
295
- expect(parsed!.flags["ide"]).toEqual({ kind: "boolean", enabled: true });
296
- });
214
+ it("preserves a complex POSIX config end-to-end", () => {
215
+ const { parsed } = buildAndParse((c) => {
216
+ c.flags["dangerously-skip-permissions"] = {
217
+ kind: "boolean",
218
+ enabled: true,
219
+ };
220
+ c.flags.debug = {
221
+ kind: "multi-with-custom",
222
+ enabled: true,
223
+ picked: ["api", "hooks"],
224
+ custom: ["!file"],
225
+ };
226
+ c.flags.effort = { kind: "select", enabled: true, value: "high" };
227
+ c.flags["dangerously-load-development-channels"] = {
228
+ kind: "text-list",
229
+ enabled: true,
230
+ values: ["plugin:claudish@magus"],
231
+ };
232
+ c.flags.worktree = { kind: "boolean", enabled: true };
233
+ c.flags.tmux = { kind: "select", enabled: true, value: "classic" };
234
+ });
235
+ expect(parsed).not.toBeNull();
236
+ expect(parsed!.aliasName).toBe("c");
237
+
238
+ const f = parsed!.flags;
239
+ expect(f["dangerously-skip-permissions"]).toEqual({
240
+ kind: "boolean",
241
+ enabled: true,
242
+ });
243
+ expect(f.debug).toEqual({
244
+ kind: "multi-with-custom",
245
+ enabled: true,
246
+ picked: ["api", "hooks"],
247
+ custom: ["!file"],
248
+ });
249
+ expect(f.effort).toEqual({
250
+ kind: "select",
251
+ enabled: true,
252
+ value: "high",
253
+ });
254
+ expect(f["dangerously-load-development-channels"]).toEqual({
255
+ kind: "text-list",
256
+ enabled: true,
257
+ values: ["plugin:claudish@magus"],
258
+ });
259
+ expect(f.worktree).toEqual({ kind: "boolean", enabled: true });
260
+ expect(f.tmux).toEqual({
261
+ kind: "select",
262
+ enabled: true,
263
+ value: "classic",
264
+ });
265
+ });
266
+
267
+ it("preserves a templated remote-control prefix", () => {
268
+ const { parsed } = buildAndParse((c) => {
269
+ c.flags["remote-control-session-name-prefix"] = {
270
+ kind: "text",
271
+ enabled: true,
272
+ value: "dev-{folder}-{day}",
273
+ };
274
+ });
275
+ expect(parsed!.flags["remote-control-session-name-prefix"]).toEqual({
276
+ kind: "text",
277
+ enabled: true,
278
+ value: "dev-{folder}-{day}",
279
+ });
280
+ });
281
+
282
+ it("preserves a quoted text value with spaces", () => {
283
+ const { parsed } = buildAndParse((c) => {
284
+ c.flags["append-system-prompt"] = {
285
+ kind: "text",
286
+ enabled: true,
287
+ value: "be terse",
288
+ };
289
+ });
290
+ expect(parsed!.flags["append-system-prompt"]).toEqual({
291
+ kind: "text",
292
+ enabled: true,
293
+ value: "be terse",
294
+ });
295
+ });
296
+
297
+ it("preserves a custom aliasName", () => {
298
+ const { parsed } = buildAndParse((c) => {
299
+ c.aliasName = "cc";
300
+ c.flags.ide = { kind: "boolean", enabled: true };
301
+ });
302
+ expect(parsed!.aliasName).toBe("cc");
303
+ expect(parsed!.flags.ide).toEqual({ kind: "boolean", enabled: true });
304
+ });
297
305
  });
298
306
 
299
307
  describe("channels: render-time union + parse-time provenance split", () => {
300
- // The model: channels.values holds OWN channels only; dev-load values are
301
- // unioned into --channels at render time and subtracted back out on parse,
302
- // so the two lists stay distinct in storage but merge on the command line.
303
-
304
- it("emits --channels as (own ∪ enabled dev-load), deduped, own-first", () => {
305
- const c = defaultAliasConfig();
306
- c.flags["channels"] = {
307
- kind: "text-list",
308
- enabled: true,
309
- values: ["plugin:own@m"],
310
- };
311
- c.flags["dangerously-load-development-channels"] = {
312
- kind: "text-list",
313
- enabled: true,
314
- values: ["plugin:dev@m", "plugin:own@m"], // overlap dedupes
315
- };
316
- const tokens = renderArgsAsTokens(c);
317
- // --channels carries own + derived (deduped); dev-load still emits its own.
318
- const channelsArgs = tokens
319
- .map((t, i) => (tokens[i - 1] === "--channels" ? t : null))
320
- .filter(Boolean);
321
- expect(channelsArgs).toEqual(["plugin:own@m", "plugin:dev@m"]);
322
- });
323
-
324
- it("forces --channels to emit derived values even when own channels is OFF", () => {
325
- const c = defaultAliasConfig();
326
- c.flags["channels"] = { kind: "text-list", enabled: false, values: [] };
327
- c.flags["dangerously-load-development-channels"] = {
328
- kind: "text-list",
329
- enabled: true,
330
- values: ["plugin:dev@m"],
331
- };
332
- const tokens = renderArgsAsTokens(c);
333
- expect(tokens).toContain("--channels");
334
- const idx = tokens.indexOf("--channels");
335
- expect(tokens[idx + 1]).toBe("plugin:dev@m");
336
- });
337
-
338
- it("does NOT emit --channels when both own and dev-load are empty/off", () => {
339
- const c = defaultAliasConfig(); // both channels + dev-load default off
340
- expect(renderArgsAsTokens(c)).not.toContain("--channels");
341
- });
342
-
343
- it("round-trips: derived values do NOT leak into parsed channels.values", () => {
344
- const c = defaultAliasConfig();
345
- c.aliasName = "c";
346
- c.flags["channels"] = {
347
- kind: "text-list",
348
- enabled: true,
349
- values: ["plugin:own@m"],
350
- };
351
- c.flags["dangerously-load-development-channels"] = {
352
- kind: "text-list",
353
- enabled: true,
354
- values: ["plugin:dev@m"],
355
- };
356
- const rendered = renderAlias(c, "zsh").block;
357
- const parsed = parseAliasFromRc(rendered);
358
- // channels.values comes back as OWN ONLY — derived was subtracted.
359
- expect(parsed!.flags["channels"]).toEqual({
360
- kind: "text-list",
361
- enabled: true,
362
- values: ["plugin:own@m"],
363
- });
364
- // dev-load round-trips intact (it's the source of truth for derived).
365
- expect(parsed!.flags["dangerously-load-development-channels"]).toEqual({
366
- kind: "text-list",
367
- enabled: true,
368
- values: ["plugin:dev@m"],
369
- });
370
- });
371
-
372
- it("round-trips a purely-derived channels list to disabled+empty own", () => {
373
- const c = defaultAliasConfig();
374
- c.aliasName = "c";
375
- // No own channels; only dev-load.
376
- c.flags["dangerously-load-development-channels"] = {
377
- kind: "text-list",
378
- enabled: true,
379
- values: ["plugin:dev@m"],
380
- };
381
- const parsed = parseAliasFromRc(renderAlias(c, "zsh").block);
382
- expect(parsed!.flags["channels"]).toEqual({
383
- kind: "text-list",
384
- enabled: false,
385
- values: [],
386
- });
387
- });
308
+ // The model: channels.values holds OWN channels only; dev-load values are
309
+ // unioned into --channels at render time and subtracted back out on parse,
310
+ // so the two lists stay distinct in storage but merge on the command line.
311
+
312
+ it("emits --channels as (own ∪ enabled dev-load), deduped, own-first", () => {
313
+ const c = defaultAliasConfig();
314
+ c.flags.channels = {
315
+ kind: "text-list",
316
+ enabled: true,
317
+ values: ["plugin:own@m"],
318
+ };
319
+ c.flags["dangerously-load-development-channels"] = {
320
+ kind: "text-list",
321
+ enabled: true,
322
+ values: ["plugin:dev@m", "plugin:own@m"], // overlap dedupes
323
+ };
324
+ const tokens = renderArgsAsTokens(c);
325
+ // --channels carries own + derived (deduped); dev-load still emits its own.
326
+ const channelsArgs = tokens
327
+ .map((t, i) => (tokens[i - 1] === "--channels" ? t : null))
328
+ .filter(Boolean);
329
+ expect(channelsArgs).toEqual(["plugin:own@m", "plugin:dev@m"]);
330
+ });
331
+
332
+ it("forces --channels to emit derived values even when own channels is OFF", () => {
333
+ const c = defaultAliasConfig();
334
+ c.flags.channels = { kind: "text-list", enabled: false, values: [] };
335
+ c.flags["dangerously-load-development-channels"] = {
336
+ kind: "text-list",
337
+ enabled: true,
338
+ values: ["plugin:dev@m"],
339
+ };
340
+ const tokens = renderArgsAsTokens(c);
341
+ expect(tokens).toContain("--channels");
342
+ const idx = tokens.indexOf("--channels");
343
+ expect(tokens[idx + 1]).toBe("plugin:dev@m");
344
+ });
345
+
346
+ it("does NOT emit --channels when both own and dev-load are empty/off", () => {
347
+ const c = defaultAliasConfig(); // both channels + dev-load default off
348
+ expect(renderArgsAsTokens(c)).not.toContain("--channels");
349
+ });
350
+
351
+ it("round-trips: derived values do NOT leak into parsed channels.values", () => {
352
+ const c = defaultAliasConfig();
353
+ c.aliasName = "c";
354
+ c.flags.channels = {
355
+ kind: "text-list",
356
+ enabled: true,
357
+ values: ["plugin:own@m"],
358
+ };
359
+ c.flags["dangerously-load-development-channels"] = {
360
+ kind: "text-list",
361
+ enabled: true,
362
+ values: ["plugin:dev@m"],
363
+ };
364
+ const rendered = renderAlias(c, "zsh").block;
365
+ const parsed = parseAliasFromRc(rendered);
366
+ // channels.values comes back as OWN ONLY — derived was subtracted.
367
+ expect(parsed!.flags.channels).toEqual({
368
+ kind: "text-list",
369
+ enabled: true,
370
+ values: ["plugin:own@m"],
371
+ });
372
+ // dev-load round-trips intact (it's the source of truth for derived).
373
+ expect(parsed!.flags["dangerously-load-development-channels"]).toEqual({
374
+ kind: "text-list",
375
+ enabled: true,
376
+ values: ["plugin:dev@m"],
377
+ });
378
+ });
379
+
380
+ it("round-trips a purely-derived channels list to disabled+empty own", () => {
381
+ const c = defaultAliasConfig();
382
+ c.aliasName = "c";
383
+ // No own channels; only dev-load.
384
+ c.flags["dangerously-load-development-channels"] = {
385
+ kind: "text-list",
386
+ enabled: true,
387
+ values: ["plugin:dev@m"],
388
+ };
389
+ const parsed = parseAliasFromRc(renderAlias(c, "zsh").block);
390
+ expect(parsed!.flags.channels).toEqual({
391
+ kind: "text-list",
392
+ enabled: false,
393
+ values: [],
394
+ });
395
+ });
388
396
  });
389
397
 
390
398
  describe("parseAliasFromRc — entry point", () => {
391
- it("returns null on text with no managed block", () => {
392
- expect(parseAliasFromRc("export PATH=...\n")).toBeNull();
393
- });
394
-
395
- it("returns the alias name + flags for a well-formed block", () => {
396
- const text =
397
- "# >>> claudeup managed alias >>>\n" +
398
- "alias c='claude --ide --effort high'\n" +
399
- "# <<< claudeup managed alias <<<\n";
400
- const r = parseAliasFromRc(text);
401
- expect(r?.aliasName).toBe("c");
402
- expect(r?.flags["ide"]).toEqual({ kind: "boolean", enabled: true });
403
- expect(r?.flags["effort"]).toEqual({
404
- kind: "select",
405
- enabled: true,
406
- value: "high",
407
- });
408
- });
399
+ it("returns null on text with no managed block", () => {
400
+ expect(parseAliasFromRc("export PATH=...\n")).toBeNull();
401
+ });
402
+
403
+ it("returns the alias name + flags for a well-formed block", () => {
404
+ const text =
405
+ "# >>> claudeup managed alias >>>\n" +
406
+ "alias c='claude --ide --effort high'\n" +
407
+ "# <<< claudeup managed alias <<<\n";
408
+ const r = parseAliasFromRc(text);
409
+ expect(r?.aliasName).toBe("c");
410
+ expect(r?.flags.ide).toEqual({ kind: "boolean", enabled: true });
411
+ expect(r?.flags.effort).toEqual({
412
+ kind: "select",
413
+ enabled: true,
414
+ value: "high",
415
+ });
416
+ });
409
417
  });