@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.
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +49 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/SKILL.md +32 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/SKILL.md +121 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
- package/plugins/lisa/commands/setup/workstation.md +7 -0
- package/plugins/lisa/skills/lisa-detect-tooling/SKILL.md +33 -3
- package/plugins/lisa/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
- package/plugins/lisa/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
- package/plugins/lisa/skills/lisa-setup-workstation/SKILL.md +121 -0
- package/plugins/lisa/skills/lisa-setup-workstation/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
- package/plugins/lisa/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
- package/plugins/lisa/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
- package/plugins/lisa-agy/commands/lisa/setup/workstation.md +7 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-detect-tooling/SKILL.md +33 -3
- package/plugins/lisa-agy/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
- package/plugins/lisa-agy/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
- package/plugins/lisa-agy/skills/lisa-setup-workstation/SKILL.md +121 -0
- package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
- package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
- package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/commands/lisa/setup/workstation.md +7 -0
- package/plugins/lisa-copilot/skills/lisa-detect-tooling/SKILL.md +33 -3
- package/plugins/lisa-copilot/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
- package/plugins/lisa-copilot/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
- package/plugins/lisa-copilot/skills/lisa-setup-workstation/SKILL.md +121 -0
- package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
- package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
- package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/commands/lisa/setup/workstation.md +7 -0
- package/plugins/lisa-cursor/skills/lisa-detect-tooling/SKILL.md +33 -3
- package/plugins/lisa-cursor/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
- package/plugins/lisa-cursor/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
- package/plugins/lisa-cursor/skills/lisa-setup-workstation/SKILL.md +121 -0
- package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
- package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
- package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/commands/setup/workstation.md +7 -0
- package/plugins/src/base/skills/lisa-detect-tooling/SKILL.md +33 -3
- package/plugins/src/base/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
- package/plugins/src/base/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
- package/plugins/src/base/skills/lisa-setup-workstation/SKILL.md +121 -0
- package/plugins/src/base/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
- package/plugins/src/base/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
- package/plugins/src/base/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull the commands out of a shell body.
|
|
3
|
+
*
|
|
4
|
+
* `lisa-detect-tooling` used to match script bodies against a fixed list of tool
|
|
5
|
+
* names, which meant it could only ever re-find tools someone had already
|
|
6
|
+
* thought of — the set that needs a detector least. An Expo project invoking
|
|
7
|
+
* `eas` from eight npm scripts produced nothing, because `eas` was not in the
|
|
8
|
+
* list.
|
|
9
|
+
*
|
|
10
|
+
* So the question this module answers is the open one: what does this shell text
|
|
11
|
+
* actually RUN? Everything downstream then subtracts what is already provided.
|
|
12
|
+
*
|
|
13
|
+
* ## Only the first word of a command position
|
|
14
|
+
*
|
|
15
|
+
* Naive word-scanning of this repository's own hooks proposed `ltrimstr`, `map`
|
|
16
|
+
* and `select` (jq filter internals), `console.log` and `process.exit` (embedded
|
|
17
|
+
* JavaScript), and `load_audit_cves` (a function the hook defines three lines
|
|
18
|
+
* up). None of those are tools; all of them appear where a word-scanner looks.
|
|
19
|
+
*
|
|
20
|
+
* A command runs at a command POSITION — the start of the text, or just after a
|
|
21
|
+
* separator. Reading only that position removes every one of those false
|
|
22
|
+
* positives without a denylist entry for any of them.
|
|
23
|
+
* @module commands
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shell keywords and coreutils: present on any machine, never provisioned.
|
|
28
|
+
*
|
|
29
|
+
* This is a floor, not a policy. Anything genuinely absent from a minimal
|
|
30
|
+
* container belongs in the manifest, and a few of these (`unzip`, `curl`) are
|
|
31
|
+
* already declared there by projects that need them — being listed here only
|
|
32
|
+
* means discovery will not RAISE them, not that they are assumed present.
|
|
33
|
+
*/
|
|
34
|
+
export const SHELL_VOCABULARY = new Set([
|
|
35
|
+
// Keywords and builtins.
|
|
36
|
+
"if",
|
|
37
|
+
"then",
|
|
38
|
+
"else",
|
|
39
|
+
"elif",
|
|
40
|
+
"fi",
|
|
41
|
+
"for",
|
|
42
|
+
"while",
|
|
43
|
+
"until",
|
|
44
|
+
"do",
|
|
45
|
+
"done",
|
|
46
|
+
"case",
|
|
47
|
+
"esac",
|
|
48
|
+
"in",
|
|
49
|
+
"function",
|
|
50
|
+
"return",
|
|
51
|
+
"break",
|
|
52
|
+
"continue",
|
|
53
|
+
"exit",
|
|
54
|
+
"local",
|
|
55
|
+
"readonly",
|
|
56
|
+
"declare",
|
|
57
|
+
"export",
|
|
58
|
+
"unset",
|
|
59
|
+
"shift",
|
|
60
|
+
"eval",
|
|
61
|
+
"exec",
|
|
62
|
+
"source",
|
|
63
|
+
"set",
|
|
64
|
+
"trap",
|
|
65
|
+
"wait",
|
|
66
|
+
"read",
|
|
67
|
+
"echo",
|
|
68
|
+
"printf",
|
|
69
|
+
"cd",
|
|
70
|
+
"pwd",
|
|
71
|
+
"true",
|
|
72
|
+
"false",
|
|
73
|
+
"test",
|
|
74
|
+
"let",
|
|
75
|
+
"alias",
|
|
76
|
+
"shopt",
|
|
77
|
+
"getopts",
|
|
78
|
+
"umask",
|
|
79
|
+
// Coreutils and the usual POSIX furniture.
|
|
80
|
+
"cat",
|
|
81
|
+
"grep",
|
|
82
|
+
"egrep",
|
|
83
|
+
"fgrep",
|
|
84
|
+
"sed",
|
|
85
|
+
"awk",
|
|
86
|
+
"cut",
|
|
87
|
+
"tr",
|
|
88
|
+
"sort",
|
|
89
|
+
"uniq",
|
|
90
|
+
"head",
|
|
91
|
+
"tail",
|
|
92
|
+
"wc",
|
|
93
|
+
"tee",
|
|
94
|
+
"xargs",
|
|
95
|
+
"find",
|
|
96
|
+
"ls",
|
|
97
|
+
"mkdir",
|
|
98
|
+
"rmdir",
|
|
99
|
+
"rm",
|
|
100
|
+
"mv",
|
|
101
|
+
"cp",
|
|
102
|
+
"ln",
|
|
103
|
+
"touch",
|
|
104
|
+
"chmod",
|
|
105
|
+
"chown",
|
|
106
|
+
"stat",
|
|
107
|
+
"du",
|
|
108
|
+
"df",
|
|
109
|
+
"dirname",
|
|
110
|
+
"basename",
|
|
111
|
+
"realpath",
|
|
112
|
+
"readlink",
|
|
113
|
+
"mktemp",
|
|
114
|
+
"date",
|
|
115
|
+
"sleep",
|
|
116
|
+
"seq",
|
|
117
|
+
"env",
|
|
118
|
+
"kill",
|
|
119
|
+
"pgrep",
|
|
120
|
+
"pkill",
|
|
121
|
+
"ps",
|
|
122
|
+
"diff",
|
|
123
|
+
"comm",
|
|
124
|
+
"join",
|
|
125
|
+
"paste",
|
|
126
|
+
"split",
|
|
127
|
+
"tar",
|
|
128
|
+
"gzip",
|
|
129
|
+
"gunzip",
|
|
130
|
+
"zip",
|
|
131
|
+
"unzip",
|
|
132
|
+
"curl",
|
|
133
|
+
"wget",
|
|
134
|
+
"sh",
|
|
135
|
+
"bash",
|
|
136
|
+
"zsh",
|
|
137
|
+
"dash",
|
|
138
|
+
"nohup",
|
|
139
|
+
"timeout",
|
|
140
|
+
"yes",
|
|
141
|
+
"tty",
|
|
142
|
+
"id",
|
|
143
|
+
"whoami",
|
|
144
|
+
"uname",
|
|
145
|
+
"hostname",
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Runtimes and package managers whose presence is assumed by everything here.
|
|
150
|
+
*
|
|
151
|
+
* Separate from the vocabulary above because these are genuinely provisioned —
|
|
152
|
+
* just not by this detector. `node` is what runs it; proposing it would be
|
|
153
|
+
* circular.
|
|
154
|
+
*/
|
|
155
|
+
export const RUNTIMES = new Set([
|
|
156
|
+
"node",
|
|
157
|
+
"npm",
|
|
158
|
+
"npx",
|
|
159
|
+
"bun",
|
|
160
|
+
"bunx",
|
|
161
|
+
"yarn",
|
|
162
|
+
"pnpm",
|
|
163
|
+
"pnpx",
|
|
164
|
+
"corepack",
|
|
165
|
+
"git",
|
|
166
|
+
"python",
|
|
167
|
+
"python3",
|
|
168
|
+
"pip",
|
|
169
|
+
"pip3",
|
|
170
|
+
"ruby",
|
|
171
|
+
"bundle",
|
|
172
|
+
"go",
|
|
173
|
+
"cargo",
|
|
174
|
+
"docker",
|
|
175
|
+
"make",
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Commands that RESOLVE the next word from `node_modules`.
|
|
180
|
+
*
|
|
181
|
+
* `npx playwright test` needs no manifest entry: npm put the binary in
|
|
182
|
+
* `node_modules/.bin` and npx finds it there. Reading the mediated word as a
|
|
183
|
+
* tool would propose pinning something the package manager already provides —
|
|
184
|
+
* the noise that teaches a reader to skim the output.
|
|
185
|
+
*/
|
|
186
|
+
const MEDIATORS = new Set(["npx", "bunx", "pnpx", "dlx"]);
|
|
187
|
+
|
|
188
|
+
/** Sub-commands after a package manager that mean "run something local". */
|
|
189
|
+
const LOCAL_RUNNERS = new Set(["run", "exec", "x", "dlx", "run-script"]);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Commands that TEST for another command rather than running it.
|
|
193
|
+
*
|
|
194
|
+
* `command -v gitleaks` is the single most valuable signal a hook can carry, and
|
|
195
|
+
* a word-scanner reading only the first position would throw it away. It is also
|
|
196
|
+
* the shape of the worst failure mode in this codebase: a hook that guards its
|
|
197
|
+
* own tool with `command -v` SKIPS SILENTLY when the tool is absent. The commit
|
|
198
|
+
* succeeds and nothing was scanned.
|
|
199
|
+
*
|
|
200
|
+
* A guarded tool is therefore stronger evidence than an unguarded one, not
|
|
201
|
+
* weaker — its absence is invisible at the moment it matters.
|
|
202
|
+
*/
|
|
203
|
+
const PROBES = new Set(["command", "which", "type", "hash"]);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Words that stand in FRONT of a command without being one.
|
|
207
|
+
*
|
|
208
|
+
* Control keywords have to be skipped past rather than treated as the command,
|
|
209
|
+
* or the very shape this exists to catch is missed: in
|
|
210
|
+
* `if ! command -v gitleaks; then`, stopping at `if` never reaches the probe.
|
|
211
|
+
*/
|
|
212
|
+
const PREFIXES = new Set([
|
|
213
|
+
"!",
|
|
214
|
+
"sudo",
|
|
215
|
+
"time",
|
|
216
|
+
"nice",
|
|
217
|
+
"builtin",
|
|
218
|
+
"exec",
|
|
219
|
+
"if",
|
|
220
|
+
"then",
|
|
221
|
+
"elif",
|
|
222
|
+
"else",
|
|
223
|
+
"while",
|
|
224
|
+
"until",
|
|
225
|
+
"do",
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
/** A name that could plausibly be an executable on PATH. */
|
|
229
|
+
const TOOL_NAME = /^[a-z][a-z0-9_-]*$/u;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Names the text defines as shell functions, which are never PATH tools.
|
|
233
|
+
*
|
|
234
|
+
* This repository's `pre-push` hook defines `load_audit_cves` and
|
|
235
|
+
* `missing_opencode_metadata` and then calls them, which reads exactly like a
|
|
236
|
+
* command invocation because it is one — just not of anything installable.
|
|
237
|
+
* @param {string} text Shell body.
|
|
238
|
+
* @returns {Set<string>} Locally defined function names.
|
|
239
|
+
*/
|
|
240
|
+
export function localFunctions(text) {
|
|
241
|
+
const names = new Set();
|
|
242
|
+
const pattern =
|
|
243
|
+
/(?:^|\n)\s*(?:function\s+)?([a-z_][a-z0-9_]*)\s*\(\)\s*\{/giu;
|
|
244
|
+
let match = pattern.exec(text);
|
|
245
|
+
while (match) {
|
|
246
|
+
names.add(match[1].toLowerCase());
|
|
247
|
+
match = pattern.exec(text);
|
|
248
|
+
}
|
|
249
|
+
return names;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Pull out `sh -c '...'` payloads so their contents are parsed as shell too.
|
|
254
|
+
*
|
|
255
|
+
* Done BEFORE quoted spans are stripped, because the payload is precisely a
|
|
256
|
+
* quoted span — and it is the one kind that really is a command. Frontend's
|
|
257
|
+
* `eas:publish:e2e` script wraps its `eas update` in exactly this shape.
|
|
258
|
+
* @param {string} text Shell body.
|
|
259
|
+
* @returns {{stripped: string, payloads: string[]}} Text and nested bodies.
|
|
260
|
+
*/
|
|
261
|
+
export function extractShellPayloads(text) {
|
|
262
|
+
const payloads = [];
|
|
263
|
+
const stripped = text.replace(
|
|
264
|
+
/\b(?:sh|bash|zsh)\s+-[a-z]*c\s+('([^']*)'|"([^"]*)")/gu,
|
|
265
|
+
(_all, _quoted, single, double) => {
|
|
266
|
+
payloads.push(single ?? double ?? "");
|
|
267
|
+
return " ";
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
return { stripped, payloads };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Blank out quoted spans and comments, keeping only text at command level.
|
|
275
|
+
*
|
|
276
|
+
* Quoted text is where embedded programs live — the jq filters and `node -e`
|
|
277
|
+
* snippets that produced `ltrimstr` and `console.log`. A command inside quotes
|
|
278
|
+
* is an argument to something else, with one exception, which
|
|
279
|
+
* `extractShellPayloads` has already lifted out by the time this runs.
|
|
280
|
+
*
|
|
281
|
+
* This is a character scanner rather than a chain of regexes because quoting and
|
|
282
|
+
* commenting are MUTUALLY nested and no ordering of independent passes gets it
|
|
283
|
+
* right. This repository's own `pre-push.local` proves it: the comment on line 9
|
|
284
|
+
* ends `...the script's exit code)`, and that lone apostrophe pairs with the
|
|
285
|
+
* next real quote 28 lines later, blanking everything between — including the
|
|
286
|
+
* `node -e` payload, whose JavaScript then leaked out as `try`, `catch`,
|
|
287
|
+
* `const` and `unreadable` proposals.
|
|
288
|
+
*
|
|
289
|
+
* Stripping comments first does not fix it either, since a `#` inside a quoted
|
|
290
|
+
* string is not a comment. One pass that tracks both is the only correct shape.
|
|
291
|
+
*
|
|
292
|
+
* Newlines survive so command positions are preserved; everything else inside a
|
|
293
|
+
* masked span becomes a space.
|
|
294
|
+
* @param {string} text Shell body.
|
|
295
|
+
* @returns {string} Text with non-command spans blanked.
|
|
296
|
+
*/
|
|
297
|
+
export function stripNonCommandSpans(text) {
|
|
298
|
+
const out = [];
|
|
299
|
+
let state = "normal";
|
|
300
|
+
// `$( )` restarts the quoting context: inside it, quotes pair among
|
|
301
|
+
// themselves rather than continuing the enclosing span. A flat scanner falls
|
|
302
|
+
// out of phase on the very first one — this repository's
|
|
303
|
+
// `"$(printf '%s' "$JSON" | node -e '...')"` left the payload's own string
|
|
304
|
+
// literals exposed at command level, proposing `data`, `end` and `stale`.
|
|
305
|
+
const enclosing = [];
|
|
306
|
+
|
|
307
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
308
|
+
const char = text[index];
|
|
309
|
+
const previous = index > 0 ? text[index - 1] : "";
|
|
310
|
+
|
|
311
|
+
// A substitution opens a fresh context from inside ANY state but a
|
|
312
|
+
// single-quoted one, where shell performs no substitution at all.
|
|
313
|
+
if (
|
|
314
|
+
state !== "single" &&
|
|
315
|
+
state !== "comment" &&
|
|
316
|
+
char === "$" &&
|
|
317
|
+
text[index + 1] === "("
|
|
318
|
+
) {
|
|
319
|
+
enclosing.push(state);
|
|
320
|
+
state = "normal";
|
|
321
|
+
out.push("\n");
|
|
322
|
+
index += 1;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (state === "normal" && char === ")" && enclosing.length > 0) {
|
|
326
|
+
state = enclosing.pop();
|
|
327
|
+
out.push("\n");
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (state === "normal") {
|
|
332
|
+
if (char === "\\") {
|
|
333
|
+
// An escaped character is never a delimiter. Skip both.
|
|
334
|
+
out.push(" ");
|
|
335
|
+
index += 1;
|
|
336
|
+
if (text[index] === "\n") out.push("\n");
|
|
337
|
+
else out.push(" ");
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (char === "'") {
|
|
341
|
+
state = "single";
|
|
342
|
+
out.push(" ");
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (char === '"') {
|
|
346
|
+
state = "double";
|
|
347
|
+
out.push(" ");
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
// A `#` only opens a comment at the start of a word.
|
|
351
|
+
if (char === "#" && (previous === "" || /\s/u.test(previous))) {
|
|
352
|
+
state = "comment";
|
|
353
|
+
out.push(" ");
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
out.push(char);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (state === "comment") {
|
|
361
|
+
if (char === "\n") {
|
|
362
|
+
state = "normal";
|
|
363
|
+
out.push("\n");
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
out.push(" ");
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Inside quotes. A single-quoted span has no escapes at all in shell.
|
|
371
|
+
if (state === "double" && char === "\\") {
|
|
372
|
+
out.push(" ");
|
|
373
|
+
index += 1;
|
|
374
|
+
out.push(text[index] === "\n" ? "\n" : " ");
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (
|
|
378
|
+
(state === "single" && char === "'") ||
|
|
379
|
+
(state === "double" && char === '"')
|
|
380
|
+
) {
|
|
381
|
+
state = "normal";
|
|
382
|
+
out.push(" ");
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
out.push(char === "\n" ? "\n" : " ");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Redirections last, on text that is already command-level.
|
|
389
|
+
return out.join("").replace(/\d?[<>]+&?\S*/gu, " ");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Whether a word can be a tool this detector should raise.
|
|
394
|
+
*
|
|
395
|
+
* One gate, applied to every path into the results. Probed names used to skip
|
|
396
|
+
* it, which is how `command -v bun` — a hook checking its own runtime — became
|
|
397
|
+
* a proposal to pin bun on every repository scanned.
|
|
398
|
+
* @param {string} word Candidate command name.
|
|
399
|
+
* @param {Set<string>} defined Function names the text defines itself.
|
|
400
|
+
* @returns {boolean} Whether to raise it.
|
|
401
|
+
*/
|
|
402
|
+
function admissible(word, defined) {
|
|
403
|
+
// A path is not a PATH lookup: `./scripts/check.sh` is project code.
|
|
404
|
+
if (word.includes("/") || word.includes("$")) return false;
|
|
405
|
+
if (!TOOL_NAME.test(word)) return false;
|
|
406
|
+
if (SHELL_VOCABULARY.has(word)) return false;
|
|
407
|
+
// `bun run build` resolves locally, and the runtime itself is assumed.
|
|
408
|
+
if (RUNTIMES.has(word)) return false;
|
|
409
|
+
return !defined.has(word);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Read the tool a probe is testing for.
|
|
414
|
+
* @param {string[]} words Words after the probe.
|
|
415
|
+
* @returns {string|null} The probed name, or null.
|
|
416
|
+
*/
|
|
417
|
+
function probedTool(words) {
|
|
418
|
+
for (const word of words) {
|
|
419
|
+
if (word.startsWith("-")) continue;
|
|
420
|
+
return TOOL_NAME.test(word) ? word : null;
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Every command this shell text runs, at a command position.
|
|
427
|
+
* @param {string} text Shell body.
|
|
428
|
+
* @returns {string[]} Command names, deduplicated, in first-seen order.
|
|
429
|
+
*/
|
|
430
|
+
export function commandsIn(text) {
|
|
431
|
+
if (typeof text !== "string" || text.trim() === "") return [];
|
|
432
|
+
|
|
433
|
+
const { stripped, payloads } = extractShellPayloads(text);
|
|
434
|
+
const defined = localFunctions(text);
|
|
435
|
+
const found = new Set();
|
|
436
|
+
|
|
437
|
+
// Nested `sh -c` bodies are shell in their own right, so they get the same
|
|
438
|
+
// treatment rather than a weaker one.
|
|
439
|
+
for (const payload of payloads) {
|
|
440
|
+
for (const command of commandsIn(payload)) found.add(command);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const segments = stripNonCommandSpans(stripped).split(
|
|
444
|
+
/\|\||&&|\$\(|[;|&\n(){}`]/u
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
for (const segment of segments) {
|
|
448
|
+
const words = segment.trim().split(/\s+/u).filter(Boolean);
|
|
449
|
+
let index = 0;
|
|
450
|
+
// Environment assignments and decorations sit in front of the command.
|
|
451
|
+
while (
|
|
452
|
+
index < words.length &&
|
|
453
|
+
(/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[index]) ||
|
|
454
|
+
PREFIXES.has(words[index]))
|
|
455
|
+
) {
|
|
456
|
+
index += 1;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const word = words[index]?.toLowerCase();
|
|
460
|
+
if (!word) continue;
|
|
461
|
+
|
|
462
|
+
if (PROBES.has(word)) {
|
|
463
|
+
// `command -v gitleaks` — the probed name is the evidence. It goes
|
|
464
|
+
// through the SAME filters as a directly invoked command: reading it
|
|
465
|
+
// straight into the results proposed `bun` and `yarn` on every repo,
|
|
466
|
+
// because `command -v bun` is how a hook checks its own runtime.
|
|
467
|
+
const probed = probedTool(words.slice(index + 1));
|
|
468
|
+
if (probed && admissible(probed, defined)) found.add(probed);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (MEDIATORS.has(word)) continue;
|
|
472
|
+
if (LOCAL_RUNNERS.has(word)) continue;
|
|
473
|
+
if (!admissible(word, defined)) continue;
|
|
474
|
+
|
|
475
|
+
found.add(word);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return [...found];
|
|
479
|
+
}
|
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
* @module detect-tooling
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
25
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
|
|
28
|
+
import { commandsIn } from "./commands.mjs";
|
|
29
|
+
|
|
28
30
|
/** The tool named by the Expo template's scripts and its flows directory. */
|
|
29
31
|
const MAESTRO = "maestro";
|
|
30
32
|
|
|
@@ -109,6 +111,128 @@ export function toolsFromScripts(pkg) {
|
|
|
109
111
|
return found;
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
/** Where git hooks live, strongest signal first. */
|
|
115
|
+
const HOOK_DIRECTORIES = [".husky", join(".git", "hooks")];
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Coding agents, which a PROJECT manifest must never try to provision.
|
|
119
|
+
*
|
|
120
|
+
* A hook that shells out to `claude` is real evidence of a real dependency, but
|
|
121
|
+
* `remoteEnv.tools` is the wrong place to answer it: agents belong to the
|
|
122
|
+
* machine, not the checkout, and each is installed by its vendor's own method
|
|
123
|
+
* into a version directory it manages itself. Pinning one as a release archive
|
|
124
|
+
* would fight the self-updater that owns that directory.
|
|
125
|
+
*
|
|
126
|
+
* That layer is `lisa-setup-workstation`. Named here rather than imported from
|
|
127
|
+
* it, because a detector that stops working when a sibling skill is absent is
|
|
128
|
+
* worse than one carrying six strings.
|
|
129
|
+
*
|
|
130
|
+
* Deliberately NOT extended to `gh`, `bws`, `aws` or `sonar`: those genuinely
|
|
131
|
+
* belong in a project manifest, and `gh` going undeclared is the incident this
|
|
132
|
+
* skill was written after.
|
|
133
|
+
*/
|
|
134
|
+
const WORKSTATION_PROVISIONED = new Set([
|
|
135
|
+
"claude",
|
|
136
|
+
"codex",
|
|
137
|
+
"cursor-agent",
|
|
138
|
+
"opencode",
|
|
139
|
+
"agy",
|
|
140
|
+
"copilot",
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Tools a git hook runs — the strongest signal a project can give.
|
|
145
|
+
*
|
|
146
|
+
* Stronger than an npm script, because a hook runs on EVERY commit and push
|
|
147
|
+
* whether anyone asked or not. A container missing one of these cannot commit at
|
|
148
|
+
* all; the failure arrives mid-task, unattended, at the moment of use.
|
|
149
|
+
*
|
|
150
|
+
* The worst shape is the guarded one. This repository's pre-commit hook wraps
|
|
151
|
+
* its secret scan in `command -v gitleaks`, so on a machine without gitleaks the
|
|
152
|
+
* scan is SKIPPED SILENTLY — the hook passes, the commit succeeds, and nothing
|
|
153
|
+
* was scanned. A guard makes the absence invisible exactly where it matters
|
|
154
|
+
* most, which is why a probed name counts as evidence rather than being read as
|
|
155
|
+
* "optional".
|
|
156
|
+
* @param {string} cwd Project root.
|
|
157
|
+
* @returns {Map<string, string>} Tool name to the hook that proves it.
|
|
158
|
+
*/
|
|
159
|
+
export function toolsFromGitHooks(cwd) {
|
|
160
|
+
const found = new Map();
|
|
161
|
+
for (const directory of HOOK_DIRECTORIES) {
|
|
162
|
+
const full = join(cwd, directory);
|
|
163
|
+
let names = [];
|
|
164
|
+
try {
|
|
165
|
+
names = readdirSync(full);
|
|
166
|
+
} catch {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
for (const name of names.sort()) {
|
|
170
|
+
// `.sample` files ship with git and run nothing.
|
|
171
|
+
if (name.endsWith(".sample")) continue;
|
|
172
|
+
let body;
|
|
173
|
+
try {
|
|
174
|
+
body = readFileSync(join(full, name), "utf8");
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
for (const tool of commandsIn(body)) {
|
|
179
|
+
if (!found.has(tool)) found.set(tool, `git hook ${directory}/${name}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return found;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Tools an npm script invokes, discovered rather than matched against a list.
|
|
188
|
+
*
|
|
189
|
+
* `toolsFromScripts` answers "which of the tools I already know about appear
|
|
190
|
+
* here". This answers the open question — what does this project actually RUN —
|
|
191
|
+
* which is the one that finds a tool nobody thought to add. An Expo project
|
|
192
|
+
* invoking `eas` from eight scripts produced nothing under the old shape,
|
|
193
|
+
* because `eas` was not in `KNOWN_TOOLS`.
|
|
194
|
+
* @param {object|null} pkg Parsed package.json.
|
|
195
|
+
* @returns {Map<string, string>} Tool name to the script that proves it.
|
|
196
|
+
*/
|
|
197
|
+
export function toolsDiscoveredInScripts(pkg) {
|
|
198
|
+
const found = new Map();
|
|
199
|
+
for (const [name, body] of Object.entries(pkg?.scripts ?? {})) {
|
|
200
|
+
if (typeof body !== "string") continue;
|
|
201
|
+
for (const tool of commandsIn(body)) {
|
|
202
|
+
if (found.has(tool)) continue;
|
|
203
|
+
found.set(tool, `npm script "${name}": ${body.trim().slice(0, 60)}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return found;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether the package manager already puts this binary on the local path.
|
|
211
|
+
*
|
|
212
|
+
* A dependency's binary lands in `node_modules/.bin`, where npm scripts resolve
|
|
213
|
+
* it without it ever being on PATH. Proposing a manifest entry for one is noise,
|
|
214
|
+
* and noise is how a detector teaches people to skim it.
|
|
215
|
+
*
|
|
216
|
+
* Only meaningful once dependencies are installed, which is why
|
|
217
|
+
* `dependenciesInstalled` exists rather than this quietly returning false on a
|
|
218
|
+
* fresh clone and over-proposing every dev tool in the project.
|
|
219
|
+
* @param {string} cwd Project root.
|
|
220
|
+
* @param {string} tool Tool name.
|
|
221
|
+
* @returns {boolean} Whether npm already provides it.
|
|
222
|
+
*/
|
|
223
|
+
export function providedByNodeModules(cwd, tool) {
|
|
224
|
+
return existsSync(join(cwd, "node_modules", ".bin", tool));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whether `node_modules` is populated, so its absence can be reported.
|
|
229
|
+
* @param {string} cwd Project root.
|
|
230
|
+
* @returns {boolean} Whether dependencies are installed.
|
|
231
|
+
*/
|
|
232
|
+
export function dependenciesInstalled(cwd) {
|
|
233
|
+
return existsSync(join(cwd, "node_modules", ".bin"));
|
|
234
|
+
}
|
|
235
|
+
|
|
112
236
|
/**
|
|
113
237
|
* MCP servers that are access substrates rather than evidence for a CLI.
|
|
114
238
|
*
|
|
@@ -330,8 +454,14 @@ export function detectTooling(cwd = process.cwd()) {
|
|
|
330
454
|
const notes = readJson(join(cwd, ".lisa", "secret-notes.json"));
|
|
331
455
|
|
|
332
456
|
const declared = declaredTools(config);
|
|
457
|
+
// Curated signals carry a vetted `why`. Discovered ones carry only the fact
|
|
458
|
+
// that the project runs the thing — weaker prose, identical standing as
|
|
459
|
+
// evidence, and the only kind that can find a tool nobody listed.
|
|
460
|
+
const hooks = toolsFromGitHooks(cwd);
|
|
333
461
|
const signals = [
|
|
462
|
+
hooks,
|
|
334
463
|
toolsFromScripts(pkg),
|
|
464
|
+
toolsDiscoveredInScripts(pkg),
|
|
335
465
|
toolsFromMcp(mcp),
|
|
336
466
|
toolsFromSecretNotes(notes),
|
|
337
467
|
toolsFromQuality(config, pkg, cwd),
|
|
@@ -343,8 +473,11 @@ export function detectTooling(cwd = process.cwd()) {
|
|
|
343
473
|
if (declared.has(tool)) continue;
|
|
344
474
|
// Already provided by the package manager, so there is nothing to pin.
|
|
345
475
|
if (satisfiedByNpm(pkg, tool)) continue;
|
|
476
|
+
if (providedByNodeModules(cwd, tool)) continue;
|
|
477
|
+
// Real dependency, wrong manifest — the workstation layer owns it.
|
|
478
|
+
if (WORKSTATION_PROVISIONED.has(tool)) continue;
|
|
346
479
|
const entry = merged.get(tool) ?? { evidence: [] };
|
|
347
|
-
entry.evidence.push(evidence);
|
|
480
|
+
if (!entry.evidence.includes(evidence)) entry.evidence.push(evidence);
|
|
348
481
|
merged.set(tool, entry);
|
|
349
482
|
}
|
|
350
483
|
}
|
|
@@ -352,12 +485,35 @@ export function detectTooling(cwd = process.cwd()) {
|
|
|
352
485
|
return [...merged.entries()]
|
|
353
486
|
.map(([name, entry]) => ({
|
|
354
487
|
name,
|
|
355
|
-
why: KNOWN_TOOLS[name]?.why ??
|
|
488
|
+
why: KNOWN_TOOLS[name]?.why ?? discoveredWhy(name, hooks.has(name)),
|
|
356
489
|
evidence: entry.evidence,
|
|
490
|
+
// Says which vocabulary the `why` came from, so a reader can weigh it.
|
|
491
|
+
// A curated entry has been thought about; a discovered one has only been
|
|
492
|
+
// observed.
|
|
493
|
+
source: Object.hasOwn(KNOWN_TOOLS, name) ? "curated" : "discovered",
|
|
357
494
|
}))
|
|
358
495
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
359
496
|
}
|
|
360
497
|
|
|
498
|
+
/**
|
|
499
|
+
* The `why` for a tool nobody wrote prose for.
|
|
500
|
+
*
|
|
501
|
+
* Every proposal must carry one — a proposal without a reason is an assertion,
|
|
502
|
+
* and this program's whole boundary is that it asserts nothing. So a discovered
|
|
503
|
+
* tool states the observation plainly rather than borrowing confidence it has
|
|
504
|
+
* not earned.
|
|
505
|
+
* @param {string} name Tool name.
|
|
506
|
+
* @param {boolean} fromHook Whether a git hook runs it.
|
|
507
|
+
* @returns {string} A reason a reader can check.
|
|
508
|
+
*/
|
|
509
|
+
export function discoveredWhy(name, fromHook) {
|
|
510
|
+
return fromHook
|
|
511
|
+
? `A git hook runs \`${name}\` on every commit or push, and nothing ` +
|
|
512
|
+
`declares it — a machine without it fails at commit time, or skips the ` +
|
|
513
|
+
`check silently if the hook guards it.`
|
|
514
|
+
: `An npm script invokes \`${name}\`, and nothing puts it on PATH.`;
|
|
515
|
+
}
|
|
516
|
+
|
|
361
517
|
/**
|
|
362
518
|
* The manifest entries an operator can paste, with the parts only they supply.
|
|
363
519
|
*
|
|
@@ -438,8 +594,18 @@ if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
|
|
438
594
|
console.log(
|
|
439
595
|
`${proposals.length} tool(s) look required but are not declared:\n`
|
|
440
596
|
);
|
|
597
|
+
if (!dependenciesInstalled(process.cwd())) {
|
|
598
|
+
// Without node_modules there is no way to tell a tool that needs PATH
|
|
599
|
+
// from one npm already provides, so every dev dependency looks missing.
|
|
600
|
+
// Said out loud rather than silently over-proposing.
|
|
601
|
+
console.log(
|
|
602
|
+
"note: dependencies are not installed, so binaries npm would provide " +
|
|
603
|
+
"cannot be\n excluded. Run the install first for a shorter, " +
|
|
604
|
+
"more accurate list.\n"
|
|
605
|
+
);
|
|
606
|
+
}
|
|
441
607
|
for (const proposal of proposals) {
|
|
442
|
-
console.log(` ${proposal.name}`);
|
|
608
|
+
console.log(` ${proposal.name} [${proposal.source}]`);
|
|
443
609
|
console.log(` why: ${proposal.why}`);
|
|
444
610
|
for (const evidence of proposal.evidence) {
|
|
445
611
|
console.log(` evidence: ${evidence}`);
|