@lucasschirm/devinp 0.1.9 → 0.1.11

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.
Files changed (3) hide show
  1. package/README.md +29 -10
  2. package/bin/command.js +184 -68
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -24,18 +24,36 @@ The package ships as a self-contained bundle — no dependencies are installed.
24
24
  not authenticated, `devinp` exits with an error (use `--source` for a saved
25
25
  snapshot).
26
26
 
27
- ### When `devin` fails or truncates
28
-
29
- - Prices are captured through a temp file in `os.tmpdir()` — `devin`'s
30
- interactive CLI can truncate a pipe stream mid-write, so `devinp` points
31
- `devin`'s stdout at a real file instead. The temp file is always removed
32
- after the run.
27
+ ### When `devin` fails, pages, or truncates
28
+
29
+ `devin` is an interactive CLI, and on some machines it stays interactive even
30
+ when a script calls it: the model list comes back paged ("click to show more")
31
+ and the JSON never reaches stdout, so `devinp` used to die with *"returned
32
+ invalid JSON (… 0 bytes)"*. It now works harder to get the whole document:
33
+
34
+ - Prices are captured through a temp file in `os.tmpdir()` — a pipe stream from
35
+ `devin` can be truncated mid-write, so `devinp` points `devin`'s stdout at a
36
+ real file first. The temp file is always removed after the run.
37
+ - **Retries, each one louder about not being interactive.** If an attempt
38
+ yields no usable JSON, `devinp` runs `devin` again with the "not interactive"
39
+ conventions set (`PAGER=cat`, `GIT_PAGER=cat`, `TERM=dumb`, `NO_COLOR=1`,
40
+ `CI=1`) and, on the last try, captures over a pipe instead of a file. The
41
+ first successful attempt wins, so the healthy path costs one `devin` call.
42
+ - Noise around the document is tolerated: ANSI colours, an update banner in
43
+ front, or the `pass a slug to --model` hint behind are stripped before the
44
+ JSON is parsed.
33
45
  - If `devin`'s output is cut off mid-stream, `devinp` repairs the document,
34
46
  prints a warning, and renders the partial table.
35
- - If the output cannot be recovered, `devinp` reports a diagnostic with the
36
- size and tail of what `devin` wrote, and suggests re-running or saving the
37
- output manually: `devin models list --format json > models.json`, then
38
- `devinp --source models.json`.
47
+ - A real CLI failure (not signed in, network down) is reported as-is instead of
48
+ being retried.
49
+ - If nothing worked, the error lists every attempt — bytes, exit code, stderr,
50
+ timeout — and how to feed the data in yourself. That always works, because a
51
+ redirect or a pipe makes `devin` write the whole document in one go:
52
+
53
+ ```sh
54
+ devin models list --format json > models.json && devinp --source models.json
55
+ devin models list --format json | devinp --source=-
56
+ ```
39
57
 
40
58
  ## Usage
41
59
 
@@ -55,6 +73,7 @@ devinp --web # serve an interactive page on http://127.0.0.1:432
55
73
  devinp --web --port 8080 # pick a port
56
74
 
57
75
  devinp --source models.json # read a saved `devin models list --format json`
76
+ devin models list --format json | devinp --source=- # feed it straight from stdin
58
77
  devinp --json # emit normalized rows as JSON
