@cozibud/harness 0.1.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 +24 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +129 -0
- package/dist/contracts.d.ts +79 -0
- package/dist/contracts.js +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/process-driver.d.ts +39 -0
- package/dist/process-driver.js +316 -0
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.js +111 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +140 -0
- package/dist/setup.d.ts +32 -0
- package/dist/setup.js +286 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# `@cozibud/harness`
|
|
2
|
+
|
|
3
|
+
Internal infrastructure for the Cozibud Watch local-client bridge. It invokes a
|
|
4
|
+
supported local AI client and exposes the provider-neutral runtime used by the
|
|
5
|
+
bridge engineering tools.
|
|
6
|
+
|
|
7
|
+
This package is not a released Watch feature. The authenticated server relay,
|
|
8
|
+
device pairing, and production delivery path are not included, and the local
|
|
9
|
+
HTTP adapter must not be exposed to a LAN or the internet.
|
|
10
|
+
|
|
11
|
+
## Maintainer smoke check
|
|
12
|
+
|
|
13
|
+
After building or installing an explicitly reviewed version:
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
cozibud-harness --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Provider setup and request commands are internal acceptance tools. They use the
|
|
20
|
+
provider's own installed client and sign-in state; the harness does not accept
|
|
21
|
+
or store provider passwords.
|
|
22
|
+
|
|
23
|
+
Release and bootstrap procedures live in the private repository's
|
|
24
|
+
`docs/NPM-HARNESS-PUBLISHING.md` operator runbook.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { createBuiltInDrivers } from "./process-driver.js";
|
|
4
|
+
import { HarnessRuntime } from "./runtime.js";
|
|
5
|
+
import { createHarnessHttpServer } from "./server.js";
|
|
6
|
+
import { DEFAULT_COZIBUD_MCP_URL, setupProvider } from "./setup.js";
|
|
7
|
+
function value(args, name, fallback) {
|
|
8
|
+
const index = args.indexOf(name);
|
|
9
|
+
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
|
|
10
|
+
}
|
|
11
|
+
function promptFrom(args) {
|
|
12
|
+
const parts = [];
|
|
13
|
+
for (let index = 1; index < args.length; index += 1) {
|
|
14
|
+
if (["--provider", "--locale"].includes(args[index])) {
|
|
15
|
+
index += 1;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
parts.push(args[index]);
|
|
19
|
+
}
|
|
20
|
+
return parts.join(" ").trim();
|
|
21
|
+
}
|
|
22
|
+
function printHelp() {
|
|
23
|
+
console.log(`Cozibud local LLM harness
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
cozibud-harness setup [--provider codex|claude|gemini|cursor] [--mcp-url https://cozibud.com/api/mcp]
|
|
27
|
+
cozibud-harness ask [--provider codex|claude|gemini|cursor] [--locale en-US] "When was the last bottle?"
|
|
28
|
+
cozibud-harness serve [--host 127.0.0.1] [--port 8798]
|
|
29
|
+
|
|
30
|
+
Quick start:
|
|
31
|
+
setup prepares a signed-in local client and the MCP access its bridge turns need
|
|
32
|
+
ask sends one typed request through an already signed-in provider
|
|
33
|
+
|
|
34
|
+
Environment:
|
|
35
|
+
COZIBUD_HARNESS_TOKEN bearer token (generated for this run when omitted)
|
|
36
|
+
COZIBUD_HARNESS_ORIGINS comma-separated browser origins allowed by CORS
|
|
37
|
+
COZIBUD_CODEX_PATH optional Codex executable path
|
|
38
|
+
COZIBUD_CLAUDE_PATH optional Claude executable path
|
|
39
|
+
COZIBUD_GEMINI_PATH optional Gemini executable path
|
|
40
|
+
COZIBUD_CURSOR_PATH optional Cursor Agent executable path
|
|
41
|
+
COZIBUD_CURSOR_MCP_CONFIG optional Cursor user MCP config path (test/development override)`);
|
|
42
|
+
}
|
|
43
|
+
async function ask(args) {
|
|
44
|
+
const provider = value(args, "--provider", "codex");
|
|
45
|
+
const locale = value(args, "--locale", "en-US");
|
|
46
|
+
const prompt = promptFrom(args);
|
|
47
|
+
if (!prompt)
|
|
48
|
+
throw new Error('Add a request, for example: cozibud-harness ask "When was the last bottle?"');
|
|
49
|
+
if (prompt.length > 8_000)
|
|
50
|
+
throw new Error("Requests are limited to 8,000 characters.");
|
|
51
|
+
const driver = createBuiltInDrivers().find((candidate) => candidate.id === provider);
|
|
52
|
+
if (!driver)
|
|
53
|
+
throw new Error(`Unknown provider: ${provider}. Choose codex, claude, gemini, or cursor.`);
|
|
54
|
+
const availability = await driver.probe();
|
|
55
|
+
if (availability.state === "unavailable")
|
|
56
|
+
throw new Error(availability.message);
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
const cancel = () => controller.abort();
|
|
59
|
+
process.once("SIGINT", cancel);
|
|
60
|
+
let streamed = false;
|
|
61
|
+
try {
|
|
62
|
+
const result = await driver.run({ prompt, source: "typed", client: "web", locale }, {
|
|
63
|
+
turnId: "cli",
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
emit: (event) => {
|
|
66
|
+
if (event.type !== "assistant.delta")
|
|
67
|
+
return;
|
|
68
|
+
streamed = true;
|
|
69
|
+
process.stdout.write(event.text);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
if (streamed)
|
|
73
|
+
process.stdout.write("\n");
|
|
74
|
+
else
|
|
75
|
+
console.log(result.text);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
process.removeListener("SIGINT", cancel);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function serve(args) {
|
|
82
|
+
const host = value(args, "--host", "127.0.0.1");
|
|
83
|
+
const port = Number(value(args, "--port", "8798"));
|
|
84
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
85
|
+
throw new Error("--port must be an integer from 1 to 65535.");
|
|
86
|
+
if (!["127.0.0.1", "::1", "localhost"].includes(host) && !args.includes("--allow-insecure-lan")) {
|
|
87
|
+
throw new Error("Refusing a non-loopback bind without --allow-insecure-lan. Use a TLS private-network proxy for a phone or watch.");
|
|
88
|
+
}
|
|
89
|
+
const configuredToken = process.env.COZIBUD_HARNESS_TOKEN?.trim();
|
|
90
|
+
const token = configuredToken || randomBytes(32).toString("base64url");
|
|
91
|
+
const origins = (process.env.COZIBUD_HARNESS_ORIGINS ?? "").split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
92
|
+
const service = createHarnessHttpServer({ runtime: new HarnessRuntime(createBuiltInDrivers()), token, host, port, allowedOrigins: origins });
|
|
93
|
+
const address = await service.listen();
|
|
94
|
+
console.log(`Cozibud harness listening on ${address.url}`);
|
|
95
|
+
if (!configuredToken)
|
|
96
|
+
console.log(`One-time bearer token: ${token}`);
|
|
97
|
+
console.log("Prompts and transcripts stay on this bridge and the selected provider; Cozibud never receives provider credentials.");
|
|
98
|
+
let stopping = false;
|
|
99
|
+
const stop = async () => {
|
|
100
|
+
if (stopping)
|
|
101
|
+
return;
|
|
102
|
+
stopping = true;
|
|
103
|
+
await service.close();
|
|
104
|
+
};
|
|
105
|
+
process.once("SIGINT", () => void stop());
|
|
106
|
+
process.once("SIGTERM", () => void stop());
|
|
107
|
+
}
|
|
108
|
+
const args = process.argv.slice(2);
|
|
109
|
+
const command = args[0] ?? "serve";
|
|
110
|
+
try {
|
|
111
|
+
if (args.includes("--help") || args.includes("-h"))
|
|
112
|
+
printHelp();
|
|
113
|
+
else if (command === "setup") {
|
|
114
|
+
const provider = value(args, "--provider", "codex");
|
|
115
|
+
if (!["codex", "claude", "gemini", "cursor"].includes(provider))
|
|
116
|
+
throw new Error(`Unknown setup provider: ${provider}. Choose codex, claude, gemini, or cursor.`);
|
|
117
|
+
await setupProvider({ provider: provider, mcpUrl: value(args, "--mcp-url", DEFAULT_COZIBUD_MCP_URL) });
|
|
118
|
+
}
|
|
119
|
+
else if (command === "ask")
|
|
120
|
+
await ask(args);
|
|
121
|
+
else if (command === "serve")
|
|
122
|
+
await serve(args);
|
|
123
|
+
else
|
|
124
|
+
throw new Error(`Unknown command: ${command}. Run cozibud-harness --help.`);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
console.error(error instanceof Error ? error.message : "The local harness failed.");
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export declare const HARNESS_IDS: readonly ["codex", "claude", "gemini", "cursor"];
|
|
2
|
+
export type BuiltInHarnessId = (typeof HARNESS_IDS)[number];
|
|
3
|
+
export type HarnessInputSource = "typed" | "voice-transcript" | "quick-action";
|
|
4
|
+
export type HarnessClient = "web" | "phone" | "watch";
|
|
5
|
+
export interface HarnessTurnInput {
|
|
6
|
+
prompt: string;
|
|
7
|
+
source: HarnessInputSource;
|
|
8
|
+
client: HarnessClient;
|
|
9
|
+
locale?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface HarnessTurnRequest extends HarnessTurnInput {
|
|
12
|
+
harness: string;
|
|
13
|
+
}
|
|
14
|
+
export type HarnessAvailability = {
|
|
15
|
+
state: "available";
|
|
16
|
+
version?: string;
|
|
17
|
+
} | {
|
|
18
|
+
state: "unavailable";
|
|
19
|
+
reason: "missing-cli" | "probe-failed";
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
export interface HarnessDescriptor {
|
|
23
|
+
id: string;
|
|
24
|
+
label: string;
|
|
25
|
+
command: string;
|
|
26
|
+
authentication: "existing-login" | "api-key";
|
|
27
|
+
availability: HarnessAvailability;
|
|
28
|
+
}
|
|
29
|
+
export type HarnessEvent = {
|
|
30
|
+
sequence: number;
|
|
31
|
+
turnId: string;
|
|
32
|
+
harness: string;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
} & ({
|
|
35
|
+
type: "turn.started";
|
|
36
|
+
source: HarnessInputSource;
|
|
37
|
+
client: HarnessClient;
|
|
38
|
+
} | {
|
|
39
|
+
type: "assistant.delta";
|
|
40
|
+
text: string;
|
|
41
|
+
} | {
|
|
42
|
+
type: "assistant.completed";
|
|
43
|
+
text: string;
|
|
44
|
+
} | {
|
|
45
|
+
type: "turn.completed";
|
|
46
|
+
} | {
|
|
47
|
+
type: "turn.failed";
|
|
48
|
+
code: "unavailable" | "cancelled" | "timeout" | "upstream-error";
|
|
49
|
+
message: string;
|
|
50
|
+
});
|
|
51
|
+
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
|
52
|
+
export type HarnessEventPayload = DistributiveOmit<HarnessEvent, "sequence" | "turnId" | "harness" | "createdAt">;
|
|
53
|
+
export interface HarnessRunContext {
|
|
54
|
+
turnId: string;
|
|
55
|
+
signal: AbortSignal;
|
|
56
|
+
emit: (event: HarnessEventPayload) => void;
|
|
57
|
+
}
|
|
58
|
+
export interface HarnessDriver {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
readonly label: string;
|
|
61
|
+
readonly command: string;
|
|
62
|
+
readonly authentication: "existing-login" | "api-key";
|
|
63
|
+
probe(): Promise<HarnessAvailability>;
|
|
64
|
+
run(input: HarnessTurnInput, context: HarnessRunContext): Promise<{
|
|
65
|
+
text: string;
|
|
66
|
+
}>;
|
|
67
|
+
}
|
|
68
|
+
export interface TurnSnapshot {
|
|
69
|
+
id: string;
|
|
70
|
+
harness: string;
|
|
71
|
+
state: "running" | "completed" | "failed" | "cancelled";
|
|
72
|
+
source: HarnessInputSource;
|
|
73
|
+
client: HarnessClient;
|
|
74
|
+
createdAt: string;
|
|
75
|
+
updatedAt: string;
|
|
76
|
+
response?: string;
|
|
77
|
+
events: HarnessEvent[];
|
|
78
|
+
}
|
|
79
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const HARNESS_IDS = ["codex", "claude", "gemini", "cursor"];
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { HarnessDriver } from "./contracts.js";
|
|
2
|
+
declare const TEMP_DIRECTORY_CLEANUP_OPTIONS: {
|
|
3
|
+
readonly recursive: true;
|
|
4
|
+
readonly force: true;
|
|
5
|
+
readonly maxRetries: 5;
|
|
6
|
+
readonly retryDelay: 100;
|
|
7
|
+
};
|
|
8
|
+
type RemoveDirectory = (path: string, options: typeof TEMP_DIRECTORY_CLEANUP_OPTIONS) => Promise<void>;
|
|
9
|
+
export interface ProcessHarnessDefinition {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
command: string;
|
|
13
|
+
versionArgs?: string[];
|
|
14
|
+
/** Applied only to a fresh empty directory created by this driver. */
|
|
15
|
+
emptyDirectoryArgs?: string[];
|
|
16
|
+
args(): string[];
|
|
17
|
+
}
|
|
18
|
+
declare function messageText(value: unknown): string | null;
|
|
19
|
+
declare function appendNovelText(current: string, candidate: string): {
|
|
20
|
+
value: string;
|
|
21
|
+
delta: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function createProcessHarnessDriver(definition: ProcessHarnessDefinition, options?: {
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
env?: NodeJS.ProcessEnv;
|
|
26
|
+
cwd?: string;
|
|
27
|
+
removeDirectory?: RemoveDirectory;
|
|
28
|
+
}): HarnessDriver;
|
|
29
|
+
export declare function executable(configured: string): {
|
|
30
|
+
command: string;
|
|
31
|
+
prefixArgs: string[];
|
|
32
|
+
};
|
|
33
|
+
export declare const builtInProcessHarnesses: ProcessHarnessDefinition[];
|
|
34
|
+
export declare function createBuiltInDrivers(): HarnessDriver[];
|
|
35
|
+
export declare const processOutput: {
|
|
36
|
+
messageText: typeof messageText;
|
|
37
|
+
appendNovelText: typeof appendNovelText;
|
|
38
|
+
};
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
6
|
+
import { StringDecoder } from "node:string_decoder";
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
8
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
9
|
+
const TEMP_DIRECTORY_CLEANUP_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 };
|
|
10
|
+
function stopProcess(child) {
|
|
11
|
+
if (!child.pid || child.exitCode !== null)
|
|
12
|
+
return;
|
|
13
|
+
if (process.platform === "win32") {
|
|
14
|
+
const killer = spawn(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
|
|
15
|
+
killer.on("error", () => child.kill());
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
try {
|
|
19
|
+
process.kill(-child.pid, "SIGKILL");
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
child.kill("SIGKILL");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function carePrompt(input) {
|
|
27
|
+
const origin = `${input.client} ${input.source}`;
|
|
28
|
+
return [
|
|
29
|
+
"You are handling a Cozibud family-care request.",
|
|
30
|
+
"Use the already configured Cozibud MCP server for any household read or write.",
|
|
31
|
+
"Do not inspect or modify local files and do not run shell commands.",
|
|
32
|
+
"Ask a short follow-up only when ambiguity would make a care record incorrect.",
|
|
33
|
+
"Otherwise return a concise, spoken-friendly answer and confirm writes only after the tool succeeds.",
|
|
34
|
+
`Input origin: ${origin}. Locale: ${input.locale ?? "unspecified"}.`,
|
|
35
|
+
"<caregiver_request>",
|
|
36
|
+
input.prompt,
|
|
37
|
+
"</caregiver_request>",
|
|
38
|
+
].join("\n");
|
|
39
|
+
}
|
|
40
|
+
function messageText(value) {
|
|
41
|
+
if (!value || typeof value !== "object")
|
|
42
|
+
return null;
|
|
43
|
+
const event = value;
|
|
44
|
+
if (event.is_error === true || event.type === "error" || event.error)
|
|
45
|
+
return null;
|
|
46
|
+
if (event.type === "result" && typeof event.result === "string")
|
|
47
|
+
return event.result;
|
|
48
|
+
if (event.type === "assistant" && event.message && typeof event.message === "object") {
|
|
49
|
+
const content = event.message.content;
|
|
50
|
+
if (Array.isArray(content)) {
|
|
51
|
+
const text = content
|
|
52
|
+
.filter((block) => Boolean(block) && typeof block === "object")
|
|
53
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
54
|
+
.map((block) => block.text)
|
|
55
|
+
.join("");
|
|
56
|
+
if (text)
|
|
57
|
+
return text;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (event.type === "item.completed" && event.item && typeof event.item === "object") {
|
|
61
|
+
const item = event.item;
|
|
62
|
+
if ((item.type === "agent_message" || item.type === "assistant_message") && typeof item.text === "string")
|
|
63
|
+
return item.text;
|
|
64
|
+
}
|
|
65
|
+
if ((event.type === "assistant" || event.type === "assistant_message") && typeof event.text === "string")
|
|
66
|
+
return event.text;
|
|
67
|
+
if (event.type === "message" && event.role === "assistant" && typeof event.content === "string")
|
|
68
|
+
return event.content;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function appendNovelText(current, candidate) {
|
|
72
|
+
if (!candidate || candidate === current || current.endsWith(candidate))
|
|
73
|
+
return { value: current, delta: "" };
|
|
74
|
+
if (candidate.startsWith(current))
|
|
75
|
+
return { value: candidate, delta: candidate.slice(current.length) };
|
|
76
|
+
return { value: current + candidate, delta: candidate };
|
|
77
|
+
}
|
|
78
|
+
export function createProcessHarnessDriver(definition, options = {}) {
|
|
79
|
+
const spawnOptions = {
|
|
80
|
+
cwd: options.cwd,
|
|
81
|
+
env: options.env ?? process.env,
|
|
82
|
+
windowsHide: true,
|
|
83
|
+
detached: process.platform !== "win32",
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
id: definition.id,
|
|
87
|
+
label: definition.label,
|
|
88
|
+
command: definition.command,
|
|
89
|
+
authentication: "existing-login",
|
|
90
|
+
async probe() {
|
|
91
|
+
return await new Promise((resolve) => {
|
|
92
|
+
const child = spawn(definition.command, definition.versionArgs ?? ["--version"], { ...spawnOptions, stdio: ["ignore", "pipe", "pipe"] });
|
|
93
|
+
let output = "";
|
|
94
|
+
let bytes = 0;
|
|
95
|
+
let overflow = false;
|
|
96
|
+
const timer = setTimeout(() => stopProcess(child), 5_000);
|
|
97
|
+
const collect = (chunk, stdout) => {
|
|
98
|
+
bytes += chunk.length;
|
|
99
|
+
if (bytes > MAX_OUTPUT_BYTES) {
|
|
100
|
+
if (!overflow)
|
|
101
|
+
stopProcess(child);
|
|
102
|
+
overflow = true;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (stdout)
|
|
106
|
+
output += chunk.toString("utf8");
|
|
107
|
+
};
|
|
108
|
+
child.stdout.on("data", (chunk) => collect(chunk, true));
|
|
109
|
+
child.stderr.on("data", (chunk) => collect(chunk, false));
|
|
110
|
+
child.once("error", (error) => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
resolve(error.code === "ENOENT"
|
|
113
|
+
? { state: "unavailable", reason: "missing-cli", message: `${definition.command} is not installed or is not on PATH.` }
|
|
114
|
+
: { state: "unavailable", reason: "probe-failed", message: `${definition.label} could not be checked.` });
|
|
115
|
+
});
|
|
116
|
+
child.once("close", (code) => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
resolve(code === 0 && !overflow
|
|
119
|
+
? { state: "available", version: output.trim().split(/\r?\n/, 1)[0] || undefined }
|
|
120
|
+
: { state: "unavailable", reason: "probe-failed", message: `${definition.label} returned exit code ${code ?? "unknown"}.` });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
async run(input, context) {
|
|
125
|
+
if (context.signal.aborted)
|
|
126
|
+
throw new Error(`${definition.label} was cancelled.`);
|
|
127
|
+
const ephemeralCwd = options.cwd ? null : await mkdtemp(join(tmpdir(), "cozibud-harness-"));
|
|
128
|
+
let turnError;
|
|
129
|
+
try {
|
|
130
|
+
if (context.signal.aborted)
|
|
131
|
+
throw new Error(`${definition.label} was cancelled.`);
|
|
132
|
+
return await new Promise((resolve, reject) => {
|
|
133
|
+
const args = [...definition.args(), ...(ephemeralCwd ? definition.emptyDirectoryArgs ?? [] : [])];
|
|
134
|
+
const child = spawn(definition.command, args, { ...spawnOptions, cwd: options.cwd ?? ephemeralCwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
135
|
+
child.stdin.on("error", () => { });
|
|
136
|
+
child.stdin.end(carePrompt(input));
|
|
137
|
+
const decoder = new StringDecoder("utf8");
|
|
138
|
+
let pending = "";
|
|
139
|
+
let response = "";
|
|
140
|
+
let outputBytes = 0;
|
|
141
|
+
let settled = false;
|
|
142
|
+
let timedOut = false;
|
|
143
|
+
let failure;
|
|
144
|
+
let stopping = false;
|
|
145
|
+
const stop = () => { if (!stopping) {
|
|
146
|
+
stopping = true;
|
|
147
|
+
stopProcess(child);
|
|
148
|
+
} };
|
|
149
|
+
const finish = (error) => {
|
|
150
|
+
if (settled)
|
|
151
|
+
return;
|
|
152
|
+
settled = true;
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
context.signal.removeEventListener("abort", abort);
|
|
155
|
+
if (error)
|
|
156
|
+
reject(error);
|
|
157
|
+
else
|
|
158
|
+
resolve({ text: response.trim() });
|
|
159
|
+
};
|
|
160
|
+
const abort = () => stop();
|
|
161
|
+
context.signal.addEventListener("abort", abort, { once: true });
|
|
162
|
+
const timer = setTimeout(() => { timedOut = true; stop(); }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
163
|
+
const lineReceived = (line) => {
|
|
164
|
+
if (failure || context.signal.aborted || timedOut)
|
|
165
|
+
return;
|
|
166
|
+
try {
|
|
167
|
+
const event = JSON.parse(line);
|
|
168
|
+
if (event?.type === "error" && event.severity === "warning")
|
|
169
|
+
return;
|
|
170
|
+
if (event && (event.is_error === true || event.type === "error" || event.type === "turn.failed" || event.error)) {
|
|
171
|
+
failure = new Error(`${definition.label} reported a failed turn. Check the client's sign-in, subscription, and Cozibud MCP authorization, then retry.`);
|
|
172
|
+
stop();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const candidate = messageText(event);
|
|
176
|
+
if (!candidate)
|
|
177
|
+
return;
|
|
178
|
+
const next = event.type === "message" && event.delta === true
|
|
179
|
+
? { value: response + candidate, delta: candidate }
|
|
180
|
+
: appendNovelText(response, candidate);
|
|
181
|
+
response = next.value;
|
|
182
|
+
if (next.delta)
|
|
183
|
+
context.emit({ type: "assistant.delta", text: next.delta });
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Never return provider diagnostics as household answers.
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const count = (chunk) => {
|
|
190
|
+
outputBytes += chunk.length;
|
|
191
|
+
if (outputBytes <= MAX_OUTPUT_BYTES && !failure)
|
|
192
|
+
return true;
|
|
193
|
+
if (!failure) {
|
|
194
|
+
failure = new Error("Harness output exceeded the 1 MiB safety limit.");
|
|
195
|
+
pending = "";
|
|
196
|
+
stop();
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
};
|
|
200
|
+
child.stderr.on("data", (chunk) => { count(chunk); });
|
|
201
|
+
child.stdout.on("data", (chunk) => {
|
|
202
|
+
if (!count(chunk))
|
|
203
|
+
return;
|
|
204
|
+
pending += decoder.write(chunk);
|
|
205
|
+
let newline;
|
|
206
|
+
while ((newline = pending.indexOf("\n")) >= 0) {
|
|
207
|
+
const line = pending.slice(0, newline);
|
|
208
|
+
pending = pending.slice(newline + 1);
|
|
209
|
+
lineReceived(line);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
child.once("error", (error) => {
|
|
213
|
+
finish(new Error(error.code === "ENOENT" ? `${definition.command} is not installed or is not on PATH.` : `${definition.label} failed to start.`));
|
|
214
|
+
});
|
|
215
|
+
child.once("close", (code) => {
|
|
216
|
+
if (failure)
|
|
217
|
+
return finish(failure);
|
|
218
|
+
if (timedOut)
|
|
219
|
+
return finish(new Error(`${definition.label} timed out.`));
|
|
220
|
+
if (context.signal.aborted)
|
|
221
|
+
return finish(new Error(`${definition.label} was cancelled.`));
|
|
222
|
+
if (code !== 0)
|
|
223
|
+
return finish(new Error(`${definition.label} exited with code ${code ?? "unknown"}. Check the client's sign-in, subscription, and Cozibud MCP authorization.`));
|
|
224
|
+
lineReceived(pending + decoder.end());
|
|
225
|
+
if (failure)
|
|
226
|
+
return finish(failure);
|
|
227
|
+
if (!response.trim())
|
|
228
|
+
return finish(new Error(`${definition.label} returned no assistant response.`));
|
|
229
|
+
finish();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
turnError = error;
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
if (ephemeralCwd) {
|
|
239
|
+
try {
|
|
240
|
+
await (options.removeDirectory ?? rm)(ephemeralCwd, TEMP_DIRECTORY_CLEANUP_OPTIONS);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
const cleanupWarning = "Temporary working-directory cleanup did not complete.";
|
|
244
|
+
if (turnError instanceof Error)
|
|
245
|
+
turnError.message = `${turnError.message} ${cleanupWarning}`;
|
|
246
|
+
else
|
|
247
|
+
throw new Error(`${definition.label} completed, but temporary working-directory cleanup did not complete.`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
export function executable(configured) {
|
|
255
|
+
if (process.platform === "win32" && !/\.(?:exe|[cm]?js)$/i.test(configured)) {
|
|
256
|
+
const directories = isAbsolute(configured) ? [""] : (process.env.PATH ?? process.env.Path ?? "").split(delimiter);
|
|
257
|
+
// Preserve native executable discovery before falling back to script shims.
|
|
258
|
+
for (const directory of directories) {
|
|
259
|
+
const base = directory ? join(directory, configured) : configured;
|
|
260
|
+
if (existsSync(`${base}.exe`))
|
|
261
|
+
return { command: `${base}.exe`, prefixArgs: [] };
|
|
262
|
+
}
|
|
263
|
+
for (const directory of directories) {
|
|
264
|
+
const base = directory ? join(directory, configured) : configured;
|
|
265
|
+
const script = /\.ps1$/i.test(base) ? base : `${base}.ps1`;
|
|
266
|
+
if (existsSync(script))
|
|
267
|
+
return {
|
|
268
|
+
command: join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
|
|
269
|
+
prefixArgs: ["-NoLogo", "-NoProfile", "-File", script],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return /\.(?:c|m)?js$/i.test(configured)
|
|
274
|
+
? { command: process.execPath, prefixArgs: [configured] }
|
|
275
|
+
: { command: configured, prefixArgs: [] };
|
|
276
|
+
}
|
|
277
|
+
const codexExecutable = executable(process.env.COZIBUD_CODEX_PATH || "codex");
|
|
278
|
+
const claudeExecutable = executable(process.env.COZIBUD_CLAUDE_PATH || "claude");
|
|
279
|
+
const geminiExecutable = executable(process.env.COZIBUD_GEMINI_PATH || "gemini");
|
|
280
|
+
const cursorExecutable = executable(process.env.COZIBUD_CURSOR_PATH || "cursor-agent");
|
|
281
|
+
export const builtInProcessHarnesses = [
|
|
282
|
+
{
|
|
283
|
+
id: "codex",
|
|
284
|
+
label: "Codex CLI",
|
|
285
|
+
command: codexExecutable.command,
|
|
286
|
+
versionArgs: [...codexExecutable.prefixArgs, "--version"],
|
|
287
|
+
args: () => [...codexExecutable.prefixArgs, "exec", "--json", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "-c", "mcp_servers.cozibud.required=true", "-"],
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
id: "claude",
|
|
291
|
+
label: "Claude Code",
|
|
292
|
+
command: claudeExecutable.command,
|
|
293
|
+
versionArgs: [...claudeExecutable.prefixArgs, "--version"],
|
|
294
|
+
args: () => [...claudeExecutable.prefixArgs, "-p", "--output-format", "stream-json", "--verbose", "--allowedTools", "mcp__cozibud__list_children"],
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
id: "gemini",
|
|
298
|
+
label: "Gemini CLI",
|
|
299
|
+
command: geminiExecutable.command,
|
|
300
|
+
emptyDirectoryArgs: ["--skip-trust"],
|
|
301
|
+
versionArgs: [...geminiExecutable.prefixArgs, "--version"],
|
|
302
|
+
args: () => [...geminiExecutable.prefixArgs, "--output-format", "stream-json"],
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
id: "cursor",
|
|
306
|
+
label: "Cursor Agent",
|
|
307
|
+
command: cursorExecutable.command,
|
|
308
|
+
emptyDirectoryArgs: ["--trust"],
|
|
309
|
+
versionArgs: [...cursorExecutable.prefixArgs, "--version"],
|
|
310
|
+
args: () => [...cursorExecutable.prefixArgs, "-p", "--output-format", "stream-json"],
|
|
311
|
+
},
|
|
312
|
+
];
|
|
313
|
+
export function createBuiltInDrivers() {
|
|
314
|
+
return builtInProcessHarnesses.map((definition) => createProcessHarnessDriver(definition));
|
|
315
|
+
}
|
|
316
|
+
export const processOutput = { messageText, appendNovelText };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HarnessDriver, HarnessEvent, HarnessTurnRequest, TurnSnapshot } from "./contracts.js";
|
|
2
|
+
type Listener = (event: HarnessEvent) => void;
|
|
3
|
+
export declare class HarnessRuntime {
|
|
4
|
+
private readonly maxTurns;
|
|
5
|
+
private readonly maxActiveTurns;
|
|
6
|
+
private readonly drivers;
|
|
7
|
+
private readonly turns;
|
|
8
|
+
constructor(drivers: HarnessDriver[], maxTurns?: number, maxActiveTurns?: number);
|
|
9
|
+
listDrivers(): HarnessDriver[];
|
|
10
|
+
getTurn(id: string): TurnSnapshot | null;
|
|
11
|
+
subscribe(id: string, listener: Listener): (() => void) | null;
|
|
12
|
+
start(request: HarnessTurnRequest): TurnSnapshot;
|
|
13
|
+
cancel(id: string): boolean;
|
|
14
|
+
private emit;
|
|
15
|
+
private execute;
|
|
16
|
+
}
|
|
17
|
+
export {};
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
export class HarnessRuntime {
|
|
3
|
+
maxTurns;
|
|
4
|
+
maxActiveTurns;
|
|
5
|
+
drivers = new Map();
|
|
6
|
+
turns = new Map();
|
|
7
|
+
constructor(drivers, maxTurns = 100, maxActiveTurns = 2) {
|
|
8
|
+
this.maxTurns = maxTurns;
|
|
9
|
+
this.maxActiveTurns = maxActiveTurns;
|
|
10
|
+
for (const driver of drivers) {
|
|
11
|
+
if (this.drivers.has(driver.id))
|
|
12
|
+
throw new Error(`Duplicate harness driver: ${driver.id}`);
|
|
13
|
+
this.drivers.set(driver.id, driver);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
listDrivers() { return [...this.drivers.values()]; }
|
|
17
|
+
getTurn(id) {
|
|
18
|
+
const turn = this.turns.get(id);
|
|
19
|
+
if (!turn)
|
|
20
|
+
return null;
|
|
21
|
+
const { controller: _controller, listeners: _listeners, ...snapshot } = turn;
|
|
22
|
+
return structuredClone(snapshot);
|
|
23
|
+
}
|
|
24
|
+
subscribe(id, listener) {
|
|
25
|
+
const turn = this.turns.get(id);
|
|
26
|
+
if (!turn)
|
|
27
|
+
return null;
|
|
28
|
+
turn.listeners.add(listener);
|
|
29
|
+
return () => turn.listeners.delete(listener);
|
|
30
|
+
}
|
|
31
|
+
start(request) {
|
|
32
|
+
const driver = this.drivers.get(request.harness);
|
|
33
|
+
if (!driver)
|
|
34
|
+
throw new Error(`Unknown harness: ${request.harness}`);
|
|
35
|
+
const active = [...this.turns.values()].filter((turn) => turn.state === "running").length;
|
|
36
|
+
if (active >= this.maxActiveTurns)
|
|
37
|
+
throw new Error("The local harness is busy. Wait for a turn to finish.");
|
|
38
|
+
while (this.turns.size >= this.maxTurns) {
|
|
39
|
+
let removed = false;
|
|
40
|
+
for (const [id, turn] of this.turns) {
|
|
41
|
+
if (turn.state === "running")
|
|
42
|
+
continue;
|
|
43
|
+
this.turns.delete(id);
|
|
44
|
+
removed = true;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
if (!removed)
|
|
48
|
+
throw new Error("The local harness is at capacity. Wait for a turn to finish.");
|
|
49
|
+
}
|
|
50
|
+
const now = new Date().toISOString();
|
|
51
|
+
const turn = {
|
|
52
|
+
id: randomUUID(), harness: driver.id, state: "running", source: request.source, client: request.client,
|
|
53
|
+
createdAt: now, updatedAt: now, events: [], controller: new AbortController(), listeners: new Set(),
|
|
54
|
+
};
|
|
55
|
+
this.turns.set(turn.id, turn);
|
|
56
|
+
this.emit(turn, { type: "turn.started", source: request.source, client: request.client });
|
|
57
|
+
void this.execute(turn, driver, request);
|
|
58
|
+
return this.getTurn(turn.id);
|
|
59
|
+
}
|
|
60
|
+
cancel(id) {
|
|
61
|
+
const turn = this.turns.get(id);
|
|
62
|
+
if (!turn || turn.state !== "running")
|
|
63
|
+
return false;
|
|
64
|
+
turn.state = "cancelled";
|
|
65
|
+
turn.controller.abort();
|
|
66
|
+
this.emit(turn, { type: "turn.failed", code: "cancelled", message: "The turn was cancelled." });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
emit(turn, event) {
|
|
70
|
+
const full = { ...event, sequence: turn.events.length + 1, turnId: turn.id, harness: turn.harness, createdAt: new Date().toISOString() };
|
|
71
|
+
turn.events.push(full);
|
|
72
|
+
turn.updatedAt = full.createdAt;
|
|
73
|
+
for (const listener of turn.listeners)
|
|
74
|
+
listener(full);
|
|
75
|
+
}
|
|
76
|
+
async execute(turn, driver, request) {
|
|
77
|
+
try {
|
|
78
|
+
const availability = await driver.probe();
|
|
79
|
+
if (turn.state !== "running")
|
|
80
|
+
return;
|
|
81
|
+
if (availability.state === "unavailable") {
|
|
82
|
+
turn.state = "failed";
|
|
83
|
+
this.emit(turn, { type: "turn.failed", code: "unavailable", message: availability.message });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const result = await driver.run(request, {
|
|
87
|
+
turnId: turn.id,
|
|
88
|
+
signal: turn.controller.signal,
|
|
89
|
+
emit: (event) => { if (turn.state === "running")
|
|
90
|
+
this.emit(turn, event); },
|
|
91
|
+
});
|
|
92
|
+
if (turn.state !== "running")
|
|
93
|
+
return;
|
|
94
|
+
turn.response = result.text;
|
|
95
|
+
this.emit(turn, { type: "assistant.completed", text: result.text });
|
|
96
|
+
turn.state = "completed";
|
|
97
|
+
this.emit(turn, { type: "turn.completed" });
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (turn.state !== "running")
|
|
101
|
+
return;
|
|
102
|
+
const message = error instanceof Error ? error.message : "The harness failed.";
|
|
103
|
+
turn.state = "failed";
|
|
104
|
+
this.emit(turn, {
|
|
105
|
+
type: "turn.failed",
|
|
106
|
+
code: /timed out/i.test(message) ? "timeout" : "upstream-error",
|
|
107
|
+
message,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { HarnessRuntime } from "./runtime.js";
|
|
3
|
+
export interface HarnessServerOptions {
|
|
4
|
+
runtime: HarnessRuntime;
|
|
5
|
+
token: string;
|
|
6
|
+
host?: string;
|
|
7
|
+
port?: number;
|
|
8
|
+
allowedOrigins?: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function createHarnessHttpServer(options: HarnessServerOptions): {
|
|
11
|
+
server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
12
|
+
listen(): Promise<{
|
|
13
|
+
host: string;
|
|
14
|
+
port: number;
|
|
15
|
+
url: string;
|
|
16
|
+
}>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
const MAX_BODY_BYTES = 16 * 1024;
|
|
4
|
+
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
|
5
|
+
function json(response, status, body) {
|
|
6
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
7
|
+
response.end(JSON.stringify(body));
|
|
8
|
+
}
|
|
9
|
+
function validTurnRequest(value) {
|
|
10
|
+
if (!value || typeof value !== "object")
|
|
11
|
+
return false;
|
|
12
|
+
const row = value;
|
|
13
|
+
return typeof row.harness === "string" && row.harness.length <= 64
|
|
14
|
+
&& typeof row.prompt === "string" && row.prompt.trim().length > 0 && row.prompt.length <= 8_000
|
|
15
|
+
&& ["typed", "voice-transcript", "quick-action"].includes(String(row.source))
|
|
16
|
+
&& ["web", "phone", "watch"].includes(String(row.client))
|
|
17
|
+
&& (row.locale === undefined || (typeof row.locale === "string" && row.locale.length <= 35));
|
|
18
|
+
}
|
|
19
|
+
async function body(request) {
|
|
20
|
+
let raw = "";
|
|
21
|
+
for await (const chunk of request) {
|
|
22
|
+
raw += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
23
|
+
if (Buffer.byteLength(raw) > MAX_BODY_BYTES)
|
|
24
|
+
throw new Error("too-large");
|
|
25
|
+
}
|
|
26
|
+
return JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
function sse(response, event) {
|
|
29
|
+
response.write(`id: ${event.sequence}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
30
|
+
}
|
|
31
|
+
function authorized(header, token) {
|
|
32
|
+
const expected = Buffer.from(`Bearer ${token}`);
|
|
33
|
+
const actual = Buffer.from(header ?? "");
|
|
34
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
35
|
+
}
|
|
36
|
+
export function createHarnessHttpServer(options) {
|
|
37
|
+
if (options.token.length < 32)
|
|
38
|
+
throw new Error("The local harness token must contain at least 32 characters.");
|
|
39
|
+
const allowedOrigins = new Set(options.allowedOrigins ?? []);
|
|
40
|
+
const runtime = options.runtime;
|
|
41
|
+
const server = createServer(async (request, response) => {
|
|
42
|
+
response.setHeader("x-content-type-options", "nosniff");
|
|
43
|
+
response.setHeader("referrer-policy", "no-referrer");
|
|
44
|
+
const origin = request.headers.origin;
|
|
45
|
+
if (origin && allowedOrigins.has(origin)) {
|
|
46
|
+
response.setHeader("access-control-allow-origin", origin);
|
|
47
|
+
response.setHeader("vary", "Origin");
|
|
48
|
+
response.setHeader("access-control-allow-headers", "authorization, content-type");
|
|
49
|
+
response.setHeader("access-control-allow-methods", "GET, POST, DELETE, OPTIONS");
|
|
50
|
+
}
|
|
51
|
+
if (request.method === "OPTIONS") {
|
|
52
|
+
if (!origin || !allowedOrigins.has(origin))
|
|
53
|
+
return json(response, 403, { error: "Origin is not allowed." });
|
|
54
|
+
response.writeHead(204);
|
|
55
|
+
response.end();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (request.url === "/health" && request.method === "GET")
|
|
59
|
+
return json(response, 200, { status: "ok" });
|
|
60
|
+
if (!authorized(request.headers.authorization, options.token)) {
|
|
61
|
+
response.setHeader("www-authenticate", 'Bearer realm="cozibud-local-harness"');
|
|
62
|
+
return json(response, 401, { error: "A valid local harness token is required." });
|
|
63
|
+
}
|
|
64
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
65
|
+
if (url.pathname === "/v1/harnesses" && request.method === "GET") {
|
|
66
|
+
const harnesses = await Promise.all(runtime.listDrivers().map(async (driver) => ({
|
|
67
|
+
id: driver.id, label: driver.label, command: driver.command, authentication: driver.authentication,
|
|
68
|
+
availability: await driver.probe(),
|
|
69
|
+
})));
|
|
70
|
+
return json(response, 200, { harnesses });
|
|
71
|
+
}
|
|
72
|
+
if (url.pathname === "/v1/turns" && request.method === "POST") {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = await body(request);
|
|
75
|
+
if (!validTurnRequest(parsed))
|
|
76
|
+
return json(response, 400, { error: "Provide a harness, prompt, input source, and client." });
|
|
77
|
+
const turn = runtime.start(parsed);
|
|
78
|
+
response.setHeader("location", `/v1/turns/${turn.id}`);
|
|
79
|
+
return json(response, 202, { turn });
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error instanceof SyntaxError)
|
|
83
|
+
return json(response, 400, { error: "Request body must be valid JSON." });
|
|
84
|
+
if (error instanceof Error && error.message === "too-large")
|
|
85
|
+
return json(response, 413, { error: "Request body is too large." });
|
|
86
|
+
return json(response, 409, { error: error instanceof Error ? error.message : "The turn could not be started." });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const match = url.pathname.match(/^\/v1\/turns\/([^/]+)(\/events)?$/);
|
|
90
|
+
if (match && ID_PATTERN.test(match[1] ?? "")) {
|
|
91
|
+
const id = match[1];
|
|
92
|
+
if (!match[2] && request.method === "GET") {
|
|
93
|
+
const turn = runtime.getTurn(id);
|
|
94
|
+
return turn ? json(response, 200, { turn }) : json(response, 404, { error: "Turn not found." });
|
|
95
|
+
}
|
|
96
|
+
if (!match[2] && request.method === "DELETE") {
|
|
97
|
+
return runtime.cancel(id) ? json(response, 200, { cancelled: true }) : json(response, 409, { error: "Turn is not running." });
|
|
98
|
+
}
|
|
99
|
+
if (match[2] && request.method === "GET") {
|
|
100
|
+
const turn = runtime.getTurn(id);
|
|
101
|
+
if (!turn)
|
|
102
|
+
return json(response, 404, { error: "Turn not found." });
|
|
103
|
+
response.writeHead(200, {
|
|
104
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
105
|
+
"cache-control": "no-store",
|
|
106
|
+
connection: "keep-alive",
|
|
107
|
+
});
|
|
108
|
+
for (const event of turn.events)
|
|
109
|
+
sse(response, event);
|
|
110
|
+
if (turn.state !== "running")
|
|
111
|
+
return response.end();
|
|
112
|
+
const unsubscribe = runtime.subscribe(id, (event) => {
|
|
113
|
+
sse(response, event);
|
|
114
|
+
if (event.type === "turn.completed" || event.type === "turn.failed") {
|
|
115
|
+
unsubscribe?.();
|
|
116
|
+
response.end();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
request.once("close", () => unsubscribe?.());
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return json(response, 404, { error: "Not found." });
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
server,
|
|
127
|
+
async listen() {
|
|
128
|
+
const host = options.host ?? "127.0.0.1";
|
|
129
|
+
await new Promise((resolve, reject) => {
|
|
130
|
+
server.once("error", reject);
|
|
131
|
+
server.listen(options.port ?? 0, host, () => { server.off("error", reject); resolve(); });
|
|
132
|
+
});
|
|
133
|
+
const address = server.address();
|
|
134
|
+
return { host, port: address.port, url: `http://${host}:${address.port}` };
|
|
135
|
+
},
|
|
136
|
+
async close() {
|
|
137
|
+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const DEFAULT_COZIBUD_MCP_URL = "https://cozibud.com/api/mcp";
|
|
2
|
+
export type SetupProviderId = "codex" | "claude" | "gemini" | "cursor";
|
|
3
|
+
export type SetupCommandResult = {
|
|
4
|
+
status: "completed" | "missing" | "failed";
|
|
5
|
+
exitCode: number | null;
|
|
6
|
+
output: string;
|
|
7
|
+
};
|
|
8
|
+
export type SetupCommandRunner = (command: string, args: string[], options: {
|
|
9
|
+
interactive: boolean;
|
|
10
|
+
}) => Promise<SetupCommandResult>;
|
|
11
|
+
export type SetupWriter = (message: string) => void;
|
|
12
|
+
export declare const runSetupCommand: SetupCommandRunner;
|
|
13
|
+
export interface CursorMcpConfigStore {
|
|
14
|
+
read(): Promise<string | null>;
|
|
15
|
+
write(contents: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare function mergeCursorMcpConfig(contents: string | null, mcpUrl: string): string;
|
|
18
|
+
export declare function validateMcpUrl(input: string): string;
|
|
19
|
+
export declare function setupProvider(options?: {
|
|
20
|
+
provider?: SetupProviderId;
|
|
21
|
+
command?: string;
|
|
22
|
+
mcpUrl?: string;
|
|
23
|
+
run?: SetupCommandRunner;
|
|
24
|
+
write?: SetupWriter;
|
|
25
|
+
cursorConfig?: CursorMcpConfigStore;
|
|
26
|
+
}): Promise<void>;
|
|
27
|
+
export declare function setupCodex(options?: {
|
|
28
|
+
command?: string;
|
|
29
|
+
mcpUrl?: string;
|
|
30
|
+
run?: SetupCommandRunner;
|
|
31
|
+
write?: SetupWriter;
|
|
32
|
+
}): Promise<void>;
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { executable } from "./process-driver.js";
|
|
6
|
+
export const DEFAULT_COZIBUD_MCP_URL = "https://cozibud.com/api/mcp";
|
|
7
|
+
const MAX_SETUP_OUTPUT_BYTES = 64 * 1024;
|
|
8
|
+
export const runSetupCommand = async (command, args, options) => await new Promise((resolve) => {
|
|
9
|
+
const resolved = executable(command);
|
|
10
|
+
const child = spawn(resolved.command, [...resolved.prefixArgs, ...args], {
|
|
11
|
+
windowsHide: true,
|
|
12
|
+
stdio: options.interactive ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
13
|
+
});
|
|
14
|
+
let output = "";
|
|
15
|
+
let settled = false;
|
|
16
|
+
const append = (chunk) => {
|
|
17
|
+
const remaining = MAX_SETUP_OUTPUT_BYTES - Buffer.byteLength(output);
|
|
18
|
+
if (remaining > 0)
|
|
19
|
+
output += chunk.toString("utf8").slice(0, remaining);
|
|
20
|
+
};
|
|
21
|
+
if (!options.interactive) {
|
|
22
|
+
child.stdout?.on("data", append);
|
|
23
|
+
child.stderr?.on("data", append);
|
|
24
|
+
}
|
|
25
|
+
const finish = (result) => {
|
|
26
|
+
if (settled)
|
|
27
|
+
return;
|
|
28
|
+
settled = true;
|
|
29
|
+
resolve({ ...result, output });
|
|
30
|
+
};
|
|
31
|
+
child.once("error", (error) => finish({
|
|
32
|
+
status: error.code === "ENOENT" ? "missing" : "failed",
|
|
33
|
+
exitCode: null,
|
|
34
|
+
}));
|
|
35
|
+
child.once("close", (code) => finish({ status: "completed", exitCode: code }));
|
|
36
|
+
});
|
|
37
|
+
function succeeded(result) {
|
|
38
|
+
return result.status === "completed" && result.exitCode === 0;
|
|
39
|
+
}
|
|
40
|
+
function requireSuccess(result, missingMessage, failureMessage) {
|
|
41
|
+
if (result.status === "missing")
|
|
42
|
+
throw new Error(missingMessage);
|
|
43
|
+
if (!succeeded(result))
|
|
44
|
+
throw new Error(failureMessage);
|
|
45
|
+
}
|
|
46
|
+
function configuredUrl(output, requireNamedLine = false) {
|
|
47
|
+
const namedLine = output.split(/\r?\n/).find((line) => /\bcozibud\b/i.test(line) && /https?:\/\//i.test(line));
|
|
48
|
+
const namedUrl = namedLine?.match(/https?:\/\/[^\s)]+/i)?.[0];
|
|
49
|
+
if (namedUrl || requireNamedLine)
|
|
50
|
+
return namedUrl ?? null;
|
|
51
|
+
return output.match(/^\s*url:\s*(\S+)\s*$/im)?.[1] ?? null;
|
|
52
|
+
}
|
|
53
|
+
function reportsConnected(provider, output, expectedUrl) {
|
|
54
|
+
if (provider === "claude")
|
|
55
|
+
return /^\s*Status:\s*(?:[✓✔√]\s*)?Connected\s*$/im.test(output);
|
|
56
|
+
const namedLine = output.split(/\r?\n/).find((line) => /^\s*[^\w\s]?\s*cozibud\s*:/i.test(line));
|
|
57
|
+
const namedUrl = namedLine?.match(/https?:\/\/[^\s)]+/i)?.[0];
|
|
58
|
+
return namedUrl === expectedUrl && /-\s*Connected\s*$/i.test(namedLine ?? "");
|
|
59
|
+
}
|
|
60
|
+
function cursorReportsConnected(output) {
|
|
61
|
+
return output.split(/\r?\n/).some((line) => /^\s*cozibud\s*:\s*(?:ready|connected)\s*$/i.test(line));
|
|
62
|
+
}
|
|
63
|
+
function commandFor(configuredCommand) {
|
|
64
|
+
return /\.(?:c|m)?js$/i.test(configuredCommand)
|
|
65
|
+
? { command: process.execPath, prefixArgs: [configuredCommand] }
|
|
66
|
+
: { command: configuredCommand, prefixArgs: [] };
|
|
67
|
+
}
|
|
68
|
+
function providerCommand(provider, override) {
|
|
69
|
+
const configured = override ?? ({
|
|
70
|
+
codex: process.env.COZIBUD_CODEX_PATH,
|
|
71
|
+
claude: process.env.COZIBUD_CLAUDE_PATH,
|
|
72
|
+
gemini: process.env.COZIBUD_GEMINI_PATH,
|
|
73
|
+
cursor: process.env.COZIBUD_CURSOR_PATH,
|
|
74
|
+
}[provider]) ?? (provider === "cursor" ? "cursor-agent" : provider);
|
|
75
|
+
return commandFor(configured);
|
|
76
|
+
}
|
|
77
|
+
function providerLabel(provider) {
|
|
78
|
+
return { codex: "Codex CLI", claude: "Claude Code", gemini: "Gemini CLI", cursor: "Cursor Agent" }[provider];
|
|
79
|
+
}
|
|
80
|
+
function installUrl(provider) {
|
|
81
|
+
return {
|
|
82
|
+
codex: "https://developers.openai.com/codex/cli",
|
|
83
|
+
claude: "https://docs.anthropic.com/en/docs/claude-code/getting-started",
|
|
84
|
+
gemini: "https://google-gemini.github.io/gemini-cli/",
|
|
85
|
+
cursor: "https://docs.cursor.com/en/cli/installation",
|
|
86
|
+
}[provider];
|
|
87
|
+
}
|
|
88
|
+
function cursorConfigStore() {
|
|
89
|
+
const path = process.env.COZIBUD_CURSOR_MCP_CONFIG?.trim() || join(homedir(), ".cursor", "mcp.json");
|
|
90
|
+
return {
|
|
91
|
+
async read() {
|
|
92
|
+
try {
|
|
93
|
+
return await readFile(path, "utf8");
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error.code === "ENOENT")
|
|
97
|
+
return null;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
async write(contents) {
|
|
102
|
+
await mkdir(dirname(path), { recursive: true });
|
|
103
|
+
await writeFile(path, contents, { encoding: "utf8", mode: 0o600 });
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function parsedCursorConfig(contents) {
|
|
108
|
+
if (!contents?.trim())
|
|
109
|
+
return {};
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(contents);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw new Error("Cursor MCP configuration is not valid JSON; fix it before running setup again.");
|
|
116
|
+
}
|
|
117
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
118
|
+
throw new Error("Cursor MCP configuration must contain a JSON object.");
|
|
119
|
+
}
|
|
120
|
+
return parsed;
|
|
121
|
+
}
|
|
122
|
+
export function mergeCursorMcpConfig(contents, mcpUrl) {
|
|
123
|
+
const root = parsedCursorConfig(contents);
|
|
124
|
+
const currentServers = root.mcpServers;
|
|
125
|
+
if (currentServers !== undefined && (!currentServers || typeof currentServers !== "object" || Array.isArray(currentServers))) {
|
|
126
|
+
throw new Error("Cursor MCP configuration has an invalid mcpServers value.");
|
|
127
|
+
}
|
|
128
|
+
return `${JSON.stringify({
|
|
129
|
+
...root,
|
|
130
|
+
mcpServers: {
|
|
131
|
+
...currentServers,
|
|
132
|
+
cozibud: { url: mcpUrl },
|
|
133
|
+
},
|
|
134
|
+
}, null, 2)}\n`;
|
|
135
|
+
}
|
|
136
|
+
function cursorConfiguredUrl(contents) {
|
|
137
|
+
const servers = parsedCursorConfig(contents).mcpServers;
|
|
138
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers))
|
|
139
|
+
return null;
|
|
140
|
+
const cozibud = servers.cozibud;
|
|
141
|
+
if (!cozibud || typeof cozibud !== "object" || Array.isArray(cozibud))
|
|
142
|
+
return null;
|
|
143
|
+
const url = cozibud.url;
|
|
144
|
+
return typeof url === "string" ? url : null;
|
|
145
|
+
}
|
|
146
|
+
export function validateMcpUrl(input) {
|
|
147
|
+
let url;
|
|
148
|
+
try {
|
|
149
|
+
url = new URL(input);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
throw new Error("--mcp-url must be a valid HTTPS URL.");
|
|
153
|
+
}
|
|
154
|
+
const loopback = ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
|
|
155
|
+
if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) {
|
|
156
|
+
throw new Error("--mcp-url must use HTTPS (HTTP is allowed only for loopback development).");
|
|
157
|
+
}
|
|
158
|
+
if (url.username || url.password || url.hash)
|
|
159
|
+
throw new Error("--mcp-url must not contain credentials or a fragment.");
|
|
160
|
+
return url.href;
|
|
161
|
+
}
|
|
162
|
+
async function setupCodexProvider(options) {
|
|
163
|
+
const executable = providerCommand("codex", options.command);
|
|
164
|
+
const args = (...values) => [...executable.prefixArgs, ...values];
|
|
165
|
+
const missing = `Codex CLI is not installed or is not on PATH. Install it from ${installUrl("codex")} and run setup again.`;
|
|
166
|
+
options.write("Checking Codex CLI…");
|
|
167
|
+
requireSuccess(await options.run(executable.command, args("--version"), { interactive: false }), missing, "Codex CLI was found but could not be started.");
|
|
168
|
+
const login = await options.run(executable.command, args("login", "status"), { interactive: false });
|
|
169
|
+
if (!succeeded(login)) {
|
|
170
|
+
options.write("Opening Codex sign-in in your browser…");
|
|
171
|
+
requireSuccess(await options.run(executable.command, args("login"), { interactive: true }), missing, "Codex sign-in did not complete. Run setup again when you are ready to sign in.");
|
|
172
|
+
}
|
|
173
|
+
else
|
|
174
|
+
options.write("Codex sign-in is ready.");
|
|
175
|
+
requireSuccess(await options.run(executable.command, args("login", "status"), { interactive: false }), missing, "Codex still reports that you are signed out.");
|
|
176
|
+
const existingMcp = await options.run(executable.command, args("mcp", "get", "cozibud"), { interactive: false });
|
|
177
|
+
const existingUrl = succeeded(existingMcp) ? configuredUrl(existingMcp.output) : null;
|
|
178
|
+
if (succeeded(existingMcp) && existingUrl !== options.mcpUrl) {
|
|
179
|
+
options.write("Replacing the existing cozibud connection because it points at a different endpoint…");
|
|
180
|
+
requireSuccess(await options.run(executable.command, args("mcp", "remove", "cozibud"), { interactive: true }), missing, "Codex could not remove the existing cozibud MCP connection.");
|
|
181
|
+
}
|
|
182
|
+
if (existingUrl !== options.mcpUrl) {
|
|
183
|
+
options.write(`Adding the Cozibud connection at ${options.mcpUrl}…`);
|
|
184
|
+
requireSuccess(await options.run(executable.command, args("mcp", "add", "cozibud", "--url", options.mcpUrl), { interactive: true }), missing, "Codex could not add the Cozibud MCP connection.");
|
|
185
|
+
}
|
|
186
|
+
else
|
|
187
|
+
options.write("The Cozibud connection is configured with the expected endpoint.");
|
|
188
|
+
options.write("Opening Cozibud sign-in and permission approval in your browser…");
|
|
189
|
+
requireSuccess(await options.run(executable.command, args("mcp", "login", "cozibud"), { interactive: true }), missing, "Cozibud sign-in or permission approval did not complete.");
|
|
190
|
+
const verified = await options.run(executable.command, args("mcp", "get", "cozibud"), { interactive: false });
|
|
191
|
+
requireSuccess(verified, missing, "Codex could not verify the Cozibud MCP connection after sign-in.");
|
|
192
|
+
if (configuredUrl(verified.output) !== options.mcpUrl)
|
|
193
|
+
throw new Error("The verified cozibud connection does not use the expected endpoint.");
|
|
194
|
+
options.write("Ready. Codex is signed in and connected to Cozibud.");
|
|
195
|
+
}
|
|
196
|
+
async function setupInteractiveProvider(provider, options) {
|
|
197
|
+
const executable = providerCommand(provider, options.command);
|
|
198
|
+
const args = (...values) => [...executable.prefixArgs, ...values];
|
|
199
|
+
const label = providerLabel(provider);
|
|
200
|
+
const missing = `${label} is not installed or is not on PATH. Install it from ${installUrl(provider)} and run setup again.`;
|
|
201
|
+
options.write(`Checking ${label}…`);
|
|
202
|
+
requireSuccess(await options.run(executable.command, args("--version"), { interactive: false }), missing, `${label} was found but could not be started.`);
|
|
203
|
+
if (provider === "claude") {
|
|
204
|
+
const login = await options.run(executable.command, args("auth", "status"), { interactive: false });
|
|
205
|
+
if (!succeeded(login)) {
|
|
206
|
+
options.write("Opening Claude sign-in in your browser…");
|
|
207
|
+
requireSuccess(await options.run(executable.command, args("auth", "login"), { interactive: true }), missing, "Claude sign-in did not complete.");
|
|
208
|
+
}
|
|
209
|
+
else
|
|
210
|
+
options.write("Claude sign-in is ready.");
|
|
211
|
+
requireSuccess(await options.run(executable.command, args("auth", "status"), { interactive: false }), missing, "Claude still reports that you are signed out.");
|
|
212
|
+
}
|
|
213
|
+
const inspectArgs = provider === "claude" ? args("mcp", "get", "cozibud") : args("mcp", "list");
|
|
214
|
+
const existing = await options.run(executable.command, inspectArgs, { interactive: false });
|
|
215
|
+
const existingUrl = succeeded(existing) ? configuredUrl(existing.output, provider === "gemini") : null;
|
|
216
|
+
if (succeeded(existing) && existingUrl && existingUrl !== options.mcpUrl) {
|
|
217
|
+
options.write("Replacing the existing cozibud connection because it points at a different endpoint…");
|
|
218
|
+
const removeArgs = provider === "claude"
|
|
219
|
+
? args("mcp", "remove", "cozibud", "--scope", "user")
|
|
220
|
+
: args("mcp", "remove", "cozibud");
|
|
221
|
+
requireSuccess(await options.run(executable.command, removeArgs, { interactive: true }), missing, `${label} could not remove the existing cozibud MCP connection.`);
|
|
222
|
+
}
|
|
223
|
+
if (existingUrl !== options.mcpUrl) {
|
|
224
|
+
options.write(`Adding the Cozibud connection at ${options.mcpUrl}…`);
|
|
225
|
+
requireSuccess(await options.run(executable.command, args("mcp", "add", "--scope", "user", "--transport", "http", "cozibud", options.mcpUrl), { interactive: true }), missing, `${label} could not add the Cozibud MCP connection.`);
|
|
226
|
+
}
|
|
227
|
+
else
|
|
228
|
+
options.write("The Cozibud connection is configured with the expected endpoint.");
|
|
229
|
+
const authInstruction = provider === "claude" ? "Choose cozibud from /mcp" : "Run /mcp auth cozibud";
|
|
230
|
+
options.write(`${authInstruction}, complete Cozibud browser approval, then exit the provider to finish setup.`);
|
|
231
|
+
requireSuccess(await options.run(executable.command, args(), { interactive: true }), missing, `${label} closed before setup could finish.`);
|
|
232
|
+
const verified = await options.run(executable.command, inspectArgs, { interactive: false });
|
|
233
|
+
requireSuccess(verified, missing, `${label} could not verify the Cozibud MCP configuration.`);
|
|
234
|
+
if (configuredUrl(verified.output, provider === "gemini") !== options.mcpUrl)
|
|
235
|
+
throw new Error("The verified cozibud connection does not use the expected endpoint.");
|
|
236
|
+
if (!reportsConnected(provider, verified.output, options.mcpUrl)) {
|
|
237
|
+
throw new Error(`${label} did not report a connected Cozibud MCP session. Check any disabled or untrusted-client status, then run setup again and complete Cozibud browser approval before exiting the provider.`);
|
|
238
|
+
}
|
|
239
|
+
options.write(`Ready. ${label} is configured for Cozibud. The provider stores its own login and OAuth credentials.`);
|
|
240
|
+
}
|
|
241
|
+
async function setupCursorProvider(options) {
|
|
242
|
+
const executable = providerCommand("cursor", options.command);
|
|
243
|
+
const args = (...values) => [...executable.prefixArgs, ...values];
|
|
244
|
+
const missing = `Cursor Agent is not installed or is not on PATH. Install it from ${installUrl("cursor")} and run setup again.`;
|
|
245
|
+
options.write("Checking Cursor Agent…");
|
|
246
|
+
requireSuccess(await options.run(executable.command, args("--version"), { interactive: false }), missing, "Cursor Agent was found but could not be started.");
|
|
247
|
+
const login = await options.run(executable.command, args("status"), { interactive: false });
|
|
248
|
+
if (!succeeded(login)) {
|
|
249
|
+
options.write("Opening Cursor sign-in in your browser…");
|
|
250
|
+
requireSuccess(await options.run(executable.command, args("login"), { interactive: true }), missing, "Cursor sign-in did not complete.");
|
|
251
|
+
}
|
|
252
|
+
else
|
|
253
|
+
options.write("Cursor sign-in is ready.");
|
|
254
|
+
requireSuccess(await options.run(executable.command, args("status"), { interactive: false }), missing, "Cursor still reports that you are signed out.");
|
|
255
|
+
const currentConfig = await options.config.read();
|
|
256
|
+
if (cursorConfiguredUrl(currentConfig) !== options.mcpUrl) {
|
|
257
|
+
options.write("Adding the Cozibud connection to Cursor's user MCP configuration…");
|
|
258
|
+
await options.config.write(mergeCursorMcpConfig(currentConfig, options.mcpUrl));
|
|
259
|
+
}
|
|
260
|
+
else
|
|
261
|
+
options.write("The Cozibud connection is configured with the expected endpoint.");
|
|
262
|
+
options.write("Opening Cozibud sign-in and permission approval in your browser…");
|
|
263
|
+
requireSuccess(await options.run(executable.command, args("mcp", "login", "cozibud"), { interactive: true }), missing, "Cozibud sign-in or permission approval did not complete.");
|
|
264
|
+
const verified = await options.run(executable.command, args("mcp", "list"), { interactive: false });
|
|
265
|
+
requireSuccess(verified, missing, "Cursor could not verify the Cozibud MCP connection after sign-in.");
|
|
266
|
+
if (!cursorReportsConnected(verified.output))
|
|
267
|
+
throw new Error("Cursor did not report a ready Cozibud MCP connection after sign-in.");
|
|
268
|
+
if (cursorConfiguredUrl(await options.config.read()) !== options.mcpUrl)
|
|
269
|
+
throw new Error("Cursor's cozibud connection does not use the expected endpoint.");
|
|
270
|
+
options.write("Ready. Cursor is signed in and available to the local Cozibud bridge.");
|
|
271
|
+
}
|
|
272
|
+
export async function setupProvider(options = {}) {
|
|
273
|
+
const provider = options.provider ?? "codex";
|
|
274
|
+
const mcpUrl = validateMcpUrl(options.mcpUrl ?? DEFAULT_COZIBUD_MCP_URL);
|
|
275
|
+
const run = options.run ?? runSetupCommand;
|
|
276
|
+
const write = options.write ?? ((message) => console.log(message));
|
|
277
|
+
if (provider === "codex")
|
|
278
|
+
await setupCodexProvider({ command: options.command, mcpUrl, run, write });
|
|
279
|
+
else if (provider === "cursor")
|
|
280
|
+
await setupCursorProvider({ command: options.command, mcpUrl, run, write, config: options.cursorConfig ?? cursorConfigStore() });
|
|
281
|
+
else
|
|
282
|
+
await setupInteractiveProvider(provider, { command: options.command, mcpUrl, run, write });
|
|
283
|
+
}
|
|
284
|
+
export async function setupCodex(options = {}) {
|
|
285
|
+
await setupProvider({ ...options, provider: "codex" });
|
|
286
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cozibud/harness",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cozibud local-client bridge and CLI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/rongduan-zhu/cozibud.git",
|
|
10
|
+
"directory": "packages/harness"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/rongduan-zhu/cozibud/tree/main/packages/harness#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/rongduan-zhu/cozibud/issues"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22"
|
|
24
|
+
},
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"bin": {
|
|
28
|
+
"cozibud-harness": "dist/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
38
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
39
|
+
"prepack": "npm run build",
|
|
40
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
41
|
+
"test": "vitest run --root ../.. packages/harness/src"
|
|
42
|
+
}
|
|
43
|
+
}
|