wizz-method 1.2.2 → 1.3.1

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.
@@ -0,0 +1,140 @@
1
+ // Writes a project-local cache manifest of the skill dependencies (CLIs + MCPs)
2
+ // resolved at install-time for the chosen areas.
3
+ //
4
+ // Behavior B of the "Doutrina de Instalação" (skills-registry.yaml, rule 2):
5
+ // when the installed skills declare NPX/CLI/MCP dependencies, detect them and
6
+ // cache the result in the project so future runs and the maestro can see —
7
+ // without re-resolving the registry or re-probing PATH — exactly which deps
8
+ // belong to this project's skills and what state they were left in:
9
+ // - clis.installed / clis.recommended / clis.alreadyPresent
10
+ // - mcps.written (merged into .mcp.json) / mcps.recommended
11
+ //
12
+ // This is a passive manifest (a cache, not an installer): it records the plan
13
+ // the install already executed. It never runs commands, never writes secrets
14
+ // (MCP env stays as the registry `${VAR}` placeholders), and is a no-op when
15
+ // there is nothing to record. Written into `_wizz/_config/` next to the
16
+ // installed skills-registry.yaml so the routing source and the dep cache stay
17
+ // co-located.
18
+
19
+ const path = require('node:path');
20
+ const fs = require('../fs-native');
21
+
22
+ const CACHE_BASENAME = 'skill-deps-cache.json';
23
+
24
+ /**
25
+ * Shape a resolved CLI entry for the cache: identity + the copy-pasteable
26
+ * install command, dropping the transient `installed` probe flag (its bucket
27
+ * already encodes that). Returns a new object; never mutates the input.
28
+ * @param {{id: string, when?: string, install?: string, areas?: string[]}} cli
29
+ * @returns {{id: string, when: string, install: string, areas: string[]}}
30
+ */
31
+ function shapeCli(cli) {
32
+ return {
33
+ id: cli.id,
34
+ when: cli.when || '',
35
+ install: cli.install || '',
36
+ areas: Array.isArray(cli.areas) ? [...cli.areas] : [],
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Shape a resolved MCP entry for the cache: identity + the server block as it
42
+ * came from the registry (env values remain `${VAR}` placeholders — no real
43
+ * token is ever cached). Returns a new object; never mutates the input.
44
+ * @param {{id: string, when?: string, server?: Object, areas?: string[]}} mcp
45
+ * @returns {{id: string, when: string, server: Object, areas: string[]}}
46
+ */
47
+ function shapeMcp(mcp) {
48
+ return {
49
+ id: mcp.id,
50
+ when: mcp.when || '',
51
+ server: mcp.server ? { ...mcp.server } : {},
52
+ areas: Array.isArray(mcp.areas) ? [...mcp.areas] : [],
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Build the cache object from the install-time cli/mcp plans. Pure: no I/O,
58
+ * no mutation of the inputs. Exposed for tests and for the caller to inspect.
59
+ * @param {Object} args
60
+ * @param {string[]} [args.selectedAreas] - Chosen area keys ([] => all areas)
61
+ * @param {{toInstall?: Object[], toRecommend?: Object[], alreadyInstalled?: Object[]}} [args.cliPlan]
62
+ * @param {{toWrite?: Object[], toRecommend?: Object[]}} [args.mcpPlan]
63
+ * @returns {Object} The manifest to be written.
64
+ */
65
+ function buildDepsCache({ selectedAreas, cliPlan = {}, mcpPlan = {} }) {
66
+ return {
67
+ version: 1,
68
+ generatedAt: new Date().toISOString(),
69
+ selectedAreas: Array.isArray(selectedAreas) ? [...selectedAreas] : [],
70
+ clis: {
71
+ installed: (cliPlan.toInstall || []).map(shapeCli),
72
+ recommended: (cliPlan.toRecommend || []).map(shapeCli),
73
+ alreadyPresent: (cliPlan.alreadyInstalled || []).map(shapeCli),
74
+ },
75
+ mcps: {
76
+ written: (mcpPlan.toWrite || []).map(shapeMcp),
77
+ recommended: (mcpPlan.toRecommend || []).map(shapeMcp),
78
+ },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * True when a built cache has no dependency entries at all — nothing worth
84
+ * persisting (e.g. no `bmm` module, or the chosen areas declare no deps).
85
+ * @param {Object} cache - Output of buildDepsCache
86
+ * @returns {boolean}
87
+ */
88
+ function isEmptyDepsCache(cache) {
89
+ const c = cache.clis || {};
90
+ const m = cache.mcps || {};
91
+ return (
92
+ (c.installed || []).length === 0 &&
93
+ (c.recommended || []).length === 0 &&
94
+ (c.alreadyPresent || []).length === 0 &&
95
+ (m.written || []).length === 0 &&
96
+ (m.recommended || []).length === 0
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Build and write the deps cache into `<wizzDir>/_config/skill-deps-cache.json`.
102
+ * No-op (writes nothing, returns wrote:false) when there are no deps to record,
103
+ * so it is always safe to call. Never throws for lack of deps; a real fs error
104
+ * propagates to the caller, which already wraps this step in a warn-not-abort.
105
+ *
106
+ * @param {Object} args
107
+ * @param {string} args.wizzDir - Installed `_wizz` directory
108
+ * @param {string[]} [args.selectedAreas] - Chosen area keys ([] => all)
109
+ * @param {Object} [args.cliPlan] - The install-time CLI plan (from ui.selectClis)
110
+ * @param {Object} [args.mcpPlan] - The install-time MCP plan (from ui.selectMcps)
111
+ * @param {(absPath: string) => void} [args.trackFile] - Record the written file
112
+ * @returns {Promise<{wrote: boolean, file: string|null, counts: Object}>}
113
+ */
114
+ async function writeDepsCache({ wizzDir, selectedAreas, cliPlan, mcpPlan, trackFile = () => {} }) {
115
+ const cache = buildDepsCache({ selectedAreas, cliPlan, mcpPlan });
116
+ const counts = {
117
+ clisInstalled: cache.clis.installed.length,
118
+ clisRecommended: cache.clis.recommended.length,
119
+ clisAlreadyPresent: cache.clis.alreadyPresent.length,
120
+ mcpsWritten: cache.mcps.written.length,
121
+ mcpsRecommended: cache.mcps.recommended.length,
122
+ };
123
+
124
+ if (isEmptyDepsCache(cache)) return { wrote: false, file: null, counts };
125
+
126
+ const file = path.join(wizzDir, '_config', CACHE_BASENAME);
127
+ await fs.ensureDir(path.dirname(file));
128
+ await fs.writeJson(file, cache, { spaces: 2 });
129
+ trackFile(file);
130
+ return { wrote: true, file, counts };
131
+ }
132
+
133
+ module.exports = {
134
+ buildDepsCache,
135
+ isEmptyDepsCache,
136
+ writeDepsCache,
137
+ shapeCli,
138
+ shapeMcp,
139
+ CACHE_BASENAME,
140
+ };
@@ -20,6 +20,7 @@
20
20
 
21
21
  const path = require('node:path');
22
22
  const fs = require('../fs-native');
23
+ const { defaultExec } = require('./cli-config');
23
24
 
24
25
  /**
25
26
  * Resolve the recommended MCP entries for the chosen areas, deduped by id.
@@ -48,6 +49,12 @@ function resolveMcps(registry, selectedAreas) {
48
49
  id: mcp.id,
49
50
  when: mcp.when || '',
50
51
  server: mcp.server,
52
+ // `setup` (optional) declares how to make a `command:`-relative server
53
+ // actually usable before it is written: install the backing package,
54
+ // download runtime assets, resolve the binary to an absolute path, and
55
+ // verify it boots. Carried here so prepareMcps() can act on it; dropped
56
+ // from what toServerConfig() writes (it never belongs in .mcp.json).
57
+ setup: mcp.setup || null,
51
58
  areas: areaKey ? [areaKey] : [],
52
59
  });
53
60
  };
@@ -74,6 +81,157 @@ function toServerConfig(server) {
74
81
  return out;
75
82
  }
76
83
 
84
+ /**
85
+ * POSIX-quote a path so it survives interpolation into a shell command even
86
+ * when it contains spaces or shell metacharacters. Single-quote wrapped, with
87
+ * embedded single quotes escaped the classic `'\''` way.
88
+ * @param {string} value
89
+ * @returns {string}
90
+ */
91
+ function shellQuote(value) {
92
+ return `'${String(value).replaceAll("'", `'\\''`)}'`;
93
+ }
94
+
95
+ /**
96
+ * Substitute the `{bin}` placeholder in a setup command with the shell-quoted
97
+ * absolute path of the resolved binary. Lets the registry write portable setup
98
+ * commands (`{bin} install`, `{bin} --version`) that bind to the real path at
99
+ * run time instead of relying on PATH.
100
+ * @param {string} command - Command template possibly containing `{bin}`
101
+ * @param {string} binPath - Absolute path to substitute
102
+ * @returns {string}
103
+ */
104
+ function substituteBin(command, binPath) {
105
+ return String(command).replaceAll('{bin}', shellQuote(binPath));
106
+ }
107
+
108
+ /**
109
+ * Resolve the absolute path of a binary an MCP server shells out to. A bare
110
+ * `command: "scrapling"` is fragile: the MCP subprocess spawned by the client
111
+ * does not always inherit the user's ~/.local/bin on PATH, so a relative name
112
+ * fails with ENOENT even when the tool is installed. We resolve and write the
113
+ * absolute path instead. Tries `command -v` first (honors the current PATH),
114
+ * then the conventional user-local bin dir where `uv tool` / `pipx` / `pip
115
+ * --user` land executables. Returns null when the binary is nowhere to be found.
116
+ *
117
+ * @param {string} bin - Binary name to locate
118
+ * @param {Function} [exec] - Injected runner (command, opts) => {ok, stdout,...}
119
+ * @returns {Promise<string|null>} Absolute path, or null if not found.
120
+ */
121
+ async function resolveBinPath(bin, exec = defaultExec) {
122
+ if (!bin) return null;
123
+ const viaWhich = await exec(`command -v ${bin}`, { timeoutMs: 5000 });
124
+ if (viaWhich.ok) {
125
+ const found = (viaWhich.stdout || '').trim().split('\n')[0].trim();
126
+ if (found) return found;
127
+ }
128
+ const home = process.env.HOME || process.env.USERPROFILE || '';
129
+ if (home) {
130
+ const candidate = path.join(home, '.local', 'bin', bin);
131
+ if (await fs.pathExists(candidate)) return candidate;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * Run an MCP entry's `setup` block so the server is genuinely usable BEFORE it
138
+ * lands in .mcp.json. This closes the ENOENT gap where the installer wrote a
139
+ * server config but never installed the backing tool. Contract (detect-first,
140
+ * mirroring cli-config's install philosophy):
141
+ *
142
+ * 1. DETECT — resolve the binary; if already present we skip installation.
143
+ * 2. INSTALL — on a miss, run `setup.install` (package + its MCP extra), then
144
+ * re-resolve. `setup.post_install` (e.g. downloading browsers) runs after,
145
+ * only on a fresh install, with `{bin}` bound to the resolved path.
146
+ * 3. RESOLVE — rewrite `server.command` to the ABSOLUTE path so the client's
147
+ * MCP subprocess never depends on PATH inheriting ~/.local/bin.
148
+ * 4. VERIFY — run `setup.verify` ({bin} bound). A failure means writing the
149
+ * config would point at a broken/missing binary, so we DO NOT write it —
150
+ * the entry is reported failed and the caller drops it with a clear message.
151
+ *
152
+ * Immutable: returns a new entry with a patched `server.command`; the input is
153
+ * never mutated. Entries without a `setup` block pass through untouched.
154
+ *
155
+ * @param {Object} args
156
+ * @param {Object} args.mcp - Resolved MCP entry (id, server, setup?)
157
+ * @param {Function} [args.exec] - Injected runner (command, opts) => {ok,...}
158
+ * @returns {Promise<{mcp: Object, ok: boolean, ran: boolean, error: string|null}>}
159
+ */
160
+ async function prepareMcp({ mcp, exec = defaultExec }) {
161
+ const setup = mcp && mcp.setup;
162
+ if (!setup) return { mcp, ok: true, ran: false, error: null };
163
+
164
+ const bin = setup.bin || (mcp.server && mcp.server.command);
165
+ if (!bin) {
166
+ return { mcp, ok: false, ran: false, error: 'setup declarado sem `bin` nem `server.command` para resolver' };
167
+ }
168
+
169
+ // 1. DETECT — install only when the binary is not already resolvable.
170
+ let binPath = await resolveBinPath(bin, exec);
171
+ const freshInstall = !binPath;
172
+
173
+ if (freshInstall) {
174
+ if (!setup.install) {
175
+ return { mcp, ok: false, ran: false, error: `binário '${bin}' ausente e setup não tem comando \`install\`` };
176
+ }
177
+ const installed = await exec(setup.install, { timeoutMs: 900_000 });
178
+ if (!installed.ok) {
179
+ return { mcp, ok: false, ran: true, error: `instalação falhou: ${(installed.stderr || 'erro desconhecido').trim()}` };
180
+ }
181
+ binPath = await resolveBinPath(bin, exec);
182
+ if (!binPath) {
183
+ return {
184
+ mcp,
185
+ ok: false,
186
+ ran: true,
187
+ error: `'${bin}' não encontrado no PATH nem em ~/.local/bin após a instalação`,
188
+ };
189
+ }
190
+ // 2. POST-INSTALL (e.g. browser download) — fresh installs only, idempotent.
191
+ if (setup.post_install) {
192
+ const post = await exec(substituteBin(setup.post_install, binPath), { timeoutMs: 900_000 });
193
+ if (!post.ok) {
194
+ return { mcp, ok: false, ran: true, error: `pós-instalação falhou: ${(post.stderr || 'erro desconhecido').trim()}` };
195
+ }
196
+ }
197
+ }
198
+
199
+ // 4. VERIFY — never write a config pointing at a binary that cannot boot.
200
+ if (setup.verify) {
201
+ const verified = await exec(substituteBin(setup.verify, binPath), { timeoutMs: 60_000 });
202
+ if (!verified.ok) {
203
+ return { mcp, ok: false, ran: freshInstall, error: `verificação falhou: ${(verified.stderr || 'erro desconhecido').trim()}` };
204
+ }
205
+ }
206
+
207
+ // 3. RESOLVE — patch the absolute path; leave args/env/everything else intact.
208
+ const patched = { ...mcp, server: { ...mcp.server, command: binPath } };
209
+ return { mcp: patched, ok: true, ran: freshInstall, error: null };
210
+ }
211
+
212
+ /**
213
+ * Prepare every MCP entry (see prepareMcp), splitting the set into `ready`
214
+ * (safe to write, with any `server.command` patched to an absolute path) and
215
+ * `failed` (setup did not complete — the caller must NOT write these and should
216
+ * surface the error so the user can fix it manually). Never throws: each entry
217
+ * is isolated so one broken tool never blocks the others or the wider install.
218
+ *
219
+ * @param {Object} args
220
+ * @param {Array<Object>} args.mcps - Resolved MCP entries to prepare
221
+ * @param {Function} [args.exec] - Injected runner (command, opts) => {ok,...}
222
+ * @returns {Promise<{ready: Array<Object>, failed: Array<{id: string, error: string}>}>}
223
+ */
224
+ async function prepareMcps({ mcps, exec = defaultExec }) {
225
+ const ready = [];
226
+ const failed = [];
227
+ for (const mcp of mcps || []) {
228
+ const res = await prepareMcp({ mcp, exec });
229
+ if (res.ok) ready.push(res.mcp);
230
+ else failed.push({ id: mcp.id, error: res.error || 'falha desconhecida' });
231
+ }
232
+ return { ready, failed };
233
+ }
234
+
77
235
  /**
78
236
  * Render the `claude mcp add` command for an MCP entry — used to recommend
79
237
  * servers the user chose NOT to write (nothing is lost; they can add later).
@@ -134,4 +292,14 @@ async function writeMcpConfig({ projectDir, mcps, trackFile = () => {} }) {
134
292
  return { added, skipped, file };
135
293
  }
136
294
 
137
- module.exports = { resolveMcps, toServerConfig, renderAddCommand, writeMcpConfig };
295
+ module.exports = {
296
+ resolveMcps,
297
+ toServerConfig,
298
+ renderAddCommand,
299
+ writeMcpConfig,
300
+ prepareMcp,
301
+ prepareMcps,
302
+ resolveBinPath,
303
+ shellQuote,
304
+ substituteBin,
305
+ };