@codyswann/lisa 2.328.4 → 2.329.0

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 (103) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +49 -2
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/package.json +1 -1
  5. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/SKILL.md +32 -2
  8. package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  9. package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
  10. package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/SKILL.md +121 -0
  11. package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/agents/openai.yaml +4 -0
  12. package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
  13. package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
  14. package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
  15. package/plugins/lisa/commands/setup/workstation.md +7 -0
  16. package/plugins/lisa/skills/lisa-detect-tooling/SKILL.md +33 -3
  17. package/plugins/lisa/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  18. package/plugins/lisa/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
  19. package/plugins/lisa/skills/lisa-setup-workstation/SKILL.md +121 -0
  20. package/plugins/lisa/skills/lisa-setup-workstation/agents/openai.yaml +4 -0
  21. package/plugins/lisa/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
  22. package/plugins/lisa/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
  23. package/plugins/lisa/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
  24. package/plugins/lisa-agy/commands/lisa/setup/workstation.md +7 -0
  25. package/plugins/lisa-agy/plugin.json +1 -1
  26. package/plugins/lisa-agy/skills/lisa-detect-tooling/SKILL.md +33 -3
  27. package/plugins/lisa-agy/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  28. package/plugins/lisa-agy/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
  29. package/plugins/lisa-agy/skills/lisa-setup-workstation/SKILL.md +121 -0
  30. package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
  31. package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
  32. package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
  33. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  36. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-copilot/commands/lisa/setup/workstation.md +7 -0
  40. package/plugins/lisa-copilot/skills/lisa-detect-tooling/SKILL.md +33 -3
  41. package/plugins/lisa-copilot/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  42. package/plugins/lisa-copilot/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
  43. package/plugins/lisa-copilot/skills/lisa-setup-workstation/SKILL.md +121 -0
  44. package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
  45. package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
  46. package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
  47. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-cursor/commands/lisa/setup/workstation.md +7 -0
  49. package/plugins/lisa-cursor/skills/lisa-detect-tooling/SKILL.md +33 -3
  50. package/plugins/lisa-cursor/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  51. package/plugins/lisa-cursor/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
  52. package/plugins/lisa-cursor/skills/lisa-setup-workstation/SKILL.md +121 -0
  53. package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
  54. package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
  55. package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
  56. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-expo-agy/plugin.json +1 -1
  59. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  64. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  69. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  74. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  79. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  83. package/plugins/lisa-rails-agy/plugin.json +1 -1
  84. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  88. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  89. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  93. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  94. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  96. package/plugins/src/base/commands/setup/workstation.md +7 -0
  97. package/plugins/src/base/skills/lisa-detect-tooling/SKILL.md +33 -3
  98. package/plugins/src/base/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  99. package/plugins/src/base/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
  100. package/plugins/src/base/skills/lisa-setup-workstation/SKILL.md +121 -0
  101. package/plugins/src/base/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
  102. package/plugins/src/base/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
  103. package/plugins/src/base/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
