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
@@ -1,31 +1,32 @@
1
- import fs from "fs-extra";
2
- import path from "node:path";
3
1
  import os from "node:os";
4
- import { UpdateCache } from "../services/update-cache.js";
5
- import {
6
- getAvailablePlugins,
7
- clearMarketplaceCache,
8
- } from "../services/plugin-manager.js";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import { isClaudeAvailable, updatePlugin } from "../services/claude-cli.js";
9
5
  import { runClaude } from "../services/claude-runner.js";
10
6
  import {
11
- recoverMarketplaceSettings,
12
- migrateMarketplaceRename,
13
7
  cleanupExtraKnownMarketplaces,
8
+ gapFillInstalledPluginVersions,
9
+ migrateMarketplaceRename,
14
10
  readGlobalSettings,
15
- writeGlobalSettings,
11
+ recoverMarketplaceSettings,
16
12
  saveGlobalInstalledPluginVersion,
17
- gapFillInstalledPluginVersions,
13
+ writeGlobalSettings,
18
14
  } from "../services/claude-settings.js";
19
- import {
20
- checkPluginVersionMismatches,
21
- formatMismatchWarning,
22
- } from "../services/plugin-version-check.js";
23
- import { updatePlugin, isClaudeAvailable } from "../services/claude-cli.js";
24
- import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
25
15
  import {
26
16
  checkGitignore,
27
17
  formatPrerunWarning as formatGitignoreWarning,
28
18
  } from "../services/gitignore-prerun.js";
19
+ import { refreshRegisteredMarketplaces } from "../services/marketplace-refresh.js";
20
+ import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
21
+ import {
22
+ clearMarketplaceCache,
23
+ getAvailablePlugins,
24
+ } from "../services/plugin-manager.js";
25
+ import {
26
+ checkPluginVersionMismatches,
27
+ formatMismatchWarning,
28
+ } from "../services/plugin-version-check.js";
29
+ import { UpdateCache } from "../services/update-cache.js";
29
30
 
