@lucasschirm/devinp 0.1.11 → 0.1.14

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 +47 -13
  2. package/bin/command.js +61 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -31,14 +31,32 @@ when a script calls it: the model list comes back paged ("click to show more")
31
31
  and the JSON never reaches stdout, so `devinp` used to die with *"returned
32
32
  invalid JSON (… 0 bytes)"*. It now works harder to get the whole document:
33
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.
34
+ - **A pipe first, so `TMPDIR` cannot break anything.** The happy path captures
35
+ `devin`'s stdout over a pipe — no scratch file, no permissions, no sandbox.
36
+ Measured against the real CLI, the piped document is byte-identical to the
37
+ file capture (471 855 bytes, 46 families), so the old "pipes truncate" worry
38
+ that put a temp file first was misplaced; an unwritable or missing `TMPDIR`
39
+ used to cost two extra `devin` runs, and a missing one even reported *"`devin`
40
+ CLI not found in PATH"*.
41
+ - **Retries that take the terminal away from `devin`.** If an attempt yields no
42
+ usable JSON, `devinp` runs `devin` again, each time stripping more of the
43
+ interactive terminal off it:
44
+
45
+ | # | stdout goes to | environment | controlling terminal |
46
+ | - | ------------ | -------------------------------------------------------- | --------------------- |
47
+ | 1 | pipe | yours, untouched | yes — the healthy path, one `devin` call |
48
+ | 2 | pipe | `PAGER=cat`, `GIT_PAGER=cat`, `TERM=dumb`, `NO_COLOR=1`, `CI=1` | **none** |
49
+ | 3 | scratch file | same | **none** |
50
+
51
+ Spawned in its own session, `devin` cannot open `/dev/tty`: a pager or a TUI
52
+ that paints a "click to show more" view onto the terminal has nowhere to
53
+ draw, so the document has to come out of stdout. Nothing lands on the screen
54
+ you are typing in, and Ctrl-C there no longer kills the lookup. The first
55
+ attempt that parses wins, so the healthy path is unchanged.
56
+ - **The scratch file (attempt 3) walks candidate directories** — `DEVINP_TMP`,
57
+ `os.tmpdir()`, `~/.devinp`, then the current directory — and uses the first
58
+ one it can actually write, removing the file afterwards. Set `DEVINP_TMP` to
59
+ pin it somewhere you control.
42
60
  - Noise around the document is tolerated: ANSI colours, an update banner in
43
61
  front, or the `pass a slug to --model` hint behind are stripped before the
44
62
  JSON is parsed.
@@ -46,9 +64,11 @@ invalid JSON (… 0 bytes)"*. It now works harder to get the whole document:
46
64
  prints a warning, and renders the partial table.
47
65
  - A real CLI failure (not signed in, network down) is reported as-is instead of
48
66
  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:
67
+ - If nothing worked, the error lists all three attempts — bytes, exit code,
68
+ stderr, timeout — and how to feed the data in yourself. That always works,
69
+ because a redirect or a pipe makes `devin` write the whole document in one go:
70
+ (and if it does not, `command -v devin` tells you whether devinp is even
71
+ talking to the CLI and not to a shell function or agent wrapper around it).
52
72
 
