@kuralle-syrinx/cli 4.4.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/LICENSE +22 -0
- package/README.md +152 -0
- package/dist/index.js +1161 -0
- package/dist/index.js.map +7 -0
- package/package.json +53 -0
- package/src/__fixtures__/good-text-agent.mjs +21 -0
- package/src/__fixtures__/no-callable-export.mjs +7 -0
- package/src/__fixtures__/throwing-agent.mjs +8 -0
- package/src/agent-resolve.test.ts +50 -0
- package/src/agent-resolve.ts +80 -0
- package/src/cli.test.ts +154 -0
- package/src/cli.ts +225 -0
- package/src/doctor.test.ts +50 -0
- package/src/doctor.ts +101 -0
- package/src/exit-codes.ts +38 -0
- package/src/fixture.test.ts +100 -0
- package/src/fixture.ts +96 -0
- package/src/help.ts +70 -0
- package/src/index.ts +37 -0
- package/src/text-command.test.ts +32 -0
- package/src/text-command.ts +32 -0
- package/src/text-turn.test.ts +49 -0
- package/src/text-turn.ts +108 -0
- package/src/turn-command.test.ts +80 -0
- package/src/turn-command.ts +81 -0
- package/src/turn-runner.test.ts +111 -0
- package/src/turn-runner.ts +381 -0
- package/src/version.test.ts +69 -0
- package/src/version.ts +92 -0
package/src/cli.test.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { main, type CliIO } from "./cli.js";
|
|
9
|
+
import { EXIT_CODES } from "./exit-codes.js";
|
|
10
|
+
|
|
11
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const FIXTURES = join(HERE, "__fixtures__");
|
|
13
|
+
|
|
14
|
+
function captureIO(): { io: CliIO; stdout: string[]; stderr: string[] } {
|
|
15
|
+
const stdout: string[] = [];
|
|
16
|
+
const stderr: string[] = [];
|
|
17
|
+
return { io: { stdout: (l) => stdout.push(l), stderr: (l) => stderr.push(l) }, stdout, stderr };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("main — global flags", () => {
|
|
21
|
+
it("--help prints usage to stdout and exits SUCCESS", async () => {
|
|
22
|
+
const { io, stdout } = captureIO();
|
|
23
|
+
const code = await main(["--help"], io);
|
|
24
|
+
expect(code).toBe(EXIT_CODES.SUCCESS);
|
|
25
|
+
expect(stdout.join("\n")).toContain("USAGE");
|
|
26
|
+
expect(stdout.join("\n")).toContain("EXIT CODES");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("no arguments prints usage (same as --help)", async () => {
|
|
30
|
+
const { io, stdout } = captureIO();
|
|
31
|
+
const code = await main([], io);
|
|
32
|
+
expect(code).toBe(EXIT_CODES.SUCCESS);
|
|
33
|
+
expect(stdout.join("\n")).toContain("USAGE");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("--version prints just the version and exits SUCCESS", async () => {
|
|
37
|
+
const { io, stdout } = captureIO();
|
|
38
|
+
const code = await main(["--version"], io);
|
|
39
|
+
expect(code).toBe(EXIT_CODES.SUCCESS);
|
|
40
|
+
expect(stdout).toHaveLength(1);
|
|
41
|
+
expect(stdout[0]).toMatch(/^\d+\.\d+\.\d+/);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("main — usage errors", () => {
|
|
46
|
+
it("an unknown command exits USAGE and, with --json, stays parseable on stdout", async () => {
|
|
47
|
+
const { io, stdout } = captureIO();
|
|
48
|
+
const code = await main(["frobnicate", "--json"], io);
|
|
49
|
+
expect(code).toBe(EXIT_CODES.USAGE);
|
|
50
|
+
expect(stdout).toHaveLength(1);
|
|
51
|
+
const parsed = JSON.parse(stdout[0]!) as { ok: boolean; error: { code: string } };
|
|
52
|
+
expect(parsed.ok).toBe(false);
|
|
53
|
+
expect(parsed.error.code).toBe("USAGE");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("without --json, an unknown command prints nothing to stdout and the error to stderr", async () => {
|
|
57
|
+
const { io, stdout, stderr } = captureIO();
|
|
58
|
+
const code = await main(["frobnicate"], io);
|
|
59
|
+
expect(code).toBe(EXIT_CODES.USAGE);
|
|
60
|
+
expect(stdout).toHaveLength(0);
|
|
61
|
+
expect(stderr.join("\n")).toContain("frobnicate");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("console/chat/listen are explicitly rejected — this CLI is not a console", async () => {
|
|
65
|
+
for (const verb of ["console", "chat", "listen"]) {
|
|
66
|
+
const { io } = captureIO();
|
|
67
|
+
const code = await main([verb], io);
|
|
68
|
+
expect(code).toBe(EXIT_CODES.USAGE);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("turn without --in exits USAGE", async () => {
|
|
73
|
+
const { io, stdout } = captureIO();
|
|
74
|
+
const code = await main(["turn", "--agent", "whatever#x", "--json"], io);
|
|
75
|
+
expect(code).toBe(EXIT_CODES.USAGE);
|
|
76
|
+
const parsed = JSON.parse(stdout[0]!) as { error: { code: string } };
|
|
77
|
+
expect(parsed.error.code).toBe("USAGE");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("turn without --agent exits USAGE — the CLI brings no provider of its own", async () => {
|
|
81
|
+
const { io, stdout } = captureIO();
|
|
82
|
+
const code = await main(["turn", "--in", "/tmp/whatever.wav", "--json"], io);
|
|
83
|
+
expect(code).toBe(EXIT_CODES.USAGE);
|
|
84
|
+
const parsed = JSON.parse(stdout[0]!) as { error: { code: string; message: string } };
|
|
85
|
+
expect(parsed.error.code).toBe("USAGE");
|
|
86
|
+
expect(parsed.error.message).toContain("--agent");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("turn --agent pointing at a nonexistent module fails USAGE with a message naming what could not be resolved — no stack trace, no silent fallback", async () => {
|
|
90
|
+
const { io, stdout, stderr } = captureIO();
|
|
91
|
+
const code = await main(["turn", "--in", "/tmp/whatever.wav", "--agent", "./does/not/exist.ts#nope", "--json"], io);
|
|
92
|
+
expect(code).toBe(EXIT_CODES.USAGE);
|
|
93
|
+
expect(stdout).toHaveLength(1);
|
|
94
|
+
const parsed = JSON.parse(stdout[0]!) as { ok: boolean; error: { code: string; message: string } };
|
|
95
|
+
expect(parsed.ok).toBe(false);
|
|
96
|
+
expect(parsed.error.code).toBe("USAGE");
|
|
97
|
+
expect(parsed.error.message).toContain("does/not/exist.ts");
|
|
98
|
+
expect(stderr.join("\n")).not.toContain("at "); // no stack trace leaked for a documented failure class
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("text without a message exits USAGE", async () => {
|
|
102
|
+
const { io, stdout } = captureIO();
|
|
103
|
+
const code = await main(["text", "--agent", "whatever#x", "--json"], io);
|
|
104
|
+
expect(code).toBe(EXIT_CODES.USAGE);
|
|
105
|
+
const parsed = JSON.parse(stdout[0]!) as { error: { code: string } };
|
|
106
|
+
expect(parsed.error.code).toBe("USAGE");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("text without --agent exits USAGE", async () => {
|
|
110
|
+
const { io, stdout } = captureIO();
|
|
111
|
+
const code = await main(["text", "hi", "--json"], io);
|
|
112
|
+
expect(code).toBe(EXIT_CODES.USAGE);
|
|
113
|
+
const parsed = JSON.parse(stdout[0]!) as { error: { code: string; message: string } };
|
|
114
|
+
expect(parsed.error.code).toBe("USAGE");
|
|
115
|
+
expect(parsed.error.message).toContain("--agent");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("an unrecognised flag exits USAGE rather than throwing past the CLI", async () => {
|
|
119
|
+
const { io } = captureIO();
|
|
120
|
+
const code = await main(["doctor", "--not-a-real-flag"], io);
|
|
121
|
+
expect(code).toBe(EXIT_CODES.USAGE);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("main — end to end through --agent, no live provider", () => {
|
|
126
|
+
it("text --agent <fixture> resolves the caller's own module and drives a real turn", async () => {
|
|
127
|
+
const { io, stdout } = captureIO();
|
|
128
|
+
const code = await main(["text", "hi", "--agent", `${join(FIXTURES, "good-text-agent.mjs")}#createSession`, "--json"], io);
|
|
129
|
+
expect(code).toBe(EXIT_CODES.SUCCESS);
|
|
130
|
+
const parsed = JSON.parse(stdout[0]!) as { ok: boolean; reply: string; agent: string };
|
|
131
|
+
expect(parsed.ok).toBe(true);
|
|
132
|
+
expect(parsed.reply).toBe("Hello from the fake agent.");
|
|
133
|
+
expect(parsed.agent).toContain("createSession");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("main — doctor", () => {
|
|
138
|
+
it("doctor --json emits one parseable object and always exits SUCCESS", async () => {
|
|
139
|
+
const { io, stdout } = captureIO();
|
|
140
|
+
const code = await main(["doctor", "--json"], io);
|
|
141
|
+
expect(code).toBe(EXIT_CODES.SUCCESS);
|
|
142
|
+
expect(stdout).toHaveLength(1);
|
|
143
|
+
const parsed = JSON.parse(stdout[0]!) as { ok: boolean; verb: string };
|
|
144
|
+
expect(parsed.ok).toBe(true);
|
|
145
|
+
expect(parsed.verb).toBe("doctor");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("doctor without --json prints a human summary, not JSON", async () => {
|
|
149
|
+
const { io, stdout } = captureIO();
|
|
150
|
+
const code = await main(["doctor"], io);
|
|
151
|
+
expect(code).toBe(EXIT_CODES.SUCCESS);
|
|
152
|
+
expect(() => JSON.parse(stdout[0]!)).toThrow();
|
|
153
|
+
});
|
|
154
|
+
});
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Argument parsing and verb dispatch. Never interactive: no prompts, no
|
|
4
|
+
// spinners, no colour unless the stream is a TTY. Results go to stdout,
|
|
5
|
+
// diagnostics to stderr — always, so --json output stays parseable.
|
|
6
|
+
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
|
|
9
|
+
import { runDoctor } from "./doctor.js";
|
|
10
|
+
import { CliError, EXIT_CODES, EXIT_CODE_TABLE, type ExitCode } from "./exit-codes.js";
|
|
11
|
+
import { HELP_TEXT } from "./help.js";
|
|
12
|
+
import { runTextCommand } from "./text-command.js";
|
|
13
|
+
import { runTurnCommand } from "./turn-command.js";
|
|
14
|
+
import { CLI_VERSION, warnOnVersionSkew } from "./version.js";
|
|
15
|
+
|
|
16
|
+
export interface CliIO {
|
|
17
|
+
readonly stdout: (line: string) => void;
|
|
18
|
+
readonly stderr: (line: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const defaultIO: CliIO = {
|
|
22
|
+
stdout: (line) => {
|
|
23
|
+
process.stdout.write(`${line}\n`);
|
|
24
|
+
},
|
|
25
|
+
stderr: (line) => {
|
|
26
|
+
process.stderr.write(`${line}\n`);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function exitCodeName(code: ExitCode): string {
|
|
31
|
+
return EXIT_CODE_TABLE.find((row) => row.code === code)?.name ?? "UNKNOWN";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Best-effort pre-parse scan so even a usage error can honour --json, without depending on parseArgs succeeding. */
|
|
35
|
+
function looksLikeJsonRequested(args: readonly string[]): boolean {
|
|
36
|
+
return args.includes("--json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function reportSuccess(io: CliIO, json: boolean, verb: string, payload: Record<string, unknown>, prose: string): ExitCode {
|
|
40
|
+
if (json) {
|
|
41
|
+
io.stdout(JSON.stringify({ ok: true, verb, ...payload }));
|
|
42
|
+
} else {
|
|
43
|
+
io.stdout(prose);
|
|
44
|
+
}
|
|
45
|
+
return EXIT_CODES.SUCCESS;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function reportError(io: CliIO, json: boolean, verb: string, err: unknown): ExitCode {
|
|
49
|
+
const cliErr =
|
|
50
|
+
err instanceof CliError ? err : new CliError(EXIT_CODES.INTERNAL, err instanceof Error ? err.message : String(err));
|
|
51
|
+
if (cliErr.exitCode === EXIT_CODES.INTERNAL && err instanceof Error && err.stack) {
|
|
52
|
+
io.stderr(err.stack);
|
|
53
|
+
}
|
|
54
|
+
if (json) {
|
|
55
|
+
io.stdout(
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
ok: false,
|
|
58
|
+
verb,
|
|
59
|
+
error: { code: exitCodeName(cliErr.exitCode), message: cliErr.message, ...(cliErr.details ?? {}) },
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
} else {
|
|
63
|
+
io.stderr(`error: ${cliErr.message}`);
|
|
64
|
+
}
|
|
65
|
+
return cliErr.exitCode;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleTurn(args: string[], io: CliIO): Promise<ExitCode> {
|
|
69
|
+
let values;
|
|
70
|
+
try {
|
|
71
|
+
({ values } = parseArgs({
|
|
72
|
+
args,
|
|
73
|
+
options: {
|
|
74
|
+
in: { type: "string" },
|
|
75
|
+
agent: { type: "string" },
|
|
76
|
+
"session-dir": { type: "string" },
|
|
77
|
+
json: { type: "boolean", default: false },
|
|
78
|
+
},
|
|
79
|
+
strict: true,
|
|
80
|
+
allowPositionals: false,
|
|
81
|
+
}));
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return reportError(io, looksLikeJsonRequested(args), "turn", new CliError(EXIT_CODES.USAGE, err instanceof Error ? err.message : String(err)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const json = values.json === true;
|
|
87
|
+
if (!values.in) {
|
|
88
|
+
return reportError(io, json, "turn", new CliError(EXIT_CODES.USAGE, "turn requires --in <fixture.wav|fixture.json>"));
|
|
89
|
+
}
|
|
90
|
+
if (!values.agent) {
|
|
91
|
+
return reportError(
|
|
92
|
+
io,
|
|
93
|
+
json,
|
|
94
|
+
"turn",
|
|
95
|
+
new CliError(EXIT_CODES.USAGE, "turn requires --agent <module>#<export> — the CLI brings no providers of its own (see --help)"),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const result = await runTurnCommand({
|
|
101
|
+
inputPath: values.in,
|
|
102
|
+
agentSpec: values.agent,
|
|
103
|
+
sessionDir: values["session-dir"],
|
|
104
|
+
});
|
|
105
|
+
const prose = [
|
|
106
|
+
`agent: ${result.agent}`,
|
|
107
|
+
`transcript: ${result.transcript || "(empty)"}`,
|
|
108
|
+
`reply: ${result.reply || "(empty)"}`,
|
|
109
|
+
`session: ${result.sessionDir}`,
|
|
110
|
+
result.assertion ? `assertion: match (expected "${result.assertion.expectedTranscript}")` : "assertion: none",
|
|
111
|
+
].join("\n");
|
|
112
|
+
return reportSuccess(io, json, "turn", { ...result }, prose);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return reportError(io, json, "turn", err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function handleText(args: string[], io: CliIO): Promise<ExitCode> {
|
|
119
|
+
let values, positionals;
|
|
120
|
+
try {
|
|
121
|
+
({ values, positionals } = parseArgs({
|
|
122
|
+
args,
|
|
123
|
+
options: {
|
|
124
|
+
agent: { type: "string" },
|
|
125
|
+
json: { type: "boolean", default: false },
|
|
126
|
+
},
|
|
127
|
+
strict: true,
|
|
128
|
+
allowPositionals: true,
|
|
129
|
+
}));
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return reportError(io, looksLikeJsonRequested(args), "text", new CliError(EXIT_CODES.USAGE, err instanceof Error ? err.message : String(err)));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const json = values.json === true;
|
|
135
|
+
const message = positionals[0];
|
|
136
|
+
if (!message) {
|
|
137
|
+
return reportError(io, json, "text", new CliError(EXIT_CODES.USAGE, 'text requires a message: syrinx text "<message>" --agent <module>#<export>'));
|
|
138
|
+
}
|
|
139
|
+
if (!values.agent) {
|
|
140
|
+
return reportError(
|
|
141
|
+
io,
|
|
142
|
+
json,
|
|
143
|
+
"text",
|
|
144
|
+
new CliError(EXIT_CODES.USAGE, "text requires --agent <module>#<export> — the CLI brings no providers of its own (see --help)"),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const result = await runTextCommand({ message, agentSpec: values.agent });
|
|
150
|
+
const prose = `agent: ${result.agent}\nreply: ${result.reply || "(empty)"}\nttft: ${String(result.ttftMs)}ms total: ${String(result.totalMs)}ms`;
|
|
151
|
+
return reportSuccess(io, json, "text", { ...result }, prose);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return reportError(io, json, "text", err);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function handleDoctor(args: string[], io: CliIO): Promise<ExitCode> {
|
|
158
|
+
let values;
|
|
159
|
+
try {
|
|
160
|
+
({ values } = parseArgs({
|
|
161
|
+
args,
|
|
162
|
+
options: { agent: { type: "string" }, json: { type: "boolean", default: false } },
|
|
163
|
+
strict: true,
|
|
164
|
+
allowPositionals: false,
|
|
165
|
+
}));
|
|
166
|
+
} catch (err) {
|
|
167
|
+
return reportError(io, looksLikeJsonRequested(args), "doctor", new CliError(EXIT_CODES.USAGE, err instanceof Error ? err.message : String(err)));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const json = values.json === true;
|
|
171
|
+
const report = await runDoctor({ agentSpec: values.agent });
|
|
172
|
+
const prose = [
|
|
173
|
+
`syrinx cli ${report.cliVersion} node ${report.node.version} (${report.node.platform})`,
|
|
174
|
+
`${report.core.package}: ${report.core.resolved ? `v${String(report.core.version)}` : "not installed"}${report.core.majorMismatch ? " (major mismatch with CLI)" : ""}`,
|
|
175
|
+
"well-known provider keys (informational only):",
|
|
176
|
+
...Object.entries(report.wellKnownProviderKeys).map(([key, present]) => ` ${key}: ${present ? "present" : "missing"}`),
|
|
177
|
+
...(report.agent ? [`agent (${report.agent.spec}): ${report.agent.resolved ? `resolved -> ${String(report.agent.label)}` : `NOT resolved (${String(report.agent.error)})`}`] : []),
|
|
178
|
+
report.summary,
|
|
179
|
+
].join("\n");
|
|
180
|
+
return reportSuccess(io, json, "doctor", { ...report }, prose);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function main(argv: readonly string[], io: CliIO = defaultIO): Promise<ExitCode> {
|
|
184
|
+
try {
|
|
185
|
+
warnOnVersionSkew(process.cwd());
|
|
186
|
+
} catch {
|
|
187
|
+
// Version-skew reporting is best-effort diagnostics; never let it block a command.
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const [verb, ...rest] = argv;
|
|
191
|
+
|
|
192
|
+
if (verb === undefined || verb === "--help" || verb === "-h") {
|
|
193
|
+
io.stdout(HELP_TEXT);
|
|
194
|
+
return EXIT_CODES.SUCCESS;
|
|
195
|
+
}
|
|
196
|
+
if (verb === "--version" || verb === "-v") {
|
|
197
|
+
io.stdout(CLI_VERSION);
|
|
198
|
+
return EXIT_CODES.SUCCESS;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
switch (verb) {
|
|
202
|
+
case "turn":
|
|
203
|
+
return handleTurn(rest, io);
|
|
204
|
+
case "text":
|
|
205
|
+
return handleText(rest, io);
|
|
206
|
+
case "doctor":
|
|
207
|
+
return handleDoctor(rest, io);
|
|
208
|
+
case "console":
|
|
209
|
+
case "chat":
|
|
210
|
+
case "listen":
|
|
211
|
+
return reportError(
|
|
212
|
+
io,
|
|
213
|
+
looksLikeJsonRequested(rest),
|
|
214
|
+
verb,
|
|
215
|
+
new CliError(EXIT_CODES.USAGE, `"${verb}" is not a syrinx command — this CLI is non-interactive by design (see --help). Use the Studio for that.`),
|
|
216
|
+
);
|
|
217
|
+
default:
|
|
218
|
+
return reportError(
|
|
219
|
+
io,
|
|
220
|
+
looksLikeJsonRequested(rest),
|
|
221
|
+
verb,
|
|
222
|
+
new CliError(EXIT_CODES.USAGE, `unknown command: ${verb} (see --help)`),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { runDoctor } from "./doctor.js";
|
|
9
|
+
|
|
10
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const FIXTURES = join(HERE, "__fixtures__");
|
|
12
|
+
|
|
13
|
+
describe("runDoctor", () => {
|
|
14
|
+
it("never echoes key values, only presence", async () => {
|
|
15
|
+
const report = await runDoctor({
|
|
16
|
+
cwd: HERE,
|
|
17
|
+
env: { OPENAI_API_KEY: "sk-super-secret-value", DEEPGRAM_API_KEY: undefined },
|
|
18
|
+
});
|
|
19
|
+
expect(JSON.stringify(report)).not.toContain("sk-super-secret-value");
|
|
20
|
+
expect(report.wellKnownProviderKeys.OPENAI_API_KEY).toBe(true);
|
|
21
|
+
expect(report.wellKnownProviderKeys.DEEPGRAM_API_KEY).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("treats a blank/whitespace-only key as absent", async () => {
|
|
25
|
+
const report = await runDoctor({ cwd: HERE, env: { OPENAI_API_KEY: " " } });
|
|
26
|
+
expect(report.wellKnownProviderKeys.OPENAI_API_KEY).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("reports the installed @kuralle-syrinx/core version from this workspace", async () => {
|
|
30
|
+
const report = await runDoctor({ cwd: HERE, env: {} });
|
|
31
|
+
expect(report.core.resolved).toBe(true);
|
|
32
|
+
expect(report.core.majorMismatch).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("without --agent, reports no fixed provider requirement — just informational keys", async () => {
|
|
36
|
+
const report = await runDoctor({ cwd: HERE, env: {} });
|
|
37
|
+
expect(report.agent).toBeNull();
|
|
38
|
+
expect(report.summary).not.toMatch(/deepgram|cartesia|openai/i);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("with --agent, resolves and reports the specific agent instead of a hardcoded set", async () => {
|
|
42
|
+
const ok = await runDoctor({ cwd: HERE, env: {}, agentSpec: `${join(FIXTURES, "good-text-agent.mjs")}#createSession` });
|
|
43
|
+
expect(ok.agent?.resolved).toBe(true);
|
|
44
|
+
expect(ok.agent?.label).toContain("createSession");
|
|
45
|
+
|
|
46
|
+
const bad = await runDoctor({ cwd: HERE, env: {}, agentSpec: join(FIXTURES, "does-not-exist.mjs") });
|
|
47
|
+
expect(bad.agent?.resolved).toBe(false);
|
|
48
|
+
expect(bad.agent?.error).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
});
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// `syrinx doctor` — reports what is configured and what is missing. Never prints
|
|
4
|
+
// key values, only presence. Always exits SUCCESS: this verb diagnoses, it does
|
|
5
|
+
// not assert.
|
|
6
|
+
//
|
|
7
|
+
// This CLI has no fixed provider set — it never constructs an STT/TTS/reasoner
|
|
8
|
+
// itself (see --agent, agent-resolve.ts). So doctor cannot claim "ready for
|
|
9
|
+
// turn"/"ready for text" against a hardcoded key list; it reports well-known
|
|
10
|
+
// provider keys as informational only, and — when given --agent — reports
|
|
11
|
+
// whether that specific agent module resolves.
|
|
12
|
+
|
|
13
|
+
import { resolveAgentFactory } from "./agent-resolve.js";
|
|
14
|
+
import { CliError } from "./exit-codes.js";
|
|
15
|
+
import { CLI_VERSION, CORE_PACKAGE_NAME, checkCoreVersionSkew, majorOf } from "./version.js";
|
|
16
|
+
|
|
17
|
+
/** Commonly used in the Syrinx ecosystem — shown for convenience only. The CLI itself does not require any of these; whatever --agent resolves to owns its own provider requirements. */
|
|
18
|
+
export const WELL_KNOWN_PROVIDER_KEYS = ["DEEPGRAM_API_KEY", "OPENAI_API_KEY", "CARTESIA_API_KEY", "GEMINI_API_KEY", "ELEVENLABS_API_KEY", "GROK_API_KEY"] as const;
|
|
19
|
+
|
|
20
|
+
export type WellKnownProviderKey = (typeof WELL_KNOWN_PROVIDER_KEYS)[number];
|
|
21
|
+
|
|
22
|
+
export interface DoctorAgentReport {
|
|
23
|
+
readonly spec: string;
|
|
24
|
+
readonly resolved: boolean;
|
|
25
|
+
readonly label?: string;
|
|
26
|
+
readonly error?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DoctorReport {
|
|
30
|
+
readonly cliVersion: string;
|
|
31
|
+
readonly node: { readonly version: string; readonly platform: string };
|
|
32
|
+
readonly core: {
|
|
33
|
+
readonly package: string;
|
|
34
|
+
readonly resolved: boolean;
|
|
35
|
+
readonly version: string | undefined;
|
|
36
|
+
readonly cliMajor: string;
|
|
37
|
+
readonly coreMajor: string | undefined;
|
|
38
|
+
readonly majorMismatch: boolean;
|
|
39
|
+
};
|
|
40
|
+
/** Informational only — presence, never the value. This CLI does not require any specific set; see `agent`. */
|
|
41
|
+
readonly wellKnownProviderKeys: Readonly<Record<WellKnownProviderKey, boolean>>;
|
|
42
|
+
readonly agent: DoctorAgentReport | null;
|
|
43
|
+
readonly summary: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RunDoctorOptions {
|
|
47
|
+
readonly cwd?: string;
|
|
48
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
49
|
+
readonly nodeVersion?: string;
|
|
50
|
+
readonly platform?: string;
|
|
51
|
+
/** `--agent <module>#<export>` — when given, doctor resolves (imports + finds the callable export) but does not invoke it, so checking doesn't require live provider access. */
|
|
52
|
+
readonly agentSpec?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runDoctor(opts: RunDoctorOptions = {}): Promise<DoctorReport> {
|
|
56
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
57
|
+
const env = opts.env ?? process.env;
|
|
58
|
+
const skew = checkCoreVersionSkew(cwd);
|
|
59
|
+
|
|
60
|
+
const wellKnownProviderKeys = Object.fromEntries(
|
|
61
|
+
WELL_KNOWN_PROVIDER_KEYS.map((key) => [key, Boolean(env[key]?.trim())]),
|
|
62
|
+
) as Record<WellKnownProviderKey, boolean>;
|
|
63
|
+
|
|
64
|
+
let agent: DoctorAgentReport | null = null;
|
|
65
|
+
if (opts.agentSpec) {
|
|
66
|
+
try {
|
|
67
|
+
const resolved = await resolveAgentFactory(opts.agentSpec, cwd);
|
|
68
|
+
agent = { spec: opts.agentSpec, resolved: true, label: resolved.label };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
agent = {
|
|
71
|
+
spec: opts.agentSpec,
|
|
72
|
+
resolved: false,
|
|
73
|
+
error: err instanceof CliError ? err.message : err instanceof Error ? err.message : String(err),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const summary = !skew.coreResolved
|
|
79
|
+
? `${CORE_PACKAGE_NAME} is not installed in this project`
|
|
80
|
+
: agent
|
|
81
|
+
? agent.resolved
|
|
82
|
+
? `agent resolved: ${agent.label}`
|
|
83
|
+
: `agent could not be resolved: ${agent.error}`
|
|
84
|
+
: "pass --agent <module>#<export> to check whether a specific agent resolves; the provider keys above are informational only — this CLI does not require any specific set";
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
cliVersion: CLI_VERSION,
|
|
88
|
+
node: { version: opts.nodeVersion ?? process.version, platform: opts.platform ?? process.platform },
|
|
89
|
+
core: {
|
|
90
|
+
package: CORE_PACKAGE_NAME,
|
|
91
|
+
resolved: skew.coreResolved,
|
|
92
|
+
version: skew.coreVersion,
|
|
93
|
+
cliMajor: majorOf(CLI_VERSION),
|
|
94
|
+
coreMajor: skew.coreVersion ? majorOf(skew.coreVersion) : undefined,
|
|
95
|
+
majorMismatch: skew.mismatch,
|
|
96
|
+
},
|
|
97
|
+
wellKnownProviderKeys,
|
|
98
|
+
agent,
|
|
99
|
+
summary,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// The exit-code contract an agent depends on. Every verb resolves to exactly one
|
|
4
|
+
// of these — never collapsed to a bare 1 for "something went wrong". Documented
|
|
5
|
+
// here once; `--help` and the README quote this table verbatim.
|
|
6
|
+
|
|
7
|
+
export const EXIT_CODES = {
|
|
8
|
+
SUCCESS: 0,
|
|
9
|
+
INTERNAL: 1,
|
|
10
|
+
USAGE: 2,
|
|
11
|
+
CONFIG: 3,
|
|
12
|
+
BACKEND: 4,
|
|
13
|
+
ASSERTION: 5,
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES];
|
|
17
|
+
|
|
18
|
+
export const EXIT_CODE_TABLE: ReadonlyArray<{ readonly code: ExitCode; readonly name: string; readonly meaning: string }> = [
|
|
19
|
+
{ code: EXIT_CODES.SUCCESS, name: "SUCCESS", meaning: "the command completed and, where applicable, any assertion matched" },
|
|
20
|
+
{ code: EXIT_CODES.INTERNAL, name: "INTERNAL", meaning: "an unexpected error inside the CLI itself (not a usage or backend problem) — treat as a bug" },
|
|
21
|
+
{ code: EXIT_CODES.USAGE, name: "USAGE", meaning: "bad invocation: unknown verb/flag, a missing required argument, or a fixture the CLI cannot honour (unsupported format, capture-config mismatch)" },
|
|
22
|
+
{ code: EXIT_CODES.CONFIG, name: "CONFIG", meaning: "required provider configuration or API keys are missing" },
|
|
23
|
+
{ code: EXIT_CODES.BACKEND, name: "BACKEND", meaning: "the agent or backend failed while running the turn (provider/network error, timeout, pipeline error)" },
|
|
24
|
+
{ code: EXIT_CODES.ASSERTION, name: "ASSERTION", meaning: "a replayed fixture's transcript drifted from the expected transcript" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** A typed error carrying the exit code it should terminate the process with. */
|
|
28
|
+
export class CliError extends Error {
|
|
29
|
+
readonly exitCode: ExitCode;
|
|
30
|
+
readonly details: Record<string, unknown> | undefined;
|
|
31
|
+
|
|
32
|
+
constructor(exitCode: ExitCode, message: string, details?: Record<string, unknown>) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "CliError";
|
|
35
|
+
this.exitCode = exitCode;
|
|
36
|
+
this.details = details;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { CliError, EXIT_CODES } from "./exit-codes.js";
|
|
10
|
+
import { assertTranscript, loadFixture, normalizeTranscript } from "./fixture.js";
|
|
11
|
+
|
|
12
|
+
describe("normalizeTranscript / assertTranscript", () => {
|
|
13
|
+
it("ignores case, punctuation, and extra whitespace", () => {
|
|
14
|
+
expect(normalizeTranscript("Hello, World!")).toBe("hello world");
|
|
15
|
+
expect(assertTranscript("Hello, World!", "hello world").match).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("flags a real drift", () => {
|
|
19
|
+
const a = assertTranscript("book a flight to paris", "book a flight to london");
|
|
20
|
+
expect(a.match).toBe(false);
|
|
21
|
+
expect(a.expectedTranscript).toBe("book a flight to paris");
|
|
22
|
+
expect(a.actualTranscript).toBe("book a flight to london");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("loadFixture", () => {
|
|
27
|
+
let root: string;
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
root = await mkdtemp(join(tmpdir(), "syrinx-cli-fixture-"));
|
|
30
|
+
});
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
await rm(root, { recursive: true, maxRetries: 3, force: true }).catch(() => {});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("loads a well-formed sidecar and prefers a sibling captured.wav", async () => {
|
|
36
|
+
const sidecarPath = join(root, "turn.json");
|
|
37
|
+
await writeFile(
|
|
38
|
+
sidecarPath,
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
format: "syrinx.fixture.v1",
|
|
41
|
+
audioFile: "turn.wav",
|
|
42
|
+
audio: { sampleRateHz: 16000, channels: 1, encoding: "pcm_s16le" },
|
|
43
|
+
expectedTranscript: "hi there",
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
await writeFile(join(root, "captured.wav"), Buffer.from([1, 2, 3]));
|
|
47
|
+
|
|
48
|
+
const loaded = loadFixture(sidecarPath);
|
|
49
|
+
expect(loaded.sidecar.expectedTranscript).toBe("hi there");
|
|
50
|
+
expect(loaded.wavPath).toBe(join(root, "captured.wav"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("falls back to audioFile when no sibling captured.wav exists", async () => {
|
|
54
|
+
const sidecarPath = join(root, "turn.json");
|
|
55
|
+
await writeFile(
|
|
56
|
+
sidecarPath,
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
format: "syrinx.fixture.v1",
|
|
59
|
+
audioFile: "turn.wav",
|
|
60
|
+
audio: { sampleRateHz: 16000, channels: 1, encoding: "pcm_s16le" },
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
const loaded = loadFixture(sidecarPath);
|
|
64
|
+
expect(loaded.wavPath).toBe(join(root, "turn.wav"));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("refuses an unrecognised format", async () => {
|
|
68
|
+
const sidecarPath = join(root, "turn.json");
|
|
69
|
+
await writeFile(
|
|
70
|
+
sidecarPath,
|
|
71
|
+
JSON.stringify({ format: "other.v1", audioFile: "turn.wav", audio: { sampleRateHz: 16000, channels: 1, encoding: "pcm_s16le" } }),
|
|
72
|
+
);
|
|
73
|
+
try {
|
|
74
|
+
loadFixture(sidecarPath);
|
|
75
|
+
expect.unreachable();
|
|
76
|
+
} catch (err) {
|
|
77
|
+
expect(err).toBeInstanceOf(CliError);
|
|
78
|
+
expect((err as CliError).exitCode).toBe(EXIT_CODES.USAGE);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("refuses a capture config it cannot honestly replay (stereo / non-16kHz)", async () => {
|
|
83
|
+
const sidecarPath = join(root, "turn.json");
|
|
84
|
+
await writeFile(
|
|
85
|
+
sidecarPath,
|
|
86
|
+
JSON.stringify({ format: "syrinx.fixture.v1", audioFile: "turn.wav", audio: { sampleRateHz: 16000, channels: 2, encoding: "pcm_s16le" } }),
|
|
87
|
+
);
|
|
88
|
+
expect(() => loadFixture(sidecarPath)).toThrow(CliError);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("refuses malformed JSON", async () => {
|
|
92
|
+
const sidecarPath = join(root, "turn.json");
|
|
93
|
+
await writeFile(sidecarPath, "{not json");
|
|
94
|
+
expect(() => loadFixture(sidecarPath)).toThrow(CliError);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("refuses a missing file", () => {
|
|
98
|
+
expect(() => loadFixture(join(root, "nope.json"))).toThrow(CliError);
|
|
99
|
+
});
|
|
100
|
+
});
|