magus-cli 7.3.1 → 7.4.1
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/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +120 -15
- package/src/cli/install.ts +35 -4
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/data/cli-tools.ts +9 -1
- package/src/services/claude-settings.ts +434 -198
- package/src/services/cli-tool-commands.ts +45 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/gitignore-service.ts +39 -22
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +164 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +316 -44
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +107 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +304 -0
- package/src/ui/input/key-dispatch.ts +197 -0
- package/src/ui/keymap.ts +905 -0
- package/src/ui/renderers/cliToolRenderers.tsx +192 -92
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +173 -101
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +129 -129
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- package/src/ui/hooks/useKeyboardHandler.ts +0 -63
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
uninstallPlugin as cliUninstallPlugin,
|
|
13
13
|
} from "../../services/claude-cli.js";
|
|
14
14
|
import {
|
|
15
|
+
SettingsParseError,
|
|
15
16
|
getMcpEnvVars,
|
|
16
17
|
readSettings,
|
|
17
18
|
setMcpEnvVar,
|
|
@@ -68,6 +69,7 @@ import {
|
|
|
68
69
|
saveSeenVersions,
|
|
69
70
|
} from "../../services/version-snapshot.js";
|
|
70
71
|
import { fuzzyFilter } from "../../utils/fuzzy-search.js";
|
|
72
|
+
import { createRequestGate } from "../../utils/request-gate.js";
|
|
71
73
|
import { buildCatalogNoticeText } from "../adapters/catalogNotice.js";
|
|
72
74
|
import {
|
|
73
75
|
type PluginBrowserItem,
|
|
@@ -80,7 +82,8 @@ import {
|
|
|
80
82
|
import { EmptyFilterState } from "../components/EmptyFilterState.js";
|
|
81
83
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
82
84
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
83
|
-
import {
|
|
85
|
+
import { useScreenKeys, useSearchInput } from "../input/KeyRouter.js";
|
|
86
|
+
import { type BindingId, moveDirection, tabKey } from "../keymap.js";
|
|
84
87
|
import {
|
|
85
88
|
renderPluginDetail,
|
|
86
89
|
renderPluginRow,
|
|
@@ -90,6 +93,17 @@ import { useDimensions } from "../state/DimensionsContext.js";
|
|
|
90
93
|
import { asyncValue } from "../state/types.js";
|
|
91
94
|
import { theme } from "../theme.js";
|
|
92
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The latest Plugins load wins.
|
|
98
|
+
*
|
|
99
|
+
* A load starts on mount and again on every `DATA_REFRESH_COMPLETE` — at
|
|
100
|
+
* startup that is two loads racing, and whichever finished LAST used to be the
|
|
101
|
+
* one on screen, often the older. Module scope rather than a ref, because the
|
|
102
|
+
* router remounts this screen on every tab switch and a load begun by the
|
|
103
|
+
* previous mount still has to lose to the new one.
|
|
104
|
+
*/
|
|
105
|
+
const pluginsLoadGate = createRequestGate();
|
|
106
|
+
|
|
93
107
|
/** Wraps the notice text in the warning-coloured row ScreenLayout renders. */
|
|
94
108
|
function buildCatalogNotice(
|
|
95
109
|
input: Parameters<typeof buildCatalogNoticeText>[0],
|
|
@@ -107,9 +121,7 @@ export function PluginsScreen() {
|
|
|
107
121
|
const dimensions = useDimensions();
|
|
108
122
|
|
|
109
123
|
const isSearchActive =
|
|
110
|
-
state.isSearching &&
|
|
111
|
-
state.currentRoute.screen === "plugins" &&
|
|
112
|
-
!state.modal;
|
|
124
|
+
state.isSearching && state.currentRoute.screen === "plugins";
|
|
113
125
|
|
|
114
126
|
/**
|
|
115
127
|
* Catalogs that could not be read from any authoritative source.
|
|
@@ -136,20 +148,28 @@ export function PluginsScreen() {
|
|
|
136
148
|
Readonly<Record<string, string>> | null | undefined
|
|
137
149
|
>(undefined);
|
|
138
150
|
|
|
139
|
-
// Fetch data (always fetches all scopes)
|
|
151
|
+
// Fetch data (always fetches all scopes). Only the most recently started
|
|
152
|
+
// load may touch the screen — or the seen-versions baseline, which a stale
|
|
153
|
+
// load would rewind.
|
|
140
154
|
const fetchData = useCallback(async () => {
|
|
155
|
+
const token = pluginsLoadGate.begin();
|
|
141
156
|
dispatch({ type: "PLUGINS_DATA_LOADING" });
|
|
142
157
|
try {
|
|
143
158
|
const localMarketplaces = await getLocalMarketplacesInfo();
|
|
144
159
|
const allMarketplaces = getAllMarketplaces(localMarketplaces);
|
|
145
|
-
// Read-only,
|
|
146
|
-
//
|
|
160
|
+
// Read-only, and it throws for exactly one thing: a settings file or an
|
|
161
|
+
// installed_plugins.json that exists and cannot be read. That lands in
|
|
162
|
+
// the catch below and is shown on this screen, which is the point —
|
|
163
|
+
// "nothing installed" would be a lie about a file nobody could read.
|
|
164
|
+
// `resolveBins: false`: this screen needs only the plugin pins, not
|
|
165
|
+
// every declared plugin's binary requirements.
|
|
147
166
|
const [pluginData, active] = await Promise.all([
|
|
148
167
|
getAvailablePlugins(state.projectPath),
|
|
149
168
|
loadActiveClosure(state.projectPath || process.cwd(), {
|
|
150
169
|
resolveBins: false,
|
|
151
170
|
}),
|
|
152
171
|
]);
|
|
172
|
+
if (!pluginsLoadGate.isCurrent(token)) return;
|
|
153
173
|
setPins(active?.closure.plugins ?? null);
|
|
154
174
|
|
|
155
175
|
// Which catalogs answered, and which only appeared to. Read straight
|
|
@@ -178,6 +198,7 @@ export function PluginsScreen() {
|
|
|
178
198
|
}
|
|
179
199
|
const projectPath = state.projectPath || process.cwd();
|
|
180
200
|
const previous = await loadSeenVersions(projectPath);
|
|
201
|
+
if (!pluginsLoadGate.isCurrent(token)) return;
|
|
181
202
|
for (const change of diffVersions(previous, current)) {
|
|
182
203
|
const target = pluginData.find((p) => p.id === change.pluginId);
|
|
183
204
|
if (!target) continue;
|
|
@@ -191,6 +212,7 @@ export function PluginsScreen() {
|
|
|
191
212
|
// refresh or a post-action refetch could load the baseline before the
|
|
192
213
|
// previous save landed and re-report the same change.
|
|
193
214
|
await saveSeenVersions(projectPath, current);
|
|
215
|
+
if (!pluginsLoadGate.isCurrent(token)) return;
|
|
194
216
|
|
|
195
217
|
dispatch({
|
|
196
218
|
type: "PLUGINS_DATA_SUCCESS",
|
|
@@ -198,6 +220,7 @@ export function PluginsScreen() {
|
|
|
198
220
|
plugins: pluginData,
|
|
199
221
|
});
|
|
200
222
|
} catch (error) {
|
|
223
|
+
if (!pluginsLoadGate.isCurrent(token)) return;
|
|
201
224
|
dispatch({
|
|
202
225
|
type: "PLUGINS_DATA_ERROR",
|
|
203
226
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
@@ -441,119 +464,79 @@ export function PluginsScreen() {
|
|
|
441
464
|
[dispatch, pluginsState.pluginContents, shownMarketplaces],
|
|
442
465
|
);
|
|
443
466
|
|
|
444
|
-
//
|
|
445
|
-
useKeyboard((event) => {
|
|
446
|
-
if (state.modal) return;
|
|
447
|
-
|
|
448
|
-
const hasQuery = pluginsState.searchQuery.length > 0;
|
|
449
|
-
|
|
450
|
-
if (event.name === "escape") {
|
|
451
|
-
if (hasQuery || isSearchActive) {
|
|
452
|
-
dispatch({ type: "PLUGINS_SET_SEARCH", query: "" });
|
|
453
|
-
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
454
|
-
dispatch({ type: "PLUGINS_SELECT", index: 0 });
|
|
455
|
-
}
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
467
|
+
// ── Keys ──────────────────────────────────────────────────────────────────
|
|
458
468
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
dispatch({ type: "PLUGINS_SELECT", index: 0 });
|
|
464
|
-
if (newQuery.length === 0) {
|
|
465
|
-
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if (event.name === "up" || event.name === "k") {
|
|
472
|
-
if (isSearchActive)
|
|
473
|
-
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
474
|
-
dispatch({
|
|
475
|
-
type: "PLUGINS_SELECT",
|
|
476
|
-
index: Math.max(0, pluginsState.selectedIndex - 1),
|
|
477
|
-
});
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
if (event.name === "down" || event.name === "j") {
|
|
481
|
-
if (isSearchActive)
|
|
482
|
-
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
483
|
-
dispatch({
|
|
484
|
-
type: "PLUGINS_SELECT",
|
|
485
|
-
index: Math.min(
|
|
486
|
-
selectableItems.length - 1,
|
|
487
|
-
pluginsState.selectedIndex + 1,
|
|
488
|
-
),
|
|
489
|
-
});
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
469
|
+
const setQuery = (query: string) => {
|
|
470
|
+
dispatch({ type: "PLUGINS_SET_SEARCH", query });
|
|
471
|
+
dispatch({ type: "PLUGINS_SELECT", index: 0 });
|
|
472
|
+
};
|
|
492
473
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}
|
|
474
|
+
// The search box, while it has focus: digits, j/k, q and ? are typed here.
|
|
475
|
+
useSearchInput({
|
|
476
|
+
focused: isSearchActive,
|
|
477
|
+
query: pluginsState.searchQuery,
|
|
478
|
+
append: (char) => setQuery(pluginsState.searchQuery + char),
|
|
479
|
+
erase: () => setQuery(pluginsState.searchQuery.slice(0, -1)),
|
|
480
|
+
clear: () => setQuery(""),
|
|
481
|
+
blur: () => dispatch({ type: "SET_SEARCHING", isSearching: false }),
|
|
482
|
+
});
|
|
501
483
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
484
|
+
useScreenKeys("plugins", {
|
|
485
|
+
// Three keys, one binding: the footer reads "u/p/l toggle".
|
|
486
|
+
scope: (key) => {
|
|
487
|
+
void handleScopeToggle(
|
|
488
|
+
key.name === "u" ? "user" : key.name === "p" ? "project" : "local",
|
|
489
|
+
);
|
|
490
|
+
},
|
|
491
|
+
update: () => {
|
|
492
|
+
void handleUpdate();
|
|
493
|
+
},
|
|
494
|
+
"update-all": () => {
|
|
495
|
+
void handleUpdateAll();
|
|
496
|
+
},
|
|
497
|
+
open: () => {
|
|
498
|
+
void handleSelect();
|
|
499
|
+
},
|
|
500
|
+
expand: () => {
|
|
508
501
|
const item = selectableItems[pluginsState.selectedIndex];
|
|
509
502
|
if (item?.kind === "category") {
|
|
510
503
|
dispatch({
|
|
511
504
|
type: "PLUGINS_TOGGLE_MARKETPLACE",
|
|
512
505
|
name: item.marketplace.name,
|
|
513
506
|
});
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
if (item?.kind === "plugin" && item.isExpandable) {
|
|
507
|
+
} else if (item?.kind === "plugin" && item.isExpandable) {
|
|
517
508
|
togglePluginContents(item);
|
|
518
|
-
return;
|
|
519
509
|
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
)
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
else if (event.name === "u" && event.shift) handleUpdate();
|
|
551
|
-
else if (event.name === "u") handleScopeToggle("user");
|
|
552
|
-
else if (event.name === "p") handleScopeToggle("project");
|
|
553
|
-
else if (event.name === "l") handleScopeToggle("local");
|
|
554
|
-
else if (event.name === "d") handleRemoveDeprecated();
|
|
555
|
-
else if (event.name === "a") handleUpdateAll();
|
|
556
|
-
else if (event.name === "s") handleSaveAsProfile();
|
|
510
|
+
},
|
|
511
|
+
"save-profile": () => {
|
|
512
|
+
void handleSaveAsProfile();
|
|
513
|
+
},
|
|
514
|
+
remove: () => {
|
|
515
|
+
void handleRemoveDeprecated();
|
|
516
|
+
},
|
|
517
|
+
"add-marketplace": () => {
|
|
518
|
+
void handleShowAddMarketplaceInstructions();
|
|
519
|
+
},
|
|
520
|
+
"team-help": () => {
|
|
521
|
+
void handleShowTeamConfigHelp();
|
|
522
|
+
},
|
|
523
|
+
refresh: () => {
|
|
524
|
+
void handleRefresh();
|
|
525
|
+
},
|
|
526
|
+
search: () => dispatch({ type: "SET_SEARCHING", isSearching: true }),
|
|
527
|
+
// Only while a committed filter is set; otherwise Esc is the global back.
|
|
528
|
+
"clear-filter": () => {
|
|
529
|
+
if (!pluginsState.searchQuery) return false;
|
|
530
|
+
setQuery("");
|
|
531
|
+
return true;
|
|
532
|
+
},
|
|
533
|
+
move: (key) => {
|
|
534
|
+
const index = pluginsState.selectedIndex + moveDirection(key);
|
|
535
|
+
dispatch({
|
|
536
|
+
type: "PLUGINS_SELECT",
|
|
537
|
+
index: Math.max(0, Math.min(selectableItems.length - 1, index)),
|
|
538
|
+
});
|
|
539
|
+
},
|
|
557
540
|
});
|
|
558
541
|
|
|
559
542
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
@@ -713,7 +696,7 @@ export function PluginsScreen() {
|
|
|
713
696
|
if (!wantToConfigure) {
|
|
714
697
|
await modal.message(
|
|
715
698
|
"Skipped Configuration",
|
|
716
|
-
|
|
699
|
+
`You can configure these variables later in the Settings tab (press ${tabKey("settings")}).`,
|
|
717
700
|
"info",
|
|
718
701
|
);
|
|
719
702
|
return true;
|
|
@@ -741,7 +724,7 @@ export function PluginsScreen() {
|
|
|
741
724
|
if (value === null) {
|
|
742
725
|
await modal.message(
|
|
743
726
|
"Configuration Incomplete",
|
|
744
|
-
|
|
727
|
+
`Skipped remaining configuration.\nYou can configure these later in the Settings tab (press ${tabKey("settings")}).`,
|
|
745
728
|
"info",
|
|
746
729
|
);
|
|
747
730
|
return true;
|
|
@@ -754,7 +737,18 @@ export function PluginsScreen() {
|
|
|
754
737
|
|
|
755
738
|
return true;
|
|
756
739
|
} catch (error) {
|
|
757
|
-
|
|
740
|
+
// The plugin itself is installed either way. But a settings.local.json
|
|
741
|
+
// that does not parse is something the user has to fix by hand before
|
|
742
|
+
// any variable can be saved, so it is said, not logged out of sight.
|
|
743
|
+
if (error instanceof SettingsParseError) {
|
|
744
|
+
await modal.message(
|
|
745
|
+
"Environment variables not saved",
|
|
746
|
+
error.message,
|
|
747
|
+
"error",
|
|
748
|
+
);
|
|
749
|
+
} else {
|
|
750
|
+
console.error("Error collecting plugin env vars:", error);
|
|
751
|
+
}
|
|
758
752
|
return true;
|
|
759
753
|
}
|
|
760
754
|
};
|
|
@@ -1251,8 +1245,20 @@ export function PluginsScreen() {
|
|
|
1251
1245
|
};
|
|
1252
1246
|
|
|
1253
1247
|
const handleSaveAsProfile = async () => {
|
|
1254
|
-
|
|
1255
|
-
|
|
1248
|
+
// Inside a catch: this runs fire-and-forget from a keypress, so a rejected
|
|
1249
|
+
// read would be an unhandled rejection — which ends the app.
|
|
1250
|
+
let enabledPlugins: Record<string, boolean>;
|
|
1251
|
+
try {
|
|
1252
|
+
enabledPlugins =
|
|
1253
|
+
(await readSettings(state.projectPath)).enabledPlugins ?? {};
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
await modal.message(
|
|
1256
|
+
"Error",
|
|
1257
|
+
`Cannot save a profile: ${error instanceof Error ? error.message : String(error)}`,
|
|
1258
|
+
"error",
|
|
1259
|
+
);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1256
1262
|
|
|
1257
1263
|
const name = await modal.input("Save Profile", "Profile name:");
|
|
1258
1264
|
if (name === null || !name.trim()) return;
|
|
@@ -1281,7 +1287,7 @@ export function PluginsScreen() {
|
|
|
1281
1287
|
modal.hideModal();
|
|
1282
1288
|
await modal.message(
|
|
1283
1289
|
"Saved",
|
|
1284
|
-
`Profile "${name.trim()}" saved.\nPress
|
|
1290
|
+
`Profile "${name.trim()}" saved.\nPress ${tabKey("profiles")} to manage profiles.`,
|
|
1285
1291
|
"success",
|
|
1286
1292
|
);
|
|
1287
1293
|
} catch (error) {
|
|
@@ -1339,19 +1345,13 @@ export function PluginsScreen() {
|
|
|
1339
1345
|
|
|
1340
1346
|
const selectedItem = selectableItems[pluginsState.selectedIndex];
|
|
1341
1347
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
]
|
|
1348
|
-
|
|
1349
|
-
{ keys: ["u", "/", "p", "/", "l"], label: "toggle" },
|
|
1350
|
-
{ keys: ["U"], label: "update" },
|
|
1351
|
-
{ keys: ["a"], label: "all" },
|
|
1352
|
-
{ keys: ["s"], label: "profile" },
|
|
1353
|
-
{ keys: ["/"], label: "search" },
|
|
1354
|
-
];
|
|
1348
|
+
// Advertised only where they act; they stay bound, and help lists them.
|
|
1349
|
+
const hiddenHints: BindingId<"plugins">[] = [
|
|
1350
|
+
...(pluginsState.searchQuery ? [] : ["clear-filter" as const]),
|
|
1351
|
+
...(selectedItem?.kind === "plugin" && selectedItem.plugin.isOrphaned
|
|
1352
|
+
? []
|
|
1353
|
+
: ["remove" as const]),
|
|
1354
|
+
];
|
|
1355
1355
|
|
|
1356
1356
|
const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
|
|
1357
1357
|
const plugins: PluginInfo[] = shownPlugins ?? [];
|
|
@@ -1417,7 +1417,7 @@ export function PluginsScreen() {
|
|
|
1417
1417
|
query: pluginsState.searchQuery,
|
|
1418
1418
|
placeholder: searchPlaceholder,
|
|
1419
1419
|
}}
|
|
1420
|
-
|
|
1420
|
+
footer={{ hide: hiddenHints }}
|
|
1421
1421
|
listPanel={
|
|
1422
1422
|
<box flexDirection="column">
|
|
1423
1423
|
<ScrollableList
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useCallback } from "react";
|
|
2
2
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
4
|
+
import { moveDirection } from "../keymap.js";
|
|
4
5
|
import { useApp, useModal } from "../state/AppContext.js";
|
|
5
6
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
6
7
|
|
|
@@ -8,7 +9,11 @@ import {
|
|
|
8
9
|
PREDEFINED_PROFILES,
|
|
9
10
|
type PredefinedProfile,
|
|
10
11
|
} from "../../data/predefined-profiles.js";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
getSettingsPath,
|
|
14
|
+
readSettings,
|
|
15
|
+
writeSettings,
|
|
16
|
+
} from "../../services/claude-settings.js";
|
|
12
17
|
import {
|
|
13
18
|
applyProfile,
|
|
14
19
|
deleteProfile,
|
|
@@ -17,6 +22,7 @@ import {
|
|
|
17
22
|
listProfiles,
|
|
18
23
|
renameProfile,
|
|
19
24
|
} from "../../services/profiles.js";
|
|
25
|
+
import { writableMap } from "../../services/settings-file.js";
|
|
20
26
|
import {
|
|
21
27
|
ClipboardUnavailableError,
|
|
22
28
|
readClipboard,
|
|
@@ -67,43 +73,38 @@ export function ProfilesScreen() {
|
|
|
67
73
|
const isNavigable = (item: ProfileListItem | undefined) =>
|
|
68
74
|
item !== undefined && item.kind !== "header";
|
|
69
75
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (event.name === "up" || event.name === "k") {
|
|
74
|
-
let newIndex = profilesState.selectedIndex - 1;
|
|
75
|
-
while (newIndex > 0 && !isNavigable(allItems[newIndex])) {
|
|
76
|
-
newIndex--;
|
|
77
|
-
}
|
|
78
|
-
if (newIndex >= 0 && isNavigable(allItems[newIndex])) {
|
|
79
|
-
dispatch({ type: "PROFILES_SELECT", index: newIndex });
|
|
80
|
-
}
|
|
81
|
-
} else if (event.name === "down" || event.name === "j") {
|
|
82
|
-
let newIndex = profilesState.selectedIndex + 1;
|
|
83
|
-
while (
|
|
84
|
-
newIndex < allItems.length - 1 &&
|
|
85
|
-
!isNavigable(allItems[newIndex])
|
|
86
|
-
) {
|
|
87
|
-
newIndex++;
|
|
88
|
-
}
|
|
89
|
-
if (newIndex < allItems.length && isNavigable(allItems[newIndex])) {
|
|
90
|
-
dispatch({ type: "PROFILES_SELECT", index: newIndex });
|
|
91
|
-
}
|
|
92
|
-
} else if (event.name === "enter" || event.name === "a") {
|
|
76
|
+
useScreenKeys("profiles", {
|
|
77
|
+
apply: () => {
|
|
93
78
|
if (selectedItem?.kind === "predefined") {
|
|
94
79
|
void handleApplyPredefined(selectedItem.profile);
|
|
95
80
|
} else if (selectedItem?.kind === "saved") {
|
|
96
81
|
void handleApply();
|
|
97
82
|
}
|
|
98
|
-
}
|
|
83
|
+
},
|
|
84
|
+
rename: () => {
|
|
99
85
|
if (selectedItem?.kind === "saved") void handleRename();
|
|
100
|
-
}
|
|
86
|
+
},
|
|
87
|
+
delete: () => {
|
|
101
88
|
if (selectedItem?.kind === "saved") void handleDelete();
|
|
102
|
-
}
|
|
89
|
+
},
|
|
90
|
+
copy: () => {
|
|
103
91
|
if (selectedItem?.kind === "saved") void handleCopy();
|
|
104
|
-
}
|
|
92
|
+
},
|
|
93
|
+
import: () => {
|
|
105
94
|
void handleImport();
|
|
106
|
-
}
|
|
95
|
+
},
|
|
96
|
+
// Section headers are labels, not rows: step over them.
|
|
97
|
+
move: (key) => {
|
|
98
|
+
const delta = moveDirection(key);
|
|
99
|
+
let index = profilesState.selectedIndex + delta;
|
|
100
|
+
while (index >= 0 && index < allItems.length) {
|
|
101
|
+
if (isNavigable(allItems[index])) {
|
|
102
|
+
dispatch({ type: "PROFILES_SELECT", index });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
index += delta;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
107
108
|
});
|
|
108
109
|
|
|
109
110
|
// ─── Actions ──────────────────────────────────────────────────────────────
|
|
@@ -124,18 +125,23 @@ export function ProfilesScreen() {
|
|
|
124
125
|
modal.loading(`Applying "${profile.name}"...`);
|
|
125
126
|
try {
|
|
126
127
|
const settings = await readSettings(state.projectPath);
|
|
127
|
-
|
|
128
|
+
const settingsPath = getSettingsPath(state.projectPath);
|
|
129
|
+
// `writableMap` refuses a map that is present with the wrong shape; a
|
|
130
|
+
// bare `?? {}` kept an array, and the entries set on it were never saved.
|
|
131
|
+
const enabledPlugins =
|
|
132
|
+
writableMap(settings, "enabledPlugins", settingsPath) ?? {};
|
|
133
|
+
settings.enabledPlugins = enabledPlugins;
|
|
128
134
|
for (const plugin of allPlugins) {
|
|
129
|
-
if (!
|
|
130
|
-
|
|
135
|
+
if (!enabledPlugins[plugin]) {
|
|
136
|
+
enabledPlugins[plugin] = true;
|
|
131
137
|
}
|
|
132
138
|
}
|
|
133
139
|
for (const [key, value] of Object.entries(profile.settings)) {
|
|
134
140
|
if (key === "env") {
|
|
135
141
|
const envMap = value as Record<string, string>;
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
142
|
+
// `ClaudeSettings` does not declare `env`; this view types the one key read.
|
|
143
|
+
const envView = settings as { env?: Record<string, string> };
|
|
144
|
+
const existingEnv = writableMap(envView, "env", settingsPath) ?? {};
|
|
139
145
|
for (const [envKey, envVal] of Object.entries(envMap)) {
|
|
140
146
|
if (!existingEnv[envKey]) {
|
|
141
147
|
existingEnv[envKey] = envVal;
|
|
@@ -381,11 +387,11 @@ export function ProfilesScreen() {
|
|
|
381
387
|
|
|
382
388
|
const statusContent = (
|
|
383
389
|
<text fg={theme.colors.text}>
|
|
384
|
-
<span fg={theme.colors.
|
|
390
|
+
<span fg={theme.colors.info}>{PREDEFINED_PROFILES.length} presets</span>
|
|
385
391
|
<span fg={theme.colors.muted}> + </span>
|
|
386
392
|
<span fg={theme.colors.info}>{userCount} user</span>
|
|
387
393
|
<span fg={theme.colors.muted}> + </span>
|
|
388
|
-
<span fg={theme.colors.
|
|
394
|
+
<span fg={theme.colors.info}>{projCount} project</span>
|
|
389
395
|
<span fg={theme.colors.muted}> = </span>
|
|
390
396
|
<span fg={theme.colors.text}>
|
|
391
397
|
{PREDEFINED_PROFILES.length + profileCount} total
|
|
@@ -410,14 +416,11 @@ export function ProfilesScreen() {
|
|
|
410
416
|
title="magus Plugin Profiles"
|
|
411
417
|
currentScreen="profiles"
|
|
412
418
|
statusLine={statusContent}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
{ keys: ["c"], label: "copy" },
|
|
419
|
-
{ keys: ["i"], label: "import" },
|
|
420
|
-
]}
|
|
419
|
+
footer={{
|
|
420
|
+
// rename / delete / copy act on a saved profile only.
|
|
421
|
+
hide:
|
|
422
|
+
selectedItem?.kind === "saved" ? [] : ["rename", "delete", "copy"],
|
|
423
|
+
}}
|
|
421
424
|
listPanel={
|
|
422
425
|
<ScrollableList
|
|
423
426
|
items={allItems}
|