@os-eco/overstory-cli 0.7.4 → 0.7.6
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 +10 -8
- package/package.json +1 -1
- package/src/commands/agents.ts +21 -3
- package/src/commands/completions.ts +7 -1
- package/src/commands/coordinator.test.ts +3 -1
- package/src/commands/coordinator.ts +6 -3
- package/src/commands/costs.test.ts +45 -2
- package/src/commands/costs.ts +42 -13
- package/src/commands/doctor.ts +3 -1
- package/src/commands/init.test.ts +366 -27
- package/src/commands/init.ts +194 -2
- package/src/commands/monitor.ts +4 -3
- package/src/commands/supervisor.ts +4 -3
- package/src/doctor/providers.test.ts +373 -0
- package/src/doctor/providers.ts +250 -0
- package/src/doctor/types.ts +2 -1
- package/src/e2e/init-sling-lifecycle.test.ts +12 -7
- package/src/index.ts +11 -2
- package/src/metrics/pricing.ts +57 -2
- package/src/metrics/store.test.ts +38 -0
- package/src/metrics/store.ts +10 -0
- package/src/metrics/transcript.test.ts +84 -2
- package/src/metrics/transcript.ts +1 -1
- package/src/runtimes/claude.test.ts +40 -0
- package/src/runtimes/claude.ts +8 -1
- package/src/runtimes/copilot.test.ts +507 -0
- package/src/runtimes/copilot.ts +226 -0
- package/src/runtimes/pi.test.ts +28 -0
- package/src/runtimes/pi.ts +5 -1
- package/src/runtimes/registry.test.ts +20 -0
- package/src/runtimes/registry.ts +2 -0
- package/src/runtimes/types.ts +2 -0
package/src/commands/init.ts
CHANGED
|
@@ -10,15 +10,148 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { Database } from "bun:sqlite";
|
|
13
|
-
import { mkdir, readdir } from "node:fs/promises";
|
|
13
|
+
import { mkdir, readdir, stat } from "node:fs/promises";
|
|
14
14
|
import { basename, join } from "node:path";
|
|
15
15
|
import { DEFAULT_CONFIG } from "../config.ts";
|
|
16
16
|
import { ValidationError } from "../errors.ts";
|
|
17
|
-
import {
|
|
17
|
+
import { jsonOutput } from "../json.ts";
|
|
18
|
+
import { printHint, printSuccess, printWarning } from "../logging/color.ts";
|
|
18
19
|
import type { AgentManifest, OverstoryConfig } from "../types.ts";
|
|
19
20
|
|
|
20
21
|
const OVERSTORY_DIR = ".overstory";
|
|
21
22
|
|
|
23
|
+
// ---- Ecosystem Bootstrap ----
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Spawner abstraction for testability.
|
|
27
|
+
* Wraps Bun.spawn for running sibling CLI tools.
|
|
28
|
+
*/
|
|
29
|
+
export type Spawner = (
|
|
30
|
+
args: string[],
|
|
31
|
+
opts?: { cwd?: string },
|
|
32
|
+
) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
|
33
|
+
|
|
34
|
+
const defaultSpawner: Spawner = async (args, opts) => {
|
|
35
|
+
const proc = Bun.spawn(args, {
|
|
36
|
+
cwd: opts?.cwd,
|
|
37
|
+
stdout: "pipe",
|
|
38
|
+
stderr: "pipe",
|
|
39
|
+
});
|
|
40
|
+
const exitCode = await proc.exited;
|
|
41
|
+
const stdout = await new Response(proc.stdout).text();
|
|
42
|
+
const stderr = await new Response(proc.stderr).text();
|
|
43
|
+
return { exitCode, stdout, stderr };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
interface SiblingTool {
|
|
47
|
+
name: string;
|
|
48
|
+
cli: string;
|
|
49
|
+
dotDir: string;
|
|
50
|
+
initCmd: string[];
|
|
51
|
+
onboardCmd: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SIBLING_TOOLS: SiblingTool[] = [
|
|
55
|
+
{ name: "mulch", cli: "ml", dotDir: ".mulch", initCmd: ["init"], onboardCmd: ["onboard"] },
|
|
56
|
+
{ name: "seeds", cli: "sd", dotDir: ".seeds", initCmd: ["init"], onboardCmd: ["onboard"] },
|
|
57
|
+
{ name: "canopy", cli: "cn", dotDir: ".canopy", initCmd: ["init"], onboardCmd: ["onboard"] },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
type ToolStatus = "initialized" | "already_initialized" | "skipped";
|
|
61
|
+
type OnboardStatus = "appended" | "current";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the set of sibling tools to bootstrap.
|
|
65
|
+
*
|
|
66
|
+
* If opts.tools is set (comma-separated list of names), filter to those.
|
|
67
|
+
* Otherwise start with all three and remove any skipped via skip flags.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveToolSet(opts: InitOptions): SiblingTool[] {
|
|
70
|
+
if (opts.tools) {
|
|
71
|
+
const requested = opts.tools.split(",").map((t) => t.trim());
|
|
72
|
+
return SIBLING_TOOLS.filter((t) => requested.includes(t.name));
|
|
73
|
+
}
|
|
74
|
+
return SIBLING_TOOLS.filter((t) => {
|
|
75
|
+
if (t.name === "mulch" && opts.skipMulch) return false;
|
|
76
|
+
if (t.name === "seeds" && opts.skipSeeds) return false;
|
|
77
|
+
if (t.name === "canopy" && opts.skipCanopy) return false;
|
|
78
|
+
return true;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function isToolInstalled(cli: string, spawner: Spawner): Promise<boolean> {
|
|
83
|
+
const result = await spawner([cli, "--version"]);
|
|
84
|
+
return result.exitCode === 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function initSiblingTool(
|
|
88
|
+
tool: SiblingTool,
|
|
89
|
+
projectRoot: string,
|
|
90
|
+
spawner: Spawner,
|
|
91
|
+
): Promise<ToolStatus> {
|
|
92
|
+
const installed = await isToolInstalled(tool.cli, spawner);
|
|
93
|
+
if (!installed) {
|
|
94
|
+
printWarning(
|
|
95
|
+
`${tool.name} not installed — skipping`,
|
|
96
|
+
`install: npm i -g @os-eco/${tool.name}-cli`,
|
|
97
|
+
);
|
|
98
|
+
return "skipped";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = await spawner([tool.cli, ...tool.initCmd], { cwd: projectRoot });
|
|
102
|
+
if (result.exitCode !== 0) {
|
|
103
|
+
// Check if dot directory already exists (already initialized)
|
|
104
|
+
try {
|
|
105
|
+
await stat(join(projectRoot, tool.dotDir));
|
|
106
|
+
return "already_initialized";
|
|
107
|
+
} catch {
|
|
108
|
+
// Directory doesn't exist — real failure
|
|
109
|
+
printWarning(`${tool.name} init failed`, result.stderr.trim() || result.stdout.trim());
|
|
110
|
+
return "skipped";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
printSuccess(`Bootstrapped ${tool.name}`);
|
|
115
|
+
return "initialized";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function onboardTool(
|
|
119
|
+
tool: SiblingTool,
|
|
120
|
+
projectRoot: string,
|
|
121
|
+
spawner: Spawner,
|
|
122
|
+
): Promise<OnboardStatus> {
|
|
123
|
+
const installed = await isToolInstalled(tool.cli, spawner);
|
|
124
|
+
if (!installed) return "current";
|
|
125
|
+
|
|
126
|
+
const result = await spawner([tool.cli, ...tool.onboardCmd], { cwd: projectRoot });
|
|
127
|
+
return result.exitCode === 0 ? "appended" : "current";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Set up .gitattributes with merge=union entries for JSONL files.
|
|
132
|
+
*
|
|
133
|
+
* Only adds entries not already present. Returns true if file was modified.
|
|
134
|
+
*/
|
|
135
|
+
async function setupGitattributes(projectRoot: string): Promise<boolean> {
|
|
136
|
+
const entries = [".mulch/expertise/*.jsonl merge=union", ".seeds/issues.jsonl merge=union"];
|
|
137
|
+
|
|
138
|
+
const gitattrsPath = join(projectRoot, ".gitattributes");
|
|
139
|
+
let existing = "";
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
existing = await Bun.file(gitattrsPath).text();
|
|
143
|
+
} catch {
|
|
144
|
+
// File doesn't exist yet — will be created
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const missing = entries.filter((e) => !existing.includes(e));
|
|
148
|
+
if (missing.length === 0) return false;
|
|
149
|
+
|
|
150
|
+
const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
151
|
+
await Bun.write(gitattrsPath, `${existing}${separator}${missing.join("\n")}\n`);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
22
155
|
/**
|
|
23
156
|
* Detect the project name from git or fall back to directory name.
|
|
24
157
|
*/
|
|
@@ -511,6 +644,17 @@ export interface InitOptions {
|
|
|
511
644
|
yes?: boolean;
|
|
512
645
|
name?: string;
|
|
513
646
|
force?: boolean;
|
|
647
|
+
/** Comma-separated list of ecosystem tools to bootstrap (e.g. "mulch,seeds"). Default: all. */
|
|
648
|
+
tools?: string;
|
|
649
|
+
skipMulch?: boolean;
|
|
650
|
+
skipSeeds?: boolean;
|
|
651
|
+
skipCanopy?: boolean;
|
|
652
|
+
/** Skip the onboard step (injecting CLAUDE.md sections for ecosystem tools). */
|
|
653
|
+
skipOnboard?: boolean;
|
|
654
|
+
/** Output final result as JSON envelope. */
|
|
655
|
+
json?: boolean;
|
|
656
|
+
/** Injectable spawner for testability. */
|
|
657
|
+
_spawner?: Spawner;
|
|
514
658
|
}
|
|
515
659
|
|
|
516
660
|
/**
|
|
@@ -531,6 +675,7 @@ export async function initCommand(opts: InitOptions): Promise<void> {
|
|
|
531
675
|
const force = opts.force ?? false;
|
|
532
676
|
const yes = opts.yes ?? false;
|
|
533
677
|
const projectRoot = process.cwd();
|
|
678
|
+
const spawner = opts._spawner ?? defaultSpawner;
|
|
534
679
|
const overstoryPath = join(projectRoot, OVERSTORY_DIR);
|
|
535
680
|
|
|
536
681
|
// 0. Verify we're inside a git repository
|
|
@@ -633,6 +778,53 @@ export async function initCommand(opts: InitOptions): Promise<void> {
|
|
|
633
778
|
}
|
|
634
779
|
}
|
|
635
780
|
|
|
781
|
+
// 9. Bootstrap sibling ecosystem tools
|
|
782
|
+
const toolSet = resolveToolSet(opts);
|
|
783
|
+
const toolResults: Record<string, { status: ToolStatus; path: string }> = {
|
|
784
|
+
overstory: { status: "initialized", path: overstoryPath },
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
if (toolSet.length > 0) {
|
|
788
|
+
process.stdout.write("\n");
|
|
789
|
+
process.stdout.write("Bootstrapping ecosystem tools...\n\n");
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
for (const tool of toolSet) {
|
|
793
|
+
const status = await initSiblingTool(tool, projectRoot, spawner);
|
|
794
|
+
toolResults[tool.name] = {
|
|
795
|
+
status,
|
|
796
|
+
path: join(projectRoot, tool.dotDir),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// 10. Set up .gitattributes with merge=union for JSONL files
|
|
801
|
+
const gitattrsUpdated = await setupGitattributes(projectRoot);
|
|
802
|
+
if (gitattrsUpdated) {
|
|
803
|
+
printCreated(".gitattributes");
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// 11. Run onboard for each tool (inject CLAUDE.md sections)
|
|
807
|
+
const onboardResults: Record<string, OnboardStatus> = {};
|
|
808
|
+
if (!opts.skipOnboard) {
|
|
809
|
+
for (const tool of toolSet) {
|
|
810
|
+
if (toolResults[tool.name]?.status !== "skipped") {
|
|
811
|
+
const status = await onboardTool(tool, projectRoot, spawner);
|
|
812
|
+
onboardResults[tool.name] = status;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// 12. Output final result
|
|
818
|
+
if (opts.json) {
|
|
819
|
+
jsonOutput("init", {
|
|
820
|
+
project: projectName,
|
|
821
|
+
tools: toolResults,
|
|
822
|
+
onboard: onboardResults,
|
|
823
|
+
gitattributes: gitattrsUpdated,
|
|
824
|
+
});
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
636
828
|
printSuccess("Initialized");
|
|
637
829
|
printHint("Next: run `ov hooks install` to enable Claude Code hooks.");
|
|
638
830
|
printHint("Then: run `ov status` to see the current state.");
|
package/src/commands/monitor.ts
CHANGED
|
@@ -142,17 +142,18 @@ async function startMonitor(opts: { json: boolean; attach: boolean }): Promise<v
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
// Spawn tmux session at project root with Claude Code (interactive mode).
|
|
145
|
+
// Pass file path (not content) to avoid tmux "command too long" (overstory#45).
|
|
145
146
|
const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "monitor.md");
|
|
146
147
|
const agentDefFile = Bun.file(agentDefPath);
|
|
147
|
-
let
|
|
148
|
+
let appendSystemPromptFile: string | undefined;
|
|
148
149
|
if (await agentDefFile.exists()) {
|
|
149
|
-
|
|
150
|
+
appendSystemPromptFile = agentDefPath;
|
|
150
151
|
}
|
|
151
152
|
const spawnCmd = runtime.buildSpawnCommand({
|
|
152
153
|
model: resolvedModel.model,
|
|
153
154
|
permissionMode: "bypass",
|
|
154
155
|
cwd: projectRoot,
|
|
155
|
-
|
|
156
|
+
appendSystemPromptFile,
|
|
156
157
|
env: {
|
|
157
158
|
...runtime.buildEnv(resolvedModel),
|
|
158
159
|
OVERSTORY_AGENT_NAME: MONITOR_NAME,
|
|
@@ -169,18 +169,19 @@ async function startSupervisor(opts: {
|
|
|
169
169
|
|
|
170
170
|
// Spawn tmux session at project root with Claude Code (interactive mode).
|
|
171
171
|
// Inject the supervisor base definition via --append-system-prompt.
|
|
172
|
+
// Pass file path (not content) to avoid tmux "command too long" (overstory#45).
|
|
172
173
|
const tmuxSession = `overstory-${config.project.name}-supervisor-${opts.name}`;
|
|
173
174
|
const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "supervisor.md");
|
|
174
175
|
const agentDefFile = Bun.file(agentDefPath);
|
|
175
|
-
let
|
|
176
|
+
let appendSystemPromptFile: string | undefined;
|
|
176
177
|
if (await agentDefFile.exists()) {
|
|
177
|
-
|
|
178
|
+
appendSystemPromptFile = agentDefPath;
|
|
178
179
|
}
|
|
179
180
|
const spawnCmd = runtime.buildSpawnCommand({
|
|
180
181
|
model: resolvedModel.model,
|
|
181
182
|
permissionMode: "bypass",
|
|
182
183
|
cwd: projectRoot,
|
|
183
|
-
|
|
184
|
+
appendSystemPromptFile,
|
|
184
185
|
env: {
|
|
185
186
|
...runtime.buildEnv(resolvedModel),
|
|
186
187
|
OVERSTORY_AGENT_NAME: opts.name,
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { OverstoryConfig } from "../types.ts";
|
|
5
|
+
import { checkProviders } from "./providers.ts";
|
|
6
|
+
|
|
7
|
+
/** Build a minimal valid OverstoryConfig for testing. */
|
|
8
|
+
function makeConfig(overrides: Partial<OverstoryConfig> = {}): OverstoryConfig {
|
|
9
|
+
const tmp = tmpdir();
|
|
10
|
+
return {
|
|
11
|
+
project: {
|
|
12
|
+
name: "test-project",
|
|
13
|
+
root: tmp,
|
|
14
|
+
canonicalBranch: "main",
|
|
15
|
+
},
|
|
16
|
+
agents: {
|
|
17
|
+
manifestPath: join(tmp, ".overstory", "agent-manifest.json"),
|
|
18
|
+
baseDir: join(tmp, ".overstory", "agents"),
|
|
19
|
+
maxConcurrent: 5,
|
|
20
|
+
staggerDelayMs: 1000,
|
|
21
|
+
maxDepth: 2,
|
|
22
|
+
maxSessionsPerRun: 0,
|
|
23
|
+
maxAgentsPerLead: 5,
|
|
24
|
+
},
|
|
25
|
+
worktrees: {
|
|
26
|
+
baseDir: join(tmp, ".overstory", "worktrees"),
|
|
27
|
+
},
|
|
28
|
+
taskTracker: {
|
|
29
|
+
backend: "auto",
|
|
30
|
+
enabled: false,
|
|
31
|
+
},
|
|
32
|
+
mulch: {
|
|
33
|
+
enabled: false,
|
|
34
|
+
domains: [],
|
|
35
|
+
primeFormat: "markdown",
|
|
36
|
+
},
|
|
37
|
+
merge: {
|
|
38
|
+
aiResolveEnabled: false,
|
|
39
|
+
reimagineEnabled: false,
|
|
40
|
+
},
|
|
41
|
+
providers: {
|
|
42
|
+
anthropic: { type: "native" },
|
|
43
|
+
},
|
|
44
|
+
watchdog: {
|
|
45
|
+
tier0Enabled: false,
|
|
46
|
+
tier0IntervalMs: 30000,
|
|
47
|
+
tier1Enabled: false,
|
|
48
|
+
tier2Enabled: false,
|
|
49
|
+
staleThresholdMs: 300000,
|
|
50
|
+
zombieThresholdMs: 600000,
|
|
51
|
+
nudgeIntervalMs: 60000,
|
|
52
|
+
},
|
|
53
|
+
models: {},
|
|
54
|
+
logging: {
|
|
55
|
+
verbose: false,
|
|
56
|
+
redactSecrets: true,
|
|
57
|
+
},
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Dummy overstoryDir — provider checks don't use the filesystem
|
|
63
|
+
const OVERSTORY_DIR = join(tmpdir(), ".overstory");
|
|
64
|
+
|
|
65
|
+
describe("checkProviders", () => {
|
|
66
|
+
test("all checks have required DoctorCheck fields", async () => {
|
|
67
|
+
const config = makeConfig();
|
|
68
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
69
|
+
|
|
70
|
+
expect(checks).toBeArray();
|
|
71
|
+
for (const check of checks) {
|
|
72
|
+
expect(typeof check.name).toBe("string");
|
|
73
|
+
expect(check.category).toBe("providers");
|
|
74
|
+
expect(["pass", "warn", "fail"]).toContain(check.status);
|
|
75
|
+
expect(typeof check.message).toBe("string");
|
|
76
|
+
if (check.details !== undefined) {
|
|
77
|
+
expect(check.details).toBeArray();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("providers-configured check", () => {
|
|
83
|
+
test("native-only config (no gateway) returns pass for providers-configured", async () => {
|
|
84
|
+
const config = makeConfig({
|
|
85
|
+
providers: { anthropic: { type: "native" } },
|
|
86
|
+
});
|
|
87
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
88
|
+
|
|
89
|
+
const check = checks.find((c) => c.name === "providers-configured");
|
|
90
|
+
expect(check).toBeDefined();
|
|
91
|
+
expect(check?.status).toBe("pass");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("empty providers returns warn for providers-configured", async () => {
|
|
95
|
+
const config = makeConfig({ providers: {} });
|
|
96
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
97
|
+
|
|
98
|
+
const check = checks.find((c) => c.name === "providers-configured");
|
|
99
|
+
expect(check).toBeDefined();
|
|
100
|
+
expect(check?.status).toBe("warn");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("providers-configured details list provider names and types", async () => {
|
|
104
|
+
const config = makeConfig({
|
|
105
|
+
providers: {
|
|
106
|
+
anthropic: { type: "native" },
|
|
107
|
+
openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
111
|
+
|
|
112
|
+
const check = checks.find((c) => c.name === "providers-configured");
|
|
113
|
+
expect(check?.status).toBe("pass");
|
|
114
|
+
expect(check?.details).toBeDefined();
|
|
115
|
+
expect(check?.details?.some((d) => d.includes("anthropic"))).toBe(true);
|
|
116
|
+
expect(check?.details?.some((d) => d.includes("openrouter"))).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("provider-reachable-{name} check", () => {
|
|
121
|
+
test("gateway config triggers reachability check (warn path — no real server)", async () => {
|
|
122
|
+
const config = makeConfig({
|
|
123
|
+
providers: {
|
|
124
|
+
fake: {
|
|
125
|
+
type: "gateway",
|
|
126
|
+
// Use a port that is almost certainly not listening
|
|
127
|
+
baseUrl: "http://127.0.0.1:19873",
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
132
|
+
|
|
133
|
+
const check = checks.find((c) => c.name === "provider-reachable-fake");
|
|
134
|
+
expect(check).toBeDefined();
|
|
135
|
+
expect(check?.status).toBe("warn");
|
|
136
|
+
expect(check?.message).toContain("fake");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("reachability pass path — local HTTP server", async () => {
|
|
140
|
+
// Start a minimal Bun HTTP server on an ephemeral port
|
|
141
|
+
const server = Bun.serve({
|
|
142
|
+
port: 0, // OS assigns a free port
|
|
143
|
+
fetch() {
|
|
144
|
+
return new Response("ok");
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const config = makeConfig({
|
|
150
|
+
providers: {
|
|
151
|
+
localtest: {
|
|
152
|
+
type: "gateway",
|
|
153
|
+
baseUrl: `http://127.0.0.1:${server.port}`,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
158
|
+
|
|
159
|
+
const check = checks.find((c) => c.name === "provider-reachable-localtest");
|
|
160
|
+
expect(check).toBeDefined();
|
|
161
|
+
expect(check?.status).toBe("pass");
|
|
162
|
+
} finally {
|
|
163
|
+
await server.stop();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("gateway without baseUrl skips reachability check", async () => {
|
|
168
|
+
const config = makeConfig({
|
|
169
|
+
providers: {
|
|
170
|
+
nourl: { type: "gateway" }, // no baseUrl
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
174
|
+
|
|
175
|
+
const reachCheck = checks.find((c) => c.name === "provider-reachable-nourl");
|
|
176
|
+
expect(reachCheck).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("provider-auth-token-{name} check", () => {
|
|
181
|
+
const ENV_KEY = "OVERSTORY_TEST_FAKE_PROVIDER_TOKEN_XYZ";
|
|
182
|
+
|
|
183
|
+
beforeAll(() => {
|
|
184
|
+
// Ensure env var is unset before tests
|
|
185
|
+
delete process.env[ENV_KEY];
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
afterAll(() => {
|
|
189
|
+
delete process.env[ENV_KEY];
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("gateway with authTokenEnv warns when env var missing", async () => {
|
|
193
|
+
const config = makeConfig({
|
|
194
|
+
providers: {
|
|
195
|
+
testgateway: {
|
|
196
|
+
type: "gateway",
|
|
197
|
+
authTokenEnv: ENV_KEY,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
202
|
+
|
|
203
|
+
const check = checks.find((c) => c.name === "provider-auth-token-testgateway");
|
|
204
|
+
expect(check).toBeDefined();
|
|
205
|
+
expect(check?.status).toBe("warn");
|
|
206
|
+
// Details must include the env var NAME, never a value
|
|
207
|
+
expect(check?.details?.some((d) => d.includes(ENV_KEY))).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("gateway with authTokenEnv passes when env var is set", async () => {
|
|
211
|
+
process.env[ENV_KEY] = "test-token-value";
|
|
212
|
+
|
|
213
|
+
const config = makeConfig({
|
|
214
|
+
providers: {
|
|
215
|
+
testgateway: {
|
|
216
|
+
type: "gateway",
|
|
217
|
+
authTokenEnv: ENV_KEY,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
222
|
+
|
|
223
|
+
const check = checks.find((c) => c.name === "provider-auth-token-testgateway");
|
|
224
|
+
expect(check).toBeDefined();
|
|
225
|
+
expect(check?.status).toBe("pass");
|
|
226
|
+
// Details include the var name, not the value
|
|
227
|
+
expect(check?.details?.some((d) => d.includes(ENV_KEY))).toBe(true);
|
|
228
|
+
// Value must NOT appear in details
|
|
229
|
+
expect(check?.details?.some((d) => d.includes("test-token-value"))).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("native provider with no authTokenEnv skips auth-token check", async () => {
|
|
233
|
+
const config = makeConfig({
|
|
234
|
+
providers: { anthropic: { type: "native" } },
|
|
235
|
+
});
|
|
236
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
237
|
+
|
|
238
|
+
const authCheck = checks.find((c) => c.name?.startsWith("provider-auth-token-"));
|
|
239
|
+
expect(authCheck).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("tool-use-compat check", () => {
|
|
244
|
+
test("tool-heavy role with provider-prefixed model warns", async () => {
|
|
245
|
+
const config = makeConfig({
|
|
246
|
+
models: { builder: "openrouter/openai/gpt-4o" },
|
|
247
|
+
providers: {
|
|
248
|
+
openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
252
|
+
|
|
253
|
+
const check = checks.find((c) => c.name === "tool-use-compat" && c.status === "warn");
|
|
254
|
+
expect(check).toBeDefined();
|
|
255
|
+
expect(check?.message).toContain("builder");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("non-tool-heavy role with provider-prefixed model does not warn", async () => {
|
|
259
|
+
// "lead" is not a tool-heavy role
|
|
260
|
+
const config = makeConfig({
|
|
261
|
+
models: { lead: "openrouter/openai/gpt-4o" },
|
|
262
|
+
providers: {
|
|
263
|
+
openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
267
|
+
|
|
268
|
+
const warnChecks = checks.filter((c) => c.name === "tool-use-compat" && c.status === "warn");
|
|
269
|
+
expect(warnChecks.length).toBe(0);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("no tool-heavy roles with prefixed models emits single pass", async () => {
|
|
273
|
+
const config = makeConfig({
|
|
274
|
+
models: { builder: "sonnet" }, // alias, not provider-prefixed
|
|
275
|
+
});
|
|
276
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
277
|
+
|
|
278
|
+
const passCheck = checks.find((c) => c.name === "tool-use-compat" && c.status === "pass");
|
|
279
|
+
expect(passCheck).toBeDefined();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("all three tool-heavy roles can trigger separate warns", async () => {
|
|
283
|
+
const config = makeConfig({
|
|
284
|
+
models: {
|
|
285
|
+
builder: "openrouter/openai/gpt-4o",
|
|
286
|
+
scout: "openrouter/openai/gpt-4o",
|
|
287
|
+
merger: "openrouter/openai/gpt-4o",
|
|
288
|
+
},
|
|
289
|
+
providers: {
|
|
290
|
+
openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
294
|
+
|
|
295
|
+
const warnChecks = checks.filter((c) => c.name === "tool-use-compat" && c.status === "warn");
|
|
296
|
+
expect(warnChecks.length).toBe(3);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("model-provider-ref(s) check", () => {
|
|
301
|
+
test("model referencing unknown provider fails", async () => {
|
|
302
|
+
const config = makeConfig({
|
|
303
|
+
models: { builder: "unknownprovider/some-model" },
|
|
304
|
+
// unknownprovider not in providers
|
|
305
|
+
providers: { anthropic: { type: "native" } },
|
|
306
|
+
});
|
|
307
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
308
|
+
|
|
309
|
+
const check = checks.find((c) => c.name === "model-provider-ref" && c.status === "fail");
|
|
310
|
+
expect(check).toBeDefined();
|
|
311
|
+
expect(check?.message).toContain("unknownprovider");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("model referencing defined provider passes", async () => {
|
|
315
|
+
const config = makeConfig({
|
|
316
|
+
models: { builder: "openrouter/openai/gpt-4o" },
|
|
317
|
+
providers: {
|
|
318
|
+
openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
322
|
+
|
|
323
|
+
const check = checks.find((c) => c.name === "model-provider-ref" && c.status === "pass");
|
|
324
|
+
expect(check).toBeDefined();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("no provider-prefixed models emits single pass named model-provider-refs", async () => {
|
|
328
|
+
const config = makeConfig({
|
|
329
|
+
models: { builder: "sonnet", scout: "haiku" },
|
|
330
|
+
});
|
|
331
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
332
|
+
|
|
333
|
+
const check = checks.find((c) => c.name === "model-provider-refs");
|
|
334
|
+
expect(check).toBeDefined();
|
|
335
|
+
expect(check?.status).toBe("pass");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("empty models emits single pass named model-provider-refs", async () => {
|
|
339
|
+
const config = makeConfig({ models: {} });
|
|
340
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
341
|
+
|
|
342
|
+
const check = checks.find((c) => c.name === "model-provider-refs");
|
|
343
|
+
expect(check).toBeDefined();
|
|
344
|
+
expect(check?.status).toBe("pass");
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("gateway-api-key-reminder check", () => {
|
|
349
|
+
test("gateway present triggers api-key reminder warn", async () => {
|
|
350
|
+
const config = makeConfig({
|
|
351
|
+
providers: {
|
|
352
|
+
openrouter: { type: "gateway", baseUrl: "http://127.0.0.1:19873" },
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
356
|
+
|
|
357
|
+
const check = checks.find((c) => c.name === "gateway-api-key-reminder");
|
|
358
|
+
expect(check).toBeDefined();
|
|
359
|
+
expect(check?.status).toBe("warn");
|
|
360
|
+
expect(check?.message).toContain("ANTHROPIC_API_KEY");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("no gateway providers — reminder is absent", async () => {
|
|
364
|
+
const config = makeConfig({
|
|
365
|
+
providers: { anthropic: { type: "native" } },
|
|
366
|
+
});
|
|
367
|
+
const checks = await checkProviders(config, OVERSTORY_DIR);
|
|
368
|
+
|
|
369
|
+
const check = checks.find((c) => c.name === "gateway-api-key-reminder");
|
|
370
|
+
expect(check).toBeUndefined();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
});
|