30
31
  export interface PrerunOptions {
31
32
  force?: boolean; // Bypass cache and force update check
@@ -133,9 +134,7 @@ export async function prerunClaude(
133
134
  // otherwise a freshly cloned repo whose only @magus references live
134
135
  // in project settings looks "clean" from the global-scope view.
135
136
  if (await isClaudeAvailable()) {
136
- const addedMarketplaces = await autoAddMissingMarketplaces(
137
- process.cwd(),
138
- );
137
+ const addedMarketplaces = await autoAddMissingMarketplaces(process.cwd());
139
138
  if (addedMarketplaces.length > 0) {
140
139
  console.log(
141
140
  `✓ Auto-added marketplace(s): ${addedMarketplaces.join(", ")}`,
@@ -238,20 +237,40 @@ export async function prerunClaude(
238
237
  console.log(
239
238
  `✓ Re-registered as GitHub source: ${recovery.reregistered.join(", ")}`,
240
239
  );
241
- // Trigger marketplace update for re-registered entries so the local
242
- // clone gets refreshed with the latest plugins.
240
+ // Re-registration only rewrites known_marketplaces.json (directory
241
+ // github source); it does NOT create the on-disk clone. Clone any whose
242
+ // directory is missing, using the NON-DESTRUCTIVE `marketplace add` —
243
+ // never `marketplace update`, which deletes the directory before
244
+ // re-cloning and can leave it permanently gone on failure (see
245
+ // marketplace-refresh.ts / marketplace-sync.ts). Clones that already
246
+ // exist are left to STEP 1.7's safe `git pull --ff-only`.
243
247
  const cliAvailable = await isClaudeAvailable();
244
248
  if (cliAvailable) {
245
- const { updateMarketplace } = await import(
246
- "../services/claude-cli.js"
249
+ const { addMarketplace } = await import("../services/claude-cli.js");
250
+ const { defaultMarketplaces } = await import(
251
+ "../data/marketplaces.js"
252
+ );
253
+ const marketplacesDir = path.join(
254
+ os.homedir(),
255
+ ".claude",
256
+ "plugins",
257
+ "marketplaces",
247
258
  );
248
259
  for (const mpName of recovery.reregistered) {
260
+ // Already on disk → STEP 1.7 fast-forwards it; nothing to clone.
261
+ if (await fs.pathExists(path.join(marketplacesDir, mpName))) {
262
+ continue;
263
+ }
264
+ const repo = defaultMarketplaces.find(
265
+ (m) => m.name === mpName,
266
+ )?.source.repo;
267
+ if (!repo) continue;
249
268
  try {
250
- await updateMarketplace(mpName);
251
- console.log(`✓ Updated marketplace: ${mpName}`);
269
+ await addMarketplace(repo);
270
+ console.log(`✓ Cloned re-registered marketplace: ${mpName}`);
252
271
  } catch (error) {
253
272
  console.warn(
254
- `⚠ Failed to update marketplace ${mpName}:`,
273
+ `⚠ Failed to clone marketplace ${mpName}:`,
255
274
  error instanceof Error ? error.message : "Unknown error",
256
275
  );
257
276
  }
@@ -259,6 +278,22 @@ export async function prerunClaude(
259
278
  }
260
279
  }
261
280
 
281
+ // STEP 1.7: Fast-forward the on-disk clones of every registered
282
+ // marketplace. Recovery above only *clones* re-registered ones that were
283
+ // missing; nothing else advances an existing healthy clone, so updated
284
+ // plugin setup metadata — e.g. a pinned dependency version — is never seen
285
+ // even though the fetched plugin list is current. Non-destructive
286
+ // `git pull --ff-only`: a failed pull leaves the clone stale, never gone.
287
+ const mpRefresh = await refreshRegisteredMarketplaces();
288
+ if (mpRefresh.refreshed.length > 0) {
289
+ console.log(
290
+ `✓ Refreshed marketplaces: ${mpRefresh.refreshed.join(", ")}`,
291
+ );
292
+ }
293
+ for (const name of mpRefresh.failed) {
294
+ console.warn(`⚠ Failed to refresh marketplace: ${name}`);
295
+ }
296
+
262
297
  // STEP 2: Clear cache to force fresh plugin info
263
298
  clearMarketplaceCache();
264
299
 
@@ -16,7 +16,14 @@ import {
16
16
  getFlagById,
17
17
  type AliasFlag,
18
18
  } from "../data/alias-flags.js";
19
- import type { AliasConfig, FlagValue } from "./alias-store.js";
19
+ import {
20
+ CHANNELS_FLAG_ID,
21
+ defaultValueFor,
22
+ derivedChannelValues,
23
+ listValues,
24
+ type AliasConfig,
25
+ type FlagValue,
26
+ } from "./alias-store.js";
20
27
 
21
28
  export type ShellKind = "zsh" | "bash" | "fish";
22
29
 
@@ -128,7 +135,16 @@ export function renderArgs(config: AliasConfig): Segment[] {
128
135
  const value = config.flags[flag.id];
129
136
  if (!value) continue;
130
137
 
131
- const rendered = renderFlag(flag, value);
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);
132
148
  if (rendered.length === 0) continue;
133
149
  out.push(...rendered);
134
150
  if (flag.xorGroup) seenXor.add(flag.xorGroup);
@@ -184,6 +200,26 @@ function templatedSegment(value: string): Segment {
184
200
  return { kind: "composite", parts };
185
201
  }
186
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 = own.kind === "text-list" && own.enabled
216
+ ? listValues(own)
217
+ : [];
218
+ const derived = derivedChannelValues(flags);
219
+ const merged = dedupe([...ownValues, ...derived]).filter((v) => v.length > 0);
220
+ return { kind: "text-list", enabled: merged.length > 0, values: merged };
221
+ }
222
+
187
223
  function renderFlag(flag: AliasFlag, value: FlagValue): Segment[] {
188
224
  switch (value.kind) {
189
225
  case "boolean":
@@ -455,9 +491,21 @@ export interface WriteResult {
455
491
  action: "created" | "updated";
456
492
  }
457
493
 
494
+ export interface WriteOptions {
495
+ /**
496
+ * Line range [start, end] (zero-based, inclusive) of a hand-written alias
497
+ * to REPLACE in place with the managed block — the adoption path. Only
498
+ * honored when the file has no managed block yet (a first write after
499
+ * adoption); subsequent writes find the markers and ignore this. Guarantees
500
+ * the adopted line is removed so no duplicate alias survives.
501
+ */
502
+ adoptLineRange?: [number, number];
503
+ }
504
+
458
505
  export async function writeAliasToShell(
459
506
  config: AliasConfig,
460
507
  target: ShellTarget,
508
+ options: WriteOptions = {},
461
509
  ): Promise<WriteResult> {
462
510
  const args = renderArgs(config);
463
511
  const unquotable = findUnquotableTokens(args);
@@ -472,7 +520,17 @@ export async function writeAliasToShell(
472
520
  }
473
521
  const rendered = renderAlias(config, target.kind);
474
522
  const existing = target.exists ? await readFile(target.path, "utf8") : "";
475
- const next = spliceManagedBlock(existing, rendered.block);
523
+
524
+ // Adoption: when a line range was supplied AND the file has no managed block
525
+ // yet, replace the adopted alias line in place — the managed block takes its
526
+ // slot, so the original can't survive as a duplicate. Once a managed block
527
+ // exists, marker-based splicing is position-independent and takes over.
528
+ const hasBlock = existing.includes(BLOCK_BEGIN);
529
+ const next =
530
+ options.adoptLineRange && !hasBlock
531
+ ? spliceManagedBlockAtRange(existing, rendered.block, options.adoptLineRange)
532
+ : spliceManagedBlock(existing, rendered.block);
533
+
476
534
  await writeFile(target.path, next, "utf8");
477
535
  return {
478
536
  shell: target.kind,
@@ -712,6 +770,35 @@ function unparseSubstitution(posixCode: string): string {
712
770
  * scan is sufficient. We pre-index by flag string for `--foo` lookups.
713
771
  */
714
772
  export function argsToFlagValues(args: string[]): Record<string, FlagValue> {
773
+ return argsToFlagValuesWithLeftovers(args).flags;
774
+ }
775
+
776
+ /**
777
+ * Result of {@link argsToFlagValuesWithLeftovers}: the parsed flag map plus the
778
+ * argv tokens that didn't map to any catalog flag.
779
+ *
780
+ * `leftovers` is the lossless gate for alias adoption: a hand-written alias is
781
+ * safe to absorb into the managed block ONLY when `leftovers` is empty, i.e.
782
+ * every token was recognized. Any leftover token would be silently lost if we
783
+ * deleted the original line and re-rendered from the parsed flags, so a
784
+ * non-empty `leftovers` means "import the flags for the user to see, but do NOT
785
+ * take ownership / do NOT remove their line".
786
+ */
787
+ export interface ParsedFlagsWithLeftovers {
788
+ flags: Record<string, FlagValue>;
789
+ /** argv tokens that matched no catalog flag (and so would be dropped). */
790
+ leftovers: string[];
791
+ }
792
+
793
+ /**
794
+ * Like {@link argsToFlagValues}, but reports every token it couldn't map to a
795
+ * catalog flag. Unknown `--flag value` pairs contribute BOTH tokens to
796
+ * `leftovers` (the flag and its consumed value) so the adoption gate sees the
797
+ * full extent of what wouldn't round-trip.
798
+ */
799
+ export function argsToFlagValuesWithLeftovers(
800
+ args: string[],
801
+ ): ParsedFlagsWithLeftovers {
715
802
  // Index every flag by its primary `flag` string AND its triStateOff variant.
716
803
  const byFlag = new Map<string, { flag: AliasFlag; off?: boolean }>();
717
804
  for (const flag of ALIAS_FLAGS) {
@@ -722,16 +809,22 @@ export function argsToFlagValues(args: string[]): Record<string, FlagValue> {
722
809
  }
723
810
 
724
811
  const out: Record<string, FlagValue> = {};
812
+ const leftovers: string[] = [];
725
813
  let i = 0;
726
814
  while (i < args.length) {
727
815
  const tok = args[i];
728
816
  const entry = byFlag.get(tok);
729
817
  if (!entry) {
730
- // Unknown flagskip this token AND its likely value, if next looks
731
- // like a value (doesn't start with --). This is a best-effort skip
732
- // so unknown flag pairs don't cascade-corrupt the rest of the parse.
818
+ // Unknown tokenrecord it. If the next token looks like its value
819
+ // (doesn't start with `-`), consume and record that too so unknown
820
+ // flag/value pairs don't cascade-corrupt the rest of the parse, and so
821
+ // the adoption gate sees the full unrecognized span.
822
+ leftovers.push(tok);
733
823
  i += 1;
734
- if (i < args.length && !args[i].startsWith("-")) i += 1;
824
+ if (i < args.length && !args[i].startsWith("-")) {
825
+ leftovers.push(args[i]);
826
+ i += 1;
827
+ }
735
828
  continue;
736
829
  }
737
830
  const { flag, off } = entry;
@@ -852,7 +945,26 @@ export function argsToFlagValues(args: string[]): Record<string, FlagValue> {
852
945
  }
853
946
  }
854
947
  }
855
- return out;
948
+
949
+ // Restore channels provenance: the writer emits `--channels` as
950
+ // (own ∪ dev-load), so a naive parse folds derived values back into
951
+ // channels.values. Subtract the dev-load values (when that flag was
952
+ // emitted/enabled) so channels.values holds only the user's OWN channels —
953
+ // the derived ones come from the dev-load flag at display/render time.
954
+ const channels = out[CHANNELS_FLAG_ID];
955
+ const derived = derivedChannelValues(out);
956
+ if (channels && channels.kind === "text-list" && derived.length > 0) {
957
+ const ownOnly = channels.values.filter((v) => !derived.includes(v));
958
+ out[CHANNELS_FLAG_ID] = {
959
+ kind: "text-list",
960
+ // Keep enabled only if the user has their own channels; a channels list
961
+ // that was purely derived shouldn't appear enabled after the subtraction.
962
+ enabled: ownOnly.length > 0,
963
+ values: ownOnly,
964
+ };
965
+ }
966
+
967
+ return { flags: out, leftovers };
856
968
  }
857
969
 
858
970
  /**
@@ -871,3 +983,265 @@ export function parseAliasFromRc(rcText: string): {
871
983
  flags: argsToFlagValues(parsed.args),
872
984
  };
873
985
  }
986
+
987
+ // ─── Adoption (detect & offer to import a hand-written alias) ───────────────
988
+ //
989
+ // When the screen opens and there is NO managed block, the user may still have
990
+ // a hand-written `alias c='claude …'` line elsewhere in the rc file. Rather
991
+ // than ignore it (and look broken), we DETECT it and OFFER to adopt it: import
992
+ // its flags into the editor and, on the next write, replace that line in place
993
+ // with the managed block so ownership transfers cleanly with no duplicate.
994
+ //
995
+ // Safety is built on a single invariant: we only mark an alias `lossless` —
996
+ // and therefore safe to remove — when every token round-trips. Two checks:
997
+ // 1. Leftover-empty: every argv token mapped to a catalog flag.
998
+ // 2. Fixpoint: re-rendering the parsed flags and re-parsing yields the same
999
+ // flag map (catches peek-based mis-maps that consume a token into the
1000
+ // wrong bucket without surfacing as a leftover).
1001
+ // Both are order- and quote-insensitive by construction. A NON-lossless alias
1002
+ // is still offered (the user sees its recognized flags) but its original line
1003
+ // is never removed — we keep what we couldn't fully understand.
1004
+
1005
+ /**
1006
+ * A hand-written `claude`-wrapping alias found OUTSIDE the managed block.
1007
+ */
1008
+ export interface AdoptableAlias {
1009
+ /** The alias name (e.g. `c`). */
1010
+ name: string;
1011
+ /** Parsed flag values from the alias body. */
1012
+ flags: Record<string, FlagValue>;
1013
+ /**
1014
+ * Zero-based line indices [start, end] of the alias line in the rc file.
1015
+ * Single-line today (start === end), but kept as a range so the splice
1016
+ * helper has the exact span to replace.
1017
+ */
1018
+ lineRange: [number, number];
1019
+ /** argv tokens that didn't map to any catalog flag (data we'd drop). */
1020
+ leftovers: string[];
1021
+ /**
1022
+ * True when the alias round-trips exactly: leftovers empty AND the parsed
1023
+ * flags survive a render→reparse fixpoint. Only a lossless alias may have
1024
+ * its original line removed on adoption.
1025
+ */
1026
+ lossless: boolean;
1027
+ /** The raw alias line, verbatim (for display / preservation). */
1028
+ rawLine: string;
1029
+ /**
1030
+ * Other top-level `claude`-wrapping alias lines we found but did NOT pick.
1031
+ * Surfaced so the UI can note "also found N other claude aliases" rather
1032
+ * than silently choosing the first.
1033
+ */
1034
+ others: string[];
1035
+ }
1036
+
1037
+ const ANY_ALIAS_LINE_RE =
1038
+ /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(['"])(.*)\2\s*$/;
1039
+
1040
+ /**
1041
+ * Tokenize a general (hand-written) POSIX alias body into argv strings.
1042
+ *
1043
+ * Unlike {@link tokenizePosixAliasBody} — which only undoes OUR writer's escape
1044
+ * scheme — this handles the shapes a human would type: bare words, single-
1045
+ * quoted runs (literal, no escapes), and double-quoted runs (we treat the
1046
+ * content literally; we do NOT expand `$VAR` or `$(cmd)`). Adjacent quoted and
1047
+ * bare pieces with no whitespace between them concatenate into one argv token,
1048
+ * matching shell word-joining (`foo' bar'` → `foo bar`).
1049
+ *
1050
+ * Returns `null` for anything we can't cleanly tokenize: unterminated quotes,
1051
+ * or a body containing a shell substitution (`$(`, backtick) or variable (`$`)
1052
+ * — those can't be faithfully represented as a static flag value, so the
1053
+ * caller treats the whole alias as not-adoptable rather than guessing.
1054
+ */
1055
+ export function tokenizeGeneralAliasBody(body: string): string[] | null {
1056
+ const tokens: string[] = [];
1057
+ let current = "";
1058
+ let inToken = false;
1059
+ let i = 0;
1060
+ const len = body.length;
1061
+
1062
+ const flush = () => {
1063
+ if (inToken) {
1064
+ tokens.push(current);
1065
+ current = "";
1066
+ inToken = false;
1067
+ }
1068
+ };
1069
+
1070
+ while (i < len) {
1071
+ const ch = body[i];
1072
+
1073
+ if (ch === " " || ch === "\t") {
1074
+ flush();
1075
+ i += 1;
1076
+ continue;
1077
+ }
1078
+
1079
+ // Bail on dynamic content — we can't round-trip a variable/substitution.
1080
+ if (ch === "$" || ch === "`") return null;
1081
+
1082
+ if (ch === "'") {
1083
+ // Single-quoted run: literal until the next single quote.
1084
+ const end = body.indexOf("'", i + 1);
1085
+ if (end === -1) return null; // unterminated
1086
+ current += body.slice(i + 1, end);
1087
+ inToken = true;
1088
+ i = end + 1;
1089
+ continue;
1090
+ }
1091
+
1092
+ if (ch === '"') {
1093
+ // Double-quoted run: literal until the next double quote. We reject any
1094
+ // `$` or backtick inside (caught at the top of the loop on the next
1095
+ // pass only if unquoted — so scan the run explicitly here).
1096
+ const end = body.indexOf('"', i + 1);
1097
+ if (end === -1) return null; // unterminated
1098
+ const inner = body.slice(i + 1, end);
1099
+ if (inner.includes("$") || inner.includes("`")) return null;
1100
+ current += inner;
1101
+ inToken = true;
1102
+ i = end + 1;
1103
+ continue;
1104
+ }
1105
+
1106
+ // Bare character.
1107
+ current += ch;
1108
+ inToken = true;
1109
+ i += 1;
1110
+ }
1111
+ flush();
1112
+ return tokens;
1113
+ }
1114
+
1115
+ /**
1116
+ * Parse a single hand-written `alias <name>=<quote><body><quote>` line into an
1117
+ * AdoptableAlias (sans `lineRange`/`others`, which the scanner fills in), or
1118
+ * null when the line isn't a `claude`-wrapping alias we can tokenize.
1119
+ */
1120
+ function parseAdoptableLine(line: string): Omit<
1121
+ AdoptableAlias,
1122
+ "lineRange" | "others"
1123
+ > | null {
1124
+ const match = ANY_ALIAS_LINE_RE.exec(line.trim());
1125
+ if (!match) return null;
1126
+ const [, name, , body] = match;
1127
+ const tokens = tokenizeGeneralAliasBody(body);
1128
+ if (!tokens || tokens.length === 0 || tokens[0] !== "claude") return null;
1129
+
1130
+ const args = tokens.slice(1);
1131
+ const { flags, leftovers } = argsToFlagValuesWithLeftovers(args);
1132
+
1133
+ // Fixpoint: render the parsed flags back to tokens and re-parse. If the
1134
+ // result differs, a token was mis-bucketed (e.g. a select value mistaken
1135
+ // for bare). Only an exact fixpoint with no leftovers is lossless.
1136
+ const config: AliasConfig = { aliasName: name, flags: withDefaults(flags) };
1137
+ const reparsed = argsToFlagValues(renderArgsAsTokens(config));
1138
+ const fixpoint = flagsDeepEqual(withDefaults(flags), withDefaults(reparsed));
1139
+ const lossless = leftovers.length === 0 && fixpoint;
1140
+
1141
+ return { name, flags, leftovers, lossless, rawLine: line };
1142
+ }
1143
+
1144
+ /**
1145
+ * Scan rc text for a top-level, hand-written `claude`-wrapping alias OUTSIDE
1146
+ * the managed block. Returns the first such alias (with any others noted), or
1147
+ * null when none is found.
1148
+ *
1149
+ * "Top-level" = the line's indentation is zero. We deliberately skip indented
1150
+ * lines: an alias nested in a function or `if` block can't be replaced by the
1151
+ * managed block without dragging the block into that scope.
1152
+ *
1153
+ * Lines inside an existing managed block are ignored — adoption is only for
1154
+ * the no-managed-block case (the screen's mount checks that separately, but we
1155
+ * guard here too so the function is correct in isolation).
1156
+ */
1157
+ export function findAdoptableAlias(rcText: string): AdoptableAlias | null {
1158
+ const lines = rcText.split("\n");
1159
+
1160
+ // Compute the [begin, end] line span of the managed block, if present, so we
1161
+ // can exclude any alias inside it.
1162
+ let blockStart = -1;
1163
+ let blockEnd = -1;
1164
+ for (let i = 0; i < lines.length; i++) {
1165
+ if (lines[i].includes(BLOCK_BEGIN)) blockStart = i;
1166
+ else if (lines[i].includes(BLOCK_END)) {
1167
+ blockEnd = i;
1168
+ break;
1169
+ }
1170
+ }
1171
+ const insideBlock = (i: number) =>
1172
+ blockStart !== -1 && blockEnd !== -1 && i >= blockStart && i <= blockEnd;
1173
+
1174
+ let picked: AdoptableAlias | null = null;
1175
+ const others: string[] = [];
1176
+
1177
+ for (let i = 0; i < lines.length; i++) {
1178
+ const line = lines[i];
1179
+ if (insideBlock(i)) continue;
1180
+ // Top-level only: no leading whitespace.
1181
+ if (/^\s/.test(line)) continue;
1182
+ const parsed = parseAdoptableLine(line);
1183
+ if (!parsed) continue;
1184
+ if (picked === null) {
1185
+ picked = { ...parsed, lineRange: [i, i], others: [] };
1186
+ } else {
1187
+ others.push(line.trim());
1188
+ }
1189
+ }
1190
+
1191
+ if (picked) picked.others = others;
1192
+ return picked;
1193
+ }
1194
+
1195
+ /**
1196
+ * Replace a line range in `existing` (inclusive, zero-based) with `block`.
1197
+ * Used to absorb an adopted hand-written alias: the original line is removed
1198
+ * and the managed block takes its place, guaranteeing no duplicate alias.
1199
+ * Pure function — no I/O. Falls back to {@link spliceManagedBlock} semantics
1200
+ * (append) when the range is out of bounds.
1201
+ */
1202
+ export function spliceManagedBlockAtRange(
1203
+ existing: string,
1204
+ block: string,
1205
+ range: [number, number],
1206
+ ): string {
1207
+ const lines = existing.split("\n");
1208
+ const [start, end] = range;
1209
+ if (start < 0 || end >= lines.length || start > end) {
1210
+ return spliceManagedBlock(existing, block);
1211
+ }
1212
+ // `block` ends with a newline; splice it as its own line(s) where the old
1213
+ // alias line was. Rejoin and drop the duplicate trailing newline `block`
1214
+ // would introduce when followed by more lines.
1215
+ const before = lines.slice(0, start);
1216
+ const after = lines.slice(end + 1);
1217
+ const blockLines = block.replace(/\n$/, "").split("\n");
1218
+ return [...before, ...blockLines, ...after].join("\n");
1219
+ }
1220
+
1221
+ /** Fill any catalog flags missing from a partial map with their defaults. */
1222
+ function withDefaults(
1223
+ partial: Record<string, FlagValue>,
1224
+ ): Record<string, FlagValue> {
1225
+ const out: Record<string, FlagValue> = {};
1226
+ for (const flag of ALIAS_FLAGS) {
1227
+ out[flag.id] = partial[flag.id] ?? defaultValueFor(flag);
1228
+ }
1229
+ return out;
1230
+ }
1231
+
1232
+ /** Deep-equal two flag maps via per-key JSON compare (small maps). */
1233
+ function flagsDeepEqual(
1234
+ a: Record<string, FlagValue>,
1235
+ b: Record<string, FlagValue>,
1236
+ ): boolean {
1237
+ const aKeys = Object.keys(a).sort();
1238
+ const bKeys = Object.keys(b).sort();
1239
+ if (aKeys.length !== bKeys.length) return false;
1240
+ for (let i = 0; i < aKeys.length; i++) {
1241
+ if (aKeys[i] !== bKeys[i]) return false;
1242
+ }
1243
+ for (const k of aKeys) {
1244
+ if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
1245
+ }
1246
+ return true;
1247
+ }
@@ -79,6 +79,66 @@ export function defaultAliasConfig(): AliasConfig {
79
79
  return { aliasName: DEFAULT_ALIAS_NAME, flags };
80
80
  }
81
81
 
82
+ // ─── Channels ⊇ dev-load (derive-on-display) ───────────────────────────
83
+ //
84
+ // `--channels` is the approved-channel allowlist; `--dangerously-load-
85
+ // development-channels` sideloads channels for local dev. A dev-loaded
86
+ // channel must also run, so at the COMMAND LINE the channels list is the
87
+ // union of both. We do NOT store that union — provenance would be lost
88
+ // (you couldn't tell an own channel from a derived one). Instead:
89
+ // - `channels.values` holds the user's OWN approved channels only.
90
+ // - The dev-load flag is the single source of truth for derived channels.
91
+ // - The editor shows them as a separate read-only section (display).
92
+ // - The writer unions `own ∪ dev-load(if enabled)` at RENDER time.
93
+ // This keeps the two lists distinct everywhere except the emitted alias.
94
+
95
+ /** Id of the channels allowlist flag. */
96
+ export const CHANNELS_FLAG_ID = "channels";
97
+ /** Id of the dev-load flag whose values are derived into channels. */
98
+ export const DEVLOAD_FLAG_ID = "dangerously-load-development-channels";
99
+
100
+ /** Read the value list off a flag value, or `[]` for non-list kinds. */
101
+ export function listValues(v: FlagValue | undefined): string[] {
102
+ return v && v.kind === "text-list" ? v.values : [];
103
+ }
104
+
105
+ /**
106
+ * Channels that `--channels` derives from the dev-load flag for DISPLAY and
107
+ * for the render-time union: the dev-load flag's values, but only when that
108
+ * flag is enabled (a disabled dev-load contributes nothing to the alias).
109
+ */
110
+ export function derivedChannelValues(
111
+ flags: Record<string, FlagValue>,
112
+ ): string[] {
113
+ const devload = flags[DEVLOAD_FLAG_ID];
114
+ if (!devload || devload.kind !== "text-list" || !devload.enabled) return [];
115
+ return devload.values;
116
+ }
117
+
118
+ /**
119
+ * Return a new flags map with `value` removed from the dev-load flag (the
120
+ * SOURCE of derived channels). Used when the user deletes a "Dev-loaded
121
+ * channels" row inside the --channels editor: the deletion must land on the
122
+ * source flag, never on `channels.values`. Disables dev-load when emptied so
123
+ * it stops emitting. Pure — leaves `channels.values` untouched.
124
+ */
125
+ export function withoutDerivedChannel(
126
+ flags: Record<string, FlagValue>,
127
+ value: string,
128
+ ): Record<string, FlagValue> {
129
+ const cur = flags[DEVLOAD_FLAG_ID];
130
+ if (!cur || cur.kind !== "text-list") return flags;
131
+ const values = cur.values.filter((v) => v !== value);
132
+ return {
133
+ ...flags,
134
+ [DEVLOAD_FLAG_ID]: {
135
+ kind: "text-list",
136
+ enabled: values.length > 0 && cur.enabled,
137
+ values,
138
+ },
139
+ };
140
+ }
141
+
82
142
  export function defaultValueFor(flag: AliasFlag): FlagValue {
83
143
  switch (flag.kind) {
84
144
  case "boolean":