howtofixcli 0.7.24 → 0.7.25
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/dist/account-credential.js +136 -0
- package/dist/acp.js +234 -0
- package/dist/activity.js +124 -0
- package/dist/agent-defs.js +237 -0
- package/dist/agent-limiter.js +68 -0
- package/dist/agents.js +554 -0
- package/dist/anim.js +361 -0
- package/dist/args.js +250 -0
- package/dist/at-file.js +28 -0
- package/dist/brand-extension.js +87 -0
- package/dist/brand.js +76 -0
- package/dist/browser.js +58 -0
- package/dist/cache-report.js +139 -0
- package/dist/checkpoint.js +437 -0
- package/dist/claims.js +167 -0
- package/dist/cli-auth.js +142 -0
- package/dist/cli-checkin.js +121 -0
- package/dist/cli-credentials.js +254 -0
- package/dist/clipboard.js +149 -0
- package/dist/collapse.js +129 -0
- package/dist/commands.js +381 -0
- package/dist/compaction-guard.js +81 -0
- package/dist/connect-prompt.js +22 -0
- package/dist/context-extension.js +17 -0
- package/dist/dcp/commands.js +172 -0
- package/dist/dcp/compress-state.js +139 -0
- package/dist/dcp/compress.js +329 -0
- package/dist/dcp/dcp-extension.js +295 -0
- package/dist/dcp/dedup.js +93 -0
- package/dist/dcp/ids.js +189 -0
- package/dist/dcp/index.js +25 -0
- package/dist/dcp/middle-prune.js +76 -0
- package/dist/dcp/prompts.js +68 -0
- package/dist/dcp/protected-content.js +221 -0
- package/dist/dcp/protected.js +161 -0
- package/dist/dcp/prune.js +418 -0
- package/dist/dcp/state.js +176 -0
- package/dist/errors.js +63 -0
- package/dist/extensions.js +48 -0
- package/dist/fatal.js +35 -0
- package/dist/feed.js +498 -0
- package/dist/fs-atomic.js +54 -0
- package/dist/gateway-errors.js +107 -0
- package/dist/git.js +230 -0
- package/dist/goal-extension.js +88 -0
- package/dist/goal.js +222 -0
- package/dist/hooks-extension.js +176 -0
- package/dist/hooks.js +443 -0
- package/dist/howtofix-account.js +834 -0
- package/dist/hud-line.js +158 -0
- package/dist/hwid.js +130 -0
- package/dist/interrupt.js +60 -0
- package/dist/json-events.js +50 -0
- package/dist/level-style.js +112 -0
- package/dist/loop-guard.js +95 -0
- package/dist/lsp-tools.js +32 -0
- package/dist/lsp.js +429 -0
- package/dist/main.js +573 -98
- package/dist/mcp-config.js +85 -0
- package/dist/mcp-lifecycle.js +147 -0
- package/dist/model-catalog.js +217 -0
- package/dist/model-facts.js +35 -0
- package/dist/model-resolve.js +65 -0
- package/dist/notes.js +78 -0
- package/dist/oauth-login.js +568 -0
- package/dist/paths.js +61 -0
- package/dist/permissions.js +298 -0
- package/dist/plan-mode.js +363 -0
- package/dist/preset-command.js +227 -0
- package/dist/preset-extension.js +35 -0
- package/dist/preset-mcp.js +292 -0
- package/dist/presets/mcp/rea/preset.json +28 -0
- package/dist/presets/mcp/rea/prompt.md +5 -0
- package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
- package/dist/presets/mcp/remotion/preset.json +27 -0
- package/dist/presets/mcp/remotion/prompt.md +7 -0
- package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
- package/dist/presets.js +727 -0
- package/dist/prompt-files.js +103 -0
- package/dist/prompt-text.js +41 -0
- package/dist/proxy.js +137 -0
- package/dist/quality/ast.js +196 -0
- package/dist/quality/cognitive.js +83 -0
- package/dist/quality/cyclomatic.js +30 -0
- package/dist/quality/halstead.js +83 -0
- package/dist/quality/index.js +18 -0
- package/dist/quality/length.js +3 -0
- package/dist/quality/maintainability.js +15 -0
- package/dist/quality/nesting.js +57 -0
- package/dist/quality/report.js +803 -0
- package/dist/quality/smells.js +67 -0
- package/dist/quality-tool.js +63 -0
- package/dist/read-only-commands.js +597 -0
- package/dist/repo-map.js +292 -0
- package/dist/roblox-connect.js +125 -0
- package/dist/rpc.js +91 -0
- package/dist/rtk.js +193 -0
- package/dist/sandbox.js +105 -0
- package/dist/schema.js +214 -0
- package/dist/serve.js +50 -0
- package/dist/session-lifecycle.js +19 -0
- package/dist/session-retention.js +111 -0
- package/dist/session.js +460 -0
- package/dist/sessions.js +100 -0
- package/dist/settings.js +148 -0
- package/dist/skills.js +35 -0
- package/dist/slop-tool.js +96 -0
- package/dist/spend-cap.js +43 -0
- package/dist/subagent.js +171 -0
- package/dist/tar.js +146 -0
- package/dist/theme.js +79 -0
- package/dist/thinking.js +45 -0
- package/dist/todo.js +114 -0
- package/dist/tool-loadout.js +102 -0
- package/dist/tool-render.js +769 -0
- package/dist/tools.js +176 -0
- package/dist/ui.js +4930 -0
- package/dist/update.js +551 -0
- package/dist/usage-segment.js +74 -0
- package/dist/web-tools.js +46 -0
- package/dist/web.js +265 -0
- package/dist/welcome.js +179 -0
- package/dist/workflow-engine.js +246 -0
- package/dist/workflow-progress.js +446 -0
- package/dist/workflow-view.js +195 -0
- package/dist/workflow.js +349 -0
- package/package.json +62 -15
- package/dist/chunk-27TRKD65.js +0 -2
- package/dist/chunk-3FYCFLNK.js +0 -1
- package/dist/chunk-3LZR7OEU.js +0 -1
- package/dist/chunk-56SEAGAQ.js +0 -1
- package/dist/chunk-5AWTL34S.js +0 -1
- package/dist/chunk-75P2SCOT.js +0 -1
- package/dist/chunk-7CHDNTH6.js +0 -1
- package/dist/chunk-7PHRNXLN.js +0 -34
- package/dist/chunk-AGGIYXN7.js +0 -2
- package/dist/chunk-AUDMRGWF.js +0 -189
- package/dist/chunk-C5E3SFOQ.js +0 -2
- package/dist/chunk-CMTKCUL5.js +0 -1
- package/dist/chunk-DAPXGSGD.js +0 -12
- package/dist/chunk-DOD7MUGX.js +0 -1
- package/dist/chunk-FPXVDP32.js +0 -8
- package/dist/chunk-GXB7RZHH.js +0 -1
- package/dist/chunk-I7KVLBMF.js +0 -2
- package/dist/chunk-ILDDDLRC.js +0 -1
- package/dist/chunk-JFYVRWJI.js +0 -2
- package/dist/chunk-LAHVHRCS.js +0 -2
- package/dist/chunk-LSJC73DG.js +0 -1
- package/dist/chunk-MNDKS6UH.js +0 -6
- package/dist/chunk-N345KWWK.js +0 -1
- package/dist/chunk-N3J2J7EQ.js +0 -5
- package/dist/chunk-ONZLNFQ2.js +0 -3
- package/dist/chunk-ORJ2QZBG.js +0 -3
- package/dist/chunk-PCALBZOO.js +0 -2
- package/dist/chunk-QPHSHL7H.js +0 -2
- package/dist/chunk-TKROS3BE.js +0 -5
- package/dist/chunk-TSEKPGK4.js +0 -1
- package/dist/chunk-UP7ENLEL.js +0 -117
- package/dist/chunk-UTYFLLSM.js +0 -1
- package/dist/chunk-V3R4FYTJ.js +0 -1
- package/dist/chunk-V656XVXV.js +0 -8
- package/dist/chunk-WKUJ7CFO.js +0 -1
- package/dist/chunk-WOT6VMZA.js +0 -1
- package/dist/chunk-WUPU6LTD.js +0 -4
- package/dist/chunk-WX3V5P7U.js +0 -5
- package/dist/chunk-XW6LMQGX.js +0 -1
- package/dist/chunk-YW7NORH3.js +0 -2
package/dist/main.js
CHANGED
|
@@ -1,99 +1,574 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
token
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
2
|
+
import { helpText, parseArgs } from "./args.js";
|
|
3
|
+
import { APP_TITLE, APP_VERSION } from "./brand.js";
|
|
4
|
+
import { installCrashHandlers } from "./fatal.js";
|
|
5
|
+
import { reportError } from "./errors.js";
|
|
6
|
+
import { AGENT_DIR, pinAgentDir } from "./paths.js";
|
|
7
|
+
import { jsonLine } from "./json-events.js";
|
|
8
|
+
import { diagnoseGatewayFailure } from "./gateway-errors.js";
|
|
9
|
+
import { spendCapMessage, unpricedBudgetMessage } from "./spend-cap.js";
|
|
10
|
+
/**
|
|
11
|
+
* The working directory to run in, or a reason the process cannot start.
|
|
12
|
+
*
|
|
13
|
+
* `process.cwd()` throws `ENOENT` when the directory it was started in has been
|
|
14
|
+
* deleted — a real case (the scratch dir is cleaned up mid-session) and an
|
|
15
|
+
* uncaught crash with a stack trace, since the throw happens while building the
|
|
16
|
+
* session options. Report it as a sentence instead.
|
|
17
|
+
*/
|
|
18
|
+
function workingDirectory() {
|
|
19
|
+
try {
|
|
20
|
+
return { cwd: process.cwd() };
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {
|
|
24
|
+
error: "the current directory no longer exists (it was deleted while the shell stayed in it). " +
|
|
25
|
+
"cd to a directory that exists, or pass one on the command line.",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A closed stdout (`howtofix --version | head -1`) makes every later write throw
|
|
31
|
+
* `EPIPE`, which the top-level handler used to report as a crash with a stack
|
|
32
|
+
* trace. A pipe that went away is not an error the user needs to see.
|
|
33
|
+
*/
|
|
34
|
+
function exitQuietlyOnEpipe() {
|
|
35
|
+
process.stdout.on("error", (error) => {
|
|
36
|
+
if (error.code === "EPIPE")
|
|
37
|
+
process.exit(0);
|
|
38
|
+
throw error;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async function main() {
|
|
42
|
+
exitQuietlyOnEpipe();
|
|
43
|
+
pinAgentDir();
|
|
44
|
+
const startedAt = performance.now();
|
|
45
|
+
const here = workingDirectory();
|
|
46
|
+
if ("error" in here) {
|
|
47
|
+
console.error(`${APP_TITLE}: ${here.error}`);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const cwd = here.cwd;
|
|
52
|
+
// A Node too old to be supported fails deep inside a dependency instead of here.
|
|
53
|
+
// `undici` 8 reaches `zlib.createZstdDecompress` at module load, and that function
|
|
54
|
+
// only exists from Node 22.19 — so on Node 20 the user gets
|
|
55
|
+
// `TypeError: Cannot read properties of undefined (reading 'bind')` with a stack
|
|
56
|
+
// trace pointing at `undici/lib/interceptor/decompress.js`, which explains nothing
|
|
57
|
+
// and names no fix. `engines` in `package.json` is not enough on its own: npm's
|
|
58
|
+
// `engine-strict` is false by default, so the install succeeds with a warning the
|
|
59
|
+
// user never sees.
|
|
60
|
+
//
|
|
61
|
+
// `--help` and `--version` are deliberately still served. They exit before `undici`
|
|
62
|
+
// is ever loaded, so they cannot hit the bug, and the user who most needs to read the
|
|
63
|
+
// help is the one on the runtime the CLI cannot run — refusing to answer leaves them
|
|
64
|
+
// with a command that looks broken and no way to learn why. Everything else is
|
|
65
|
+
// refused, because a session that cannot start the agent is not a usable CLI and
|
|
66
|
+
// failing here is what turns a stack trace inside a dependency into a sentence.
|
|
67
|
+
//
|
|
68
|
+
// Gated behind an env var so the check itself is what breaks under test, not the
|
|
69
|
+
// runtime: `HXF_FAKE_NODE` lets a check drive this branch on a modern Node. The
|
|
70
|
+
// same value feeds the message, so a test sees the version it asked about rather
|
|
71
|
+
// than the one it happens to be running on.
|
|
72
|
+
//
|
|
73
|
+
// The exit code says whether the ENVIRONMENT is supported, not whether output was
|
|
74
|
+
// produced: an old Node exits non-zero even for `--help`, so a script reading the
|
|
75
|
+
// code is not told the runtime is fine.
|
|
76
|
+
const reportedVersion = process.env.HXF_FAKE_NODE ?? process.versions.node;
|
|
77
|
+
const [major, minor] = reportedVersion.split(".").map((part) => Number.parseInt(part, 10));
|
|
78
|
+
if (!Number.isFinite(major) || major < 22 || (major === 22 && Number.isFinite(minor) && minor < 19)) {
|
|
79
|
+
// Same sentence on both streams, and stderr carries it in the informational case
|
|
80
|
+
// too: `--version` is often parsed by a script, and a warning on stdout would
|
|
81
|
+
// corrupt the value it is reading.
|
|
82
|
+
console.error(`${APP_TITLE}: Node ${reportedVersion} is too old. Node 22.19 or newer is required ` +
|
|
83
|
+
`(run "node --version" to check yours), because undici needs zstd, which Node added in 22.19. ` +
|
|
84
|
+
`Upgrade Node and run this again.`);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
const argv = process.argv.slice(2);
|
|
87
|
+
// The two flags that work on any runtime, served from here so the account repair
|
|
88
|
+
// below — which makes an HTTP call, and therefore loads undici — is never reached
|
|
89
|
+
// on a runtime that cannot load it.
|
|
90
|
+
if (argv.some((token) => token === "-h" || token === "--help")) {
|
|
91
|
+
console.log(helpText());
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (argv.some((token) => token === "-v" || token === "--version")) {
|
|
95
|
+
console.log(`${APP_TITLE} ${APP_VERSION}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Questions about the installation, answered without a session, a model, or
|
|
101
|
+
// any of the repairs below. They run first on purpose: `howtofix models` used
|
|
102
|
+
// to rewrite settings.json and print two notices about it before listing a
|
|
103
|
+
// single model, which reads like the command did something it did not.
|
|
104
|
+
const subArgv = process.argv.slice(2);
|
|
105
|
+
if (subArgv[0] === "doctor" || subArgv[0] === "models" || subArgv[0] === "sessions") {
|
|
106
|
+
const context = subArgv[0];
|
|
107
|
+
try {
|
|
108
|
+
const commands = await import("./commands.js");
|
|
109
|
+
if (context === "doctor")
|
|
110
|
+
process.exitCode = await commands.runDoctor();
|
|
111
|
+
else if (context === "models")
|
|
112
|
+
process.exitCode = commands.runModels();
|
|
113
|
+
else
|
|
114
|
+
process.exitCode = commands.runSessions(subArgv.slice(1));
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
reportError(APP_TITLE, context, error);
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Arguments are read and checked before the start-up repairs. A typo must not
|
|
123
|
+
// rewrite settings.json, print a hint about a file the user never mentioned,
|
|
124
|
+
// or reach the network: the answer to "you forgot the prompt" is one line.
|
|
125
|
+
// `howtofix acp` is the subcommand spelling of `--acp`, dispatched here rather
|
|
126
|
+
// than in `parseArgs` so the word is not mistaken for the start of a prompt.
|
|
127
|
+
const argvForParse = subArgv[0] === "acp" ? ["--acp", ...subArgv.slice(1)] : subArgv;
|
|
128
|
+
let earlyArgs;
|
|
129
|
+
try {
|
|
130
|
+
earlyArgs = parseArgs(argvForParse);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
134
|
+
console.error(`${APP_TITLE}: ${message}\n${helpText()}`);
|
|
135
|
+
process.exitCode = 2;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (earlyArgs.print && !earlyArgs.prompt) {
|
|
139
|
+
console.error(`${APP_TITLE}: --print needs a prompt`);
|
|
140
|
+
process.exitCode = 2;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// Repair a howtofix gateway entry that predates `compat.supportsDeveloperRole`.
|
|
144
|
+
// The gateway refuses the `developer` role that the SDK selects for every
|
|
145
|
+
// reasoning model, so such an entry fails every prompt silently — the spinner
|
|
146
|
+
// turns and nothing is ever reported. `/connect` writes the flag, but only for
|
|
147
|
+
// models it adds, so an account connected earlier stays broken until something
|
|
148
|
+
// repairs the file. The repair is silent: it runs on every start and only says
|
|
149
|
+
// anything when it actually changed something. Imported lazily to keep
|
|
150
|
+
// `--version` and `--help` free of the model stack.
|
|
151
|
+
const { repairGatewayModels, ensureGatewaySettings, repairModelFacts, syncGatewayModelsOnStart, checkAccount, fetchGatewayPrices } = await import("./howtofix-account.js");
|
|
152
|
+
const { readCliCredential, renewCliCredentialIfNeeded } = await import("./cli-credentials.js");
|
|
153
|
+
const { AGENT_DIR: stateDir } = await import("./paths.js");
|
|
154
|
+
const repaired = repairGatewayModels(stateDir);
|
|
155
|
+
if (repaired > 0) {
|
|
156
|
+
console.error(`${APP_TITLE}: repaired ${repaired} howtofix model(s) that the gateway would have rejected`);
|
|
157
|
+
}
|
|
158
|
+
// The gateway's own price list, read before the repair below: the repair writes
|
|
159
|
+
// the numbers the picker shows, and a picker that calls a $2/M model free is
|
|
160
|
+
// worse than one that shows no price at all. One request, once per start, and
|
|
161
|
+
// only when there is a credential to make it with. The account's model list
|
|
162
|
+
// rides along on the same token — the re-sync further down needs it, and the
|
|
163
|
+
// two calls are independent.
|
|
164
|
+
let gatewayPrices;
|
|
165
|
+
let accountModels;
|
|
166
|
+
try {
|
|
167
|
+
const stored = readCliCredential(stateDir);
|
|
168
|
+
if (stored) {
|
|
169
|
+
const renewed = await renewCliCredentialIfNeeded(stateDir, stored);
|
|
170
|
+
const token = renewed.credential.accessToken;
|
|
171
|
+
const [prices, account] = await Promise.all([fetchGatewayPrices(token), checkAccount(token)]);
|
|
172
|
+
if (prices.size > 0)
|
|
173
|
+
gatewayPrices = prices;
|
|
174
|
+
if (account.ok)
|
|
175
|
+
accountModels = account.models;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// No price list is a picker without prices, not a failed start.
|
|
180
|
+
}
|
|
181
|
+
// Repair the numbers a synced model never got: a real context window and the
|
|
182
|
+
// reasoning flag that enables thinking. Both used to be guesses — 128k for
|
|
183
|
+
// every model, and no capability at all — so a model with a 1M window read as
|
|
184
|
+
// almost full and a reasoning model showed no thinking levels. The catalogue is
|
|
185
|
+
// cached next to the agent state, so this is a file read on a normal start and
|
|
186
|
+
// a fetch only when the cache is missing or stale.
|
|
187
|
+
const facts = await repairModelFacts(stateDir, gatewayPrices ? { prices: gatewayPrices } : undefined);
|
|
188
|
+
if (facts.changed > 0) {
|
|
189
|
+
console.error(`${APP_TITLE}: filled in the context window, reasoning flag and price for ${facts.changed} model(s)`);
|
|
190
|
+
}
|
|
191
|
+
else if (facts.message) {
|
|
192
|
+
// A catalogue that could not be read is not a failure — the models keep the
|
|
193
|
+
// conservative defaults — but it is worth one line, because otherwise the
|
|
194
|
+
// wrong numbers look like the model's own.
|
|
195
|
+
console.error(`${APP_TITLE}: model catalogue unavailable (${facts.message}); context windows stay at their conservative defaults`);
|
|
196
|
+
}
|
|
197
|
+
// The SDK's HTTP idle timeout defaults to 5 minutes, so a provider that goes
|
|
198
|
+
// quiet mid-request leaves a spinner and nothing else for that long. 90s lets
|
|
199
|
+
// the SDK's own retry recover while the user is still watching. Written only
|
|
200
|
+
// when the key is missing, so a value the user set is kept.
|
|
201
|
+
if (ensureGatewaySettings(stateDir)) {
|
|
202
|
+
console.error(`${APP_TITLE}: set httpIdleTimeoutMs in settings.json so a stalled provider recovers instead of hanging`);
|
|
203
|
+
}
|
|
204
|
+
// The account's model list follows its tier, and `models.json` is a snapshot:
|
|
205
|
+
// after a tier change the file still offered what the account could reach on
|
|
206
|
+
// the day it was written, and the only refresh was a manual `/sync`. Pull the
|
|
207
|
+
// list once per start when a sign-in exists and add whatever is new — the
|
|
208
|
+
// gateway filters by the account's own group, so a free account can never
|
|
209
|
+
// collect a VIP model this way. Silent unless something was added, and never
|
|
210
|
+
// fatal: an unreachable gateway must not stop the CLI from starting.
|
|
211
|
+
if (accountModels) {
|
|
212
|
+
try {
|
|
213
|
+
const synced = await syncGatewayModelsOnStart(stateDir, accountModels, gatewayPrices);
|
|
214
|
+
if (synced.added > 0) {
|
|
215
|
+
console.error(`${APP_TITLE}: added ${synced.added} model(s) this account can now reach`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// A start-time re-sync that fails costs the previous list, which still
|
|
220
|
+
// works; `/sync` remains the manual way to try again.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const args = earlyArgs;
|
|
224
|
+
// These two exit before a session exists, so they must not load the SDK.
|
|
225
|
+
if (args.help) {
|
|
226
|
+
console.log(helpText());
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (args.version) {
|
|
230
|
+
console.log(`${APP_TITLE} ${APP_VERSION}`);
|
|
231
|
+
// A build whose signing key does not match the panel's would fail every
|
|
232
|
+
// sign-in with a 401 that reads like a server fault. Say it here, where it
|
|
233
|
+
// costs nothing, rather than leaving it to be discovered in the login flow.
|
|
234
|
+
const { cliSigningKeyMatchesPanel, cliSigningKeyFingerprint } = await import("./cli-auth.js");
|
|
235
|
+
if (!cliSigningKeyMatchesPanel()) {
|
|
236
|
+
console.error(`${APP_TITLE}: this build's CLI signing key (${cliSigningKeyFingerprint()}) does not match ` +
|
|
237
|
+
`the key the panel verifies against, so sign-in will be refused.`);
|
|
238
|
+
process.exitCode = 1;
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// The sandbox re-executes this process once, before any session exists. The
|
|
243
|
+
// guard in the environment is what stops the child from wrapping itself again.
|
|
244
|
+
if (args.sandbox && !process.env.HOWTOFIX_SANDBOXED) {
|
|
245
|
+
const { detectSandbox, enterSandbox, describeSandbox } = await import("./sandbox.js");
|
|
246
|
+
const kind = detectSandbox();
|
|
247
|
+
if (!kind) {
|
|
248
|
+
console.error(`${APP_TITLE}: --sandbox needs a working bubblewrap (Linux) or sandbox-exec (macOS). ` +
|
|
249
|
+
`Neither could be used here — on Linux this usually means unprivileged user namespaces are disabled.`);
|
|
250
|
+
process.exitCode = 1;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
console.error(describeSandbox(kind));
|
|
254
|
+
process.exitCode = enterSandbox(kind, { cwd, agentDir: AGENT_DIR, argv: [process.execPath, ...process.argv.slice(1)] });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Route through a proxy before the first request goes out. Here rather than at
|
|
258
|
+
// the top of `main` because this is the first line that cannot exit early:
|
|
259
|
+
// `--version` and `--help` have already returned above, and loading the proxy
|
|
260
|
+
// agent for a command that makes no request is a cost with no purpose. The
|
|
261
|
+
// update check and the sign-in exchange both go through it, and they run
|
|
262
|
+
// before any session exists — a proxy installed after them would be a setting
|
|
263
|
+
// that works only sometimes.
|
|
264
|
+
const { installProxy } = await import("./proxy.js");
|
|
265
|
+
installProxy(AGENT_DIR);
|
|
266
|
+
// `--update` installs the newest release; `--check-update` only looks. Both
|
|
267
|
+
// exit without a session, so neither loads the model stack.
|
|
268
|
+
if (args.update || args.checkUpdate) {
|
|
269
|
+
const { PACKAGE_NAME, checkForUpdate, installUpdate, isInstalledCopy } = await import("./update.js");
|
|
270
|
+
const check = await checkForUpdate(APP_VERSION);
|
|
271
|
+
if (!check.latest) {
|
|
272
|
+
// Not reaching the registry is a failure only when an install was asked
|
|
273
|
+
// for; a plain check that could not run has nothing to report.
|
|
274
|
+
console.error(`${APP_TITLE}: ${check.message}`);
|
|
275
|
+
process.exitCode = args.update ? 1 : 0;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
console.log(check.message);
|
|
279
|
+
if (args.checkUpdate || !check.available)
|
|
280
|
+
return;
|
|
281
|
+
if (!isInstalledCopy()) {
|
|
282
|
+
console.error(`${APP_TITLE}: this copy was not installed by npm, so npm cannot replace it. ` +
|
|
283
|
+
`install it with: npm install -g ${PACKAGE_NAME}`);
|
|
284
|
+
process.exitCode = 1;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const result = await installUpdate({ version: check.latest });
|
|
288
|
+
console.log(result.message);
|
|
289
|
+
if (!result.ok)
|
|
290
|
+
process.exitCode = 1;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// `howtofix preset <new|check|pack>` authors a preset and exits. It runs before
|
|
294
|
+
// anything loads the SDK, because authoring has nothing to do with a model and
|
|
295
|
+
// a preset author on a machine without credentials should still be able to
|
|
296
|
+
// validate what they wrote.
|
|
297
|
+
const presetArgv = process.argv.slice(2);
|
|
298
|
+
if (presetArgv[0] === "preset") {
|
|
299
|
+
const { runPresetCommand } = await import("./preset-command.js");
|
|
300
|
+
process.exitCode = runPresetCommand(presetArgv.slice(1));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const { expandFileReferences } = await import("./at-file.js");
|
|
304
|
+
const { isApprovalMode, setApprovalMode, setDangerousBypass, isDangerousBypass } = await import("./permissions.js");
|
|
305
|
+
const { openSession } = await import("./session.js");
|
|
306
|
+
// Print mode has no dialogs, so anything that would ask must not block. The
|
|
307
|
+
// interactive default is `full-auto` too: an approval prompt that fires on
|
|
308
|
+
// every write is friction the user has to configure away, and a critical
|
|
309
|
+
// command is denied in every mode regardless.
|
|
310
|
+
setApprovalMode(args.approval && isApprovalMode(args.approval) ? args.approval : "full-auto");
|
|
311
|
+
// `--dangerously-skip-permissions` lifts the last two refusals print mode can
|
|
312
|
+
// still hit: a critical command and the loop guard. A stored preference does
|
|
313
|
+
// the same for someone who never passes flags — the flag wins when it is
|
|
314
|
+
// given, so a one-off run cannot be overridden by a file.
|
|
315
|
+
const { readWorkbenchSettings } = await import("./settings.js");
|
|
316
|
+
if (args.dangerouslySkipPermissions || readWorkbenchSettings(stateDir).skipPermissions)
|
|
317
|
+
setDangerousBypass(true);
|
|
318
|
+
// A bypass nobody can see is the dangerous kind: it removes the refusals a
|
|
319
|
+
// user relies on and leaves no trace of why. Say it is on, every run.
|
|
320
|
+
if (isDangerousBypass()) {
|
|
321
|
+
console.error(`${APP_TITLE}: permission checks are OFF — critical commands (filesystem wipes, ` +
|
|
322
|
+
`fork bombs, host shutdown) and the repeated-call guard are NOT enforced for this run. ` +
|
|
323
|
+
`Turn it off with /settings, or by removing skipPermissions from settings.json.`);
|
|
324
|
+
}
|
|
325
|
+
// A protocol server owns stdin and stdout from here on. It opens its own
|
|
326
|
+
// sessions (ACP opens one per `session/new`), so it returns before the one
|
|
327
|
+
// below is created.
|
|
328
|
+
if (args.mode) {
|
|
329
|
+
const { serveProtocol } = await import("./serve.js");
|
|
330
|
+
await serveProtocol(args.mode, { cwd, model: args.model, thinking: args.thinking });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const open = (options) => openSession({
|
|
334
|
+
cwd,
|
|
335
|
+
model: args.model,
|
|
336
|
+
thinking: args.thinking,
|
|
337
|
+
continueSession: options.continueSession ?? false,
|
|
338
|
+
noSession: options.noSession ?? false,
|
|
339
|
+
sessionPath: options.sessionPath,
|
|
340
|
+
// The caller chooses the session, not the prompt. `--print` is the only
|
|
341
|
+
// route that runs without a terminal, and the prompt must say so or a
|
|
342
|
+
// skill that asks a question becomes a dead end that still exits 0.
|
|
343
|
+
interactive: !args.print,
|
|
344
|
+
});
|
|
345
|
+
const opened = await open({
|
|
346
|
+
continueSession: args.continueSession,
|
|
347
|
+
sessionPath: args.sessionPath,
|
|
348
|
+
noSession: args.noSession || args.print,
|
|
349
|
+
});
|
|
350
|
+
// The note reports how the session was opened, so a refusal below must not
|
|
351
|
+
// swallow it: print writes it here, ahead of any guard; the TUI carries it
|
|
352
|
+
// into the feed as `options.note`.
|
|
353
|
+
if (args.print && opened.note)
|
|
354
|
+
console.error(opened.note);
|
|
355
|
+
// A budget on an unpriced model can never fire, so refuse instead of
|
|
356
|
+
// pretending to guard — in *both* modes, now that the workbench enforces the
|
|
357
|
+
// cap too: a TUI drawing a cap over a model that reports $0 a turn would be
|
|
358
|
+
// the exact lie this refusal exists to prevent. See `priced` on
|
|
359
|
+
// `OpenedSession`.
|
|
360
|
+
if (args.maxBudgetUsd !== undefined && !opened.priced) {
|
|
361
|
+
console.error(`${APP_TITLE}: ${unpricedBudgetMessage(opened.modelLabel, stateDir)}`);
|
|
362
|
+
await opened.dispose();
|
|
363
|
+
process.exitCode = 2;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (args.print) {
|
|
367
|
+
// `--print` has no slash router, but the TUI routes every "/" prompt to one
|
|
368
|
+
// (submit → runSlash) and never lets those reach the model. Without this
|
|
369
|
+
// guard the same prompt is forwarded verbatim — `/connect sk-...` pasted
|
|
370
|
+
// into a script sends the credential to the provider and records it in the
|
|
371
|
+
// transcript, and the command silently does not run. Skills are exempt:
|
|
372
|
+
// the TUI forwards those as prompts too (runSlash → skillInvocation).
|
|
373
|
+
const trimmedPrompt = args.prompt.trimStart();
|
|
374
|
+
if (trimmedPrompt.startsWith("/")) {
|
|
375
|
+
const name = trimmedPrompt.slice(1).split(/\s+/)[0] ?? "";
|
|
376
|
+
const { skillInvocation } = await import("./skills.js");
|
|
377
|
+
const skillNames = opened.session.resourceLoader.getSkills().skills.map((skill) => skill.name);
|
|
378
|
+
const isSkill = skillInvocation(trimmedPrompt, skillNames) !== undefined;
|
|
379
|
+
if (!isSkill) {
|
|
380
|
+
const { slashCommands } = await import("./ui.js");
|
|
381
|
+
if (slashCommands(opened.session).some((command) => command.name === name)) {
|
|
382
|
+
console.error(`${APP_TITLE}: /${name} runs in the TUI only — --print sends prompts straight to the model. ` +
|
|
383
|
+
`Run \`howtofix\` without --print for interactive commands.`);
|
|
384
|
+
await opened.dispose();
|
|
385
|
+
process.exitCode = 2;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const stream = args.outputFormat === "stream-json";
|
|
391
|
+
const json = args.outputFormat === "json";
|
|
392
|
+
// Both machine formats end with a result record; only `stream-json` writes
|
|
393
|
+
// the events along the way.
|
|
394
|
+
const machine = stream || json;
|
|
395
|
+
// A `system`-shaped header first, so a consumer learns the model and the
|
|
396
|
+
// session id before any event that refers to them — the same courtesy the
|
|
397
|
+
// SDK's own JSON mode pays. Only the streaming format has events to head.
|
|
398
|
+
if (stream) {
|
|
399
|
+
const header = {
|
|
400
|
+
type: "system",
|
|
401
|
+
subtype: "init",
|
|
402
|
+
cwd,
|
|
403
|
+
session_id: opened.session.sessionId,
|
|
404
|
+
model: opened.modelLabel,
|
|
405
|
+
output_format: args.outputFormat,
|
|
406
|
+
max_budget_usd: args.maxBudgetUsd ?? null,
|
|
407
|
+
dangerously_skip_permissions: args.dangerouslySkipPermissions,
|
|
408
|
+
cli_version: APP_VERSION,
|
|
409
|
+
};
|
|
410
|
+
process.stdout.write(`${JSON.stringify(header)}\n`);
|
|
411
|
+
}
|
|
412
|
+
let unsubscribe;
|
|
413
|
+
let budgetHit;
|
|
414
|
+
const checkBudget = () => {
|
|
415
|
+
if (args.maxBudgetUsd === undefined || budgetHit !== undefined)
|
|
416
|
+
return;
|
|
417
|
+
const spent = opened.session.getSessionStats().cost;
|
|
418
|
+
if (spent > args.maxBudgetUsd) {
|
|
419
|
+
budgetHit = { spent };
|
|
420
|
+
void opened.session.abort();
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
try {
|
|
424
|
+
// Both machine formats and text need the same budget signal, and the only
|
|
425
|
+
// reliable one is the event stream: usage is updated as events arrive, so
|
|
426
|
+
// a timer that samples the total can miss the update entirely and let the
|
|
427
|
+
// run overspend. Subscribe in every mode; only `stream-json` writes the
|
|
428
|
+
// lines out.
|
|
429
|
+
// The failed turn is captured here, not read back from the session at the
|
|
430
|
+
// end. When the SDK's overflow recovery omits the attempt it could not
|
|
431
|
+
// recover from, `state.messages.at(-1)` is no longer the error — it is the
|
|
432
|
+
// user's prompt, or nothing — so `--print` reported success with an empty
|
|
433
|
+
// answer and exit code 0 for a run that failed. Measured against a gateway
|
|
434
|
+
// answering 400 "prompt is too long": exit 0, no output on either stream.
|
|
435
|
+
let failedTurn;
|
|
436
|
+
unsubscribe = opened.session.subscribe((event) => {
|
|
437
|
+
if (stream) {
|
|
438
|
+
const line = jsonLine(event);
|
|
439
|
+
if (line !== undefined)
|
|
440
|
+
process.stdout.write(line);
|
|
441
|
+
}
|
|
442
|
+
if (event.type === "turn_end" && event.message?.role === "assistant") {
|
|
443
|
+
const message = event.message;
|
|
444
|
+
if (message.stopReason === "error" || message.stopReason === "aborted" || message.errorMessage) {
|
|
445
|
+
failedTurn = { stopReason: message.stopReason ?? "error", errorMessage: message.errorMessage };
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
failedTurn = undefined;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
checkBudget();
|
|
452
|
+
});
|
|
453
|
+
await opened.session.prompt(expandFileReferences(args.prompt, cwd));
|
|
454
|
+
const text = opened.session.getLastAssistantText() ?? "";
|
|
455
|
+
if (budgetHit) {
|
|
456
|
+
console.error(`${APP_TITLE}: ${spendCapMessage(budgetHit.spent, args.maxBudgetUsd)}`);
|
|
457
|
+
// A consumer reading the stream needs a terminal record on this path
|
|
458
|
+
// too: without it the run just stops mid-stream and looks truncated.
|
|
459
|
+
if (machine) {
|
|
460
|
+
const stats = opened.session.getSessionStats();
|
|
461
|
+
process.stdout.write(`${JSON.stringify({
|
|
462
|
+
type: "result",
|
|
463
|
+
subtype: "error_max_budget_usd",
|
|
464
|
+
is_error: true,
|
|
465
|
+
result: text,
|
|
466
|
+
session_id: opened.session.sessionId,
|
|
467
|
+
duration_ms: Math.round(performance.now() - startedAt),
|
|
468
|
+
num_turns: stats.assistantMessages,
|
|
469
|
+
total_cost_usd: stats.cost,
|
|
470
|
+
usage: {
|
|
471
|
+
input_tokens: stats.tokens.input,
|
|
472
|
+
output_tokens: stats.tokens.output,
|
|
473
|
+
cache_read_input_tokens: stats.tokens.cacheRead,
|
|
474
|
+
cache_creation_input_tokens: stats.tokens.cacheWrite,
|
|
475
|
+
},
|
|
476
|
+
})}\n`);
|
|
477
|
+
}
|
|
478
|
+
process.exitCode = 1;
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
// The SDK contract: a final assistant response that ended in `error` or
|
|
482
|
+
// `aborted` produces a nonzero exit status, so scripts can branch on it.
|
|
483
|
+
//
|
|
484
|
+
// `failedTurn` is the turn the event stream saw fail; `messages.at(-1)` is
|
|
485
|
+
// the fallback for a run that failed without ever emitting one. Reading
|
|
486
|
+
// only the tail is what let an unrecovered overflow report success.
|
|
487
|
+
const last = opened.session.state.messages.at(-1);
|
|
488
|
+
const failure = failedTurn ??
|
|
489
|
+
(last?.role === "assistant" && (last.stopReason === "error" || last.stopReason === "aborted" || last.errorMessage)
|
|
490
|
+
? { stopReason: last.stopReason ?? "error", errorMessage: last.errorMessage }
|
|
491
|
+
: undefined);
|
|
492
|
+
// One result record per run, written after the outcome is known. The
|
|
493
|
+
// success record used to be written first and the exit status decided
|
|
494
|
+
// afterwards, so a failed turn emitted "success" and then "error" — and a
|
|
495
|
+
// consumer reading the first record believed a failed run had worked.
|
|
496
|
+
if (machine) {
|
|
497
|
+
const stats = opened.session.getSessionStats();
|
|
498
|
+
process.stdout.write(`${JSON.stringify({
|
|
499
|
+
type: "result",
|
|
500
|
+
subtype: failure
|
|
501
|
+
? failure.stopReason === "aborted"
|
|
502
|
+
? "error_aborted"
|
|
503
|
+
: "error_during_execution"
|
|
504
|
+
: "success",
|
|
505
|
+
is_error: failure !== undefined,
|
|
506
|
+
result: text,
|
|
507
|
+
session_id: opened.session.sessionId,
|
|
508
|
+
duration_ms: Math.round(performance.now() - startedAt),
|
|
509
|
+
num_turns: stats.assistantMessages,
|
|
510
|
+
total_cost_usd: stats.cost,
|
|
511
|
+
usage: {
|
|
512
|
+
input_tokens: stats.tokens.input,
|
|
513
|
+
output_tokens: stats.tokens.output,
|
|
514
|
+
cache_read_input_tokens: stats.tokens.cacheRead,
|
|
515
|
+
cache_creation_input_tokens: stats.tokens.cacheWrite,
|
|
516
|
+
},
|
|
517
|
+
stop_reason: failure?.stopReason ?? (last?.role === "assistant" ? last.stopReason : undefined),
|
|
518
|
+
})}\n`);
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
522
|
+
}
|
|
523
|
+
if (failure) {
|
|
524
|
+
process.exitCode = 1;
|
|
525
|
+
// A failed turn used to exit 1 with no output at all, which is
|
|
526
|
+
// indistinguishable from a crash. Say what the provider said.
|
|
527
|
+
if (failure.errorMessage) {
|
|
528
|
+
console.error(`${APP_TITLE}: ${failure.errorMessage}`);
|
|
529
|
+
// Same gap as the TUI: the gateway's own sentence, and the fix for it.
|
|
530
|
+
const fix = diagnoseGatewayFailure(failure.errorMessage);
|
|
531
|
+
if (fix !== undefined)
|
|
532
|
+
console.error(`${APP_TITLE}: ${fix}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
unsubscribe?.();
|
|
538
|
+
await opened.dispose();
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
543
|
+
console.error(`${APP_TITLE}: interactive mode needs a terminal. use --print for pipes.`);
|
|
544
|
+
await opened.dispose();
|
|
545
|
+
process.exitCode = 2;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const { runWorkbench } = await import("./ui.js");
|
|
549
|
+
// Only a copy npm owns can be replaced by npm, and only when the user has not
|
|
550
|
+
// turned it off. The check itself is handed to the workbench so it runs after
|
|
551
|
+
// the UI is up rather than before it.
|
|
552
|
+
const { autoUpdateEnabled, isInstalledCopy } = await import("./update.js");
|
|
553
|
+
const canSelfUpdate = isInstalledCopy() && autoUpdateEnabled(`${stateDir}/settings.json`);
|
|
554
|
+
await runWorkbench({
|
|
555
|
+
cwd,
|
|
556
|
+
modelLabel: opened.modelLabel,
|
|
557
|
+
session: opened.session,
|
|
558
|
+
// The `/rtk` command toggles the flag the tool-result extension reads; the setter
|
|
559
|
+
// lives beside that flag so the two cannot drift apart.
|
|
560
|
+
setCompressToolOutput: opened.setCompressToolOutput,
|
|
561
|
+
initialPrompt: args.prompt || undefined,
|
|
562
|
+
note: opened.note,
|
|
563
|
+
// The cap the print path enforces over one run, handed to a UI that has to
|
|
564
|
+
// enforce it over many: same flag, same dollars, different lifetime.
|
|
565
|
+
maxBudgetUsd: args.maxBudgetUsd,
|
|
566
|
+
update: canSelfUpdate ? { current: APP_VERSION, install: true } : undefined,
|
|
567
|
+
reopen: open,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
installCrashHandlers(APP_TITLE);
|
|
571
|
+
main().catch((error) => {
|
|
572
|
+
reportError(APP_TITLE, undefined, error);
|
|
573
|
+
process.exitCode = 1;
|
|
574
|
+
});
|