@kaydash9999/banker-plugins 0.20.2 → 0.20.5

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 (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +179 -1
  4. package/PRIVACY.md +13 -3
  5. package/README.md +25 -2
  6. package/bin/banker.js +84 -40
  7. package/bin/lib/telemetry-config.mjs +31 -16
  8. package/codex/manifest.json +1 -1
  9. package/codex/transform-matrix.md +1 -1
  10. package/commands/setup.md +23 -0
  11. package/hooks/progress.mjs +143 -38
  12. package/hooks/register.mjs +13 -3
  13. package/hooks/update-checkin.mjs +15 -7
  14. package/hooks/update-fetch.mjs +15 -9
  15. package/hooks/update-notify.mjs +2 -9
  16. package/package.json +1 -1
  17. package/skills/3d-intro-build/SKILL.md +11 -5
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  19. package/skills/3d-intro-build/references/curate.mjs +48 -14
  20. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  21. package/skills/3d-intro-build/references/prompts.md +4 -4
  22. package/skills/3d-intro-build/references/serve.mjs +6 -1
  23. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  24. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  25. package/skills/3d-intro-setup/SKILL.md +41 -10
  26. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  28. package/skills/anti-drm/SKILL.md +6 -3
  29. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  30. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  31. package/skills/audit-mock/SKILL.md +1 -1
  32. package/skills/audit-security/SKILL.md +64 -2
  33. package/skills/deep-research/SKILL.md +5 -0
  34. package/skills/docs-setup/SKILL.md +37 -7
  35. package/skills/lineage/SKILL.md +12 -12
  36. package/skills/lineage/lineage.py +399 -300
  37. package/skills/motion-graphic-make/SKILL.md +1 -1
  38. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  39. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  40. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  41. package/skills/motion-graphic-setup/SKILL.md +58 -13
  42. package/skills/obsidizer/SKILL.md +1 -1
  43. package/skills/obsidizer/obsidize.mjs +8 -1
  44. package/skills/omc-patch/SKILL.md +80 -23
  45. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  46. package/skills/payload-mon/SKILL.md +4 -2
  47. package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
  48. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  49. package/skills/pdf-vision-extract/SKILL.md +11 -6
  50. package/skills/ralph-qa/SKILL.md +125 -151
  51. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  52. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  53. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  54. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  55. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  56. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  57. package/skills/setup-harness-factory/SKILL.md +17 -1
  58. package/skills/setup-insane-search/SKILL.md +16 -0
  59. package/skills/setup-java/SKILL.md +20 -1
  60. package/skills/setup-lsp/SKILL.md +56 -13
  61. package/skills/setup-mcp/SKILL.md +73 -4
  62. package/skills/setup-node/SKILL.md +31 -6
  63. package/skills/setup-omc/SKILL.md +24 -0
  64. package/skills/setup-omc-hud/SKILL.md +9 -6
  65. package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
  66. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  67. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  68. package/skills/setup-playwright/SKILL.md +45 -2
  69. package/skills/setup-pwsh/SKILL.md +19 -0
  70. package/skills/setup-python/SKILL.md +45 -7
  71. package/skills/setup-sandbox/SKILL.md +30 -5
  72. package/skills/setup-stitch/SKILL.md +22 -0
  73. package/skills/setup-tmux/SKILL.md +24 -4
  74. package/skills/tone-compact/SKILL.md +2 -1
  75. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  76. package/skills/ultra-chain/SKILL.md +4 -3
  77. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  78. package/skills/ultra-chain/references/serve.mjs +6 -1
  79. package/skills/ultra-interview/SKILL.md +1 -0
  80. package/skills/update-banker/SKILL.md +19 -3
  81. package/skills/vertical-pptx/SKILL.md +6 -3
  82. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -0,0 +1,205 @@
1
+ /**
2
+ * seat-spawn: how the ralph-qa runner (seat-run.mjs) finds a seat CLI and runs it inside its
3
+ * time limit, with every process the seat started ended when the run returns.
4
+ *
5
+ * which(name) the CLI on PATH, from absolute PATH folders only. libuv on Windows looks in the
6
+ * child's working folder before PATH for a bare name, so the runner always starts
7
+ * a CLI by the absolute path this gives. Windows tries .exe, then .cmd and .bat
8
+ * in each folder, never an extensionless file.
9
+ * cmdLine(exe, args) the cmd.exe line for a .cmd or .bat seat (Node refuses to start those
10
+ * without a shell since CVE-2024-27980). Every word is quoted by the MSVC rules;
11
+ * a word holding what cmd.exe reads itself (% ! ^ & | < >, a line break, NUL,
12
+ * or a parenthesis beside a quote, which cmd's quote toggling over \" can bare)
13
+ * makes it null: the seat does not start.
14
+ * runLimited(opts) starts the seat with stdin, stdout and stderr on files and resolves with
15
+ * { exit, signal, timedOut, stopped, secs, error, leftover }. On POSIX the seat
16
+ * gets its own process group: the limit sends the group TERM and, after the grace,
17
+ * KILL; a seat that exits on its own has its group sent KILL at once, so nothing it
18
+ * left (a late writer, an MCP server) outlives the run. On Windows the limit ends
19
+ * the tree with System32's taskkill /T /F; a process left after the seat exits on
20
+ * its own is not reached (Windows keeps no group).
21
+ * stopAll() ends every seat still running (TERM, then KILL 2 s later; taskkill on Windows).
22
+ *
23
+ * No shell and no timeout program: the limit is a timer here, so Git Bash's or System32's
24
+ * timeout never comes into it.
25
+ */
26
+ import { spawn, spawnSync } from "node:child_process";
27
+ import { accessSync, closeSync, constants, openSync, statSync } from "node:fs";
28
+ import { posix, win32 } from "node:path";
29
+
30
+ const running = new Set();
31
+ const STOP_GRACE_MS = 2000;
32
+ // How long a run waits for a seat to go after its KILL before it gives up on it (`leftover`).
33
+ const BACKSTOP_MS = 10000;
34
+
35
+ const system32 = (file) => win32.join(process.env.SystemRoot || "C:\\Windows", "System32", file);
36
+
37
+ function pathOf(env, platform) {
38
+ if (platform !== "win32") return env.PATH || "";
39
+ const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH");
40
+ return key ? env[key] || "" : "";
41
+ }
42
+
43
+ const runnable = (platform) => (file) => {
44
+ try {
45
+ if (!statSync(file).isFile()) return false;
46
+ if (platform !== "win32") accessSync(file, constants.X_OK);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ };
52
+
53
+ export function which(name, opts = {}) {
54
+ const platform = opts.platform || process.platform;
55
+ const paths = platform === "win32" ? win32 : posix;
56
+ const exts = platform === "win32" ? [".exe", ".cmd", ".bat"] : [""];
57
+ const isFile = opts.isFile || runnable(platform);
58
+ const dirs = pathOf(opts.env || process.env, platform).split(paths.delimiter).filter((dir) => dir && paths.isAbsolute(dir));
59
+ for (const dir of dirs) {
60
+ for (const ext of exts) {
61
+ const file = paths.join(dir, name + ext);
62
+ if (isFile(file)) return file;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ const CMD_META = /[%!^&|<>\r\n\0]/;
69
+ const cmdSafe = (w) => !CMD_META.test(w) && !(/[()]/.test(w) && w.includes('"'));
70
+ const quoteWord = (w) => `"${w.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`;
71
+
72
+ export function cmdLine(exe, args) {
73
+ const words = [exe, ...args].map(String);
74
+ return words.every(cmdSafe) ? words.map(quoteWord).join(" ") : null;
75
+ }
76
+
77
+ // The files the seat reads and writes, opened for it; "ignore" is passed through.
78
+ function openStdio({ stdin, stdout, stderr }) {
79
+ const fds = [];
80
+ const open = (path, flags) => {
81
+ if (path === undefined || path === "ignore") return "ignore";
82
+ const fd = openSync(path, flags, 0o600);
83
+ fds.push(fd);
84
+ return fd;
85
+ };
86
+ try {
87
+ return { stdio: [open(stdin, "r"), open(stdout, "w"), open(stderr, "w")], fds };
88
+ } catch (e) {
89
+ for (const fd of fds) closeSync(fd);
90
+ throw e;
91
+ }
92
+ }
93
+
94
+ // The child process for the seat: a .cmd or .bat through System32's cmd.exe, anything else as is.
95
+ function launch({ exe, args, cwd, env, platform }, stdio) {
96
+ const viaCmd = platform === "win32" && /\.(cmd|bat)$/i.test(exe);
97
+ if (!viaCmd) return spawn(exe, args, { cwd, env, stdio, windowsHide: true, detached: platform !== "win32" });
98
+ const line = cmdLine(exe, args);
99
+ if (line === null) throw Object.assign(new Error("a word cmd.exe would read"), { code: "unsafe-word" });
100
+ return spawn(system32("cmd.exe"), ["/d", "/v:off", "/s", "/c", `"${line}"`],
101
+ { cwd, env, stdio, windowsHide: true, windowsVerbatimArguments: true });
102
+ }
103
+
104
+ const signalGroup = (pid, sig) => {
105
+ try {
106
+ process.kill(-pid, sig);
107
+ } catch {
108
+ /* the group is gone */
109
+ }
110
+ };
111
+ const taskkill = (pid) => spawnSync(system32("taskkill.exe"), ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", windowsHide: true });
112
+
113
+ // Ends a seat: TERM to its group and KILL after `grace` (POSIX), or taskkill on its tree (Windows).
114
+ function terminate(seat, why, grace) {
115
+ if (seat.ending) return;
116
+ seat.ending = why;
117
+ if (seat.platform === "win32") {
118
+ taskkill(seat.child.pid);
119
+ seat.backstop = setTimeout(() => seat.finish(true), BACKSTOP_MS);
120
+ return;
121
+ }
122
+ signalGroup(seat.child.pid, "SIGTERM");
123
+ seat.killTimer = setTimeout(() => {
124
+ seat.killed = true;
125
+ signalGroup(seat.child.pid, "SIGKILL");
126
+ if (seat.exited) seat.finish(false);
127
+ else seat.backstop = setTimeout(() => seat.finish(true), BACKSTOP_MS);
128
+ }, grace);
129
+ }
130
+
131
+ // The seat's own exit: on POSIX its group is sent KILL now, or when the grace ends if it is being
132
+ // ended, so nothing it left keeps running.
133
+ function onExit(seat, code, signal) {
134
+ if (seat.child.pid === undefined) return undefined; // a seat that never started: its error ends the run
135
+ seat.exited = { code, signal };
136
+ clearTimeout(seat.limitTimer);
137
+ if (seat.platform === "win32") return seat.finish(false);
138
+ if (seat.ending && !seat.killed) return undefined;
139
+ signalGroup(seat.child.pid, "SIGKILL");
140
+ return seat.finish(false);
141
+ }
142
+
143
+ function watch(seat, opts) {
144
+ const { child } = seat;
145
+ const grace = opts.graceMs ?? Math.min(10000, opts.limitMs / 2);
146
+ seat.limitTimer = setTimeout(() => terminate(seat, "limit", grace), opts.limitMs);
147
+ child.on("exit", (code, signal) => onExit(seat, code, signal));
148
+ child.on("error", (e) => {
149
+ if (child.pid === undefined) seat.finish(false, e.code || "spawn");
150
+ });
151
+ }
152
+
153
+ function result(seat, t0, leftover, error) {
154
+ const { code = null, signal = null } = seat.exited || {};
155
+ return {
156
+ exit: error ? null : code, signal: error ? null : signal, timedOut: seat.ending === "limit", stopped: seat.ending === "stop",
157
+ secs: Math.round((Date.now() - t0) / 100) / 10, error: error || null, leftover,
158
+ };
159
+ }
160
+
161
+ export function runLimited(opts) {
162
+ const t0 = Date.now();
163
+ const platform = opts.platform || process.platform;
164
+ return new Promise((done) => {
165
+ let io;
166
+ try {
167
+ if (!statSync(opts.exe).isFile()) throw Object.assign(new Error("not a file"), { code: "ENOENT" });
168
+ io = openStdio(opts);
169
+ } catch (e) {
170
+ done(result({}, t0, false, e.code || "spawn"));
171
+ return;
172
+ }
173
+ const seat = { platform };
174
+ seat.finish = (leftover, error) => {
175
+ if (seat.done) return;
176
+ seat.done = true;
177
+ for (const t of [seat.limitTimer, seat.killTimer, seat.backstop]) clearTimeout(t);
178
+ running.delete(seat);
179
+ done(result(seat, t0, leftover, error));
180
+ };
181
+ try {
182
+ seat.child = launch({ ...opts, args: opts.args || [], platform }, io.stdio);
183
+ } catch (e) {
184
+ seat.finish(false, e.code || "spawn");
185
+ return;
186
+ } finally {
187
+ for (const fd of io.fds) closeSync(fd);
188
+ }
189
+ running.add(seat);
190
+ watch(seat, opts);
191
+ });
192
+ }
193
+
194
+ export function stopAll() {
195
+ const seats = [...running];
196
+ const ends = seats.map((seat) => new Promise((resolve) => {
197
+ const finish = seat.finish;
198
+ seat.finish = (...a) => {
199
+ finish(...a);
200
+ resolve();
201
+ };
202
+ }));
203
+ for (const seat of seats) terminate(seat, "stop", STOP_GRACE_MS);
204
+ return Promise.all(ends);
205
+ }
@@ -205,6 +205,13 @@ const STRING_VALUE = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*(?:#.*)?$/s;
205
205
 
206
206
  // A dotted key's segments, quotes taken off (escapes kept as written).
207
207
  const keyPath = (text) => [...text.matchAll(SEG_RE)].map((m) => m[1] ?? m[2] ?? m[3]);
208
+ // True when a "basic" key segment holds an escape: codex reads the key the escape spells
209
+ // ("sandbox\u005Fmode" is sandbox_mode), and the probe keeps it as written.
210
+ const escapedKey = (text) => [...text.matchAll(SEG_RE)].some((m) => m[1]?.includes("\\"));
211
+ // Such a key leaves the read not whole: what codex reads there is not what the probe placed.
212
+ function markEscape(state, keyText) {
213
+ if (escapedKey(keyText)) state.unplaced = true;
214
+ }
208
215
 
209
216
  // The walk's state: where it is (open string, nesting depth, table, array being gathered) and
210
217
  // what it found so far.
@@ -305,10 +312,14 @@ function assignment(state, path, value) {
305
312
  if (str) state.values[path[0]] = str[1] ?? str[2];
306
313
  }
307
314
 
308
- // A table header: the table later keys go in, and the MCP server a [mcp_servers.<name>...] names.
315
+ // A table header: the table later keys go in, and the MCP server a [mcp_servers.<name>...] names. An array of tables
316
+ // under mcp_servers ([[mcp_servers.<name>]], quoted or not) is a server list the probe cannot read: codex refuses it
317
+ // ("invalid type: sequence", 0.144.5), so a seat on it would fail later for no reason the probe gave.
309
318
  function enterTable(state, header) {
310
- state.roots.add(keyPath(header[2])[0]);
311
- state.table = header[1] ? ["[[]]"] : keyPath(header[2]);
319
+ const path = keyPath(header[2]);
320
+ state.roots.add(path[0]);
321
+ state.table = header[1] ? ["[[]]"] : path;
322
+ if (header[1] && path[0] === "mcp_servers") state.mcpUnread = true;
312
323
  if (state.table[0] === "mcp_servers" && state.table.length > 1) state.mcpServers.add(state.table[1]);
313
324
  }
314
325
 
@@ -324,9 +335,15 @@ function tomlLine(state, line) {
324
335
  return;
325
336
  }
326
337
  const header = HEADER_RE.exec(line);
327
- if (header) return enterTable(state, header);
338
+ if (header) {
339
+ markEscape(state, header[2]);
340
+ return enterTable(state, header);
341
+ }
328
342
  const assign = ASSIGN_RE.exec(line);
329
- if (assign) return assignment(state, keyPath(assign[1]), assign[2]);
343
+ if (assign) {
344
+ markEscape(state, assign[1]);
345
+ return assignment(state, keyPath(assign[1]), assign[2]);
346
+ }
330
347
  if (!/^\s*(?:#|$)/.test(line)) state.unplaced = true;
331
348
  }
332
349
 
@@ -361,13 +378,25 @@ const rawMcpNames = (lines) => lines.filter((l) => !/^\s*#/.test(l))
361
378
  // defined (table headers, under [mcp_servers], dotted keys), whether servers were written in a
362
379
  // form it does not list or that the text names beyond what it listed (`mcpUnread`), whether any
363
380
  // table sets a model (`modelKeys`), whether a multi-line string, array or inline table was left
364
- // open or a line could not be placed (`broken`), and the strings of each top-level array
381
+ // open or a line could not be placed (`broken`, with `brokenAt`: the first line it could not place
382
+ // or the line that opened what is still open), and the strings of each top-level array
365
383
  // (`arrays`). Enough for those keys; not a TOML parser.
366
384
  export function readToml(text) {
367
385
  const state = tomlState();
368
386
  const lines = String(text ?? "").split(/\r?\n/);
369
- for (const line of lines) tomlLine(state, line);
387
+ const isOpen = () => state.open !== null || state.depth > 0;
388
+ // the first line it could not place, and the line that opened what is still open at the end
389
+ let badAt = 0;
390
+ let openAt = 0;
391
+ lines.forEach((line, i) => {
392
+ const wasOpen = isOpen();
393
+ const wasBad = state.unplaced;
394
+ tomlLine(state, line);
395
+ if (!wasBad && state.unplaced) badAt = i + 1;
396
+ if (!wasOpen && isOpen()) openAt = i + 1;
397
+ });
370
398
  const unlisted = rawMcpNames(lines).filter((n) => !state.mcpServers.has(n));
399
+ const broken = isOpen() || state.unplaced;
371
400
  return {
372
401
  values: state.values,
373
402
  roots: [...state.roots],
@@ -375,7 +404,8 @@ export function readToml(text) {
375
404
  mcpServers: [...state.mcpServers],
376
405
  mcpUnread: state.mcpUnread || unlisted.length > 0,
377
406
  modelKeys: state.modelKeys,
378
- broken: state.open !== null || state.depth > 0 || state.unplaced,
407
+ broken,
408
+ ...(broken ? { brokenAt: Math.min(...[badAt, isOpen() ? openAt : 0].filter(Boolean)) } : {}),
379
409
  };
380
410
  }
381
411
 
@@ -491,7 +521,8 @@ function codexConfigFiles(world) {
491
521
 
492
522
  // Those layers as one: the first layer with a value wins, MCP servers add up.
493
523
  function codexConfig(world) {
494
- const layers = codexConfigFiles(world).map((f) => readToml(world.readFile(f)));
524
+ const files = codexConfigFiles(world);
525
+ const layers = files.map((f) => readToml(world.readFile(f)));
495
526
  const pick = (key) => layers.map((l) => l.values[key]).find((v) => v !== undefined);
496
527
  return {
497
528
  cfg: { model: pick("model"), model_provider: pick("model_provider"), profile: pick("profile") },
@@ -499,6 +530,7 @@ function codexConfig(world) {
499
530
  mcpUnread: layers.some((l) => l.mcpUnread),
500
531
  modelKeys: layers.some((l) => l.modelKeys),
501
532
  broken: layers.some((l) => l.broken),
533
+ brokenAt: layers.map((l, k) => (l.broken ? `${files[k]} line ${l.brokenAt}` : null)).filter(Boolean),
502
534
  };
503
535
  }
504
536
 
@@ -512,8 +544,8 @@ function mcpBlockedSeat(seat, conf, notes) {
512
544
  const bad = conf.mcpServers.filter((n) => !MCP_NAME.test(n)).length;
513
545
  if (!bad && !conf.mcpUnread && !conf.broken) return null;
514
546
  if (bad) notes.push(`${bad} MCP server name(s) hold characters \`-c mcp_servers.<name>.enabled=false\` cannot address: rename them to letters, digits, _ and -`);
515
- if (conf.mcpUnread) notes.push("the probe could not list every MCP server the codex config defines (one inline mcp_servers table, or a name its walk did not place): write each server as its own [mcp_servers.<name>] table");
516
- if (conf.broken) notes.push("the probe could not read the codex config to its end (a multi-line string, array or inline table left open, or a line it could not place), so its list of MCP servers may be short");
547
+ if (conf.mcpUnread) notes.push("the probe could not list every MCP server the codex config defines (one inline mcp_servers table, an array of tables under mcp_servers, or a name its walk did not place): write each server as its own [mcp_servers.<name>] table");
548
+ if (conf.broken) notes.push(`the probe could not read the codex config to its end at ${conf.brokenAt.join(", ")} (a multi-line string, array or inline table left open, or a line it could not place, such as a quoted key with a \\ escape that codex reads as another key), so its list of MCP servers may be short`);
517
549
  return { ...seat, decision: "unseated", reason: "cli-call-failed" };
518
550
  }
519
551
 
@@ -530,7 +562,7 @@ function managedBlockedSeat(seat, world, notes) {
530
562
  const read = readToml(world.readFile(CODEX_MANAGED));
531
563
  const keys = read.roots.filter((k) => SEAT_KEYS.includes(k));
532
564
  if (!keys.length && !read.broken) return null;
533
- notes.push(`${CODEX_MANAGED} ${keys.length ? `sets ${keys.join(", ")}` : "could not be read to its end"}, which codex applies over the seat's -c flags: the seat cannot hold its guards`);
565
+ notes.push(`${CODEX_MANAGED} ${keys.length ? `sets ${keys.join(", ")}` : `could not be read to its end (line ${read.brokenAt})`}, which codex applies over the seat's -c flags: the seat cannot hold its guards`);
534
566
  return { ...seat, decision: "unseated", reason: "cli-call-failed" };
535
567
  }
536
568
 
@@ -551,7 +583,7 @@ function requirementsBlockedSeat(seat, world, notes) {
551
583
  const allows = (k) => SEAT_ALLOWS[k] !== undefined && (read.arrays[k] ?? []).includes(SEAT_ALLOWS[k]);
552
584
  const keys = read.roots.filter((k) => !GUARD_FREE.includes(k) && !allows(k));
553
585
  if (!keys.length && !read.broken) return null;
554
- notes.push(`${CODEX_REQUIREMENTS} ${keys.length ? `sets ${keys.join(", ")}` : "could not be read to its end"}, which codex holds over the seat's -c, --disable and -s flags: the seat cannot hold its guards`);
586
+ notes.push(`${CODEX_REQUIREMENTS} ${keys.length ? `sets ${keys.join(", ")}` : `could not be read to its end (line ${read.brokenAt})`}, which codex holds over the seat's -c, --disable and -s flags: the seat cannot hold its guards`);
555
587
  return { ...seat, decision: "unseated", reason: "cli-call-failed" };
556
588
  }
557
589
 
@@ -797,9 +829,11 @@ function opencodeModels(cfg) {
797
829
  return { configured: own, models: [own, ...listed] };
798
830
  }
799
831
 
832
+ const opencodeCache = (world) => join(world.env.XDG_CACHE_HOME || join(world.home, ".cache"), "opencode");
833
+
800
834
  // The providers opencode's local copy of the models.dev list knows; null when it cannot be read.
801
835
  function catalogProviders(world) {
802
- const file = join(world.env.XDG_CACHE_HOME || join(world.home, ".cache"), "opencode", "models.json");
836
+ const file = join(opencodeCache(world), "models.json");
803
837
  const read = readJsonc(world.readFile(file));
804
838
  return read.value ? new Set(Object.keys(read.value)) : null;
805
839
  }
@@ -835,6 +869,10 @@ function opencodeSeat(flag, { world, authorFamilies }) {
835
869
  notes.push(...cfg.unreadable.map((f) => `config not readable: ${f}`));
836
870
  if (world.env.OPENCODE_CONFIG_CONTENT) notes.push("$OPENCODE_CONFIG_CONTENT is not read: the seat command replaces it with its own reviewer agent");
837
871
  const catalog = catalogProviders(world);
872
+ // opencode empties its cache on a start after an update; with the list refresh off it fetches nothing
873
+ if (!catalog && world.readFile(join(opencodeCache(world), "version")) != null) {
874
+ notes.push("opencode's models.dev copy is missing or unreadable while its cache version file is there: an opencode start with the list refresh off (the seat and cleanup commands) empties the cache after an update; start opencode once, online and without OPENCODE_DISABLE_MODELS_FETCH, to fetch it again; offline it cannot come back: where the copy let the seat be adopted (single-candidate), each run asks instead (no-catalog), and a run that cannot ask seats the fallback");
875
+ }
838
876
  const closed = closedSet(cfg, catalog);
839
877
  const out = flagged(seat, flag, authorFamilies) ?? opencodeDecision(seat, { configured, unsure: cfg.unreadable.length > 0, closed, catalog });
840
878
  return withNotes(out, notes);
@@ -933,13 +971,16 @@ function realWorld() {
933
971
  const exe = onPath(cmd);
934
972
  if (!exe) return { status: 127, stdout: "" };
935
973
  // Windows runs .cmd and .bat shims through cmd.exe; the path is quoted and the arguments are fixed words.
974
+ // System32's cmd.exe (a bare name is looked for in the current folder first), told not to look there for the
975
+ // bare `node` an npm shim runs.
936
976
  const viaCmd = process.platform === "win32" && /\.(cmd|bat)$/i.test(exe);
977
+ const shell = viaCmd ? join(process.env.SystemRoot || "C:\\Windows", "System32", "cmd.exe") : false;
937
978
  const r = spawnSync(viaCmd ? `"${exe}"` : exe, args, {
938
979
  encoding: "utf8",
939
980
  timeout: 15000,
940
981
  maxBuffer: 64 * 1024 * 1024,
941
- shell: viaCmd,
942
- env: { ...process.env, ...env },
982
+ shell,
983
+ env: { ...process.env, ...(viaCmd ? { NoDefaultCurrentDirectoryInExePath: "1" } : {}), ...env },
943
984
  });
944
985
  return { status: r.status, stdout: r.stdout ?? "" };
945
986
  },
@@ -26,6 +26,22 @@ ls -la .claude/agents .claude/skills 2>/dev/null # 기존 harness 산출물
26
26
  - 기존 `.claude/agents/`·`.claude/skills/`·`CLAUDE.md`에 harness 흔적이 있으면 신규 생성이 아니라
27
27
  확장·유지보수로 다룬다(harness 자신의 Phase 0 감사 원칙).
28
28
 
29
+ ### 네트워크 확인 (내려받기 전)
30
+
31
+ 이 스킬이 내려받는 원천에 10초 안에 닿는지 먼저 본다. 닿지 않으면 그 원천에서 받는 단계는 하지 않고, 이미 설치된 것의 확인만 한 뒤 "오프라인이라 설치하지 않음(필요한 것: <못 깐 것>)" 으로 보고한다.
32
+
33
+ ```bash
34
+ [ "${CODEX_SANDBOX_NETWORK_DISABLED:-}" = 1 ] && echo "Codex sandbox 가 네트워크를 막음(아래 실패는 오프라인이 아님)"
35
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://github.com # 마켓플레이스, git clone
36
+ ```
37
+
38
+ - `curl -sSI` 는 머리만 받는다(본문을 받지 않음). 닿지 못하면 종료 코드가 0 이 아니다(HTTP 상태와 관계없음). `curl` 은 `HTTPS_PROXY` 를 따른다. 확인 줄은 `--ssl-no-revoke` 를 준다. Windows 의 Git Bash curl 도 Schannel 이라, 폐지 목록 서버에 닿지 못하는 망에서 폐지 확인이 실패하면 종료 60 이다(Windows 박스 실측). 다른 curl 은 이 깃발을 받고 무시한다(macOS curl 8.7.1, Rocky 8.10 curl 7.61.1 실측).
39
+ - Windows PowerShell 에서는 같은 주소를 `curl.exe --ssl-no-revoke -sSI -o NUL --connect-timeout 5 --max-time 10 <주소>` 로 보고 `$LASTEXITCODE` 가 0 인지 본다(PowerShell 5.1 의 `curl` 은 `Invoke-WebRequest` 별칭이다). `curl.exe` 는 Windows 시스템 프록시를 따르지 않고 `HTTPS_PROXY` 만 따른다. `--ssl-no-revoke` 는 폐지 목록 서버에 닿지 못하는 망에서 Schannel 의 폐지 확인 실패를 오프라인으로 읽지 않게 한다. 닿는지만 보고 받은 내용은 쓰지 않으므로 이 확인에만 준다. `curl.exe` 가 없으면 `$LASTEXITCODE` 에 앞 명령의 값이 남으므로 `Get-Command curl.exe -ErrorAction SilentlyContinue` 로 먼저 보고, 없으면 확인하지 못한 것으로 적는다.
40
+ - 사내 미러만 열린 망이면 확인 주소를 그 미러로 바꿔 본다.
41
+ - Codex sandbox 가 네트워크를 막는 모드(기본 `workspace-write`, `read-only`)에서는 `CODEX_SANDBOX_NETWORK_DISABLED=1` 이 서고 확인이 모두 실패한다(curl 종료 7, npm `EPERM`). 블록의 첫 줄이 그렇다고 알린다. 그때는 '오프라인' 이 아니라 'sandbox 가 네트워크를 막음' 으로 보고하고, 승인을 받아 sandbox 밖에서 확인부터 다시 한다. Claude Code sandbox 안(`SANDBOX_RUNTIME=1`)에서는 허용 목록 밖 주소를 sandbox 프록시가 막는다. Windows PowerShell 에서는 `$env:CODEX_SANDBOX_NETWORK_DISABLED -eq '1'` 로 본다.
42
+ - 확인이 실패하면 보고에 그 명령, 종료 코드, stderr 첫 줄을 붙인다(예: `npm error code ETIMEDOUT`, `curl: (28) ...`). 원인을 '오프라인' 하나로 짐작하지 않는다.
43
+ - curl 의 종료 60(인증서 검증 실패)도 오프라인이 아니다. 망은 열려 있고, TLS 를 가로채는 사내 프록시의 인증서를 OS 가 믿지 않는 것이다. '인증서 문제로 설치하지 않음' 으로 보고한다.
44
+
29
45
  ## 1. 설치
30
46
  ### Claude Code
31
47
  세션 안(슬래시 커맨드):
@@ -43,7 +59,7 @@ ls -la .claude/agents .claude/skills 2>/dev/null # 기존 harness 산출물
43
59
  ### Codex CLI
44
60
  마켓플레이스 개념이 없고 Python 인스톨러를 쓴다:
45
61
  ```bash
46
- git clone https://github.com/SaehwanPark/meta-harness
62
+ git -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=30 clone https://github.com/SaehwanPark/meta-harness
47
63
  cd meta-harness
48
64
  python3 scripts/install_harness.py --scope user --layout codex
49
65
  # 프로젝트 범위: python3 scripts/install_harness.py --scope project --target <repo> --layout codex
@@ -8,6 +8,22 @@ description: "차단이나 봇 방어가 있는 사이트도 우회해 검색하
8
8
  차단 사이트 우회(WAF·봇방어; X·Reddit·YouTube·GitHub 등) 검색·페치를 제공하는 `insane-search` 플러그인을
9
9
  설치한다. 현재 도구(Claude Code / Codex)를 감지해 알맞은 경로로 진행하고, 이미 설치돼 있으면 skip(멱등). 답변은 한글.
10
10
 
11
+ ## 0. 네트워크 확인 (내려받기 전)
12
+
13
+ 이 스킬이 내려받는 원천에 10초 안에 닿는지 먼저 본다. 닿지 않으면 그 원천에서 받는 단계는 하지 않고, 이미 설치된 것의 확인만 한 뒤 "오프라인이라 설치하지 않음(필요한 것: <못 깐 것>)" 으로 보고한다.
14
+
15
+ ```bash
16
+ [ "${CODEX_SANDBOX_NETWORK_DISABLED:-}" = 1 ] && echo "Codex sandbox 가 네트워크를 막음(아래 실패는 오프라인이 아님)"
17
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://github.com # 마켓플레이스(GitHub)
18
+ ```
19
+
20
+ - `curl -sSI` 는 머리만 받는다(본문을 받지 않음). 닿지 못하면 종료 코드가 0 이 아니다(HTTP 상태와 관계없음). `curl` 은 `HTTPS_PROXY` 를 따른다. 확인 줄은 `--ssl-no-revoke` 를 준다. Windows 의 Git Bash curl 도 Schannel 이라, 폐지 목록 서버에 닿지 못하는 망에서 폐지 확인이 실패하면 종료 60 이다(Windows 박스 실측). 다른 curl 은 이 깃발을 받고 무시한다(macOS curl 8.7.1, Rocky 8.10 curl 7.61.1 실측).
21
+ - Windows PowerShell 에서는 같은 주소를 `curl.exe --ssl-no-revoke -sSI -o NUL --connect-timeout 5 --max-time 10 <주소>` 로 보고 `$LASTEXITCODE` 가 0 인지 본다(PowerShell 5.1 의 `curl` 은 `Invoke-WebRequest` 별칭이다). `curl.exe` 는 Windows 시스템 프록시를 따르지 않고 `HTTPS_PROXY` 만 따른다. `--ssl-no-revoke` 는 폐지 목록 서버에 닿지 못하는 망에서 Schannel 의 폐지 확인 실패를 오프라인으로 읽지 않게 한다. 닿는지만 보고 받은 내용은 쓰지 않으므로 이 확인에만 준다. `curl.exe` 가 없으면 `$LASTEXITCODE` 에 앞 명령의 값이 남으므로 `Get-Command curl.exe -ErrorAction SilentlyContinue` 로 먼저 보고, 없으면 확인하지 못한 것으로 적는다.
22
+ - 사내 미러만 열린 망이면 확인 주소를 그 미러로 바꿔 본다.
23
+ - Codex sandbox 가 네트워크를 막는 모드(기본 `workspace-write`, `read-only`)에서는 `CODEX_SANDBOX_NETWORK_DISABLED=1` 이 서고 확인이 모두 실패한다(curl 종료 7, npm `EPERM`). 블록의 첫 줄이 그렇다고 알린다. 그때는 '오프라인' 이 아니라 'sandbox 가 네트워크를 막음' 으로 보고하고, 승인을 받아 sandbox 밖에서 확인부터 다시 한다. Claude Code sandbox 안(`SANDBOX_RUNTIME=1`)에서는 허용 목록 밖 주소를 sandbox 프록시가 막는다. Windows PowerShell 에서는 `$env:CODEX_SANDBOX_NETWORK_DISABLED -eq '1'` 로 본다.
24
+ - 확인이 실패하면 보고에 그 명령, 종료 코드, stderr 첫 줄을 붙인다(예: `npm error code ETIMEDOUT`, `curl: (28) ...`). 원인을 '오프라인' 하나로 짐작하지 않는다.
25
+ - curl 의 종료 60(인증서 검증 실패)도 오프라인이 아니다. 망은 열려 있고, TLS 를 가로채는 사내 프록시의 인증서를 OS 가 믿지 않는 것이다. '인증서 문제로 설치하지 않음' 으로 보고한다.
26
+
11
27
  ## Claude Code · 세션 안 (슬래시 커맨드)
12
28
  ```
13
29
  /plugin marketplace add https://github.com/fivetaku/gptaku_plugins.git
@@ -26,6 +26,25 @@ if (Get-Command java -ErrorAction SilentlyContinue) { java -version } else { "ja
26
26
  [Environment]::GetEnvironmentVariable('JAVA_HOME','User')
27
27
  ```
28
28
 
29
+ ### 네트워크 확인 (내려받기 전)
30
+
31
+ 이 스킬이 내려받는 원천에 10초 안에 닿는지 먼저 본다. 닿지 않으면 그 원천에서 받는 단계는 하지 않고, 이미 설치된 것의 확인만 한 뒤 "오프라인이라 설치하지 않음(필요한 것: <못 깐 것>)" 으로 보고한다.
32
+
33
+ ```bash
34
+ [ "${CODEX_SANDBOX_NETWORK_DISABLED:-}" = 1 ] && echo "Codex sandbox 가 네트워크를 막음(아래 실패는 오프라인이 아님)"
35
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://packages.adoptium.net # Debian 12 의 Adoptium 저장소
36
+ ```
37
+
38
+ - `curl -sSI` 는 머리만 받는다(본문을 받지 않음). 닿지 못하면 종료 코드가 0 이 아니다(HTTP 상태와 관계없음). `curl` 은 `HTTPS_PROXY` 를 따른다. 확인 줄은 `--ssl-no-revoke` 를 준다. Windows 의 Git Bash curl 도 Schannel 이라, 폐지 목록 서버에 닿지 못하는 망에서 폐지 확인이 실패하면 종료 60 이다(Windows 박스 실측). 다른 curl 은 이 깃발을 받고 무시한다(macOS curl 8.7.1, Rocky 8.10 curl 7.61.1 실측).
39
+ - Windows PowerShell 에서는 같은 주소를 `curl.exe --ssl-no-revoke -sSI -o NUL --connect-timeout 5 --max-time 10 <주소>` 로 보고 `$LASTEXITCODE` 가 0 인지 본다(PowerShell 5.1 의 `curl` 은 `Invoke-WebRequest` 별칭이다). `curl.exe` 는 Windows 시스템 프록시를 따르지 않고 `HTTPS_PROXY` 만 따른다. `--ssl-no-revoke` 는 폐지 목록 서버에 닿지 못하는 망에서 Schannel 의 폐지 확인 실패를 오프라인으로 읽지 않게 한다. 닿는지만 보고 받은 내용은 쓰지 않으므로 이 확인에만 준다. `curl.exe` 가 없으면 `$LASTEXITCODE` 에 앞 명령의 값이 남으므로 `Get-Command curl.exe -ErrorAction SilentlyContinue` 로 먼저 보고, 없으면 확인하지 못한 것으로 적는다.
40
+ - 사내 미러만 열린 망이면 확인 주소를 그 미러로 바꿔 본다.
41
+ - OS 패키지 관리자(dnf, apt, brew, winget)의 단계는 자기 설정의 미러를 쓰므로 이 확인과 관계없이 시도하고, 실패하면 그 단계를 실패로 보고한다.
42
+ - 패킷을 버리거나 답하지 않는 망에서 dnf 는 저장소마다 약 2분(SYN 을 버리는 망 실측 121초), apt 는 원천마다 약 4분(답하지 않는 원천 실측 247초) 기다린 뒤 실패로 끝난다. 시간 제한은 관리자 설정을 따르므로 이 스킬이 바꾸지 않는다.
43
+ - `apt-get update` 는 받지 못해도 종료 0 이다. 출력의 `W: Failed to fetch` 가 있으면 그 단계를 실패로 보고한다.
44
+ - Codex sandbox 가 네트워크를 막는 모드(기본 `workspace-write`, `read-only`)에서는 `CODEX_SANDBOX_NETWORK_DISABLED=1` 이 서고 확인이 모두 실패한다(curl 종료 7, npm `EPERM`). 블록의 첫 줄이 그렇다고 알린다. 그때는 '오프라인' 이 아니라 'sandbox 가 네트워크를 막음' 으로 보고하고, 승인을 받아 sandbox 밖에서 확인부터 다시 한다. Claude Code sandbox 안(`SANDBOX_RUNTIME=1`)에서는 허용 목록 밖 주소를 sandbox 프록시가 막는다. Windows PowerShell 에서는 `$env:CODEX_SANDBOX_NETWORK_DISABLED -eq '1'` 로 본다.
45
+ - 확인이 실패하면 보고에 그 명령, 종료 코드, stderr 첫 줄을 붙인다(예: `npm error code ETIMEDOUT`, `curl: (28) ...`). 원인을 '오프라인' 하나로 짐작하지 않는다.
46
+ - curl 의 종료 60(인증서 검증 실패)도 오프라인이 아니다. 망은 열려 있고, TLS 를 가로채는 사내 프록시의 인증서를 OS 가 믿지 않는 것이다. '인증서 문제로 설치하지 않음' 으로 보고한다.
47
+
29
48
  ## 1. Rocky Linux 8
30
49
  ```bash
31
50
  sudo dnf install -y java-21-openjdk-devel
@@ -51,7 +70,7 @@ export JAVA_HOME="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")
51
70
  ⚠️ **default·backports 저장소 모두 JDK 21을 제공하지 않는다.** Adoptium 레포 추가가 유일한 경로(재확인: 배포판이 이후 21을 backport하면 이 단계는 생략 가능).
52
71
  ```bash
53
72
  sudo apt-get install -y wget gnupg
54
- wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | \
73
+ wget -qO - --timeout=10 --tries=2 https://packages.adoptium.net/artifactory/api/gpg/key/public | \
55
74
  sudo gpg --dearmor -o /usr/share/keyrings/adoptium.gpg
56
75
  echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" | \
57
76
  sudo tee /etc/apt/sources.list.d/adoptium.list
@@ -40,6 +40,44 @@ ls src/main/resources/application.* >/dev/null 2>&1 && echo "lang: spring"
40
40
  ```
41
41
  > Windows(PowerShell): `Get-Command claude,codex,npm,pipx,java`, `Test-Path tsconfig.json,pom.xml` 등으로 동일 감지. jdtls 는 `jdtls.bat`.
42
42
 
43
+ ### 네트워크 확인 (내려받기 전)
44
+
45
+ 이 스킬이 내려받는 원천에 닿는지 먼저 본다(curl 은 10초, npm 은 15초 안). 닿지 않으면 그 원천에서 받는 단계는 하지 않고, 이미 설치된 것의 확인만 한 뒤 "오프라인이라 설치하지 않음(필요한 것: <못 깐 것>)" 으로 보고한다.
46
+
47
+ ```bash
48
+ [ "${CODEX_SANDBOX_NETWORK_DISABLED:-}" = 1 ] && echo "Codex sandbox 가 네트워크를 막음(아래 실패는 오프라인이 아님)"
49
+ in15() { "$@" & p=$!; ( sleep 15 && kill "$p" ) > /dev/null 2>&1 & k=$!; wait "$p" 2>/dev/null; r=$?; kill "$k" 2>/dev/null; wait "$k" 2>/dev/null; [ "$r" = 143 ] && echo "15초 안에 끝나지 않아 멈춤(패킷을 버리는 망일 수 있음)"; return "$r"; }
50
+ in15 sh -c 'cd ~ && exec npm ping --fetch-retries=0 --fetch-timeout=10000 --no-update-notifier' # ts, bash 서버(npm)
51
+ # pip 이 읽는 설정 전부(PIP_CONFIG_FILE 포함)를 홈 폴더에서 읽는다. install 절이 global 절보다 앞선다
52
+ L=$( (cd ~ && { pip config list || pip3 config list || python3.13 -m pip config list || python3.12 -m pip config list || python3.11 -m pip config list || python3 -m pip config list; }) 2>/dev/null) || echo "pip 설정을 읽지 못함(pip 이 없거나 10 미만). 사내 미러를 쓰면 확인 주소를 그 미러로 바꿔 본다"
53
+ I="${PIP_INDEX_URL:-$(printf '%s\n' "$L" | sed -n "s/^install\.index-url='\(.*\)'$/\1/p")}"
54
+ I="${I:-$(printf '%s\n' "$L" | sed -n "s/^global\.index-url='\(.*\)'$/\1/p")}"
55
+ I=$(printf %s "${I:-https://pypi.org/simple}" | sed -E 's#^([A-Za-z][A-Za-z0-9+.-]*://)[^/]*@#\1#; s#/$##') # 주소의 사용자 정보는 뗌
56
+ printf 'url = "%s"\n' "$I/pip/" | curl --ssl-no-revoke -K - -sSI -o /dev/null --connect-timeout 5 --max-time 10 # py 서버(pipx)
57
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://download.eclipse.org # jdtls
58
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://github.com # lsp-mcp
59
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://vmware.gallery.vsassets.io # Spring Boot LS(VSIX)
60
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://marketplace.visualstudio.com # Spring Boot LS(VSIX 대체 원천. 둘 중 하나만 닿아도 받을 수 있다)
61
+ ```
62
+
63
+ - `npm ping` 은 홈 폴더에서 부른다. 지금 폴더에 있는 프로젝트의 `.npmrc` 가 정한 레지스트리와 토큰을 따르지 않게 하려는 것이다.
64
+ - `npm ping` 은 15초 감시(`in15`) 안에서 부른다. 패킷을 버리는 망에서 npm 은 연결에 시간 제한을 두지 않아(`--fetch-timeout` 은 받는 중의 쉼만 잰다) OS 의 연결 재시도가 끝날 때까지 기다린다(Rocky 8.10 실측 130초). 15초 안에 끝나지 않으면 `15초 안에 끝나지 않아 멈춤(패킷을 버리는 망일 수 있음)` 과 종료 143 이다. `--no-update-notifier` 는 npm 이 제 판을 묻는 요청을 하나 더 내지 않게 한다.
65
+ - `npm ping` 이 실패하면 ping 에 답하지 않는 사내 미러일 수 있다. `(cd ~ && T=$(mktemp -d) && { npm view bash-language-server version --fetch-retries=0 --fetch-timeout=10000 --cache "$T" --no-update-notifier; r=$?; rm -rf "$T"; exit $r; })` 도 실패해야 오프라인으로 본다. 이 스킬이 설치할 패키지(`bash-language-server`)를 빈 캐시로 물으므로 옛 값이 나올 수 없다. ping 이 15초 감시에 걸려 멈췄으면(종료 143) 이 확인은 하지 않는다. 같은 레지스트리라 같은 시간을 또 기다린다.
66
+ - `npm` 이 없으면(명령 없음) 오프라인이 아니라 확인하지 못한 것이다. Node 를 먼저 갖춘다(`setup-node`).
67
+ - `npm ping` 은 npm 의 프록시 설정(`proxy`, `https-proxy`)을 따른다. 사내 미러만 열린 망이면 그 미러가 쓰이는지 npm 설정을 본다.
68
+ - `curl -sSI` 는 머리만 받는다(본문을 받지 않음). 닿지 못하면 종료 코드가 0 이 아니다(HTTP 상태와 관계없음). `curl` 은 `HTTPS_PROXY` 를 따른다. 확인 줄은 `--ssl-no-revoke` 를 준다. Windows 의 Git Bash curl 도 Schannel 이라, 폐지 목록 서버에 닿지 못하는 망에서 폐지 확인이 실패하면 종료 60 이다(Windows 박스 실측). 다른 curl 은 이 깃발을 받고 무시한다(macOS curl 8.7.1, Rocky 8.10 curl 7.61.1 실측).
69
+ - PyPI 는 pip 이 쓰는 색인(`PIP_INDEX_URL`, `pip config list` 의 `install.index-url` 이나 `global.index-url`, 없으면 pypi.org)의 작은 쪽(`<색인>/pip/`)을 본다.
70
+ - `pip config get` 은 `PIP_CONFIG_FILE` 로 준 파일을 읽지 않아(pip 25.0 실측) `pip config list` 를 읽는다.
71
+ - Rocky 8 의 기본 pip(9.0.3, `pip3`)은 `config` 명령이 없고 `pip` 이름은 없다(Rocky 8.10 실측). 그래서 판 붙은 python(3.13, 3.12, 3.11)의 pip 도 차례로 본다.
72
+ - 홈 폴더에서 읽는다. `python3 -m pip` 는 지금 폴더를 import 경로 맨 앞에 두어, 지금 폴더에 있는 남의 저장소의 `pip/` 가 실행될 수 있다.
73
+ - bash 블록은 주소를 명령 인자가 아니라 표준 입력의 curl 설정(`-K -`)으로 넘긴다. 명령 인자는 같은 PC 의 다른 사용자가 `ps` 로 볼 수 있어, 경로나 질의에 든 토큰도 거기 싣지 않는다. 사용자 정보(`사용자:토큰@`)는 떼고 묻는다. 닿는지만 보므로 인증은 필요 없다(401 도 닿은 것이다). Windows 에서도 뗀 주소를 쓴다.
74
+ - Windows PowerShell 에서는 같은 주소를 `curl.exe --ssl-no-revoke -sSI -o NUL --connect-timeout 5 --max-time 10 <주소>` 로 보고 `$LASTEXITCODE` 가 0 인지 본다(PowerShell 5.1 의 `curl` 은 `Invoke-WebRequest` 별칭이다). `curl.exe` 는 Windows 시스템 프록시를 따르지 않고 `HTTPS_PROXY` 만 따른다. `--ssl-no-revoke` 는 폐지 목록 서버에 닿지 못하는 망에서 Schannel 의 폐지 확인 실패를 오프라인으로 읽지 않게 한다. 닿는지만 보고 받은 내용은 쓰지 않으므로 이 확인에만 준다. `curl.exe` 가 없으면 `$LASTEXITCODE` 에 앞 명령의 값이 남으므로 `Get-Command curl.exe -ErrorAction SilentlyContinue` 로 먼저 보고, 없으면 확인하지 못한 것으로 적는다. `npm ping` 은 `Push-Location ~; npm ping --fetch-retries=0 --fetch-timeout=10000 --no-update-notifier; Pop-Location` 처럼 홈에서 부르고 돌아온다(돌아오지 않으면 뒤 단계가 프로젝트가 아니라 홈에서 돈다). PowerShell 에는 15초 감시를 두지 않는다(Windows 의 연결 시간 제한은 약 21초로 알려져 있다. 미실측).
75
+ - 사내 미러만 열린 망이면 확인 주소를 그 미러로 바꿔 본다.
76
+ - Codex sandbox 가 네트워크를 막는 모드(기본 `workspace-write`, `read-only`)에서는 `CODEX_SANDBOX_NETWORK_DISABLED=1` 이 서고 확인이 모두 실패한다(curl 종료 7, npm `EPERM`). 블록의 첫 줄이 그렇다고 알린다. 그때는 '오프라인' 이 아니라 'sandbox 가 네트워크를 막음' 으로 보고하고, 승인을 받아 sandbox 밖에서 확인부터 다시 한다. Claude Code sandbox 안(`SANDBOX_RUNTIME=1`)에서는 허용 목록 밖 주소를 sandbox 프록시가 막는다. Windows PowerShell 에서는 `$env:CODEX_SANDBOX_NETWORK_DISABLED -eq '1'` 로 본다.
77
+ - 확인이 실패하면 보고에 그 명령, 종료 코드, stderr 첫 줄을 붙인다(예: `npm error code ETIMEDOUT`, `curl: (28) ...`). 원인을 '오프라인' 하나로 짐작하지 않는다.
78
+ - curl 의 종료 60(인증서 검증 실패)도 오프라인이 아니다. 망은 열려 있고, TLS 를 가로채는 사내 프록시의 인증서를 OS 가 믿지 않는 것이다. '인증서 문제로 설치하지 않음' 으로 보고한다.
79
+ - curl 은 닿는데 npm 만 인증서 오류(`UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `SELF_SIGNED_CERT_IN_CHAIN`, `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`)로 실패하면 망은 열려 있고 npm 이 사내 인증서를 믿지 않는 것이다(npm 은 OS 인증서 저장소를 쓰지 않는다). '인증서 문제로 설치하지 않음' 으로 보고하고 `NODE_EXTRA_CA_CERTS=<사내 CA 파일>` 을 안내한다. Node 24 는 `NODE_OPTIONS=--use-system-ca` 도 된다.
80
+
43
81
  ## 1. 사전요구(preflight)
44
82
 
45
83
  각 서버는 선행 런타임이 필요하다 - **없으면 먼저 해당 setup 스킬을 실행**(추정·강행 금지):
@@ -86,10 +124,11 @@ for c in basedpyright-langserver ruff; do command -v $c >/dev/null && echo "✓
86
124
  **jdtls (Java LSP)** - Eclipse 최신 스냅샷:
87
125
  ```bash
88
126
  mkdir -p ~/.local/jdtls
89
- curl -fLo /tmp/jdtls.tar.gz \
90
- "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz"
91
- tar -xzf /tmp/jdtls.tar.gz -C ~/.local/jdtls
92
- rm /tmp/jdtls.tar.gz
127
+ T=$(mktemp -d) \
128
+ && curl -fL --connect-timeout 10 --speed-limit 1000 --speed-time 30 -o "$T/jdtls.tar.gz" \
129
+ "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz" \
130
+ && tar -xzf "$T/jdtls.tar.gz" -C ~/.local/jdtls
131
+ rm -rf "$T"
93
132
  chmod +x ~/.local/jdtls/bin/jdtls
94
133
  which jdtls # PATH 에 ~/.local/jdtls/bin 필요
95
134
  ```
@@ -97,28 +136,31 @@ which jdtls # PATH 에 ~/.local/jdtls/bin 필요
97
136
 
98
137
  **Spring Boot Language Server** - VSIX(=ZIP) 다운로드. vsassets.io/marketplace 가 **HTML 에러페이지**를 반환할 수 있어 **ZIP 매직바이트(`504b0304`) 검증** 후 `find` 로 `*-exec.jar` 탐색 + 심볼릭 링크:
99
138
  ```bash
139
+ (
100
140
  set -euo pipefail
101
- TARGET="$HOME/.local/spring-boot-ls"; VSIX=/tmp/sbls.vsix; LINK=spring-boot-language-server.jar
141
+ TARGET="$HOME/.local/spring-boot-ls"; T=$(mktemp -d); trap 'rm -rf "$T"' EXIT; VSIX="$T/sbls.vsix"; LINK=spring-boot-language-server.jar
102
142
  mkdir -p "$TARGET"
103
143
  VSIX_URL="https://vmware.gallery.vsassets.io/_apis/public/gallery/publisher/vmware/extension/vscode-spring-boot/latest/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage"
104
144
  FALLBACK="https://marketplace.visualstudio.com/_apis/public/gallery/publishers/vmware/vsextensions/vscode-spring-boot/latest/vspackage"
105
- curl -fL --connect-timeout 30 --retry 2 -H "User-Agent: Mozilla/5.0" -o "$VSIX" "$VSIX_URL" 2>/dev/null \
106
- || curl -fL --connect-timeout 30 --retry 2 -H "User-Agent: Mozilla/5.0" -o "$VSIX" "$FALLBACK"
145
+ curl -fL --connect-timeout 30 --speed-limit 1000 --speed-time 30 --retry 2 -H "User-Agent: Mozilla/5.0" -o "$VSIX" "$VSIX_URL" 2>/dev/null \
146
+ || curl -fL --connect-timeout 30 --speed-limit 1000 --speed-time 30 --retry 2 -H "User-Agent: Mozilla/5.0" -o "$VSIX" "$FALLBACK"
107
147
  # ZIP 매직 검증 (HTML 에러페이지면 즉시 중단)
108
148
  [ "$(xxd -l 4 "$VSIX" | awk 'NR==1{print $2$3}')" = "504b0304" ] \
109
149
  || { echo "✗ ZIP 아님(HTML 에러페이지?)"; rm -f "$VSIX"; exit 1; }
110
- rm -rf "${TARGET:?}"/*; unzip -o -q "$VSIX" -d "$TARGET"
150
+ find "${TARGET:?}" -mindepth 1 -delete; unzip -o -q "$VSIX" -d "$TARGET" # 글롭 없이 비움(zsh 는 빈 폴더의 * 에서 멈춤)
111
151
  JAR=$(find "$TARGET" -name "spring-boot-language-server-*-exec.jar" | sort -V | tail -1)
112
152
  [ -n "$JAR" ] || { echo "✗ exec.jar 미발견"; rm -f "$VSIX"; exit 1; }
113
153
  ln -sf "$(basename "$JAR")" "$(dirname "$JAR")/$LINK" # 디렉토리 이동에도 유효한 상대 링크
114
154
  rm -f "$VSIX"; find ~/.local/spring-boot-ls -name "$LINK" -ls
155
+ )
115
156
  ```
157
+ 블록 전체가 괄호 안의 서브셸에서 돈다. `set -e`, EXIT trap, `exit 1` 이 이 블록 안에만 머물러, 같은 셸의 다음 명령을 끝내거나 그 셸이 끝날 때 다른 `T` 를 지우지 않는다.
116
158
  > Windows: 심볼릭 링크는 **개발자 모드/관리자 권한** 필요 → 없으면 `Copy-Item` 복사 fallback(둘 다 동작). 매직검증·find 는 PowerShell 로 동일 수행, JAR 경로는 `.Replace('\','/')`. RHEL8 에 `xxd` 없으면 `dnf install -y vim-common` 또는 `od -An -tx1 -N4`.
117
159
 
118
160
  ## 6. lsp-mcp 브리지 설치 (Codex 경로 - Claude Code 는 건너뜀)
119
161
  ```bash
120
162
  mkdir -p ~/.local/src
121
- git clone https://github.com/CesarPetrescu/lsp-mcp.git ~/.local/src/lsp-mcp
163
+ git -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=30 clone https://github.com/CesarPetrescu/lsp-mcp.git ~/.local/src/lsp-mcp
122
164
  pipx install --editable ~/.local/src/lsp-mcp # → ~/.local/bin/codex-lsp-bridge (Windows: .exe)
123
165
  which codex-lsp-bridge && codex-lsp-bridge --help | head -5
124
166
  ```
@@ -144,7 +186,7 @@ language_id = "spring-boot-properties"
144
186
  EOF
145
187
  # TOML 유효성 (중복/문법오류 즉시 감지). tomllib 는 Python 3.11+ - RHEL8/Rocky8 기본 python3(3.6)면 setup-python 의 3.11 을 쓴다:
146
188
  PY="$(command -v python3.12 || command -v python3.11 || command -v python3)"
147
- "$PY" -c "import tomllib; tomllib.loads(open('config/default.toml',encoding='utf-8').read()); print('OK lsp-mcp TOML')" 2>/dev/null \
189
+ (cd ~ && "$PY" -c "import tomllib; tomllib.loads(open('$HOME/.local/src/lsp-mcp/config/default.toml',encoding='utf-8').read()); print('OK lsp-mcp TOML')") 2>/dev/null \
148
190
  || echo "⚠ tomllib(3.11+) 미가용 - append 는 위 grep guard 로 이미 적용됨, setup-python 설치 후 재검증"
149
191
  cd - >/dev/null
150
192
  ```
@@ -156,7 +198,7 @@ cd - >/dev/null
156
198
  ### 7-Codex - `[mcp_servers.lsp_bridge]` 등록
157
199
  OMX 가 배포한 기존 엔트리를 보존하려 **Python replace-or-append** 로 patch(전체 덮어쓰기 금지):
158
200
  ```bash
159
- python3 - << 'PY'
201
+ (cd ~ && python3 - << 'PY' # 홈에서: -c, -m, 표준 입력 스크립트는 지금 폴더의 모듈을 먼저 불러온다
160
202
  import re
161
203
  from pathlib import Path
162
204
  home = str(Path.home()) # Windows: str(Path.home()).replace("\\","/")
@@ -178,8 +220,9 @@ txt = rep(txt, "mcp_servers.lsp_bridge", block + "\n")
178
220
  p.write_text(txt, encoding="utf-8") # Windows: encoding 필수(CP949 회피)
179
221
  print("patched")
180
222
  PY
223
+ )
181
224
  PY="$(command -v python3.12 || command -v python3.11 || command -v python3)" # tomllib=3.11+
182
- "$PY" -c "import tomllib; tomllib.loads(open('$HOME/.codex/config.toml',encoding='utf-8').read()); print('OK config.toml valid')" 2>/dev/null \
225
+ (cd ~ && "$PY" -c "import tomllib; tomllib.loads(open('$HOME/.codex/config.toml',encoding='utf-8').read()); print('OK config.toml valid')") 2>/dev/null \
183
226
  || echo "⚠ tomllib(3.11+) 미가용 - patch 는 적용됨, setup-python 후 재검증"
184
227
  ```
185
228
 
@@ -213,7 +256,7 @@ jdtls --version 2>/dev/null || which jdtls # Windows: jdtls.bat
213
256
  codex-lsp-bridge --help | head -3
214
257
  grep -q 'lsp_bridge' ~/.codex/config.toml && echo "✓ config.toml lsp_bridge" || echo "✗ 미등록"
215
258
  PY="$(command -v python3.12 || command -v python3.11 || command -v python3)" # tomllib=3.11+
216
- "$PY" -c "import tomllib; tomllib.loads(open('$HOME/.codex/config.toml',encoding='utf-8').read()); print('✓ config.toml valid')" 2>/dev/null || echo "⚠ tomllib(3.11+) 미가용 - setup-python 후 재검증"
259
+ (cd ~ && "$PY" -c "import tomllib; tomllib.loads(open('$HOME/.codex/config.toml',encoding='utf-8').read()); print('✓ config.toml valid')") 2>/dev/null || echo "⚠ tomllib(3.11+) 미가용 - setup-python 후 재검증"
217
260
  # Claude 만: lsp_servers 도구로 인식 서버 나열
218
261
  ```
219
262
  4-field 보고: **변경**(설치 서버·브리지·config 배선) / **Evidence**(위 --version·valid 출력) / **검증**(각 서버 응답 + 브리지·config TOML valid + 런타임 인식) / **Unknown**(네트워크 차단으로 미설치·JDK 21 부재로 java/spring 스킵 등).