claudeup 4.19.0 → 4.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/claudeup.js +43 -19
- package/package.json +14 -19
- package/scripts/build-binaries.ts +77 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +43 -0
- package/src/__tests__/cli-router.test.ts +65 -0
- package/src/__tests__/conventions-integration.test.ts +745 -0
- package/src/__tests__/conventions-manager.test.ts +1172 -0
- package/src/__tests__/doctor-bins.test.ts +35 -0
- package/src/__tests__/doctor.test.ts +450 -0
- package/src/__tests__/dual-write-prevention.test.ts +14 -4
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/gitignore-prerun.test.ts +1 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +98 -0
- package/src/__tests__/manifest.test.ts +136 -0
- package/src/__tests__/marketplace-refresh.test.ts +267 -0
- package/src/__tests__/plugin-requires.test.ts +133 -0
- package/src/__tests__/plugin-setup.test.ts +7 -0
- package/src/__tests__/profile-command.test.ts +116 -0
- package/src/__tests__/profile-materializer.test.ts +82 -0
- package/src/__tests__/profile-sync.test.ts +136 -0
- package/src/__tests__/registry-version-resolution.test.ts +74 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +218 -0
- package/src/__tests__/symlink-manager.test.ts +99 -0
- package/src/__tests__/toolchain.test.ts +56 -0
- package/src/cli/claude.ts +21 -0
- package/src/cli/doctor.ts +132 -0
- package/src/cli/install.ts +360 -0
- package/src/cli/profile.ts +166 -0
- package/src/cli/router.ts +107 -0
- package/src/cli/update.ts +85 -0
- package/src/data/cli-tools.ts +7 -7
- package/src/data/gitignore-defaults.ts +4 -0
- package/src/data/marketplaces.ts +12 -0
- package/src/data/predefined-profiles.ts +3 -4
- package/src/main.tsx +11 -154
- package/src/prerunner/index.ts +62 -27
- package/src/services/claude-cli.ts +19 -22
- package/src/services/claude-settings.ts +99 -26
- package/src/services/conventions-manager.ts +865 -0
- package/src/services/doctor-bins.ts +49 -0
- package/src/services/doctor.ts +509 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/manifest.ts +166 -0
- package/src/services/marketplace-refresh.ts +173 -0
- package/src/services/plugin-manager.ts +7 -1
- package/src/services/plugin-requires.ts +215 -0
- package/src/services/plugin-version-check.ts +9 -4
- package/src/services/profile-materializer.ts +61 -0
- package/src/services/profile-sync.ts +150 -0
- package/src/services/resolver.ts +293 -0
- package/src/services/symlink-manager.ts +146 -0
- package/src/services/toolchain.ts +97 -0
- package/src/services/version-check.ts +10 -11
- package/src/types/index.ts +138 -31
- package/src/ui/App.tsx +0 -4
- package/src/ui/screens/CliToolsScreen.tsx +11 -3
- package/src/ui/screens/index.ts +0 -1
- package/src/ui/state/reducer.ts +0 -101
- package/src/ui/state/types.ts +0 -35
- package/src/utils/command-utils.ts +23 -0
- package/src/utils/file-locking.ts +144 -0
- package/src/data/alias-flags.js +0 -205
- package/src/data/cli-tools.js +0 -123
- package/src/data/gitignore-defaults.js +0 -24
- package/src/data/gitignore-reasons.js +0 -97
- package/src/data/gitignore-templates.js +0 -21
- package/src/data/marketplaces.js +0 -138
- package/src/data/mcp-servers.js +0 -509
- package/src/data/predefined-profiles.js +0 -248
- package/src/data/settings-catalog.js +0 -625
- package/src/data/skill-repos.js +0 -160
- package/src/data/statuslines.js +0 -159
- package/src/data/statuslines.ts +0 -188
- package/src/index.js +0 -4
- package/src/index.ts +0 -5
- package/src/main.js +0 -170
- package/src/prerunner/index.js +0 -252
- package/src/services/alias-settings.js +0 -51
- package/src/services/alias-shell-writer.js +0 -1018
- package/src/services/alias-store.js +0 -129
- package/src/services/claude-cli.js +0 -189
- package/src/services/claude-runner.js +0 -28
- package/src/services/claude-settings.js +0 -1223
- package/src/services/gitignore-detector.js +0 -155
- package/src/services/gitignore-fixer.js +0 -231
- package/src/services/gitignore-prerun.js +0 -46
- package/src/services/gitignore-resolver.js +0 -143
- package/src/services/gitignore-service.js +0 -117
- package/src/services/local-marketplace.js +0 -339
- package/src/services/marketplace-fetcher.js +0 -96
- package/src/services/marketplace-sync.js +0 -94
- package/src/services/mcp-registry.js +0 -87
- package/src/services/plugin-manager.js +0 -473
- package/src/services/plugin-mcp-config.js +0 -177
- package/src/services/plugin-setup.js +0 -499
- package/src/services/plugin-version-check.js +0 -262
- package/src/services/profiles.js +0 -161
- package/src/services/settings-manager.js +0 -243
- package/src/services/skills-manager.js +0 -393
- package/src/services/skillsmp-client.js +0 -87
- package/src/services/update-cache.js +0 -52
- package/src/services/version-check.js +0 -99
- package/src/types/gitignore.js +0 -6
- package/src/types/index.js +0 -1
- package/src/ui/App.js +0 -355
- package/src/ui/adapters/pluginsAdapter.js +0 -139
- package/src/ui/adapters/settingsAdapter.js +0 -111
- package/src/ui/adapters/skillsAdapter.js +0 -154
- package/src/ui/components/CategoryHeader.js +0 -9
- package/src/ui/components/EmptyFilterState.js +0 -4
- package/src/ui/components/FlagDetailEditor.js +0 -0
- package/src/ui/components/ScopeIndicator.js +0 -30
- package/src/ui/components/ScrollableList.js +0 -36
- package/src/ui/components/SearchInput.js +0 -19
- package/src/ui/components/StyledText.js +0 -39
- package/src/ui/components/TabBar.js +0 -19
- package/src/ui/components/layout/FooterHints.js +0 -29
- package/src/ui/components/layout/Panel.js +0 -6
- package/src/ui/components/layout/ProgressBar.js +0 -14
- package/src/ui/components/layout/ScopeTabs.js +0 -6
- package/src/ui/components/layout/ScreenLayout.js +0 -16
- package/src/ui/components/layout/index.js +0 -5
- package/src/ui/components/modals/ConfirmModal.js +0 -14
- package/src/ui/components/modals/InputModal.js +0 -5
- package/src/ui/components/modals/LoadingModal.js +0 -17
- package/src/ui/components/modals/MessageModal.js +0 -16
- package/src/ui/components/modals/ModalContainer.js +0 -137
- package/src/ui/components/modals/SelectModal.js +0 -18
- package/src/ui/components/modals/VersionMismatchModal.js +0 -36
- package/src/ui/components/modals/index.js +0 -6
- package/src/ui/components/primitives/ActionHints.js +0 -13
- package/src/ui/components/primitives/DetailSection.js +0 -7
- package/src/ui/components/primitives/KeyValueLine.js +0 -8
- package/src/ui/components/primitives/ListCategoryRow.js +0 -8
- package/src/ui/components/primitives/MetaText.js +0 -8
- package/src/ui/components/primitives/ScopeDetail.js +0 -32
- package/src/ui/components/primitives/ScopeSquares.js +0 -11
- package/src/ui/components/primitives/SelectableRow.js +0 -5
- package/src/ui/components/primitives/index.js +0 -8
- package/src/ui/hooks/index.js +0 -4
- package/src/ui/hooks/useAsyncData.js +0 -77
- package/src/ui/hooks/useGitignoreModal.js +0 -74
- package/src/ui/hooks/useKeyboard.js +0 -13
- package/src/ui/hooks/useKeyboardHandler.js +0 -39
- package/src/ui/hooks/useMismatchModal.js +0 -77
- package/src/ui/registry.js +0 -1
- package/src/ui/renderers/cliToolRenderers.js +0 -54
- package/src/ui/renderers/gitignoreRenderers.js +0 -46
- package/src/ui/renderers/mcpRenderers.js +0 -26
- package/src/ui/renderers/pluginRenderers.js +0 -124
- package/src/ui/renderers/profileRenderers.js +0 -177
- package/src/ui/renderers/settingsRenderers.js +0 -73
- package/src/ui/renderers/skillRenderers.js +0 -138
- package/src/ui/screens/AliasScreen.js +0 -1111
- package/src/ui/screens/CliToolsScreen.js +0 -338
- package/src/ui/screens/EnvVarsScreen.js +0 -152
- package/src/ui/screens/GitignoreScreen.js +0 -328
- package/src/ui/screens/McpRegistryScreen.js +0 -238
- package/src/ui/screens/McpScreen.js +0 -176
- package/src/ui/screens/ModelSelectorScreen.js +0 -296
- package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
- package/src/ui/screens/PluginsScreen.js +0 -769
- package/src/ui/screens/ProfilesScreen.js +0 -298
- package/src/ui/screens/SkillsScreen.js +0 -549
- package/src/ui/screens/StatusLineScreen.js +0 -206
- package/src/ui/screens/StatusLineScreen.tsx +0 -416
- package/src/ui/screens/index.js +0 -10
- package/src/ui/state/AnimationContext.js +0 -34
- package/src/ui/state/AppContext.js +0 -162
- package/src/ui/state/DimensionsContext.js +0 -71
- package/src/ui/state/reducer.js +0 -547
- package/src/ui/state/types.js +0 -1
- package/src/ui/theme.js +0 -47
- package/src/utils/clipboard.js +0 -56
- package/src/utils/command-utils.js +0 -19
- package/src/utils/fuzzy-search.js +0 -101
- package/src/utils/string-utils.js +0 -62
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import { computeSync, syncProfile } from "../services/profile-sync.js";
|
|
7
|
+
import { profileDir } from "../services/symlink-manager.js";
|
|
8
|
+
import type { ProfileManifestEntry } from "../types/index.js";
|
|
9
|
+
|
|
10
|
+
describe("computeSync (pure)", () => {
|
|
11
|
+
const entry: ProfileManifestEntry = {
|
|
12
|
+
name: "Frontend",
|
|
13
|
+
plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
|
|
14
|
+
settings: { effortLevel: "high" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
test("adds a newly-enabled plugin, preserving existing pins", () => {
|
|
18
|
+
const { entry: next, changes } = computeSync(
|
|
19
|
+
entry,
|
|
20
|
+
{
|
|
21
|
+
enabledPlugins: {
|
|
22
|
+
"dev@magus": true,
|
|
23
|
+
"terminal@magus": true,
|
|
24
|
+
"designer@magus": true,
|
|
25
|
+
},
|
|
26
|
+
effortLevel: "high",
|
|
27
|
+
},
|
|
28
|
+
{ mcpServers: {} },
|
|
29
|
+
);
|
|
30
|
+
// existing pin preserved (not downgraded to latest)
|
|
31
|
+
expect(next.plugins!["dev@magus"]).toBe("2.9.0");
|
|
32
|
+
// new plugin added as latest
|
|
33
|
+
expect(next.plugins!["designer@magus"]).toBe("latest");
|
|
34
|
+
expect(changes).toContainEqual({ kind: "plugin-added", detail: "designer@magus" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("removes a plugin that was disabled", () => {
|
|
38
|
+
const { entry: next, changes } = computeSync(
|
|
39
|
+
entry,
|
|
40
|
+
{ enabledPlugins: { "dev@magus": true }, effortLevel: "high" },
|
|
41
|
+
{ mcpServers: {} },
|
|
42
|
+
);
|
|
43
|
+
expect(next.plugins!["terminal@magus"]).toBeUndefined();
|
|
44
|
+
expect(changes).toContainEqual({ kind: "plugin-removed", detail: "terminal@magus" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("captures a settings change and an mcp change", () => {
|
|
48
|
+
const { entry: next, changes } = computeSync(
|
|
49
|
+
entry,
|
|
50
|
+
{
|
|
51
|
+
enabledPlugins: { "dev@magus": true, "terminal@magus": true },
|
|
52
|
+
effortLevel: "max",
|
|
53
|
+
},
|
|
54
|
+
{ mcpServers: { "chrome-devtools": { command: "npx" } } },
|
|
55
|
+
);
|
|
56
|
+
expect(next.settings!.effortLevel).toBe("max");
|
|
57
|
+
expect(next.mcpServers!["chrome-devtools"]).toEqual({ command: "npx" });
|
|
58
|
+
expect(changes.some((c) => c.kind === "settings-changed")).toBe(true);
|
|
59
|
+
expect(changes.some((c) => c.kind === "mcp-changed")).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("no changes when the materialized state matches the manifest", () => {
|
|
63
|
+
const { changes } = computeSync(
|
|
64
|
+
entry,
|
|
65
|
+
{
|
|
66
|
+
enabledPlugins: { "dev@magus": true, "terminal@magus": true },
|
|
67
|
+
effortLevel: "high",
|
|
68
|
+
},
|
|
69
|
+
{ mcpServers: {} },
|
|
70
|
+
);
|
|
71
|
+
expect(changes).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("refreshes updatedAt to the injected timestamp when changed", () => {
|
|
75
|
+
const withStamp: ProfileManifestEntry = { ...entry, updatedAt: "OLD" };
|
|
76
|
+
const { entry: changed } = computeSync(
|
|
77
|
+
withStamp,
|
|
78
|
+
{ enabledPlugins: { "dev@magus": true, "terminal@magus": true, "new@magus": true }, effortLevel: "high" },
|
|
79
|
+
{ mcpServers: {} },
|
|
80
|
+
"2026-07-27T00:00:00.000Z",
|
|
81
|
+
);
|
|
82
|
+
expect(changed.updatedAt).toBe("2026-07-27T00:00:00.000Z");
|
|
83
|
+
|
|
84
|
+
// Unchanged sync leaves updatedAt alone.
|
|
85
|
+
const { entry: same } = computeSync(
|
|
86
|
+
withStamp,
|
|
87
|
+
{ enabledPlugins: { "dev@magus": true, "terminal@magus": true }, effortLevel: "high" },
|
|
88
|
+
{ mcpServers: {} },
|
|
89
|
+
"2026-07-27T00:00:00.000Z",
|
|
90
|
+
);
|
|
91
|
+
expect(same.updatedAt).toBe("OLD");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("syncProfile (io)", () => {
|
|
96
|
+
let dir: string;
|
|
97
|
+
beforeEach(async () => {
|
|
98
|
+
dir = await mkdtemp(join(tmpdir(), "sync-"));
|
|
99
|
+
await fs.ensureDir(join(dir, ".claude"));
|
|
100
|
+
await fs.writeJson(join(dir, ".claude", "profiles.json"), {
|
|
101
|
+
version: 2,
|
|
102
|
+
profiles: { frontend: { name: "Frontend", plugins: { "dev@magus": "2.9.0" } } },
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
afterEach(async () => {
|
|
106
|
+
await rm(dir, { recursive: true, force: true });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("promotes an edit in the materialized dir into the manifest file", async () => {
|
|
110
|
+
// A teammate enabled a new plugin directly in the active profile's settings.
|
|
111
|
+
const pd = profileDir("frontend", dir);
|
|
112
|
+
await fs.ensureDir(pd);
|
|
113
|
+
await fs.writeJson(join(pd, "settings.json"), {
|
|
114
|
+
enabledPlugins: { "dev@magus": true, "designer@magus": true },
|
|
115
|
+
});
|
|
116
|
+
await fs.writeJson(join(pd, "mcp.json"), { mcpServers: {} });
|
|
117
|
+
|
|
118
|
+
const { changes, wrote } = await syncProfile("frontend", dir);
|
|
119
|
+
expect(wrote).toBe(true);
|
|
120
|
+
expect(changes.some((c) => c.detail === "designer@magus")).toBe(true);
|
|
121
|
+
|
|
122
|
+
const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
|
|
123
|
+
expect(manifest.profiles.frontend.plugins["designer@magus"]).toBe("latest");
|
|
124
|
+
expect(manifest.profiles.frontend.plugins["dev@magus"]).toBe("2.9.0"); // pin kept
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("no-op when nothing drifted", async () => {
|
|
128
|
+
const pd = profileDir("frontend", dir);
|
|
129
|
+
await fs.ensureDir(pd);
|
|
130
|
+
await fs.writeJson(join(pd, "settings.json"), {
|
|
131
|
+
enabledPlugins: { "dev@magus": true },
|
|
132
|
+
});
|
|
133
|
+
const { wrote } = await syncProfile("frontend", dir);
|
|
134
|
+
expect(wrote).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { pickRegistryEntry } from "../services/claude-settings.js";
|
|
3
|
+
import type { InstalledPluginEntry } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression cover for the "no outdated plugins shown" bug.
|
|
7
|
+
*
|
|
8
|
+
* claudeup used to read installed versions from `settings.installedPluginVersions`
|
|
9
|
+
* only. Claude Code's own `plugin install` writes installed_plugins.json but not
|
|
10
|
+
* that settings key, so a genuinely installed plugin could be missing from it.
|
|
11
|
+
* The update check is `installedVersion && compare(latest, installedVersion) > 0`,
|
|
12
|
+
* so a missing version resolved to `hasUpdate: false` — an available update was
|
|
13
|
+
* reported as "up to date" rather than "unknown".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const entry = (
|
|
17
|
+
scope: InstalledPluginEntry["scope"],
|
|
18
|
+
version: string,
|
|
19
|
+
projectPath?: string,
|
|
20
|
+
): InstalledPluginEntry => ({
|
|
21
|
+
scope,
|
|
22
|
+
version,
|
|
23
|
+
projectPath,
|
|
24
|
+
installPath: `/cache/${version}`,
|
|
25
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
26
|
+
lastUpdated: "2026-01-01T00:00:00.000Z",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("pickRegistryEntry — scope resolution", () => {
|
|
30
|
+
test("user scope takes the user entry, ignoring project installs", () => {
|
|
31
|
+
const entries = [
|
|
32
|
+
entry("project", "1.0.0", "/proj/a"),
|
|
33
|
+
entry("user", "2.0.0"),
|
|
34
|
+
entry("project", "3.0.0", "/proj/b"),
|
|
35
|
+
];
|
|
36
|
+
expect(pickRegistryEntry(entries, "user")?.version).toBe("2.0.0");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("project scope prefers the entry pinned to that exact project", () => {
|
|
40
|
+
const entries = [
|
|
41
|
+
entry("user", "2.0.0"),
|
|
42
|
+
entry("project", "1.0.0", "/proj/a"),
|
|
43
|
+
entry("project", "3.0.0", "/proj/b"),
|
|
44
|
+
];
|
|
45
|
+
expect(pickRegistryEntry(entries, "project", "/proj/b")?.version).toBe("3.0.0");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("project scope falls back to the user entry when the project has no install", () => {
|
|
49
|
+
// This is the statusline case: installed at user scope, absent from project
|
|
50
|
+
// settings, previously surfaced as "no version" and skipped by update checks.
|
|
51
|
+
const entries = [entry("user", "2.1.2"), entry("project", "1.0.0", "/proj/other")];
|
|
52
|
+
expect(pickRegistryEntry(entries, "project", "/proj/mine")?.version).toBe("2.1.2");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("project paths are compared resolved, not as raw strings", () => {
|
|
56
|
+
const entries = [entry("project", "4.2.0", "/proj/a/../a")];
|
|
57
|
+
expect(pickRegistryEntry(entries, "project", "/proj/a")?.version).toBe("4.2.0");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a local-scope entry satisfies a project lookup for the same path", () => {
|
|
61
|
+
const entries = [entry("local", "5.0.0", "/proj/a"), entry("user", "1.0.0")];
|
|
62
|
+
expect(pickRegistryEntry(entries, "project", "/proj/a")?.version).toBe("5.0.0");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("no project path and no user entry yields undefined, not a wrong project's version", () => {
|
|
66
|
+
const entries = [entry("project", "9.9.9", "/proj/somewhere-else")];
|
|
67
|
+
expect(pickRegistryEntry(entries, "project")).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("empty and missing entry lists are handled", () => {
|
|
71
|
+
expect(pickRegistryEntry([], "user")).toBeUndefined();
|
|
72
|
+
expect(pickRegistryEntry(undefined, "user")).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, symlink, writeFile, chmod } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { resolveExecutable } from "../utils/command-utils.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* resolveExecutable must treat a dangling symlink as NOT available — the exact
|
|
9
|
+
* failure mode behind a plugin's binary looking present (the symlink exists)
|
|
10
|
+
* while failing at runtime (its target is gone).
|
|
11
|
+
*/
|
|
12
|
+
describe("resolveExecutable — dangling symlink detection", () => {
|
|
13
|
+
let dir: string;
|
|
14
|
+
let prevPath: string | undefined;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
dir = await mkdtemp(join(tmpdir(), "resolveexe-"));
|
|
18
|
+
prevPath = process.env.PATH;
|
|
19
|
+
process.env.PATH = `${dir}:${prevPath}`;
|
|
20
|
+
});
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
process.env.PATH = prevPath;
|
|
23
|
+
await rm(dir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a live executable resolves", async () => {
|
|
27
|
+
const bin = join(dir, "livetool");
|
|
28
|
+
await writeFile(bin, "#!/bin/sh\necho hi\n");
|
|
29
|
+
await chmod(bin, 0o755);
|
|
30
|
+
expect(await resolveExecutable("livetool")).not.toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a dangling symlink resolves to null", async () => {
|
|
34
|
+
const link = join(dir, "ghosttool");
|
|
35
|
+
await symlink(join(dir, "does-not-exist"), link);
|
|
36
|
+
expect(await resolveExecutable("ghosttool")).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a missing command resolves to null", async () => {
|
|
40
|
+
expect(await resolveExecutable("definitely-not-a-real-cmd-xyz")).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
dedupeBins,
|
|
4
|
+
resolveAllProfiles,
|
|
5
|
+
resolveExtends,
|
|
6
|
+
resolveProfile,
|
|
7
|
+
} from "../services/resolver.js";
|
|
8
|
+
import type {
|
|
9
|
+
BinRequirement,
|
|
10
|
+
ProfileManifest,
|
|
11
|
+
ProfileManifestEntry,
|
|
12
|
+
} from "../types/index.js";
|
|
13
|
+
|
|
14
|
+
// A binResolver that returns canned requires.bin per plugin — keeps the
|
|
15
|
+
// resolver tests hermetic (no marketplace cache / filesystem).
|
|
16
|
+
function fakeBinResolver(
|
|
17
|
+
table: Record<string, BinRequirement[]>,
|
|
18
|
+
): (pluginId: string) => Promise<BinRequirement[]> {
|
|
19
|
+
return async (pluginId) => table[pluginId] ?? [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function manifestOf(
|
|
23
|
+
profiles: Record<string, ProfileManifestEntry>,
|
|
24
|
+
): ProfileManifest {
|
|
25
|
+
return { version: 2, profiles };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("dedupeBins — precedence by order", () => {
|
|
29
|
+
test("first-seen wins installer/version; later contributes only provenance", () => {
|
|
30
|
+
const claudishPin: BinRequirement = {
|
|
31
|
+
name: "claudish",
|
|
32
|
+
via: "bun",
|
|
33
|
+
package: "claudish",
|
|
34
|
+
version: "1.2.0",
|
|
35
|
+
};
|
|
36
|
+
const claudishLoose: BinRequirement = {
|
|
37
|
+
name: "claudish",
|
|
38
|
+
via: "npm",
|
|
39
|
+
package: "claudish",
|
|
40
|
+
};
|
|
41
|
+
const result = dedupeBins([
|
|
42
|
+
{ bin: claudishPin, source: "profile:x.cliTools" },
|
|
43
|
+
{ bin: claudishLoose, source: "plugin:foo@magus" },
|
|
44
|
+
]);
|
|
45
|
+
expect(result).toHaveLength(1);
|
|
46
|
+
expect(result[0]!.via).toBe("bun");
|
|
47
|
+
expect(result[0]!.version).toBe("1.2.0");
|
|
48
|
+
expect(result[0]!.sources).toEqual([
|
|
49
|
+
"profile:x.cliTools",
|
|
50
|
+
"plugin:foo@magus",
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a later entry fills a version the winner left unset", () => {
|
|
55
|
+
const loose: BinRequirement = { name: "tmux", via: "brew" };
|
|
56
|
+
const pinned: BinRequirement = { name: "tmux", via: "brew", version: "3.4" };
|
|
57
|
+
const result = dedupeBins([
|
|
58
|
+
{ bin: loose, source: "plugin:a" },
|
|
59
|
+
{ bin: pinned, source: "plugin:b" },
|
|
60
|
+
]);
|
|
61
|
+
expect(result[0]!.version).toBe("3.4");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("resolveExtends", () => {
|
|
66
|
+
test("inherits predefined plugins/settings under the entry's own", () => {
|
|
67
|
+
const entry: ProfileManifestEntry = {
|
|
68
|
+
name: "Custom",
|
|
69
|
+
extends: "must-have",
|
|
70
|
+
plugins: { "dev@magus": "2.9.0" },
|
|
71
|
+
settings: { effortLevel: "max" },
|
|
72
|
+
};
|
|
73
|
+
const merged = resolveExtends(entry);
|
|
74
|
+
// must-have contributes statusline@magus + multimodel@magus at "latest".
|
|
75
|
+
expect(merged.plugins!["statusline@magus"]).toBe("latest");
|
|
76
|
+
expect(merged.plugins!["multimodel@magus"]).toBe("latest");
|
|
77
|
+
// Entry's own plugin pin is preserved.
|
|
78
|
+
expect(merged.plugins!["dev@magus"]).toBe("2.9.0");
|
|
79
|
+
// Entry setting wins over the inherited one.
|
|
80
|
+
expect(merged.settings!.effortLevel).toBe("max");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("honors an explicit @marketplace on a predefined plugin name", () => {
|
|
84
|
+
const entry: ProfileManifestEntry = {
|
|
85
|
+
name: "Growth",
|
|
86
|
+
extends: "growth-marketer",
|
|
87
|
+
};
|
|
88
|
+
const merged = resolveExtends(entry);
|
|
89
|
+
// seo/nanobanana/video-editing ship on the magus-marketing channel and
|
|
90
|
+
// pin it explicitly — they must NOT be rewritten to @magus.
|
|
91
|
+
expect(merged.plugins!["seo@magus-marketing"]).toBe("latest");
|
|
92
|
+
expect(merged.plugins!["nanobanana@magus-marketing"]).toBe("latest");
|
|
93
|
+
expect(merged.plugins!["video-editing@magus-marketing"]).toBe("latest");
|
|
94
|
+
expect(merged.plugins!["seo@magus"]).toBeUndefined();
|
|
95
|
+
// Bare names still default to @magus.
|
|
96
|
+
expect(merged.plugins!["browser-use@magus"]).toBe("latest");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("no extends is a no-op", () => {
|
|
100
|
+
const entry: ProfileManifestEntry = { name: "Plain", plugins: {} };
|
|
101
|
+
expect(resolveExtends(entry)).toBe(entry);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("unknown extends id is ignored (returns entry unchanged)", () => {
|
|
105
|
+
const entry: ProfileManifestEntry = { name: "X", extends: "nope" };
|
|
106
|
+
expect(resolveExtends(entry)).toBe(entry);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("resolveProfile", () => {
|
|
111
|
+
test("normalizes plugins, unions cliTools with plugin bins (project pin wins)", async () => {
|
|
112
|
+
const manifest = manifestOf({
|
|
113
|
+
frontend: {
|
|
114
|
+
name: "Frontend",
|
|
115
|
+
plugins: {
|
|
116
|
+
"terminal@magus": "4.1.0",
|
|
117
|
+
"dev@magus": true, // → latest
|
|
118
|
+
"old@magus": false, // → omitted
|
|
119
|
+
},
|
|
120
|
+
cliTools: { claudish: "1.2.0" },
|
|
121
|
+
env: { required: ["FIGMA_ACCESS_TOKEN"] },
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const closure = await resolveProfile(manifest, "frontend", {
|
|
126
|
+
binResolver: fakeBinResolver({
|
|
127
|
+
"terminal@magus": [
|
|
128
|
+
{ name: "tmux", via: "brew", formula: "tmux" },
|
|
129
|
+
// terminal ALSO wants claudish, but the profile pinned it →
|
|
130
|
+
// profile pin must win.
|
|
131
|
+
{ name: "claudish", via: "npm", package: "claudish" },
|
|
132
|
+
],
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(closure.plugins).toEqual({
|
|
137
|
+
"terminal@magus": "4.1.0",
|
|
138
|
+
"dev@magus": "latest",
|
|
139
|
+
});
|
|
140
|
+
expect(closure.env.required).toEqual(["FIGMA_ACCESS_TOKEN"]);
|
|
141
|
+
|
|
142
|
+
const claudish = closure.bins.find((b) => b.name === "claudish")!;
|
|
143
|
+
expect(claudish.via).toBe("bun"); // from the cli-tools catalog, not npm
|
|
144
|
+
expect(claudish.version).toBe("1.2.0");
|
|
145
|
+
expect(claudish.sources[0]).toContain("cliTools");
|
|
146
|
+
|
|
147
|
+
const tmux = closure.bins.find((b) => b.name === "tmux")!;
|
|
148
|
+
expect(tmux.via).toBe("brew");
|
|
149
|
+
expect(tmux.sources).toContain("plugin:terminal@magus");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("throws for an unknown profile id", async () => {
|
|
153
|
+
await expect(
|
|
154
|
+
resolveProfile(manifestOf({}), "ghost"),
|
|
155
|
+
).rejects.toThrow('Profile "ghost" not found');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("resolveAllProfiles — union", () => {
|
|
160
|
+
test("unions plugins/bins/skills/env; a pin beats latest across profiles", async () => {
|
|
161
|
+
const manifest = manifestOf({
|
|
162
|
+
frontend: {
|
|
163
|
+
name: "Frontend",
|
|
164
|
+
plugins: { "dev@magus": "latest", "terminal@magus": "4.1.0" },
|
|
165
|
+
skills: [{ name: "A", repo: "o/r", path: "skills/a" }],
|
|
166
|
+
env: { required: ["FIGMA_ACCESS_TOKEN"] },
|
|
167
|
+
},
|
|
168
|
+
backend: {
|
|
169
|
+
name: "Backend",
|
|
170
|
+
plugins: { "dev@magus": "2.9.0" }, // pin should win over "latest"
|
|
171
|
+
cliTools: { mnemex: "latest" },
|
|
172
|
+
skills: [{ name: "A", repo: "o/r", path: "skills/a" }], // dupe
|
|
173
|
+
env: { required: ["DATABASE_URL"] },
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const union = await resolveAllProfiles(manifest, {
|
|
178
|
+
binResolver: fakeBinResolver({}),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(union.plugins["dev@magus"]).toBe("2.9.0"); // pin beat latest
|
|
182
|
+
expect(union.plugins["terminal@magus"]).toBe("4.1.0");
|
|
183
|
+
expect(union.skills).toHaveLength(1); // deduped by repo#path
|
|
184
|
+
expect(union.env.required.sort()).toEqual([
|
|
185
|
+
"DATABASE_URL",
|
|
186
|
+
"FIGMA_ACCESS_TOKEN",
|
|
187
|
+
]);
|
|
188
|
+
expect(union.bins.find((b) => b.name === "mnemex")?.via).toBe("bun");
|
|
189
|
+
expect(union.conflicts).toBeUndefined(); // latest-vs-pin isn't a conflict
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("conflicting explicit pins resolve to the higher semver, order-independent, and are reported", async () => {
|
|
193
|
+
const mk = (order: "ab" | "ba"): ProfileManifest => {
|
|
194
|
+
const a: ProfileManifestEntry = { name: "A", plugins: { "dev@magus": "2.9.0" } };
|
|
195
|
+
const b: ProfileManifestEntry = { name: "B", plugins: { "dev@magus": "2.11.0" } };
|
|
196
|
+
return manifestOf(order === "ab" ? { a, b } : { b, a });
|
|
197
|
+
};
|
|
198
|
+
const ab = await resolveAllProfiles(mk("ab"), { binResolver: fakeBinResolver({}) });
|
|
199
|
+
const ba = await resolveAllProfiles(mk("ba"), { binResolver: fakeBinResolver({}) });
|
|
200
|
+
|
|
201
|
+
// Deterministic: higher pin wins regardless of profile order.
|
|
202
|
+
expect(ab.plugins["dev@magus"]).toBe("2.11.0");
|
|
203
|
+
expect(ba.plugins["dev@magus"]).toBe("2.11.0");
|
|
204
|
+
// And the conflict is surfaced, not silent.
|
|
205
|
+
expect(ab.conflicts?.some((c) => c.includes("dev@magus"))).toBe(true);
|
|
206
|
+
expect(ba.conflicts?.some((c) => c.includes("dev@magus"))).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("conflicting bin pins across profiles resolve deterministically + report", async () => {
|
|
210
|
+
const manifest = manifestOf({
|
|
211
|
+
a: { name: "A", cliTools: { claudish: "1.2.0" } },
|
|
212
|
+
b: { name: "B", cliTools: { claudish: "1.5.0" } },
|
|
213
|
+
});
|
|
214
|
+
const union = await resolveAllProfiles(manifest, { binResolver: fakeBinResolver({}) });
|
|
215
|
+
expect(union.bins.find((x) => x.name === "claudish")?.version).toBe("1.5.0");
|
|
216
|
+
expect(union.conflicts?.some((c) => c.includes("claudish"))).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, lstat, readlink } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import {
|
|
7
|
+
activateProfile,
|
|
8
|
+
activeProfile,
|
|
9
|
+
profileDir,
|
|
10
|
+
} from "../services/symlink-manager.js";
|
|
11
|
+
|
|
12
|
+
/** Materialize a fake profile dir with the given files. */
|
|
13
|
+
async function materialize(
|
|
14
|
+
project: string,
|
|
15
|
+
name: string,
|
|
16
|
+
files: { settings?: object; mcp?: object; skills?: boolean },
|
|
17
|
+
) {
|
|
18
|
+
const dir = profileDir(name, project);
|
|
19
|
+
await fs.ensureDir(dir);
|
|
20
|
+
if (files.settings) await fs.writeJson(join(dir, "settings.json"), files.settings);
|
|
21
|
+
if (files.mcp) await fs.writeJson(join(dir, "mcp.json"), files.mcp);
|
|
22
|
+
if (files.skills) await fs.ensureDir(join(dir, "skills"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("symlink-manager", () => {
|
|
26
|
+
let project: string;
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
project = await mkdtemp(join(tmpdir(), "symlink-"));
|
|
29
|
+
});
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await rm(project, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("activateProfile links settings.json, .mcp.json, and skills/", async () => {
|
|
35
|
+
await materialize(project, "frontend", {
|
|
36
|
+
settings: { enabledPlugins: { "dev@magus": true } },
|
|
37
|
+
mcp: { mcpServers: {} },
|
|
38
|
+
skills: true,
|
|
39
|
+
});
|
|
40
|
+
await activateProfile("frontend", project);
|
|
41
|
+
|
|
42
|
+
// settings.json is a symlink into the profile dir
|
|
43
|
+
expect((await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink()).toBe(true);
|
|
44
|
+
expect((await lstat(join(project, ".mcp.json"))).isSymbolicLink()).toBe(true);
|
|
45
|
+
expect((await lstat(join(project, ".claude", "skills"))).isSymbolicLink()).toBe(true);
|
|
46
|
+
|
|
47
|
+
// reading through the link returns the profile's content
|
|
48
|
+
const settings = await fs.readJson(join(project, ".claude", "settings.json"));
|
|
49
|
+
expect(settings.enabledPlugins["dev@magus"]).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("writeJson through the settings symlink lands in the profile dir", async () => {
|
|
53
|
+
await materialize(project, "frontend", { settings: { a: 1 } });
|
|
54
|
+
await activateProfile("frontend", project);
|
|
55
|
+
await fs.writeJson(join(project, ".claude", "settings.json"), { a: 2 });
|
|
56
|
+
// The real file inside the profile dir was updated (symlink preserved).
|
|
57
|
+
expect((await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink()).toBe(true);
|
|
58
|
+
expect((await fs.readJson(profileDir("frontend", project) + "/settings.json")).a).toBe(2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("switching repoints links and activeProfile tracks it", async () => {
|
|
62
|
+
await materialize(project, "frontend", { settings: { which: "frontend" } });
|
|
63
|
+
await materialize(project, "backend", { settings: { which: "backend" } });
|
|
64
|
+
|
|
65
|
+
await activateProfile("frontend", project);
|
|
66
|
+
expect(await activeProfile(project)).toBe("frontend");
|
|
67
|
+
expect((await fs.readJson(join(project, ".claude", "settings.json"))).which).toBe("frontend");
|
|
68
|
+
|
|
69
|
+
await activateProfile("backend", project);
|
|
70
|
+
expect(await activeProfile(project)).toBe("backend");
|
|
71
|
+
expect((await fs.readJson(join(project, ".claude", "settings.json"))).which).toBe("backend");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("uses relative (not absolute) symlinks", async () => {
|
|
75
|
+
await materialize(project, "frontend", { settings: { a: 1 } });
|
|
76
|
+
await activateProfile("frontend", project);
|
|
77
|
+
const target = await readlink(join(project, ".claude", "settings.json"));
|
|
78
|
+
expect(target.startsWith("/")).toBe(false);
|
|
79
|
+
expect(target).toContain("_profiles");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a profile without skills/ creates no skills link", async () => {
|
|
83
|
+
await materialize(project, "frontend", { settings: { a: 1 } });
|
|
84
|
+
await activateProfile("frontend", project);
|
|
85
|
+
expect(await fs.pathExists(join(project, ".claude", "skills"))).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("activateProfile throws if the profile isn't materialized", async () => {
|
|
89
|
+
await expect(activateProfile("ghost", project)).rejects.toThrow(
|
|
90
|
+
"not materialized",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("activeProfile returns null when settings.json is a real file", async () => {
|
|
95
|
+
await fs.ensureDir(join(project, ".claude"));
|
|
96
|
+
await fs.writeJson(join(project, ".claude", "settings.json"), { a: 1 });
|
|
97
|
+
expect(await activeProfile(project)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
binInstallCommand,
|
|
4
|
+
detectToolchains,
|
|
5
|
+
} from "../services/toolchain.js";
|
|
6
|
+
import type { ResolvedBin } from "../types/index.js";
|
|
7
|
+
|
|
8
|
+
describe("binInstallCommand", () => {
|
|
9
|
+
test("bun with and without a version pin", () => {
|
|
10
|
+
expect(binInstallCommand({ name: "claudish", via: "bun", package: "claudish", version: "1.2.0" })).toBe(
|
|
11
|
+
"bun install -g claudish@1.2.0",
|
|
12
|
+
);
|
|
13
|
+
expect(binInstallCommand({ name: "claudish", via: "bun" })).toBe(
|
|
14
|
+
"bun install -g claudish",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("npm, pip, brew, go each render correctly", () => {
|
|
19
|
+
expect(binInstallCommand({ name: "x", via: "npm", package: "x" })).toBe(
|
|
20
|
+
"npm install -g x",
|
|
21
|
+
);
|
|
22
|
+
expect(binInstallCommand({ name: "aider", via: "pip", package: "aider", version: "0.1" })).toBe(
|
|
23
|
+
"pip install aider==0.1",
|
|
24
|
+
);
|
|
25
|
+
expect(binInstallCommand({ name: "tmux", via: "brew", formula: "tmux" })).toBe(
|
|
26
|
+
"brew install tmux",
|
|
27
|
+
);
|
|
28
|
+
expect(binInstallCommand({ name: "t", via: "go", module: "github.com/x/t" })).toBe(
|
|
29
|
+
"go install github.com/x/t@latest",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("detectToolchains", () => {
|
|
35
|
+
test("groups bins by installer and reports requiredBy + presence", async () => {
|
|
36
|
+
const bins: ResolvedBin[] = [
|
|
37
|
+
{ name: "claudish", via: "bun", sources: ["a"] },
|
|
38
|
+
{ name: "mnemex", via: "bun", sources: ["b"] },
|
|
39
|
+
{ name: "tmux", via: "brew", sources: ["c"] },
|
|
40
|
+
];
|
|
41
|
+
const statuses = await detectToolchains(bins);
|
|
42
|
+
const bun = statuses.find((s) => s.name === "bun")!;
|
|
43
|
+
const brew = statuses.find((s) => s.name === "brew")!;
|
|
44
|
+
expect(bun.requiredBy.sort()).toEqual(["claudish", "mnemex"]);
|
|
45
|
+
expect(brew.requiredBy).toEqual(["tmux"]);
|
|
46
|
+
// bun/brew are bootstrappable; the field is populated.
|
|
47
|
+
expect(bun.bootstrap).toContain("bun.sh");
|
|
48
|
+
expect(typeof bun.present).toBe("boolean");
|
|
49
|
+
// stable order: bun before brew
|
|
50
|
+
expect(statuses.map((s) => s.name)).toEqual(["bun", "brew"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("returns only toolchains actually needed (empty for no bins)", async () => {
|
|
54
|
+
expect(await detectToolchains([])).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `claudeup claude [args...]` — prerunner mode.
|
|
3
|
+
*
|
|
4
|
+
* Runs plugin/marketplace update & migration checks (1h cache, or `-f`/`--force`
|
|
5
|
+
* to bypass), then execs `claude` with the remaining args. Returns claude's exit
|
|
6
|
+
* code so the caller can propagate it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { prerunClaude } from "../prerunner/index.js";
|
|
10
|
+
|
|
11
|
+
export async function runClaudeCommand(claudeArgs: string[]): Promise<number> {
|
|
12
|
+
const forceUpdate =
|
|
13
|
+
claudeArgs.includes("--force") || claudeArgs.includes("-f");
|
|
14
|
+
|
|
15
|
+
// Remove prerunner-specific flags before passing args to claude.
|
|
16
|
+
const filteredArgs = claudeArgs.filter(
|
|
17
|
+
(arg) => arg !== "--force" && arg !== "-f",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return prerunClaude(filteredArgs, { force: forceUpdate });
|
|
21
|
+
}
|