59
78
  ```
60
79
 
package/bin/command.js CHANGED
@@ -4193,14 +4193,14 @@ var require_emoji_regex = __commonJS({
4193
4193
  var require_string_width = __commonJS({
4194
4194
  "node_modules/string-width/index.js"(exports, module) {
4195
4195
  "use strict";
4196
- var stripAnsi = require_strip_ansi();
4196
+ var stripAnsi2 = require_strip_ansi();
4197
4197
  var isFullwidthCodePoint = require_is_fullwidth_code_point();
4198
4198
  var emojiRegex = require_emoji_regex();
4199
4199
  var stringWidth = (string) => {
4200
4200
  if (typeof string !== "string" || string.length === 0) {
4201
4201
  return 0;
4202
4202
  }
4203
- string = stripAnsi(string);
4203
+ string = stripAnsi2(string);
4204
4204
  if (string.length === 0) {
4205
4205
  return 0;
4206
4206
  }
@@ -5332,7 +5332,7 @@ var require_wrap_ansi = __commonJS({
5332
5332
  "node_modules/wrap-ansi/index.js"(exports, module) {
5333
5333
  "use strict";
5334
5334
  var stringWidth = require_string_width();
5335
- var stripAnsi = require_strip_ansi();
5335
+ var stripAnsi2 = require_strip_ansi();
5336
5336
  var ansiStyles = require_ansi_styles();
5337
5337
  var ESCAPES = /* @__PURE__ */ new Set([
5338
5338
  "\x1B",
@@ -5351,7 +5351,7 @@ var require_wrap_ansi = __commonJS({
5351
5351
  const characters = [...word];
5352
5352
  let isInsideEscape = false;
5353
5353
  let isInsideLinkEscape = false;
5354
- let visible2 = stringWidth(stripAnsi(rows[rows.length - 1]));
5354
+ let visible2 = stringWidth(stripAnsi2(rows[rows.length - 1]));
5355
5355
  for (const [index, character] of characters.entries()) {
5356
5356
  const characterLength = stringWidth(character);
5357
5357
  if (visible2 + characterLength <= columns) {
@@ -13355,7 +13355,7 @@ var require_parse2 = __commonJS({
13355
13355
  const parts = [];
13356
13356
  let bracket = 0;
13357
13357
  let paren = 0;
13358
- let quote = 0;
13358
+ let quote2 = 0;
13359
13359
  let value = "";
13360
13360
  let escaped = false;
13361
13361
  for (const ch of input) {
@@ -13370,11 +13370,11 @@ var require_parse2 = __commonJS({
13370
13370
  continue;
13371
13371
  }
13372
13372
  if (ch === '"') {
13373
- quote = quote === 1 ? 0 : 1;
13373
+ quote2 = quote2 === 1 ? 0 : 1;
13374
13374
  value += ch;
13375
13375
  continue;
13376
13376
  }
13377
- if (quote === 0) {
13377
+ if (quote2 === 0) {
13378
13378
  if (ch === "[") {
13379
13379
  bracket++;
13380
13380
  } else if (ch === "]" && bracket > 0) {
@@ -13451,7 +13451,7 @@ var require_parse2 = __commonJS({
13451
13451
  }
13452
13452
  let bracket = 0;
13453
13453
  let paren = 0;
13454
- let quote = 0;
13454
+ let quote2 = 0;
13455
13455
  let escaped = false;
13456
13456
  for (let i = 1; i < pattern.length; i++) {
13457
13457
  const ch = pattern[i];
@@ -13464,10 +13464,10 @@ var require_parse2 = __commonJS({
13464
13464
  continue;
13465
13465
  }
13466
13466
  if (ch === '"') {
13467
- quote = quote === 1 ? 0 : 1;
13467
+ quote2 = quote2 === 1 ? 0 : 1;
13468
13468
  continue;
13469
13469
  }
13470
- if (quote === 1) {
13470
+ if (quote2 === 1) {
13471
13471
  continue;
13472
13472
  }
13473
13473
  if (ch === "[") {
@@ -17633,7 +17633,7 @@ var require_parse3 = __commonJS({
17633
17633
  debug = () => {
17634
17634
  };
17635
17635
  }
17636
- var readStdin = async () => {
17636
+ var readStdin2 = async () => {
17637
17637
  const { stdin, stdout } = process;
17638
17638
  if (stdin.isTTY)
17639
17639
  return null;
@@ -17669,7 +17669,7 @@ var require_parse3 = __commonJS({
17669
17669
  }, { once: true });
17670
17670
  });
17671
17671
  };
17672
- exports.readStdin = readStdin;
17672
+ exports.readStdin = readStdin2;
17673
17673
  function isNegativeNumber(input) {
17674
17674
  return /^-\d/g.test(input);
17675
17675
  }
@@ -41394,15 +41394,15 @@ var require_quote = __commonJS({
41394
41394
  '"': '\\"',
41395
41395
  "\\": "\\\\"
41396
41396
  };
41397
- function quote(s) {
41397
+ function quote2(s) {
41398
41398
  rxEscapable.lastIndex = 0;
41399
41399
  return '"' + (rxEscapable.test(s) ? s.replace(rxEscapable, (a2) => {
41400
41400
  const c = escaped[a2];
41401
41401
  return typeof c === "string" ? c : "\\u" + ("0000" + a2.charCodeAt(0).toString(16)).slice(-4);
41402
41402
  }) : s) + '"';
41403
41403
  }
41404
- exports.default = quote;
41405
- quote.code = 'require("ajv/dist/runtime/quote").default';
41404
+ exports.default = quote2;
41405
+ quote2.code = 'require("ajv/dist/runtime/quote").default';
41406
41406
  }
41407
41407
  });
41408
41408
 
@@ -55088,45 +55088,113 @@ function scanJson(s, end) {
55088
55088
 
55089
55089
  // src/lib/source.js
55090
55090
  var DEVIN_TIMEOUT_MS = 3e4;
55091
- async function runDevinJson({ command = "devin", timeoutMs = DEVIN_TIMEOUT_MS } = {}) {
55091
+ var NON_INTERACTIVE_ENV = {
55092
+ PAGER: "cat",
55093
+ GIT_PAGER: "cat",
55094
+ TERM: "dumb",
55095
+ NO_COLOR: "1",
55096
+ CI: "1"
55097
+ };
55098
+ var STRATEGIES = [
55099
+ { label: "temp file", file: true, env: null },
55100
+ { label: "temp file + non-interactive env", file: true, env: NON_INTERACTIVE_ENV },
55101
+ { label: "pipe + non-interactive env", file: false, env: NON_INTERACTIVE_ENV }
55102
+ ];
55103
+ var ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][\s\S]*?(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
55104
+ function stripAnsi(text) {
55105
+ return text.replace(ANSI, "");
55106
+ }
55107
+ function parseJsonLoosely(text) {
55108
+ const clean = stripAnsi(text);
55109
+ try {
55110
+ return JSON.parse(clean);
55111
+ } catch {
55112
+ }
55113
+ const start = clean.indexOf("{");
55114
+ const end = clean.lastIndexOf("}");
55115
+ if (start >= 0 && end > start) {
55116
+ try {
55117
+ return JSON.parse(clean.slice(start, end + 1));
55118
+ } catch {
55119
+ }
55120
+ }
55121
+ return null;
55122
+ }
55123
+ function spawnDevin({ command, env, stdout, timeoutMs }) {
55124
+ return new Promise((resolve, reject) => {
55125
+ const child = spawn(command, ["models", "list", "--format", "json"], {
55126
+ stdio: ["ignore", stdout, "pipe"],
55127
+ ...env ? { env: { ...process.env, ...env } } : {}
55128
+ });
55129
+ let stdoutText = "";
55130
+ let stderr = "";
55131
+ let timedOut = false;
55132
+ const timer = setTimeout(() => {
55133
+ timedOut = true;
55134
+ child.kill("SIGKILL");
55135
+ }, timeoutMs);
55136
+ child.stdout?.on("data", (chunk) => {
55137
+ stdoutText += chunk;
55138
+ });
55139
+ child.stderr.on("data", (chunk) => {
55140
+ stderr += chunk;
55141
+ });
55142
+ child.on("error", (error) => {
55143
+ clearTimeout(timer);
55144
+ reject(error);
55145
+ });
55146
+ child.on("close", (code) => {
55147
+ clearTimeout(timer);
55148
+ resolve({ stdout: stdoutText, stderr, code, timedOut });
55149
+ });
55150
+ });
55151
+ }
55152
+ async function captureToFile({ command, env, timeoutMs }) {
55092
55153
  const file = join2(tmpdir(), `devinp-models-${process.pid}-${Date.now()}.json`);
55093
55154
  const handle = await open(file, "w");
55094
55155
  try {
55095
- const { code, stderr, timedOut } = await new Promise((resolve, reject) => {
55096
- const child = spawn(command, ["models", "list", "--format", "json"], {
55097
- stdio: ["ignore", handle.fd, "pipe"]
55098
- });
55099
- let stderr2 = "";
55100
- let timedOut2 = false;
55101
- const timer = setTimeout(() => {
55102
- timedOut2 = true;
55103
- child.kill("SIGKILL");
55104
- }, timeoutMs);
55105
- child.stderr.on("data", (chunk) => {
55106
- stderr2 += chunk;
55107
- });
55108
- child.on("error", (error) => {
55109
- clearTimeout(timer);
55110
- reject(error);
55111
- });
55112
- child.on("close", (childCode) => {
55113
- clearTimeout(timer);
55114
- resolve({ code: childCode, stderr: stderr2, timedOut: timedOut2 });
55115
- });
55116
- });
55117
- if (timedOut) {
55118
- throw new Error(`\`devin models list\` timed out after ${timeoutMs / 1e3}s`);
55119
- }
55120
- if (code !== 0) {
55121
- throw new Error(`\`devin models list\` failed \u2014 ${stderr.trim() || `exited with code ${code}`}`);
55122
- }
55123
- return await readFile2(file, "utf8");
55156
+ const result = await spawnDevin({ command, env, stdout: handle.fd, timeoutMs });
55157
+ result.stdout = await readFile2(file, "utf8");
55158
+ return result;
55124
55159
  } finally {
55125
55160
  await handle.close();
55126
55161
  await unlink(file).catch(() => {
55127
55162
  });
55128
55163
  }
