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,208 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { findFreshestCredentials, syncFreshestToSource } from "./credentials-sync";
|
|
7
|
+
|
|
8
|
+
let root: string;
|
|
9
|
+
beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "cue-credsync-")); });
|
|
10
|
+
afterEach(async () => { await rm(root, { recursive: true, force: true }); });
|
|
11
|
+
|
|
12
|
+
const UUID_A = "aaaaaaaa-1111-2222-3333-444444444444";
|
|
13
|
+
const UUID_B = "bbbbbbbb-5555-6666-7777-888888888888";
|
|
14
|
+
|
|
15
|
+
interface Creds {
|
|
16
|
+
accessToken?: string;
|
|
17
|
+
refreshToken: string;
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function writeAccountDir(dir: string, uuid: string | undefined, creds: Creds | undefined): Promise<void> {
|
|
22
|
+
await mkdir(dir, { recursive: true });
|
|
23
|
+
if (uuid) {
|
|
24
|
+
await writeFile(
|
|
25
|
+
join(dir, ".claude.json"),
|
|
26
|
+
JSON.stringify({ oauthAccount: { accountUuid: uuid, emailAddress: "u@example.com" } }),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (creds) {
|
|
30
|
+
await writeFile(
|
|
31
|
+
join(dir, ".credentials.json"),
|
|
32
|
+
JSON.stringify({ claudeAiOauth: { accessToken: "at-" + creds.refreshToken, ...creds } }),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("findFreshestCredentials", () => {
|
|
38
|
+
test("returns undefined when no credentials exist anywhere", async () => {
|
|
39
|
+
const sourceDir = join(root, "source");
|
|
40
|
+
await mkdir(sourceDir, { recursive: true });
|
|
41
|
+
const out = await findFreshestCredentials(sourceDir, join(root, "runtime"));
|
|
42
|
+
expect(out).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("returns source when source has the highest expiresAt", async () => {
|
|
46
|
+
const sourceDir = join(root, "source");
|
|
47
|
+
await writeAccountDir(sourceDir, UUID_A, { refreshToken: "rt-source", expiresAt: 9999 });
|
|
48
|
+
|
|
49
|
+
await writeAccountDir(
|
|
50
|
+
join(root, "runtime", "old", "claude"),
|
|
51
|
+
UUID_A,
|
|
52
|
+
{ refreshToken: "rt-old", expiresAt: 1000 },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const out = await findFreshestCredentials(sourceDir, join(root, "runtime"));
|
|
56
|
+
expect(out).toBeDefined();
|
|
57
|
+
expect(out!.path).toBe(join(sourceDir, ".credentials.json"));
|
|
58
|
+
expect(out!.expiresAt).toBe(9999);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns sibling runtime when it has a higher expiresAt than source", async () => {
|
|
62
|
+
const sourceDir = join(root, "source");
|
|
63
|
+
await writeAccountDir(sourceDir, UUID_A, { refreshToken: "rt-stale", expiresAt: 1000 });
|
|
64
|
+
|
|
65
|
+
const freshRuntime = join(root, "runtime", "core", "claude");
|
|
66
|
+
await writeAccountDir(freshRuntime, UUID_A, { refreshToken: "rt-fresh", expiresAt: 5000 });
|
|
67
|
+
|
|
68
|
+
const out = await findFreshestCredentials(sourceDir, join(root, "runtime"));
|
|
69
|
+
expect(out).toBeDefined();
|
|
70
|
+
expect(out!.path).toBe(join(freshRuntime, ".credentials.json"));
|
|
71
|
+
expect(out!.refreshToken).toBe("rt-fresh");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("ignores runtime profiles with a different accountUuid", async () => {
|
|
75
|
+
const sourceDir = join(root, "source");
|
|
76
|
+
await writeAccountDir(sourceDir, UUID_A, { refreshToken: "rt-source", expiresAt: 1000 });
|
|
77
|
+
|
|
78
|
+
// Different account — must NOT be picked even though it has a higher expiresAt.
|
|
79
|
+
await writeAccountDir(
|
|
80
|
+
join(root, "runtime", "other-acct", "claude"),
|
|
81
|
+
UUID_B,
|
|
82
|
+
{ refreshToken: "rt-other-account", expiresAt: 9999 },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Same account — should be picked.
|
|
86
|
+
await writeAccountDir(
|
|
87
|
+
join(root, "runtime", "same-acct", "claude"),
|
|
88
|
+
UUID_A,
|
|
89
|
+
{ refreshToken: "rt-same-account", expiresAt: 5000 },
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const out = await findFreshestCredentials(sourceDir, join(root, "runtime"));
|
|
93
|
+
expect(out).toBeDefined();
|
|
94
|
+
expect(out!.refreshToken).toBe("rt-same-account");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("skips runtime files with empty refresh tokens", async () => {
|
|
98
|
+
const sourceDir = join(root, "source");
|
|
99
|
+
await writeAccountDir(sourceDir, UUID_A, { refreshToken: "rt-source", expiresAt: 1000 });
|
|
100
|
+
|
|
101
|
+
// Higher expiresAt but empty refreshToken — must be skipped.
|
|
102
|
+
await writeAccountDir(
|
|
103
|
+
join(root, "runtime", "broken", "claude"),
|
|
104
|
+
UUID_A,
|
|
105
|
+
{ refreshToken: "", expiresAt: 9999 },
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const out = await findFreshestCredentials(sourceDir, join(root, "runtime"));
|
|
109
|
+
expect(out!.path).toBe(join(sourceDir, ".credentials.json"));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Regression: runtime dirs whose `.credentials.json` is a symlink into a
|
|
113
|
+
// *different* account's source (cue used to symlink shared state) and which
|
|
114
|
+
// therefore have no local `.claude.json` to identify the account. Without a
|
|
115
|
+
// strict uuid match we'd cross-contaminate (account1's source got account2's
|
|
116
|
+
// tokens during the v1 heal — this test pins the fix).
|
|
117
|
+
test("skips candidates with no .claude.json even if they have credentials", async () => {
|
|
118
|
+
const sourceDir = join(root, "source");
|
|
119
|
+
await writeAccountDir(sourceDir, UUID_A, { refreshToken: "rt-A", expiresAt: 1000 });
|
|
120
|
+
|
|
121
|
+
// Runtime dir without a .claude.json but with .credentials.json that
|
|
122
|
+
// happens to belong to account B (e.g. a symlink into account B's storage).
|
|
123
|
+
const dangerous = join(root, "runtime", "no-uuid", "claude");
|
|
124
|
+
await writeAccountDir(dangerous, undefined, { refreshToken: "rt-B-stolen", expiresAt: 9999 });
|
|
125
|
+
|
|
126
|
+
const out = await findFreshestCredentials(sourceDir, join(root, "runtime"));
|
|
127
|
+
expect(out!.path).toBe(join(sourceDir, ".credentials.json"));
|
|
128
|
+
expect(out!.refreshToken).toBe("rt-A");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("returns source-only when source has no .claude.json (unknown target uuid)", async () => {
|
|
132
|
+
const sourceDir = join(root, "source");
|
|
133
|
+
// No .claude.json — uuid is unknown.
|
|
134
|
+
await writeAccountDir(sourceDir, undefined, { refreshToken: "rt-source", expiresAt: 1000 });
|
|
135
|
+
|
|
136
|
+
// Runtime has fresher creds for some account, but we shouldn't trust them
|
|
137
|
+
// when we can't verify the source's identity.
|
|
138
|
+
await writeAccountDir(
|
|
139
|
+
join(root, "runtime", "any", "claude"),
|
|
140
|
+
UUID_A,
|
|
141
|
+
{ refreshToken: "rt-runtime", expiresAt: 9999 },
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const out = await findFreshestCredentials(sourceDir, join(root, "runtime"));
|
|
145
|
+
expect(out!.path).toBe(join(sourceDir, ".credentials.json"));
|
|
146
|
+
expect(out!.refreshToken).toBe("rt-source");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("syncFreshestToSource", () => {
|
|
151
|
+
test("copies freshest sibling into source when source is stale", async () => {
|
|
152
|
+
const sourceDir = join(root, "source");
|
|
153
|
+
await writeAccountDir(sourceDir, UUID_A, { refreshToken: "rt-stale", expiresAt: 1000 });
|
|
154
|
+
|
|
155
|
+
await writeAccountDir(
|
|
156
|
+
join(root, "runtime", "core", "claude"),
|
|
157
|
+
UUID_A,
|
|
158
|
+
{ refreshToken: "rt-fresh", expiresAt: 5000 },
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const result = await syncFreshestToSource(sourceDir, join(root, "runtime"));
|
|
162
|
+
expect(result.synced).toBe(true);
|
|
163
|
+
|
|
164
|
+
const after = JSON.parse(await readFile(join(sourceDir, ".credentials.json"), "utf8"));
|
|
165
|
+
expect(after.claudeAiOauth.refreshToken).toBe("rt-fresh");
|
|
166
|
+
expect(after.claudeAiOauth.expiresAt).toBe(5000);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("does not write when source is already freshest", async () => {
|
|
170
|
+
const sourceDir = join(root, "source");
|
|
171
|
+
await writeAccountDir(sourceDir, UUID_A, { refreshToken: "rt-source", expiresAt: 9999 });
|
|
172
|
+
await writeAccountDir(
|
|
173
|
+
join(root, "runtime", "old", "claude"),
|
|
174
|
+
UUID_A,
|
|
175
|
+
{ refreshToken: "rt-old", expiresAt: 1000 },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const result = await syncFreshestToSource(sourceDir, join(root, "runtime"));
|
|
179
|
+
expect(result.synced).toBe(false);
|
|
180
|
+
|
|
181
|
+
const after = JSON.parse(await readFile(join(sourceDir, ".credentials.json"), "utf8"));
|
|
182
|
+
expect(after.claudeAiOauth.refreshToken).toBe("rt-source");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("does nothing when no credentials exist at all", async () => {
|
|
186
|
+
const sourceDir = join(root, "source");
|
|
187
|
+
await mkdir(sourceDir, { recursive: true });
|
|
188
|
+
const result = await syncFreshestToSource(sourceDir, join(root, "runtime"));
|
|
189
|
+
expect(result.synced).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("does not cross-contaminate across accountUuids", async () => {
|
|
193
|
+
const sourceDir = join(root, "source");
|
|
194
|
+
await writeAccountDir(sourceDir, UUID_A, { refreshToken: "rt-A", expiresAt: 1000 });
|
|
195
|
+
// Different account, much fresher — must NOT overwrite source.
|
|
196
|
+
await writeAccountDir(
|
|
197
|
+
join(root, "runtime", "other", "claude"),
|
|
198
|
+
UUID_B,
|
|
199
|
+
{ refreshToken: "rt-B", expiresAt: 9999 },
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const result = await syncFreshestToSource(sourceDir, join(root, "runtime"));
|
|
203
|
+
expect(result.synced).toBe(false);
|
|
204
|
+
|
|
205
|
+
const after = JSON.parse(await readFile(join(sourceDir, ".credentials.json"), "utf8"));
|
|
206
|
+
expect(after.claudeAiOauth.refreshToken).toBe("rt-A");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credentials-sync — heal OAuth refresh-token rotation desync between an
|
|
3
|
+
* authmux account snapshot (`~/.claude-accounts/<name>/.credentials.json`)
|
|
4
|
+
* and the per-profile cue runtimes (`~/.config/cue/runtime/<profile>/claude/`).
|
|
5
|
+
*
|
|
6
|
+
* The problem
|
|
7
|
+
* -----------
|
|
8
|
+
* Anthropic's OAuth rotates the refresh token on every refresh. cue
|
|
9
|
+
* materializes a separate `.credentials.json` per profile (so concurrent
|
|
10
|
+
* sessions don't clobber each other's session state). When profile A
|
|
11
|
+
* refreshes mid-session, the previous refresh token is revoked — meaning
|
|
12
|
+
* every other profile's copy (and the source snapshot) now holds a dead
|
|
13
|
+
* refresh token. Spinning up a new profile from that stale source forces
|
|
14
|
+
* the user to re-login.
|
|
15
|
+
*
|
|
16
|
+
* The fix
|
|
17
|
+
* -------
|
|
18
|
+
* Before materialization, scan the source dir + every existing runtime
|
|
19
|
+
* `<profile>/claude/.credentials.json` belonging to the same `accountUuid`,
|
|
20
|
+
* pick the one with the highest `expiresAt`, and copy it back to the source
|
|
21
|
+
* so the materializer's overlay step sees fresh tokens.
|
|
22
|
+
*
|
|
23
|
+
* Pure surface — caller injects fs-rooted paths so this is testable without
|
|
24
|
+
* touching `~/`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFile, readdir, copyFile, stat } from "node:fs/promises";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
interface CredentialsBlob {
|
|
31
|
+
claudeAiOauth?: {
|
|
32
|
+
accessToken?: string;
|
|
33
|
+
refreshToken?: string;
|
|
34
|
+
expiresAt?: number;
|
|
35
|
+
scopes?: string[];
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ClaudeJsonBlob {
|
|
40
|
+
oauthAccount?: {
|
|
41
|
+
accountUuid?: string;
|
|
42
|
+
emailAddress?: string;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FreshestCandidate {
|
|
47
|
+
path: string;
|
|
48
|
+
expiresAt: number;
|
|
49
|
+
refreshToken: string;
|
|
50
|
+
accountUuid: string | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read the `accountUuid` recorded in `<dir>/.claude.json`. Returns undefined
|
|
55
|
+
* if the file is missing or doesn't have the OAuth metadata.
|
|
56
|
+
*/
|
|
57
|
+
async function readAccountUuid(dir: string): Promise<string | undefined> {
|
|
58
|
+
try {
|
|
59
|
+
const raw = await readFile(join(dir, ".claude.json"), "utf8");
|
|
60
|
+
const parsed = JSON.parse(raw) as ClaudeJsonBlob;
|
|
61
|
+
return parsed?.oauthAccount?.accountUuid;
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read `<dir>/.credentials.json` and return the fields we care about.
|
|
69
|
+
* Returns undefined if the file is missing or unparseable.
|
|
70
|
+
*/
|
|
71
|
+
async function readCredentials(dir: string): Promise<FreshestCandidate | undefined> {
|
|
72
|
+
const path = join(dir, ".credentials.json");
|
|
73
|
+
try {
|
|
74
|
+
const raw = await readFile(path, "utf8");
|
|
75
|
+
const parsed = JSON.parse(raw) as CredentialsBlob;
|
|
76
|
+
const oauth = parsed?.claudeAiOauth;
|
|
77
|
+
if (!oauth) return undefined;
|
|
78
|
+
return {
|
|
79
|
+
path,
|
|
80
|
+
expiresAt: typeof oauth.expiresAt === "number" ? oauth.expiresAt : 0,
|
|
81
|
+
refreshToken: typeof oauth.refreshToken === "string" ? oauth.refreshToken : "",
|
|
82
|
+
accountUuid: await readAccountUuid(dir),
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Walk `<runtimeRoot>/<profile>/claude/` for every existing profile and
|
|
91
|
+
* collect candidates whose `accountUuid` matches `targetUuid`.
|
|
92
|
+
*
|
|
93
|
+
* Strictness rules (keep these — they prevent cross-account contamination):
|
|
94
|
+
* 1. If `targetUuid` is known, the candidate's accountUuid MUST match it.
|
|
95
|
+
* A candidate with no `.claude.json` (undefined accountUuid) is skipped:
|
|
96
|
+
* its `.credentials.json` could be a symlink pointing back into a
|
|
97
|
+
* different account's source dir, and we have no way to verify it
|
|
98
|
+
* belongs to the target account.
|
|
99
|
+
* 2. If `targetUuid` is unknown (the source dir has no `.claude.json` —
|
|
100
|
+
* e.g. a fresh, empty profile), we conservatively return [] rather
|
|
101
|
+
* than mixing tokens from random accounts. Caller falls back to source.
|
|
102
|
+
*/
|
|
103
|
+
async function collectRuntimeCandidates(
|
|
104
|
+
runtimeRoot: string,
|
|
105
|
+
targetUuid: string | undefined,
|
|
106
|
+
): Promise<FreshestCandidate[]> {
|
|
107
|
+
if (!targetUuid) return [];
|
|
108
|
+
|
|
109
|
+
let dirs: string[];
|
|
110
|
+
try {
|
|
111
|
+
dirs = await readdir(runtimeRoot);
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const out: FreshestCandidate[] = [];
|
|
117
|
+
for (const profile of dirs) {
|
|
118
|
+
const claudeDir = join(runtimeRoot, profile, "claude");
|
|
119
|
+
try {
|
|
120
|
+
const st = await stat(claudeDir);
|
|
121
|
+
if (!st.isDirectory()) continue;
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const cand = await readCredentials(claudeDir);
|
|
126
|
+
if (!cand) continue;
|
|
127
|
+
// Strict uuid match — must equal the target. Undefined uuids are treated
|
|
128
|
+
// as "unknown account" and skipped because the credentials file may be
|
|
129
|
+
// a symlink pointing into a different account's storage.
|
|
130
|
+
if (cand.accountUuid !== targetUuid) continue;
|
|
131
|
+
if (cand.refreshToken.length === 0) continue;
|
|
132
|
+
out.push(cand);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Find the freshest `.credentials.json` for the account anchored at
|
|
139
|
+
* `sourceDir`. Looks in:
|
|
140
|
+
* - sourceDir itself
|
|
141
|
+
* - every `<runtimeRoot>/<profile>/claude/` whose `.claude.json` reports
|
|
142
|
+
* the same accountUuid as sourceDir
|
|
143
|
+
*
|
|
144
|
+
* Returns the candidate with the highest `expiresAt`, or undefined if no
|
|
145
|
+
* usable candidates exist (no creds anywhere — caller falls back to source
|
|
146
|
+
* which the materializer already handles).
|
|
147
|
+
*/
|
|
148
|
+
export async function findFreshestCredentials(
|
|
149
|
+
sourceDir: string,
|
|
150
|
+
runtimeRoot: string,
|
|
151
|
+
): Promise<FreshestCandidate | undefined> {
|
|
152
|
+
const targetUuid = await readAccountUuid(sourceDir);
|
|
153
|
+
const candidates: FreshestCandidate[] = [];
|
|
154
|
+
|
|
155
|
+
const sourceCand = await readCredentials(sourceDir);
|
|
156
|
+
if (sourceCand) candidates.push(sourceCand);
|
|
157
|
+
|
|
158
|
+
const runtimeCands = await collectRuntimeCandidates(runtimeRoot, targetUuid);
|
|
159
|
+
candidates.push(...runtimeCands);
|
|
160
|
+
|
|
161
|
+
if (candidates.length === 0) return undefined;
|
|
162
|
+
|
|
163
|
+
candidates.sort((a, b) => b.expiresAt - a.expiresAt);
|
|
164
|
+
return candidates[0];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* If the freshest credential blob lives somewhere other than `sourceDir`,
|
|
169
|
+
* copy it back to source so the next materialize step picks up live tokens.
|
|
170
|
+
*
|
|
171
|
+
* Returns:
|
|
172
|
+
* - { synced: true, from } when source was healed
|
|
173
|
+
* - { synced: false } when source was already freshest (or no candidates)
|
|
174
|
+
*
|
|
175
|
+
* Failures are swallowed and reported as `synced: false` — this is a
|
|
176
|
+
* best-effort heal. Materialization will still proceed.
|
|
177
|
+
*/
|
|
178
|
+
export async function syncFreshestToSource(
|
|
179
|
+
sourceDir: string,
|
|
180
|
+
runtimeRoot: string,
|
|
181
|
+
): Promise<{ synced: false } | { synced: true; from: string; expiresAt: number }> {
|
|
182
|
+
const freshest = await findFreshestCredentials(sourceDir, runtimeRoot);
|
|
183
|
+
if (!freshest) return { synced: false };
|
|
184
|
+
|
|
185
|
+
const sourcePath = join(sourceDir, ".credentials.json");
|
|
186
|
+
if (freshest.path === sourcePath) return { synced: false };
|
|
187
|
+
|
|
188
|
+
// Only copy if the freshest candidate is *strictly* newer than source.
|
|
189
|
+
// Equal expiresAt → keep source untouched (no benefit, avoids needless writes).
|
|
190
|
+
let sourceExpiresAt = 0;
|
|
191
|
+
try {
|
|
192
|
+
const raw = await readFile(sourcePath, "utf8");
|
|
193
|
+
const parsed = JSON.parse(raw) as CredentialsBlob;
|
|
194
|
+
sourceExpiresAt = parsed?.claudeAiOauth?.expiresAt ?? 0;
|
|
195
|
+
} catch { /* missing — anything is better */ }
|
|
196
|
+
|
|
197
|
+
if (freshest.expiresAt <= sourceExpiresAt) return { synced: false };
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await copyFile(freshest.path, sourcePath);
|
|
201
|
+
return { synced: true, from: freshest.path, expiresAt: freshest.expiresAt };
|
|
202
|
+
} catch {
|
|
203
|
+
return { synced: false };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -115,7 +115,7 @@ function makeProfile(
|
|
|
115
115
|
// ---------------------------------------------------------------------------
|
|
116
116
|
|
|
117
117
|
beforeEach(async () => {
|
|
118
|
-
configsRoot = await mkdtemp(join(tmpdir(), "
|
|
118
|
+
configsRoot = await mkdtemp(join(tmpdir(), "cue-mcp-mat-"));
|
|
119
119
|
await mkdir(configsRoot, { recursive: true });
|
|
120
120
|
await writeFixtures(configsRoot);
|
|
121
121
|
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Phase 1+2 schema extensions (persona, playbooks, qualityGates, evals).
|
|
3
|
+
* Verifies profile-loader merge semantics and runtime-materializer output without
|
|
4
|
+
* needing real npx skills or live MCP servers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync, readFileSync, readlinkSync, existsSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { materializeRuntime } from "./runtime-materializer";
|
|
13
|
+
import type { ResolvedProfile } from "../../profiles/_types";
|
|
14
|
+
|
|
15
|
+
let root: string;
|
|
16
|
+
beforeEach(async () => { root = mkdtempSync(join(tmpdir(), "cue-persona-")); });
|
|
17
|
+
afterEach(async () => { try { rmSync(root, { recursive: true, force: true }); } catch {} });
|
|
18
|
+
|
|
19
|
+
const base: ResolvedProfile = {
|
|
20
|
+
name: "test-persona", description: "test profile",
|
|
21
|
+
agents: ["claude-code"], inheritanceChain: ["test-persona"],
|
|
22
|
+
skills: { local: [], npx: [] },
|
|
23
|
+
mcps: [], plugins: [], env: {},
|
|
24
|
+
rules: [], commands: [], hooks: [],
|
|
25
|
+
persona: "", playbooks: [], qualityGates: [], evals: [],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("Phase 1: persona injection", () => {
|
|
29
|
+
test("when persona is set, it appears as '## Your Expertise' above '## Your Role' in CLAUDE.md", async () => {
|
|
30
|
+
const profile: ResolvedProfile = {
|
|
31
|
+
...base,
|
|
32
|
+
persona: "You're a senior Rust engineer. You default to safety.",
|
|
33
|
+
};
|
|
34
|
+
const out = await materializeRuntime({
|
|
35
|
+
profile, agent: "claude-code", runtimeRoot: join(root, "runtime"),
|
|
36
|
+
skillSourceLookup: async (id) => `/fake/${id}`,
|
|
37
|
+
mcpRegistry: {}, userClaudeMd: "",
|
|
38
|
+
});
|
|
39
|
+
const md = readFileSync(join(out.runtimeDir, "CLAUDE.md"), "utf8");
|
|
40
|
+
expect(md).toContain("## Your Expertise");
|
|
41
|
+
expect(md).toContain("senior Rust engineer");
|
|
42
|
+
// Ordering: persona before role
|
|
43
|
+
const expertiseIdx = md.indexOf("## Your Expertise");
|
|
44
|
+
const roleIdx = md.indexOf("## Your Role");
|
|
45
|
+
expect(expertiseIdx).toBeGreaterThan(0);
|
|
46
|
+
expect(roleIdx).toBeGreaterThan(expertiseIdx);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("when persona is empty, '## Your Expertise' is omitted (backwards-compat)", async () => {
|
|
50
|
+
const out = await materializeRuntime({
|
|
51
|
+
profile: base, agent: "claude-code", runtimeRoot: join(root, "runtime"),
|
|
52
|
+
skillSourceLookup: async (id) => `/fake/${id}`,
|
|
53
|
+
mcpRegistry: {}, userClaudeMd: "",
|
|
54
|
+
});
|
|
55
|
+
const md = readFileSync(join(out.runtimeDir, "CLAUDE.md"), "utf8");
|
|
56
|
+
expect(md).not.toContain("## Your Expertise");
|
|
57
|
+
expect(md).toContain("## Your Role");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("Phase 2: playbooks symlink + index", () => {
|
|
62
|
+
test("playbook refs are symlinked into <runtime>/playbooks/ and indexed in CLAUDE.md", async () => {
|
|
63
|
+
const profile: ResolvedProfile = {
|
|
64
|
+
...base,
|
|
65
|
+
playbooks: ["ship-feature", "triage-bug"],
|
|
66
|
+
};
|
|
67
|
+
const out = await materializeRuntime({
|
|
68
|
+
profile, agent: "claude-code", runtimeRoot: join(root, "runtime"),
|
|
69
|
+
skillSourceLookup: async (id) => `/fake/${id}`,
|
|
70
|
+
mcpRegistry: {}, userClaudeMd: "",
|
|
71
|
+
});
|
|
72
|
+
const link = readlinkSync(join(out.runtimeDir, "playbooks", "ship-feature.md"));
|
|
73
|
+
expect(link).toContain("resources/playbooks/ship-feature.md");
|
|
74
|
+
const md = readFileSync(join(out.runtimeDir, "CLAUDE.md"), "utf8");
|
|
75
|
+
expect(md).toContain("## Playbooks (2)");
|
|
76
|
+
expect(md).toContain("`playbooks/ship-feature.md`");
|
|
77
|
+
expect(md).toContain("`playbooks/triage-bug.md`");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("missing playbook ref is non-fatal (skipped silently)", async () => {
|
|
81
|
+
const profile: ResolvedProfile = {
|
|
82
|
+
...base, playbooks: ["definitely-not-a-real-playbook"],
|
|
83
|
+
};
|
|
84
|
+
const out = await materializeRuntime({
|
|
85
|
+
profile, agent: "claude-code", runtimeRoot: join(root, "runtime"),
|
|
86
|
+
skillSourceLookup: async (id) => `/fake/${id}`,
|
|
87
|
+
mcpRegistry: {}, userClaudeMd: "",
|
|
88
|
+
});
|
|
89
|
+
expect(out.rebuilt).toBe(true);
|
|
90
|
+
// dir gets created but with no symlinks
|
|
91
|
+
expect(existsSync(join(out.runtimeDir, "playbooks"))).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("Phase 3: quality gates symlink + CLAUDE.md mention", () => {
|
|
96
|
+
test("gate refs are symlinked and mentioned in '## Quality Gates' block", async () => {
|
|
97
|
+
const profile: ResolvedProfile = {
|
|
98
|
+
...base, qualityGates: ["tests-pass.sh"],
|
|
99
|
+
};
|
|
100
|
+
const out = await materializeRuntime({
|
|
101
|
+
profile, agent: "claude-code", runtimeRoot: join(root, "runtime"),
|
|
102
|
+
skillSourceLookup: async (id) => `/fake/${id}`,
|
|
103
|
+
mcpRegistry: {}, userClaudeMd: "",
|
|
104
|
+
});
|
|
105
|
+
const link = readlinkSync(join(out.runtimeDir, "quality-gates", "tests-pass.sh"));
|
|
106
|
+
expect(link).toContain("resources/quality-gates/tests-pass.sh");
|
|
107
|
+
const md = readFileSync(join(out.runtimeDir, "CLAUDE.md"), "utf8");
|
|
108
|
+
expect(md).toContain("## Quality Gates");
|
|
109
|
+
expect(md).toContain("`tests-pass.sh`");
|
|
110
|
+
});
|
|
111
|
+
});
|