@mafdet/cli 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/README.zh-CN.md +148 -0
- package/dist/agent/approvals.d.ts +38 -0
- package/dist/agent/approvals.d.ts.map +1 -0
- package/dist/agent/approvals.js +53 -0
- package/dist/agent/command-policy.d.ts +36 -0
- package/dist/agent/command-policy.d.ts.map +1 -0
- package/dist/agent/command-policy.js +133 -0
- package/dist/agent/loop.d.ts +107 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/loop.js +242 -0
- package/dist/agent/session.d.ts +62 -0
- package/dist/agent/session.d.ts.map +1 -0
- package/dist/agent/session.js +290 -0
- package/dist/agent/tools/read-only.d.ts +15 -0
- package/dist/agent/tools/read-only.d.ts.map +1 -0
- package/dist/agent/tools/read-only.js +254 -0
- package/dist/agent/tools/run-command.d.ts +20 -0
- package/dist/agent/tools/run-command.d.ts.map +1 -0
- package/dist/agent/tools/run-command.js +116 -0
- package/dist/agent/tools/types.d.ts +46 -0
- package/dist/agent/tools/types.d.ts.map +1 -0
- package/dist/agent/tools/types.js +16 -0
- package/dist/agent/tools/write.d.ts +3 -0
- package/dist/agent/tools/write.d.ts.map +1 -0
- package/dist/agent/tools/write.js +68 -0
- package/dist/api/account.d.ts +39 -0
- package/dist/api/account.d.ts.map +1 -0
- package/dist/api/account.js +39 -0
- package/dist/api/errors.d.ts +38 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +149 -0
- package/dist/api/http.d.ts +63 -0
- package/dist/api/http.d.ts.map +1 -0
- package/dist/api/http.js +207 -0
- package/dist/api/models.d.ts +68 -0
- package/dist/api/models.d.ts.map +1 -0
- package/dist/api/models.js +91 -0
- package/dist/api/sse-parser.d.ts +72 -0
- package/dist/api/sse-parser.d.ts.map +1 -0
- package/dist/api/sse-parser.js +163 -0
- package/dist/commands/account.d.ts +4 -0
- package/dist/commands/account.d.ts.map +1 -0
- package/dist/commands/account.js +103 -0
- package/dist/commands/agent.d.ts +10 -0
- package/dist/commands/agent.d.ts.map +1 -0
- package/dist/commands/agent.js +268 -0
- package/dist/commands/chat.d.ts +13 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/chat.js +191 -0
- package/dist/commands/config.d.ts +15 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +74 -0
- package/dist/commands/doctor.d.ts +15 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +157 -0
- package/dist/commands/embed.d.ts +7 -0
- package/dist/commands/embed.d.ts.map +1 -0
- package/dist/commands/embed.js +105 -0
- package/dist/commands/models.d.ts +4 -0
- package/dist/commands/models.d.ts.map +1 -0
- package/dist/commands/models.js +151 -0
- package/dist/commands/runtime.d.ts +29 -0
- package/dist/commands/runtime.d.ts.map +1 -0
- package/dist/commands/runtime.js +56 -0
- package/dist/commands/speech.d.ts +7 -0
- package/dist/commands/speech.d.ts.map +1 -0
- package/dist/commands/speech.js +111 -0
- package/dist/config/config.d.ts +57 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +129 -0
- package/dist/config/load.d.ts +20 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/load.js +48 -0
- package/dist/config/paths.d.ts +11 -0
- package/dist/config/paths.d.ts.map +1 -0
- package/dist/config/paths.js +26 -0
- package/dist/filesystem/diff.d.ts +12 -0
- package/dist/filesystem/diff.d.ts.map +1 -0
- package/dist/filesystem/diff.js +109 -0
- package/dist/filesystem/sensitive-files.d.ts +19 -0
- package/dist/filesystem/sensitive-files.d.ts.map +1 -0
- package/dist/filesystem/sensitive-files.js +46 -0
- package/dist/filesystem/workspace.d.ts +92 -0
- package/dist/filesystem/workspace.d.ts.map +1 -0
- package/dist/filesystem/workspace.js +264 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/output/exit-codes.d.ts +25 -0
- package/dist/output/exit-codes.d.ts.map +1 -0
- package/dist/output/exit-codes.js +23 -0
- package/dist/output/format.d.ts +28 -0
- package/dist/output/format.d.ts.map +1 -0
- package/dist/output/format.js +56 -0
- package/dist/output/money.d.ts +16 -0
- package/dist/output/money.d.ts.map +1 -0
- package/dist/output/money.js +35 -0
- package/dist/output/write-file.d.ts +11 -0
- package/dist/output/write-file.d.ts.map +1 -0
- package/dist/output/write-file.js +38 -0
- package/dist/security/redact.d.ts +16 -0
- package/dist/security/redact.d.ts.map +1 -0
- package/dist/security/redact.js +61 -0
- package/package.json +51 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { fetchSubscription, fetchUsage, fetchWallet } from "../api/account.js";
|
|
2
|
+
import { ExitCode } from "../output/exit-codes.js";
|
|
3
|
+
import { parseFormatFlags, renderJson, renderRecord, } from "../output/format.js";
|
|
4
|
+
import { formatUsd } from "../output/money.js";
|
|
5
|
+
import { buildRuntime, reportError, requireApiKey, } from "./runtime.js";
|
|
6
|
+
const USAGE = `Usage:
|
|
7
|
+
mafdet account balance [--json]
|
|
8
|
+
mafdet account usage [--days <n>] [--json]
|
|
9
|
+
mafdet account subscription [--json]
|
|
10
|
+
|
|
11
|
+
Requires an API key with the 'account:read' scope.
|
|
12
|
+
`;
|
|
13
|
+
export async function accountCommand(argv, io) {
|
|
14
|
+
const [subcommand, ...rest] = argv;
|
|
15
|
+
if (!["balance", "usage", "subscription"].includes(subcommand ?? "")) {
|
|
16
|
+
io.stderr(subcommand === undefined
|
|
17
|
+
? USAGE
|
|
18
|
+
: `mafdet account: unknown subcommand '${subcommand}'\n${USAGE}`);
|
|
19
|
+
return ExitCode.Usage;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const { format, rest: args } = parseFormatFlags(rest);
|
|
23
|
+
const runtime = buildRuntime(io);
|
|
24
|
+
requireApiKey(runtime.config);
|
|
25
|
+
if (subcommand === "balance") {
|
|
26
|
+
if (args.length > 0)
|
|
27
|
+
return badArg(args[0], io);
|
|
28
|
+
const wallet = await fetchWallet(runtime.accountPlane);
|
|
29
|
+
if (format === "json") {
|
|
30
|
+
io.stdout(renderJson(wallet));
|
|
31
|
+
return ExitCode.Ok;
|
|
32
|
+
}
|
|
33
|
+
io.stdout(renderRecord([
|
|
34
|
+
["balance", formatUsd(wallet.balance_micro_usd)],
|
|
35
|
+
["bonus", formatUsd(wallet.bonus_micro_usd)],
|
|
36
|
+
["frozen", formatUsd(wallet.frozen_micro_usd)],
|
|
37
|
+
["available", formatUsd(wallet.available_micro_usd)],
|
|
38
|
+
]));
|
|
39
|
+
return ExitCode.Ok;
|
|
40
|
+
}
|
|
41
|
+
if (subcommand === "usage") {
|
|
42
|
+
const daysAt = args.indexOf("--days");
|
|
43
|
+
let days;
|
|
44
|
+
if (daysAt !== -1) {
|
|
45
|
+
const raw = args[daysAt + 1];
|
|
46
|
+
days = Number(raw);
|
|
47
|
+
if (!Number.isInteger(days) || days <= 0) {
|
|
48
|
+
io.stderr(`mafdet account usage: --days needs a positive integer, got '${raw ?? ""}'\n`);
|
|
49
|
+
return ExitCode.Usage;
|
|
50
|
+
}
|
|
51
|
+
args.splice(daysAt, 2);
|
|
52
|
+
}
|
|
53
|
+
if (args.length > 0)
|
|
54
|
+
return badArg(args[0], io);
|
|
55
|
+
const usage = await fetchUsage(runtime.accountPlane, days);
|
|
56
|
+
if (format === "json") {
|
|
57
|
+
io.stdout(renderJson(usage));
|
|
58
|
+
return ExitCode.Ok;
|
|
59
|
+
}
|
|
60
|
+
io.stdout(renderRecord([
|
|
61
|
+
["window", `${usage.days} day(s), ${usage.from} -> ${usage.to}`],
|
|
62
|
+
// Throttled attempts land in the ledger with a zero charge
|
|
63
|
+
// (CLI-001), so the count and the money answer different questions.
|
|
64
|
+
[
|
|
65
|
+
"requests",
|
|
66
|
+
`${usage.count} (includes throttled attempts, which cost nothing)`,
|
|
67
|
+
],
|
|
68
|
+
["input tokens", String(usage.input_tokens)],
|
|
69
|
+
["output tokens", String(usage.output_tokens)],
|
|
70
|
+
["charged", formatUsd(usage.charge_micro_usd)],
|
|
71
|
+
]));
|
|
72
|
+
return ExitCode.Ok;
|
|
73
|
+
}
|
|
74
|
+
if (args.length > 0)
|
|
75
|
+
return badArg(args[0], io);
|
|
76
|
+
const subscription = await fetchSubscription(runtime.accountPlane);
|
|
77
|
+
if (format === "json") {
|
|
78
|
+
io.stdout(renderJson(subscription));
|
|
79
|
+
return ExitCode.Ok;
|
|
80
|
+
}
|
|
81
|
+
const rows = [
|
|
82
|
+
["plan", subscription.plan_name ?? subscription.plan_slug ?? "-"],
|
|
83
|
+
["status", subscription.status],
|
|
84
|
+
["renews", subscription.current_period_end ?? "-"],
|
|
85
|
+
];
|
|
86
|
+
if (subscription.playground) {
|
|
87
|
+
const pg = subscription.playground;
|
|
88
|
+
rows.push(["playground allowance", formatUsd(pg.allowance_micro_usd)], [
|
|
89
|
+
"playground used",
|
|
90
|
+
`${formatUsd(pg.used_micro_usd)} (${pg.usage_percent}%)`,
|
|
91
|
+
], ["playground left", formatUsd(pg.remaining_micro_usd)]);
|
|
92
|
+
}
|
|
93
|
+
io.stdout(renderRecord(rows));
|
|
94
|
+
return ExitCode.Ok;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
return reportError(error, io);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function badArg(arg, io) {
|
|
101
|
+
io.stderr(`mafdet account: unexpected argument '${arg}'\n${USAGE}`);
|
|
102
|
+
return ExitCode.Usage;
|
|
103
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ExitCode } from "../output/exit-codes.js";
|
|
2
|
+
import { type CommandIo } from "./runtime.js";
|
|
3
|
+
export interface AgentIo extends CommandIo {
|
|
4
|
+
signal?: AbortSignal;
|
|
5
|
+
/** Overridden in tests; production reads the TTY. */
|
|
6
|
+
isInteractive?: boolean;
|
|
7
|
+
approver?: import("../agent/approvals.js").Approver;
|
|
8
|
+
}
|
|
9
|
+
export declare function agentCommand(argv: readonly string[], io: AgentIo): Promise<ExitCode>;
|
|
10
|
+
//# sourceMappingURL=agent.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/commands/agent.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAGnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AA6BtB,MAAM,WAAW,OAAQ,SAAQ,SAAS;IACxC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,qDAAqD;IACrD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,uBAAuB,EAAE,QAAQ,CAAC;CACrD;AAED,wBAAsB,YAAY,CAChC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,OAAO,GACV,OAAO,CAAC,QAAQ,CAAC,CA+GnB"}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { fetchAccessibleModels, fetchCatalog, intersectModels, } from "../api/models.js";
|
|
2
|
+
import { approverFor } from "../agent/approvals.js";
|
|
3
|
+
import { runAgent } from "../agent/loop.js";
|
|
4
|
+
import { runSession } from "../agent/session.js";
|
|
5
|
+
import { READ_ONLY_TOOLS } from "../agent/tools/read-only.js";
|
|
6
|
+
import { COMMAND_TOOLS } from "../agent/tools/run-command.js";
|
|
7
|
+
import { WRITE_TOOLS } from "../agent/tools/write.js";
|
|
8
|
+
import { Workspace, WorkspaceError } from "../filesystem/workspace.js";
|
|
9
|
+
import { ExitCode } from "../output/exit-codes.js";
|
|
10
|
+
import { renderJson, renderRecord } from "../output/format.js";
|
|
11
|
+
import { formatUsd } from "../output/money.js";
|
|
12
|
+
import { buildRuntime, reportError, requireApiKey, } from "./runtime.js";
|
|
13
|
+
const USAGE = `Usage:
|
|
14
|
+
mafdet agent "<goal>" [--model <id>] [--allow-write]
|
|
15
|
+
[--allow-command "<prefix>"]... [--max-turns <n>]
|
|
16
|
+
[--max-cost-usd <n>] [--json]
|
|
17
|
+
|
|
18
|
+
Read-only by default. With --allow-write the agent may edit and create files,
|
|
19
|
+
and every change is shown as a diff and confirmed before it is written. There
|
|
20
|
+
is no flag that approves everything in advance, and a non-interactive run
|
|
21
|
+
cannot write at all.
|
|
22
|
+
`;
|
|
23
|
+
const READ_ONLY_PROMPT = `You are the Mafdet CLI agent working inside a user's workspace.
|
|
24
|
+
|
|
25
|
+
Use the provided tools to inspect the workspace. You cannot modify anything:
|
|
26
|
+
there are no write or shell tools, so do not claim to have changed a file.
|
|
27
|
+
Read before you conclude - never guess a file's contents. When you have the
|
|
28
|
+
answer, reply with it directly instead of calling another tool. Be concise.`;
|
|
29
|
+
const WRITE_PROMPT = `You are the Mafdet CLI agent working inside a user's workspace.
|
|
30
|
+
|
|
31
|
+
Inspect with the read tools, and change files with edit_file and create_file.
|
|
32
|
+
Always read a file before editing it, and quote old_text exactly as it appears,
|
|
33
|
+
with enough surrounding lines to be unique - a near miss is refused rather than
|
|
34
|
+
guessed at. Every change is shown to the user and applied only if they accept
|
|
35
|
+
it; if one is declined, do not retry the same change. There is no shell, so do
|
|
36
|
+
not claim to have run anything. Be concise.`;
|
|
37
|
+
export async function agentCommand(argv, io) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = parseArgs(argv, io);
|
|
40
|
+
if (typeof parsed === "number")
|
|
41
|
+
return parsed;
|
|
42
|
+
const runtime = buildRuntime(io);
|
|
43
|
+
requireApiKey(runtime.config);
|
|
44
|
+
const model = parsed.model ?? runtime.config.model;
|
|
45
|
+
if (!model) {
|
|
46
|
+
io.stderr("mafdet agent: no model. Pass --model or set MAFDET_MODEL.\n");
|
|
47
|
+
return ExitCode.Usage;
|
|
48
|
+
}
|
|
49
|
+
const [accessible, catalog] = await Promise.all([
|
|
50
|
+
fetchAccessibleModels(runtime.dataPlane),
|
|
51
|
+
fetchCatalog(runtime.accountPlane),
|
|
52
|
+
]);
|
|
53
|
+
const resolved = intersectModels(accessible, catalog).available.find((candidate) => candidate.modelId === model);
|
|
54
|
+
// Admission is the catalogue's call, and it fails closed: a model whose
|
|
55
|
+
// tool calling has not been verified end to end is refused rather than
|
|
56
|
+
// tried hopefully (plan §7.3, CLI-000C).
|
|
57
|
+
if (!resolved?.agentEligible) {
|
|
58
|
+
io.stderr(`mafdet agent: ${model} is not verified for tool calling, so the agent will not use it.\n` +
|
|
59
|
+
`Run 'mafdet models list --agent' to see the models that are.\n`);
|
|
60
|
+
return ExitCode.Usage;
|
|
61
|
+
}
|
|
62
|
+
const workspace = Workspace.open(io.cwd ?? process.cwd());
|
|
63
|
+
// Plan §8.1: writes need an explicit flag AND a terminal to confirm in.
|
|
64
|
+
// A pipeline that asked for --allow-write still gets read-only, because
|
|
65
|
+
// there is nobody there to see the diff.
|
|
66
|
+
const interactive = io.isInteractive ?? process.stdin.isTTY === true;
|
|
67
|
+
const mode = parsed.allowWrite && interactive ? "suggest" : "read-only";
|
|
68
|
+
if (parsed.allowWrite && !interactive) {
|
|
69
|
+
io.stderr("note: --allow-write needs an interactive terminal to confirm changes; running read-only\n");
|
|
70
|
+
}
|
|
71
|
+
io.stderr(`workspace: ${workspace.root}\nmodel: ${model} (${mode})\n`);
|
|
72
|
+
if (parsed.goal.length === 0) {
|
|
73
|
+
if (!interactive) {
|
|
74
|
+
io.stderr(`mafdet agent: no goal\n${USAGE}`);
|
|
75
|
+
return ExitCode.Usage;
|
|
76
|
+
}
|
|
77
|
+
const summary = await runSession({
|
|
78
|
+
client: runtime.dataPlane,
|
|
79
|
+
model,
|
|
80
|
+
pricing: resolved,
|
|
81
|
+
catalog,
|
|
82
|
+
systemPrompt: mode === "read-only" ? READ_ONLY_PROMPT : WRITE_PROMPT,
|
|
83
|
+
tools: buildToolset(mode, parsed.allowedCommands.length > 0),
|
|
84
|
+
toolContext: { workspace, allowedCommands: parsed.allowedCommands },
|
|
85
|
+
approver: io.approver ?? approverFor(mode, io, interactive),
|
|
86
|
+
mode,
|
|
87
|
+
allowedCommands: parsed.allowedCommands,
|
|
88
|
+
stdout: io.stdout,
|
|
89
|
+
stderr: io.stderr,
|
|
90
|
+
signal: io.signal,
|
|
91
|
+
});
|
|
92
|
+
io.stderr("\n" +
|
|
93
|
+
renderRecord([
|
|
94
|
+
["goals", `${summary.goals}`],
|
|
95
|
+
["model requests", `${summary.modelRequests}`],
|
|
96
|
+
["tool runs", `${summary.toolRuns}`],
|
|
97
|
+
[
|
|
98
|
+
"tokens",
|
|
99
|
+
`${summary.promptTokens} in / ${summary.completionTokens} out`,
|
|
100
|
+
],
|
|
101
|
+
["cost", `~${formatUsd(summary.costMicroUsd)}`],
|
|
102
|
+
]));
|
|
103
|
+
return ExitCode.Ok;
|
|
104
|
+
}
|
|
105
|
+
const report = await runAgent({
|
|
106
|
+
client: runtime.dataPlane,
|
|
107
|
+
model,
|
|
108
|
+
pricing: resolved,
|
|
109
|
+
goal: parsed.goal,
|
|
110
|
+
systemPrompt: mode === "read-only" ? READ_ONLY_PROMPT : WRITE_PROMPT,
|
|
111
|
+
tools: buildToolset(mode, parsed.allowedCommands.length > 0),
|
|
112
|
+
approver: io.approver ?? approverFor(mode, io, interactive),
|
|
113
|
+
toolContext: { workspace, allowedCommands: parsed.allowedCommands },
|
|
114
|
+
limits: {
|
|
115
|
+
...(parsed.maxTurns === undefined ? {} : { maxTurns: parsed.maxTurns }),
|
|
116
|
+
...(parsed.maxCostMicroUsd === undefined
|
|
117
|
+
? {}
|
|
118
|
+
: { maxCostMicroUsd: parsed.maxCostMicroUsd }),
|
|
119
|
+
},
|
|
120
|
+
signal: io.signal,
|
|
121
|
+
onEvent: (event) => reportEvent(event, io),
|
|
122
|
+
});
|
|
123
|
+
return finish(report, parsed.json, io);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error instanceof WorkspaceError) {
|
|
127
|
+
io.stderr(`mafdet agent: ${error.message}\n`);
|
|
128
|
+
return ExitCode.LocalDenied;
|
|
129
|
+
}
|
|
130
|
+
return reportError(error, io);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function parseArgs(argv, io) {
|
|
134
|
+
const positional = [];
|
|
135
|
+
let model;
|
|
136
|
+
let maxTurns;
|
|
137
|
+
let maxCostMicroUsd;
|
|
138
|
+
let json = false;
|
|
139
|
+
let allowWrite = false;
|
|
140
|
+
const allowedCommands = [];
|
|
141
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
142
|
+
const arg = argv[i];
|
|
143
|
+
const next = () => argv[(i += 1)];
|
|
144
|
+
if (arg === "--model")
|
|
145
|
+
model = next();
|
|
146
|
+
else if (arg === "--json")
|
|
147
|
+
json = true;
|
|
148
|
+
else if (arg === "--allow-write")
|
|
149
|
+
allowWrite = true;
|
|
150
|
+
else if (arg === "--allow-command") {
|
|
151
|
+
const entry = next();
|
|
152
|
+
if (!entry) {
|
|
153
|
+
io.stderr("mafdet agent: --allow-command needs a command prefix\n");
|
|
154
|
+
return ExitCode.Usage;
|
|
155
|
+
}
|
|
156
|
+
allowedCommands.push(entry);
|
|
157
|
+
}
|
|
158
|
+
else if (arg === "--read-only")
|
|
159
|
+
allowWrite = false;
|
|
160
|
+
else if (arg === "--max-turns") {
|
|
161
|
+
const raw = next();
|
|
162
|
+
maxTurns = Number(raw);
|
|
163
|
+
if (!Number.isInteger(maxTurns) || maxTurns <= 0) {
|
|
164
|
+
io.stderr(`mafdet agent: --max-turns needs a positive integer, got '${raw ?? ""}'\n`);
|
|
165
|
+
return ExitCode.Usage;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else if (arg === "--max-cost-usd") {
|
|
169
|
+
const raw = next() ?? "";
|
|
170
|
+
// Parsed as a decimal string into integer micro-USD: a float would make
|
|
171
|
+
// the ceiling itself imprecise.
|
|
172
|
+
const match = /^(\d+)(?:\.(\d{1,6}))?$/.exec(raw);
|
|
173
|
+
if (!match) {
|
|
174
|
+
io.stderr(`mafdet agent: --max-cost-usd needs a positive amount, got '${raw}'\n`);
|
|
175
|
+
return ExitCode.Usage;
|
|
176
|
+
}
|
|
177
|
+
maxCostMicroUsd =
|
|
178
|
+
BigInt(match[1]) * 1000000n + BigInt((match[2] ?? "").padEnd(6, "0"));
|
|
179
|
+
if (maxCostMicroUsd <= 0n) {
|
|
180
|
+
io.stderr("mafdet agent: --max-cost-usd must be greater than zero\n");
|
|
181
|
+
return ExitCode.Usage;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else if (arg.startsWith("--")) {
|
|
185
|
+
io.stderr(`mafdet agent: unknown option '${arg}'\n${USAGE}`);
|
|
186
|
+
return ExitCode.Usage;
|
|
187
|
+
}
|
|
188
|
+
else
|
|
189
|
+
positional.push(arg);
|
|
190
|
+
}
|
|
191
|
+
// No goal is not an error when there is a terminal: it starts a session.
|
|
192
|
+
const goal = positional.join(" ").trim();
|
|
193
|
+
return {
|
|
194
|
+
goal,
|
|
195
|
+
allowWrite,
|
|
196
|
+
allowedCommands,
|
|
197
|
+
model,
|
|
198
|
+
maxTurns,
|
|
199
|
+
maxCostMicroUsd,
|
|
200
|
+
json,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A tool the run cannot use is not offered at all. Read-only means the model
|
|
205
|
+
* never sees a write tool, and run_command appears only when the user named at
|
|
206
|
+
* least one allowed command or can approve interactively.
|
|
207
|
+
*/
|
|
208
|
+
function buildToolset(mode, hasAllowedCommands) {
|
|
209
|
+
if (mode === "read-only" && !hasAllowedCommands)
|
|
210
|
+
return READ_ONLY_TOOLS;
|
|
211
|
+
if (mode === "read-only")
|
|
212
|
+
return [...READ_ONLY_TOOLS, ...COMMAND_TOOLS];
|
|
213
|
+
return [...READ_ONLY_TOOLS, ...WRITE_TOOLS, ...COMMAND_TOOLS];
|
|
214
|
+
}
|
|
215
|
+
function reportEvent(event, io) {
|
|
216
|
+
if (event.type === "turn")
|
|
217
|
+
io.stderr(`\n[turn ${event.turn}]\n`);
|
|
218
|
+
else if (event.type === "tool") {
|
|
219
|
+
const first = event.detail.split("\n")[0].slice(0, 100);
|
|
220
|
+
io.stderr(` ${event.ok ? "-" : "!"} ${event.name}: ${first}\n`);
|
|
221
|
+
}
|
|
222
|
+
else if (event.type === "rate_limited") {
|
|
223
|
+
io.stderr(` rate limited, waiting ${event.waitMs}ms (attempt ${event.attempt})\n`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Exit codes follow §4.5: a ceiling is 7, a cancel is 6. */
|
|
227
|
+
function finish(report, json, io) {
|
|
228
|
+
if (json) {
|
|
229
|
+
io.stdout(renderJson({ ...report, costMicroUsd: report.costMicroUsd.toString() }));
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
if (report.finalText)
|
|
233
|
+
io.stdout(`${report.finalText}\n`);
|
|
234
|
+
io.stderr("\n" +
|
|
235
|
+
renderRecord([
|
|
236
|
+
["status", report.stopReason],
|
|
237
|
+
["turns", `${report.turns}`],
|
|
238
|
+
["model requests", `${report.modelRequests}`],
|
|
239
|
+
["tool runs", `${report.toolRuns.length}`],
|
|
240
|
+
[
|
|
241
|
+
"rate limited",
|
|
242
|
+
`${report.rateLimitHits} (waited ${report.totalWaitMs}ms)`,
|
|
243
|
+
],
|
|
244
|
+
[
|
|
245
|
+
"tokens",
|
|
246
|
+
`${report.promptTokens} in / ${report.completionTokens} out`,
|
|
247
|
+
],
|
|
248
|
+
["cost", `~${formatUsd(report.costMicroUsd)}`],
|
|
249
|
+
]));
|
|
250
|
+
if (report.error)
|
|
251
|
+
io.stderr(`error: ${report.error}\n`);
|
|
252
|
+
}
|
|
253
|
+
switch (report.stopReason) {
|
|
254
|
+
case "completed":
|
|
255
|
+
return ExitCode.Ok;
|
|
256
|
+
case "cancelled":
|
|
257
|
+
return ExitCode.Cancelled;
|
|
258
|
+
case "max_turns":
|
|
259
|
+
case "timeout":
|
|
260
|
+
case "max_cost":
|
|
261
|
+
// Stopped short on purpose; the report says so rather than pretending.
|
|
262
|
+
return ExitCode.LimitReached;
|
|
263
|
+
case "rate_limited":
|
|
264
|
+
return ExitCode.Unavailable;
|
|
265
|
+
default:
|
|
266
|
+
return ExitCode.General;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type CatalogModel } from "../api/models.js";
|
|
2
|
+
import { type StreamUsage } from "../api/sse-parser.js";
|
|
3
|
+
import { ExitCode } from "../output/exit-codes.js";
|
|
4
|
+
import { type CommandIo } from "./runtime.js";
|
|
5
|
+
export interface ChatIo extends CommandIo {
|
|
6
|
+
/** Reads stdin when it is piped; tests inject it. */
|
|
7
|
+
readStdin?: () => Promise<string>;
|
|
8
|
+
/** Fires on Ctrl+C; tests drive it directly. */
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
}
|
|
11
|
+
export declare function chatCommand(argv: readonly string[], io: ChatIo): Promise<ExitCode>;
|
|
12
|
+
export declare function estimateChargeMicroUsd(model: CatalogModel | undefined, usage: StreamUsage): bigint | undefined;
|
|
13
|
+
//# sourceMappingURL=chat.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/commands/chat.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACnE,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAExE,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAUtB,MAAM,WAAW,MAAO,SAAQ,SAAS;IACvC,qDAAqD;IACrD,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAClC,gDAAgD;IAChD,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAWD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,QAAQ,CAAC,CA8BnB;AAwLD,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,YAAY,GAAG,SAAS,EAC/B,KAAK,EAAE,WAAW,GACjB,MAAM,GAAG,SAAS,CASpB"}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fetchCatalog } from "../api/models.js";
|
|
3
|
+
import { readChatStream } from "../api/sse-parser.js";
|
|
4
|
+
import { ApiError } from "../api/errors.js";
|
|
5
|
+
import { ExitCode } from "../output/exit-codes.js";
|
|
6
|
+
import { formatUsd } from "../output/money.js";
|
|
7
|
+
import { buildRuntime, reportError, requireApiKey, } from "./runtime.js";
|
|
8
|
+
const USAGE = `Usage:
|
|
9
|
+
mafdet chat [prompt] [--model <id>] [--system <file>] [--input <file>]
|
|
10
|
+
[--no-stream] [--max-tokens <n>] [--quiet]
|
|
11
|
+
|
|
12
|
+
The prompt comes from an argument, from --input <file>, or from stdin when
|
|
13
|
+
neither is given. Use "--input -" to force reading stdin.
|
|
14
|
+
`;
|
|
15
|
+
export async function chatCommand(argv, io) {
|
|
16
|
+
try {
|
|
17
|
+
const runtime = buildRuntime(io);
|
|
18
|
+
requireApiKey(runtime.config);
|
|
19
|
+
const args = await parseArgs(argv, runtime.config.model, io);
|
|
20
|
+
if (typeof args === "number")
|
|
21
|
+
return args;
|
|
22
|
+
const body = {
|
|
23
|
+
model: args.model,
|
|
24
|
+
messages: [
|
|
25
|
+
...(args.system ? [{ role: "system", content: args.system }] : []),
|
|
26
|
+
{ role: "user", content: args.prompt },
|
|
27
|
+
],
|
|
28
|
+
stream: args.stream,
|
|
29
|
+
};
|
|
30
|
+
if (args.maxTokens !== undefined)
|
|
31
|
+
body.max_tokens = args.maxTokens;
|
|
32
|
+
// Ask for usage on the stream too, or the cost summary would be empty.
|
|
33
|
+
if (args.stream)
|
|
34
|
+
body.stream_options = { include_usage: true };
|
|
35
|
+
return args.stream
|
|
36
|
+
? await streamChat(runtime, body, args, io)
|
|
37
|
+
: await completeChat(runtime, body, args, io);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof ApiError && error.kind === "cancelled") {
|
|
41
|
+
io.stderr("\nmafdet: cancelled\n");
|
|
42
|
+
return ExitCode.Cancelled;
|
|
43
|
+
}
|
|
44
|
+
return reportError(error, io);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function parseArgs(argv, configuredModel, io) {
|
|
48
|
+
const positional = [];
|
|
49
|
+
let model = configuredModel;
|
|
50
|
+
let systemFile;
|
|
51
|
+
let inputFile;
|
|
52
|
+
let stream = true;
|
|
53
|
+
let quiet = false;
|
|
54
|
+
let maxTokens;
|
|
55
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
56
|
+
const arg = argv[i];
|
|
57
|
+
const next = () => argv[(i += 1)];
|
|
58
|
+
if (arg === "--model")
|
|
59
|
+
model = next();
|
|
60
|
+
else if (arg === "--system")
|
|
61
|
+
systemFile = next();
|
|
62
|
+
else if (arg === "--input")
|
|
63
|
+
inputFile = next();
|
|
64
|
+
else if (arg === "--no-stream")
|
|
65
|
+
stream = false;
|
|
66
|
+
else if (arg === "--quiet")
|
|
67
|
+
quiet = true;
|
|
68
|
+
else if (arg === "--max-tokens") {
|
|
69
|
+
const raw = next();
|
|
70
|
+
maxTokens = Number(raw);
|
|
71
|
+
if (!Number.isInteger(maxTokens) || maxTokens <= 0) {
|
|
72
|
+
io.stderr(`mafdet chat: --max-tokens needs a positive integer, got '${raw ?? ""}'\n`);
|
|
73
|
+
return ExitCode.Usage;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (arg.startsWith("--")) {
|
|
77
|
+
io.stderr(`mafdet chat: unknown option '${arg}'\n${USAGE}`);
|
|
78
|
+
return ExitCode.Usage;
|
|
79
|
+
}
|
|
80
|
+
else
|
|
81
|
+
positional.push(arg);
|
|
82
|
+
}
|
|
83
|
+
if (!model) {
|
|
84
|
+
io.stderr("mafdet chat: no model. Pass --model, set MAFDET_MODEL, or put one in your config.\n");
|
|
85
|
+
return ExitCode.Usage;
|
|
86
|
+
}
|
|
87
|
+
// Two *explicitly chosen* sources is an error. Stdin is different: it is
|
|
88
|
+
// ambient, and it is not a TTY whenever the command runs from a script, a
|
|
89
|
+
// pipeline or CI — so treating "not a TTY" as "the user piped a prompt"
|
|
90
|
+
// would break `mafdet chat "hello"` everywhere except an interactive
|
|
91
|
+
// terminal. Stdin is read only when nothing else was given, or when asked
|
|
92
|
+
// for explicitly with `--input -`.
|
|
93
|
+
const wantsStdin = inputFile === "-";
|
|
94
|
+
if (positional.length > 0 && inputFile) {
|
|
95
|
+
io.stderr(`mafdet chat: a prompt argument and --input cannot be combined\n${USAGE}`);
|
|
96
|
+
return ExitCode.Usage;
|
|
97
|
+
}
|
|
98
|
+
const stdinAvailable = io.readStdin !== undefined && !process.stdin.isTTY;
|
|
99
|
+
const useStdin = wantsStdin || (positional.length === 0 && !inputFile && stdinAvailable);
|
|
100
|
+
if (positional.length === 0 && !inputFile && !useStdin) {
|
|
101
|
+
io.stderr(`mafdet chat: no prompt\n${USAGE}`);
|
|
102
|
+
return ExitCode.Usage;
|
|
103
|
+
}
|
|
104
|
+
if (wantsStdin && !io.readStdin) {
|
|
105
|
+
io.stderr("mafdet chat: --input - was given but stdin is not readable\n");
|
|
106
|
+
return ExitCode.Usage;
|
|
107
|
+
}
|
|
108
|
+
let prompt;
|
|
109
|
+
if (useStdin)
|
|
110
|
+
prompt = (await io.readStdin()).trim();
|
|
111
|
+
else if (positional.length > 0)
|
|
112
|
+
prompt = positional.join(" ");
|
|
113
|
+
else
|
|
114
|
+
prompt = readTextFile(inputFile, "--input");
|
|
115
|
+
if (prompt.length === 0) {
|
|
116
|
+
io.stderr("mafdet chat: the prompt is empty\n");
|
|
117
|
+
return ExitCode.Usage;
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
prompt,
|
|
121
|
+
model,
|
|
122
|
+
system: systemFile ? readTextFile(systemFile, "--system") : undefined,
|
|
123
|
+
stream,
|
|
124
|
+
maxTokens,
|
|
125
|
+
quiet,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function readTextFile(path, flag) {
|
|
129
|
+
try {
|
|
130
|
+
return readFileSync(path, "utf8");
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
throw new ApiError({
|
|
134
|
+
kind: "request",
|
|
135
|
+
exitCode: ExitCode.Usage,
|
|
136
|
+
message: `${flag}: cannot read ${path}: ${error.message}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function streamChat(runtime, body, args, io) {
|
|
141
|
+
const { response } = await runtime.dataPlane.postStream("/chat/completions", body, {
|
|
142
|
+
signal: io.signal,
|
|
143
|
+
});
|
|
144
|
+
// Text goes to stdout as it arrives; everything else is stderr, so
|
|
145
|
+
// `mafdet chat ... > answer.txt` captures only the answer.
|
|
146
|
+
const result = await readChatStream(response, { onText: io.stdout }, io.signal);
|
|
147
|
+
io.stdout("\n");
|
|
148
|
+
if (io.signal?.aborted) {
|
|
149
|
+
io.stderr("mafdet: cancelled - the connection was closed mid-generation\n");
|
|
150
|
+
await summarise(runtime, args, result.usage, io, { partial: true });
|
|
151
|
+
return ExitCode.Cancelled;
|
|
152
|
+
}
|
|
153
|
+
await summarise(runtime, args, result.usage, io);
|
|
154
|
+
return ExitCode.Ok;
|
|
155
|
+
}
|
|
156
|
+
async function completeChat(runtime, body, args, io) {
|
|
157
|
+
const { data } = await runtime.dataPlane.postJson("/chat/completions", body, { signal: io.signal });
|
|
158
|
+
io.stdout(`${data.choices?.[0]?.message?.content ?? ""}\n`);
|
|
159
|
+
await summarise(runtime, args, data.usage, io);
|
|
160
|
+
return ExitCode.Ok;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Tokens and money, on stderr. The charge is computed with the same arithmetic
|
|
164
|
+
* the gateway bills with — ceil per side, integer micro-USD — which CLI-000B
|
|
165
|
+
* verified against the ledger to the micro-dollar. It is still labelled as an
|
|
166
|
+
* estimate: the wallet, not the client, is the authority.
|
|
167
|
+
*/
|
|
168
|
+
async function summarise(runtime, args, usage, io, options = {}) {
|
|
169
|
+
if (args.quiet || !usage)
|
|
170
|
+
return;
|
|
171
|
+
let cost = "";
|
|
172
|
+
try {
|
|
173
|
+
const catalog = await fetchCatalog(runtime.accountPlane);
|
|
174
|
+
const model = catalog.find((m) => m.modelId === args.model);
|
|
175
|
+
const micros = estimateChargeMicroUsd(model, usage);
|
|
176
|
+
if (micros !== undefined)
|
|
177
|
+
cost = `, ~${formatUsd(micros)}`;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// A pricing lookup must never fail the call that already succeeded.
|
|
181
|
+
}
|
|
182
|
+
const prefix = options.partial ? "partial: " : "";
|
|
183
|
+
io.stderr(`${prefix}${usage.prompt_tokens} in / ${usage.completion_tokens} out tokens${cost}\n`);
|
|
184
|
+
}
|
|
185
|
+
export function estimateChargeMicroUsd(model, usage) {
|
|
186
|
+
if (!model?.inputPricePer1mMicroUsd || !model.outputPricePer1mMicroUsd)
|
|
187
|
+
return undefined;
|
|
188
|
+
const ceilDiv = (tokens, pricePer1m) => (BigInt(tokens) * BigInt(pricePer1m) + 999999n) / 1000000n;
|
|
189
|
+
return (ceilDiv(usage.prompt_tokens, model.inputPricePer1mMicroUsd) +
|
|
190
|
+
ceilDiv(usage.completion_tokens, model.outputPricePer1mMicroUsd));
|
|
191
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ExitCode } from "../output/exit-codes.js";
|
|
2
|
+
export interface CommandIo {
|
|
3
|
+
stdout: (text: string) => void;
|
|
4
|
+
stderr: (text: string) => void;
|
|
5
|
+
env?: NodeJS.ProcessEnv;
|
|
6
|
+
cwd?: string;
|
|
7
|
+
userConfigFile?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* `config list` answers "what settings is the CLI actually using, and where
|
|
11
|
+
* did each come from" — the question a user asks when a run picks a model they
|
|
12
|
+
* did not expect. The key is only ever shown as a fingerprint.
|
|
13
|
+
*/
|
|
14
|
+
export declare function configCommand(argv: readonly string[], io: CommandIo): ExitCode;
|
|
15
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/commands/config.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAOnD,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,SAAS,GACZ,QAAQ,CAwEV"}
|