cue-ai 0.5.0 → 0.7.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 +757 -110
- package/package.json +5 -5
- package/profiles/README.md +12 -12
- package/profiles/SCHEMA.md +31 -3
- package/profiles/_cache/README.md +1 -1
- package/profiles/_types.ts +26 -1
- package/profiles/backend/profile.yaml +1 -0
- package/profiles/career/profile.yaml +13 -0
- package/profiles/core/profile.yaml +76 -9
- package/profiles/creative-media/README.md +1 -1
- package/profiles/cybersecurity/profile.yaml +779 -756
- package/profiles/ecc/profile.yaml +39 -0
- package/profiles/event-design/profile.yaml +10 -0
- package/profiles/fleet-control/README.md +1 -1
- package/profiles/frontend/profile.yaml +14 -0
- package/profiles/full/README.md +1 -1
- package/profiles/go-api/profile.yaml +1 -0
- package/profiles/marketing/profile.yaml +12 -1
- package/profiles/predict-everything/profile.yaml +9 -0
- package/profiles/rust/profile.yaml +22 -3
- package/profiles/rust-cli/profile.yaml +14 -0
- package/profiles/rust-core/profile.yaml +35 -0
- package/profiles/rust-embedded/profile.yaml +11 -0
- package/profiles/rust-ffi/profile.yaml +13 -0
- package/profiles/rust-game/profile.yaml +11 -0
- package/profiles/rust-wasm/profile.yaml +11 -0
- package/profiles/rust-web/profile.yaml +17 -0
- package/profiles/schema.json +44 -4
- package/profiles/trendradar/profile.yaml +11 -0
- package/resources/mcps/README.md +39 -164
- package/resources/mcps/configs/claude.sanitized.json +55 -0
- package/resources/mcps/configs/claude_runtime.sanitized.json +47 -0
- package/resources/skills/README.md +70 -113
- package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +43 -0
- package/resources/skills/skills/meta/acpx/SKILL.md +78 -0
- package/resources/skills/skills/meta/cue-usage/SKILL.md +24 -0
- package/resources/skills/skills/meta/profile-fit-monitor/SKILL.md +24 -0
- package/resources/skills/skills/predict-everything/mirofish/SKILL.md +75 -0
- package/resources/skills/skills/research/trendradar/SKILL.md +88 -0
- package/resources/skills/skills/rust/async-tokio/SKILL.md +27 -0
- package/resources/skills/skills/rust/axum-api/SKILL.md +38 -0
- package/resources/skills/skills/rust/bacon-watch/SKILL.md +24 -0
- package/resources/skills/skills/rust/bevy/SKILL.md +43 -0
- package/resources/skills/skills/rust/bindgen/SKILL.md +39 -0
- package/resources/skills/skills/rust/cargo-audit/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-basics/SKILL.md +28 -0
- package/resources/skills/skills/rust/cargo-chef/SKILL.md +43 -0
- package/resources/skills/skills/rust/cargo-edit/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-expand/SKILL.md +24 -0
- package/resources/skills/skills/rust/cargo-flamegraph/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-fuzz/SKILL.md +34 -0
- package/resources/skills/skills/rust/cargo-hack/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-msrv/SKILL.md +30 -0
- package/resources/skills/skills/rust/cargo-mutants/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-nextest/SKILL.md +24 -0
- package/resources/skills/skills/rust/cargo-readme/SKILL.md +36 -0
- package/resources/skills/skills/rust/cbindgen/SKILL.md +41 -0
- package/resources/skills/skills/rust/chisel-tool/SKILL.md +32 -0
- package/resources/skills/skills/rust/clap-cli/SKILL.md +44 -0
- package/resources/skills/skills/rust/clippy-and-fmt/SKILL.md +25 -0
- package/resources/skills/skills/rust/cross-compile/SKILL.md +26 -0
- package/resources/skills/skills/rust/embedded/SKILL.md +33 -0
- package/resources/skills/skills/rust/error-handling/SKILL.md +32 -0
- package/resources/skills/skills/rust/just-runner/SKILL.md +26 -0
- package/resources/skills/skills/rust/mdbook/SKILL.md +25 -0
- package/resources/skills/skills/rust/napi-rs/SKILL.md +32 -0
- package/resources/skills/skills/rust/no-std/SKILL.md +42 -0
- package/resources/skills/skills/rust/property-testing/SKILL.md +35 -0
- package/resources/skills/skills/rust/pyo3/SKILL.md +40 -0
- package/resources/skills/skills/rust/ratatui-tui/SKILL.md +36 -0
- package/resources/skills/skills/rust/release-plz/SKILL.md +27 -0
- package/resources/skills/skills/rust/reqwest/SKILL.md +37 -0
- package/resources/skills/skills/rust/sccache/SKILL.md +28 -0
- package/resources/skills/skills/rust/serde/SKILL.md +30 -0
- package/resources/skills/skills/rust/snapshot-testing/SKILL.md +30 -0
- package/resources/skills/skills/rust/sqlx-cli/SKILL.md +33 -0
- package/resources/skills/skills/rust/tracing/SKILL.md +36 -0
- package/resources/skills/skills/rust/typos-spellcheck/SKILL.md +31 -0
- package/resources/skills/skills/rust/uniffi/SKILL.md +38 -0
- package/resources/skills/skills/rust/wasm-rust/SKILL.md +27 -0
- package/resources/skills/skills/security/agentshield/SKILL.md +119 -0
- package/src/commands/_index.ts +47 -3
- package/src/commands/cli.test.ts +192 -0
- package/src/commands/cli.ts +303 -0
- package/src/commands/current.ts +1 -1
- package/src/commands/debug.test.ts +62 -0
- package/src/commands/debug.ts +212 -0
- package/src/commands/discover.scoring.test.ts +216 -0
- package/src/commands/discover.test.ts +145 -0
- package/src/commands/discover.ts +2618 -0
- package/src/commands/eval-behavior.test.ts +56 -0
- package/src/commands/eval-behavior.ts +189 -0
- package/src/commands/eval.test.ts +102 -0
- package/src/commands/eval.ts +348 -0
- package/src/commands/evolve.ts +291 -0
- package/src/commands/failures.test.ts +78 -0
- package/src/commands/failures.ts +393 -0
- package/src/commands/feedback.ts +219 -0
- package/src/commands/init.ts +26 -0
- package/src/commands/launch.e2e.test.ts +9 -1
- package/src/commands/launch.ts +174 -11
- package/src/commands/lint-skill.ts +157 -0
- package/src/commands/marketplace.ts +763 -2
- package/src/commands/new.ts +1 -1
- package/src/commands/optimizer.ts +92 -28
- package/src/commands/profile-draft-skill.test.ts +96 -0
- package/src/commands/profile-draft-skill.ts +287 -0
- package/src/commands/profile-evolve.test.ts +126 -0
- package/src/commands/profile-evolve.ts +0 -0
- package/src/commands/profile-suggest.ts +223 -0
- package/src/commands/profile.ts +41 -0
- package/src/commands/quick.ts +2 -17
- package/src/commands/scan.ts +2 -2
- package/src/commands/score.ts +1 -1
- package/src/commands/share.ts +1 -1
- package/src/commands/sources.ts +2 -2
- package/src/commands/submit-profile.ts +262 -0
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/use.ts +35 -5
- package/src/commands/validate.ts +1 -1
- package/src/index.ts +66 -0
- package/src/lib/analytics.ts +48 -2
- package/src/lib/claude-binary.ts +39 -0
- package/src/lib/cli-extractor.ts +77 -0
- package/src/lib/cluster-skills.test.ts +268 -0
- package/src/lib/cluster-skills.ts +290 -0
- package/src/lib/credentials-sync.test.ts +208 -0
- package/src/lib/credentials-sync.ts +205 -0
- package/src/lib/mcp-materializer.test.ts +1 -1
- package/src/lib/persona-playbooks.test.ts +111 -0
- package/src/lib/pr-poster.test.ts +243 -0
- package/src/lib/pr-poster.ts +285 -0
- package/src/lib/pr-throttle.test.ts +148 -0
- package/src/lib/pr-throttle.ts +209 -0
- package/src/lib/profile-generator.test.ts +1 -1
- package/src/lib/profile-generator.ts +2 -2
- package/src/lib/profile-linter.test.ts +6 -3
- package/src/lib/profile-linter.ts +71 -8
- package/src/lib/profile-loader.test.ts +1 -1
- package/src/lib/profile-loader.ts +16 -0
- package/src/lib/resolver-local.test.ts +1 -1
- package/src/lib/resolver-npx.test.ts +76 -1
- package/src/lib/resolver-npx.ts +35 -3
- package/src/lib/resolver-plugins.test.ts +1 -1
- package/src/lib/runtime-materializer.test.ts +191 -7
- package/src/lib/runtime-materializer.ts +310 -42
- package/src/lib/scan-plugins.test.ts +1 -1
- package/src/lib/skill-linter.test.ts +174 -0
- package/src/lib/skill-linter.ts +507 -0
- package/src/lib/skill-subset.test.ts +95 -0
- package/src/lib/skill-subset.ts +166 -0
- package/src/lib/star-prompt.ts +1 -1
- package/src/lib/uvx-installer.test.ts +229 -0
- package/src/lib/uvx-installer.ts +278 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart skill subsetting: given a profile's skill list and a user prompt,
|
|
3
|
+
* ask Claude which skills are plausibly relevant. The intent is to cut context
|
|
4
|
+
* bloat in `cue launch` for sessions that only need 3-4 of N skills.
|
|
5
|
+
*
|
|
6
|
+
* Design rules:
|
|
7
|
+
* 1. **Fail open.** Any error path returns the original list unchanged.
|
|
8
|
+
* Smart-subset is an optimization, not a gate. Never make `cue launch`
|
|
9
|
+
* slower than today on the failure path.
|
|
10
|
+
* 2. **Single Claude call.** All skills + prompt in one --print invocation.
|
|
11
|
+
* One round-trip cost (~$0.001, ~2s) regardless of profile size.
|
|
12
|
+
* 3. **Always keep "core" essentials.** A handful of skills (caveman,
|
|
13
|
+
* analyze, cue-usage) are operational primitives — never prune them
|
|
14
|
+
* even if the classifier doesn't pick them.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { findRealClaudeBin } from "./claude-binary";
|
|
22
|
+
import { resolveLocalSkill } from "./resolver-local";
|
|
23
|
+
import { parseMetadataFromContent } from "../commands/optimizer";
|
|
24
|
+
|
|
25
|
+
// Skills that survive every subset filter. They're operational, not domain-
|
|
26
|
+
// specific, and pruning them changes how the agent behaves more than it
|
|
27
|
+
// changes what it can do.
|
|
28
|
+
const ALWAYS_KEEP = new Set([
|
|
29
|
+
"meta/analyze",
|
|
30
|
+
"meta/cue-usage",
|
|
31
|
+
"meta/acpx",
|
|
32
|
+
"caveman/caveman",
|
|
33
|
+
"caveman/caveman-commit",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export interface SubsetResult {
|
|
37
|
+
/** Skill IDs the classifier picked (plus ALWAYS_KEEP). Same ordering as input. */
|
|
38
|
+
selected: string[];
|
|
39
|
+
/** True if classification ran; false if we fell back to the original list. */
|
|
40
|
+
classified: boolean;
|
|
41
|
+
/** One-line reason — useful for the user-facing message. */
|
|
42
|
+
reason: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface SkillDescriptor {
|
|
46
|
+
id: string;
|
|
47
|
+
description: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function loadSkillDescriptor(id: string): Promise<SkillDescriptor> {
|
|
51
|
+
try {
|
|
52
|
+
const dir = await resolveLocalSkill(id);
|
|
53
|
+
const md = join(dir, "SKILL.md");
|
|
54
|
+
if (!existsSync(md)) return { id, description: "" };
|
|
55
|
+
const meta = parseMetadataFromContent(readFileSync(md, "utf8"));
|
|
56
|
+
return { id, description: meta.description };
|
|
57
|
+
} catch {
|
|
58
|
+
return { id, description: "" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildPrompt(prompt: string, descriptors: SkillDescriptor[]): string {
|
|
63
|
+
const lines = descriptors.map((d, i) => `${i + 1}. ${d.id}${d.description ? ` — ${d.description}` : ""}`);
|
|
64
|
+
return `You are choosing which skills to load for a Claude Code session. Each skill is a chunk of system-prompt context; loading every skill costs tokens. Pick only the ones plausibly relevant to the user's first prompt.
|
|
65
|
+
|
|
66
|
+
User prompt:
|
|
67
|
+
${prompt}
|
|
68
|
+
|
|
69
|
+
Available skills:
|
|
70
|
+
${lines.join("\n")}
|
|
71
|
+
|
|
72
|
+
Respond in EXACTLY this format (no other text):
|
|
73
|
+
KEEP: <comma-separated skill IDs from the list above, or "none">
|
|
74
|
+
REASON: <one short sentence>
|
|
75
|
+
|
|
76
|
+
Rules:
|
|
77
|
+
- Pick 3-8 skills, never more than half the list.
|
|
78
|
+
- If the prompt is generic ("help", "what can you do"), respond KEEP: none.
|
|
79
|
+
- If unsure, KEEP fewer skills. The user can always load more by retrying.`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function callClaude(prompt: string, timeoutMs: number): { ok: boolean; output: string } {
|
|
83
|
+
const tryOne = (bin: string) => spawnSync(bin, ["--print", "-p", prompt], {
|
|
84
|
+
encoding: "utf8",
|
|
85
|
+
timeout: timeoutMs,
|
|
86
|
+
env: { ...process.env, CUE_BYPASS: "1" },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
let res = tryOne("claude");
|
|
90
|
+
if (res.status !== 0 || !res.stdout?.trim()) {
|
|
91
|
+
const fallback = findRealClaudeBin();
|
|
92
|
+
if (fallback) res = tryOne(fallback);
|
|
93
|
+
}
|
|
94
|
+
if (res.status !== 0 || !res.stdout?.trim()) return { ok: false, output: "" };
|
|
95
|
+
return { ok: true, output: res.stdout.trim() };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseClaudeKeep(output: string, allSkillIds: string[]): string[] | null {
|
|
99
|
+
const m = output.match(/KEEP:\s*(.+)/i);
|
|
100
|
+
if (!m) return null;
|
|
101
|
+
const raw = m[1]!.trim();
|
|
102
|
+
if (/^none$/i.test(raw)) return [];
|
|
103
|
+
const known = new Set(allSkillIds);
|
|
104
|
+
const picked = raw.split(",").map(s => s.trim()).filter(s => s && known.has(s));
|
|
105
|
+
// Sanity check: if Claude returned nothing usable, signal a parse failure
|
|
106
|
+
// rather than an empty selection.
|
|
107
|
+
return picked.length === 0 ? null : picked;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Returns the subset of `skillIds` relevant to `prompt`. ALWAYS_KEEP skills
|
|
112
|
+
* are always included. If anything goes wrong (no claude binary, timeout,
|
|
113
|
+
* unparseable response), returns the original list unchanged with classified=false.
|
|
114
|
+
*/
|
|
115
|
+
export async function selectRelevantSkills(
|
|
116
|
+
skillIds: string[],
|
|
117
|
+
prompt: string,
|
|
118
|
+
opts: { timeoutMs?: number; minKeep?: number } = {},
|
|
119
|
+
): Promise<SubsetResult> {
|
|
120
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
121
|
+
const minKeep = opts.minKeep ?? 3;
|
|
122
|
+
const trimmed = prompt.trim();
|
|
123
|
+
|
|
124
|
+
if (!trimmed) {
|
|
125
|
+
return { selected: skillIds, classified: false, reason: "empty prompt — kept all skills" };
|
|
126
|
+
}
|
|
127
|
+
// Very short prompts don't have enough signal to classify well.
|
|
128
|
+
if (trimmed.length < 8) {
|
|
129
|
+
return { selected: skillIds, classified: false, reason: `prompt too short (${trimmed.length} chars) — kept all skills` };
|
|
130
|
+
}
|
|
131
|
+
if (skillIds.length <= 4) {
|
|
132
|
+
return { selected: skillIds, classified: false, reason: `only ${skillIds.length} skills — nothing to subset` };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const descriptors = await Promise.all(skillIds.map(loadSkillDescriptor));
|
|
136
|
+
const claudePrompt = buildPrompt(trimmed, descriptors);
|
|
137
|
+
const { ok, output } = callClaude(claudePrompt, timeoutMs);
|
|
138
|
+
if (!ok) {
|
|
139
|
+
return { selected: skillIds, classified: false, reason: "claude --print unavailable — kept all skills" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const picked = parseClaudeKeep(output, skillIds);
|
|
143
|
+
if (picked === null) {
|
|
144
|
+
return { selected: skillIds, classified: false, reason: "could not parse classifier output — kept all skills" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const keepSet = new Set(picked);
|
|
148
|
+
for (const id of skillIds) if (ALWAYS_KEEP.has(id)) keepSet.add(id);
|
|
149
|
+
|
|
150
|
+
if (keepSet.size < minKeep) {
|
|
151
|
+
return { selected: skillIds, classified: false, reason: `classifier picked < ${minKeep} skills — kept all` };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Preserve original ordering.
|
|
155
|
+
const selected = skillIds.filter(id => keepSet.has(id));
|
|
156
|
+
const reasonMatch = output.match(/REASON:\s*(.+)/i);
|
|
157
|
+
const why = reasonMatch?.[1]?.trim().slice(0, 100) ?? "relevance ranking";
|
|
158
|
+
return {
|
|
159
|
+
selected,
|
|
160
|
+
classified: true,
|
|
161
|
+
reason: `${selected.length}/${skillIds.length} skills kept — ${why}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Exported for tests.
|
|
166
|
+
export const __test = { parseClaudeKeep, buildPrompt, ALWAYS_KEEP };
|
package/src/lib/star-prompt.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { spawnSync } from "node:child_process";
|
|
|
11
11
|
const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cue");
|
|
12
12
|
const FLAG_FILE = join(CONFIG_DIR, ".star-prompted");
|
|
13
13
|
const ANALYTICS_FILE = join(CONFIG_DIR, "analytics.jsonl");
|
|
14
|
-
const REPO = "
|
|
14
|
+
const REPO = "opencue/cue";
|
|
15
15
|
const SESSION_THRESHOLD = 10;
|
|
16
16
|
|
|
17
17
|
export async function maybePromptStar(): Promise<void> {
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { normalizeUvxGitServers } from "./uvx-installer";
|
|
6
|
+
import type { McpServerConfig } from "./runtime-materializer";
|
|
7
|
+
|
|
8
|
+
const localBin = (b: string) => join(homedir(), ".local", "bin", b);
|
|
9
|
+
|
|
10
|
+
const stubs = (overrides: {
|
|
11
|
+
binExists?: (b: string) => boolean;
|
|
12
|
+
uvOnPath?: () => boolean;
|
|
13
|
+
install?: (gitUrl: string, binary: string) => { ok: boolean; stderr: string };
|
|
14
|
+
seedAssets?: (gitUrl: string, binary: string, assets: string[]) => string[];
|
|
15
|
+
}) => ({
|
|
16
|
+
binExists: overrides.binExists ?? (() => false),
|
|
17
|
+
uvOnPath: overrides.uvOnPath ?? (() => true),
|
|
18
|
+
install: overrides.install ?? (() => ({ ok: true, stderr: "" })),
|
|
19
|
+
seedAssets: overrides.seedAssets ?? (() => []),
|
|
20
|
+
warn: () => { /* swallow */ },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("normalizeUvxGitServers", () => {
|
|
24
|
+
test("passes non-uvx entries through unchanged", () => {
|
|
25
|
+
const servers: Record<string, McpServerConfig> = {
|
|
26
|
+
drawio: { command: "npx", args: ["-y", "@drawio/mcp"] },
|
|
27
|
+
gbrain: { command: "~/.local/bin/gbrain.sh" },
|
|
28
|
+
};
|
|
29
|
+
const { normalized, report } = normalizeUvxGitServers(servers, stubs({}));
|
|
30
|
+
expect(normalized).toEqual(servers);
|
|
31
|
+
expect(report).toEqual({ installed: [], reused: [], skipped: [], seeded: [] });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("passes uvx entries without git+ source through unchanged", () => {
|
|
35
|
+
const servers: Record<string, McpServerConfig> = {
|
|
36
|
+
pypi: { command: "uvx", args: ["--from", "trendradar-mcp", "trendradar-mcp"] },
|
|
37
|
+
};
|
|
38
|
+
const { normalized, report } = normalizeUvxGitServers(servers, stubs({}));
|
|
39
|
+
expect(normalized).toEqual(servers);
|
|
40
|
+
expect(report.installed).toEqual([]);
|
|
41
|
+
expect(report.reused).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("rewrites git+ entry to local binary when already installed", () => {
|
|
45
|
+
const servers: Record<string, McpServerConfig> = {
|
|
46
|
+
trendradar: {
|
|
47
|
+
command: "uvx",
|
|
48
|
+
args: ["--from", "git+https://github.com/sansan0/TrendRadar.git", "trendradar-mcp"],
|
|
49
|
+
env: { FOO: "bar" },
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const calls: string[] = [];
|
|
53
|
+
const { normalized, report } = normalizeUvxGitServers(servers, stubs({
|
|
54
|
+
binExists: (b) => { calls.push(`exists:${b}`); return true; },
|
|
55
|
+
install: () => { throw new Error("install should not be called when binary exists"); },
|
|
56
|
+
}));
|
|
57
|
+
expect(normalized.trendradar).toEqual({
|
|
58
|
+
command: localBin("trendradar-mcp"),
|
|
59
|
+
args: [],
|
|
60
|
+
env: { FOO: "bar" },
|
|
61
|
+
});
|
|
62
|
+
expect(report.reused).toEqual(["trendradar"]);
|
|
63
|
+
expect(report.installed).toEqual([]);
|
|
64
|
+
expect(calls).toEqual(["exists:trendradar-mcp"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("installs missing binary and rewrites entry", () => {
|
|
68
|
+
const servers: Record<string, McpServerConfig> = {
|
|
69
|
+
trendradar: {
|
|
70
|
+
command: "uvx",
|
|
71
|
+
args: ["--from", "git+https://github.com/sansan0/TrendRadar.git", "trendradar-mcp"],
|
|
72
|
+
env: {},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
let installCalled: { gitUrl?: string; binary?: string } = {};
|
|
76
|
+
const { normalized, report } = normalizeUvxGitServers(servers, stubs({
|
|
77
|
+
binExists: () => false,
|
|
78
|
+
install: (gitUrl, binary) => {
|
|
79
|
+
installCalled = { gitUrl, binary };
|
|
80
|
+
return { ok: true, stderr: "" };
|
|
81
|
+
},
|
|
82
|
+
}));
|
|
83
|
+
expect(installCalled).toEqual({
|
|
84
|
+
gitUrl: "git+https://github.com/sansan0/TrendRadar.git",
|
|
85
|
+
binary: "trendradar-mcp",
|
|
86
|
+
});
|
|
87
|
+
expect(normalized.trendradar.command).toBe(localBin("trendradar-mcp"));
|
|
88
|
+
expect(normalized.trendradar.args).toEqual([]);
|
|
89
|
+
expect(report.installed).toEqual(["trendradar"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("falls back to raw uvx when uv is missing", () => {
|
|
93
|
+
const servers: Record<string, McpServerConfig> = {
|
|
94
|
+
trendradar: {
|
|
95
|
+
command: "uvx",
|
|
96
|
+
args: ["--from", "git+https://github.com/sansan0/TrendRadar.git", "trendradar-mcp"],
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const warnings: string[] = [];
|
|
100
|
+
const { normalized, report } = normalizeUvxGitServers(servers, {
|
|
101
|
+
binExists: () => false,
|
|
102
|
+
uvOnPath: () => false,
|
|
103
|
+
install: () => { throw new Error("install should not be called when uv missing"); },
|
|
104
|
+
warn: (m) => warnings.push(m),
|
|
105
|
+
});
|
|
106
|
+
expect(normalized.trendradar).toEqual(servers.trendradar);
|
|
107
|
+
expect(report.skipped).toEqual([{ id: "trendradar", reason: "uv-missing" }]);
|
|
108
|
+
expect(warnings.length).toBe(1);
|
|
109
|
+
expect(warnings[0]).toContain("uv");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("falls back to raw uvx when install fails", () => {
|
|
113
|
+
const servers: Record<string, McpServerConfig> = {
|
|
114
|
+
bad: {
|
|
115
|
+
command: "uvx",
|
|
116
|
+
args: ["--from", "git+https://example.invalid/x.git", "bad-mcp"],
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const warnings: string[] = [];
|
|
120
|
+
const { normalized, report } = normalizeUvxGitServers(servers, {
|
|
121
|
+
binExists: () => false,
|
|
122
|
+
uvOnPath: () => true,
|
|
123
|
+
install: () => ({ ok: false, stderr: "fatal: repository not found" }),
|
|
124
|
+
warn: (m) => warnings.push(m),
|
|
125
|
+
});
|
|
126
|
+
expect(normalized.bad).toEqual(servers.bad);
|
|
127
|
+
expect(report.skipped).toEqual([{ id: "bad", reason: "install-failed" }]);
|
|
128
|
+
expect(warnings[0]).toContain("repository not found");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("preserves extra uvx args around --from", () => {
|
|
132
|
+
const servers: Record<string, McpServerConfig> = {
|
|
133
|
+
x: {
|
|
134
|
+
command: "uvx",
|
|
135
|
+
args: ["--python", "3.12", "--from", "git+https://example.com/x.git", "x-mcp", "--verbose"],
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
const { normalized } = normalizeUvxGitServers(servers, stubs({ binExists: () => true }));
|
|
139
|
+
expect(normalized.x.command).toBe(localBin("x-mcp"));
|
|
140
|
+
// Pre-`--from` and post-binary args are preserved (order: pre then post).
|
|
141
|
+
expect(normalized.x.args).toEqual(["--python", "3.12", "--verbose"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("seeds repo-root assets after fresh install for known git URLs", () => {
|
|
145
|
+
const servers: Record<string, McpServerConfig> = {
|
|
146
|
+
trendradar: {
|
|
147
|
+
command: "uvx",
|
|
148
|
+
args: ["--from", "git+https://github.com/sansan0/TrendRadar.git", "trendradar-mcp"],
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
const seedCalls: { gitUrl: string; binary: string; assets: string[] }[] = [];
|
|
152
|
+
const { report } = normalizeUvxGitServers(servers, stubs({
|
|
153
|
+
binExists: () => false,
|
|
154
|
+
install: () => ({ ok: true, stderr: "" }),
|
|
155
|
+
seedAssets: (gitUrl, binary, assets) => {
|
|
156
|
+
seedCalls.push({ gitUrl, binary, assets });
|
|
157
|
+
return assets; // pretend we copied all of them
|
|
158
|
+
},
|
|
159
|
+
}));
|
|
160
|
+
expect(seedCalls).toEqual([{
|
|
161
|
+
gitUrl: "git+https://github.com/sansan0/TrendRadar.git",
|
|
162
|
+
binary: "trendradar-mcp",
|
|
163
|
+
assets: ["config"],
|
|
164
|
+
}]);
|
|
165
|
+
expect(report.seeded).toEqual([{ id: "trendradar", assets: ["config"] }]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("heals existing install missing repo-root assets on reuse path", () => {
|
|
169
|
+
const servers: Record<string, McpServerConfig> = {
|
|
170
|
+
trendradar: {
|
|
171
|
+
command: "uvx",
|
|
172
|
+
args: ["--from", "git+https://github.com/sansan0/TrendRadar.git", "trendradar-mcp"],
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const { report } = normalizeUvxGitServers(servers, stubs({
|
|
176
|
+
binExists: () => true, // binary already on disk
|
|
177
|
+
seedAssets: (_gitUrl, _binary, assets) => assets, // assets were missing, now copied
|
|
178
|
+
}));
|
|
179
|
+
expect(report.reused).toEqual(["trendradar"]);
|
|
180
|
+
expect(report.seeded).toEqual([{ id: "trendradar", assets: ["config"] }]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("does not seed when git URL is not in the asset table", () => {
|
|
184
|
+
const servers: Record<string, McpServerConfig> = {
|
|
185
|
+
other: {
|
|
186
|
+
command: "uvx",
|
|
187
|
+
args: ["--from", "git+https://example.com/other.git", "other-mcp"],
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
let seedCalled = false;
|
|
191
|
+
const { report } = normalizeUvxGitServers(servers, stubs({
|
|
192
|
+
binExists: () => true,
|
|
193
|
+
seedAssets: () => { seedCalled = true; return []; },
|
|
194
|
+
}));
|
|
195
|
+
expect(seedCalled).toBe(false);
|
|
196
|
+
expect(report.seeded).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("does not record seed when seeder copies nothing (assets already present)", () => {
|
|
200
|
+
const servers: Record<string, McpServerConfig> = {
|
|
201
|
+
trendradar: {
|
|
202
|
+
command: "uvx",
|
|
203
|
+
args: ["--from", "git+https://github.com/sansan0/TrendRadar.git", "trendradar-mcp"],
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
const { report } = normalizeUvxGitServers(servers, stubs({
|
|
207
|
+
binExists: () => true,
|
|
208
|
+
seedAssets: () => [], // nothing copied — already healthy
|
|
209
|
+
}));
|
|
210
|
+
expect(report.seeded).toEqual([]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("only spawns uv once for many git+ entries", () => {
|
|
214
|
+
const servers: Record<string, McpServerConfig> = {
|
|
215
|
+
a: { command: "uvx", args: ["--from", "git+https://example.com/a.git", "a"] },
|
|
216
|
+
b: { command: "uvx", args: ["--from", "git+https://example.com/b.git", "b"] },
|
|
217
|
+
c: { command: "uvx", args: ["--from", "git+https://example.com/c.git", "c"] },
|
|
218
|
+
};
|
|
219
|
+
let uvChecks = 0;
|
|
220
|
+
normalizeUvxGitServers(servers, {
|
|
221
|
+
binExists: () => true, // skip install path
|
|
222
|
+
uvOnPath: () => { uvChecks++; return true; },
|
|
223
|
+
install: () => ({ ok: true, stderr: "" }),
|
|
224
|
+
warn: () => { /* swallow */ },
|
|
225
|
+
});
|
|
226
|
+
// All entries hit the binExists fast path, so uvOnPath shouldn't be touched.
|
|
227
|
+
expect(uvChecks).toBe(0);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uvx-installer — normalize `uvx --from git+<repo> <binary>` MCP entries to a
|
|
3
|
+
* locally-installed binary.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists
|
|
6
|
+
* Two problems with the raw `uvx --from git+...` shape inside an MCP entry:
|
|
7
|
+
* 1. First-launch cold start does a network download. MCP startup has a
|
|
8
|
+
* short handshake timeout, so the server often races and gets reaped
|
|
9
|
+
* before registering tools.
|
|
10
|
+
* 2. Claude Code's auto-mode bash classifier blocks the same command when
|
|
11
|
+
* the model tries to probe it (`uvx --from git+<url>` is treated as
|
|
12
|
+
* "fetch and execute arbitrary code from an unverified URL").
|
|
13
|
+
*
|
|
14
|
+
* Fix
|
|
15
|
+
* At materialize time, run `uv tool install --from <git-url> <binary>` once
|
|
16
|
+
* (idempotent) and rewrite the runtime entry to invoke `~/.local/bin/<binary>`
|
|
17
|
+
* directly. Subsequent session starts are a plain local exec — no network,
|
|
18
|
+
* no classifier flag.
|
|
19
|
+
*
|
|
20
|
+
* Failure modes (warn + leave entry untouched, never throw)
|
|
21
|
+
* - `uv` missing from PATH → tell the user to install uv.
|
|
22
|
+
* - `uv tool install` non-zero → print stderr, fall back to raw uvx.
|
|
23
|
+
* Both keep the profile working on machines where the optimization can't
|
|
24
|
+
* be applied; only the first-launch race + classifier annoyance persist.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import { cpSync, existsSync, mkdtempSync, realpathSync, rmSync, readdirSync } from "node:fs";
|
|
29
|
+
import { homedir, tmpdir } from "node:os";
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
31
|
+
|
|
32
|
+
import type { McpServerConfig } from "./runtime-materializer";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Repo-root assets to seed into the venv's site-packages after `uv tool install`.
|
|
36
|
+
*
|
|
37
|
+
* Some upstream packages (e.g. TrendRadar) ship runtime config at the repo root
|
|
38
|
+
* rather than inside the python package. `uv tool install` only copies the
|
|
39
|
+
* package, so the binary then fails at startup looking for `<site-packages>/X/`.
|
|
40
|
+
* For each known git URL, we sparse-clone the listed top-level dirs and copy
|
|
41
|
+
* them next to the installed package. Idempotent: skipped if target exists.
|
|
42
|
+
*/
|
|
43
|
+
const REPO_ROOT_ASSETS: Record<string, string[]> = {
|
|
44
|
+
"git+https://github.com/sansan0/TrendRadar.git": ["config"],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export interface NormalizeReport {
|
|
48
|
+
/** Entries we just installed via `uv tool install` and rewrote. */
|
|
49
|
+
installed: string[];
|
|
50
|
+
/** Entries whose binary was already on disk — just rewrote. */
|
|
51
|
+
reused: string[];
|
|
52
|
+
/** Entries we left alone, with why. */
|
|
53
|
+
skipped: { id: string; reason: "uv-missing" | "install-failed" }[];
|
|
54
|
+
/** Entries whose repo-root assets we just seeded into site-packages. */
|
|
55
|
+
seeded: { id: string; assets: string[] }[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ParsedUvxGit {
|
|
59
|
+
gitUrl: string;
|
|
60
|
+
binary: string;
|
|
61
|
+
/** Any args before `--from` or after the binary slot — preserved on rewrite. */
|
|
62
|
+
extraArgs: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Recognize the shape `uvx [pre...] --from git+<url> <binary> [post...]`. */
|
|
66
|
+
function parseUvxGit(cfg: McpServerConfig): ParsedUvxGit | null {
|
|
67
|
+
if (cfg.command !== "uvx") return null;
|
|
68
|
+
const args = cfg.args ?? [];
|
|
69
|
+
const fromIdx = args.indexOf("--from");
|
|
70
|
+
if (fromIdx < 0 || fromIdx + 2 >= args.length) return null;
|
|
71
|
+
const gitUrl = args[fromIdx + 1]!;
|
|
72
|
+
if (!gitUrl.startsWith("git+")) return null;
|
|
73
|
+
const binary = args[fromIdx + 2]!;
|
|
74
|
+
if (!binary || binary.startsWith("-")) return null;
|
|
75
|
+
return {
|
|
76
|
+
gitUrl,
|
|
77
|
+
binary,
|
|
78
|
+
extraArgs: [...args.slice(0, fromIdx), ...args.slice(fromIdx + 3)],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function uvOnPathDefault(): boolean {
|
|
83
|
+
const res = spawnSync("uv", ["--version"], { stdio: "ignore" });
|
|
84
|
+
return res.status === 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function localBinPath(binary: string): string {
|
|
88
|
+
return join(homedir(), ".local", "bin", binary);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function installBinaryDefault(
|
|
92
|
+
gitUrl: string,
|
|
93
|
+
_binary: string,
|
|
94
|
+
): { ok: boolean; stderr: string } {
|
|
95
|
+
// `uv tool install <git-url>` installs whatever package the URL declares and
|
|
96
|
+
// links every `[project.scripts]` entry into ~/.local/bin/. We deliberately
|
|
97
|
+
// do NOT pass `--from <url> <bin>` — uv enforces install-name == package-name
|
|
98
|
+
// in that mode, which breaks when the binary name (e.g. `trendradar-mcp`)
|
|
99
|
+
// differs from the package name (`trendradar`).
|
|
100
|
+
const res = spawnSync(
|
|
101
|
+
"uv",
|
|
102
|
+
["tool", "install", gitUrl],
|
|
103
|
+
{ stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", timeout: 180_000 },
|
|
104
|
+
);
|
|
105
|
+
if (res.status === 0) return { ok: true, stderr: "" };
|
|
106
|
+
return { ok: false, stderr: (res.stderr ?? "").toString().trim() };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Default seeder: sparse-clones `gitUrl` and copies each repo-root `asset` dir
|
|
111
|
+
* into the site-packages of the venv that owns `binary`. No-op for any asset
|
|
112
|
+
* already present. Returns the list of asset names actually copied.
|
|
113
|
+
*/
|
|
114
|
+
function seedRepoRootAssetsDefault(
|
|
115
|
+
gitUrl: string,
|
|
116
|
+
binary: string,
|
|
117
|
+
assets: string[],
|
|
118
|
+
): string[] {
|
|
119
|
+
const seeded: string[] = [];
|
|
120
|
+
const sitePackages = resolveSitePackages(binary);
|
|
121
|
+
if (!sitePackages) return seeded;
|
|
122
|
+
|
|
123
|
+
const missing = assets.filter((a) => !existsSync(join(sitePackages, a)));
|
|
124
|
+
if (missing.length === 0) return seeded;
|
|
125
|
+
|
|
126
|
+
const cloneUrl = gitUrl.startsWith("git+") ? gitUrl.slice("git+".length) : gitUrl;
|
|
127
|
+
const tmp = mkdtempSync(join(tmpdir(), "cue-uvx-seed-"));
|
|
128
|
+
try {
|
|
129
|
+
const clone = spawnSync(
|
|
130
|
+
"git",
|
|
131
|
+
["clone", "--depth=1", "--filter=blob:none", "--sparse", cloneUrl, "repo"],
|
|
132
|
+
{ cwd: tmp, stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: 120_000 },
|
|
133
|
+
);
|
|
134
|
+
if (clone.status !== 0) return seeded;
|
|
135
|
+
|
|
136
|
+
const sparse = spawnSync(
|
|
137
|
+
"git",
|
|
138
|
+
["-C", "repo", "sparse-checkout", "set", ...missing],
|
|
139
|
+
{ cwd: tmp, stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: 60_000 },
|
|
140
|
+
);
|
|
141
|
+
if (sparse.status !== 0) return seeded;
|
|
142
|
+
|
|
143
|
+
for (const asset of missing) {
|
|
144
|
+
const src = join(tmp, "repo", asset);
|
|
145
|
+
if (!existsSync(src)) continue;
|
|
146
|
+
cpSync(src, join(sitePackages, asset), { recursive: true });
|
|
147
|
+
seeded.push(asset);
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
return seeded;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Find the site-packages dir for a `uv tool install`-ed binary by resolving the
|
|
157
|
+
* symlink at ~/.local/bin/<binary> and walking up to the venv's
|
|
158
|
+
* lib/python<X.Y>/site-packages/. Returns null if anything is off.
|
|
159
|
+
*/
|
|
160
|
+
function resolveSitePackages(binary: string): string | null {
|
|
161
|
+
const linkPath = localBinPath(binary);
|
|
162
|
+
if (!existsSync(linkPath)) return null;
|
|
163
|
+
let real: string;
|
|
164
|
+
try {
|
|
165
|
+
real = realpathSync(linkPath);
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
// real = <venv>/bin/<binary> → venv root is two dirs up.
|
|
170
|
+
const venvRoot = dirname(dirname(real));
|
|
171
|
+
const libDir = join(venvRoot, "lib");
|
|
172
|
+
if (!existsSync(libDir)) return null;
|
|
173
|
+
let entries: string[];
|
|
174
|
+
try {
|
|
175
|
+
entries = readdirSync(libDir);
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const pyDir = entries.find((e) => e.startsWith("python"));
|
|
180
|
+
if (!pyDir) return null;
|
|
181
|
+
const sp = join(libDir, pyDir, "site-packages");
|
|
182
|
+
return existsSync(sp) ? sp : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface NormalizeOptions {
|
|
186
|
+
install?: (gitUrl: string, binary: string) => { ok: boolean; stderr: string };
|
|
187
|
+
binExists?: (binary: string) => boolean;
|
|
188
|
+
uvOnPath?: () => boolean;
|
|
189
|
+
warn?: (msg: string) => void;
|
|
190
|
+
/** Seed repo-root assets into the venv's site-packages. Returns names copied. */
|
|
191
|
+
seedAssets?: (gitUrl: string, binary: string, assets: string[]) => string[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Normalize MCP server entries in-place (functionally — returns a new map).
|
|
196
|
+
*
|
|
197
|
+
* Pure-ish: the only side effects are (optionally) spawning `uv` subprocesses
|
|
198
|
+
* and writing to the warn sink. Tests inject mocks via {@link NormalizeOptions}.
|
|
199
|
+
*/
|
|
200
|
+
export function normalizeUvxGitServers(
|
|
201
|
+
servers: Record<string, McpServerConfig>,
|
|
202
|
+
opts: NormalizeOptions = {},
|
|
203
|
+
): { normalized: Record<string, McpServerConfig>; report: NormalizeReport } {
|
|
204
|
+
const install = opts.install ?? installBinaryDefault;
|
|
205
|
+
const binExists = opts.binExists ?? ((b) => existsSync(localBinPath(b)));
|
|
206
|
+
const uvOnPath = opts.uvOnPath ?? uvOnPathDefault;
|
|
207
|
+
const warn = opts.warn ?? ((m) => process.stderr.write(`[cue] ${m}\n`));
|
|
208
|
+
const seedAssets = opts.seedAssets ?? seedRepoRootAssetsDefault;
|
|
209
|
+
|
|
210
|
+
const report: NormalizeReport = { installed: [], reused: [], skipped: [], seeded: [] };
|
|
211
|
+
const normalized: Record<string, McpServerConfig> = {};
|
|
212
|
+
|
|
213
|
+
const trySeed = (id: string, gitUrl: string, binary: string): void => {
|
|
214
|
+
const assets = REPO_ROOT_ASSETS[gitUrl];
|
|
215
|
+
if (!assets || assets.length === 0) return;
|
|
216
|
+
const copied = seedAssets(gitUrl, binary, assets);
|
|
217
|
+
if (copied.length > 0) report.seeded.push({ id, assets: copied });
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
let uvCache: boolean | null = null;
|
|
221
|
+
const checkUv = () => {
|
|
222
|
+
if (uvCache === null) uvCache = uvOnPath();
|
|
223
|
+
return uvCache;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
for (const [id, cfg] of Object.entries(servers)) {
|
|
227
|
+
const parsed = parseUvxGit(cfg);
|
|
228
|
+
if (!parsed) {
|
|
229
|
+
normalized[id] = cfg;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const { gitUrl, binary, extraArgs } = parsed;
|
|
233
|
+
const binPath = localBinPath(binary);
|
|
234
|
+
|
|
235
|
+
const rewrite = (): McpServerConfig => ({
|
|
236
|
+
...cfg,
|
|
237
|
+
command: binPath,
|
|
238
|
+
args: extraArgs,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (binExists(binary)) {
|
|
242
|
+
report.reused.push(id);
|
|
243
|
+
trySeed(id, gitUrl, binary);
|
|
244
|
+
normalized[id] = rewrite();
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!checkUv()) {
|
|
249
|
+
warn(
|
|
250
|
+
`MCP "${id}": uvx git+ source detected but \`uv\` is not on PATH. ` +
|
|
251
|
+
`Leaving entry as raw \`uvx --from git+...\` — first session may time out ` +
|
|
252
|
+
`during cold download. Install uv (https://docs.astral.sh/uv/) and re-run ` +
|
|
253
|
+
`\`cue switch\` to enable the local-binary fast path.`,
|
|
254
|
+
);
|
|
255
|
+
report.skipped.push({ id, reason: "uv-missing" });
|
|
256
|
+
normalized[id] = cfg;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const { ok, stderr } = install(gitUrl, binary);
|
|
261
|
+
if (!ok) {
|
|
262
|
+
warn(
|
|
263
|
+
`MCP "${id}": \`uv tool install ${gitUrl}\` failed (looking for binary "${binary}").\n ${
|
|
264
|
+
stderr || "(no stderr)"
|
|
265
|
+
}\nLeaving entry as raw \`uvx --from git+...\`.`,
|
|
266
|
+
);
|
|
267
|
+
report.skipped.push({ id, reason: "install-failed" });
|
|
268
|
+
normalized[id] = cfg;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
report.installed.push(id);
|
|
273
|
+
trySeed(id, gitUrl, binary);
|
|
274
|
+
normalized[id] = rewrite();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { normalized, report };
|
|
278
|
+
}
|