mixdog 0.7.5 → 0.7.6

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Claude Code all-in-one agent plugin — autonomous agents, continuous memory, cost-aware sub-agents, and syntax-aware code editing.",
5
5
  "hooks": "./hooks/hooks.json",
6
6
  "mcpServers": {
package/README.md CHANGED
@@ -48,6 +48,20 @@ as JSON you can diff.
48
48
 
49
49
  ## Install
50
50
 
51
+ **From npm (terminal):**
52
+
53
+ ```bash
54
+ npx mixdog setup # register plugin + run the setup wizard
55
+ npm i -g mixdog # then launch Claude Code with mixdog pre-loaded:
56
+ mixdog # → claude --dangerously-load-development-channels plugin:mixdog@trib-plugin
57
+ mixdog --dangerously-skip-permissions # extra Claude flags pass through
58
+ ```
59
+
60
+ `mixdog` (no subcommand) starts Claude Code with mixdog pre-loaded; the `claude`
61
+ CLI must be on your PATH.
62
+
63
+ **Inside Claude Code (slash commands):**
64
+
51
65
  ```
52
66
  /plugin marketplace add trib-plugin/mixdog
53
67
  /plugin install mixdog@trib-plugin
package/hooks/hooks.json CHANGED
@@ -7,17 +7,17 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "\"${CLAUDE_PLUGIN_ROOT}/native/mixdog-shim/target/release/mixdog-shim.exe\" --part=rules",
10
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/shim-launcher.cjs\" --part=rules",
11
11
  "timeout": 30
12
12
  },
13
13
  {
14
14
  "type": "command",
15
- "command": "\"${CLAUDE_PLUGIN_ROOT}/native/mixdog-shim/target/release/mixdog-shim.exe\" --part=core",
15
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/shim-launcher.cjs\" --part=core",
16
16
  "timeout": 130
17
17
  },
18
18
  {
19
19
  "type": "command",
20
- "command": "\"${CLAUDE_PLUGIN_ROOT}/native/mixdog-shim/target/release/mixdog-shim.exe\" --part=recap",
20
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/shim-launcher.cjs\" --part=recap",
21
21
  "timeout": 130
22
22
  }
23
23
  ]
@@ -29,7 +29,7 @@
29
29
  "hooks": [
30
30
  {
31
31
  "type": "command",
32
- "command": "\"${CLAUDE_PLUGIN_ROOT}/native/mixdog-shim/target/release/mixdog-shim.exe\"",
32
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/shim-launcher.cjs\"",
33
33
  "timeout": 10
34
34
  }
35
35
  ]
@@ -41,7 +41,7 @@
41
41
  "hooks": [
42
42
  {
43
43
  "type": "command",
44
- "command": "\"${CLAUDE_PLUGIN_ROOT}/native/mixdog-shim/target/release/mixdog-shim.exe\" --kind=post-tool",
44
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/shim-launcher.cjs\" --kind=post-tool",
45
45
  "timeout": 5
46
46
  }
47
47
  ]
@@ -70,4 +70,4 @@
70
70
  }
71
71
  ]
72
72
  }
73
- }
73
+ }
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const SHIM_ARCH_ALIAS = Object.freeze({
8
+ x64: 'x86_64',
9
+ arm64: 'aarch64',
10
+ ia32: 'i686',
11
+ });
12
+
13
+ function shimBinExt() {
14
+ return process.platform === 'win32' ? '.exe' : '';
15
+ }
16
+
17
+ function shimArchTag() {
18
+ const arch = SHIM_ARCH_ALIAS[process.arch] || process.arch;
19
+ const os = process.platform === 'win32' ? 'windows'
20
+ : process.platform === 'darwin' ? 'macos'
21
+ : 'linux';
22
+ return `${os}-${arch}`;
23
+ }
24
+
25
+ function resolveShim(pluginRoot) {
26
+ const ext = shimBinExt();
27
+ const name = 'mixdog-shim' + ext;
28
+ const candidates = [
29
+ path.join(pluginRoot, 'native', 'mixdog-shim', 'target', 'release', name),
30
+ path.join(pluginRoot, 'native', 'prebuilt', shimArchTag(), name),
31
+ ];
32
+ for (const candidate of candidates) {
33
+ try {
34
+ if (fs.existsSync(candidate)) return candidate;
35
+ } catch { /* ignore */ }
36
+ }
37
+ return null;
38
+ }
39
+
40
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
41
+ if (!pluginRoot) process.exit(0);
42
+
43
+ const shimPath = resolveShim(pluginRoot);
44
+ if (!shimPath) process.exit(0);
45
+
46
+ const child = spawnSync(shimPath, process.argv.slice(2), {
47
+ stdio: 'inherit',
48
+ windowsHide: true,
49
+ });
50
+
51
+ process.exit(child.status ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Claude Code all-in-one bridge plugin: role-based bridge workers, continuous memory, and syntax-aware code editing.",
5
5
  "author": "mixdog contributors <dev@tribgames.com>",
6
6
  "license": "MIT",
@@ -9,7 +9,7 @@
9
9
  "bin": {
10
10
  "mcfg": "./setup/launch.mjs",
11
11
  "mixdog-install": "./setup/install.mjs",
12
- "mixdog": "./setup/install.mjs"
12
+ "mixdog": "./setup/mixdog-cli.mjs"
13
13
  },
14
14
  "engines": {
15
15
  "bun": ">=1.1.0"
package/setup/install.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
  // install.mjs — one-shot bootstrapper that registers the mixdog plugin in the
3
3
  // user's Claude Code settings so it auto-loads on the next session start.
4
4
  //
5
- // Run via: npx -p mixdog mixdog-install (after the package is published)
6
- // or: node setup/install.mjs (from a checkout)
5
+ // Run via: npx mixdog setup | mixdog-install (after the package is published)
6
+ // or: node setup/install.mjs (from a checkout)
7
7
  //
8
8
  // It merges two keys into the user-scope settings file (preserving everything
9
9
  // else that is already there):
@@ -22,6 +22,8 @@ import {
22
22
  } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { homedir } from 'node:os';
25
+ import { realpathSync } from 'node:fs';
26
+ import { fileURLToPath } from 'node:url';
25
27
  import { createInterface } from 'node:readline';
26
28
  import { spawn } from 'node:child_process';
27
29
  import { DEFAULT_MARKETPLACE, DEFAULT_PLUGIN } from '../src/shared/plugin-paths.mjs';
@@ -31,9 +33,23 @@ const PLUGIN_REF = `${DEFAULT_PLUGIN}@${DEFAULT_MARKETPLACE}`;
31
33
  const REPO = 'trib-plugin/mixdog'; // github owner/repo
32
34
  const REPO_URL = 'https://github.com/trib-plugin/mixdog';
33
35
 
36
+ /** Claude config root — matches Claude Code (settings + plugins tree). */
37
+ export function claudeConfigBaseDir() {
38
+ return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
39
+ }
40
+
34
41
  // Claude Code honours CLAUDE_CONFIG_DIR; otherwise the user scope is ~/.claude.
35
42
  function settingsDir() {
36
- return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
43
+ return claudeConfigBaseDir();
44
+ }
45
+
46
+ export function defaultPluginDataDir() {
47
+ return join(
48
+ claudeConfigBaseDir(),
49
+ 'plugins',
50
+ 'data',
51
+ `${DEFAULT_PLUGIN}-${MARKETPLACE}`,
52
+ );
37
53
  }
38
54
 
39
55
  function loadSettings(file) {
@@ -91,17 +107,14 @@ function registerPluginInSettings() {
91
107
  console.log(`\nNext: restart Claude Code (or run /reload-plugins). mixdog loads automatically.`);
92
108
  }
93
109
 
94
- async function main() {
110
+ export async function runInstall() {
95
111
  registerPluginInSettings();
96
112
 
97
113
  // npx / node setup/install.mjs runs outside Claude Code — config.mjs needs a data dir.
98
- process.env.CLAUDE_PLUGIN_DATA = join(
99
- homedir(),
100
- '.claude',
101
- 'plugins',
102
- 'data',
103
- 'mixdog-trib-plugin',
104
- );
114
+ const pluginData = process.env.CLAUDE_PLUGIN_DATA;
115
+ if (!pluginData || !String(pluginData).trim()) {
116
+ process.env.CLAUDE_PLUGIN_DATA = defaultPluginDataDir();
117
+ }
105
118
 
106
119
  const { runSetupWizard } = await import('./wizard.mjs');
107
120
  await runSetupWizard();
@@ -134,7 +147,21 @@ function openRepo() {
134
147
  }
135
148
  }
136
149
 
137
- main().catch((err) => {
138
- console.error(err?.stack || err?.message || String(err));
139
- process.exit(1);
140
- });
150
+ function isInstallerEntry() {
151
+ const entry = process.argv[1];
152
+ if (!entry) return false;
153
+ try {
154
+ const self = realpathSync(fileURLToPath(import.meta.url));
155
+ const invoked = realpathSync(entry);
156
+ return self === invoked;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ if (isInstallerEntry()) {
163
+ runInstall().catch((err) => {
164
+ console.error(err?.stack || err?.message || String(err));
165
+ process.exit(1);
166
+ });
167
+ }
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ // mixdog-cli.mjs — `mixdog` bin dispatcher: launch Claude Code with the dev
3
+ // plugin load flags, or run setup/install on demand.
4
+
5
+ import { spawn, execFileSync } from 'node:child_process';
6
+ import { existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { realpathSync } from 'node:fs';
9
+ import { constants as osConstants } from 'node:os';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { DEFAULT_MARKETPLACE, DEFAULT_PLUGIN } from '../src/shared/plugin-paths.mjs';
12
+ import { runInstall } from './install.mjs';
13
+
14
+ const PLUGIN_LOAD_ARG = `plugin:${DEFAULT_PLUGIN}@${DEFAULT_MARKETPLACE}`;
15
+ const CLAUDE_PREFIX = ['--dangerously-load-development-channels', PLUGIN_LOAD_ARG];
16
+
17
+ function isSetupCommand(first) {
18
+ return first === 'setup' || first === 'install';
19
+ }
20
+
21
+ export function buildClaudeLaunchArgv(passthrough = []) {
22
+ return [...CLAUDE_PREFIX, ...passthrough];
23
+ }
24
+
25
+ export function resolveClaudeExecutable() {
26
+ const win32 = process.platform === 'win32';
27
+ try {
28
+ const cmd = win32 ? 'where' : 'which';
29
+ const out = execFileSync(cmd, ['claude'], { encoding: 'utf8', windowsHide: true }).trim();
30
+ const first = out.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
31
+ if (first && existsSync(first)) return first;
32
+ } catch { /* PATH scan */ }
33
+
34
+ const pathSep = win32 ? ';' : ':';
35
+ const dirs = String(process.env.PATH || '').split(pathSep).filter(Boolean);
36
+ const pathext = win32
37
+ ? String(process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';').map((e) => e.toLowerCase())
38
+ : [''];
39
+ const bases = win32 ? ['claude'] : ['claude'];
40
+ for (const dir of dirs) {
41
+ for (const base of bases) {
42
+ if (win32) {
43
+ for (const ext of pathext) {
44
+ const candidate = join(dir, base + ext);
45
+ if (existsSync(candidate)) return candidate;
46
+ }
47
+ const bare = join(dir, base);
48
+ if (existsSync(bare)) return bare;
49
+ } else {
50
+ const candidate = join(dir, base);
51
+ if (existsSync(candidate)) return candidate;
52
+ }
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ export async function dispatchMixdogCli(argv = process.argv.slice(2)) {
59
+ const [first] = argv;
60
+ if (isSetupCommand(first)) {
61
+ if (process.env.MIXDOG_CLI_DRY_RUN === '1') {
62
+ process.stdout.write('mixdog-cli: route=setup\n');
63
+ return 0;
64
+ }
65
+ await runInstall();
66
+ return 0;
67
+ }
68
+
69
+ const claudeArgs = buildClaudeLaunchArgv(argv);
70
+ if (process.env.MIXDOG_CLI_DRY_RUN === '1') {
71
+ process.stdout.write(`mixdog-cli: claude ${JSON.stringify(claudeArgs)}\n`);
72
+ return 0;
73
+ }
74
+
75
+ const claudePath = resolveClaudeExecutable();
76
+ if (!claudePath) {
77
+ process.stderr.write(
78
+ '\n✗ `claude` was not found on PATH. Install Claude Code first: https://docs.anthropic.com/en/docs/claude-code\n',
79
+ );
80
+ return 127;
81
+ }
82
+
83
+ return launchClaude(claudePath, claudeArgs);
84
+ }
85
+
86
+ function launchClaude(claudePath, claudeArgs) {
87
+ return new Promise((resolve) => {
88
+ const win32 = process.platform === 'win32';
89
+ const needsShell = win32 && /\.(cmd|bat)$/i.test(claudePath);
90
+ const child = spawn(claudePath, claudeArgs, {
91
+ stdio: 'inherit',
92
+ shell: needsShell,
93
+ windowsHide: true,
94
+ });
95
+
96
+ child.on('error', (err) => {
97
+ process.stderr.write(`${err?.stack || err?.message || String(err)}\n`);
98
+ resolve(1);
99
+ });
100
+
101
+ child.on('close', (code, signal) => {
102
+ if (signal) {
103
+ const sigNum = osConstants.signals?.[signal] ?? 0;
104
+ resolve(128 + sigNum);
105
+ return;
106
+ }
107
+ resolve(code ?? 0);
108
+ });
109
+ });
110
+ }
111
+
112
+ function isDirectCliEntry() {
113
+ const entry = process.argv[1];
114
+ if (!entry) return false;
115
+ try {
116
+ const self = realpathSync(fileURLToPath(import.meta.url));
117
+ const invoked = realpathSync(entry);
118
+ return self === invoked;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ if (isDirectCliEntry()) {
125
+ dispatchMixdogCli()
126
+ .then((code) => process.exit(code))
127
+ .catch((err) => {
128
+ console.error(err?.stack || err?.message || String(err));
129
+ process.exit(1);
130
+ });
131
+ }
package/setup/wizard.mjs CHANGED
@@ -6,9 +6,11 @@
6
6
  * module (install.mjs does that) so src/shared/config.mjs can resolve paths.
7
7
  */
8
8
  import { createInterface } from 'node:readline';
9
+ import { spawnSync } from 'node:child_process';
9
10
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
10
11
  import { join, dirname, basename } from 'node:path';
11
12
  import { fileURLToPath } from 'node:url';
13
+ import { defaultPluginDataDir } from './install.mjs';
12
14
  import {
13
15
  mergeAgentConfig,
14
16
  mergeMemoryConfig,
@@ -17,6 +19,10 @@ import {
17
19
  } from './config-merge.mjs';
18
20
  import { DEFAULT_MODELS } from '../src/search/lib/config.mjs';
19
21
 
22
+ let _linuxSecretsCapable;
23
+ const KEYTAR_SERVICE = 'mixdog';
24
+ const KEYTAR_PROBE_TIMEOUT_MS = 8000;
25
+
20
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
27
  const REPO_ROOT = join(__dirname, '..');
22
28
  const DEFAULT_USER_WORKFLOW = JSON.parse(
@@ -52,12 +58,59 @@ const SEARCH_OAUTH_ALIASES = Object.freeze({
52
58
  });
53
59
  function pluginDataDir() {
54
60
  const dir = process.env.CLAUDE_PLUGIN_DATA;
55
- if (!dir || typeof dir !== 'string' || !String(dir).trim()) {
56
- throw new Error(
57
- 'CLAUDE_PLUGIN_DATA must be set before running the setup wizard (install.mjs sets it unconditionally)',
58
- );
61
+ if (dir && typeof dir === 'string' && String(dir).trim()) {
62
+ return String(dir).trim();
59
63
  }
60
- return String(dir).trim();
64
+ return defaultPluginDataDir();
65
+ }
66
+
67
+ /** One-time Linux preflight: optional keytar must load before any secret prompt. */
68
+ export function resetLinuxSecretsCapableCache() {
69
+ _linuxSecretsCapable = undefined;
70
+ }
71
+
72
+ function runLinuxKeytarOperationalProbe() {
73
+ const script = [
74
+ 'try {',
75
+ 'const keytar = require("keytar");',
76
+ 'if (process.env.MIXDOG_KEYTAR_PROBE_INJECT_FAIL === "1") process.exit(3);',
77
+ `keytar.findCredentials(${JSON.stringify(KEYTAR_SERVICE)})`,
78
+ ' .then(() => process.exit(0))',
79
+ ' .catch((e) => { process.stderr.write(String(e && e.message ? e.message : e)); process.exit(1); });',
80
+ '} catch (e) { process.stderr.write(String(e && e.message ? e.message : e)); process.exit(2); }',
81
+ ].join(' ');
82
+ const r = spawnSync(process.execPath, ['-e', script], {
83
+ env: { ...process.env },
84
+ encoding: 'utf8',
85
+ timeout: KEYTAR_PROBE_TIMEOUT_MS,
86
+ windowsHide: true,
87
+ stdio: ['ignore', 'ignore', 'pipe'],
88
+ });
89
+ if (r.error) return false;
90
+ return r.status === 0;
91
+ }
92
+
93
+ /**
94
+ * @param {{ treatAsLinux?: boolean }} [probeOptions] — .scratch harness only (operational probe).
95
+ */
96
+ export function probeLinuxSecretsCapable(probeOptions = null) {
97
+ const isLinux = probeOptions?.treatAsLinux === true || process.platform === 'linux';
98
+ if (!isLinux) return true;
99
+ if (_linuxSecretsCapable !== undefined) return _linuxSecretsCapable;
100
+ _linuxSecretsCapable = runLinuxKeytarOperationalProbe();
101
+ return _linuxSecretsCapable;
102
+ }
103
+
104
+ export function linuxKeychainUnavailableMessage() {
105
+ return [
106
+ '',
107
+ '⚠ Linux keychain unavailable (optional `keytar` not installed or libsecret missing).',
108
+ ' Secret prompts are skipped; non-secret setup continues. Install keytar later, then use the Mixdog UI:',
109
+ ' Debian/Ubuntu: sudo apt install libsecret-1-dev then: npm install keytar',
110
+ ' Fedora/RHEL: sudo dnf install libsecret-devel then: npm install keytar',
111
+ ' Arch: sudo pacman -S libsecret then: npm install keytar',
112
+ '',
113
+ ].join('\n');
61
114
  }
62
115
 
63
116
  function sanitizeName(n) {
@@ -193,20 +246,27 @@ function presetIdsFromAgent(agentSection) {
193
246
  return presets.map((p) => p.id || p.name).filter(Boolean);
194
247
  }
195
248
 
196
- export async function stepDiscordToken(io, { updateSection, readSection }) {
249
+ export async function stepDiscordToken(io, { updateSection, readSection, secretsCapable = true }) {
197
250
  const { hasStoredSecret, SECRET_ACCOUNTS, getDiscordToken } = await import('../src/shared/config.mjs');
198
251
  io.say('\n── Step 2/9: Discord ──');
199
252
  io.say('Bot token (keychain), application ID, and optional main channel.');
200
253
 
201
- const hadStoredToken = hasStoredSecret(SECRET_ACCOUNTS.discordToken);
202
- const tokenPrompt = hadStoredToken
203
- ? 'Discord bot token (stored, Enter=keep): '
204
- : 'Discord bot token [hidden] (Enter=skip whole step): ';
205
- const token = (await io.askSecret(tokenPrompt)).trim();
206
- const enteredToken = !isSkippableAnswer(token);
207
- if (!enteredToken && !hadStoredToken) {
208
- io.say('• Skipped Discord setup.');
209
- return false;
254
+ let hadStoredToken = false;
255
+ let token = '';
256
+ let enteredToken = false;
257
+ if (!secretsCapable) {
258
+ io.say('• Discord bot token: skipped (Linux keychain unavailable).');
259
+ } else {
260
+ hadStoredToken = hasStoredSecret(SECRET_ACCOUNTS.discordToken);
261
+ const tokenPrompt = hadStoredToken
262
+ ? 'Discord bot token (stored, Enter=keep): '
263
+ : 'Discord bot token [hidden] (Enter=skip whole step): ';
264
+ token = (await io.askSecret(tokenPrompt)).trim();
265
+ enteredToken = !isSkippableAnswer(token);
266
+ if (!enteredToken && !hadStoredToken) {
267
+ io.say('• Skipped Discord setup.');
268
+ return false;
269
+ }
210
270
  }
211
271
 
212
272
  const channels = readSection('channels') || {};
@@ -326,7 +386,7 @@ async function stepAddressForm(io, { updateSection, readSection }) {
326
386
  io.say('• Saved memory.user (title/name).');
327
387
  }
328
388
 
329
- export async function stepWebhookReceiver(io, { updateSection, readSection }) {
389
+ export async function stepWebhookReceiver(io, { updateSection, readSection, secretsCapable = true }) {
330
390
  io.say('\n── Step 3/9: Inbound webhooks (ngrok receiver) ──');
331
391
  io.say('Global webhook tunnel for inbound HTTP (channels.webhook). Per-endpoint registration is configured later in the UI.');
332
392
  const enableRaw = await io.ask('Enable inbound webhooks? [y/N]: ');
@@ -353,17 +413,27 @@ export async function stepWebhookReceiver(io, { updateSection, readSection }) {
353
413
  if (!isSkippableAnswer(domainRaw)) {
354
414
  webhook.domain = String(domainRaw).trim();
355
415
  }
356
- const authPrompt = hasStoredSecret(SECRET_ACCOUNTS.webhookAuth)
357
- ? 'Auth Token (stored, Enter=keep): '
358
- : 'ngrok Auth Token [hidden]: ';
359
- webhook.authtoken = (await io.askSecret(authPrompt)).trim();
416
+ if (secretsCapable) {
417
+ const authPrompt = hasStoredSecret(SECRET_ACCOUNTS.webhookAuth)
418
+ ? 'Auth Token (stored, Enter=keep): '
419
+ : 'ngrok Auth Token [hidden]: ';
420
+ webhook.authtoken = (await io.askSecret(authPrompt)).trim();
421
+ } else {
422
+ io.say('• ngrok Auth Token: skipped (Linux keychain unavailable).');
423
+ }
360
424
  const secrets = {};
361
425
  updateSection('channels', (current) => mergeConfig(current, { webhook }, secrets));
362
- io.say('• Inbound webhook receiver saved (channels.webhook enabled/domain; authtoken in keychain).');
426
+ io.say(secretsCapable
427
+ ? '• Inbound webhook receiver saved (channels.webhook enabled/domain; authtoken in keychain).'
428
+ : '• Inbound webhook receiver saved (channels.webhook enabled/domain; authtoken not collected).');
363
429
  }
364
430
 
365
- async function stepProviderKeys(io, { updateSection }) {
431
+ async function stepProviderKeys(io, { updateSection, secretsCapable = true }) {
366
432
  io.say('\n── Step 4/9: Provider API keys ──');
433
+ if (!secretsCapable) {
434
+ io.say('• Skipped provider API keys (Linux keychain unavailable).');
435
+ return;
436
+ }
367
437
  io.say('Optional API keys (hidden). Enter to skip a provider.');
368
438
  const providers = {};
369
439
  for (const p of AG_API_PROVIDERS) {
@@ -458,7 +528,7 @@ function parseYesNo(raw) {
458
528
  }
459
529
 
460
530
  /** Mirrors POST /search/config → mergeSearchConfig. */
461
- export async function stepSearchBackend(io, { updateSection, readSection }) {
531
+ export async function stepSearchBackend(io, { updateSection, readSection, secretsCapable = true }) {
462
532
  io.say('\n── Step 7/9: Search backend ──');
463
533
  io.say('Active provider: anthropic-oauth | openai-oauth | grok-oauth (OAuth — uses Agent credentials).');
464
534
  const { hasStoredSecret, SECRET_ACCOUNTS, getSearchApiKey } = await import('../src/shared/config.mjs');
@@ -481,15 +551,19 @@ export async function stepSearchBackend(io, { updateSection, readSection }) {
481
551
  if (provider !== curProvider) payload.provider = provider;
482
552
 
483
553
  const searchProviders = {};
484
- for (const p of SEARCH_RAW_KEY_PROVIDERS) {
485
- const hadKey = hasStoredSecret(SECRET_ACCOUNTS.searchApiKey(p.id));
486
- const keyPrompt = hadKey
487
- ? `${p.name} API key (stored, Enter=keep): `
488
- : `${p.name} API key [hidden] (Enter=skip): `;
489
- const key = (await io.askSecret(keyPrompt)).trim();
490
- if (!isSkippableAnswer(key)) {
491
- searchProviders[p.id] = key;
554
+ if (secretsCapable) {
555
+ for (const p of SEARCH_RAW_KEY_PROVIDERS) {
556
+ const hadKey = hasStoredSecret(SECRET_ACCOUNTS.searchApiKey(p.id));
557
+ const keyPrompt = hadKey
558
+ ? `${p.name} API key (stored, Enter=keep): `
559
+ : `${p.name} API key [hidden] (Enter=skip): `;
560
+ const key = (await io.askSecret(keyPrompt)).trim();
561
+ if (!isSkippableAnswer(key)) {
562
+ searchProviders[p.id] = key;
563
+ }
492
564
  }
565
+ } else {
566
+ io.say('• Search provider API keys: skipped (Linux keychain unavailable).');
493
567
  }
494
568
  if (Object.keys(searchProviders).length) payload.searchProviders = searchProviders;
495
569
 
@@ -597,15 +671,21 @@ export async function stepExplorerPreset(io, { readSection, updateSection, DEFAU
597
671
  * @param {(prompt:string)=>Promise<string>} [ioOverride.ask]
598
672
  * @param {(prompt:string)=>Promise<string>} [ioOverride.askSecret]
599
673
  * @param {(line:string)=>void} [ioOverride.say]
674
+ * @param {object} [options]
675
+ * @param {boolean} [options.secretsCapable] — override Linux keytar preflight (tests).
600
676
  */
601
- export async function runSetupWizard(ioOverride = null) {
677
+ export async function runSetupWizard(ioOverride = null, options = {}) {
602
678
  const io = ioOverride ? { ...defaultIo(), ...ioOverride } : defaultIo();
603
679
  if (!io.interactive) return { skipped: true };
604
680
 
681
+ const secretsCapable = options.secretsCapable ?? probeLinuxSecretsCapable();
682
+ if (!secretsCapable) io.say(linuxKeychainUnavailableMessage());
683
+
605
684
  io.say('\nMixdog setup wizard — configure before opening Claude Code.');
606
685
  io.say('Press Enter on any step to skip it.\n');
607
686
 
608
687
  const ctx = await loadConfigModules();
688
+ ctx.secretsCapable = secretsCapable;
609
689
  try {
610
690
  await stepAddressForm(io, ctx);
611
691
  const discordSaved = await stepDiscordToken(io, ctx);