claudeup 4.36.0 → 4.38.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.
- package/package.json +4 -4
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/catalog-cache-store.test.ts +271 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +722 -0
- package/src/__tests__/github-budget.test.ts +200 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1124 -0
- package/src/__tests__/styles-origins.test.ts +416 -0
- package/src/__tests__/styles-screen-state.test.ts +460 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/tabbar-layout.test.ts +62 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/data/community-styles.ts +521 -0
- package/src/main.tsx +15 -0
- package/src/services/catalog-cache-store.ts +312 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/marketplace-catalog-git.ts +170 -0
- package/src/services/marketplace-catalog.ts +95 -0
- package/src/services/marketplace-fetcher.ts +310 -87
- package/src/services/plugin-manager.ts +103 -92
- package/src/services/styles-manager.ts +1400 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/TabBar.tsx +43 -9
- package/src/ui/components/layout/ScreenLayout.tsx +19 -2
- package/src/ui/components/primitives/ActionHints.tsx +4 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
- package/src/ui/registry.ts +6 -0
- package/src/ui/renderers/pluginRenderers.tsx +39 -1
- package/src/ui/renderers/styleRenderers.tsx +809 -0
- package/src/ui/screens/PluginsScreen.tsx +138 -29
- package/src/ui/screens/StylesScreen.tsx +1089 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +124 -3
- package/src/ui/state/types.ts +76 -3
- package/src/utils/config-dir.ts +47 -0
- package/src/utils/open-file.ts +84 -0
package/src/ui/screens/index.ts
CHANGED
|
@@ -5,5 +5,6 @@ export { SettingsScreen } from "./EnvVarsScreen.js";
|
|
|
5
5
|
export { CliToolsScreen } from "./CliToolsScreen.js";
|
|
6
6
|
export { ProfilesScreen } from "./ProfilesScreen.js";
|
|
7
7
|
export { SkillsScreen } from "./SkillsScreen.js";
|
|
8
|
+
export { StylesScreen } from "./StylesScreen.js";
|
|
8
9
|
export { GitignoreScreen } from "./GitignoreScreen.js";
|
|
9
10
|
export { AliasScreen } from "./AliasScreen.js";
|
package/src/ui/state/reducer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AppAction, AppState } from "./types.js";
|
|
2
|
+
import { asyncValue } from "./types.js";
|
|
2
3
|
|
|
3
4
|
export const initialState: AppState = {
|
|
4
5
|
// Navigation - start on plugins screen
|
|
@@ -60,6 +61,15 @@ export const initialState: AppState = {
|
|
|
60
61
|
skills: { status: "idle" },
|
|
61
62
|
updateStatus: null,
|
|
62
63
|
},
|
|
64
|
+
|
|
65
|
+
styles: {
|
|
66
|
+
selectedIndex: 0,
|
|
67
|
+
searchQuery: "",
|
|
68
|
+
snapshot: { status: "idle" },
|
|
69
|
+
selected: new Set(),
|
|
70
|
+
status: null,
|
|
71
|
+
isFilling: false,
|
|
72
|
+
},
|
|
63
73
|
};
|
|
64
74
|
|
|
65
75
|
export function appReducer(state: AppState, action: AppAction): AppState {
|
|
@@ -137,8 +147,16 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
137
147
|
...state,
|
|
138
148
|
plugins: {
|
|
139
149
|
...state.plugins,
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
// Hand the previous list forward so a reload refreshes in place
|
|
151
|
+
// instead of replacing the screen with "Loading...".
|
|
152
|
+
marketplaces: {
|
|
153
|
+
status: "loading",
|
|
154
|
+
previous: asyncValue(state.plugins.marketplaces),
|
|
155
|
+
},
|
|
156
|
+
plugins: {
|
|
157
|
+
status: "loading",
|
|
158
|
+
previous: asyncValue(state.plugins.plugins),
|
|
159
|
+
},
|
|
142
160
|
},
|
|
143
161
|
};
|
|
144
162
|
|
|
@@ -477,6 +495,109 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
477
495
|
};
|
|
478
496
|
}
|
|
479
497
|
|
|
498
|
+
// =========================================================================
|
|
499
|
+
// Styles screen
|
|
500
|
+
// =========================================================================
|
|
501
|
+
case "STYLES_SELECT":
|
|
502
|
+
return {
|
|
503
|
+
...state,
|
|
504
|
+
styles: { ...state.styles, selectedIndex: action.index },
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
case "STYLES_SET_SEARCH":
|
|
508
|
+
return {
|
|
509
|
+
...state,
|
|
510
|
+
styles: { ...state.styles, searchQuery: action.query },
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
case "STYLES_SEARCH_APPEND":
|
|
514
|
+
return {
|
|
515
|
+
...state,
|
|
516
|
+
styles: {
|
|
517
|
+
...state.styles,
|
|
518
|
+
searchQuery: state.styles.searchQuery + action.char,
|
|
519
|
+
selectedIndex: 0,
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
case "STYLES_SEARCH_BACKSPACE":
|
|
524
|
+
return {
|
|
525
|
+
...state,
|
|
526
|
+
styles: {
|
|
527
|
+
...state.styles,
|
|
528
|
+
searchQuery: state.styles.searchQuery.slice(0, -1),
|
|
529
|
+
selectedIndex: 0,
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
case "STYLES_DATA_LOADING":
|
|
534
|
+
return {
|
|
535
|
+
...state,
|
|
536
|
+
styles: { ...state.styles, snapshot: { status: "loading" } },
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
case "STYLES_DATA_SUCCESS":
|
|
540
|
+
return {
|
|
541
|
+
...state,
|
|
542
|
+
styles: {
|
|
543
|
+
...state.styles,
|
|
544
|
+
snapshot: { status: "success", data: action.snapshot },
|
|
545
|
+
// Seed from the project's committed declaration when there is
|
|
546
|
+
// one, falling back to whatever is live locally.
|
|
547
|
+
//
|
|
548
|
+
// The declaration wins deliberately. It is what the project asked
|
|
549
|
+
// for; the local artifact is only what this machine last did. When
|
|
550
|
+
// the two differ the screen says "press a to re-apply" — and that
|
|
551
|
+
// sentence is a lie if `a` would re-apply the stale local set
|
|
552
|
+
// instead of the pulled one.
|
|
553
|
+
selected: new Set(
|
|
554
|
+
action.snapshot.declaration
|
|
555
|
+
? [
|
|
556
|
+
...action.snapshot.declaration.presets,
|
|
557
|
+
...action.snapshot.declaration.imports,
|
|
558
|
+
]
|
|
559
|
+
: [
|
|
560
|
+
...(action.snapshot.applied?.presets ?? []),
|
|
561
|
+
...(action.snapshot.applied?.imports ?? []),
|
|
562
|
+
],
|
|
563
|
+
),
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
case "STYLES_DATA_ERROR":
|
|
568
|
+
return {
|
|
569
|
+
...state,
|
|
570
|
+
styles: {
|
|
571
|
+
...state.styles,
|
|
572
|
+
snapshot: { status: "error", error: action.error },
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
case "STYLES_STATUS_SET":
|
|
577
|
+
return { ...state, styles: { ...state.styles, status: action.status } };
|
|
578
|
+
|
|
579
|
+
case "STYLES_STATUS_CLEAR":
|
|
580
|
+
return { ...state, styles: { ...state.styles, status: null } };
|
|
581
|
+
|
|
582
|
+
case "STYLES_FILL_START":
|
|
583
|
+
return { ...state, styles: { ...state.styles, isFilling: true } };
|
|
584
|
+
|
|
585
|
+
case "STYLES_FILL_END":
|
|
586
|
+
return { ...state, styles: { ...state.styles, isFilling: false } };
|
|
587
|
+
|
|
588
|
+
case "STYLES_TOGGLE": {
|
|
589
|
+
const selected = new Set(state.styles.selected);
|
|
590
|
+
if (selected.has(action.id)) selected.delete(action.id);
|
|
591
|
+
else selected.add(action.id);
|
|
592
|
+
return { ...state, styles: { ...state.styles, selected } };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
case "STYLES_SET_SELECTION":
|
|
596
|
+
return {
|
|
597
|
+
...state,
|
|
598
|
+
styles: { ...state.styles, selected: new Set(action.ids) },
|
|
599
|
+
};
|
|
600
|
+
|
|
480
601
|
// =========================================================================
|
|
481
602
|
// Modals
|
|
482
603
|
// =========================================================================
|
package/src/ui/state/types.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import type { PluginInfo } from "../../services/plugin-manager.js";
|
|
2
|
+
import type { VersionMismatchInfo } from "../../services/plugin-version-check.js";
|
|
3
|
+
import type { StylesSnapshot } from "../../services/styles-manager.js";
|
|
1
4
|
import type {
|
|
2
5
|
Marketplace,
|
|
3
6
|
McpServer,
|
|
4
7
|
ProfileEntry,
|
|
5
8
|
SkillInfo,
|
|
6
9
|
} from "../../types/index.js";
|
|
7
|
-
import type { PluginInfo } from "../../services/plugin-manager.js";
|
|
8
|
-
import type { VersionMismatchInfo } from "../../services/plugin-version-check.js";
|
|
9
10
|
|
|
10
11
|
// ============================================================================
|
|
11
12
|
// Route Types
|
|
@@ -19,6 +20,7 @@ export type Screen =
|
|
|
19
20
|
| "cli-tools"
|
|
20
21
|
| "profiles"
|
|
21
22
|
| "skills"
|
|
23
|
+
| "styles"
|
|
22
24
|
| "gitignore"
|
|
23
25
|
| "alias";
|
|
24
26
|
|
|
@@ -30,6 +32,7 @@ export type Route =
|
|
|
30
32
|
| { screen: "cli-tools" }
|
|
31
33
|
| { screen: "profiles" }
|
|
32
34
|
| { screen: "skills" }
|
|
35
|
+
| { screen: "styles" }
|
|
33
36
|
| { screen: "gitignore" }
|
|
34
37
|
| { screen: "alias" };
|
|
35
38
|
|
|
@@ -39,10 +42,25 @@ export type Route =
|
|
|
39
42
|
|
|
40
43
|
export type AsyncData<T> =
|
|
41
44
|
| { status: "idle" }
|
|
42
|
-
|
|
45
|
+
/**
|
|
46
|
+
* `previous` carries the last successful value through a reload.
|
|
47
|
+
*
|
|
48
|
+
* Without it a refetch is indistinguishable from a first load, so a screen
|
|
49
|
+
* that already had data blanked itself to "Loading..." every time it
|
|
50
|
+
* remounted — and the router unmounts screens on every tab switch. Optional so
|
|
51
|
+
* that a genuine first load is still just `{ status: "loading" }`.
|
|
52
|
+
*/
|
|
53
|
+
| { status: "loading"; previous?: T }
|
|
43
54
|
| { status: "success"; data: T }
|
|
44
55
|
| { status: "error"; error: Error };
|
|
45
56
|
|
|
57
|
+
/** The value to render: fresh if loaded, else the last good one during a reload. */
|
|
58
|
+
export function asyncValue<T>(d: AsyncData<T>): T | undefined {
|
|
59
|
+
if (d.status === "success") return d.data;
|
|
60
|
+
if (d.status === "loading") return d.previous;
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
// ============================================================================
|
|
47
65
|
// Modal Types
|
|
48
66
|
// ============================================================================
|
|
@@ -176,6 +194,38 @@ export interface SkillsScreenState {
|
|
|
176
194
|
updateStatus: Map<string, boolean> | null;
|
|
177
195
|
}
|
|
178
196
|
|
|
197
|
+
export interface StylesScreenState {
|
|
198
|
+
selectedIndex: number;
|
|
199
|
+
searchQuery: string;
|
|
200
|
+
/** Presets + importable styles + what is currently live, in one read. */
|
|
201
|
+
snapshot: AsyncData<StylesSnapshot>;
|
|
202
|
+
/**
|
|
203
|
+
* Pending selection, by source id. Staged rather than applied on each
|
|
204
|
+
* keystroke: composing writes a file and flips the harness's active output
|
|
205
|
+
* style, which is not something a stray Space press should do.
|
|
206
|
+
*/
|
|
207
|
+
selected: Set<string>;
|
|
208
|
+
/**
|
|
209
|
+
* The status line's current message, or null for the default line.
|
|
210
|
+
*
|
|
211
|
+
* App state for the same reason `isFilling` is: `Router` swaps the component
|
|
212
|
+
* type on a tab change, so anything held in the screen's own `useState` is
|
|
213
|
+
* destroyed by `9 → 1 → 9`. It also stops being cleared on a wall clock —
|
|
214
|
+
* the message now survives until the next action replaces it, so the last
|
|
215
|
+
* thing you did is still on screen whenever you look.
|
|
216
|
+
*/
|
|
217
|
+
status: { text: string; tone: "success" | "error" } | null;
|
|
218
|
+
/**
|
|
219
|
+
* True while a template fill (Claude Code over the whole project) is running.
|
|
220
|
+
*
|
|
221
|
+
* App state, not screen state, because the screen UNMOUNTS when you switch
|
|
222
|
+
* tabs. A fill runs for minutes, so switching away is the normal thing to do
|
|
223
|
+
* — and with the flag held locally the returning user saw no sign one was in
|
|
224
|
+
* flight and could launch a second subprocess on top of the first.
|
|
225
|
+
*/
|
|
226
|
+
isFilling: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
179
229
|
// ============================================================================
|
|
180
230
|
// App State
|
|
181
231
|
// ============================================================================
|
|
@@ -209,6 +259,7 @@ export interface AppState {
|
|
|
209
259
|
cliTools: CliToolsScreenState;
|
|
210
260
|
profiles: ProfilesScreenState;
|
|
211
261
|
skills: SkillsScreenState;
|
|
262
|
+
styles: StylesScreenState;
|
|
212
263
|
}
|
|
213
264
|
|
|
214
265
|
// ============================================================================
|
|
@@ -297,5 +348,27 @@ export type AppAction =
|
|
|
297
348
|
| { type: "SKILLS_UPDATE_STATUS"; updates: Map<string, boolean> }
|
|
298
349
|
| { type: "SKILLS_UPDATE_ITEM"; name: string; updates: Partial<SkillInfo> }
|
|
299
350
|
|
|
351
|
+
// Styles screen
|
|
352
|
+
| { type: "STYLES_SELECT"; index: number }
|
|
353
|
+
| { type: "STYLES_SET_SEARCH"; query: string }
|
|
354
|
+
// Append/backspace are computed IN the reducer, from current state. A
|
|
355
|
+
// screen-side `searchQuery + char` reads a value captured at render time, so
|
|
356
|
+
// keystrokes arriving faster than React re-renders all overwrite each other
|
|
357
|
+
// and typing "slop" leaves just "p".
|
|
358
|
+
| { type: "STYLES_SEARCH_APPEND"; char: string }
|
|
359
|
+
| { type: "STYLES_SEARCH_BACKSPACE" }
|
|
360
|
+
| { type: "STYLES_DATA_LOADING" }
|
|
361
|
+
| { type: "STYLES_DATA_SUCCESS"; snapshot: StylesSnapshot }
|
|
362
|
+
| { type: "STYLES_DATA_ERROR"; error: Error }
|
|
363
|
+
| { type: "STYLES_TOGGLE"; id: string }
|
|
364
|
+
| {
|
|
365
|
+
type: "STYLES_STATUS_SET";
|
|
366
|
+
status: { text: string; tone: "success" | "error" };
|
|
367
|
+
}
|
|
368
|
+
| { type: "STYLES_STATUS_CLEAR" }
|
|
369
|
+
| { type: "STYLES_FILL_START" }
|
|
370
|
+
| { type: "STYLES_FILL_END" }
|
|
371
|
+
| { type: "STYLES_SET_SELECTION"; ids: string[] }
|
|
372
|
+
|
|
300
373
|
// Data refresh - triggers screens to refetch
|
|
301
374
|
| { type: "DATA_REFRESH_COMPLETE" };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-dir.ts — where Claude Code's config lives, with a test guard.
|
|
3
|
+
*
|
|
4
|
+
* `CLAUDE_CONFIG_DIR` is Claude Code's own override and must be honoured. It is
|
|
5
|
+
* resolved per call rather than captured at import time, because a module-level
|
|
6
|
+
* `os.homedir()` bakes in the operator's real directory and cannot be overridden
|
|
7
|
+
* afterwards.
|
|
8
|
+
*
|
|
9
|
+
* The test guard
|
|
10
|
+
* --------------
|
|
11
|
+
* Under `bun test`, a missing `CLAUDE_CONFIG_DIR` returns null instead of
|
|
12
|
+
* `~/.claude`. Callers treat null as "no cache, no clone" — an empty, inert state.
|
|
13
|
+
*
|
|
14
|
+
* This is not defensive decoration. Once the catalog cache and the rate-limit
|
|
15
|
+
* cooldown moved to disk, and the catalog resolver gained a `git fetch` fallback,
|
|
16
|
+
* any test that had not isolated its config dir began reading the operator's real
|
|
17
|
+
* cooldown file and fetching from their real marketplace clones. The suite went
|
|
18
|
+
* from 6s to 59s and started failing intermittently, because results depended on
|
|
19
|
+
* whether this particular machine happened to be rate-limited at that moment.
|
|
20
|
+
*
|
|
21
|
+
* A test that forgets to isolate should get nothing, not the developer's live
|
|
22
|
+
* state. Production is unaffected: NODE_ENV is not "test" there.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The Claude config directory, or null when a test has not chosen one.
|
|
30
|
+
*
|
|
31
|
+
* Callers that must always have a path (writing real user config) should use
|
|
32
|
+
* `requireClaudeConfigDir`. Callers holding regenerable state — caches — should
|
|
33
|
+
* treat null as "cache unavailable" and carry on.
|
|
34
|
+
*/
|
|
35
|
+
export function claudeConfigDirOrNull(): string | null {
|
|
36
|
+
const explicit = process.env.CLAUDE_CONFIG_DIR;
|
|
37
|
+
if (explicit) return explicit;
|
|
38
|
+
if (process.env.NODE_ENV === "test") return null;
|
|
39
|
+
return path.join(os.homedir(), ".claude");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The Claude config directory, falling back to `~/.claude` even under test. */
|
|
43
|
+
export function requireClaudeConfigDir(): string {
|
|
44
|
+
return (
|
|
45
|
+
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand a file or URL to the operating system's default application.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists rather than launching `$EDITOR`: claudeup owns the terminal.
|
|
5
|
+
* Suspending an OpenTUI app to give the TTY to a full-screen editor and then
|
|
6
|
+
* restoring raw mode, the alternate screen buffer and the cursor is a reliable
|
|
7
|
+
* way to leave the terminal wedged if anything goes wrong in between. Handing
|
|
8
|
+
* the path to the OS launches a separate process that never touches our TTY.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
export interface Opener {
|
|
14
|
+
command: string;
|
|
15
|
+
/** Arguments that precede the target path. */
|
|
16
|
+
args: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The platform's "open this with whatever handles it" command.
|
|
21
|
+
*
|
|
22
|
+
* claudeup ships darwin-arm64, darwin-x64 and linux-x64, so macOS and Linux
|
|
23
|
+
* both have to work — the existing `open "${url}"` call in the Skills screen is
|
|
24
|
+
* macOS-only and silently does nothing on Linux.
|
|
25
|
+
*/
|
|
26
|
+
export function openerFor(platform: NodeJS.Platform): Opener {
|
|
27
|
+
if (platform === "darwin") return { command: "open", args: [] };
|
|
28
|
+
// `start` is a cmd.exe builtin, and its first quoted argument is the window
|
|
29
|
+
// title — hence the empty string, or a quoted path would be consumed as one.
|
|
30
|
+
if (platform === "win32")
|
|
31
|
+
return { command: "cmd", args: ["/c", "start", ""] };
|
|
32
|
+
return { command: "xdg-open", args: [] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Open `target` in the default application, without blocking the TUI.
|
|
37
|
+
*
|
|
38
|
+
* The child is spawned WITHOUT a shell and the target is passed as its own
|
|
39
|
+
* argv entry, so a filename containing a space, quote or `$(…)` is opened
|
|
40
|
+
* rather than interpreted. Resolves once the process has been spawned, not
|
|
41
|
+
* when it exits — the viewer outlives us by design.
|
|
42
|
+
*/
|
|
43
|
+
export function openInDefaultApp(
|
|
44
|
+
target: string,
|
|
45
|
+
platform: NodeJS.Platform = process.platform,
|
|
46
|
+
/**
|
|
47
|
+
* Test seam. The default is the platform's real opener; a test passes a
|
|
48
|
+
* command that cannot exist, so the missing-opener path is exercised
|
|
49
|
+
* deterministically on every OS — CI runners ship `xdg-open`, so "assume
|
|
50
|
+
* the opener is absent" fails exactly there, and "spawn the real one"
|
|
51
|
+
* launches applications on a build machine.
|
|
52
|
+
*/
|
|
53
|
+
opener: Opener = openerFor(platform),
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const { command, args } = opener;
|
|
56
|
+
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
let settled = false;
|
|
59
|
+
const child = spawn(command, [...args, target], {
|
|
60
|
+
detached: true,
|
|
61
|
+
stdio: "ignore",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
child.once("error", (error: NodeJS.ErrnoException) => {
|
|
65
|
+
if (settled) return;
|
|
66
|
+
settled = true;
|
|
67
|
+
reject(
|
|
68
|
+
error.code === "ENOENT"
|
|
69
|
+
? new Error(
|
|
70
|
+
`No way to open files here — "${command}" is not on PATH.`,
|
|
71
|
+
)
|
|
72
|
+
: error,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
child.once("spawn", () => {
|
|
77
|
+
if (settled) return;
|
|
78
|
+
settled = true;
|
|
79
|
+
// Detach so claudeup exiting does not take the editor with it.
|
|
80
|
+
child.unref();
|
|
81
|
+
resolve();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|