55129
55164
  }
55165
+ async function runDevinJson({ command = "devin", timeoutMs = DEVIN_TIMEOUT_MS } = {}) {
55166
+ const attempts = [];
55167
+ for (const [index, strategy] of STRATEGIES.entries()) {
55168
+ let result;
55169
+ try {
55170
+ result = strategy.file ? await captureToFile({ command, env: strategy.env, timeoutMs }) : await spawnDevin({ command, env: strategy.env, stdout: "pipe", timeoutMs });
55171
+ } catch (error) {
55172
+ if (error.code === "ENOENT") throw error;
55173
+ attempts.push({ label: strategy.label, bytes: 0, code: null, stderr: "", timedOut: false, note: error.message });
55174
+ continue;
55175
+ }
55176
+ const raw = result.stdout ?? "";
55177
+ const parsed = raw.trim() ? parseJsonLoosely(raw) : null;
55178
+ attempts.push({
55179
+ label: strategy.label,
55180
+ bytes: raw.length,
55181
+ code: result.code,
55182
+ stderr: result.stderr.trim(),
55183
+ timedOut: result.timedOut,
55184
+ raw
55185
+ });
55186
+ if (parsed != null) return { raw, parsed, attempts, timeoutMs };
55187
+ if (index === 0 && result.code !== 0 && !result.timedOut && result.stderr.trim()) {
55188
+ throw new Error(`\`devin models list\` failed \u2014 ${result.stderr.trim()}`);
55189
+ }
55190
+ }
55191
+ const explained = [...attempts].reverse().find((a2) => a2.stderr);
55192
+ if (explained && attempts.every((a2) => a2.timedOut || a2.code !== 0)) {
55193
+ throw new Error(`\`devin models list\` failed \u2014 ${explained.stderr}`);
55194
+ }
55195
+ const longest = attempts.reduce((acc, a2) => (a2.raw?.length ?? 0) > acc.length ? a2.raw : acc, "");
55196
+ return { raw: longest || "", parsed: null, attempts, timeoutMs };
55197
+ }
55130
55198
  function assertFamilies(data, sourceName) {
55131
55199
  if (data == null || typeof data !== "object" || !Array.isArray(data.families)) {
55132
55200
  throw new Error(`Unable to get model prices: ${sourceName} has no "families" array (expected \`devin models list --format json\` output)`);
@@ -55136,43 +55204,87 @@ function assertFamilies(data, sourceName) {
55136
55204
  }
55137
55205
  return data;
55138
55206
  }
55139
- async function loadModelData({ source, command = "devin", timeoutMs = DEVIN_TIMEOUT_MS } = {}) {
55207
+ function quote(text) {
55208
+ return JSON.stringify(text.length > 200 ? `\u2026${text.slice(-199)}` : text);
55209
+ }
55210
+ function describeAttempt(attempt, timeoutMs) {
55211
+ const parts = [`${attempt.label}: ${attempt.bytes} bytes`];
55212
+ if (attempt.timedOut) parts.push(`timed out after ${timeoutMs / 1e3}s`);
55213
+ else parts.push(`exit ${attempt.code}`);
55214
+ if (attempt.stderr) parts.push(`stderr ${quote(attempt.stderr)}`);
55215
+ if (attempt.note) parts.push(attempt.note);
55216
+ return parts.join(", ");
55217
+ }
55218
+ function describeFailure(attempts, timeoutMs) {
55219
+ const bytes = Math.max(...attempts.map((a2) => a2.bytes), 0);
55220
+ const longest = attempts.find((a2) => a2.bytes === bytes);
55221
+ const tail = longest?.raw?.trimEnd().slice(-200) ?? "";
55222
+ const lines = [];
55223
+ if (bytes === 0) {
55224
+ lines.push(
55225
+ 'Unable to get model prices: `devin models list --format json` exited without writing the JSON to stdout (0 bytes). Something between devinp and the CLI \u2014 a pager, an interactive "click to show more" view, or a shell/agent wrapper around `devin` \u2014 kept the output for the screen.'
55226
+ );
55227
+ } else {
55228
+ let parseError = "";
55229
+ try {
55230
+ JSON.parse(stripAnsi(longest.raw));
55231
+ } catch (error) {
55232
+ parseError = `${error.message} \u2014 `;
55233
+ }
55234
+ lines.push(
55235
+ `Unable to get model prices: \`devin models list --format json\` returned invalid JSON (${parseError}${bytes} bytes, output ends with ${JSON.stringify(tail)}). The output may have been truncated mid-stream.`
55236
+ );
55237
+ }
55238
+ lines.push(`Tried: ${attempts.map((a2) => describeAttempt(a2, timeoutMs)).join("; ")}.`);
55239
+ lines.push("Capture the JSON yourself and hand it to devinp \u2014 piping or redirecting makes `devin` write the whole document in one go:");
55240
+ lines.push(" devin models list --format json > models.json && devinp --source models.json");
55241
+ lines.push(" devin models list --format json | devinp --source=-");
55242
+ lines.push("If a plain `devin models list --format json > models.json` still prints a paged view, update the CLI (`devin update`).");
55243
+ return lines.join("\n");
55244
+ }
55245
+ async function readStdin() {
55246
+ const chunks = [];
55247
+ for await (const chunk of process.stdin) chunks.push(chunk);
55248
+ return Buffer.concat(chunks.map((c) => typeof c === "string" ? Buffer.from(c) : c)).toString("utf8");
55249
+ }
55250
+ async function loadModelData({
55251
+ source,
55252
+ command = "devin",
55253
+ timeoutMs = DEVIN_TIMEOUT_MS,
55254
+ readStdinFrom = readStdin
55255
+ } = {}) {
55140
55256
  if (source) {
55141
- let raw2;
55257
+ const label = source === "-" ? "stdin" : `"${source}"`;
55258
+ let raw;
55142
55259
  try {
55143
- raw2 = await readFile2(source, "utf8");
55260
+ raw = source === "-" ? await readStdinFrom() : await readFile2(source, "utf8");
55144
55261
  } catch (error) {
55145
- throw new Error(`Unable to get model prices: cannot read source file "${source}" (${error.code ?? error.message})`);
55262
+ throw new Error(`Unable to get model prices: cannot read ${source === "-" ? "stdin" : `source file "${source}"`} (${error.code ?? error.message})`);
55146
55263
  }
55147
55264
  try {
55148
- return { data: assertFamilies(JSON.parse(raw2), source) };
55265
+ return { data: assertFamilies(JSON.parse(raw), label), retries: 0 };
55149
55266
  } catch (error) {
55150
55267
  if (error.message.startsWith("Unable to get model prices")) throw error;
55151
- throw new Error(`Unable to get model prices: "${source}" is not valid JSON (expected \`devin models list --format json\` output)`);
55268
+ throw new Error(`Unable to get model prices: ${label} is not valid JSON (expected \`devin models list --format json\` output)`);
55152
55269
  }
55153
55270
  }
55154
- let raw;
55271
+ let outcome;
55155
55272
  try {
55156
- raw = await runDevinJson({ command, timeoutMs });
55273
+ outcome = await runDevinJson({ command, timeoutMs });
55157
55274
  } catch (error) {
55158
55275
  if (error.code === "ENOENT") {
55159
- throw new Error("Unable to get model prices: `devin` CLI not found in PATH. Install it or pass --source <file>.");
55276
+ throw new Error("Unable to get model prices: `devin` CLI not found in PATH. Install it, or pass --source <file> / --source=- to supply the JSON yourself.");
55160
55277
  }
55161
55278
  throw new Error(`Unable to get model prices: ${error.message}`);
55162
55279
  }
55163
- try {
55164
- return { data: assertFamilies(JSON.parse(raw), "devin models list") };
55165
- } catch (error) {
55166
- if (error.message.startsWith("Unable to get model prices")) throw error;
55167
- const recovered = recoverTruncatedJson(raw);
55168
- if (recovered != null) {
55169
- return { data: assertFamilies(recovered, "devin models list (recovered)"), truncated: true };
55170
- }
55171
- const tail = raw.trimEnd().slice(-200);
55172
- throw new Error(
55173
- `Unable to get model prices: \`devin models list --format json\` returned invalid JSON (${error.message} \u2014 ${raw.length} bytes, output ends with ${JSON.stringify(tail)}). The output may have been truncated mid-stream: re-run, or save the output with \`devin models list --format json > models.json\` and pass \`--source models.json\`.`
55174
- );
55280
+ if (outcome.parsed != null) {
55281
+ return { data: assertFamilies(outcome.parsed, "devin models list"), retries: outcome.attempts.length - 1 };
55282
+ }
55283
+ const recovered = recoverTruncatedJson(outcome.raw);
55284
+ if (recovered != null) {
55285
+ return { data: assertFamilies(recovered, "devin models list (recovered)"), truncated: true, retries: outcome.attempts.length - 1 };
55175
55286
  }
55287
+ throw new Error(describeFailure(outcome.attempts, outcome.timeoutMs));
55176
55288
  }
55177
55289
 
55178
55290
  // src/command.js
@@ -55189,7 +55301,8 @@ var Devinp = class _Devinp extends import_core.Command {
55189
55301
  "<%= config.bin %> -o=input",
55190
55302
  "<%= config.bin %> -o=bench -g=family",
55191
55303
  "<%= config.bin %> --web --port 8080",
55192
- "<%= config.bin %> --source sample/models.json"
55304
+ "<%= config.bin %> --source sample/models.json",
55305
+ "devin models list --format json | <%= config.bin %> --source=-"
55193
55306
  ];
55194
55307
  static flags = {
55195
55308
  order: import_core.Flags.string({
@@ -55214,7 +55327,7 @@ var Devinp = class _Devinp extends import_core.Command {
55214
55327
  dependsOn: ["web"]
55215
55328
  }),
55216
55329
  source: import_core.Flags.string({
55217
- summary: "load prices from a saved `devin models list --format json` file"
55330
+ summary: "load prices from a saved `devin models list --format json` file, or from stdin with --source=- (use it when the CLI pages its output instead of writing it to the pipe)"
55218
55331
  }),
55219
55332
  json: import_core.Flags.boolean({
55220
55333
  summary: "print normalized rows as JSON instead of a table",
@@ -55229,6 +55342,9 @@ var Devinp = class _Devinp extends import_core.Command {
55229
55342
  if (loaded.truncated) {
55230
55343
  this.warn("devin output was truncated mid-stream \u2014 showing partial data, re-run for the full list");
55231
55344
  }
55345
+ if (loaded.retries > 0) {
55346
+ this.warn(`devin needed ${loaded.retries + 1} attempts to hand over the model list \u2014 something on this machine (pager, paged "show more" view, wrapper) interferes with \`devin models list\``);
55347
+ }
55232
55348
  data = loaded.data;
55233
55349
  } catch (error) {
55234
55350
  this.error(error instanceof Error ? error.message : String(error), { exit: 1 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucasschirm/devinp",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Pretty pricing table for Devin models, powered by `devin models list`",
5
5
  "type": "module",
6
6
  "bin": {