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,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throttle + opt-out registry for cue's outbound PRs.
|
|
3
|
+
*
|
|
4
|
+
* Storage: ~/.cache/cue/pr-opened.json (atomically written).
|
|
5
|
+
*
|
|
6
|
+
* Three responsibilities:
|
|
7
|
+
* 1. **Cooldown per repo** — don't re-PR the same repo within `cooldownDays`
|
|
8
|
+
* (default 90). Cue should *never* PR a repo twice without an invitation.
|
|
9
|
+
* 2. **Daily cap** — GitHub auto-flags accounts that open >30 PRs/day. We
|
|
10
|
+
* cap ourselves at 25/day with headroom for retries.
|
|
11
|
+
* 3. **Opt-out cache** — if a repo's README contains `<!-- cue: ignore -->`
|
|
12
|
+
* we record it once and never check again. Idempotent on every scan.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync } from "node:fs";
|
|
16
|
+
import { join, dirname } from "node:path";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_PATH = join(
|
|
20
|
+
process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"),
|
|
21
|
+
"cue",
|
|
22
|
+
"pr-opened.json",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_COOLDOWN_DAYS = 90;
|
|
26
|
+
export const DAILY_CAP = 25;
|
|
27
|
+
|
|
28
|
+
export type EntryState =
|
|
29
|
+
| "open" // PR open on GitHub
|
|
30
|
+
| "merged" // PR was merged
|
|
31
|
+
| "closed" // PR was closed without merge
|
|
32
|
+
| "opted-out" // skipped — repo README has <!-- cue: ignore -->
|
|
33
|
+
| "skipped"; // skipped for any other reason (e.g. no fixes to apply)
|
|
34
|
+
|
|
35
|
+
export interface ThrottleEntry {
|
|
36
|
+
repo: string; // owner/name
|
|
37
|
+
state: EntryState;
|
|
38
|
+
openedAt: string; // ISO timestamp
|
|
39
|
+
rulesFixed?: string[]; // R001, R005, etc. — only present when state was open
|
|
40
|
+
prNumber?: number;
|
|
41
|
+
prUrl?: string;
|
|
42
|
+
fork?: string; // owner/name of cue's fork (null after cleanup)
|
|
43
|
+
branch?: string; // branch on the fork
|
|
44
|
+
reason?: string; // optional human-readable reason
|
|
45
|
+
cleanedAt?: string; // when fork was deleted (cleanup-forks command)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ThrottleDB {
|
|
49
|
+
version: 1;
|
|
50
|
+
history: ThrottleEntry[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Read + write
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
function emptyDb(): ThrottleDB { return { version: 1, history: [] }; }
|
|
58
|
+
|
|
59
|
+
export function loadDb(path: string = DEFAULT_PATH): ThrottleDB {
|
|
60
|
+
if (!existsSync(path)) return emptyDb();
|
|
61
|
+
try {
|
|
62
|
+
const raw = readFileSync(path, "utf8");
|
|
63
|
+
const parsed = JSON.parse(raw) as ThrottleDB;
|
|
64
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.history)) return emptyDb();
|
|
65
|
+
return parsed;
|
|
66
|
+
} catch {
|
|
67
|
+
return emptyDb();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Atomic write: temp file + rename. Survives concurrent writes. */
|
|
72
|
+
export function saveDb(db: ThrottleDB, path: string = DEFAULT_PATH): void {
|
|
73
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
74
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
75
|
+
writeFileSync(tmp, JSON.stringify(db, null, 2) + "\n");
|
|
76
|
+
renameSync(tmp, path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Queries
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/** Days since `iso` timestamp. */
|
|
84
|
+
function daysSince(iso: string): number {
|
|
85
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
86
|
+
return ms / (1000 * 60 * 60 * 24);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Is this repo currently throttled? Returns reason string when throttled,
|
|
91
|
+
* null when free to post. Considers cooldown + opted-out state.
|
|
92
|
+
*/
|
|
93
|
+
export function isThrottled(
|
|
94
|
+
db: ThrottleDB,
|
|
95
|
+
repo: string,
|
|
96
|
+
cooldownDays: number = DEFAULT_COOLDOWN_DAYS,
|
|
97
|
+
): string | null {
|
|
98
|
+
const entries = db.history.filter((e) => e.repo === repo);
|
|
99
|
+
if (entries.length === 0) return null;
|
|
100
|
+
|
|
101
|
+
// Opted out — permanent
|
|
102
|
+
if (entries.some((e) => e.state === "opted-out")) {
|
|
103
|
+
return "repo has <!-- cue: ignore --> in README";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Most recent post-or-skip
|
|
107
|
+
entries.sort((a, b) => b.openedAt.localeCompare(a.openedAt));
|
|
108
|
+
const latest = entries[0]!;
|
|
109
|
+
const days = daysSince(latest.openedAt);
|
|
110
|
+
if (days < cooldownDays) {
|
|
111
|
+
const remaining = Math.ceil(cooldownDays - days);
|
|
112
|
+
return `cooldown: last interaction was ${Math.floor(days)}d ago (state=${latest.state}); ${remaining}d remaining`;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** How many PRs we've opened in the last rolling 24h window. */
|
|
118
|
+
export function todayCount(db: ThrottleDB): number {
|
|
119
|
+
return db.history.filter((e) => e.state === "open" && daysSince(e.openedAt) < 1).length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Headroom under DAILY_CAP. Negative means we've already exceeded. */
|
|
123
|
+
export function canPostMore(db: ThrottleDB): { ok: boolean; remaining: number; cap: number } {
|
|
124
|
+
const used = todayCount(db);
|
|
125
|
+
return { ok: used < DAILY_CAP, remaining: DAILY_CAP - used, cap: DAILY_CAP };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** List entries by state — for the cleanup-forks command. */
|
|
129
|
+
export function filterByState(db: ThrottleDB, states: EntryState[]): ThrottleEntry[] {
|
|
130
|
+
const set = new Set(states);
|
|
131
|
+
return db.history.filter((e) => set.has(e.state));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Mutations — always go through these helpers so audit history is correct
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
export function recordOpened(
|
|
139
|
+
db: ThrottleDB,
|
|
140
|
+
e: { repo: string; rulesFixed: string[]; prNumber: number; prUrl: string; fork: string; branch: string },
|
|
141
|
+
): ThrottleDB {
|
|
142
|
+
db.history.push({
|
|
143
|
+
repo: e.repo,
|
|
144
|
+
state: "open",
|
|
145
|
+
openedAt: new Date().toISOString(),
|
|
146
|
+
rulesFixed: e.rulesFixed,
|
|
147
|
+
prNumber: e.prNumber,
|
|
148
|
+
prUrl: e.prUrl,
|
|
149
|
+
fork: e.fork,
|
|
150
|
+
branch: e.branch,
|
|
151
|
+
});
|
|
152
|
+
return db;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function recordOptOut(db: ThrottleDB, repo: string): ThrottleDB {
|
|
156
|
+
db.history.push({
|
|
157
|
+
repo,
|
|
158
|
+
state: "opted-out",
|
|
159
|
+
openedAt: new Date().toISOString(),
|
|
160
|
+
reason: "README contains <!-- cue: ignore -->",
|
|
161
|
+
});
|
|
162
|
+
return db;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function recordSkipped(db: ThrottleDB, repo: string, reason: string): ThrottleDB {
|
|
166
|
+
db.history.push({
|
|
167
|
+
repo,
|
|
168
|
+
state: "skipped",
|
|
169
|
+
openedAt: new Date().toISOString(),
|
|
170
|
+
reason,
|
|
171
|
+
});
|
|
172
|
+
return db;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function updateEntryState(
|
|
176
|
+
db: ThrottleDB,
|
|
177
|
+
match: { repo: string; prNumber?: number },
|
|
178
|
+
newState: EntryState,
|
|
179
|
+
extra: Partial<ThrottleEntry> = {},
|
|
180
|
+
): ThrottleDB {
|
|
181
|
+
for (const e of db.history) {
|
|
182
|
+
if (e.repo !== match.repo) continue;
|
|
183
|
+
if (match.prNumber !== undefined && e.prNumber !== match.prNumber) continue;
|
|
184
|
+
e.state = newState;
|
|
185
|
+
Object.assign(e, extra);
|
|
186
|
+
}
|
|
187
|
+
return db;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Opt-out check — fetches README via gh, looks for the marker
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export const OPT_OUT_MARKER = "<!-- cue: ignore -->";
|
|
195
|
+
/**
|
|
196
|
+
* Opposite of OPT_OUT_MARKER: when --opt-in-only mode is active, we only
|
|
197
|
+
* post PRs to repos whose README contains this marker. Lets authors flip
|
|
198
|
+
* the consent model from "assumed yes" to "explicit yes".
|
|
199
|
+
*/
|
|
200
|
+
export const OPT_IN_MARKER = "<!-- cue: ok -->";
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Look in the cached DB first; if not seen, caller should fetch the README
|
|
204
|
+
* and call `recordOptOut` if it matches. Keeping the fetch out of this module
|
|
205
|
+
* keeps it dependency-free + easily testable.
|
|
206
|
+
*/
|
|
207
|
+
export function hasRecordedOptOut(db: ThrottleDB, repo: string): boolean {
|
|
208
|
+
return db.history.some((e) => e.repo === repo && e.state === "opted-out");
|
|
209
|
+
}
|
|
@@ -20,7 +20,7 @@ let profilesRoot: string;
|
|
|
20
20
|
let priorProfilesDir: string | undefined;
|
|
21
21
|
|
|
22
22
|
beforeEach(async () => {
|
|
23
|
-
scratchRoot = await mkdtemp(join(tmpdir(), "
|
|
23
|
+
scratchRoot = await mkdtemp(join(tmpdir(), "cue-profile-generator-"));
|
|
24
24
|
profilesRoot = join(scratchRoot, "profiles");
|
|
25
25
|
priorProfilesDir = process.env.CUE_PROFILES_DIR;
|
|
26
26
|
process.env.CUE_PROFILES_DIR = profilesRoot;
|
|
@@ -506,7 +506,7 @@ export function generateProfile(
|
|
|
506
506
|
name: options.name,
|
|
507
507
|
description:
|
|
508
508
|
options.description ??
|
|
509
|
-
`Auto-generated from
|
|
509
|
+
`Auto-generated from cue scan (${included.length} skills)`,
|
|
510
510
|
agents: ["claude-code", "codex"],
|
|
511
511
|
};
|
|
512
512
|
if (options.inheritCore && core.length > 0) {
|
|
@@ -1024,7 +1024,7 @@ function renderProfileYaml(
|
|
|
1024
1024
|
): string {
|
|
1025
1025
|
const skills = buildProfileSkills(included);
|
|
1026
1026
|
const lines: string[] = [];
|
|
1027
|
-
lines.push(`# Auto-generated by
|
|
1027
|
+
lines.push(`# Auto-generated by cue new ${profile.name} --from-scan.`);
|
|
1028
1028
|
if (core.length > 0) {
|
|
1029
1029
|
lines.push("# Core candidates were detected as cross-cutting.");
|
|
1030
1030
|
if (profile.inherits === "core") {
|
|
@@ -26,7 +26,7 @@ let configsRoot: string;
|
|
|
26
26
|
let npxRepoRoot: string;
|
|
27
27
|
|
|
28
28
|
beforeEach(async () => {
|
|
29
|
-
root = await mkdtemp(join(tmpdir(), "
|
|
29
|
+
root = await mkdtemp(join(tmpdir(), "cue-profile-linter-"));
|
|
30
30
|
profilesDir = join(root, "profiles");
|
|
31
31
|
skillsRoot = join(root, "skills", "skills");
|
|
32
32
|
pluginsRoot = join(root, "plugins");
|
|
@@ -132,7 +132,7 @@ describe("lintProfile", () => {
|
|
|
132
132
|
|
|
133
133
|
test("reports W1, W2, W3, and W4 warnings", async () => {
|
|
134
134
|
const localRefs: string[] = [];
|
|
135
|
-
for (let i = 0; i <
|
|
135
|
+
for (let i = 0; i < 41; i++) {
|
|
136
136
|
const ref = `bulk/skill-${i}`;
|
|
137
137
|
localRefs.push(ref);
|
|
138
138
|
await writeLocalSkill(ref);
|
|
@@ -217,7 +217,10 @@ describe("lintProfile", () => {
|
|
|
217
217
|
);
|
|
218
218
|
|
|
219
219
|
const messages = result.issues.map((issue) => issue.message).join("\n");
|
|
220
|
-
|
|
220
|
+
// Plugin-not-installed is demoted to W5 (environmental), so the three
|
|
221
|
+
// genuine resolver errors stay as E3 and the plugin shows up as W5.
|
|
222
|
+
expect(rules(result).filter((rule) => rule === "E3")).toHaveLength(3);
|
|
223
|
+
expect(rules(result).filter((rule) => rule === "W5")).toHaveLength(1);
|
|
221
224
|
expect(messages).toContain("local skill");
|
|
222
225
|
expect(messages).toContain("npx skill");
|
|
223
226
|
expect(messages).toContain("plugin");
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Profile linter for `
|
|
2
|
+
* Profile linter for `cue validate`.
|
|
3
3
|
*
|
|
4
4
|
* The rules below are intentionally numbered and centralized so a later
|
|
5
5
|
* suppression pass can key off stable ids such as `# lint: ignore W1`.
|
|
6
6
|
*
|
|
7
|
-
* W1: profile declares more than
|
|
7
|
+
* W1: profile declares more than 40 skills.
|
|
8
8
|
* W2: profile declares more than 5 MCP servers.
|
|
9
9
|
* W3: inheritance chain depth is greater than 2.
|
|
10
10
|
* W4: a skill slug appears in both `skills.local` and `skills.npx`.
|
|
11
|
+
* W5: a referenced plugin is not installed locally (environmental, not a profile bug).
|
|
11
12
|
* E1: profile `name:` collides with another profile.
|
|
12
13
|
* E2: inheritance chain contains a cycle.
|
|
13
|
-
* E3: referenced skill
|
|
14
|
+
* E3: referenced skill or MCP cannot be resolved.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
@@ -38,15 +39,18 @@ import {
|
|
|
38
39
|
resolveNpxDetailed,
|
|
39
40
|
type NpxFetchFn,
|
|
40
41
|
} from "./resolver-npx";
|
|
41
|
-
import { resolvePlugins } from "./resolver-plugins";
|
|
42
|
+
import { PluginNotInstalled, resolvePlugins } from "./resolver-plugins";
|
|
42
43
|
|
|
43
44
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
44
45
|
const REPO_ROOT = process.env.CUE_REPO_ROOT ?? process.env.SOUL_REPO_ROOT ?? resolve(HERE, "..", "..");
|
|
45
46
|
const DEFAULT_PROFILES_DIR = join(REPO_ROOT, "profiles");
|
|
46
47
|
const DEFAULT_SKILLS_ROOT = join(REPO_ROOT, "resources", "skills", "skills");
|
|
47
48
|
const DEFAULT_CONFIGS_ROOT = join(REPO_ROOT, "resources", "mcps", "configs");
|
|
49
|
+
const DEFAULT_RULES_ROOT = join(REPO_ROOT, "resources", "rules");
|
|
50
|
+
const DEFAULT_COMMANDS_ROOT = join(REPO_ROOT, "resources", "commands");
|
|
51
|
+
const DEFAULT_HOOKS_ROOT = join(REPO_ROOT, "resources", "hooks");
|
|
48
52
|
|
|
49
|
-
export type LintRuleId = "W1" | "W2" | "W3" | "W4" | "E1" | "E2" | "E3";
|
|
53
|
+
export type LintRuleId = "W1" | "W2" | "W3" | "W4" | "W5" | "E1" | "E2" | "E3";
|
|
50
54
|
export type DiagnosticRuleId = LintRuleId | "SCHEMA" | "LOAD";
|
|
51
55
|
export type LintSeverity = "warning" | "error";
|
|
52
56
|
|
|
@@ -60,7 +64,7 @@ export const PROFILE_LINT_RULES: Record<LintRuleId, RuleDoc> = {
|
|
|
60
64
|
W1: {
|
|
61
65
|
severity: "warning",
|
|
62
66
|
title: "too many skills",
|
|
63
|
-
description: "Profile declares more than
|
|
67
|
+
description: "Profile declares more than 40 skills; this can bloat prompt tokens.",
|
|
64
68
|
},
|
|
65
69
|
W2: {
|
|
66
70
|
severity: "warning",
|
|
@@ -77,6 +81,11 @@ export const PROFILE_LINT_RULES: Record<LintRuleId, RuleDoc> = {
|
|
|
77
81
|
title: "ambiguous skill source",
|
|
78
82
|
description: "A skill slug appears in both local and npx sources.",
|
|
79
83
|
},
|
|
84
|
+
W5: {
|
|
85
|
+
severity: "warning",
|
|
86
|
+
title: "plugin not installed locally",
|
|
87
|
+
description: "A referenced plugin is not installed under ~/.claude/plugins (environmental — install with `/plugin marketplace add <name>`).",
|
|
88
|
+
},
|
|
80
89
|
E1: {
|
|
81
90
|
severity: "error",
|
|
82
91
|
title: "profile name collision",
|
|
@@ -201,10 +210,51 @@ export async function lintProfile(
|
|
|
201
210
|
await checkNpxSkills(resolved, result, opts);
|
|
202
211
|
await checkPlugins(resolved, result, opts);
|
|
203
212
|
await checkMcps(resolved, result, opts);
|
|
213
|
+
await checkResourceRefs(resolved, result, opts);
|
|
204
214
|
|
|
205
215
|
return result;
|
|
206
216
|
}
|
|
207
217
|
|
|
218
|
+
async function checkResourceRefs(
|
|
219
|
+
profile: ResolvedProfile,
|
|
220
|
+
result: ProfileLintResult,
|
|
221
|
+
_opts: ProfileLinterOptions,
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
const { access } = await import("node:fs/promises");
|
|
224
|
+
const { isAbsolute } = await import("node:path");
|
|
225
|
+
|
|
226
|
+
const buckets: { label: string; refs: string[]; root: string; addExt: boolean }[] = [
|
|
227
|
+
{ label: "rules", refs: profile.rules, root: DEFAULT_RULES_ROOT, addExt: true },
|
|
228
|
+
{ label: "commands", refs: profile.commands, root: DEFAULT_COMMANDS_ROOT, addExt: true },
|
|
229
|
+
{ label: "hooks", refs: profile.hooks, root: DEFAULT_HOOKS_ROOT, addExt: false },
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
for (const { label, refs, root, addExt } of buckets) {
|
|
233
|
+
if (refs.length === 0) {
|
|
234
|
+
addCheck(result, label, `no ${label} declared`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
let ok = 0;
|
|
238
|
+
for (const ref of refs) {
|
|
239
|
+
const withExt = addExt && !ref.endsWith(".md") ? `${ref}.md` : ref;
|
|
240
|
+
const full = isAbsolute(withExt) ? withExt : join(root, withExt);
|
|
241
|
+
try {
|
|
242
|
+
await access(full);
|
|
243
|
+
ok++;
|
|
244
|
+
} catch {
|
|
245
|
+
addIssue(
|
|
246
|
+
result,
|
|
247
|
+
"E3",
|
|
248
|
+
"error",
|
|
249
|
+
`${label} reference "${ref}" not found at ${full}`,
|
|
250
|
+
{ subject: ref },
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (ok === refs.length) addCheck(result, label, `${ok} resolved`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
208
258
|
async function withProfilesDir<T>(
|
|
209
259
|
profilesRoot: string | undefined,
|
|
210
260
|
fn: () => Promise<T>,
|
|
@@ -359,7 +409,7 @@ function checkStaticRules(
|
|
|
359
409
|
result: ProfileLintResult,
|
|
360
410
|
): void {
|
|
361
411
|
const skillCount = declaredSkillCount(profile);
|
|
362
|
-
if (skillCount >
|
|
412
|
+
if (skillCount > 40) {
|
|
363
413
|
addIssue(
|
|
364
414
|
result,
|
|
365
415
|
"W1",
|
|
@@ -520,7 +570,7 @@ async function resolveOneNpxSkill(
|
|
|
520
570
|
}
|
|
521
571
|
}
|
|
522
572
|
|
|
523
|
-
const tempRepo = await mkdtemp(join(tmpdir(), "
|
|
573
|
+
const tempRepo = await mkdtemp(join(tmpdir(), "cue-validate-npx-"));
|
|
524
574
|
try {
|
|
525
575
|
await mkdir(join(tempRepo, "profiles", "_cache", "npx"), { recursive: true });
|
|
526
576
|
await resolveNpxDetailed(single, {
|
|
@@ -595,6 +645,19 @@ function addResolverIssue(
|
|
|
595
645
|
ref: string,
|
|
596
646
|
err: unknown,
|
|
597
647
|
): void {
|
|
648
|
+
// Plugin-not-installed is environmental state (the profile is correct, the
|
|
649
|
+
// user's machine just doesn't have the plugin yet) — demote to W5 warning
|
|
650
|
+
// rather than failing validation with E3.
|
|
651
|
+
if (err instanceof PluginNotInstalled) {
|
|
652
|
+
addIssue(
|
|
653
|
+
result,
|
|
654
|
+
"W5",
|
|
655
|
+
"warning",
|
|
656
|
+
`plugin "${ref}" is not installed locally — run /plugin marketplace add ${ref.split("@")[0]}`,
|
|
657
|
+
{ subject: ref },
|
|
658
|
+
);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
598
661
|
addIssue(
|
|
599
662
|
result,
|
|
600
663
|
"E3",
|
|
@@ -34,7 +34,7 @@ let scratchRoot: string;
|
|
|
34
34
|
let priorEnv: string | undefined;
|
|
35
35
|
|
|
36
36
|
beforeEach(async () => {
|
|
37
|
-
scratchRoot = await mkdtemp(join(tmpdir(), "
|
|
37
|
+
scratchRoot = await mkdtemp(join(tmpdir(), "cue-profile-loader-"));
|
|
38
38
|
priorEnv = process.env.CUE_PROFILES_DIR;
|
|
39
39
|
process.env.CUE_PROFILES_DIR = scratchRoot;
|
|
40
40
|
});
|
|
@@ -373,6 +373,15 @@ function foldChain(chain: Profile[]): ResolvedProfile {
|
|
|
373
373
|
child.plugins?.map(normalizePluginRef),
|
|
374
374
|
),
|
|
375
375
|
env: mergeEnv(acc.env, child.env),
|
|
376
|
+
rules: dedupePrimitiveArray(acc.rules, child.rules),
|
|
377
|
+
commands: dedupePrimitiveArray(acc.commands, child.commands),
|
|
378
|
+
hooks: dedupePrimitiveArray(acc.hooks, child.hooks),
|
|
379
|
+
// Persona is leaf-wins (child overrides parent fully). Concatenating
|
|
380
|
+
// would produce awkward "you are X. ALSO you are Y" priming.
|
|
381
|
+
persona: child.persona ?? acc.persona,
|
|
382
|
+
playbooks: dedupePrimitiveArray(acc.playbooks, child.playbooks),
|
|
383
|
+
qualityGates: dedupePrimitiveArray(acc.qualityGates, child.qualityGates),
|
|
384
|
+
evals: dedupePrimitiveArray(acc.evals, child.evals),
|
|
376
385
|
inheritanceChain: [...acc.inheritanceChain, child.name],
|
|
377
386
|
};
|
|
378
387
|
}
|
|
@@ -401,6 +410,13 @@ function normalizeToResolved(p: Profile, chain: string[]): ResolvedProfile {
|
|
|
401
410
|
mcps: (p.mcps ?? []).map(normalizeMCPRef),
|
|
402
411
|
plugins: (p.plugins ?? []).map(normalizePluginRef),
|
|
403
412
|
env: { ...(p.env ?? {}) },
|
|
413
|
+
rules: [...(p.rules ?? [])],
|
|
414
|
+
commands: [...(p.commands ?? [])],
|
|
415
|
+
hooks: [...(p.hooks ?? [])],
|
|
416
|
+
persona: p.persona ?? "",
|
|
417
|
+
playbooks: [...(p.playbooks ?? [])],
|
|
418
|
+
qualityGates: [...(p.qualityGates ?? [])],
|
|
419
|
+
evals: [...(p.evals ?? [])],
|
|
404
420
|
inheritanceChain: chain,
|
|
405
421
|
};
|
|
406
422
|
}
|
|
@@ -67,7 +67,7 @@ function profile(local: string[]): ResolvedProfile {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
beforeEach(async () => {
|
|
70
|
-
rootDir = await mkdtemp(join(tmpdir(), "
|
|
70
|
+
rootDir = await mkdtemp(join(tmpdir(), "cue-resolver-local-"));
|
|
71
71
|
skillsRoot = join(rootDir, "skills", "skills");
|
|
72
72
|
await mkdir(skillsRoot, { recursive: true });
|
|
73
73
|
await buildTree();
|
|
@@ -29,7 +29,7 @@ let repoRoot: string;
|
|
|
29
29
|
let calls: Array<{ repo: string; pin: string | undefined; skill: string; destDir: string }>;
|
|
30
30
|
|
|
31
31
|
beforeEach(() => {
|
|
32
|
-
repoRoot = mkdtempSync(join(tmpdir(), "
|
|
32
|
+
repoRoot = mkdtempSync(join(tmpdir(), "cue-test-"));
|
|
33
33
|
// The resolver writes under <repoRoot>/profiles/_cache/npx/<key>/, so make
|
|
34
34
|
// sure the parent dirs exist — cache.cachePut calls mkdirSync recursive.
|
|
35
35
|
mkdirSync(join(repoRoot, "profiles", "_cache", "npx"), { recursive: true });
|
|
@@ -307,3 +307,78 @@ describe("resolveNpxDetailed", () => {
|
|
|
307
307
|
);
|
|
308
308
|
});
|
|
309
309
|
});
|
|
310
|
+
|
|
311
|
+
// --- flattenNpxLayout: post-fetch path relocation --------------------------
|
|
312
|
+
|
|
313
|
+
describe("flattenNpxLayout", () => {
|
|
314
|
+
let staging: string;
|
|
315
|
+
|
|
316
|
+
beforeEach(() => {
|
|
317
|
+
staging = mkdtempSync(join(tmpdir(), "cue-flatten-test-"));
|
|
318
|
+
});
|
|
319
|
+
afterEach(() => {
|
|
320
|
+
try { rmSync(staging, { recursive: true, force: true }); } catch {}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("relocates <staging>/.claude/skills/<skill>/ → <staging>/<skill>/", async () => {
|
|
324
|
+
const { flattenNpxLayout } = await import("./resolver-npx");
|
|
325
|
+
|
|
326
|
+
// Set up the layout the `skills` CLI actually produces.
|
|
327
|
+
const fromPath = join(staging, ".claude", "skills", "my-skill");
|
|
328
|
+
mkdirSync(fromPath, { recursive: true });
|
|
329
|
+
writeFileSync(join(fromPath, "SKILL.md"), "# my-skill\n");
|
|
330
|
+
|
|
331
|
+
flattenNpxLayout(staging, "my-skill");
|
|
332
|
+
|
|
333
|
+
const flatPath = join(staging, "my-skill");
|
|
334
|
+
expect(existsSync(flatPath)).toBe(true);
|
|
335
|
+
expect(existsSync(join(flatPath, "SKILL.md"))).toBe(true);
|
|
336
|
+
expect(existsSync(fromPath)).toBe(false);
|
|
337
|
+
// Cleanup: .claude/ should be gone now (was empty after the move).
|
|
338
|
+
expect(existsSync(join(staging, ".claude"))).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("is a no-op if <staging>/<skill>/ already exists (don't clobber)", async () => {
|
|
342
|
+
const { flattenNpxLayout } = await import("./resolver-npx");
|
|
343
|
+
|
|
344
|
+
const existing = join(staging, "my-skill");
|
|
345
|
+
mkdirSync(existing, { recursive: true });
|
|
346
|
+
writeFileSync(join(existing, "EXISTING.md"), "do not overwrite\n");
|
|
347
|
+
|
|
348
|
+
const fromPath = join(staging, ".claude", "skills", "my-skill");
|
|
349
|
+
mkdirSync(fromPath, { recursive: true });
|
|
350
|
+
writeFileSync(join(fromPath, "NEW.md"), "would clobber existing\n");
|
|
351
|
+
|
|
352
|
+
flattenNpxLayout(staging, "my-skill");
|
|
353
|
+
|
|
354
|
+
// The existing skill is preserved.
|
|
355
|
+
expect(existsSync(join(existing, "EXISTING.md"))).toBe(true);
|
|
356
|
+
expect(existsSync(join(existing, "NEW.md"))).toBe(false);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("is a no-op when source layout is absent (e.g. CLI changed again)", async () => {
|
|
360
|
+
const { flattenNpxLayout } = await import("./resolver-npx");
|
|
361
|
+
|
|
362
|
+
// Nothing in staging — flattening should be silent, not throw.
|
|
363
|
+
expect(() => flattenNpxLayout(staging, "missing-skill")).not.toThrow();
|
|
364
|
+
expect(existsSync(join(staging, "missing-skill"))).toBe(false);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("leaves sibling skills under .claude/skills/ alone when relocating one", async () => {
|
|
368
|
+
const { flattenNpxLayout } = await import("./resolver-npx");
|
|
369
|
+
|
|
370
|
+
const skillsDir = join(staging, ".claude", "skills");
|
|
371
|
+
mkdirSync(join(skillsDir, "alpha"), { recursive: true });
|
|
372
|
+
mkdirSync(join(skillsDir, "beta"), { recursive: true });
|
|
373
|
+
writeFileSync(join(skillsDir, "alpha", "SKILL.md"), "");
|
|
374
|
+
writeFileSync(join(skillsDir, "beta", "SKILL.md"), "");
|
|
375
|
+
|
|
376
|
+
flattenNpxLayout(staging, "alpha");
|
|
377
|
+
|
|
378
|
+
// alpha got moved out, beta stayed in place — the .claude tree is still
|
|
379
|
+
// present because beta is still there.
|
|
380
|
+
expect(existsSync(join(staging, "alpha", "SKILL.md"))).toBe(true);
|
|
381
|
+
expect(existsSync(join(skillsDir, "beta", "SKILL.md"))).toBe(true);
|
|
382
|
+
expect(existsSync(join(skillsDir, "alpha"))).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
});
|
package/src/lib/resolver-npx.ts
CHANGED
|
@@ -118,7 +118,7 @@ export type NpxFetchFn = (
|
|
|
118
118
|
* because the tests pass their own fetcher.
|
|
119
119
|
*/
|
|
120
120
|
export const npxFetch: NpxFetchFn = async (repo, pin, skill, destDir) => {
|
|
121
|
-
const args = ["skills", "add", repo, "--skill", skill, "-a", "claude-code", "-y"];
|
|
121
|
+
const args = ["-y", "skills@latest", "add", repo, "--skill", skill, "-a", "claude-code", "-y"];
|
|
122
122
|
if (pin) {
|
|
123
123
|
// Pin format from schema: "git@<sha>" or "tag@<version>".
|
|
124
124
|
// `npx skills add` accepts `--ref <ref>` for both shas and tags.
|
|
@@ -139,8 +139,40 @@ export const npxFetch: NpxFetchFn = async (repo, pin, skill, destDir) => {
|
|
|
139
139
|
stderr: res.stderr,
|
|
140
140
|
});
|
|
141
141
|
}
|
|
142
|
+
|
|
143
|
+
flattenNpxLayout(destDir, skill);
|
|
142
144
|
};
|
|
143
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The `skills` CLI drops fetched skills at `<destDir>/.claude/skills/<skill>/`
|
|
148
|
+
* (it follows Claude Code's runtime layout). Cue's resolver expects a flat
|
|
149
|
+
* `<destDir>/<skill>/` layout — so relocate here. Exported for tests; callers
|
|
150
|
+
* outside this module should not need this.
|
|
151
|
+
*/
|
|
152
|
+
export function flattenNpxLayout(destDir: string, skill: string): void {
|
|
153
|
+
const fromClaudeLayout = join(destDir, ".claude", "skills", skill);
|
|
154
|
+
const flatTarget = join(destDir, skill);
|
|
155
|
+
if (!existsSync(fromClaudeLayout) || existsSync(flatTarget)) return;
|
|
156
|
+
|
|
157
|
+
// Lazy import — avoids node:fs/promises overhead in the cached-fetch path.
|
|
158
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
159
|
+
const fs = require("node:fs") as typeof import("node:fs");
|
|
160
|
+
fs.renameSync(fromClaudeLayout, flatTarget);
|
|
161
|
+
|
|
162
|
+
// Clean up the now-empty .claude/skills/ scaffold so the staging dir
|
|
163
|
+
// doesn't accumulate cruft when multiple skills are pulled in sequence.
|
|
164
|
+
try {
|
|
165
|
+
const claudeSkills = join(destDir, ".claude", "skills");
|
|
166
|
+
if (existsSync(claudeSkills) && fs.readdirSync(claudeSkills).length === 0) {
|
|
167
|
+
fs.rmSync(claudeSkills, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
const claudeDir = join(destDir, ".claude");
|
|
170
|
+
if (existsSync(claudeDir) && fs.readdirSync(claudeDir).length === 0) {
|
|
171
|
+
fs.rmSync(claudeDir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
} catch { /* cleanup is best-effort */ }
|
|
174
|
+
}
|
|
175
|
+
|
|
144
176
|
// ---------------------------------------------------------------------------
|
|
145
177
|
// Public resolver
|
|
146
178
|
// ---------------------------------------------------------------------------
|
|
@@ -156,7 +188,7 @@ export interface ResolveNpxOptions {
|
|
|
156
188
|
|
|
157
189
|
export interface ResolveNpxResult {
|
|
158
190
|
plans: LinkPlan[];
|
|
159
|
-
/** Per-entry cache key — useful for debugging / `
|
|
191
|
+
/** Per-entry cache key — useful for debugging / `cue doctor`. */
|
|
160
192
|
keys: Record<string, string>;
|
|
161
193
|
}
|
|
162
194
|
|
|
@@ -263,7 +295,7 @@ async function fetchInto(
|
|
|
263
295
|
// If the slot already exists (partial-hit repair), we merge skill subdirs
|
|
264
296
|
// into the existing slot rather than nuking it; this keeps already-good
|
|
265
297
|
// skills warm.
|
|
266
|
-
const staging = mkdtempSync(join(tmpdir(), "
|
|
298
|
+
const staging = mkdtempSync(join(tmpdir(), "cue-npx-"));
|
|
267
299
|
try {
|
|
268
300
|
for (const skill of skills) {
|
|
269
301
|
await fetcher(entry.repo, entry.pin, skill, staging);
|