wizz-method 1.7.0 → 1.8.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "wizz-method",
4
- "version": "1.7.0",
4
+ "version": "1.8.0",
5
5
  "description": "Wizz Method — método de agência orientado por IA em PT-BR (fork independente do BMad Method)",
6
6
  "keywords": [
7
7
  "agile",
@@ -29,17 +29,36 @@
29
29
  // placeholder stays in `.mcp.json`, and the summary explains how to
30
30
  // configure it later.
31
31
  //
32
+ // GLOBAL KEY STORE (`~/.claude/wizz-env.json`): a key the user already typed
33
+ // in ANY project is reused silently in every new install — the store is read
34
+ // by the installer only (C7 still holds: the runtime never reads it), and a
35
+ // hit is copied into the new project's `settings.local.json`. A key typed at
36
+ // the prompt is saved to the store too, so it is only ever asked once.
37
+ //
32
38
  // API (decomposed per E3 so each piece is unit-testable without a TTY):
33
39
  // extractEnvPlaceholders(mcps) — pure
34
40
  // resolveEnvVars(vars, opts) — I/O read (providers + prompt)
35
41
  // persistEnvValues(toPersist, opts) — I/O write (settings.local.json)
36
42
  // persistProjectEnv(projectDir, envRecord) — the actual writer, reusable
43
+ // persistGlobalEnv(storePath, envRecord) — writer do store global
37
44
  // promptMissingEnvVars(mcps, opts) — thin orchestrator of the above
38
45
 
39
46
  const path = require('node:path');
47
+ const os = require('node:os');
40
48
  const fs = require('../fs-native');
41
49
  const prompts = require('../prompts');
42
50
 
51
+ // Global key store, read by the INSTALLER only (never by the Claude Code
52
+ // runtime — C7 still holds). A value found here is copied into the project's
53
+ // `.claude/settings.local.json` at install time, which IS what reaches the
54
+ // MCP subprocess. This is what makes a key typed once in project A resolve
55
+ // silently in projects B, C, D... without ever living in the global
56
+ // settings.json `env` (which would expose it to every session of every
57
+ // project — the exact pattern the 360° audit flagged as a security critical).
58
+ function defaultGlobalEnvPath() {
59
+ return path.join(os.homedir(), '.claude', 'wizz-env.json');
60
+ }
61
+
43
62
  // Deliberately POSIX-strict (uppercase + underscore only): this both matches
44
63
  // standard env var naming and doubles as a defensive filter against false
45
64
  // positives like `{bin}` (no `$` prefix at all, so it never matches) or a
@@ -168,12 +187,51 @@ function createDotenvFileProvider(dotenvPath) {
168
187
  }
169
188
 
170
189
  /**
171
- * Try each provider in order, returning the first non-empty value found.
190
+ * The global key store (`~/.claude/wizz-env.json`, flat `{ "VAR": "value" }`
191
+ * map, chmod 600) as a provider. Read-only here — `persistGlobalEnv` is the
192
+ * writer. Marked `persistToProject: true`: unlike `process.env`, a value from
193
+ * this store is NOT in the runtime's environment, so the resolver must copy
194
+ * it into the project's `settings.local.json` for it to actually reach the
195
+ * MCP subprocess (C7).
196
+ * @param {string} storePath - Absolute path to the global store file
197
+ * @returns {{name: string, persistToProject: boolean, available: () => Promise<boolean>, get: (name: string) => Promise<string|undefined>}}
198
+ */
199
+ function createGlobalStoreProvider(storePath) {
200
+ let cache = null;
201
+
202
+ async function load() {
203
+ if (cache) return cache;
204
+ cache = {};
205
+ if (!storePath || !(await fs.pathExists(storePath))) return cache;
206
+ try {
207
+ const parsed = JSON.parse(await fs.readFile(storePath, 'utf8'));
208
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) cache = parsed;
209
+ } catch {
210
+ // Malformed/unreadable store — treat as empty, never throw.
211
+ cache = {};
212
+ }
213
+ return cache;
214
+ }
215
+
216
+ return {
217
+ name: 'global-store',
218
+ persistToProject: true,
219
+ available: async () => !!storePath && (await fs.pathExists(storePath)),
220
+ get: async (name) => {
221
+ const value = (await load())[name];
222
+ return typeof value === 'string' && value !== '' ? value : undefined;
223
+ },
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Try each provider in order, returning the first non-empty value found and
229
+ * the provider that had it (so the caller can honor `persistToProject`).
172
230
  * A provider throwing or being unavailable is skipped, never fatal — one
173
231
  * broken provider must not block the chain (e.g. an unreadable `.env`).
174
232
  * @param {string} name - Var name to look up
175
233
  * @param {Array<Object>} providers
176
- * @returns {Promise<string|undefined>}
234
+ * @returns {Promise<{value: string, provider: Object}|undefined>}
177
235
  */
178
236
  async function findInProviders(name, providers) {
179
237
  for (const provider of providers || []) {
@@ -182,7 +240,7 @@ async function findInProviders(name, providers) {
182
240
  const isAvailable = typeof provider.available === 'function' ? await provider.available() : true;
183
241
  if (!isAvailable) continue;
184
242
  const value = await provider.get(name);
185
- if (value !== undefined && value !== null && value !== '') return value;
243
+ if (value !== undefined && value !== null && value !== '') return { value, provider };
186
244
  } catch {
187
245
  continue;
188
246
  }
@@ -221,7 +279,14 @@ function createDefaultPrompter() {
221
279
  * @param {boolean} [opts.interactive=false] - Whether to prompt for missing vars
222
280
  * @param {Array<Object>} [opts.providers] - Provider chain, tried in order
223
281
  * @param {(entry) => Promise<string|undefined>} [opts.prompter] - Injectable prompt fn
224
- * @returns {Promise<{filled: Array, skipped: Array, existing: Array, toPersist: Record<string,string>}>}
282
+ * @returns {Promise<{filled: Array, skipped: Array, existing: Array, imported: Array,
283
+ * toPersist: Record<string,string>, toPersistGlobal: Record<string,string>}>}
284
+ * `existing` = found in a runtime-visible source (process.env/.env), nothing
285
+ * to write. `imported` = found in a `persistToProject` provider (the global
286
+ * store): resolved without prompting, but must be written to the project's
287
+ * settings.local.json (included in `toPersist`). `toPersistGlobal` = the
288
+ * subset of typed answers that should ALSO be saved to the global store so
289
+ * the next project never asks.
225
290
  */
226
291
  async function resolveEnvVars(vars, opts = {}) {
227
292
  const { interactive = false, providers = [createProcessEnvProvider()], prompter = null } = opts;
@@ -229,7 +294,9 @@ async function resolveEnvVars(vars, opts = {}) {
229
294
  const filled = [];
230
295
  const skipped = [];
231
296
  const existing = [];
297
+ const imported = [];
232
298
  const toPersist = {};
299
+ const toPersistGlobal = {};
233
300
 
234
301
  for (const entry of vars || []) {
235
302
  // A default is resolved by the Claude Code runtime itself; asking would
@@ -242,7 +309,12 @@ async function resolveEnvVars(vars, opts = {}) {
242
309
 
243
310
  const found = await findInProviders(entry.name, providers);
244
311
  if (found !== undefined) {
245
- existing.push(entry);
312
+ if (found.provider && found.provider.persistToProject) {
313
+ imported.push(entry);
314
+ toPersist[entry.name] = found.value;
315
+ } else {
316
+ existing.push(entry);
317
+ }
246
318
  continue;
247
319
  }
248
320
 
@@ -259,9 +331,10 @@ async function resolveEnvVars(vars, opts = {}) {
259
331
 
260
332
  filled.push(entry);
261
333
  toPersist[entry.name] = answer;
334
+ toPersistGlobal[entry.name] = answer;
262
335
  }
263
336
 
264
- return { filled, skipped, existing, toPersist };
337
+ return { filled, skipped, existing, imported, toPersist, toPersistGlobal };
265
338
  }
266
339
 
267
340
  /**
@@ -352,23 +425,80 @@ async function persistEnvValues(toPersist, opts = {}) {
352
425
  return persistProjectEnv(projectDir, toPersist);
353
426
  }
354
427
 
428
+ /**
429
+ * Merge `envRecord` into the flat global store (`~/.claude/wizz-env.json`).
430
+ * Additive only — a key already present is never overwritten (same rule as
431
+ * `persistProjectEnv`; a prompt only ever fires for a var no provider had,
432
+ * so an overwrite here would always mean clobbering something newer). File
433
+ * is chmod 600 after any write that changed it, same best-effort semantics
434
+ * as the project writer.
435
+ *
436
+ * @param {string} storePath - Absolute path to the global store file
437
+ * @param {Record<string,string>} envRecord - Vars to merge
438
+ * @returns {Promise<string|null>} Path written/merged, or null when empty
439
+ */
440
+ async function persistGlobalEnv(storePath, envRecord) {
441
+ if (!storePath || !envRecord || Object.keys(envRecord).length === 0) return null;
442
+
443
+ await fs.ensureDir(path.dirname(storePath));
444
+
445
+ let store = {};
446
+ if (await fs.pathExists(storePath)) {
447
+ try {
448
+ const parsed = JSON.parse(await fs.readFile(storePath, 'utf8'));
449
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) store = parsed;
450
+ } catch {
451
+ // A corrupt store must never eat keys the user just typed: keep the
452
+ // broken file aside and start a fresh store with the new values.
453
+ try {
454
+ await fs.rename(storePath, `${storePath}.bak`);
455
+ } catch {
456
+ // Even the rename failing must not block the install.
457
+ }
458
+ }
459
+ }
460
+
461
+ let changed = false;
462
+ for (const [key, value] of Object.entries(envRecord)) {
463
+ if (Object.prototype.hasOwnProperty.call(store, key)) continue;
464
+ store[key] = value;
465
+ changed = true;
466
+ }
467
+
468
+ if (changed) {
469
+ await fs.writeJson(storePath, store, { spaces: 2 });
470
+ if (process.platform !== 'win32') {
471
+ try {
472
+ await fs.chmod(storePath, 0o600);
473
+ } catch {
474
+ // Best-effort, same as persistProjectEnv.
475
+ }
476
+ }
477
+ }
478
+
479
+ return storePath;
480
+ }
481
+
355
482
  /**
356
483
  * Render the DX summary (E6) — the highest-value part of this feature: turn
357
484
  * a skipped var from a dead end into a 30-second fix. One line per var,
358
485
  * grouped by outcome. Never includes a raw secret value, only names/status.
359
- * @param {{filled: Array, skipped: Array, existing: Array}} resolved
486
+ * @param {{filled: Array, skipped: Array, existing: Array, imported: Array}} resolved
360
487
  * @returns {string|null} Formatted block, or null when there is nothing to say
361
488
  */
362
- function formatSummary({ filled, skipped, existing }) {
363
- const total = (filled?.length || 0) + (skipped?.length || 0) + (existing?.length || 0);
489
+ function formatSummary({ filled, skipped, existing, imported }) {
490
+ const total = (filled?.length || 0) + (skipped?.length || 0) + (existing?.length || 0) + (imported?.length || 0);
364
491
  if (total === 0) return null;
365
492
 
366
493
  const lines = [];
367
494
  for (const entry of existing || []) {
368
495
  lines.push(` ✓ ${entry.name.padEnd(28)} já existia no ambiente`);
369
496
  }
497
+ for (const entry of imported || []) {
498
+ lines.push(` ✓ ${entry.name.padEnd(28)} importada do global (~/.claude/wizz-env.json)`);
499
+ }
370
500
  for (const entry of filled || []) {
371
- lines.push(` ✓ ${entry.name.padEnd(28)} configurada agora (.claude/settings.local.json)`);
501
+ lines.push(` ✓ ${entry.name.padEnd(28)} configurada agora (+ salva no global p/ próximos projetos)`);
372
502
  }
373
503
  for (const entry of skipped || []) {
374
504
  if (entry.hasDefault) {
@@ -403,18 +533,26 @@ function formatSummary({ filled, skipped, existing }) {
403
533
  * @param {string} opts.projectDir - Project root (for persistence + the
404
534
  * default `.env` provider)
405
535
  * @param {boolean} [opts.interactive=false]
406
- * @param {Array<Object>} [opts.providers] - Defaults to `[processEnv, dotenvFile(<projectDir>/.env)]`
536
+ * @param {Array<Object>} [opts.providers] - Defaults to `[processEnv,
537
+ * dotenvFile(<projectDir>/.env), globalStore(~/.claude/wizz-env.json)]`
538
+ * @param {string} [opts.globalEnvPath] - Global store path (default
539
+ * `~/.claude/wizz-env.json`); used for both the default provider chain and
540
+ * the save-on-prompt write. Injectable so tests never touch the real home.
407
541
  * @param {(entry) => Promise<string|undefined>} [opts.prompter] - Defaults to
408
542
  * the masked `password()` prompter
409
- * @returns {Promise<{filled: Array, skipped: Array, existing: Array, envFile: string|null}>}
543
+ * @returns {Promise<{filled: Array, skipped: Array, existing: Array, imported: Array, envFile: string|null}>}
410
544
  */
411
545
  async function promptMissingEnvVars(mcps, opts = {}) {
412
- const { projectDir, interactive = false, providers, prompter } = opts;
546
+ const { projectDir, interactive = false, providers, prompter, globalEnvPath = defaultGlobalEnvPath() } = opts;
413
547
 
414
548
  const vars = extractEnvPlaceholders(mcps);
415
- if (vars.length === 0) return { filled: [], skipped: [], existing: [], envFile: null };
549
+ if (vars.length === 0) return { filled: [], skipped: [], existing: [], imported: [], envFile: null };
416
550
 
417
- const resolvedProviders = providers || [createProcessEnvProvider(), createDotenvFileProvider(path.join(projectDir, '.env'))];
551
+ const resolvedProviders = providers || [
552
+ createProcessEnvProvider(),
553
+ createDotenvFileProvider(path.join(projectDir, '.env')),
554
+ createGlobalStoreProvider(globalEnvPath),
555
+ ];
418
556
  const resolvedPrompter = interactive ? prompter || createDefaultPrompter() : null;
419
557
 
420
558
  const resolved = await resolveEnvVars(vars, {
@@ -428,10 +566,27 @@ async function promptMissingEnvVars(mcps, opts = {}) {
428
566
  envFile = await persistEnvValues(resolved.toPersist, { projectDir, target: 'settings-local' });
429
567
  }
430
568
 
569
+ // Typed answers also go to the global store so the NEXT project resolves
570
+ // them silently. Failure here must never block the install — the project
571
+ // write above already succeeded, which is what this install needs.
572
+ if (Object.keys(resolved.toPersistGlobal).length > 0) {
573
+ try {
574
+ await persistGlobalEnv(globalEnvPath, resolved.toPersistGlobal);
575
+ } catch {
576
+ // Global save is a convenience for future installs, never a blocker.
577
+ }
578
+ }
579
+
431
580
  const summary = formatSummary(resolved);
432
581
  if (summary) await prompts.log.info(summary);
433
582
 
434
- return { filled: resolved.filled, skipped: resolved.skipped, existing: resolved.existing, envFile };
583
+ return {
584
+ filled: resolved.filled,
585
+ skipped: resolved.skipped,
586
+ existing: resolved.existing,
587
+ imported: resolved.imported,
588
+ envFile,
589
+ };
435
590
  }
436
591
 
437
592
  module.exports = {
@@ -439,8 +594,11 @@ module.exports = {
439
594
  resolveEnvVars,
440
595
  persistEnvValues,
441
596
  persistProjectEnv,
597
+ persistGlobalEnv,
442
598
  promptMissingEnvVars,
443
599
  createProcessEnvProvider,
444
600
  createDotenvFileProvider,
601
+ createGlobalStoreProvider,
602
+ defaultGlobalEnvPath,
445
603
  formatSummary,
446
604
  };
@@ -19,6 +19,7 @@
19
19
  // a `claude mcp add` command renderer for the recommend path.
20
20
 
21
21
  const path = require('node:path');
22
+ const os = require('node:os');
22
23
  const crypto = require('node:crypto');
23
24
  const fs = require('../fs-native');
24
25
  const { defaultExec } = require('./cli-config');
@@ -325,6 +326,48 @@ async function partitionAlreadyConfigured({ projectDir, mcps }) {
325
326
  return { toPrepare, alreadyConfigured };
326
327
  }
327
328
 
329
+ /**
330
+ * Split resolved MCP entries into those still worth offering/installing in
331
+ * the project and those the user already configured GLOBALLY (user scope, the
332
+ * `mcpServers` key of `~/.claude.json`). A server configured there is live in
333
+ * every project already — usually with the real key embedded — so writing the
334
+ * registry's `${VAR}`-placeholder copy into the project `.mcp.json` would at
335
+ * best duplicate it and at worst shadow a working global config with a broken
336
+ * placeholder one, then prompt the user for a key they already provided.
337
+ *
338
+ * Read failures (missing/malformed `~/.claude.json`) fall back to "nothing is
339
+ * global", the safe default: worst case the user sees the old behavior.
340
+ *
341
+ * @param {Object} args
342
+ * @param {Array<{id: string}>} args.mcps - Resolved MCP entries to partition
343
+ * @param {string} [args.claudeJsonPath] - Override of `~/.claude.json` (tests)
344
+ * @returns {Promise<{toInstall: Array<Object>, globallyConfigured: string[]}>}
345
+ */
346
+ async function partitionGloballyConfigured({ mcps, claudeJsonPath }) {
347
+ if (!mcps || mcps.length === 0) return { toInstall: [], globallyConfigured: [] };
348
+
349
+ const file = claudeJsonPath || path.join(os.homedir(), '.claude.json');
350
+ let globalIds = new Set();
351
+ if (await fs.pathExists(file)) {
352
+ try {
353
+ const config = JSON.parse(await fs.readFile(file, 'utf8'));
354
+ const servers =
355
+ config && typeof config === 'object' && config.mcpServers && !Array.isArray(config.mcpServers) ? config.mcpServers : null;
356
+ if (servers) globalIds = new Set(Object.keys(servers));
357
+ } catch {
358
+ globalIds = new Set();
359
+ }
360
+ }
361
+
362
+ const toInstall = [];
363
+ const globallyConfigured = [];
364
+ for (const mcp of mcps) {
365
+ if (mcp && mcp.id && globalIds.has(mcp.id)) globallyConfigured.push(mcp.id);
366
+ else toInstall.push(mcp);
367
+ }
368
+ return { toInstall, globallyConfigured };
369
+ }
370
+
328
371
  /**
329
372
  * Merge the chosen MCP entries into `<projectDir>/.mcp.json`, additively.
330
373
  * Reads any existing file (preserving unknown keys and existing servers),
@@ -442,6 +485,7 @@ module.exports = {
442
485
  prepareMcp,
443
486
  prepareMcps,
444
487
  partitionAlreadyConfigured,
488
+ partitionGloballyConfigured,
445
489
  resolveBinPath,
446
490
  shellQuote,
447
491
  substituteBin,
@@ -17,7 +17,7 @@ const {
17
17
  bundledTargetWarnings,
18
18
  } = require('./modules/channel-plan');
19
19
  const channelResolver = require('./modules/channel-resolver');
20
- const { resolveMcps } = require('./modules/mcp-config');
20
+ const { resolveMcps, partitionGloballyConfigured } = require('./modules/mcp-config');
21
21
  const { resolveClis, detectClis } = require('./modules/cli-config');
22
22
  const prompts = require('./prompts');
23
23
  const { parseSetEntries } = require('./set-overrides');
@@ -571,9 +571,21 @@ class UI {
571
571
  if (!selectedModules.includes('bmm')) return { toWrite: [], toRecommend: [] };
572
572
 
573
573
  const registry = this._loadSkillsRegistry();
574
- const resolved = resolveMcps(registry, selectedAreas);
574
+ let resolved = resolveMcps(registry, selectedAreas);
575
575
  if (resolved.length === 0) return { toWrite: [], toRecommend: [] };
576
576
 
577
+ // A server the user already configured globally (user scope, `mcpServers`
578
+ // in ~/.claude.json) is live in every project — offering it again would
579
+ // duplicate config and re-prompt for a key that already works. Filter it
580
+ // out of every path below (multiselect, --mcps, --yes) with an info line
581
+ // so nothing disappears silently.
582
+ const { toInstall, globallyConfigured } = await partitionGloballyConfigured({ mcps: resolved });
583
+ if (globallyConfigured.length > 0) {
584
+ await prompts.log.info(`MCPs já configurados no seu Claude global (~/.claude.json), pulados: ${globallyConfigured.join(', ')}`);
585
+ resolved = toInstall;
586
+ if (resolved.length === 0) return { toWrite: [], toRecommend: [] };
587
+ }
588
+
577
589
  const byId = new Map(resolved.map((m) => [m.id, m]));
578
590
  const split = (writeIds) => {
579
591
  const writeSet = new Set(writeIds);