53
73
  ```sh
54
74
  devin models list --format json > models.json && devinp --source models.json
@@ -125,11 +145,25 @@ so `npm publish`, git-URL installs, and `npm link` always ship a fresh bundle.
125
145
 
126
146
  ## Publishing
127
147
 
128
- `.github/workflows/publish.yml` bumps the patch version and publishes with
129
- [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC —
148
+ `.github/workflows/publish.yml` publishes a patch release on every push to main
149
+ with [npm trusted publishing](https://docs.npmjs.org/trusted-publishers) (OIDC —
130
150
  no `NPM_TOKEN`). Requires the trusted publisher to be configured on npmjs.com:
131
151
  repository `lucasschirm/devinp`, workflow `publish.yml`.
132
152
 
153
+ It **never commits to main** — the branch ruleset requires every change to
154
+ arrive through a pull request, and a rejected release commit used to strand the
155
+ publish step that ran after it (GH013, then `npm publish` skipped). The released
156
+ version therefore lives in the npm registry and in the git tag, not in main's
157
+ `package.json`:
158
+
159
+ ```sh
160
+ git ls-remote --tags origin # released versions
161
+ npm view @lucasschirm/devinp version # latest published
162
+ ```
163
+
164
+ The workflow takes the first patch number that is free in both places, so a tag
165
+ left behind by an older run cannot wedge it.
166
+
133
167
  ## License
134
168
 
135
169
  MIT
package/bin/command.js CHANGED
@@ -26885,7 +26885,7 @@ var require_sonic_boom = __commonJS({
26885
26885
  if (!(this instanceof SonicBoom)) {
26886
26886
  return new SonicBoom(opts);
26887
26887
  }
26888
- let { fd, dest, minLength, maxLength, maxWrite, periodicFlush, sync, append = true, mkdir, retryEAGAIN, fsync, contentMode, mode } = opts || {};
26888
+ let { fd, dest, minLength, maxLength, maxWrite, periodicFlush, sync, append = true, mkdir: mkdir2, retryEAGAIN, fsync, contentMode, mode } = opts || {};
26889
26889
  fd = fd || dest;
26890
26890
  this._len = 0;
26891
26891
  this.fd = -1;
@@ -26910,7 +26910,7 @@ var require_sonic_boom = __commonJS({
26910
26910
  this.append = append || false;
26911
26911
  this.mode = mode;
26912
26912
  this.retryEAGAIN = retryEAGAIN || (() => true);
26913
- this.mkdir = mkdir || false;
26913
+ this.mkdir = mkdir2 || false;
26914
26914
  let fsWriteSync;
26915
26915
  let fsWrite;
26916
26916
  if (contentMode === kContentModeBuffer) {
@@ -55024,8 +55024,8 @@ async function startServer({ rows, benchMeta, port, log }) {
55024
55024
 
55025
55025
  // src/lib/source.js
55026
55026
  import { spawn } from "node:child_process";
55027
- import { open, readFile as readFile2, unlink } from "node:fs/promises";
55028
- import { tmpdir } from "node:os";
55027
+ import { mkdir, open, readFile as readFile2, unlink } from "node:fs/promises";
55028
+ import { homedir, tmpdir } from "node:os";
55029
55029
  import { join as join2 } from "node:path";
55030
55030
 
55031
55031
  // src/lib/repair-json.js
@@ -55095,10 +55095,11 @@ var NON_INTERACTIVE_ENV = {
55095
55095
  NO_COLOR: "1",
55096
55096
  CI: "1"
55097
55097
  };
55098
+ var DETACHED_SPAWN = { detached: true, windowsHide: true };
55098
55099
  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 }
55100
+ { label: "pipe", file: false, env: null },
55101
+ { label: "pipe, paging disabled, no controlling terminal", file: false, env: NON_INTERACTIVE_ENV, detached: true },
55102
+ { label: "temp file, paging disabled, no controlling terminal", file: true, env: NON_INTERACTIVE_ENV, detached: true }
55102
55103
  ];
55103
55104
  var ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][\s\S]*?(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
55104
55105
  function stripAnsi(text) {
@@ -55120,11 +55121,12 @@ function parseJsonLoosely(text) {
55120
55121
  }
55121
55122
  return null;
55122
55123
  }
55123
- function spawnDevin({ command, env, stdout, timeoutMs }) {
55124
+ function spawnDevin({ command, env, stdout, timeoutMs, detached = false }) {
55124
55125
  return new Promise((resolve, reject) => {
55125
55126
  const child = spawn(command, ["models", "list", "--format", "json"], {
55126
55127
  stdio: ["ignore", stdout, "pipe"],
55127
- ...env ? { env: { ...process.env, ...env } } : {}
55128
+ ...env ? { env: { ...process.env, ...env } } : {},
55129
+ ...detached ? DETACHED_SPAWN : {}
55128
55130
  });
55129
55131
  let stdoutText = "";
55130
55132
  let stderr = "";
@@ -55140,6 +55142,7 @@ function spawnDevin({ command, env, stdout, timeoutMs }) {
55140
55142
  stderr += chunk;
55141
55143
  });
55142
55144
  child.on("error", (error) => {
55145
+ error.devinSpawn = true;
55143
55146
  clearTimeout(timer);
55144
55147
  reject(error);
55145
55148
  });
@@ -55149,11 +55152,41 @@ function spawnDevin({ command, env, stdout, timeoutMs }) {
55149
55152
  });
55150
55153
  });
55151
55154
  }
55152
- async function captureToFile({ command, env, timeoutMs }) {
55153
- const file = join2(tmpdir(), `devinp-models-${process.pid}-${Date.now()}.json`);
55154
- const handle = await open(file, "w");
55155
+ async function writableScratchDir() {
55156
+ const candidates = [];
55157
+ if (process.env.DEVINP_TMP) candidates.push(process.env.DEVINP_TMP);
55158
+ candidates.push(tmpdir(), join2(homedir(), ".devinp"), process.cwd());
55159
+ for (const dir of candidates) {
55160
+ try {
55161
+ await mkdir(dir, { recursive: true });
55162
+ const probe = join2(dir, `.devinp-probe-${process.pid}`);
55163
+ const handle = await open(probe, "w");
55164
+ await handle.close();
55165
+ await unlink(probe);
55166
+ return dir;
55167
+ } catch {
55168
+ }
55169
+ }
55170
+ return null;
55171
+ }
55172
+ async function captureToFile({ command, env, timeoutMs, detached }) {
55173
+ const dir = await writableScratchDir();
55174
+ if (!dir) {
55175
+ const error = new Error(`cannot write a scratch file \u2014 DEVINP_TMP, os.tmpdir() ("${tmpdir()}"), ~/.devinp and "${process.cwd()}" all refused`);
55176
+ error.scratchPath = dir;
55177
+ throw error;
55178
+ }
55179
+ const file = join2(dir, `devinp-models-${process.pid}-${Date.now()}.json`);
55180
+ let handle;
55155
55181
  try {
55156
- const result = await spawnDevin({ command, env, stdout: handle.fd, timeoutMs });
55182
+ handle = await open(file, "w");
55183
+ } catch (error) {
55184
+ error.message = `cannot write scratch file "${file}" (${error.code ?? error.message})`;
55185
+ error.scratchPath = file;
55186
+ throw error;
55187
+ }
55188
+ try {
55189
+ const result = await spawnDevin({ command, env, stdout: handle.fd, timeoutMs, detached });
55157
55190
  result.stdout = await readFile2(file, "utf8");
55158
55191
  return result;
55159
55192
  } finally {
@@ -55167,9 +55200,9 @@ async function runDevinJson({ command = "devin", timeoutMs = DEVIN_TIMEOUT_MS }
55167
55200
  for (const [index, strategy] of STRATEGIES.entries()) {
55168
55201
  let result;
55169
55202
  try {
55170
- result = strategy.file ? await captureToFile({ command, env: strategy.env, timeoutMs }) : await spawnDevin({ command, env: strategy.env, stdout: "pipe", timeoutMs });
55203
+ result = strategy.file ? await captureToFile({ command, env: strategy.env, timeoutMs, detached: strategy.detached }) : await spawnDevin({ command, env: strategy.env, stdout: "pipe", timeoutMs, detached: strategy.detached });
55171
55204
  } catch (error) {
55172
- if (error.code === "ENOENT") throw error;
55205
+ if (error.code === "ENOENT" && error.devinSpawn) throw error;
55173
55206
  attempts.push({ label: strategy.label, bytes: 0, code: null, stderr: "", timedOut: false, note: error.message });
55174
55207
  continue;
55175
55208
  }
@@ -55183,7 +55216,7 @@ async function runDevinJson({ command = "devin", timeoutMs = DEVIN_TIMEOUT_MS }
55183
55216
  timedOut: result.timedOut,
55184
55217
  raw
55185
55218
  });
55186
- if (parsed != null) return { raw, parsed, attempts, timeoutMs };
55219
+ if (parsed != null) return { raw, parsed, attempts, timeoutMs, via: strategy.label };
55187
55220
  if (index === 0 && result.code !== 0 && !result.timedOut && result.stderr.trim()) {
55188
55221
  throw new Error(`\`devin models list\` failed \u2014 ${result.stderr.trim()}`);
