magus-cli 0.0.0-placeholder → 7.0.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/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +445 -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 +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +335 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/moved-marketplace.test.ts +86 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1373 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +520 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +207 -0
- package/src/cli/update-view.ts +526 -0
- package/src/cli/update.ts +613 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1589 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +587 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +253 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +878 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +344 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +863 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +538 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1464 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +352 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +144 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Write text to the system clipboard.
|
|
5
|
+
* macOS: uses pbcopy
|
|
6
|
+
* Linux: uses xclip
|
|
7
|
+
* Throws ClipboardUnavailableError if no clipboard tool is available.
|
|
8
|
+
*/
|
|
9
|
+
export async function writeClipboard(text: string): Promise<void> {
|
|
10
|
+
if (process.platform === "darwin") {
|
|
11
|
+
execSync("pbcopy", { input: text });
|
|
12
|
+
} else if (process.platform === "linux") {
|
|
13
|
+
execSync("xclip -selection clipboard", { input: text });
|
|
14
|
+
} else {
|
|
15
|
+
throw new ClipboardUnavailableError(
|
|
16
|
+
`Clipboard not supported on platform: ${process.platform}`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read text from the system clipboard.
|
|
23
|
+
* macOS: uses pbpaste
|
|
24
|
+
* Linux: uses xclip
|
|
25
|
+
* Throws ClipboardUnavailableError if no clipboard tool is available.
|
|
26
|
+
*/
|
|
27
|
+
export async function readClipboard(): Promise<string> {
|
|
28
|
+
if (process.platform === "darwin") {
|
|
29
|
+
return execSync("pbpaste").toString();
|
|
30
|
+
}
|
|
31
|
+
if (process.platform === "linux") {
|
|
32
|
+
return execSync("xclip -selection clipboard -o").toString();
|
|
33
|
+
}
|
|
34
|
+
throw new ClipboardUnavailableError(
|
|
35
|
+
`Clipboard not supported on platform: ${process.platform}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ClipboardUnavailableError extends Error {
|
|
40
|
+
constructor(message: string) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "ClipboardUnavailableError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Check if clipboard operations are available on this platform/system */
|
|
47
|
+
export function isClipboardAvailable(): boolean {
|
|
48
|
+
if (process.platform === "darwin") return true;
|
|
49
|
+
if (process.platform === "linux") {
|
|
50
|
+
try {
|
|
51
|
+
execSync("which xclip", { stdio: "ignore" });
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants, accessSync, statSync } from "node:fs";
|
|
3
|
+
import { access, realpath } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A command as an executable plus its already-separated arguments.
|
|
11
|
+
*
|
|
12
|
+
* The alternative — a single string handed to `/bin/sh` — has cost this
|
|
13
|
+
* codebase twice, in two different ways, and both were shipped rather than
|
|
14
|
+
* hypothetical:
|
|
15
|
+
*
|
|
16
|
+
* 1. **A word the shell could not resolve.** `pip install <pkg>` died with
|
|
17
|
+
* `/bin/sh: pip: command not found` on machines where `pip` is only an
|
|
18
|
+
* interactive-zsh alias. `services/toolchain.ts` fixed *which word* gets
|
|
19
|
+
* emitted; it could not fix that the words are re-parsed at spawn time.
|
|
20
|
+
* 2. **Package names are re-parsed as shell syntax.** The builders are fed
|
|
21
|
+
* `bin.package` / `bin.formula` / `bin.module`, which come from
|
|
22
|
+
* `.claude/profiles.json` and from plugin `requires.bin` — hand-authored,
|
|
23
|
+
* committed by teammates, fetched from marketplaces. Under `shell: true`
|
|
24
|
+
* those go through word-splitting and `$(…)` expansion. Today's catalogue is
|
|
25
|
+
* benign; the surface is not, and nothing checks it.
|
|
26
|
+
*
|
|
27
|
+
* As argv there is no shell, so a package name is one token no matter what
|
|
28
|
+
* characters it contains, and `cmd` must name a real executable rather than
|
|
29
|
+
* anything PATH-plus-a-profile might have meant.
|
|
30
|
+
*
|
|
31
|
+
* `services/plugin-setup.ts` arrived at this same shape independently for the
|
|
32
|
+
* MCP dependency installer. This is the codebase agreeing with itself.
|
|
33
|
+
*/
|
|
34
|
+
export interface Command {
|
|
35
|
+
readonly cmd: string;
|
|
36
|
+
readonly args: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Characters that need no quoting in any POSIX shell.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately conservative, and the reason {@link formatCommand} is display-
|
|
43
|
+
* neutral: every token magus actually builds — `@anthropic-ai/claude-code`,
|
|
44
|
+
* `claudish@1.2.0`, `aider==0.1`, `github.com/x/t@latest`, `--upgrade` — is
|
|
45
|
+
* made only of these, so quoting never fires for real input and the rendered
|
|
46
|
+
* string is byte-identical to the one this codebase printed before commands
|
|
47
|
+
* became argv. `doctor-bins.test.ts` is the gate on that claim.
|
|
48
|
+
*/
|
|
49
|
+
const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render a {@link Command} as the shell line a human would type.
|
|
53
|
+
*
|
|
54
|
+
* FOR DISPLAY ONLY. Nothing may feed this back to a shell — that would undo the
|
|
55
|
+
* whole point of carrying argv. It exists so `doctor` can print "run: brew
|
|
56
|
+
* install tmux" and the updater can show what it is about to do.
|
|
57
|
+
*
|
|
58
|
+
* A token outside {@link SHELL_SAFE} is single-quoted, with `'` closed,
|
|
59
|
+
* escaped and reopened (`'\''`) — the only POSIX-portable escape, since single
|
|
60
|
+
* quotes have no escape character inside them. An empty token quotes too:
|
|
61
|
+
* unquoted it would vanish, which would misrepresent the argv it came from.
|
|
62
|
+
*/
|
|
63
|
+
export function formatCommand(c: Command): string {
|
|
64
|
+
return [c.cmd, ...c.args].map(quoteToken).join(" ");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function quoteToken(token: string): string {
|
|
68
|
+
if (SHELL_SAFE.test(token)) return token;
|
|
69
|
+
return `'${token.replaceAll("'", `'\\''`)}'`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if a command exists in PATH
|
|
74
|
+
* Uses execFile (not exec) to avoid shell injection vulnerabilities
|
|
75
|
+
* @param command - Command name to check
|
|
76
|
+
* @returns Path to command if found, null otherwise
|
|
77
|
+
*/
|
|
78
|
+
export async function which(command: string): Promise<string | null> {
|
|
79
|
+
try {
|
|
80
|
+
// Use execFile with argument array to prevent command injection
|
|
81
|
+
const { stdout } = await execFileAsync("which", [command]);
|
|
82
|
+
return stdout.trim() || null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Synchronous PATH lookup — walks `$PATH` rather than spawning `which(1)`.
|
|
90
|
+
*
|
|
91
|
+
* Synchronous on purpose. The installer-command builders in
|
|
92
|
+
* `services/toolchain.ts` are sync and used by printers; making them async just
|
|
93
|
+
* to answer "does this machine have uv?" would ripple through every caller.
|
|
94
|
+
*
|
|
95
|
+
* Node's `fs` is used rather than `Bun.which` so this needs no `@types/bun`,
|
|
96
|
+
* whose globals collide with `@types/node` in this project.
|
|
97
|
+
*
|
|
98
|
+
* Unlike {@link resolveExecutable} this does NOT follow symlinks to prove the
|
|
99
|
+
* target still exists — use it to pick between candidates, not to prove a
|
|
100
|
+
* binary works.
|
|
101
|
+
*/
|
|
102
|
+
export function whichSync(command: string): string | null {
|
|
103
|
+
// An explicit path is not a PATH lookup; honour it as given.
|
|
104
|
+
if (command.includes("/")) {
|
|
105
|
+
return isExecutableFile(command) ? command : null;
|
|
106
|
+
}
|
|
107
|
+
for (const dir of (process.env.PATH ?? "").split(":")) {
|
|
108
|
+
if (!dir) continue;
|
|
109
|
+
const candidate = join(dir, command);
|
|
110
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isExecutableFile(p: string): boolean {
|
|
116
|
+
try {
|
|
117
|
+
if (!statSync(p).isFile()) return false;
|
|
118
|
+
accessSync(p, constants.X_OK);
|
|
119
|
+
return true;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Like {@link which}, but verifies the command actually RESOLVES and is
|
|
127
|
+
* executable — a dangling symlink (e.g. a stale npx-cache link) returns null.
|
|
128
|
+
* `which` alone reports the symlink's own path even when its target is gone,
|
|
129
|
+
* which is exactly how a plugin's binary can look "present" yet fail at runtime.
|
|
130
|
+
* @returns The real (symlink-resolved) executable path, or null.
|
|
131
|
+
*/
|
|
132
|
+
export async function resolveExecutable(
|
|
133
|
+
command: string,
|
|
134
|
+
): Promise<string | null> {
|
|
135
|
+
const p = await which(command);
|
|
136
|
+
if (!p) return null;
|
|
137
|
+
try {
|
|
138
|
+
const real = await realpath(p); // follows symlinks; throws if dangling
|
|
139
|
+
await access(real, constants.X_OK); // must be executable
|
|
140
|
+
return real;
|
|
141
|
+
} catch {
|
|
142
|
+
return null; // dangling link or not executable
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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 process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
45
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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 { existsSync } from "node:fs";
|
|
9
|
+
import { mkdir, open } from "node:fs/promises";
|
|
10
|
+
import type { FileHandle } from "node:fs/promises";
|
|
11
|
+
import { dirname } from "node:path";
|
|
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) =>
|
|
61
|
+
setTimeout(resolve, LOCK_RETRY_INTERVAL_MS),
|
|
62
|
+
);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (fileHandle === null) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Failed to acquire lock on ${filePath} after ${LOCK_TIMEOUT_MS}ms. ` +
|
|
72
|
+
`Another process may be holding the lock. Lock file: ${lockPath}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// Execute operation with lock held
|
|
78
|
+
return await operation();
|
|
79
|
+
} finally {
|
|
80
|
+
// Release lock
|
|
81
|
+
try {
|
|
82
|
+
await fileHandle.close();
|
|
83
|
+
// Delete lock file
|
|
84
|
+
const fs = await import("node:fs/promises");
|
|
85
|
+
await fs.unlink(lockPath);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
// Log error but don't throw - operation succeeded
|
|
88
|
+
console.error(`Warning: Failed to release lock ${lockPath}:`, error);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if file is currently locked
|
|
95
|
+
*
|
|
96
|
+
* @param filePath - Path to file to check
|
|
97
|
+
* @returns True if lock file exists
|
|
98
|
+
*/
|
|
99
|
+
export function isFileLocked(filePath: string): boolean {
|
|
100
|
+
const lockPath = `${filePath}.lock`;
|
|
101
|
+
return existsSync(lockPath);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Force remove lock file (use with caution)
|
|
106
|
+
* Only use if you're certain the lock is stale
|
|
107
|
+
*
|
|
108
|
+
* @param filePath - Path to file whose lock to remove
|
|
109
|
+
*/
|
|
110
|
+
export async function forceRemoveLock(filePath: string): Promise<void> {
|
|
111
|
+
const lockPath = `${filePath}.lock`;
|
|
112
|
+
try {
|
|
113
|
+
const fs = await import("node:fs/promises");
|
|
114
|
+
await fs.unlink(lockPath);
|
|
115
|
+
} catch (error: unknown) {
|
|
116
|
+
// Ignore ENOENT (lock doesn't exist)
|
|
117
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get information about current lock
|
|
125
|
+
*
|
|
126
|
+
* @param filePath - Path to file to check
|
|
127
|
+
* @returns Lock info or null if not locked
|
|
128
|
+
*/
|
|
129
|
+
export async function getLockInfo(
|
|
130
|
+
filePath: string,
|
|
131
|
+
): Promise<{ pid: string; timestamp: string } | null> {
|
|
132
|
+
const lockPath = `${filePath}.lock`;
|
|
133
|
+
|
|
134
|
+
if (!existsSync(lockPath)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const fs = await import("node:fs/promises");
|
|
140
|
+
const content = await fs.readFile(lockPath, "utf-8");
|
|
141
|
+
const [pid, timestamp] = content.trim().split("\n");
|
|
142
|
+
return { pid, timestamp };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple fuzzy search utility for filtering lists
|
|
3
|
+
* Uses a scoring algorithm that rewards:
|
|
4
|
+
* - Consecutive character matches
|
|
5
|
+
* - Matches at word boundaries
|
|
6
|
+
* - Matches at start of string
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface FuzzyMatch<T> {
|
|
10
|
+
item: T;
|
|
11
|
+
score: number;
|
|
12
|
+
matches: number[]; // indices of matched characters
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Calculate fuzzy match score for a query against a target string
|
|
17
|
+
* Returns null if no match, otherwise returns score and match indices
|
|
18
|
+
*/
|
|
19
|
+
export function fuzzyMatch(
|
|
20
|
+
query: string,
|
|
21
|
+
target: string,
|
|
22
|
+
): { score: number; matches: number[] } | null {
|
|
23
|
+
if (!query) return { score: 1, matches: [] };
|
|
24
|
+
|
|
25
|
+
const queryLower = query.toLowerCase();
|
|
26
|
+
const targetLower = target.toLowerCase();
|
|
27
|
+
|
|
28
|
+
let queryIdx = 0;
|
|
29
|
+
let score = 0;
|
|
30
|
+
const matches: number[] = [];
|
|
31
|
+
let prevMatchIdx = -1;
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < targetLower.length && queryIdx < queryLower.length; i++) {
|
|
34
|
+
if (targetLower[i] === queryLower[queryIdx]) {
|
|
35
|
+
matches.push(i);
|
|
36
|
+
|
|
37
|
+
// Bonus for consecutive matches
|
|
38
|
+
if (prevMatchIdx === i - 1) {
|
|
39
|
+
score += 5;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Bonus for matching at word boundary
|
|
43
|
+
if (i === 0 || /[\s\-_.]/.test(target[i - 1])) {
|
|
44
|
+
score += 10;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Bonus for matching at start
|
|
48
|
+
if (i === 0) {
|
|
49
|
+
score += 15;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Base score for each match
|
|
53
|
+
score += 1;
|
|
54
|
+
|
|
55
|
+
prevMatchIdx = i;
|
|
56
|
+
queryIdx++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// All query characters must be found
|
|
61
|
+
if (queryIdx !== queryLower.length) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Penalty for longer strings (prefer shorter, more precise matches)
|
|
66
|
+
score -= Math.floor(target.length / 10);
|
|
67
|
+
|
|
68
|
+
return { score, matches };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Filter and sort items by fuzzy match score
|
|
73
|
+
*/
|
|
74
|
+
export function fuzzyFilter<T>(
|
|
75
|
+
items: T[],
|
|
76
|
+
query: string,
|
|
77
|
+
getText: (item: T) => string,
|
|
78
|
+
): FuzzyMatch<T>[] {
|
|
79
|
+
if (!query.trim()) {
|
|
80
|
+
return items.map((item) => ({ item, score: 1, matches: [] }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const results: FuzzyMatch<T>[] = [];
|
|
84
|
+
|
|
85
|
+
for (const item of items) {
|
|
86
|
+
const text = getText(item);
|
|
87
|
+
const match = fuzzyMatch(query, text);
|
|
88
|
+
|
|
89
|
+
if (match) {
|
|
90
|
+
results.push({
|
|
91
|
+
item,
|
|
92
|
+
score: match.score,
|
|
93
|
+
matches: match.matches,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Sort by score descending
|
|
99
|
+
return results.sort((a, b) => b.score - a.score);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Highlight matched characters in a string
|
|
104
|
+
* Returns array of { text, highlighted } segments
|
|
105
|
+
*/
|
|
106
|
+
export function highlightMatches(
|
|
107
|
+
text: string,
|
|
108
|
+
matches: number[],
|
|
109
|
+
): Array<{ text: string; highlighted: boolean }> {
|
|
110
|
+
if (matches.length === 0) {
|
|
111
|
+
return [{ text, highlighted: false }];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const matchSet = new Set(matches);
|
|
115
|
+
const segments: Array<{ text: string; highlighted: boolean }> = [];
|
|
116
|
+
let currentSegment = "";
|
|
117
|
+
let isHighlighted = matchSet.has(0);
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < text.length; i++) {
|
|
120
|
+
const shouldHighlight = matchSet.has(i);
|
|
121
|
+
|
|
122
|
+
if (shouldHighlight !== isHighlighted) {
|
|
123
|
+
if (currentSegment) {
|
|
124
|
+
segments.push({ text: currentSegment, highlighted: isHighlighted });
|
|
125
|
+
}
|
|
126
|
+
currentSegment = text[i];
|
|
127
|
+
isHighlighted = shouldHighlight;
|
|
128
|
+
} else {
|
|
129
|
+
currentSegment += text[i];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (currentSegment) {
|
|
134
|
+
segments.push({ text: currentSegment, highlighted: isHighlighted });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return segments;
|
|
138
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Go module path → installed binary name.
|
|
3
|
+
*
|
|
4
|
+
* `go install` names the binary after the last path element of the module
|
|
5
|
+
* path, EXCEPT when that element is a major-version suffix: the module
|
|
6
|
+
* `github.com/MadAppGang/tmux-mcp/v2` installs `tmux-mcp`, not `v2`. Taking a
|
|
7
|
+
* plain basename therefore points every later check at a binary that never
|
|
8
|
+
* exists — presence probes fail, so the module is reinstalled on every run,
|
|
9
|
+
* doctor reports it missing, and the pin comparison never runs at all.
|
|
10
|
+
*
|
|
11
|
+
* Only the Go rule lives here. npm and cargo share the `name@version` syntax
|
|
12
|
+
* but not the suffix convention — an npm scoped package can legitimately be
|
|
13
|
+
* called `@scope/v2` — so this must not be folded into the generic spec parser.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Trailing major-version suffix per the Go modules spec: `/vN` with N >= 2.
|
|
18
|
+
* Anchored to the END of the path with nothing after `vN`, so a segment that
|
|
19
|
+
* merely starts with "v2" (`github.com/x/v2tool`) is a real name, not a suffix.
|
|
20
|
+
* v0 and v1 are never written as a path suffix. The alternation, not a plain
|
|
21
|
+
* `[2-9][0-9]*`, because that shorthand rejects v10–v19 — a first digit of 1
|
|
22
|
+
* is only excluded when it is the ONLY digit.
|
|
23
|
+
*/
|
|
24
|
+
const MAJOR_VERSION_SUFFIX = /\/v(?:[2-9]|[1-9][0-9]+)$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The binary `go install <modulePath>` produces.
|
|
28
|
+
*
|
|
29
|
+
* Takes the MODULE PATH — the part before any `@version` — so callers that
|
|
30
|
+
* hold a full `path@version` spec split it first. An empty or single-segment
|
|
31
|
+
* input is returned unchanged.
|
|
32
|
+
*/
|
|
33
|
+
export function goBinaryName(modulePath: string): string {
|
|
34
|
+
const withoutSuffix = modulePath.replace(MAJOR_VERSION_SUFFIX, "");
|
|
35
|
+
return withoutSuffix.split("/").pop() || withoutSuffix;
|
|
36
|
+
}
|
|
@@ -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`: magus 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
|
+
* magus 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 magus exiting does not take the editor with it.
|
|
80
|
+
child.unref();
|
|
81
|
+
resolve();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|