@agentplate/cli 1.0.0 → 1.2.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/CHANGELOG.md +50 -0
- package/agents/coordinator.md +43 -13
- package/agents/lead.md +8 -1
- package/package.json +5 -5
- package/src/agents/capacity.test.ts +55 -0
- package/src/agents/capacity.ts +50 -0
- package/src/agents/drive.test.ts +155 -0
- package/src/agents/drive.ts +200 -0
- package/src/agents/system-prompt.ts +2 -1
- package/src/commands/sling.test.ts +84 -0
- package/src/commands/sling.ts +73 -117
- package/src/commands/spec.test.ts +142 -0
- package/src/commands/spec.ts +192 -0
- package/src/commands/turn.test.ts +101 -0
- package/src/commands/turn.ts +113 -0
- package/src/config.test.ts +18 -0
- package/src/config.ts +6 -1
- package/src/errors.ts +11 -0
- package/src/index.ts +4 -0
- package/src/insights/quality-gates.test.ts +43 -0
- package/src/insights/quality-gates.ts +30 -31
- package/src/merge/auto.test.ts +157 -0
- package/src/merge/auto.ts +118 -0
- package/src/paths.ts +2 -1
- package/src/runtimes/resolve.test.ts +49 -0
- package/src/runtimes/resolve.ts +11 -7
- package/src/sessions/store.test.ts +13 -0
- package/src/sessions/store.ts +20 -0
- package/src/types.ts +16 -1
- package/src/version.ts +1 -1
- package/src/wizard/setup.test.ts +45 -0
- package/src/wizard/setup.ts +119 -6
- package/ui/dist/assets/index-DAq3_wei.css +1 -0
- package/ui/dist/assets/index-DjRGeS6V.js +4227 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-C7rXIMER.css +0 -1
- package/ui/dist/assets/index-W4kbr4by.js +0 -4526
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-merge: land a completed worker's branch onto the canonical branch without
|
|
3
|
+
* an operator running `agentplate merge`. Gated by `config.merge.autoMerge`:
|
|
4
|
+
*
|
|
5
|
+
* off → never (manual merge by the operator/coordinator)
|
|
6
|
+
* on-gates-pass → merge only when the task's quality gates passed cleanly
|
|
7
|
+
* on-complete → merge as soon as the agent finished without error
|
|
8
|
+
*
|
|
9
|
+
* This reuses the exact same path as the manual `merge` command — the merge queue
|
|
10
|
+
* (audit), the cross-process merge lock (so parallel auto-merges serialize), and
|
|
11
|
+
* {@link mergeBranch} (clean-merge / auto-resolve per `aiResolveEnabled`). Outcomes
|
|
12
|
+
* are reported to the parent/coordinator over mail (`merged` / `merge_failed`), so
|
|
13
|
+
* the existing coordination flow still sees every landing and handles conflicts.
|
|
14
|
+
*
|
|
15
|
+
* Pulled out of `sling` into its own unit so it can be tested against a real temp
|
|
16
|
+
* git repo without driving an agent turn.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createMailClient } from "../mail/client.ts";
|
|
20
|
+
import { mergeDbPath } from "../paths.ts";
|
|
21
|
+
import type { AutoMergeMode, Capability, MergeStatus, MergeTier, OutcomeStatus } from "../types.ts";
|
|
22
|
+
import { withMergeLock } from "./lock.ts";
|
|
23
|
+
import { createMergeQueue } from "./queue.ts";
|
|
24
|
+
import { mergeBranch } from "./resolver.ts";
|
|
25
|
+
|
|
26
|
+
/** Capabilities that never produce a branch worth landing on the canonical branch. */
|
|
27
|
+
const NON_MERGING_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>(["scout", "merger"]);
|
|
28
|
+
|
|
29
|
+
export interface AutoMergeParams {
|
|
30
|
+
root: string;
|
|
31
|
+
branchName: string;
|
|
32
|
+
targetBranch: string;
|
|
33
|
+
capability: Capability;
|
|
34
|
+
agentName: string;
|
|
35
|
+
taskId: string;
|
|
36
|
+
/** Who to notify of the outcome (falls back to the coordinator). */
|
|
37
|
+
parent: string | null;
|
|
38
|
+
mode: AutoMergeMode;
|
|
39
|
+
/** Conflict strategy passed through to `mergeBranch`. */
|
|
40
|
+
aiResolveEnabled: boolean;
|
|
41
|
+
/** The task's quality-gate outcome, or null if gates did not run. */
|
|
42
|
+
gateStatus: OutcomeStatus | null;
|
|
43
|
+
/** Mail surface for reporting the outcome (injectable for tests). */
|
|
44
|
+
mail?: { send: (m: Parameters<ReturnType<typeof createMailClient>["send"]>[0]) => unknown };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type AutoMergeOutcome =
|
|
48
|
+
| { merged: true; status: MergeStatus; tier: MergeTier | null }
|
|
49
|
+
| {
|
|
50
|
+
merged: false;
|
|
51
|
+
reason: "disabled" | "capability-skipped" | "gates-not-passed" | "merge-failed";
|
|
52
|
+
conflictFiles?: string[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decide whether to auto-merge `branchName` and, if so, do it under the merge lock.
|
|
57
|
+
* Never throws on a merge conflict — it reports `merge_failed` and returns a
|
|
58
|
+
* non-merged outcome so the spawn that called it is never failed by a landing.
|
|
59
|
+
*/
|
|
60
|
+
export async function maybeAutoMerge(params: AutoMergeParams): Promise<AutoMergeOutcome> {
|
|
61
|
+
const mail = params.mail ?? createMailClient(params.root);
|
|
62
|
+
const to = params.parent ?? "coordinator";
|
|
63
|
+
|
|
64
|
+
if (params.mode === "off") return { merged: false, reason: "disabled" };
|
|
65
|
+
if (NON_MERGING_CAPABILITIES.has(params.capability)) {
|
|
66
|
+
return { merged: false, reason: "capability-skipped" };
|
|
67
|
+
}
|
|
68
|
+
// Fail closed: on-gates-pass merges ONLY on a clean pass. A null status (gates
|
|
69
|
+
// not configured / not run) or any non-success holds the merge for a human.
|
|
70
|
+
if (params.mode === "on-gates-pass" && params.gateStatus !== "success") {
|
|
71
|
+
mail.send({
|
|
72
|
+
from: params.agentName,
|
|
73
|
+
to,
|
|
74
|
+
subject: `Auto-merge held: ${params.branchName}`,
|
|
75
|
+
body: `Quality gates did not pass (status: ${params.gateStatus ?? "not run"}); not merging. Review and merge manually.`,
|
|
76
|
+
type: "status",
|
|
77
|
+
});
|
|
78
|
+
return { merged: false, reason: "gates-not-passed" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const queue = createMergeQueue(mergeDbPath(params.root));
|
|
82
|
+
try {
|
|
83
|
+
const entry = queue.enqueue({
|
|
84
|
+
branchName: params.branchName,
|
|
85
|
+
agentName: params.agentName,
|
|
86
|
+
taskId: params.taskId,
|
|
87
|
+
targetBranch: params.targetBranch,
|
|
88
|
+
});
|
|
89
|
+
const result = await withMergeLock(params.root, () =>
|
|
90
|
+
mergeBranch(params.root, params.branchName, params.targetBranch, {
|
|
91
|
+
autoResolve: params.aiResolveEnabled,
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
queue.markStatus(entry.id, result.status);
|
|
95
|
+
|
|
96
|
+
if (result.status === "merged") {
|
|
97
|
+
mail.send({
|
|
98
|
+
from: params.agentName,
|
|
99
|
+
to,
|
|
100
|
+
subject: `Auto-merged: ${params.branchName} → ${params.targetBranch}`,
|
|
101
|
+
body: `Landed via ${result.tier ?? "merge"}.`,
|
|
102
|
+
type: "merged",
|
|
103
|
+
});
|
|
104
|
+
return { merged: true, status: result.status, tier: result.tier };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
mail.send({
|
|
108
|
+
from: params.agentName,
|
|
109
|
+
to,
|
|
110
|
+
subject: `Auto-merge failed: ${params.branchName}`,
|
|
111
|
+
body: `Conflicts in: ${result.conflictFiles.join(", ") || "unknown"}. Merge manually.`,
|
|
112
|
+
type: "merge_failed",
|
|
113
|
+
});
|
|
114
|
+
return { merged: false, reason: "merge-failed", conflictFiles: result.conflictFiles };
|
|
115
|
+
} finally {
|
|
116
|
+
queue.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -22,8 +22,9 @@ export const agentStateDir = (root: string, agentName: string): string =>
|
|
|
22
22
|
join(root, AGENTPLATE_DIR, "agents", agentName);
|
|
23
23
|
export const appliedSkillsPath = (root: string, agentName: string): string =>
|
|
24
24
|
join(agentStateDir(root, agentName), "applied-skills.json");
|
|
25
|
+
export const specsDir = (root: string): string => join(root, AGENTPLATE_DIR, "specs");
|
|
25
26
|
export const specPath = (root: string, taskId: string): string =>
|
|
26
|
-
join(root,
|
|
27
|
+
join(specsDir(root), `${taskId}.md`);
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Path to a bundled base agent definition shipped with the package
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for resolveModel — focused on per-capability model tiering. Auth-mode is
|
|
3
|
+
* set to "none" so no secret is read; we assert which model id is chosen.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { DEFAULT_CONFIG } from "../config.ts";
|
|
8
|
+
import type { AgentplateConfig, Capability } from "../types.ts";
|
|
9
|
+
import { resolveModel } from "./resolve.ts";
|
|
10
|
+
|
|
11
|
+
/** A config whose active provider has a default model + optional tiering map. */
|
|
12
|
+
function configWith(
|
|
13
|
+
model: string | undefined,
|
|
14
|
+
models?: Partial<Record<Capability, string>>,
|
|
15
|
+
): AgentplateConfig {
|
|
16
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
17
|
+
cfg.activeProvider = "anthropic";
|
|
18
|
+
cfg.providers.anthropic = { type: "native", authMode: "none", model, models };
|
|
19
|
+
return cfg;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ROOT = "/tmp/agentplate-resolve-test"; // never read (authMode "none")
|
|
23
|
+
|
|
24
|
+
describe("resolveModel — tiering", () => {
|
|
25
|
+
test("uses the provider default model when no capability is given", () => {
|
|
26
|
+
const { model } = resolveModel(configWith("claude-opus-4-8"), ROOT, "alias");
|
|
27
|
+
expect(model).toBe("claude-opus-4-8");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a per-capability override wins over the provider default", () => {
|
|
31
|
+
const cfg = configWith("claude-opus-4-8", {
|
|
32
|
+
scout: "claude-haiku-4-5",
|
|
33
|
+
reviewer: "claude-haiku-4-5",
|
|
34
|
+
});
|
|
35
|
+
expect(resolveModel(cfg, ROOT, "alias", "scout").model).toBe("claude-haiku-4-5");
|
|
36
|
+
expect(resolveModel(cfg, ROOT, "alias", "reviewer").model).toBe("claude-haiku-4-5");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a capability without an override falls back to the provider default", () => {
|
|
40
|
+
const cfg = configWith("claude-opus-4-8", { scout: "claude-haiku-4-5" });
|
|
41
|
+
expect(resolveModel(cfg, ROOT, "alias", "builder").model).toBe("claude-opus-4-8");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("falls back to the manifest alias when the provider has no model", () => {
|
|
45
|
+
expect(resolveModel(configWith(undefined), ROOT, "manifest-alias", "builder").model).toBe(
|
|
46
|
+
"manifest-alias",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
});
|
package/src/runtimes/resolve.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Model resolution — bridge between the agent manifest (which names models by
|
|
3
3
|
* alias) and a concrete {@link ResolvedModel} the runtime can spawn.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* The active provider's configured `model` (set by `agentplate setup`) is the
|
|
6
|
+
* concrete model, and the provider's `authTokenEnv` secret is injected as an env
|
|
7
|
+
* var. Per-capability **model tiering** is supported: when a `capability` is given
|
|
8
|
+
* and the provider has a `models[capability]` override (e.g. a fast model for
|
|
9
|
+
* `scout`/`reviewer`), that wins; otherwise the provider `model`, otherwise the
|
|
10
|
+
* manifest alias.
|
|
10
11
|
*
|
|
11
12
|
* Auth mode matters here: only `api-key`/`env` providers inject a credential env
|
|
12
13
|
* var. `subscription` providers delegate to the runtime CLI's own login (e.g. a
|
|
@@ -15,7 +16,7 @@
|
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { getSecret } from "../secrets.ts";
|
|
18
|
-
import type { AgentplateConfig, ResolvedModel } from "../types.ts";
|
|
19
|
+
import type { AgentplateConfig, Capability, ResolvedModel } from "../types.ts";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Resolve the concrete model + provider env for a manifest model alias.
|
|
@@ -23,14 +24,17 @@ import type { AgentplateConfig, ResolvedModel } from "../types.ts";
|
|
|
23
24
|
* @param config loaded Agentplate config
|
|
24
25
|
* @param root project root (to read the gitignored secret value)
|
|
25
26
|
* @param manifestModel the model alias/id from the agent definition
|
|
27
|
+
* @param capability optional agent capability for per-capability model tiering
|
|
26
28
|
*/
|
|
27
29
|
export function resolveModel(
|
|
28
30
|
config: AgentplateConfig,
|
|
29
31
|
root: string,
|
|
30
32
|
manifestModel: string,
|
|
33
|
+
capability?: Capability,
|
|
31
34
|
): ResolvedModel {
|
|
32
35
|
const provider = config.providers[config.activeProvider];
|
|
33
|
-
const
|
|
36
|
+
const tiered = capability ? provider?.models?.[capability] : undefined;
|
|
37
|
+
const model = tiered ?? provider?.model ?? manifestModel;
|
|
34
38
|
const env: Record<string, string> = {};
|
|
35
39
|
|
|
36
40
|
// Default to "api-key" for legacy configs written before authMode existed.
|
|
@@ -318,6 +318,19 @@ describe("createSessionStore — countActive", () => {
|
|
|
318
318
|
store.updateSessionState(s.id, "completed");
|
|
319
319
|
expect(store.countActive("run-x")).toBe(0);
|
|
320
320
|
});
|
|
321
|
+
|
|
322
|
+
test("countActiveByParent counts a parent's active children only", () => {
|
|
323
|
+
store.upsertSession(makeSession({ runId: "r", parentAgent: "lead-1", state: "working" }));
|
|
324
|
+
store.upsertSession(makeSession({ runId: "r", parentAgent: "lead-1", state: "booting" }));
|
|
325
|
+
const done = makeSession({ runId: "r", parentAgent: "lead-1", state: "completed" });
|
|
326
|
+
store.upsertSession(done);
|
|
327
|
+
store.upsertSession(makeSession({ runId: "r", parentAgent: "lead-2", state: "working" }));
|
|
328
|
+
|
|
329
|
+
expect(store.countActiveByParent("lead-1", "r")).toBe(2); // excludes the completed child
|
|
330
|
+
expect(store.countActiveByParent("lead-2", "r")).toBe(1);
|
|
331
|
+
expect(store.countActiveByParent("lead-1", "other-run")).toBe(0);
|
|
332
|
+
expect(store.countActiveByParent("nobody")).toBe(0);
|
|
333
|
+
});
|
|
321
334
|
});
|
|
322
335
|
|
|
323
336
|
describe("createSessionStore — file-backed database", () => {
|
package/src/sessions/store.ts
CHANGED
|
@@ -57,6 +57,7 @@ export interface SessionStore {
|
|
|
57
57
|
setRuntimeSessionId(id: string, runtimeSessionId: string): void;
|
|
58
58
|
touch(id: string): void;
|
|
59
59
|
countActive(runId?: string): number;
|
|
60
|
+
countActiveByParent(parentAgent: string, runId?: string): number;
|
|
60
61
|
// --- Lifecycle ---
|
|
61
62
|
close(): void;
|
|
62
63
|
}
|
|
@@ -328,6 +329,24 @@ export function createSessionStore(dbPath: string): SessionStore {
|
|
|
328
329
|
return row.n;
|
|
329
330
|
}
|
|
330
331
|
|
|
332
|
+
function countActiveByParent(parentAgent: string, runId?: string): number {
|
|
333
|
+
// Active children of a given parent (for the per-lead child cap).
|
|
334
|
+
if (runId !== undefined) {
|
|
335
|
+
const row = db
|
|
336
|
+
.query(
|
|
337
|
+
`SELECT COUNT(*) AS n FROM sessions WHERE parent_agent = ? AND run_id = ? AND state IN ${ACTIVE_STATES_SQL}`,
|
|
338
|
+
)
|
|
339
|
+
.get(parentAgent, runId) as { n: number };
|
|
340
|
+
return row.n;
|
|
341
|
+
}
|
|
342
|
+
const row = db
|
|
343
|
+
.query(
|
|
344
|
+
`SELECT COUNT(*) AS n FROM sessions WHERE parent_agent = ? AND state IN ${ACTIVE_STATES_SQL}`,
|
|
345
|
+
)
|
|
346
|
+
.get(parentAgent) as { n: number };
|
|
347
|
+
return row.n;
|
|
348
|
+
}
|
|
349
|
+
|
|
331
350
|
function close(): void {
|
|
332
351
|
db.close();
|
|
333
352
|
}
|
|
@@ -345,6 +364,7 @@ export function createSessionStore(dbPath: string): SessionStore {
|
|
|
345
364
|
setRuntimeSessionId,
|
|
346
365
|
touch,
|
|
347
366
|
countActive,
|
|
367
|
+
countActiveByParent,
|
|
348
368
|
close,
|
|
349
369
|
};
|
|
350
370
|
}
|
package/src/types.ts
CHANGED
|
@@ -127,6 +127,11 @@ export interface ProviderConfig {
|
|
|
127
127
|
authTokenEnv?: string;
|
|
128
128
|
/** Default model id for this provider. */
|
|
129
129
|
model?: string;
|
|
130
|
+
/**
|
|
131
|
+
* Per-capability model overrides (tiering): e.g. a fast/cheap model for
|
|
132
|
+
* `scout`/`reviewer` and a strong model for `builder`. Falls back to `model`.
|
|
133
|
+
*/
|
|
134
|
+
models?: Partial<Record<Capability, string>>;
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
/** Orchestration limits and agent registry locations. */
|
|
@@ -150,10 +155,20 @@ export interface AgentsConfig {
|
|
|
150
155
|
idleTimeoutMinutes: number;
|
|
151
156
|
}
|
|
152
157
|
|
|
153
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* When a completed worker's branch is auto-merged into the canonical branch.
|
|
160
|
+
* - `off`: never (the operator/coordinator merges manually) — the default.
|
|
161
|
+
* - `on-gates-pass`: merge only if the task's quality gates pass.
|
|
162
|
+
* - `on-complete`: merge as soon as the agent finishes without error.
|
|
163
|
+
*/
|
|
164
|
+
export type AutoMergeMode = "off" | "on-gates-pass" | "on-complete";
|
|
165
|
+
|
|
166
|
+
/** Conflict-resolution and auto-merge behavior for merging agent branches. */
|
|
154
167
|
export interface MergeConfig {
|
|
155
168
|
/** Allow AI-assisted resolution of semantic conflicts. */
|
|
156
169
|
aiResolveEnabled: boolean;
|
|
170
|
+
/** When to auto-merge a completed worker's branch into the canonical branch. */
|
|
171
|
+
autoMerge: AutoMergeMode;
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
/** Logging behavior. */
|
package/src/version.ts
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wizard helper tests. The interactive prompts themselves need a TTY, but the
|
|
3
|
+
* pure pieces are unit-tested directly. `detectQualityGates` reads a project's
|
|
4
|
+
* package.json to suggest gate commands.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { detectQualityGates } from "./setup.ts";
|
|
12
|
+
|
|
13
|
+
let dir: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
dir = mkdtempSync(join(tmpdir(), "agentplate-wizard-"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("detectQualityGates", () => {
|
|
24
|
+
test("falls back to stack defaults when there is no package.json", () => {
|
|
25
|
+
const gates = detectQualityGates(dir);
|
|
26
|
+
expect(gates).toEqual([
|
|
27
|
+
{ name: "test", command: "bun test" },
|
|
28
|
+
{ name: "lint", command: "biome check ." },
|
|
29
|
+
{ name: "typecheck", command: "tsc --noEmit" },
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("prefers `bun run <script>` for scripts the project defines", () => {
|
|
34
|
+
writeFileSync(
|
|
35
|
+
join(dir, "package.json"),
|
|
36
|
+
JSON.stringify({ scripts: { test: "vitest", typecheck: "tsc -p ." } }),
|
|
37
|
+
"utf8",
|
|
38
|
+
);
|
|
39
|
+
const gates = detectQualityGates(dir);
|
|
40
|
+
const byName = Object.fromEntries(gates.map((g) => [g.name, g.command]));
|
|
41
|
+
expect(byName.test).toBe("bun run test"); // script present → run it
|
|
42
|
+
expect(byName.typecheck).toBe("bun run typecheck"); // script present
|
|
43
|
+
expect(byName.lint).toBe("biome check ."); // no lint script → fallback
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/wizard/setup.ts
CHANGED
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
* module owns only the interactive I/O.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
10
12
|
import * as p from "@clack/prompts";
|
|
11
13
|
import { applyProviderSelection, buildProviderConfig } from "../providers/apply.ts";
|
|
12
14
|
import { getProviderSpec, listProviders, meetsContextFloor } from "../providers/registry.ts";
|
|
13
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
AgentplateConfig,
|
|
17
|
+
AuthMode,
|
|
18
|
+
AutoMergeMode,
|
|
19
|
+
Capability,
|
|
20
|
+
QualityGate,
|
|
21
|
+
} from "../types.ts";
|
|
14
22
|
import { commandOnPath, detectDefaultRuntime } from "../utils/detect.ts";
|
|
15
23
|
|
|
16
24
|
/** Runtimes the wizard can offer. */
|
|
@@ -34,6 +42,32 @@ export interface WizardResult {
|
|
|
34
42
|
secret?: { key: string; value: string };
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Suggest quality gates for a project. Prefers the project's own package.json
|
|
47
|
+
* scripts (`bun run <script>`) when present, falling back to the Bun/Biome/TS
|
|
48
|
+
* defaults this stack uses. The user confirms/deselects in the wizard.
|
|
49
|
+
*/
|
|
50
|
+
export function detectQualityGates(root: string): QualityGate[] {
|
|
51
|
+
let scripts: Record<string, string> = {};
|
|
52
|
+
try {
|
|
53
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
|
|
54
|
+
scripts?: Record<string, string>;
|
|
55
|
+
};
|
|
56
|
+
scripts = pkg.scripts ?? {};
|
|
57
|
+
} catch {
|
|
58
|
+
// No package.json (or unreadable) — fall back to stack defaults below.
|
|
59
|
+
}
|
|
60
|
+
const gate = (name: string, fallback: string): QualityGate => ({
|
|
61
|
+
name,
|
|
62
|
+
command: scripts[name] ? `bun run ${name}` : fallback,
|
|
63
|
+
});
|
|
64
|
+
return [
|
|
65
|
+
gate("test", "bun test"),
|
|
66
|
+
gate("lint", "biome check ."),
|
|
67
|
+
gate("typecheck", "tsc --noEmit"),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
/** Abort the wizard cleanly if the user cancelled a prompt. */
|
|
38
72
|
function ensure<T>(value: T | symbol): T {
|
|
39
73
|
if (p.isCancel(value)) {
|
|
@@ -72,7 +106,7 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
|
|
|
72
106
|
await p.text({
|
|
73
107
|
message: "Base URL for the endpoint",
|
|
74
108
|
placeholder: "https://my-endpoint.example.com/v1",
|
|
75
|
-
validate: (v) => (v.trim().length === 0 ? "A base URL is required" : undefined),
|
|
109
|
+
validate: (v) => (!v || v.trim().length === 0 ? "A base URL is required" : undefined),
|
|
76
110
|
}),
|
|
77
111
|
);
|
|
78
112
|
} else {
|
|
@@ -148,7 +182,7 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
|
|
|
148
182
|
const key = ensure(
|
|
149
183
|
await p.password({
|
|
150
184
|
message: `Enter your ${spec.label} API key (${spec.authEnvVar})`,
|
|
151
|
-
validate: (v) => (v.trim().length === 0 ? "An API key is required" : undefined),
|
|
185
|
+
validate: (v) => (!v || v.trim().length === 0 ? "An API key is required" : undefined),
|
|
152
186
|
}),
|
|
153
187
|
);
|
|
154
188
|
secret = { key: spec.authEnvVar, value: key.trim() };
|
|
@@ -178,7 +212,7 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
|
|
|
178
212
|
? ensure(
|
|
179
213
|
await p.text({
|
|
180
214
|
message: "Model id",
|
|
181
|
-
validate: (v) => (v.trim().length === 0 ? "A model id is required" : undefined),
|
|
215
|
+
validate: (v) => (!v || v.trim().length === 0 ? "A model id is required" : undefined),
|
|
182
216
|
}),
|
|
183
217
|
).trim()
|
|
184
218
|
: choice;
|
|
@@ -187,11 +221,36 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
|
|
|
187
221
|
await p.text({
|
|
188
222
|
message: "Model id",
|
|
189
223
|
placeholder: "provider/model-name",
|
|
190
|
-
validate: (v) => (v.trim().length === 0 ? "A model id is required" : undefined),
|
|
224
|
+
validate: (v) => (!v || v.trim().length === 0 ? "A model id is required" : undefined),
|
|
191
225
|
}),
|
|
192
226
|
).trim();
|
|
193
227
|
}
|
|
194
228
|
|
|
229
|
+
// 4b. Model tiering — optionally use a faster/cheaper model for read-only
|
|
230
|
+
// roles (scout, reviewer), keeping the chosen model for the rest.
|
|
231
|
+
let modelsByCapability: Partial<Record<Capability, string>> | undefined;
|
|
232
|
+
if (eligible.length > 1) {
|
|
233
|
+
const wantTiering = ensure(
|
|
234
|
+
await p.confirm({
|
|
235
|
+
message: "Use a faster/cheaper model for read-only roles (scout & reviewer)?",
|
|
236
|
+
initialValue: false,
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
if (wantTiering) {
|
|
240
|
+
const fast = ensure(
|
|
241
|
+
await p.select({
|
|
242
|
+
message: "Fast model for scout & reviewer",
|
|
243
|
+
options: eligible.map((m) => ({
|
|
244
|
+
value: m.id,
|
|
245
|
+
label: m.label,
|
|
246
|
+
hint: `${Math.round(m.contextWindow / 1000)}k context`,
|
|
247
|
+
})),
|
|
248
|
+
}),
|
|
249
|
+
) as string;
|
|
250
|
+
modelsByCapability = { scout: fast, reviewer: fast };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
195
254
|
// 5. Runtime -----------------------------------------------------------
|
|
196
255
|
const detected = await detectDefaultRuntime();
|
|
197
256
|
const installed = new Set<string>();
|
|
@@ -219,7 +278,50 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
|
|
|
219
278
|
}),
|
|
220
279
|
);
|
|
221
280
|
|
|
222
|
-
// 6.
|
|
281
|
+
// 6. Orchestration & merge --------------------------------------------
|
|
282
|
+
const canonicalBranch = currentConfig.project.canonicalBranch;
|
|
283
|
+
const suggestedGates = detectQualityGates(currentConfig.project.root || ".");
|
|
284
|
+
const chosenGateNames = ensure(
|
|
285
|
+
await p.multiselect({
|
|
286
|
+
message:
|
|
287
|
+
"Quality gates to run on a worker's output (used by skill distillation and 'on gates pass' auto-merge)",
|
|
288
|
+
options: suggestedGates.map((g) => ({ value: g.name, label: g.name, hint: g.command })),
|
|
289
|
+
initialValues: suggestedGates.map((g) => g.name),
|
|
290
|
+
required: false,
|
|
291
|
+
}),
|
|
292
|
+
) as string[];
|
|
293
|
+
const qualityGates = suggestedGates.filter((g) => chosenGateNames.includes(g.name));
|
|
294
|
+
|
|
295
|
+
const autoMerge = ensure(
|
|
296
|
+
await p.select({
|
|
297
|
+
message: `Auto-merge a worker's branch into ${canonicalBranch} when it finishes?`,
|
|
298
|
+
initialValue: "off",
|
|
299
|
+
options: [
|
|
300
|
+
{ value: "off", label: "Never — merge manually", hint: "default; safest" },
|
|
301
|
+
{
|
|
302
|
+
value: "on-gates-pass",
|
|
303
|
+
label: "On quality gates pass",
|
|
304
|
+
hint: qualityGates.length
|
|
305
|
+
? "merge only when gates are green"
|
|
306
|
+
: "needs gates — will hold otherwise",
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
value: "on-complete",
|
|
310
|
+
label: "On complete",
|
|
311
|
+
hint: "merge as soon as the agent finishes (no gate check)",
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
}),
|
|
315
|
+
) as AutoMergeMode;
|
|
316
|
+
|
|
317
|
+
if (autoMerge === "on-gates-pass" && qualityGates.length === 0) {
|
|
318
|
+
p.note(
|
|
319
|
+
"No quality gates selected, so 'on gates pass' will hold every merge for manual review.\nAdd gates or pick a different mode to actually auto-merge.",
|
|
320
|
+
"Heads up",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 7. Summary -----------------------------------------------------------
|
|
223
325
|
const previewProvider = buildProviderConfig(spec, model, authMode, baseUrl);
|
|
224
326
|
const authSummary: Record<AuthMode, string> = {
|
|
225
327
|
subscription: `subscription / ${runtimeCli(spec.subscriptionRuntime ?? runtime)} login (no key stored)`,
|
|
@@ -234,6 +336,11 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
|
|
|
234
336
|
`runtime: ${runtime}`,
|
|
235
337
|
`auth: ${authSummary[authMode]}`,
|
|
236
338
|
previewProvider.baseUrl ? `base URL: ${previewProvider.baseUrl}` : undefined,
|
|
339
|
+
`gates: ${qualityGates.length ? qualityGates.map((g) => g.name).join(", ") : "none"}`,
|
|
340
|
+
`auto-merge:${autoMerge}`,
|
|
341
|
+
modelsByCapability?.scout
|
|
342
|
+
? `fast model: ${modelsByCapability.scout} (scout, reviewer)`
|
|
343
|
+
: undefined,
|
|
237
344
|
]
|
|
238
345
|
.filter(Boolean)
|
|
239
346
|
.join("\n"),
|
|
@@ -248,6 +355,12 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
|
|
|
248
355
|
baseUrl,
|
|
249
356
|
runtime,
|
|
250
357
|
});
|
|
358
|
+
config.merge = { ...config.merge, autoMerge };
|
|
359
|
+
if (qualityGates.length) config.project = { ...config.project, qualityGates };
|
|
360
|
+
if (modelsByCapability) {
|
|
361
|
+
const pc = config.providers[providerId];
|
|
362
|
+
if (pc) config.providers[providerId] = { ...pc, models: modelsByCapability };
|
|
363
|
+
}
|
|
251
364
|
|
|
252
365
|
p.outro("Ready to write configuration.");
|
|
253
366
|
return secret ? { config, secret } : { config };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg:#08090f;--bg-rail:#0b0c14;--bg-topbar:#0c0e16;--bg-card:#14161f;--bg-card-2:#1b1e2a;--bg-input:#1d2030;--border:#262a38;--border-soft:#1c1f2b;--text:#eef0f6;--text-dim:#a3a9bd;--text-faint:#6b7188;--accent:#fb4b38;--accent-2:#ff6a4d;--accent-soft:#fb4b3824;--accent-border:#fb4b3873;--grad-accent:linear-gradient(135deg, #ff6a4d 0%, #fb4b38 55%, #e0245e 100%);--grad-brand:linear-gradient(135deg, #ff7a3d, #fb4b38);--ok:#2dd4a7;--ok-soft:#2dd4a724;--grad-ok:linear-gradient(135deg, #34e7b4, #10b981);--warn:#fbbf24;--warn-soft:#fbbf2424;--grad-warn:linear-gradient(135deg, #fcd34d, #f59e0b);--err:#fb4b38;--err-soft:#fb4b3824;--info:#4f8cff;--info-soft:#4f8cff24;--grad-info:linear-gradient(135deg, #6aa8ff, #3b6cf6);--violet:#a78bfa;--violet-soft:#a78bfa24;--grad-violet:linear-gradient(135deg, #c4b5fd, #8b5cf6);--cyan:#22d3ee;--cyan-soft:#22d3ee24;--grad-cyan:linear-gradient(135deg, #67e8f9, #06b6d4);--rail-w:80px;--topbar-h:54px;--statusbar-h:30px;--radius:16px;--radius-sm:10px;--shadow-card:0 1px 0 #ffffff08 inset, 0 8px 24px #00000047;--glow-accent:0 6px 20px #fb4b3859}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;background-image:radial-gradient(900px 500px at 100% -5%,#fb4b381a,#0000 60%),radial-gradient(800px 500px at -5% 0,#7c5cf61a,#0000 55%),radial-gradient(700px 600px at 50% 110%,#22d3ee0f,#0000 60%);background-attachment:fixed;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Inter,Roboto,Helvetica,Arial,sans-serif;font-size:14px}.os{grid-template-columns:var(--rail-w) 1fr;grid-template-rows:var(--topbar-h) 1fr var(--statusbar-h);grid-template-areas:"rail topbar""rail main""rail status";height:100vh;display:grid;overflow:hidden}.rail{background:var(--bg-rail);border-right:1px solid var(--border);flex-direction:column;grid-area:rail;align-items:center;gap:4px;padding:10px 0;display:flex;overflow-y:auto}.rail::-webkit-scrollbar{width:0}.rail-logo{color:#fff;background:var(--grad-brand);width:42px;height:42px;box-shadow:var(--glow-accent);border-radius:13px;place-items:center;margin-bottom:12px;display:grid}.rail-item{width:62px;color:var(--text-faint);cursor:pointer;font:inherit;background:0 0;border:none;border-radius:14px;flex-direction:column;align-items:center;gap:5px;padding:9px 0 7px;transition:color .15s,background .15s,transform .1s;display:flex;position:relative}.rail-item:hover{color:var(--text);background:var(--bg-card)}.rail-item:active{transform:scale(.95)}.rail-item.active{color:#fff;background:var(--accent-soft)}.rail-item.active:before{content:"";background:var(--grad-accent);border-radius:0 4px 4px 0;width:4px;height:22px;position:absolute;top:50%;left:-10px;transform:translateY(-50%)}.rail-item.active .ri-icon{color:var(--accent-2);filter:drop-shadow(0 0 8px #fb4b388c)}.rail-item .ri-icon{place-items:center;line-height:1;display:grid}.rail-item .ri-label{letter-spacing:.01em;font-size:10px;font-weight:600}.topbar{background:var(--bg-topbar);border-bottom:1px solid var(--border);grid-area:topbar;align-items:center;gap:14px;padding:0 16px;display:flex}.topbar-brand{align-items:center;gap:9px;font-size:15px;font-weight:700;display:flex}.topbar-brand .brand-glyph{color:var(--accent-2);place-items:center;display:grid}.ver-pill{color:#fff;background:var(--grad-accent);letter-spacing:.04em;border-radius:7px;padding:2px 7px;font-size:10px;font-weight:800;box-shadow:0 2px 8px #fb4b3866}.topbar-spacer{flex:1}.topbar-search{background:var(--bg-input);border:1px solid var(--border);width:min(420px,38vw);color:var(--text-faint);font:inherit;text-align:left;cursor:pointer;border-radius:11px;align-items:center;gap:9px;padding:8px 12px;font-size:13px;transition:border-color .15s,box-shadow .15s;display:flex}.topbar-search span{flex:1}.topbar-search:hover{border-color:var(--accent-border);box-shadow:0 0 0 3px var(--accent-soft)}.topbar-search kbd{background:var(--bg-card-2);border:1px solid var(--border);color:var(--text-dim);border-radius:5px;margin-left:auto;padding:1px 5px;font-size:11px}.topbar-icon{width:34px;height:34px;color:var(--text-dim);cursor:pointer;background:0 0;border:none;border-radius:9px;place-items:center;font-size:16px;display:grid;position:relative}.topbar-icon:hover{background:var(--bg-card);color:var(--text)}.topbar-badge{background:var(--accent);color:#fff;text-align:center;min-width:15px;height:15px;box-shadow:0 0 0 2px var(--bg-topbar,#0d0e11);border-radius:8px;padding:0 4px;font-size:9.5px;font-weight:800;line-height:15px;position:absolute;top:1px;right:1px}.notif-wrap{display:inline-flex;position:relative}.notif-panel{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);z-index:60;width:360px;max-height:460px;position:absolute;top:calc(100% + 8px);right:0;overflow-y:auto;box-shadow:0 16px 40px #00000080}.notif-head{border-bottom:1px solid var(--border-soft);background:var(--bg-card);justify-content:space-between;align-items:center;padding:12px 14px;display:flex;position:sticky;top:0}.notif-title{font-size:13.5px;font-weight:800}.notif-count{color:var(--text-faint);font-size:12px}.notif-empty{text-align:center;color:var(--text-faint);padding:28px 16px;font-size:13px}.notif-list{margin:0;padding:0;list-style:none}.notif-item{border-bottom:1px solid var(--border-soft);align-items:flex-start;gap:10px;padding:11px 14px;display:flex}.notif-item.clickable{cursor:pointer}.notif-item.clickable:hover{background:var(--bg-card-2)}.notif-item.fresh{background:#f5402d0f}.notif-dot{border-radius:50%;flex:none;width:9px;height:9px;margin-top:4px}.notif-body{flex:1;min-width:0}.notif-row1{align-items:baseline;gap:8px;display:flex}.notif-agent{text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:700;overflow:hidden}.notif-label{color:var(--text-faint);letter-spacing:.02em;font-size:10.5px}.notif-ago{color:var(--text-faint);white-space:nowrap;margin-left:auto;font-size:11px}.notif-summary{color:var(--text-dim);word-break:break-word;margin-top:2px;font-size:12.5px}.topbar-user{border-radius:18px;align-items:center;gap:8px;padding:4px 8px 4px 4px;display:flex}.avatar{background:var(--grad-accent);color:#fff;width:30px;height:30px;box-shadow:var(--glow-accent);border-radius:50%;place-items:center;font-size:13px;font-weight:800;display:grid}.main{grid-area:main;padding:26px 30px 40px;overflow-y:auto}.statusbar{background:var(--bg-topbar);border-top:1px solid var(--border);color:var(--text-dim);grid-area:status;align-items:center;gap:20px;padding:0 16px;font-size:11.5px;display:flex}.sb-item{align-items:center;gap:6px;display:flex}.sb-key{color:var(--text-faint);letter-spacing:.02em;font-weight:600}.sb-val{color:var(--text);font-weight:600}.sb-bar{background:var(--bg-card-2);border-radius:3px;width:46px;height:5px;overflow:hidden}.sb-bar>span{border-radius:3px;height:100%;display:block}.sb-spacer{flex:1}.sb-dot{border-radius:50%;width:7px;height:7px;display:inline-block}.page-head{margin-bottom:22px}.page-title{letter-spacing:-.02em;align-items:center;gap:12px;margin:0;font-size:30px;font-weight:800;display:flex}.page-sub{color:var(--text-dim);margin:6px 0 0;font-size:14px}.head-row{justify-content:space-between;align-items:flex-start;gap:16px;display:flex}.stat-grid{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:22px;display:grid}.stat{background:var(--bg-card);border:1px solid var(--border-soft);border-radius:var(--radius);min-height:150px;box-shadow:var(--shadow-card);flex-direction:column;padding:18px 20px;transition:transform .15s,border-color .15s;display:flex;position:relative;overflow:hidden}.stat:after{content:"";background:var(--grad-accent);opacity:.7;height:2px;position:absolute;inset:0 0 auto}.stat:hover{border-color:var(--border);transform:translateY(-2px)}.stat-top{justify-content:space-between;align-items:center;display:flex}.stat-label{color:var(--text-dim);font-size:14px;font-weight:500}.stat-icon{color:#fff;background:var(--grad-accent);border-radius:11px;place-items:center;width:38px;height:38px;display:grid;box-shadow:0 4px 12px #00000040}.stat-icon.ok{background:var(--grad-ok)}.stat-icon.warn{background:var(--grad-warn)}.stat-icon.info{background:var(--grad-info)}.stat-icon.violet{background:var(--grad-violet)}.stat-icon.cyan{background:var(--grad-cyan)}.stat-value{letter-spacing:-.02em;margin-top:auto;font-size:42px;font-weight:800;line-height:1}.stat-value.sm{font-size:30px}.stat-value.accent{color:var(--accent-2)}.stat-value.ok{color:var(--ok)}.stat-sub{color:var(--text-faint);margin-top:6px;font-size:12px}.title-icon{color:#fff;background:var(--grad-accent);width:40px;height:40px;box-shadow:var(--glow-accent);border-radius:13px;flex:none;place-items:center;display:grid}.title-icon.ok{background:var(--grad-ok);box-shadow:0 6px 18px #10b98152}.title-icon.warn{background:var(--grad-warn);box-shadow:0 6px 18px #f59e0b52}.title-icon.info{background:var(--grad-info);box-shadow:0 6px 18px #3b6cf652}.title-icon.violet{background:var(--grad-violet);box-shadow:0 6px 18px #8b5cf652}.title-icon.cyan{background:var(--grad-cyan);box-shadow:0 6px 18px #06b6d452}.card{background:var(--bg-card);border:1px solid var(--border-soft);border-radius:var(--radius);box-shadow:var(--shadow-card);padding:20px}.card-head{justify-content:space-between;align-items:center;margin-bottom:16px;display:flex}.card-title{align-items:center;gap:9px;font-size:16px;font-weight:700;display:flex}.card-title svg{color:var(--accent-2)}.card-meta{color:var(--text-faint);font-size:12px}.btn{border:1px solid var(--border);background:var(--bg-card-2);color:var(--text);font:inherit;cursor:pointer;border-radius:9px;align-items:center;gap:7px;padding:8px 14px;font-size:13px;font-weight:600;display:inline-flex}.btn:hover:not(:disabled){border-color:var(--accent-border)}.btn.primary{background:var(--grad-accent);color:#fff;box-shadow:var(--glow-accent);border-color:#0000}.btn.primary:hover:not(:disabled){filter:brightness(1.08);border-color:#0000}.btn:disabled{opacity:.5;cursor:not-allowed}.seg{background:var(--bg-card-2);border:1px solid var(--border);border-radius:10px;gap:2px;padding:3px;display:inline-flex}.seg button{color:var(--text-dim);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:7px;padding:6px 14px;font-size:13px;font-weight:600}.seg button.active{background:var(--grad-accent);color:#fff;box-shadow:0 2px 10px #fb4b3859}.badge{border:1px solid #0000;border-radius:7px;align-items:center;gap:5px;padding:3px 9px;font-size:11.5px;font-weight:600;display:inline-flex}.badge .bdot{background:currentColor;border-radius:50%;width:6px;height:6px}.badge.ok{color:var(--ok);background:var(--ok-soft);border-color:#34d3994d}.badge.warn{color:var(--warn);background:var(--warn-soft);border-color:#fbbf244d}.badge.err{color:var(--err);background:var(--err-soft);border-color:var(--accent-border)}.badge.info{color:var(--info);background:var(--info-soft);border-color:#60a5fa4d}.badge.accent{color:var(--accent-2);background:var(--accent-soft);border-color:var(--accent-border)}.badge.neutral{color:var(--text-dim);background:var(--bg-card-2);border-color:var(--border)}.badge.violet{color:var(--violet);background:#a78bfa1f;border-color:#a78bfa4d}.bar{background:var(--bg-card-2);border-radius:4px;height:7px;overflow:hidden}.bar>span{background:var(--grad-accent);border-radius:4px;height:100%;display:block}.grid-wrap{overflow-x:auto}table.grid{border-collapse:collapse;width:100%;font-size:13px}table.grid th{text-align:left;color:var(--text-faint);letter-spacing:.05em;text-transform:uppercase;border-bottom:1px solid var(--border);padding:8px 12px;font-size:11px;font-weight:600}table.grid td{border-bottom:1px solid var(--border-soft);color:var(--text);padding:11px 12px}table.grid tr:last-child td{border-bottom:none}.row-click{cursor:pointer}.row-click:hover{background:var(--bg-card-2)}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.dim{color:var(--text-dim)}.faint{color:var(--text-faint)}.nowrap{white-space:nowrap}.empty{color:var(--text-faint);text-align:center;padding:24px}.row-2col{grid-template-columns:1fr 1fr;gap:16px;display:grid}@media (width<=1000px){.row-2col{grid-template-columns:1fr}}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:var(--border);border-radius:6px}::-webkit-scrollbar-track{background:0 0}.cmdk-backdrop{-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);z-index:80;background:#0405098c;justify-content:center;align-items:flex-start;padding-top:12vh;display:flex;position:fixed;inset:0}.cmdk{background:var(--bg-card);border:1px solid var(--border);border-radius:16px;flex-direction:column;width:min(560px,92vw);display:flex;overflow:hidden;box-shadow:0 24px 70px #0000008c}.cmdk-input{border:none;border-bottom:1px solid var(--border-soft);width:100%;color:var(--text);font:inherit;background:0 0;outline:none;padding:16px 18px;font-size:16px}.cmdk-input::placeholder{color:var(--text-faint)}.cmdk-list{max-height:50vh;padding:6px;overflow-y:auto}.cmdk-section{letter-spacing:.08em;text-transform:uppercase;color:var(--text-faint);padding:10px 10px 4px;font-size:10.5px;font-weight:700}.cmdk-item{width:100%;color:var(--text);font:inherit;cursor:pointer;text-align:left;background:0 0;border:none;border-radius:10px;align-items:center;gap:11px;padding:9px 11px;font-size:14px;display:flex}.cmdk-item.active{background:var(--accent-soft)}.cmdk-icon{color:var(--text-dim);flex:none;place-items:center;display:grid}.cmdk-item.active .cmdk-icon{color:var(--accent-2)}.cmdk-label{white-space:nowrap;text-overflow:ellipsis;flex:1;font-weight:600;overflow:hidden}.cmdk-kind{color:var(--text-faint);border:1px solid var(--border);border-radius:6px;padding:1px 6px;font-size:11px}.cmdk-empty{text-align:center;color:var(--text-faint);padding:26px}.cmdk-foot{border-top:1px solid var(--border-soft);color:var(--text-faint);gap:16px;padding:9px 14px;font-size:11.5px;display:flex}.cmdk-foot kbd{background:var(--bg-card-2);border:1px solid var(--border);border-radius:5px;margin-right:3px;padding:1px 5px;font-size:10.5px}
|