55189
55222
  }
@@ -55192,8 +55225,8 @@ async function runDevinJson({ command = "devin", timeoutMs = DEVIN_TIMEOUT_MS }
55192
55225
  if (explained && attempts.every((a2) => a2.timedOut || a2.code !== 0)) {
55193
55226
  throw new Error(`\`devin models list\` failed \u2014 ${explained.stderr}`);
55194
55227
  }
55195
- const longest = attempts.reduce((acc, a2) => (a2.raw?.length ?? 0) > acc.length ? a2.raw : acc, "");
55196
- return { raw: longest || "", parsed: null, attempts, timeoutMs };
55228
+ const longest = attempts.reduce((acc, a2) => (a2.raw?.length ?? 0) >= acc.length ? a2.raw : acc, "");
55229
+ return { raw: longest || "", parsed: null, attempts, timeoutMs, via: null };
55197
55230
  }
55198
55231
  function assertFamilies(data, sourceName) {
55199
55232
  if (data == null || typeof data !== "object" || !Array.isArray(data.families)) {
@@ -55222,7 +55255,7 @@ function describeFailure(attempts, timeoutMs) {
55222
55255
  const lines = [];
55223
55256
  if (bytes === 0) {
55224
55257
  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.'
55258
+ 'Unable to get model prices: `devin models list --format json` exited without writing the JSON to stdout (0 bytes). devinp re-ran it with paging disabled and in a session with no controlling terminal, so the CLI had no terminal to paint a "click to show more" view on \u2014 whatever swallowed the output sits between devinp and `devin` (a shell function or agent wrapper around it, or a broken install).'
55226
55259
  );
55227
55260
  } else {
55228
55261
  let parseError = "";
@@ -55236,10 +55269,13 @@ function describeFailure(attempts, timeoutMs) {
55236
55269
  );
55237
55270
  }
55238
55271
  lines.push(`Tried: ${attempts.map((a2) => describeAttempt(a2, timeoutMs)).join("; ")}.`);
55272
+ if (attempts.some((a2) => a2.note?.includes("scratch"))) {
55273
+ lines.push("devinp could not write its scratch file \u2014 point it somewhere writable with `DEVINP_TMP=/tmp devinp`.");
55274
+ }
55239
55275
  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
55276
  lines.push(" devin models list --format json > models.json && devinp --source models.json");
55241
55277
  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`).");
55278
+ lines.push("Check `command -v devin` \u2014 if that is not the real CLI, devinp is asking the wrong program. If a plain redirect still prints a paged view, update the CLI (`devin update`).");
55243
55279
  return lines.join("\n");
55244
55280
  }
55245
55281
  async function readStdin() {
@@ -55262,7 +55298,7 @@ async function loadModelData({
55262
55298
  throw new Error(`Unable to get model prices: cannot read ${source === "-" ? "stdin" : `source file "${source}"`} (${error.code ?? error.message})`);
55263
55299
  }
55264
55300
  try {
55265
- return { data: assertFamilies(JSON.parse(raw), label), retries: 0 };
55301
+ return { data: assertFamilies(JSON.parse(raw), label), retries: 0, via: source === "-" ? "stdin" : "file" };
55266
55302
  } catch (error) {
55267
55303
  if (error.message.startsWith("Unable to get model prices")) throw error;
55268
55304
  throw new Error(`Unable to get model prices: ${label} is not valid JSON (expected \`devin models list --format json\` output)`);
@@ -55272,17 +55308,17 @@ async function loadModelData({
55272
55308
  try {
55273
55309
  outcome = await runDevinJson({ command, timeoutMs });
55274
55310
  } catch (error) {
55275
- if (error.code === "ENOENT") {
55311
+ if (error.code === "ENOENT" && error.devinSpawn) {
55276
55312
  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.");
55277
55313
  }
55278
55314
  throw new Error(`Unable to get model prices: ${error.message}`);
55279
55315
  }
55280
55316
  if (outcome.parsed != null) {
55281
- return { data: assertFamilies(outcome.parsed, "devin models list"), retries: outcome.attempts.length - 1 };
55317
+ return { data: assertFamilies(outcome.parsed, "devin models list"), retries: outcome.attempts.length - 1, via: outcome.via };
55282
55318
  }
55283
55319
  const recovered = recoverTruncatedJson(outcome.raw);
55284
55320
  if (recovered != null) {
55285
- return { data: assertFamilies(recovered, "devin models list (recovered)"), truncated: true, retries: outcome.attempts.length - 1 };
55321
+ return { data: assertFamilies(recovered, "devin models list (recovered)"), truncated: true, retries: outcome.attempts.length - 1, via: outcome.via ?? "repaired" };
55286
55322
  }
55287
55323
  throw new Error(describeFailure(outcome.attempts, outcome.timeoutMs));
55288
55324
  }
@@ -55343,7 +55379,7 @@ var Devinp = class _Devinp extends import_core.Command {
55343
55379
  this.warn("devin output was truncated mid-stream \u2014 showing partial data, re-run for the full list");
55344
55380
  }
55345
55381
  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\``);
55382
+ this.warn(`devin needed ${loaded.retries + 1} attempts to hand over the model list \u2014 the data arrived via ${loaded.via ?? "a fallback"} (something on this machine interferes with \`devin models list\`; DEVINP_TMP sets the scratch directory)`);
55347
55383
  }
55348
55384
  data = loaded.data;
55349
55385
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucasschirm/devinp",
3
- "version": "0.1.11",
3
+ "version": "0.1.14",
4
4
  "description": "Pretty pricing table for Devin models, powered by `devin models list`",
5
5
  "type": "module",
6
6
  "bin": {