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
@@ -171,9 +171,17 @@ async function getLatestPipVersion(packageName: string): Promise<string | undefi
171
171
  async function getLatestVersion(
172
172
  tool: import("../../data/cli-tools.js").CliTool,
173
173
  ): Promise<string | undefined> {
174
- return tool.packageManager === "npm"
175
- ? getLatestNpmVersion(tool.packageName)
176
- : getLatestPipVersion(tool.packageName);
174
+ switch (tool.packageManager) {
175
+ // bun packages are published to the npm registry, so `npm view` resolves them.
176
+ case "bun":
177
+ case "npm":
178
+ return getLatestNpmVersion(tool.packageName);
179
+ case "pip":
180
+ return getLatestPipVersion(tool.packageName);
181
+ // brew/go have no uniform remote version query here — skip the check.
182
+ default:
183
+ return undefined;
184
+ }
177
185
  }
178
186
 
179
187
  function compareVersions(v1: string, v2: string): number {
@@ -486,7 +486,8 @@ export function PluginsScreen() {
486
486
  (missing.pip?.length || 0) +
487
487
  (missing.brew?.length || 0) +
488
488
  (missing.npm?.length || 0) +
489
- (missing.cargo?.length || 0) >
489
+ (missing.cargo?.length || 0) +
490
+ (missing.go?.length || 0) >
490
491
  0;
491
492
 
492
493
  if (!hasMissing) return;
@@ -497,6 +498,7 @@ export function PluginsScreen() {
497
498
  if (missing.npm?.length) parts.push(`npm: ${missing.npm.join(", ")}`);
498
499
  if (missing.cargo?.length)
499
500
  parts.push(`cargo: ${missing.cargo.join(", ")}`);
501
+ if (missing.go?.length) parts.push(`go: ${missing.go.join(", ")}`);
500
502
 
501
503
  const wantInstall = await modal.confirm(
502
504
  "Install Dependencies?",
@@ -3,7 +3,6 @@ export { McpScreen } from "./McpScreen.js";
3
3
  export { McpRegistryScreen } from "./McpRegistryScreen.js";
4
4
  export { SettingsScreen } from "./EnvVarsScreen.js";
5
5
  export { CliToolsScreen } from "./CliToolsScreen.js";
6
- export { ModelSelectorScreen } from "./ModelSelectorScreen.js";
7
6
  export { ProfilesScreen } from "./ProfilesScreen.js";
8
7
  export { SkillsScreen } from "./SkillsScreen.js";
9
8
  export { GitignoreScreen } from "./GitignoreScreen.js";
@@ -11,6 +11,7 @@ export const initialState: AppState = {
11
11
  modal: null,
12
12
  progress: null,
13
13
  isSearching: false,
14
+ detailActive: false,
14
15
 
15
16
  // Refresh counter for triggering data refetch
16
17
  dataRefreshVersion: 0,
@@ -37,13 +38,6 @@ export const initialState: AppState = {
37
38
  servers: { status: "idle" },
38
39
  },
39
40
 
40
- statusline: {
41
- scope: "project",
42
- selectedIndex: 0,
43
- presets: { status: "idle" },
44
- currentPreset: null,
45
- },
46
-
47
41
  settings: {
48
42
  selectedIndex: 0,
49
43
  values: { status: "idle" },
@@ -54,12 +48,6 @@ export const initialState: AppState = {
54
48
  tools: { status: "idle" },
55
49
  },
56
50
 
57
- modelSelector: {
58
- selectedIndex: 0,
59
- searchQuery: "",
60
- taskSize: "large",
61
- },
62
-
63
51
  profiles: {
64
52
  selectedIndex: 0,
65
53
  profiles: { status: "idle" },
@@ -83,8 +71,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
83
71
  return {
84
72
  ...state,
85
73
  currentRoute: action.route,
86
- // Clear search state when navigating away
74
+ // Clear transient sub-view state when navigating away
87
75
  isSearching: false,
76
+ detailActive: false,
88
77
  };
89
78
 
90
79
  // =========================================================================
@@ -264,63 +253,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
264
253
  },
265
254
  };
266
255
 
267
- // =========================================================================
268
- // Status Line Screen
269
- // =========================================================================
270
- case "STATUSLINE_SET_SCOPE":
271
- return {
272
- ...state,
273
- statusline: {
274
- ...state.statusline,
275
- scope: action.scope,
276
- selectedIndex: 0,
277
- presets: { status: "loading" },
278
- },
279
- };
280
-
281
- case "STATUSLINE_TOGGLE_SCOPE":
282
- return appReducer(state, {
283
- type: "STATUSLINE_SET_SCOPE",
284
- scope: state.statusline.scope === "project" ? "global" : "project",
285
- });
286
-
287
- case "STATUSLINE_SELECT":
288
- return {
289
- ...state,
290
- statusline: {
291
- ...state.statusline,
292
- selectedIndex: action.index,
293
- },
294
- };
295
-
296
- case "STATUSLINE_DATA_LOADING":
297
- return {
298
- ...state,
299
- statusline: {
300
- ...state.statusline,
301
- presets: { status: "loading" },
302
- },
303
- };
304
-
305
- case "STATUSLINE_DATA_SUCCESS":
306
- return {
307
- ...state,
308
- statusline: {
309
- ...state.statusline,
310
- presets: { status: "success", data: action.presets },
311
- currentPreset: action.currentPreset,
312
- },
313
- };
314
-
315
- case "STATUSLINE_DATA_ERROR":
316
- return {
317
- ...state,
318
- statusline: {
319
- ...state.statusline,
320
- presets: { status: "error", error: action.error },
321
- },
322
- };
323
-
324
256
  // =========================================================================
325
257
  // Settings Screen
326
258
  // =========================================================================
@@ -415,37 +347,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
415
347
  };
416
348
  }
417
349
 
418
- // =========================================================================
419
- // Model Selector Screen
420
- // =========================================================================
421
- case "MODEL_SELECTOR_SELECT":
422
- return {
423
- ...state,
424
- modelSelector: {
425
- ...state.modelSelector,
426
- selectedIndex: action.index,
427
- },
428
- };
429
-
430
- case "MODEL_SELECTOR_SET_SEARCH":
431
- return {
432
- ...state,
433
- modelSelector: {
434
- ...state.modelSelector,
435
- searchQuery: action.query,
436
- selectedIndex: 0, // Reset selection when search changes
437
- },
438
- };
439
-
440
- case "MODEL_SELECTOR_SET_TASK_SIZE":
441
- return {
442
- ...state,
443
- modelSelector: {
444
- ...state.modelSelector,
445
- taskSize: action.size,
446
- },
447
- };
448
-
449
350
  // =========================================================================
450
351
  // Profiles Screen
451
352
  // =========================================================================
@@ -603,6 +504,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
603
504
  case "SET_SEARCHING":
604
505
  return { ...state, isSearching: action.isSearching };
605
506
 
507
+ case "SET_DETAIL_ACTIVE":
508
+ return { ...state, detailActive: action.detailActive };
509
+
606
510
  // =========================================================================
607
511
  // Data Refresh
608
512
  // =========================================================================
@@ -1,7 +1,6 @@
1
1
  import type {
2
2
  Marketplace,
3
3
  McpServer,
4
- StatusLineConfig,
5
4
  ProfileEntry,
6
5
  SkillInfo,
7
6
  } from "../../types/index.js";
@@ -18,7 +17,6 @@ export type Screen =
18
17
  | "mcp-registry"
19
18
  | "settings"
20
19
  | "cli-tools"
21
- | "model-selector"
22
20
  | "profiles"
23
21
  | "skills"
24
22
  | "gitignore"
@@ -30,7 +28,6 @@ export type Route =
30
28
  | { screen: "mcp-registry"; query?: string }
31
29
  | { screen: "settings" }
32
30
  | { screen: "cli-tools" }
33
- | { screen: "model-selector" }
34
31
  | { screen: "profiles" }
35
32
  | { screen: "skills" }
36
33
  | { screen: "gitignore" }
@@ -134,13 +131,6 @@ export interface McpRegistryScreenState {
134
131
  servers: AsyncData<McpServer[]>;
135
132
  }
136
133
 
137
- export interface StatusLineScreenState {
138
- scope: "project" | "global";
139
- selectedIndex: number;
140
- presets: AsyncData<StatusLineConfig[]>;
141
- currentPreset: string | null;
142
- }
143
-
144
134
  export interface ScopedSettingValues {
145
135
  user: string | undefined;
146
136
  project: string | undefined;
@@ -171,12 +161,6 @@ export interface CliToolsScreenState {
171
161
  tools: AsyncData<CliTool[]>;
172
162
  }
173
163
 
174
- export interface ModelSelectorScreenState {
175
- selectedIndex: number;
176
- searchQuery: string;
177
- taskSize: "large" | "small";
178
- }
179
-
180
164
  export interface ProfilesScreenState {
181
165
  selectedIndex: number;
182
166
  profiles: AsyncData<ProfileEntry[]>;
@@ -207,6 +191,11 @@ export interface AppState {
207
191
  modal: ModalState | null;
208
192
  progress: ProgressState | null;
209
193
  isSearching: boolean;
194
+ // True while a screen has a focused sub-view open that owns Escape (e.g.
195
+ // the Alias tab's flag-detail editor). The global key handler skips its
196
+ // Escape→navigate branch so Escape backs out of the sub-view instead of
197
+ // jumping to the home tab. Mirrors how `modal`/`isSearching` gate keys.
198
+ detailActive: boolean;
210
199
 
211
200
  // Refresh counter - incremented when marketplace sync completes
212
201
  // Screens can watch this to know when to refetch data
@@ -216,10 +205,8 @@ export interface AppState {
216
205
  plugins: PluginsScreenState;
217
206
  mcp: McpScreenState;
218
207
  mcpRegistry: McpRegistryScreenState;
219
- statusline: StatusLineScreenState;
220
208
  settings: SettingsScreenState;
221
209
  cliTools: CliToolsScreenState;
222
- modelSelector: ModelSelectorScreenState;
223
210
  profiles: ProfilesScreenState;
224
211
  skills: SkillsScreenState;
225
212
  }
@@ -265,18 +252,6 @@ export type AppAction =
265
252
  | { type: "MCP_REGISTRY_DATA_SUCCESS"; servers: McpServer[] }
266
253
  | { type: "MCP_REGISTRY_DATA_ERROR"; error: Error }
267
254
 
268
- // Status line screen
269
- | { type: "STATUSLINE_SET_SCOPE"; scope: "project" | "global" }
270
- | { type: "STATUSLINE_TOGGLE_SCOPE" }
271
- | { type: "STATUSLINE_SELECT"; index: number }
272
- | { type: "STATUSLINE_DATA_LOADING" }
273
- | {
274
- type: "STATUSLINE_DATA_SUCCESS";
275
- presets: StatusLineConfig[];
276
- currentPreset: string | null;
277
- }
278
- | { type: "STATUSLINE_DATA_ERROR"; error: Error }
279
-
280
255
  // Settings screen
281
256
  | { type: "SETTINGS_SELECT"; index: number }
282
257
  | { type: "SETTINGS_DATA_LOADING" }
@@ -290,11 +265,6 @@ export type AppAction =
290
265
  | { type: "CLITOOLS_DATA_ERROR"; error: Error }
291
266
  | { type: "CLITOOLS_UPDATE_TOOL"; name: string; updates: Partial<CliTool> }
292
267
 
293
- // Model Selector Screen
294
- | { type: "MODEL_SELECTOR_SELECT"; index: number }
295
- | { type: "MODEL_SELECTOR_SET_SEARCH"; query: string }
296
- | { type: "MODEL_SELECTOR_SET_TASK_SIZE"; size: "large" | "small" }
297
-
298
268
  // Modals
299
269
  | { type: "SHOW_MODAL"; modal: ModalState }
300
270
  | { type: "HIDE_MODAL" }
@@ -307,6 +277,9 @@ export type AppAction =
307
277
  // Search state (blocks global key handlers)
308
278
  | { type: "SET_SEARCHING"; isSearching: boolean }
309
279
 
280
+ // Detail sub-view state (gates global Escape→navigate)
281
+ | { type: "SET_DETAIL_ACTIVE"; detailActive: boolean }
282
+
310
283
  // Profiles screen
311
284
  | { type: "PROFILES_SELECT"; index: number }
312
285
  | { type: "PROFILES_DATA_LOADING" }
@@ -1,5 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { access, realpath } from "node:fs/promises";
4
+ import { constants } from "node:fs";
3
5
 
4
6
  const execFileAsync = promisify(execFile);
5
7
 
@@ -18,3 +20,24 @@ export async function which(command: string): Promise<string | null> {
18
20
  return null;
19
21
  }
20
22
  }
23
+
24
+ /**
25
+ * Like {@link which}, but verifies the command actually RESOLVES and is
26
+ * executable — a dangling symlink (e.g. a stale npx-cache link) returns null.
27
+ * `which` alone reports the symlink's own path even when its target is gone,
28
+ * which is exactly how a plugin's binary can look "present" yet fail at runtime.
29
+ * @returns The real (symlink-resolved) executable path, or null.
30
+ */
31
+ export async function resolveExecutable(
32
+ command: string,
33
+ ): Promise<string | null> {
34
+ const p = await which(command);
35
+ if (!p) return null;
36
+ try {
37
+ const real = await realpath(p); // follows symlinks; throws if dangling
38
+ await access(real, constants.X_OK); // must be executable
39
+ return real;
40
+ } catch {
41
+ return null; // dangling link or not executable
42
+ }
43
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * File locking utilities for concurrent write protection
3
+ *
4
+ * Prevents race conditions when multiple processes modify settings files
5
+ * Uses lock files for cross-platform compatibility
6
+ */
7
+
8
+ import { open, mkdir } from 'node:fs/promises';
9
+ import { dirname } from 'node:path';
10
+ import { existsSync } from 'node:fs';
11
+ import type { FileHandle } from 'node:fs/promises';
12
+
13
+ /**
14
+ * Maximum time to wait for a lock (milliseconds)
15
+ */
16
+ const LOCK_TIMEOUT_MS = 10000;
17
+
18
+ /**
19
+ * Lock retry interval (milliseconds)
20
+ */
21
+ const LOCK_RETRY_INTERVAL_MS = 100;
22
+
23
+ /**
24
+ * Acquire exclusive lock on file and execute operation
25
+ * Uses lock files for cross-platform compatibility
26
+ *
27
+ * @param filePath - Path to file to lock
28
+ * @param operation - Async operation to execute with lock held
29
+ * @returns Result of the operation
30
+ * @throws Error if lock cannot be acquired within timeout
31
+ */
32
+ export async function withFileLock<T>(
33
+ filePath: string,
34
+ operation: () => Promise<T>,
35
+ ): Promise<T> {
36
+ const lockPath = `${filePath}.lock`;
37
+ const lockDir = dirname(lockPath);
38
+
39
+ // Ensure lock directory exists
40
+ if (!existsSync(lockDir)) {
41
+ await mkdir(lockDir, { recursive: true });
42
+ }
43
+
44
+ const startTime = Date.now();
45
+ let fileHandle: FileHandle | null = null;
46
+
47
+ // Try to acquire lock with retries
48
+ while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
49
+ try {
50
+ // Try to create lock file exclusively (fails if exists)
51
+ fileHandle = await open(lockPath, 'wx');
52
+
53
+ // Write PID to lock file for debugging
54
+ await fileHandle.write(`${process.pid}\n${new Date().toISOString()}\n`);
55
+
56
+ break;
57
+ } catch (error: unknown) {
58
+ // Lock file exists, retry after interval
59
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
60
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS));
61
+ continue;
62
+ }
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ if (fileHandle === null) {
68
+ throw new Error(
69
+ `Failed to acquire lock on ${filePath} after ${LOCK_TIMEOUT_MS}ms. ` +
70
+ `Another process may be holding the lock. Lock file: ${lockPath}`,
71
+ );
72
+ }
73
+
74
+ try {
75
+ // Execute operation with lock held
76
+ return await operation();
77
+ } finally {
78
+ // Release lock
79
+ try {
80
+ await fileHandle.close();
81
+ // Delete lock file
82
+ const fs = await import('node:fs/promises');
83
+ await fs.unlink(lockPath);
84
+ } catch (error) {
85
+ // Log error but don't throw - operation succeeded
86
+ console.error(`Warning: Failed to release lock ${lockPath}:`, error);
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Check if file is currently locked
93
+ *
94
+ * @param filePath - Path to file to check
95
+ * @returns True if lock file exists
96
+ */
97
+ export function isFileLocked(filePath: string): boolean {
98
+ const lockPath = `${filePath}.lock`;
99
+ return existsSync(lockPath);
100
+ }
101
+
102
+ /**
103
+ * Force remove lock file (use with caution)
104
+ * Only use if you're certain the lock is stale
105
+ *
106
+ * @param filePath - Path to file whose lock to remove
107
+ */
108
+ export async function forceRemoveLock(filePath: string): Promise<void> {
109
+ const lockPath = `${filePath}.lock`;
110
+ try {
111
+ const fs = await import('node:fs/promises');
112
+ await fs.unlink(lockPath);
113
+ } catch (error: unknown) {
114
+ // Ignore ENOENT (lock doesn't exist)
115
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
116
+ throw error;
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Get information about current lock
123
+ *
124
+ * @param filePath - Path to file to check
125
+ * @returns Lock info or null if not locked
126
+ */
127
+ export async function getLockInfo(
128
+ filePath: string,
129
+ ): Promise<{ pid: string; timestamp: string } | null> {
130
+ const lockPath = `${filePath}.lock`;
131
+
132
+ if (!existsSync(lockPath)) {
133
+ return null;
134
+ }
135
+
136
+ try {
137
+ const fs = await import('node:fs/promises');
138
+ const content = await fs.readFile(lockPath, 'utf-8');
139
+ const [pid, timestamp] = content.trim().split('\n');
140
+ return { pid, timestamp };
141
+ } catch (error) {
142
+ return null;
143
+ }
144
+ }
@@ -1,196 +0,0 @@
1
- /**
2
- * Catalog of `claude` CLI flags that the Alias tab can compose into a managed
3
- * shell alias. Verified against `claude --help` output and binary string scan
4
- * of the shipped Claude Code 2.1.x binary.
5
- *
6
- * The catalog drives both the UI editor for each flag and the renderer that
7
- * emits the final `alias claude='claude …'` string per shell.
8
- */
9
- /**
10
- * Tokens supported in `templated: true` flag values. Description text is
11
- * shown to the user; the actual shell-substitution code lives in the writer
12
- * because it needs per-shell variants.
13
- *
14
- * Intentionally excludes `{hash}` for v1 — its only portable form is
15
- * `od -An -N4 -tx1 /dev/urandom | tr -d ' \n'`, which looks frightening in a
16
- * shell rc file. Users who want randomness can pair this prefix with bare
17
- * `--remote-control` (which already auto-generates a unique suffix).
18
- */
19
- export const TEMPLATE_TOKENS = [
20
- { token: "{folder}", description: "current directory's basename" },
21
- { token: "{day}", description: "day of month, zero-padded (01-31)" },
22
- { token: "{month}", description: "month, zero-padded (01-12)" },
23
- { token: "{year}", description: "4-digit year" },
24
- ];
25
- export const FLAG_GROUPS = [
26
- { id: "danger", label: "Permissions" },
27
- { id: "session", label: "Session" },
28
- { id: "context", label: "Context" },
29
- { id: "channels", label: "Development channels" },
30
- { id: "debug", label: "Debug" },
31
- { id: "integration", label: "Integrations" },
32
- { id: "worktree", label: "Worktree" },
33
- ];
34
- export const ALIAS_FLAGS = [
35
- {
36
- id: "dangerously-skip-permissions",
37
- label: "Skip permission checks",
38
- flag: "--dangerously-skip-permissions",
39
- kind: "boolean",
40
- group: "danger",
41
- description: "Bypass all permission checks. Recommended only for sandboxes with no internet access.",
42
- },
43
- {
44
- id: "allow-dangerously-skip-permissions",
45
- label: "Allow skip-permissions opt-in",
46
- flag: "--allow-dangerously-skip-permissions",
47
- kind: "boolean",
48
- group: "danger",
49
- description: "Enable the skip-permissions option without making it the default.",
50
- },
51
- {
52
- id: "remote-control",
53
- label: "Remote control",
54
- flag: "--remote-control",
55
- kind: "boolean",
56
- group: "session",
57
- description: "Start an interactive session with Remote Control enabled. Claude Code auto-generates a unique session name; use --remote-control-session-name-prefix to customize it.",
58
- },
59
- {
60
- id: "remote-control-session-name-prefix",
61
- label: "Remote control name prefix",
62
- flag: "--remote-control-session-name-prefix",
63
- kind: "text",
64
- templated: true,
65
- group: "session",
66
- description: "Prefix for auto-generated Remote Control session names. Default: hostname. Supports template tokens that expand on each shell invocation: {folder} (basename of $PWD), {day}, {month}, {year}. Example: dev-{folder}-{day} → dev-myproject-15.",
67
- },
68
- {
69
- id: "ide",
70
- label: "Auto-connect to IDE",
71
- flag: "--ide",
72
- kind: "boolean",
73
- group: "integration",
74
- description: "Automatically connect to IDE on startup if exactly one valid IDE is available.",
75
- },
76
- {
77
- id: "chrome",
78
- label: "Chrome integration",
79
- flag: "--chrome",
80
- triStateOff: "--no-chrome",
81
- kind: "tri-state",
82
- group: "integration",
83
- description: "Enable or disable Claude in Chrome integration. Unset leaves the project default in place.",
84
- },
85
- {
86
- id: "effort",
87
- label: "Effort level",
88
- flag: "--effort",
89
- kind: "select",
90
- group: "session",
91
- description: "Effort level for the current session.",
92
- options: [
93
- { label: "low", value: "low" },
94
- { label: "medium", value: "medium" },
95
- { label: "high", value: "high" },
96
- { label: "xhigh", value: "xhigh" },
97
- { label: "max", value: "max" },
98
- ],
99
- },
100
- {
101
- id: "system-prompt",
102
- label: "System prompt",
103
- flag: "--system-prompt",
104
- kind: "text",
105
- group: "context",
106
- xorGroup: "system-prompt",
107
- description: "System prompt text to use for the session. Mutually exclusive with --system-prompt-file.",
108
- },
109
- {
110
- id: "system-prompt-file",
111
- label: "System prompt file",
112
- flag: "--system-prompt-file",
113
- kind: "text",
114
- group: "context",
115
- xorGroup: "system-prompt",
116
- description: "Path to a file whose contents replace the default system prompt. Mutually exclusive with --system-prompt.",
117
- },
118
- {
119
- id: "append-system-prompt",
120
- label: "Append system prompt",
121
- flag: "--append-system-prompt",
122
- kind: "text",
123
- group: "context",
124
- description: "Text appended to the default system prompt. Composes with --system-prompt.",
125
- },
126
- {
127
- id: "append-system-prompt-file",
128
- label: "Append system prompt file",
129
- flag: "--append-system-prompt-file",
130
- kind: "text",
131
- group: "context",
132
- description: "Path to a file whose contents are appended to the default system prompt.",
133
- },
134
- {
135
- id: "dangerously-load-development-channels",
136
- label: "Load development channels",
137
- flag: "--dangerously-load-development-channels",
138
- kind: "text-list",
139
- group: "channels",
140
- description: "Sideload development channels from magus plugins. Default seed: claudish (the only magus plugin currently shipping channels).",
141
- defaultValues: ["plugin:claudish@magus"],
142
- },
143
- {
144
- id: "debug",
145
- label: "Debug categories",
146
- flag: "--debug",
147
- shortFlag: "-d",
148
- kind: "multi-with-custom",
149
- group: "debug",
150
- description: "Enable debug mode with optional category filter. Pick from common categories or add custom tokens (including negations like !file).",
151
- picklist: [
152
- "api",
153
- "hooks",
154
- "mcp",
155
- "tools",
156
- "plugins",
157
- "skills",
158
- "settings",
159
- "permissions",
160
- "auth",
161
- "cache",
162
- "statsig",
163
- "file",
164
- "1p",
165
- ],
166
- defaultValues: [],
167
- },
168
- {
169
- id: "worktree",
170
- label: "Use worktree",
171
- flag: "--worktree",
172
- kind: "boolean",
173
- group: "worktree",
174
- description: "Create a git worktree for the session. Prerequisite for --tmux.",
175
- },
176
- {
177
- id: "tmux",
178
- label: "tmux session",
179
- flag: "--tmux",
180
- kind: "select",
181
- group: "worktree",
182
- requires: "worktree",
183
- description: "Create a tmux session for the worktree. Requires --worktree. iTerm2 native panes when available; classic for traditional tmux.",
184
- options: [
185
- { label: "auto (iTerm2 native)", value: "" },
186
- { label: "classic", value: "classic" },
187
- ],
188
- },
189
- ];
190
- /** Look up a flag by id. Throws if missing — keeps callers honest. */
191
- export function getFlagById(id) {
192
- const f = ALIAS_FLAGS.find((flag) => flag.id === id);
193
- if (!f)
194
- throw new Error(`Unknown alias flag id: ${id}`);
195
- return f;
196
- }