howtofixcli 0.7.5 → 0.7.7

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/args.js CHANGED
@@ -149,6 +149,8 @@ usage:
149
149
  howtofix -p "explain this repo"
150
150
  howtofix preset new|check|pack author a preset
151
151
  howtofix doctor|models|sessions inspect the installation
152
+ howtofix sessions --search <text> find a session by what was said in it
153
+ howtofix sessions --export <n> print session n from the list as markdown
152
154
 
153
155
  options:
154
156
  -p, --print run one prompt and print the answer
package/dist/brand.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const APP_TITLE = "HowToFix";
2
- export const APP_VERSION = "0.7.5";
2
+ export const APP_VERSION = "0.7.7";
3
3
  export const SYSTEM_APPEND = [
4
4
  // House style: RFC 2119 keywords in caps, one claim per line, the
5
5
  // highest-stakes rules at
package/dist/commands.js CHANGED
@@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { AGENT_DIR } from "./paths.js";
5
+ import { APP_TITLE } from "./brand.js";
6
+ import { reportError } from "./errors.js";
5
7
  import { detectSandbox } from "./sandbox.js";
6
8
  const MIN_NODE = [22, 19];
7
9
  /** Everything the CLI needs from the machine, checked without a model call. */
@@ -89,12 +91,30 @@ export async function runDoctor() {
89
91
  /** Configured providers and their models, straight from models.json. */
90
92
  export function runModels(root = AGENT_DIR) {
91
93
  const path = join(root, "models.json");
94
+ let raw;
95
+ try {
96
+ raw = readFileSync(path, "utf8");
97
+ }
98
+ catch (error) {
99
+ const code = error.code;
100
+ if (code === "ENOENT") {
101
+ console.log(` no models.json at ${path} — run \`howtofix /connect\` to pull the catalogue`);
102
+ }
103
+ else {
104
+ reportError(APP_TITLE, "models", error);
105
+ }
106
+ return 1;
107
+ }
92
108
  let parsed;
93
109
  try {
94
- parsed = JSON.parse(readFileSync(path, "utf8"));
110
+ parsed = JSON.parse(raw);
95
111
  }
96
- catch {
97
- console.log(` no models.json at ${path} — run \`howtofix /connect\` to pull the catalogue`);
112
+ catch (error) {
113
+ // A file that exists but does not parse is a different problem from a
114
+ // missing one: /connect will not fix it, and saying "no models.json" sends
115
+ // the user looking for a file that is already there.
116
+ console.error(`${APP_TITLE}: models: ${path} is not valid JSON (${error instanceof Error ? error.message : String(error)})`);
117
+ console.error(" fix the file, or delete it and run `howtofix /connect` to write a new one");
98
118
  return 1;
99
119
  }
100
120
  const providers = parsed.providers ?? {};
@@ -148,6 +168,26 @@ export function listSessionFiles(limit = 15, root = AGENT_DIR) {
148
168
  return rows.sort((a, b) => b.modified - a.modified).slice(0, limit);
149
169
  }
150
170
  export function runSessions(argv) {
171
+ const searchIndex = argv.indexOf("--search");
172
+ if (searchIndex >= 0) {
173
+ const query = argv[searchIndex + 1]?.trim();
174
+ if (!query) {
175
+ console.error(`${APP_TITLE}: sessions: --search needs a word or phrase`);
176
+ return 2;
177
+ }
178
+ const hits = searchSessions(query);
179
+ if (hits.length === 0) {
180
+ console.log(` no session mentions "${query}"`);
181
+ return 0;
182
+ }
183
+ for (const hit of hits) {
184
+ const when = new Date(hit.row.modified).toISOString().replace("T", " ").slice(0, 16);
185
+ console.log(` ${when} ${hit.row.project} (${hit.matches} hit${hit.matches === 1 ? "" : "s"})`);
186
+ console.log(` ${hit.row.file}`);
187
+ console.log(` ${hit.snippet}`);
188
+ }
189
+ return 0;
190
+ }
151
191
  const limitIndex = argv.indexOf("--limit");
152
192
  const limit = limitIndex >= 0 ? Number(argv[limitIndex + 1]) || 15 : 15;
153
193
  const rows = listSessionFiles(limit);
@@ -155,14 +195,101 @@ export function runSessions(argv) {
155
195
  console.log(` no saved sessions under ${join(AGENT_DIR, "sessions")}`);
156
196
  return 0;
157
197
  }
158
- for (const row of rows) {
198
+ const exportIndex = argv.indexOf("--export");
199
+ if (exportIndex >= 0) {
200
+ const which = Number(argv[exportIndex + 1]);
201
+ if (!Number.isInteger(which) || which < 1 || which > rows.length) {
202
+ console.error(`${APP_TITLE}: sessions: --export needs a number from the list (1-${rows.length})`);
203
+ return 2;
204
+ }
205
+ const row = rows[which - 1];
206
+ try {
207
+ console.log(exportSessionMarkdown(row.file));
208
+ }
209
+ catch (error) {
210
+ reportError(APP_TITLE, "sessions", error);
211
+ return 1;
212
+ }
213
+ return 0;
214
+ }
215
+ rows.forEach((row, index) => {
159
216
  const when = new Date(row.modified).toISOString().replace("T", " ").slice(0, 16);
160
217
  const kb = Math.round(row.bytes / 1024);
161
- console.log(` ${when} ${String(kb).padStart(6)}k ${row.project}`);
218
+ console.log(` ${String(index + 1).padStart(2)} ${when} ${String(kb).padStart(6)}k ${row.project}`);
162
219
  console.log(` ${row.file}`);
163
- }
220
+ });
164
221
  return 0;
165
222
  }
223
+ /** Text of one JSONL line, whether its content is a string or content blocks. */
224
+ function lineText(line) {
225
+ try {
226
+ const entry = JSON.parse(line);
227
+ const content = entry.message?.content;
228
+ if (typeof content === "string")
229
+ return content.replace(/\s+/g, " ").trim();
230
+ if (Array.isArray(content)) {
231
+ return content
232
+ .map((block) => (block && typeof block === "object" && "text" in block ? String(block.text) : ""))
233
+ .join(" ")
234
+ .replace(/\s+/g, " ")
235
+ .trim();
236
+ }
237
+ }
238
+ catch {
239
+ // A line that does not parse is still searchable as raw text.
240
+ }
241
+ return line.replace(/\s+/g, " ").trim();
242
+ }
243
+ /** Sessions whose text mentions `query`, with the first matching line as evidence. */
244
+ export function searchSessions(query, limit = 10, root = AGENT_DIR) {
245
+ const needle = query.toLowerCase();
246
+ const hits = [];
247
+ for (const row of listSessionFiles(500, root)) {
248
+ let text;
249
+ try {
250
+ text = readFileSync(row.file, "utf8");
251
+ }
252
+ catch {
253
+ continue;
254
+ }
255
+ let matches = 0;
256
+ let snippet = "";
257
+ for (const line of text.split("\n")) {
258
+ if (!line.toLowerCase().includes(needle))
259
+ continue;
260
+ matches++;
261
+ if (!snippet)
262
+ snippet = lineText(line).slice(0, 160);
263
+ }
264
+ if (matches > 0)
265
+ hits.push({ row, matches, snippet });
266
+ if (hits.length >= limit)
267
+ break;
268
+ }
269
+ return hits;
270
+ }
271
+ /** A session as markdown, for pasting into an issue or a chat. */
272
+ export function exportSessionMarkdown(file) {
273
+ const out = [`# Session ${file.split(/[\\/]/).pop() ?? file}`, ""];
274
+ for (const line of readFileSync(file, "utf8").split("\n")) {
275
+ if (!line.trim())
276
+ continue;
277
+ let entry;
278
+ try {
279
+ entry = JSON.parse(line);
280
+ }
281
+ catch {
282
+ continue;
283
+ }
284
+ if (entry.type !== "message")
285
+ continue;
286
+ const text = lineText(line);
287
+ if (!text)
288
+ continue;
289
+ out.push(`## ${entry.message?.role ?? "unknown"}`, "", text, "");
290
+ }
291
+ return out.join("\n").trimEnd();
292
+ }
166
293
  /** True when the file exists and is a directory — used by doctor's callers. */
167
294
  export function stateDirExists() {
168
295
  try {
package/dist/errors.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * One way to tell a user that something failed.
3
+ *
4
+ * Every command used to phrase its own failure, which meant a missing file, a
5
+ * refused connection and a typo in a flag could all arrive in the same shape:
6
+ * a sentence that says what broke but not what to do. This module answers both
7
+ * questions from the error itself, so the answer does not depend on which code
8
+ * path happened to throw.
9
+ */
10
+ /** The action a user can take, inferred from the code Node attached. */
11
+ function hintFor(code, message) {
12
+ const evidence = `${code ?? ""} ${message}`;
13
+ if (/EACCES|EPERM/.test(evidence))
14
+ return "the file or directory is not writable; check permissions and try again";
15
+ if (/ENOENT/.test(evidence))
16
+ return "a file or directory this command needs is missing";
17
+ if (/ENOSPC/.test(evidence))
18
+ return "the disk is full";
19
+ if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|UND_ERR|fetch failed/i.test(evidence)) {
20
+ return "the network request failed; check the connection and the gateway address";
21
+ }
22
+ if (/EADDRINUSE/.test(evidence))
23
+ return "another process is already using that port";
24
+ if (/MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND/.test(evidence)) {
25
+ return "a required file or package is missing; reinstall HowToFix";
26
+ }
27
+ if (/JSON/i.test(evidence) && /parse|unexpected token|end of input/i.test(evidence)) {
28
+ return "a configuration file is not valid JSON";
29
+ }
30
+ if (/not a git repository/i.test(evidence))
31
+ return "run the command inside a project, or pass --cwd";
32
+ return undefined;
33
+ }
34
+ /** The message, and the action that follows from it. */
35
+ export function describeError(error) {
36
+ const raw = error instanceof Error ? error.message : String(error);
37
+ const message = raw.trim() || "unknown error";
38
+ const code = error?.code;
39
+ return { message, hint: hintFor(code, message) };
40
+ }
41
+ /**
42
+ * The line(s) a failure prints.
43
+ *
44
+ * `context` names the thing that failed ("doctor", "update"), because "not
45
+ * permitted" alone does not say what was not permitted. The stack is evidence,
46
+ * and evidence only appears when `HOWTOFIX_DEBUG` asks for it.
47
+ */
48
+ export function formatError(appTitle, context, error, debug = false) {
49
+ const { message, hint } = describeError(error);
50
+ const head = context ? `${appTitle}: ${context}: ${message}` : `${appTitle}: ${message}`;
51
+ const lines = [head];
52
+ if (hint)
53
+ lines.push(` ${hint}`);
54
+ if (debug && error instanceof Error && error.stack)
55
+ lines.push(error.stack);
56
+ return lines.join("\n");
57
+ }
58
+ /** Print a failure the same way everywhere. */
59
+ export function reportError(appTitle, context, error, options) {
60
+ const debug = options?.debug ?? process.env.HOWTOFIX_DEBUG === "1";
61
+ const write = options?.write ?? ((text) => console.error(text));
62
+ write(formatError(appTitle, context, error, debug));
63
+ }
package/dist/fatal.js CHANGED
@@ -6,14 +6,14 @@
6
6
  * frames the user cannot act on. The message is what matters; the stack is
7
7
  * evidence, and evidence belongs behind a flag.
8
8
  */
9
+ import { describeError } from "./errors.js";
9
10
  /** The single line a failure deserves, with the stack only when asked for. */
10
11
  export function describeFatal(error, debug = false) {
11
- const message = error instanceof Error ? error.message : String(error);
12
- const text = message.trim() || "unknown error";
12
+ const { message } = describeError(error);
13
13
  if (!debug)
14
- return text;
14
+ return message;
15
15
  const stack = error instanceof Error ? error.stack : undefined;
16
- return stack && stack.includes("\n") ? `${text}\n${stack}` : text;
16
+ return stack && stack.includes("\n") ? `${message}\n${stack}` : message;
17
17
  }
18
18
  /**
19
19
  * Catch what escapes `main()`.
package/dist/main.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { helpText, parseArgs } from "./args.js";
3
3
  import { APP_TITLE, APP_VERSION } from "./brand.js";
4
4
  import { installCrashHandlers } from "./fatal.js";
5
+ import { reportError } from "./errors.js";
5
6
  import { AGENT_DIR, pinAgentDir } from "./paths.js";
6
7
  import { jsonLine } from "./json-events.js";
7
8
  import { spendCapMessage, unpricedBudgetMessage } from "./spend-cap.js";
@@ -95,6 +96,28 @@ async function main() {
95
96
  }
96
97
  return;
97
98
  }
99
+ // Questions about the installation, answered without a session, a model, or
100
+ // any of the repairs below. They run first on purpose: `howtofix models` used
101
+ // to rewrite settings.json and print two notices about it before listing a
102
+ // single model, which reads like the command did something it did not.
103
+ const subArgv = process.argv.slice(2);
104
+ if (subArgv[0] === "doctor" || subArgv[0] === "models" || subArgv[0] === "sessions") {
105
+ const context = subArgv[0];
106
+ try {
107
+ const commands = await import("./commands.js");
108
+ if (context === "doctor")
109
+ process.exitCode = await commands.runDoctor();
110
+ else if (context === "models")
111
+ process.exitCode = commands.runModels();
112
+ else
113
+ process.exitCode = commands.runSessions(subArgv.slice(1));
114
+ }
115
+ catch (error) {
116
+ reportError(APP_TITLE, context, error);
117
+ process.exitCode = 1;
118
+ }
119
+ return;
120
+ }
98
121
  // Repair a howtofix gateway entry that predates `compat.supportsDeveloperRole`.
99
122
  // The gateway refuses the `developer` role that the SDK selects for every
100
123
  // reasoning model, so such an entry fails every prompt silently — the spinner
@@ -157,20 +180,6 @@ async function main() {
157
180
  // A start-time re-sync that fails costs the previous list, which still
158
181
  // works; `/sync` remains the manual way to try again.
159
182
  }
160
- // Questions about the installation, answered without a session or a model.
161
- // Dispatched before option parsing so their own flags (--limit) are not read
162
- // as unknown options for the agent.
163
- const subArgv = process.argv.slice(2);
164
- if (subArgv[0] === "doctor" || subArgv[0] === "models" || subArgv[0] === "sessions") {
165
- const commands = await import("./commands.js");
166
- if (subArgv[0] === "doctor")
167
- process.exitCode = await commands.runDoctor();
168
- else if (subArgv[0] === "models")
169
- process.exitCode = commands.runModels();
170
- else
171
- process.exitCode = commands.runSessions(subArgv.slice(1));
172
- return;
173
- }
174
183
  let args;
175
184
  try {
176
185
  args = parseArgs(process.argv.slice(2));
@@ -523,7 +532,6 @@ async function main() {
523
532
  }
524
533
  installCrashHandlers(APP_TITLE);
525
534
  main().catch((error) => {
526
- const message = error instanceof Error ? error.message : String(error);
527
- console.error(`${APP_TITLE}: ${message}`);
535
+ reportError(APP_TITLE, undefined, error);
528
536
  process.exitCode = 1;
529
537
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "howtofixcli",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "HowToFix coding agent. Workbench TUI, sessions, background agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,10 +44,10 @@
44
44
  "node": ">=22.19"
45
45
  },
46
46
  "dependencies": {
47
- "@netrodev/hxf-agent-core": "^1.0.19",
48
- "@netrodev/hxf-ai": "^1.0.19",
49
- "@netrodev/hxf-coding-agent": "^1.0.19",
50
- "@netrodev/hxf-tui": "^1.0.19",
47
+ "@netrodev/hxf-agent-core": "^1.0.22",
48
+ "@netrodev/hxf-ai": "^1.0.22",
49
+ "@netrodev/hxf-coding-agent": "^1.0.22",
50
+ "@netrodev/hxf-tui": "^1.0.22",
51
51
  "typebox": "1.3.27",
52
52
  "typescript": "7.0.2",
53
53
  "undici": "^8.10.2"