claudeup 6.2.0 → 6.3.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 +22 -0
- package/bin/claudeup.js +6 -1
- package/package.json +6 -6
- package/src/__tests__/alias-adopt.test.ts +331 -329
- package/src/__tests__/alias-parser.test.ts +384 -376
- package/src/__tests__/alias-shell-writer.test.ts +638 -625
- package/src/__tests__/alias-store.test.ts +143 -143
- package/src/__tests__/catalog-cache-store.test.ts +265 -265
- package/src/__tests__/catalog-notice.test.ts +123 -123
- package/src/__tests__/cli-apply-seams.test.ts +12 -8
- package/src/__tests__/cli-router.test.ts +132 -3
- package/src/__tests__/cli-update-view.test.ts +27 -8
- package/src/__tests__/community-staleness.test.ts +0 -2
- package/src/__tests__/component-badge.test.ts +4 -4
- package/src/__tests__/content-drift.test.ts +5 -1
- package/src/__tests__/conventions-integration.test.ts +774 -689
- package/src/__tests__/conventions-manager.test.ts +1216 -1152
- package/src/__tests__/doctor-bins.test.ts +14 -6
- package/src/__tests__/doctor.test.ts +438 -425
- package/src/__tests__/dual-write-prevention.test.ts +277 -282
- package/src/__tests__/enabled-not-installed.test.ts +71 -63
- package/src/__tests__/file-locking.test.ts +196 -196
- package/src/__tests__/gap-fill-versions.test.ts +318 -311
- package/src/__tests__/github-budget.test.ts +170 -164
- package/src/__tests__/gitignore-detector.test.ts +160 -152
- package/src/__tests__/gitignore-fixer.test.ts +285 -247
- package/src/__tests__/gitignore-prerun.test.ts +57 -57
- package/src/__tests__/gitignore-resolver.test.ts +173 -146
- package/src/__tests__/gitignore-service.test.ts +121 -119
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/install-command.test.ts +3 -3
- package/src/__tests__/install-plan.test.ts +13 -7
- package/src/__tests__/manifest.test.ts +16 -5
- package/src/__tests__/markdown-renderer.test.ts +0 -1
- package/src/__tests__/marketplace-badge.test.ts +111 -90
- package/src/__tests__/marketplace-version.test.ts +64 -61
- package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
- package/src/__tests__/plugin-requires.test.ts +55 -1
- package/src/__tests__/plugin-setup.test.ts +111 -9
- package/src/__tests__/profile-command.test.ts +1 -7
- package/src/__tests__/profile-materializer.test.ts +31 -8
- package/src/__tests__/profile-sync.test.ts +23 -5
- package/src/__tests__/registry-version-resolution.test.ts +61 -47
- package/src/__tests__/resolve-executable.test.ts +1 -1
- package/src/__tests__/resolver.test.ts +38 -13
- package/src/__tests__/scope-action.test.ts +52 -50
- package/src/__tests__/scope-squares.test.tsx +4 -5
- package/src/__tests__/skill-install-files.test.ts +111 -103
- package/src/__tests__/symlink-manager.test.ts +27 -10
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/update-apply.test.ts +1 -3
- package/src/__tests__/useGitignoreModal.test.ts +69 -61
- package/src/__tests__/version-snapshot.test.ts +183 -165
- package/src/cli/ansi.ts +23 -15
- package/src/cli/doctor.ts +10 -9
- package/src/cli/install.ts +3 -3
- package/src/cli/profile.ts +5 -4
- package/src/cli/prompt.ts +0 -1
- package/src/cli/router.ts +69 -2
- package/src/cli/update-view.ts +11 -6
- package/src/cli/update.ts +9 -4
- package/src/cli/upgrade.ts +6 -4
- package/src/data/alias-flags.ts +260 -260
- package/src/data/gitignore-defaults.ts +20 -20
- package/src/data/gitignore-reasons.ts +86 -92
- package/src/data/gitignore-templates.ts +14 -14
- package/src/data/predefined-profiles.ts +1 -2
- package/src/data/skill-repos.ts +43 -35
- package/src/main.tsx +29 -6
- package/src/opentui.d.ts +1 -3
- package/src/prerunner/index.ts +7 -8
- package/src/services/alias-settings.ts +20 -23
- package/src/services/alias-shell-writer.ts +800 -803
- package/src/services/alias-store.ts +77 -77
- package/src/services/claude-settings.ts +27 -18
- package/src/services/content-drift.ts +14 -4
- package/src/services/conventions-manager.ts +692 -617
- package/src/services/doctor.ts +464 -403
- package/src/services/gitignore-detector.ts +113 -103
- package/src/services/gitignore-fixer.ts +204 -189
- package/src/services/gitignore-prerun.ts +26 -26
- package/src/services/gitignore-resolver.ts +162 -144
- package/src/services/gitignore-service.ts +120 -117
- package/src/services/install-plan.ts +4 -4
- package/src/services/local-marketplace.ts +7 -5
- package/src/services/manifest.ts +1 -1
- package/src/services/marketplace-catalog-git.ts +5 -2
- package/src/services/marketplace-fetcher.ts +241 -235
- package/src/services/plugin-manager.ts +17 -20
- package/src/services/plugin-mcp-config.ts +2 -2
- package/src/services/plugin-requires.ts +16 -8
- package/src/services/plugin-setup.ts +32 -15
- package/src/services/plugin-version-check.ts +4 -4
- package/src/services/profile-materializer.ts +9 -5
- package/src/services/profile-sync.ts +12 -4
- package/src/services/profiles.ts +3 -3
- package/src/services/resolver.ts +10 -6
- package/src/services/settings-manager.ts +82 -46
- package/src/services/skills-manager.ts +11 -9
- package/src/services/symlink-manager.ts +2 -5
- package/src/services/toolchain.ts +3 -3
- package/src/services/update-cache.ts +1 -1
- package/src/services/update-engine.ts +4 -4
- package/src/services/update-plan.ts +9 -11
- package/src/services/version-snapshot.ts +88 -88
- package/src/types/gitignore.ts +28 -28
- package/src/ui/App.tsx +36 -36
- package/src/ui/adapters/settingsAdapter.ts +2 -2
- package/src/ui/components/CategoryHeader.tsx +1 -1
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +3 -1
- package/src/ui/components/ScrollableList.tsx +2 -1
- package/src/ui/components/StyledText.tsx +1 -1
- package/src/ui/components/layout/FooterHints.tsx +5 -3
- package/src/ui/components/layout/Panel.tsx +2 -2
- package/src/ui/components/layout/ScreenLayout.tsx +5 -4
- package/src/ui/components/modals/InputModal.tsx +7 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -3
- package/src/ui/components/modals/SelectModal.tsx +1 -1
- package/src/ui/components/primitives/DetailSection.tsx +1 -1
- package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +1 -5
- package/src/ui/hooks/useMismatchModal.ts +4 -10
- package/src/ui/renderers/cliToolRenderers.tsx +2 -2
- package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +3 -9
- package/src/ui/renderers/profileRenderers.tsx +9 -8
- package/src/ui/renderers/settingsRenderers.tsx +4 -4
- package/src/ui/renderers/skillRenderers.tsx +14 -14
- package/src/ui/screens/AliasScreen.tsx +31 -34
- package/src/ui/screens/CliToolsScreen.tsx +14 -13
- package/src/ui/screens/EnvVarsScreen.tsx +8 -8
- package/src/ui/screens/GitignoreScreen.tsx +15 -14
- package/src/ui/screens/McpRegistryScreen.tsx +10 -7
- package/src/ui/screens/McpScreen.tsx +10 -10
- package/src/ui/screens/PluginsScreen.tsx +17 -21
- package/src/ui/screens/ProfilesScreen.tsx +25 -24
- package/src/ui/screens/SkillsScreen.tsx +16 -14
- package/src/ui/state/AppContext.tsx +4 -8
- package/src/ui/state/DimensionsContext.tsx +3 -2
- package/src/ui/theme-mode.ts +20 -38
- package/src/ui/theme-resolve.ts +148 -0
- package/src/utils/clipboard.ts +5 -5
- package/src/utils/command-utils.ts +2 -2
- package/src/utils/config-dir.ts +1 -3
- package/src/utils/file-locking.ts +88 -86
- package/src/utils/go-module.ts +36 -0
- package/src/ui/hooks/useAsyncData.ts +0 -127
- package/src/ui/state/AnimationContext.tsx +0 -76
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
3
|
+
import type {
|
|
4
|
+
InstalledPluginsRegistry,
|
|
5
|
+
PluginRelease,
|
|
6
|
+
} from "../types/index.js";
|
|
7
|
+
import { formatMarketplaceName, parsePluginId } from "../utils/string-utils.js";
|
|
8
|
+
import type { PluginScope } from "./claude-cli.js";
|
|
2
9
|
import {
|
|
3
10
|
getConfiguredMarketplaces,
|
|
4
11
|
getEnabledPlugins,
|
|
5
12
|
getGlobalClaudeDir,
|
|
6
|
-
readSettings,
|
|
7
|
-
writeSettings,
|
|
8
13
|
getGlobalConfiguredMarketplaces,
|
|
9
14
|
getGlobalEnabledPlugins,
|
|
10
15
|
getGlobalInstalledPluginVersions,
|
|
@@ -13,40 +18,32 @@ import {
|
|
|
13
18
|
getProjectInstalledPluginVersions,
|
|
14
19
|
pickRegistryEntry,
|
|
15
20
|
readInstalledPluginsRegistry,
|
|
21
|
+
readSettings,
|
|
22
|
+
removeFromInstalledPluginsRegistry,
|
|
16
23
|
saveGlobalInstalledPluginVersion,
|
|
17
24
|
saveLocalInstalledPluginVersion,
|
|
18
25
|
updateInstalledPluginsRegistry,
|
|
19
|
-
|
|
26
|
+
writeSettings,
|
|
20
27
|
} from "./claude-settings.js";
|
|
21
|
-
import type { PluginScope } from "./claude-cli.js";
|
|
22
28
|
import { hasContentDrift } from "./content-drift.js";
|
|
23
29
|
import { inheritablePaths } from "./git-worktree.js";
|
|
24
|
-
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
25
|
-
import type {
|
|
26
|
-
InstalledPluginsRegistry,
|
|
27
|
-
PluginRelease,
|
|
28
|
-
} from "../types/index.js";
|
|
29
30
|
import {
|
|
30
|
-
scanLocalMarketplaces,
|
|
31
|
-
repairAllMarketplaces,
|
|
32
31
|
type LocalMarketplace,
|
|
33
32
|
type ProgressCallback,
|
|
34
33
|
type RepairMarketplaceResult,
|
|
34
|
+
repairAllMarketplaces,
|
|
35
|
+
scanLocalMarketplaces,
|
|
35
36
|
} from "./local-marketplace.js";
|
|
36
37
|
import {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
import {
|
|
38
|
+
type CatalogSource,
|
|
39
|
+
type MarketplaceFetchFailure,
|
|
40
|
+
type MarketplacePlugin,
|
|
41
41
|
clearMarketplaceCache as clearFetcherCache,
|
|
42
42
|
clearPersistedCatalogs,
|
|
43
43
|
fetchMarketplacePlugins,
|
|
44
44
|
getMarketplaceFetchFailures,
|
|
45
45
|
isAuthoritative,
|
|
46
46
|
resolveMarketplacePlugins,
|
|
47
|
-
type CatalogSource,
|
|
48
|
-
type MarketplaceFetchFailure,
|
|
49
|
-
type MarketplacePlugin,
|
|
50
47
|
} from "./marketplace-fetcher.js";
|
|
51
48
|
export {
|
|
52
49
|
fetchMarketplacePlugins,
|
|
@@ -224,7 +221,6 @@ export function resolveScopeAction(
|
|
|
224
221
|
return hasUpdate ? "update" : "uninstall";
|
|
225
222
|
}
|
|
226
223
|
|
|
227
|
-
|
|
228
224
|
export async function getAvailablePlugins(
|
|
229
225
|
projectPath?: string,
|
|
230
226
|
): Promise<PluginInfo[]> {
|
|
@@ -728,7 +724,8 @@ async function annotateContentDrift(
|
|
|
728
724
|
plugins.map(async (plugin) => {
|
|
729
725
|
if (plugin.hasUpdate || plugin.isOrphaned) return;
|
|
730
726
|
if (!plugin.installedVersion || !plugin.version) return;
|
|
731
|
-
if (compareVersions(plugin.version, plugin.installedVersion) !== 0)
|
|
727
|
+
if (compareVersions(plugin.version, plugin.installedVersion) !== 0)
|
|
728
|
+
return;
|
|
732
729
|
|
|
733
730
|
const entry = pickRegistryEntry(
|
|
734
731
|
registry.plugins[plugin.id],
|
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
* bug where a plugin's MCP server silently fails because its binary is missing.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import fs from "fs-extra";
|
|
11
|
-
import path from "node:path";
|
|
12
10
|
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import fs from "fs-extra";
|
|
13
13
|
import type {
|
|
14
14
|
BinInstaller,
|
|
15
15
|
BinRequirement,
|
|
16
16
|
PluginRequires,
|
|
17
17
|
} from "../types/index.js";
|
|
18
|
+
import { goBinaryName } from "../utils/go-module.js";
|
|
18
19
|
import { parsePluginId } from "../utils/string-utils.js";
|
|
19
20
|
import { getLocalMarketplacesInfo } from "./plugin-manager.js";
|
|
20
21
|
|
|
@@ -79,9 +80,7 @@ function parseSetup(setup: unknown): BinRequirement[] {
|
|
|
79
80
|
const s = setup as Record<string, unknown>;
|
|
80
81
|
const bins: BinRequirement[] = [];
|
|
81
82
|
|
|
82
|
-
const pkgManager = (
|
|
83
|
-
key: "bun" | "npm" | "pip",
|
|
84
|
-
): void => {
|
|
83
|
+
const pkgManager = (key: "bun" | "npm" | "pip"): void => {
|
|
85
84
|
for (const spec of (s[key] as string[] | undefined) ?? []) {
|
|
86
85
|
if (typeof spec !== "string") continue;
|
|
87
86
|
const { base, version } = splitVersion(spec);
|
|
@@ -98,20 +97,29 @@ function parseSetup(setup: unknown): BinRequirement[] {
|
|
|
98
97
|
pkgManager("npm");
|
|
99
98
|
pkgManager("pip");
|
|
100
99
|
|
|
100
|
+
// Not basename(): a Go module's trailing /vN is its major version, not the
|
|
101
|
+
// binary. `tmux-mcp/v2` installs `tmux-mcp`; naming it `v2` here made every
|
|
102
|
+
// downstream presence check look for a binary that never exists.
|
|
101
103
|
for (const spec of (s.go as string[] | undefined) ?? []) {
|
|
102
104
|
if (typeof spec !== "string") continue;
|
|
103
105
|
const { base, version } = splitVersion(spec);
|
|
106
|
+
const name = goBinaryName(base);
|
|
104
107
|
bins.push({
|
|
105
|
-
name
|
|
108
|
+
name,
|
|
106
109
|
via: "go",
|
|
107
110
|
module: base,
|
|
108
111
|
version,
|
|
109
|
-
check: `${
|
|
112
|
+
check: `${name} --version`,
|
|
110
113
|
});
|
|
111
114
|
}
|
|
112
115
|
for (const spec of (s.brew as string[] | undefined) ?? []) {
|
|
113
116
|
if (typeof spec !== "string") continue;
|
|
114
|
-
bins.push({
|
|
117
|
+
bins.push({
|
|
118
|
+
name: spec,
|
|
119
|
+
via: "brew",
|
|
120
|
+
formula: spec,
|
|
121
|
+
check: `${spec} --version`,
|
|
122
|
+
});
|
|
115
123
|
}
|
|
116
124
|
// `required` names a system tool that must be present; brew is the assumed
|
|
117
125
|
// installer (matches the "system tools via brew" decision).
|
|
@@ -26,13 +26,14 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { execFile } from "node:child_process";
|
|
29
|
-
import { promisify } from "node:util";
|
|
30
|
-
import fs from "fs-extra";
|
|
31
|
-
import { access } from "node:fs/promises";
|
|
32
29
|
import { constants as fsConstants } from "node:fs";
|
|
33
|
-
import
|
|
30
|
+
import { access } from "node:fs/promises";
|
|
34
31
|
import os from "node:os";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import { promisify } from "node:util";
|
|
34
|
+
import fs from "fs-extra";
|
|
35
35
|
import { which } from "../utils/command-utils.js";
|
|
36
|
+
import { goBinaryName } from "../utils/go-module.js";
|
|
36
37
|
|
|
37
38
|
const execFileAsync = promisify(execFile);
|
|
38
39
|
|
|
@@ -392,14 +393,32 @@ async function installCargoPackages(
|
|
|
392
393
|
}
|
|
393
394
|
}
|
|
394
395
|
|
|
396
|
+
/**
|
|
397
|
+
* {@link parseAtSpec} for a Go module spec.
|
|
398
|
+
*
|
|
399
|
+
* Same split, but the name comes from {@link goBinaryName}: Go's `/vN`
|
|
400
|
+
* major-version suffix is part of the module path, not the binary, so
|
|
401
|
+
* "github.com/MadAppGang/tmux-mcp/v2@v2.0.0" installs `tmux-mcp`, not `v2`.
|
|
402
|
+
* The generic parser cannot do this — an npm scope may legitimately be `v2`.
|
|
403
|
+
*/
|
|
404
|
+
export function parseGoSpec(spec: string): ParsedSpec {
|
|
405
|
+
const parsed = parseAtSpec(spec);
|
|
406
|
+
// Same separator rule as parseAtSpec: a leading `@` is never a version.
|
|
407
|
+
const at = spec.lastIndexOf("@");
|
|
408
|
+
const modulePath = at > 0 ? spec.slice(0, at) : spec;
|
|
409
|
+
return { ...parsed, name: goBinaryName(modulePath) };
|
|
410
|
+
}
|
|
411
|
+
|
|
395
412
|
/**
|
|
396
413
|
* Extract binary name from a Go module path.
|
|
397
|
-
* The binary name is the last path segment before @version
|
|
398
|
-
*
|
|
399
|
-
*
|
|
414
|
+
* The binary name is the last path segment before @version, minus any /vN
|
|
415
|
+
* major-version suffix.
|
|
416
|
+
* e.g., "github.com/MadAppGang/tmux-mcp@latest" → "tmux-mcp"
|
|
417
|
+
* "github.com/MadAppGang/tmux-mcp/v2@v2.0.0" → "tmux-mcp"
|
|
418
|
+
* "github.com/user/tool" → "tool"
|
|
400
419
|
*/
|
|
401
420
|
export function extractGoBinaryName(pkg: string): string {
|
|
402
|
-
return
|
|
421
|
+
return parseGoSpec(pkg).name;
|
|
403
422
|
}
|
|
404
423
|
|
|
405
424
|
/**
|
|
@@ -409,7 +428,7 @@ export function extractGoBinaryName(pkg: string): string {
|
|
|
409
428
|
* "github.com/user/tool" → null
|
|
410
429
|
*/
|
|
411
430
|
export function extractGoVersion(pkg: string): string | null {
|
|
412
|
-
return
|
|
431
|
+
return parseGoSpec(pkg).version;
|
|
413
432
|
}
|
|
414
433
|
|
|
415
434
|
/** Normalize a semver-ish string for comparison: trim and drop a leading "v". */
|
|
@@ -641,7 +660,7 @@ export async function binaryDepDecision(
|
|
|
641
660
|
|
|
642
661
|
/** {@link binaryDepDecision} for a Go module path. */
|
|
643
662
|
export async function goDepDecision(pkg: string): Promise<GoDepDecision> {
|
|
644
|
-
return binaryDepDecision(
|
|
663
|
+
return binaryDepDecision(parseGoSpec(pkg), getGoInstallDir);
|
|
645
664
|
}
|
|
646
665
|
|
|
647
666
|
/**
|
|
@@ -781,10 +800,10 @@ export async function getPluginSetupConfig(
|
|
|
781
800
|
|
|
782
801
|
// Find the version directory
|
|
783
802
|
const versions = await fs.readdir(pluginPath);
|
|
784
|
-
if (versions.length === 0) return null;
|
|
785
803
|
|
|
786
804
|
// Use the latest version directory
|
|
787
|
-
const latestVersion = versions.sort().pop()
|
|
805
|
+
const latestVersion = versions.sort().pop();
|
|
806
|
+
if (latestVersion === undefined) return null;
|
|
788
807
|
const manifestPath = path.join(pluginPath, latestVersion, "plugin.json");
|
|
789
808
|
|
|
790
809
|
if (!(await fs.pathExists(manifestPath))) return null;
|
|
@@ -824,9 +843,7 @@ export async function getPluginSetupFromSource(
|
|
|
824
843
|
try {
|
|
825
844
|
const manifest = await fs.readJson(pluginJson);
|
|
826
845
|
return manifest.setup || null;
|
|
827
|
-
} catch {
|
|
828
|
-
continue;
|
|
829
|
-
}
|
|
846
|
+
} catch {}
|
|
830
847
|
}
|
|
831
848
|
}
|
|
832
849
|
|
|
@@ -276,9 +276,7 @@ export function formatMismatchWarning(
|
|
|
276
276
|
* Format mismatch info for TUI modal display.
|
|
277
277
|
* Returns a multi-line string suitable for a modal message.
|
|
278
278
|
*/
|
|
279
|
-
export function formatMismatchModal(
|
|
280
|
-
mismatches: VersionMismatchInfo[],
|
|
281
|
-
): string {
|
|
279
|
+
export function formatMismatchModal(mismatches: VersionMismatchInfo[]): string {
|
|
282
280
|
const lines: string[] = [];
|
|
283
281
|
|
|
284
282
|
lines.push("Your plugins will not work correctly in this project.");
|
|
@@ -290,7 +288,9 @@ export function formatMismatchModal(
|
|
|
290
288
|
|
|
291
289
|
for (const m of mismatches) {
|
|
292
290
|
const name = m.pluginId.split("@")[0];
|
|
293
|
-
lines.push(
|
|
291
|
+
lines.push(
|
|
292
|
+
` ${name}: v${m.firstEntryVersion} loaded, expected v${m.currentProjectVersion}`,
|
|
293
|
+
);
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
lines.push("");
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* and touches no global state, so it is fully testable with a temp dir.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import fs from "fs-extra";
|
|
15
14
|
import path from "node:path";
|
|
15
|
+
import fs from "fs-extra";
|
|
16
16
|
import type { ResolvedClosure } from "../types/index.js";
|
|
17
17
|
import { profileDir } from "./symlink-manager.js";
|
|
18
18
|
|
|
@@ -48,9 +48,9 @@ export function buildProfileSettings(
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** Build the .mcp.json contents for a profile. */
|
|
51
|
-
export function buildProfileMcp(
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
export function buildProfileMcp(closure: ResolvedClosure): {
|
|
52
|
+
mcpServers: Record<string, unknown>;
|
|
53
|
+
} {
|
|
54
54
|
return { mcpServers: { ...closure.mcpServers } };
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -73,7 +73,11 @@ async function seedSkillsFromProject(
|
|
|
73
73
|
): Promise<void> {
|
|
74
74
|
if (await fs.pathExists(skillsDir)) return;
|
|
75
75
|
|
|
76
|
-
const projectSkills = path.join(
|
|
76
|
+
const projectSkills = path.join(
|
|
77
|
+
projectPath ?? process.cwd(),
|
|
78
|
+
".claude",
|
|
79
|
+
"skills",
|
|
80
|
+
);
|
|
77
81
|
try {
|
|
78
82
|
// lstat, not pathExists: an existing profile symlink is not a source.
|
|
79
83
|
const stat = await fs.lstat(projectSkills);
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* becomes a reviewable manifest diff instead of silent, throwaway drift.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import fs from "fs-extra";
|
|
12
11
|
import path from "node:path";
|
|
12
|
+
import fs from "fs-extra";
|
|
13
13
|
import type {
|
|
14
14
|
McpServerConfig,
|
|
15
15
|
ProfileManifest,
|
|
@@ -68,7 +68,10 @@ export function computeSync(
|
|
|
68
68
|
const { enabledPlugins: _drop, ...settings } = materializedSettings;
|
|
69
69
|
if (JSON.stringify(settings) !== JSON.stringify(entry.settings ?? {})) {
|
|
70
70
|
if (Object.keys(settings).length > 0) next.settings = settings;
|
|
71
|
-
else
|
|
71
|
+
else {
|
|
72
|
+
// biome-ignore lint/performance/noDelete: the manifest entry is serialised to profiles.json and diffed with JSON.stringify/toStrictEqual; the key must be absent, not undefined
|
|
73
|
+
delete next.settings;
|
|
74
|
+
}
|
|
72
75
|
changes.push({ kind: "settings-changed", detail: "settings updated" });
|
|
73
76
|
}
|
|
74
77
|
|
|
@@ -76,7 +79,10 @@ export function computeSync(
|
|
|
76
79
|
const mcp = materializedMcp.mcpServers ?? {};
|
|
77
80
|
if (JSON.stringify(mcp) !== JSON.stringify(entry.mcpServers ?? {})) {
|
|
78
81
|
if (Object.keys(mcp).length > 0) next.mcpServers = mcp;
|
|
79
|
-
else
|
|
82
|
+
else {
|
|
83
|
+
// biome-ignore lint/performance/noDelete: the manifest entry is serialised to profiles.json and diffed with JSON.stringify/toStrictEqual; the key must be absent, not undefined
|
|
84
|
+
delete next.mcpServers;
|
|
85
|
+
}
|
|
80
86
|
changes.push({ kind: "mcp-changed", detail: "mcp servers updated" });
|
|
81
87
|
}
|
|
82
88
|
|
|
@@ -129,7 +135,9 @@ export async function syncProfile(
|
|
|
129
135
|
const settingsPath = path.join(dir, "settings.json");
|
|
130
136
|
const mcpPath = path.join(dir, "mcp.json");
|
|
131
137
|
if (!(await fs.pathExists(settingsPath))) {
|
|
132
|
-
throw new Error(
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Profile "${name}" isn't materialized (${settingsPath} missing).`,
|
|
140
|
+
);
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
const materializedSettings = await fs.readJson(settingsPath);
|
package/src/services/profiles.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import fs from "fs-extra";
|
|
2
|
-
import path from "node:path";
|
|
3
1
|
import os from "node:os";
|
|
4
|
-
import
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "fs-extra";
|
|
4
|
+
import type { Profile, ProfileEntry, ProfilesFile } from "../types/index.js";
|
|
5
5
|
|
|
6
6
|
const PROFILES_FILE = "profiles.json";
|
|
7
7
|
|
package/src/services/resolver.ts
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import semver from "semver";
|
|
12
|
+
import { cliTools } from "../data/cli-tools.js";
|
|
13
|
+
import { getMarketplaceByName } from "../data/marketplaces.js";
|
|
14
|
+
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
12
15
|
import type {
|
|
13
16
|
BinRequirement,
|
|
14
17
|
ProfileManifest,
|
|
@@ -16,9 +19,6 @@ import type {
|
|
|
16
19
|
ResolvedBin,
|
|
17
20
|
ResolvedClosure,
|
|
18
21
|
} from "../types/index.js";
|
|
19
|
-
import { cliTools } from "../data/cli-tools.js";
|
|
20
|
-
import { getMarketplaceByName } from "../data/marketplaces.js";
|
|
21
|
-
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
22
22
|
import { resolvePluginBinRequirements } from "./plugin-requires.js";
|
|
23
23
|
|
|
24
24
|
export interface ResolveOptions {
|
|
@@ -237,17 +237,21 @@ function pickVersion(
|
|
|
237
237
|
* resolved deterministically to the higher pin — NOT by profile order — and
|
|
238
238
|
* collected into `conflicts` so `install`/`profile show` can surface them.
|
|
239
239
|
*/
|
|
240
|
-
function mergeClosures(
|
|
240
|
+
function mergeClosures(
|
|
241
|
+
a: ResolvedClosure,
|
|
242
|
+
b: ResolvedClosure,
|
|
243
|
+
): ResolvedClosure {
|
|
241
244
|
const conflicts = [...(a.conflicts ?? []), ...(b.conflicts ?? [])];
|
|
242
245
|
|
|
243
246
|
// Plugins: deterministic version pick per id.
|
|
244
247
|
const plugins: Record<string, string> = { ...a.plugins };
|
|
245
248
|
for (const [id, ver] of Object.entries(b.plugins)) {
|
|
246
|
-
|
|
249
|
+
const existing = plugins[id];
|
|
250
|
+
if (existing === undefined) {
|
|
247
251
|
plugins[id] = ver;
|
|
248
252
|
continue;
|
|
249
253
|
}
|
|
250
|
-
const { chosen, conflict } = pickVersion(id,
|
|
254
|
+
const { chosen, conflict } = pickVersion(id, existing, ver);
|
|
251
255
|
plugins[id] = chosen;
|
|
252
256
|
if (conflict) conflicts.push(conflict);
|
|
253
257
|
}
|
|
@@ -1,42 +1,74 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { SettingDefinition } from "../data/settings-catalog.js";
|
|
4
|
+
import type { ClaudeSettings } from "../types/index.js";
|
|
3
5
|
import {
|
|
4
|
-
readSettings,
|
|
5
|
-
writeSettings,
|
|
6
6
|
readGlobalSettings,
|
|
7
|
+
readSettings,
|
|
7
8
|
writeGlobalSettings,
|
|
9
|
+
writeSettings,
|
|
8
10
|
} from "./claude-settings.js";
|
|
9
|
-
import type { SettingDefinition } from "../data/settings-catalog.js";
|
|
10
11
|
|
|
11
12
|
export type SettingScope = "user" | "project";
|
|
12
13
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* settings.json is an open JSON object: the catalog addresses keys that the
|
|
16
|
+
* typed `ClaudeSettings` shape does not enumerate (`attribution`, `env`, any
|
|
17
|
+
* dotted path). Read and write through this record view and narrow each value.
|
|
18
|
+
*/
|
|
19
|
+
type SettingsRecord = ClaudeSettings & Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The `attribution` block as Claude Code stores it. */
|
|
26
|
+
interface Attribution {
|
|
27
|
+
commit?: unknown;
|
|
28
|
+
pr?: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readAttribution(settings: SettingsRecord): Attribution | undefined {
|
|
32
|
+
const raw = settings.attribution;
|
|
33
|
+
return isRecord(raw) ? raw : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function loadSettings(
|
|
16
37
|
scope: SettingScope,
|
|
17
38
|
projectPath?: string,
|
|
18
|
-
): Promise<
|
|
39
|
+
): Promise<SettingsRecord> {
|
|
19
40
|
const settings =
|
|
20
41
|
scope === "user"
|
|
21
42
|
? await readGlobalSettings()
|
|
22
43
|
: await readSettings(projectPath);
|
|
44
|
+
return settings as SettingsRecord;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Read the current value of a setting from the given scope */
|
|
48
|
+
export async function readSettingValue(
|
|
49
|
+
setting: SettingDefinition,
|
|
50
|
+
scope: SettingScope,
|
|
51
|
+
projectPath?: string,
|
|
52
|
+
): Promise<string | undefined> {
|
|
53
|
+
const settings = await loadSettings(scope, projectPath);
|
|
23
54
|
|
|
24
55
|
if (setting.storage.type === "attribution") {
|
|
25
56
|
// Attribution is an object: { commit: "", pr: "" } means disabled
|
|
26
|
-
const attr = (settings
|
|
57
|
+
const attr = readAttribution(settings);
|
|
27
58
|
if (attr && attr.commit === "" && attr.pr === "") {
|
|
28
59
|
return "false";
|
|
29
60
|
}
|
|
30
61
|
return undefined; // default (enabled)
|
|
31
|
-
}
|
|
62
|
+
}
|
|
63
|
+
if (setting.storage.type === "attribution-text") {
|
|
32
64
|
// Custom attribution text: read from attribution.commit, strip the Co-Authored-By trailer prefix
|
|
33
|
-
const attr = (settings
|
|
65
|
+
const attr = readAttribution(settings);
|
|
34
66
|
if (!attr || (attr.commit === "" && attr.pr === "")) {
|
|
35
67
|
// Attribution is disabled or not set — no custom text stored
|
|
36
68
|
return undefined;
|
|
37
69
|
}
|
|
38
|
-
const commit
|
|
39
|
-
if (
|
|
70
|
+
const commit = attr.commit;
|
|
71
|
+
if (typeof commit !== "string" || commit === "") {
|
|
40
72
|
return undefined;
|
|
41
73
|
}
|
|
42
74
|
// The commit value is "Co-Authored-By: Magus <magus@madappgang.com>\n\n{customText}"
|
|
@@ -48,18 +80,19 @@ export async function readSettingValue(
|
|
|
48
80
|
}
|
|
49
81
|
// If no trailer prefix, the value is the raw text (or Claude default — return undefined)
|
|
50
82
|
return undefined;
|
|
51
|
-
} else if (setting.storage.type === "env") {
|
|
52
|
-
const env = (settings as any).env as Record<string, string> | undefined;
|
|
53
|
-
return env?.[setting.storage.key];
|
|
54
|
-
} else {
|
|
55
|
-
// Direct settings key (supports dot notation like "permissions.defaultMode")
|
|
56
|
-
const keys = setting.storage.key.split(".");
|
|
57
|
-
let value: any = settings;
|
|
58
|
-
for (const k of keys) {
|
|
59
|
-
value = value?.[k];
|
|
60
|
-
}
|
|
61
|
-
return value !== undefined && value !== null ? String(value) : undefined;
|
|
62
83
|
}
|
|
84
|
+
if (setting.storage.type === "env") {
|
|
85
|
+
const env = settings.env;
|
|
86
|
+
const raw = isRecord(env) ? env[setting.storage.key] : undefined;
|
|
87
|
+
return typeof raw === "string" ? raw : undefined;
|
|
88
|
+
}
|
|
89
|
+
// Direct settings key (supports dot notation like "permissions.defaultMode")
|
|
90
|
+
const keys = setting.storage.key.split(".");
|
|
91
|
+
let value: unknown = settings;
|
|
92
|
+
for (const k of keys) {
|
|
93
|
+
value = isRecord(value) ? value[k] : undefined;
|
|
94
|
+
}
|
|
95
|
+
return value !== undefined && value !== null ? String(value) : undefined;
|
|
63
96
|
}
|
|
64
97
|
|
|
65
98
|
/** Write a setting value to the given scope */
|
|
@@ -69,57 +102,60 @@ export async function writeSettingValue(
|
|
|
69
102
|
scope: SettingScope,
|
|
70
103
|
projectPath?: string,
|
|
71
104
|
): Promise<void> {
|
|
72
|
-
const settings =
|
|
73
|
-
scope === "user"
|
|
74
|
-
? await readGlobalSettings()
|
|
75
|
-
: await readSettings(projectPath);
|
|
105
|
+
const settings = await loadSettings(scope, projectPath);
|
|
76
106
|
|
|
77
107
|
if (setting.storage.type === "attribution") {
|
|
78
108
|
// Boolean toggle: "false" -> write { commit: "", pr: "" }, "true"/undefined -> delete key
|
|
79
109
|
if (value === "false") {
|
|
80
|
-
|
|
110
|
+
settings.attribution = { commit: "", pr: "" };
|
|
81
111
|
} else {
|
|
82
|
-
|
|
112
|
+
// biome-ignore lint/performance/noDelete: the key must be absent from the serialised settings.json; `undefined` would still be listed by Object.keys and defeat the "revert to Claude defaults" contract
|
|
113
|
+
delete settings.attribution;
|
|
83
114
|
}
|
|
84
115
|
} else if (setting.storage.type === "attribution-text") {
|
|
85
116
|
if (value && value.trim().length > 0) {
|
|
86
117
|
// Write custom text: commit gets a Co-Authored-By trailer + the text; pr gets the text
|
|
87
|
-
|
|
118
|
+
settings.attribution = {
|
|
88
119
|
commit: `Co-Authored-By: Magus <magus@madappgang.com>\n\n${value}`,
|
|
89
120
|
pr: value,
|
|
90
121
|
};
|
|
91
122
|
} else {
|
|
92
123
|
// Empty value: remove attribution key entirely (revert to Claude defaults)
|
|
93
|
-
|
|
124
|
+
// biome-ignore lint/performance/noDelete: the key must be absent from the serialised settings.json, not present with an undefined value
|
|
125
|
+
delete settings.attribution;
|
|
94
126
|
}
|
|
95
127
|
} else if (setting.storage.type === "env") {
|
|
96
128
|
// Write to the "env" block in settings.json
|
|
97
|
-
|
|
129
|
+
const env = isRecord(settings.env) ? settings.env : {};
|
|
130
|
+
settings.env = env;
|
|
98
131
|
if (value === undefined || value === "" || value === setting.defaultValue) {
|
|
99
|
-
delete
|
|
132
|
+
delete env[setting.storage.key];
|
|
100
133
|
// Clean up empty env block
|
|
101
|
-
if (Object.keys(
|
|
102
|
-
|
|
134
|
+
if (Object.keys(env).length === 0) {
|
|
135
|
+
// biome-ignore lint/performance/noDelete: an empty env block is removed from the file entirely; assigning undefined would leave the key for Object.keys and JSON readers that check `in`
|
|
136
|
+
delete settings.env;
|
|
103
137
|
}
|
|
104
138
|
} else {
|
|
105
|
-
|
|
139
|
+
env[setting.storage.key] = value;
|
|
106
140
|
}
|
|
107
141
|
} else {
|
|
108
142
|
// Direct settings key (supports dot notation)
|
|
109
143
|
const keys = setting.storage.key.split(".");
|
|
110
144
|
if (keys.length === 1) {
|
|
111
145
|
if (value === undefined || value === "") {
|
|
112
|
-
delete
|
|
146
|
+
delete settings[keys[0]];
|
|
113
147
|
} else {
|
|
114
148
|
// Try to preserve type: boolean, number, or string
|
|
115
|
-
|
|
149
|
+
settings[keys[0]] = parseSettingValue(value, setting.type);
|
|
116
150
|
}
|
|
117
151
|
} else {
|
|
118
152
|
// Nested key like "permissions.defaultMode"
|
|
119
|
-
let obj:
|
|
153
|
+
let obj: Record<string, unknown> = settings;
|
|
120
154
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
121
|
-
|
|
122
|
-
|
|
155
|
+
const existing = obj[keys[i]];
|
|
156
|
+
const next = isRecord(existing) ? existing : {};
|
|
157
|
+
obj[keys[i]] = next;
|
|
158
|
+
obj = next;
|
|
123
159
|
}
|
|
124
160
|
const lastKey = keys[keys.length - 1];
|
|
125
161
|
if (value === undefined || value === "") {
|
|
@@ -137,7 +173,7 @@ export async function writeSettingValue(
|
|
|
137
173
|
}
|
|
138
174
|
}
|
|
139
175
|
|
|
140
|
-
function parseSettingValue(value: string, type: string):
|
|
176
|
+
function parseSettingValue(value: string, type: string): string | boolean {
|
|
141
177
|
if (type === "boolean") {
|
|
142
178
|
return value === "true" || value === "1";
|
|
143
179
|
}
|
|
@@ -205,13 +241,13 @@ export async function discoverOutputStyles(
|
|
|
205
241
|
const enabledPlugins = new Set<string>();
|
|
206
242
|
try {
|
|
207
243
|
const userSettings = await readGlobalSettings();
|
|
208
|
-
for (const [k, v] of Object.entries(
|
|
244
|
+
for (const [k, v] of Object.entries(userSettings.enabledPlugins ?? {})) {
|
|
209
245
|
if (v) enabledPlugins.add(k);
|
|
210
246
|
}
|
|
211
247
|
} catch {}
|
|
212
248
|
try {
|
|
213
249
|
const projSettings = await readSettings(projectPath);
|
|
214
|
-
for (const [k, v] of Object.entries(
|
|
250
|
+
for (const [k, v] of Object.entries(projSettings.enabledPlugins ?? {})) {
|
|
215
251
|
if (v) enabledPlugins.add(k);
|
|
216
252
|
}
|
|
217
253
|
} catch {}
|
|
@@ -751,10 +751,14 @@ export async function getInstalledSkillNames(
|
|
|
751
751
|
|
|
752
752
|
// ─── Firebase Skills API ──────────────────────────────────────────────────────
|
|
753
753
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
754
|
+
function mapApiSkillToSkillInfo(entry: unknown): SkillInfo {
|
|
755
|
+
const raw: Record<string, unknown> =
|
|
756
|
+
typeof entry === "object" && entry !== null
|
|
757
|
+
? (entry as Record<string, unknown>)
|
|
758
|
+
: {};
|
|
759
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
|
760
|
+
const repo = str(raw.repo) || "unknown";
|
|
761
|
+
const skillPath = str(raw.skillPath) || str(raw.name) || "";
|
|
758
762
|
const source: SkillSource = {
|
|
759
763
|
label: repo,
|
|
760
764
|
repo,
|
|
@@ -763,8 +767,8 @@ function mapApiSkillToSkillInfo(raw: any): SkillInfo {
|
|
|
763
767
|
const stars = typeof raw.stars === "number" ? raw.stars : undefined;
|
|
764
768
|
return {
|
|
765
769
|
id: `${repo}/${skillPath}`,
|
|
766
|
-
name: (raw.name
|
|
767
|
-
description: (raw.description
|
|
770
|
+
name: str(raw.name) || skillPath,
|
|
771
|
+
description: str(raw.description),
|
|
768
772
|
source,
|
|
769
773
|
repoPath: skillPath ? `${skillPath}/SKILL.md` : "SKILL.md",
|
|
770
774
|
gitBlobSha: "",
|
|
@@ -943,9 +947,7 @@ export async function installSkill(
|
|
|
943
947
|
content = await response.text();
|
|
944
948
|
break;
|
|
945
949
|
}
|
|
946
|
-
} catch {
|
|
947
|
-
continue;
|
|
948
|
-
}
|
|
950
|
+
} catch {}
|
|
949
951
|
}
|
|
950
952
|
|
|
951
953
|
if (!content) {
|