@@ -0,0 +1,403 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Prepare a machine — or a throwaway container — to run coding agents.
4
+ *
5
+ * This is the layer beneath `lisa-setup-local-env`, which reads a project's
6
+ * `remoteEnv.tools` and therefore needs a checkout to exist first. That left
7
+ * the machine most likely to be missing a tool — a fresh laptop, or an empty
8
+ * container — with nothing to ask.
9
+ *
10
+ * Nothing here reads a repository. The composition is:
11
+ *
12
+ * workstation -> git clone -> lisa-detect-tooling -> lisa-setup-local-env
13
+ *
14
+ * ## Ephemerality is not a mode
15
+ *
16
+ * There is no Docker branch. A container gets a throwaway `$HOME` and dies with
17
+ * it; a laptop keeps its own. Same code path, so the rarely-run one cannot rot
18
+ * — the failure mode a second install path always eventually has.
19
+ *
20
+ * ## Already-installed wins, however it got there
21
+ *
22
+ * A tool present by ANY means is reported and left alone. That dissolves the
23
+ * "Homebrew or ~/.local/bin" question: we never contend for ownership of a tool
24
+ * someone else's package manager installed, and never shadow it with an earlier
25
+ * PATH entry that would silently win over their upgrades.
26
+ *
27
+ * Usage:
28
+ * workstation.mjs report what is present and missing
29
+ * workstation.mjs --install install everything missing
30
+ * workstation.mjs --install --agents=claude,codex only these agents
31
+ * workstation.mjs --json machine-readable plan
32
+ * workstation.mjs --print-dockerfile an image that runs this same script
33
+ * @module workstation
34
+ */
35
+
36
+ import { execFileSync } from "node:child_process";
37
+
38
+ import {
39
+ AGENTS,
40
+ CHECKSUMMED,
41
+ INSTALLABLE,
42
+ PROVIDERS,
43
+ TOOLS,
44
+ } from "./catalogue.mjs";
45
+
46
+ /**
47
+ * Locate a command without running it.
48
+ *
49
+ * Presence and version are different questions, and conflating them has bitten
50
+ * this codebase before: probing with `--version` reported `unzip` missing on
51
+ * machines that had it, because Info-ZIP exits 10 on an unknown flag. So
52
+ * presence is `command -v` and nothing else.
53
+ * @param {string} name Executable name.
54
+ * @param {Function} [exec] Runner, injectable for tests.
55
+ * @returns {string|null} Absolute path, or null when absent.
56
+ */
57
+ export function locate(name, exec = execFileSync) {
58
+ try {
59
+ return String(
60
+ exec("command", ["-v", name], { encoding: "utf8", shell: true })
61
+ ).trim();
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Read a tool's version, tolerating tools that dislike `--version`.
69
+ *
70
+ * A non-zero exit does NOT mean absent — only a failure to spawn does. Version
71
+ * text is still recovered from a failed invocation where there is any, because
72
+ * a tool that refuses the flag often prints its banner anyway.
73
+ * @param {string} name Executable name.
74
+ * @param {Function} [exec] Runner, injectable for tests.
75
+ * @returns {string|null} A dotted version, or null.
76
+ */
77
+ export function version(name, exec = execFileSync) {
78
+ const read = out => {
79
+ const match = /(\d+(?:\.\d+)+)/.exec(String(out ?? ""));
80
+ return match ? match[1] : null;
81
+ };
82
+ try {
83
+ return read(
84
+ exec(name, ["--version"], {
85
+ encoding: "utf8",
86
+ stdio: ["ignore", "pipe", "pipe"],
87
+ })
88
+ );
89
+ } catch (error) {
90
+ if (error.code === "ENOENT") return null;
91
+ return read(`${error.stdout ?? ""}${error.stderr ?? ""}`);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Where a tool came from, so the report can say why it is being left alone.
97
+ * @param {string|null} path Absolute path to the executable.
98
+ * @returns {string} A short provenance label.
99
+ */
100
+ export function provenance(path) {
101
+ if (!path) return "absent";
102
+ if (path.includes("/homebrew/") || path.startsWith("/usr/local/Cellar"))
103
+ return "homebrew";
104
+ if (path.includes("/.local/bin/")) return "~/.local/bin";
105
+ if (path.includes("/node_modules/")) return "node_modules";
106
+ if (path.startsWith("/usr/bin") || path.startsWith("/bin")) return "system";
107
+ return "other";
108
+ }
109
+
110
+ /**
111
+ * Decide what to do about one catalogue entry.
112
+ * @param {object} entry Catalogue entry.
113
+ * @param {object} [deps] Injected probes, for tests.
114
+ * @returns {object} A plan row.
115
+ */
116
+ export function planEntry(entry, deps = {}) {
117
+ const find = deps.locate ?? locate;
118
+ const read = deps.version ?? version;
119
+
120
+ const path = find(entry.name);
121
+ const found = path ? read(entry.name) : null;
122
+ const base = {
123
+ name: entry.name,
124
+ label: entry.label,
125
+ kind: entry.kind,
126
+ path,
127
+ version: found,
128
+ from: provenance(path),
129
+ checksummed: CHECKSUMMED.has(entry.kind),
130
+ };
131
+
132
+ if (path) {
133
+ // Present by any means: report and leave alone. Re-installing over a
134
+ // vendor's own layout is how a self-updater gets broken.
135
+ return {
136
+ ...base,
137
+ action: "present",
138
+ reason: `already installed (${base.from})`,
139
+ };
140
+ }
141
+ if (entry.kind === "required") {
142
+ return {
143
+ ...base,
144
+ action: "blocked",
145
+ reason: `${entry.name} is required but absent — ${entry.note}`,
146
+ };
147
+ }
148
+ if (!INSTALLABLE.has(entry.kind)) {
149
+ return {
150
+ ...base,
151
+ action: "manual",
152
+ reason: entry.note ?? "no headless installer available",
153
+ };
154
+ }
155
+ return { ...base, action: "install", reason: "absent" };
156
+ }
157
+
158
+ /**
159
+ * Build the full plan across agents and tools.
160
+ * @param {object} [options] Selection and injected probes.
161
+ * @returns {object[]} Plan rows.
162
+ */
163
+ export function planWorkstation(options = {}) {
164
+ const wanted = options.agents ?? null;
165
+ const agents = AGENTS.filter(a => !wanted || wanted.includes(a.name)).map(
166
+ a => ({
167
+ ...planEntry(a, options),
168
+ group: "agent",
169
+ })
170
+ );
171
+ const tools = TOOLS.map(t => ({ ...planEntry(t, options), group: "tool" }));
172
+
173
+ // Only the SELECTED provider is planned. Installing every credential CLI
174
+ // would leave four unused ones on the machine, each needing patching for no
175
+ // benefit — and would contradict the secrets contract, where the provider is
176
+ // a per-project axis rather than a fixed vendor.
177
+ const provider = resolveProvider(options.provider);
178
+ const credential = provider
179
+ ? [{ ...planProvider(provider, options), group: "provider" }]
180
+ : [];
181
+
182
+ return [...agents, ...credential, ...tools];
183
+ }
184
+
185
+ /**
186
+ * Resolve a requested provider name to its catalogue entry.
187
+ *
188
+ * An unknown name is an error rather than a silent fallback to Bitwarden: a
189
+ * typo that quietly provisions the wrong credential manager is worse than one
190
+ * that stops.
191
+ * @param {string|null|undefined} name Requested provider.
192
+ * @returns {object|null} Entry, or null when none was requested.
193
+ */
194
+ export function resolveProvider(name) {
195
+ if (name === null || name === undefined) return null;
196
+ const entry = PROVIDERS.find(p => p.name === name);
197
+ if (!entry) {
198
+ throw new Error(
199
+ `unknown credential manager "${name}". Supported: ` +
200
+ `${PROVIDERS.map(p => p.name).join(", ")}`
201
+ );
202
+ }
203
+ return entry;
204
+ }
205
+
206
+ /**
207
+ * Plan the selected credential manager.
208
+ * @param {object} provider Catalogue entry.
209
+ * @param {object} [deps] Injected probes, for tests.
210
+ * @returns {object} A plan row.
211
+ */
212
+ export function planProvider(provider, deps = {}) {
213
+ if (provider.kind === "none") {
214
+ // A first-class answer, not an absence. Reported so the operator can see
215
+ // the machine is deliberately on plain environment variables.
216
+ return {
217
+ name: "none",
218
+ label: provider.label,
219
+ kind: "none",
220
+ path: null,
221
+ version: null,
222
+ from: "n/a",
223
+ checksummed: false,
224
+ action: "present",
225
+ reason: "no credential manager selected",
226
+ };
227
+ }
228
+ return planEntry({ ...provider, name: provider.binary }, deps);
229
+ }
230
+
231
+ /**
232
+ * Render the plan for a human.
233
+ * @param {object[]} plan Plan rows.
234
+ * @returns {string} Report text.
235
+ */
236
+ export function renderPlan(plan) {
237
+ const lines = [];
238
+ const heading = {
239
+ agent: "Coding agents:",
240
+ provider: "\nCredential manager:",
241
+ tool: "\nTools:",
242
+ };
243
+ for (const group of ["agent", "provider", "tool"]) {
244
+ const rows = plan.filter(r => r.group === group);
245
+ if (!rows.length) continue;
246
+ lines.push(heading[group]);
247
+ for (const row of rows) {
248
+ const mark = {
249
+ present: " ok ",
250
+ install: " install",
251
+ manual: " manual ",
252
+ blocked: " BLOCKED",
253
+ }[row.action];
254
+ // `none` is present-but-not-a-binary, so it has no version to show; a
255
+ // bare "?" there reads as a failed probe rather than a chosen answer.
256
+ const detail =
257
+ row.action !== "present"
258
+ ? row.reason
259
+ : row.path
260
+ ? `${row.version ?? "unknown version"} (${row.from})`
261
+ : row.reason;
262
+ lines.push(`${mark} ${row.label.padEnd(22)} ${detail}`);
263
+ }
264
+ }
265
+
266
+ // Say plainly which installs carry a checksum and which trust a vendor.
267
+ const unverified = plan.filter(r => r.action === "install" && !r.checksummed);
268
+ if (unverified.length) {
269
+ lines.push(
270
+ `\nNot checksummed: ${unverified.map(r => r.name).join(", ")} — these run a` +
271
+ ` vendor installer, which is weaker than the pinned entries. Installing` +
272
+ ` them trusts the vendor's script at fetch time.`
273
+ );
274
+ }
275
+ return lines.join("\n");
276
+ }
277
+
278
+ /**
279
+ * Install one entry by its own preferred method.
280
+ *
281
+ * Each vendor owns its layout; we invoke their installer rather than placing a
282
+ * binary ourselves. `release-zip`/`release-tar` are the exception and the only
283
+ * checksummed path — they are reused from the remote-env installer rather than
284
+ * reimplemented, because a second installer is a second thing to keep honest.
285
+ * @param {object} entry Catalogue entry.
286
+ * @param {object} row Plan row for it.
287
+ * @param {object} [deps] Injected runner, for tests.
288
+ * @returns {object} Result row.
289
+ */
290
+ export function installEntry(entry, row, deps = {}) {
291
+ const run =
292
+ deps.run ??
293
+ ((cmd, args, opts) =>
294
+ execFileSync(cmd, args, { stdio: "inherit", ...opts }));
295
+ const find = deps.locate ?? locate;
296
+
297
+ try {
298
+ if (entry.kind === "npm-global") {
299
+ run("npm", ["install", "-g", entry.package]);
300
+ } else if (entry.kind === "vendor-script") {
301
+ if (!entry.script) {
302
+ return {
303
+ ...row,
304
+ action: "manual",
305
+ reason: entry.note ?? "no installer",
306
+ };
307
+ }
308
+ // Piping a fetched script to a shell is exactly the unverified path the
309
+ // report warns about. It is the vendor's supported route for these
310
+ // tools, and the alternative — repackaging their binaries ourselves —
311
+ // would break the self-updaters that own those directories.
312
+ //
313
+ // bash, NOT sh. On Ubuntu `/bin/sh` is dash, and every one of these
314
+ // vendor scripts is a bash script: piping them to sh in a container gave
315
+ // `Syntax error: "(" unexpected` and `set: Illegal option -o pipefail`
316
+ // while the same command worked on a Mac, where sh is bash.
317
+ //
318
+ // `set -o pipefail` matters as much as the shell does: without it the
319
+ // exit status is the SHELL's, not curl's, so a 404 feeds an empty script
320
+ // to bash, bash exits 0, and a tool that was never installed is reported
321
+ // as installed. That is exactly what `sonarsource.com/install` did.
322
+ run("bash", [
323
+ "-o",
324
+ "pipefail",
325
+ "-c",
326
+ `curl -fsSL ${entry.script} | bash`,
327
+ ]);
328
+ } else if (entry.kind === "aws-cli") {
329
+ // macOS ships a .pkg that wants an administrator, which a headless run
330
+ // has no business prompting for. Reported as manual with the one-line
331
+ // fix rather than attempted and failed halfway.
332
+ if ((deps.platform ?? process.platform) === "darwin") {
333
+ return {
334
+ ...row,
335
+ action: "manual",
336
+ reason:
337
+ "macOS installs from a .pkg needing admin — `brew install awscli`",
338
+ };
339
+ }
340
+ // Unprivileged by construction: the vendor installer defaults to /usr/local,
341
+ // which needs root and would put a container and a laptop on different
342
+ // paths. An explicit prefix under $HOME keeps one code path for both and
343
+ // survives a throwaway container, which is where $HOME dies anyway.
344
+ const home = deps.home ?? process.env.HOME;
345
+ const url = `https://awscli.amazonaws.com/awscli-exe-linux-${
346
+ process.arch === "arm64" ? "aarch64" : "x86_64"
347
+ }.zip`;
348
+ run("bash", [
349
+ "-o",
350
+ "pipefail",
351
+ "-c",
352
+ [
353
+ "set -e",
354
+ 'tmp="$(mktemp -d)"',
355
+ `curl -fsSL "${url}" -o "$tmp/awscli.zip"`,
356
+ 'unzip -q "$tmp/awscli.zip" -d "$tmp"',
357
+ `"$tmp/aws/install" --install-dir "${home}/.local/aws-cli" ` +
358
+ `--bin-dir "${home}/.local/bin" --update`,
359
+ 'rm -rf "$tmp"',
360
+ ].join("\n"),
361
+ ]);
362
+ } else if (CHECKSUMMED.has(entry.kind)) {
363
+ // The pinned, checksummed path belongs to the remote-env installer. It is
364
+ // borrowed rather than reimplemented so the pins and checksums cannot
365
+ // drift between the two — a second installer is a second thing to keep
366
+ // honest, and the one run least often is the one that rots.
367
+ if (!deps.installPinned) {
368
+ return {
369
+ ...row,
370
+ action: "failed",
371
+ reason:
372
+ "pinned installer unavailable — cannot verify a checksum here",
373
+ };
374
+ }
375
+ deps.installPinned(entry);
376
+ } else {
377
+ return {
378
+ ...row,
379
+ action: "manual",
380
+ reason: entry.note ?? "no headless installer",
381
+ };
382
+ }
383
+ } catch (error) {
384
+ return {
385
+ ...row,
386
+ action: "failed",
387
+ reason: String(error.message).slice(0, 160),
388
+ };
389
+ }
390
+
391
+ // Verify by looking, not by exit status. Every installer above can exit 0
392
+ // having installed nothing, so a claim of success is only as good as a fresh
393
+ // probe — reporting a tool as installed when it is absent is worse than
394
+ // reporting the failure, because it moves the error to the moment of use.
395
+ if (!find(entry.name)) {
396
+ return {
397
+ ...row,
398
+ action: "failed",
399
+ reason: `installer exited 0 but ${entry.name} is still not on PATH`,
400
+ };
401
+ }
402
+ return { ...row, action: "installed", reason: "ok" };
403
+ }
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: "Prepare this machine — or a throwaway container — to run coding agents, before any repository exists. Detects which of Lisa's supported agents are already installed, asks which credential manager the machine uses (or none), and installs only what is missing, each by its vendor's own preferred method. Idempotent and headless; --print-dockerfile emits a spin-up/spin-down environment. Nothing is installed without --install."
3
+ allowed-tools: ["Skill"]
4
+ argument-hint: "[--install] [--agents=claude,codex] [--provider=bitwarden] [--json] [--print-dockerfile]"
5
+ ---
6
+
7
+ Use the /lisa-setup-workstation skill to report and optionally install the coding agents, credential manager, and base tools this machine needs. $ARGUMENTS
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lisa-detect-tooling
3
- description: "Find the command-line tools a project needs but never declares, and propose pinned manifest entries for them. Reads npm scripts, MCP servers, credential usage notes and quality configuration, subtracts whatever remoteEnv.tools already covers, and prints proposals with evidence. Writes nothing and installs nothing — a tool reaches a machine only when a human has reviewed a pinned, checksummed entry. Invoked by lisa-setup-remote-env before provisioning, and runnable on its own."
3
+ description: "Find the command-line tools a project needs but never declares, and propose pinned manifest entries for them. Reads git hooks and npm scripts for what the project actually RUNS — discovering tools no list mentions — plus MCP servers, credential usage notes and quality configuration. Subtracts whatever remoteEnv.tools, node_modules and the workstation layer already cover, and prints proposals with evidence. Writes nothing and installs nothing — a tool reaches a machine only when a human has reviewed a pinned, checksummed entry. Invoked by lisa-setup-remote-env before provisioning, and runnable on its own."
4
4
  allowed-tools: ["Bash", "Read", "AskUserQuestion"]
5
5
  ---
6
6
 
@@ -20,15 +20,45 @@ Nothing populates that last row. So a project can ship scripts that invoke `maes
20
20
 
21
21
  That is the same failure this repository has now paid for twice: `gh` was declared nowhere and a cloud session could not commit; `tar` was needed by an install method and asserted by nothing.
22
22
 
23
+ ## Discovery, not recognition
24
+
25
+ The first version of this could only find tools it had been told about: it matched script bodies against a fixed `KNOWN_TOOLS` list. That makes the detector useful for exactly the set that needs it least — the tools someone already thought of.
26
+
27
+ An Expo project invoking `eas` from **eight** npm scripts and six CI steps produced no proposal, because `eas` was not on the list. `gitleaks`, which runs on every commit in every one of these repositories, produced no proposal either.
28
+
29
+ So the question asked of executable sources is now the open one — *what does this project actually run* — and everything already provided is subtracted afterwards:
30
+
31
+ - what `remoteEnv.tools` declares
32
+ - what `node_modules/.bin` provides (a dependency's binary never needs to be on PATH)
33
+ - what `lisa-setup-workstation` owns (coding agents belong to the machine, not the checkout)
34
+
35
+ The curated list stays, because a vetted `why` is worth more than an observed one — proposals say which they are, `[curated]` or `[discovered]`.
36
+
23
37
  ## What it does
24
38
 
25
- Reads four signals, subtracts what `remoteEnv.tools` already declares, and prints what is left with the evidence for each:
39
+ Reads six signals, subtracts what is already covered, and prints what is left with the evidence for each:
40
+
41
+ - **git hooks** (`.husky/*`, `.git/hooks/*`) that invoke a binary. The **strongest** signal available, stronger than an npm script: a hook runs on every commit and push whether anyone asked or not, so a machine without the tool cannot commit at all.
26
42
 
27
- - **npm scripts** that invoke a binary. The strongest signal there is a script running `maestro test` is the project stating a dependency in executable form. Matched on the script *body*, not its name, because the name is a label.
43
+ A `command -v` guard counts as evidence rather than as "optional", because the guarded shape is the worse one. Lisa's pre-commit hook wraps its secret scan in `command -v gitleaks`, so on a machine without gitleaks the scan is **skipped silently** — the hook passes, the commit succeeds, and nothing was scanned. A guard makes the absence invisible precisely where it matters.
44
+
45
+ - **npm scripts** that invoke a binary — a script running `maestro test` is the project stating a dependency in executable form. Read from the script *body*, not its name, because the name is a label.
28
46
  - **MCP servers with a CLI equivalent.** An MCP server is not a substitute for the binary: several authenticate by browser OAuth, which a container cannot do, so a project relying on one remotely has *no* integration rather than a degraded one. Do not infer a CLI for access layers that already define their own headless substrate; Linear is handled by `lisa-linear-access` through `LINEAR_API_KEY` + GraphQL when MCP OAuth is unavailable.
29
47
  - **Credential usage notes.** A note explaining what a token is for usually names the program that consumes it. `lisa-secrets-access` already exposes these without touching a value, which makes them a first-class input rather than a trick.
30
48
  - **Quality configuration**, where a threshold implies the tool that produces it.
31
49
 
50
+ ## Reading shell is the hard part
51
+
52
+ Discovery only works if "what does this run" can be answered from shell text without inventing commands. Three things had to be right, each found by running it against real repositories:
53
+
54
+ **Only the first word of a command position counts.** Word-scanning this repo's own hooks proposed `ltrimstr`, `map` and `select` (jq filter internals), `console.log` and `process.exit` (embedded JavaScript), and `load_audit_cves` (a function the hook defines three lines up). Reading only where a command can actually start removes all of them without a denylist entry for any.
55
+
56
+ **Quoting and commenting nest, so neither can be stripped independently.** The comment on line 9 of `pre-push.local` ends `...the script's exit code)`. That lone apostrophe pairs with the next real quote 28 lines later, blanks everything between, and leaks a `node -e` payload's JavaScript into the results. Stripping comments first does not fix it, because a `#` inside a quoted string is not a comment. One character scanner tracking both is the only correct shape.
57
+
58
+ **`$( )` restarts the quoting context.** In `"$(printf '%s' "$JSON" | node -e '…')"`, quotes inside the substitution pair among themselves. A flat scanner falls out of phase on the first one and starts reporting the payload's own string literals.
59
+
60
+ Against Lisa and the three TunnlAI repositories, what survives is `gitleaks`, `jq`, `gtimeout` and `eas` — every one a real, undeclared invocation, with no false positives.
61
+
32
62
  ## What it will not do
33
63
 
34
64
  **It writes nothing and installs nothing.** Output is a proposal with `<pin>`, `<release url>` and `<sha256>` left for a human.