mixdog 0.7.4 → 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.4",
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.4",
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,14 +6,22 @@
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,
15
17
  mergeConfig,
18
+ mergeSearchConfig,
16
19
  } from './config-merge.mjs';
20
+ import { DEFAULT_MODELS } from '../src/search/lib/config.mjs';
21
+
22
+ let _linuxSecretsCapable;
23
+ const KEYTAR_SERVICE = 'mixdog';
24
+ const KEYTAR_PROBE_TIMEOUT_MS = 8000;
17
25
 
18
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
27
  const REPO_ROOT = join(__dirname, '..');
@@ -32,14 +40,77 @@ const AG_API_PROVIDERS = [
32
40
 
33
41
  const WORKFLOW_ROLES = ['worker', 'reviewer', 'debugger', 'tester'];
34
42
 
43
+ /** Raw SERP API keys (setup.html SR_KEY_PROVIDERS) — independent of active provider. */
44
+ const SEARCH_RAW_KEY_PROVIDERS = [
45
+ { id: 'firecrawl', name: 'Firecrawl' },
46
+ { id: 'tavily', name: 'Tavily' },
47
+ { id: 'exa', name: 'Exa' },
48
+ ];
49
+ const SEARCH_OAUTH_PROVIDERS = new Set(['anthropic-oauth', 'openai-oauth', 'grok-oauth']);
50
+ const OPENAI_SEARCH_EFFORT_VALUES = new Set(['low', 'medium', 'high']);
51
+ const SEARCH_OAUTH_ALIASES = Object.freeze({
52
+ 'anthropic-oauth': 'anthropic-oauth',
53
+ anthropic: 'anthropic-oauth',
54
+ 'openai-oauth': 'openai-oauth',
55
+ openai: 'openai-oauth',
56
+ 'grok-oauth': 'grok-oauth',
57
+ grok: 'grok-oauth',
58
+ });
35
59
  function pluginDataDir() {
36
60
  const dir = process.env.CLAUDE_PLUGIN_DATA;
37
- if (!dir || typeof dir !== 'string' || !String(dir).trim()) {
38
- throw new Error(
39
- 'CLAUDE_PLUGIN_DATA must be set before running the setup wizard (install.mjs sets it unconditionally)',
40
- );
61
+ if (dir && typeof dir === 'string' && String(dir).trim()) {
62
+ return String(dir).trim();
41
63
  }
42
- 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');
43
114
  }
44
115
 
45
116
  function sanitizeName(n) {
@@ -121,11 +192,11 @@ function readHiddenLine(prompt) {
121
192
  async function loadConfigModules() {
122
193
  const { ensureDataSeeds } = await import('../src/shared/seed.mjs');
123
194
  const { readSection, updateSection } = await import('../src/shared/config.mjs');
124
- const { DEFAULT_PRESETS } = await import('../src/agent/orchestrator/config.mjs');
195
+ const { DEFAULT_PRESETS, DEFAULT_MAINTENANCE } = await import('../src/agent/orchestrator/config.mjs');
125
196
  const dataDir = pluginDataDir();
126
197
  mkdirSync(dataDir, { recursive: true });
127
198
  ensureDataSeeds(dataDir);
128
- return { readSection, updateSection, DEFAULT_PRESETS, dataDir };
199
+ return { readSection, updateSection, DEFAULT_PRESETS, DEFAULT_MAINTENANCE, dataDir };
129
200
  }
130
201
 
131
202
  function readUserWorkflow(dataDir) {
@@ -175,18 +246,66 @@ function presetIdsFromAgent(agentSection) {
175
246
  return presets.map((p) => p.id || p.name).filter(Boolean);
176
247
  }
177
248
 
178
- async function stepDiscordToken(io, { updateSection }) {
179
- io.say('\n── Step 2/7: Discord bot token ──');
180
- io.say('Paste your Discord bot token (hidden). Enter to skip.');
181
- const token = (await io.askSecret('Discord bot token: ')).trim();
182
- if (isSkippableAnswer(token)) {
183
- io.say('• Skipped Discord token.');
184
- return false;
249
+ export async function stepDiscordToken(io, { updateSection, readSection, secretsCapable = true }) {
250
+ const { hasStoredSecret, SECRET_ACCOUNTS, getDiscordToken } = await import('../src/shared/config.mjs');
251
+ io.say('\n── Step 2/9: Discord ──');
252
+ io.say('Bot token (keychain), application ID, and optional main channel.');
253
+
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
+ }
185
270
  }
271
+
272
+ const channels = readSection('channels') || {};
273
+ const curDiscord = channels.discord && typeof channels.discord === 'object' ? channels.discord : {};
274
+ const curAppId = String(curDiscord.applicationId || '').trim();
275
+ const appIdBase = 'Application ID';
276
+ const appIdPrompt = curAppId
277
+ ? `${appIdBase} (current: ${curAppId}, Enter=keep): `
278
+ : `${appIdBase}: `;
279
+ const appIdRaw = await io.ask(appIdPrompt);
280
+ const appIdToSet = isSkippableAnswer(appIdRaw) ? '' : String(appIdRaw).trim();
281
+ // The main channel is conventionally named "main" with interactive mode;
282
+ // only the channel ID varies, so we ask just that. Extra channels and
283
+ // monitor mode are configured later in the UI.
284
+ const chIdRaw = await io.ask('Main channel ID (Enter=skip channel): ');
285
+ const channelId = isSkippableAnswer(chIdRaw) ? '' : String(chIdRaw).trim();
286
+ const channelName = 'main';
287
+ const mode = 'interactive';
186
288
  const secrets = {};
187
- updateSection('channels', (current) => mergeConfig(current, { discord: { token } }, secrets));
188
- io.say('• Discord token saved to keychain.');
189
- return true;
289
+ updateSection('channels', (current) => {
290
+ const payload = {};
291
+ const discord = {};
292
+ if (enteredToken) discord.token = token;
293
+ if (appIdToSet) discord.applicationId = appIdToSet;
294
+ if (Object.keys(discord).length > 0) payload.discord = discord;
295
+ if (channelId) {
296
+ const existingCfg = current.channelsConfig && typeof current.channelsConfig === 'object'
297
+ ? { ...current.channelsConfig }
298
+ : {};
299
+ existingCfg[channelName] = { channelId, mode };
300
+ payload.channelsConfig = existingCfg;
301
+ payload.mainChannel = channelName;
302
+ }
303
+ return mergeConfig(current, payload, secrets);
304
+ });
305
+ if (enteredToken) io.say('• Discord token saved to keychain.');
306
+ if (appIdToSet) io.say('• Application ID saved.');
307
+ if (channelId) io.say(`• Main channel "${channelName}" configured (${mode}).`);
308
+ return enteredToken || hadStoredToken || !!getDiscordToken();
190
309
  }
191
310
 
192
311
  function formatVoiceProgress(p) {
@@ -221,7 +340,7 @@ async function installVoiceRuntime(dataDir, io) {
221
340
  /** Mirrors setup.html channels save: `voice` via POST /config → mergeConfig. */
222
341
  async function stepVoiceTranscription(io, ctx, discordTokenSaved) {
223
342
  if (!discordTokenSaved) return;
224
- io.say('\n── Step 2a/7: Voice transcription (음성 전사) ──');
343
+ io.say('\n── Step 2a/9: Voice transcription (음성 전사) ──');
225
344
  io.say('Install local Speech-to-text (whisper.cpp) for Discord voice messages.');
226
345
  const raw = await io.ask('Enable voice transcription? [y/N] (Enter=skip): ');
227
346
  if (isSkippableAnswer(raw)) {
@@ -247,7 +366,7 @@ async function stepVoiceTranscription(io, ctx, discordTokenSaved) {
247
366
  }
248
367
 
249
368
  async function stepAddressForm(io, { updateSection, readSection }) {
250
- io.say('\n── Step 1/7: Address form (호칭) ──');
369
+ io.say('\n── Step 1/9: Address form (호칭) ──');
251
370
  const memory = readSection('memory');
252
371
  const curTitle = memory?.user?.title || '';
253
372
  const curName = memory?.user?.name || '';
@@ -267,8 +386,8 @@ async function stepAddressForm(io, { updateSection, readSection }) {
267
386
  io.say('• Saved memory.user (title/name).');
268
387
  }
269
388
 
270
- export async function stepWebhookReceiver(io, { updateSection, readSection }) {
271
- io.say('\n── Step 3/7: Inbound webhooks (ngrok receiver) ──');
389
+ export async function stepWebhookReceiver(io, { updateSection, readSection, secretsCapable = true }) {
390
+ io.say('\n── Step 3/9: Inbound webhooks (ngrok receiver) ──');
272
391
  io.say('Global webhook tunnel for inbound HTTP (channels.webhook). Per-endpoint registration is configured later in the UI.');
273
392
  const enableRaw = await io.ask('Enable inbound webhooks? [y/N]: ');
274
393
  if (isSkippableAnswer(enableRaw)) {
@@ -294,17 +413,27 @@ export async function stepWebhookReceiver(io, { updateSection, readSection }) {
294
413
  if (!isSkippableAnswer(domainRaw)) {
295
414
  webhook.domain = String(domainRaw).trim();
296
415
  }
297
- const authPrompt = hasStoredSecret(SECRET_ACCOUNTS.webhookAuth)
298
- ? 'Auth Token (stored, Enter=keep): '
299
- : 'ngrok Auth Token [hidden]: ';
300
- 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
+ }
301
424
  const secrets = {};
302
425
  updateSection('channels', (current) => mergeConfig(current, { webhook }, secrets));
303
- 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).');
304
429
  }
305
430
 
306
- async function stepProviderKeys(io, { updateSection }) {
307
- io.say('\n── Step 4/7: Provider API keys ──');
431
+ async function stepProviderKeys(io, { updateSection, secretsCapable = true }) {
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
+ }
308
437
  io.say('Optional API keys (hidden). Enter to skip a provider.');
309
438
  const providers = {};
310
439
  for (const p of AG_API_PROVIDERS) {
@@ -323,7 +452,7 @@ async function stepProviderKeys(io, { updateSection }) {
323
452
  }
324
453
 
325
454
  async function stepPresets(io, { readSection, updateSection, DEFAULT_PRESETS }) {
326
- io.say('\n── Step 5/7: Agent presets ──');
455
+ io.say('\n── Step 5/9: Agent presets ──');
327
456
  const agent = readSection('agent');
328
457
  const existing = presetIdsFromAgent(agent);
329
458
  if (existing.length > 0) {
@@ -350,11 +479,11 @@ async function stepPresets(io, { readSection, updateSection, DEFAULT_PRESETS })
350
479
  }
351
480
 
352
481
  async function stepRolePresetMapping(io, { readSection, dataDir }) {
353
- io.say('\n── Step 6/7: Role → preset mapping ──');
482
+ io.say('\n── Step 6/9: Role → preset mapping ──');
354
483
  const agent = readSection('agent');
355
484
  const presetIds = presetIdsFromAgent(agent);
356
485
  if (presetIds.length === 0) {
357
- io.say('No presets on disk — run step 4 first or configure presets in the Mixdog UI later.');
486
+ io.say('No presets on disk — run step 5 first or configure presets in the Mixdog UI later.');
358
487
  return;
359
488
  }
360
489
  io.say(`Available presets: ${presetIds.join(', ')}`);
@@ -384,21 +513,179 @@ async function stepRolePresetMapping(io, { readSection, dataDir }) {
384
513
  io.say('• Role → preset mapping saved to user-workflow.json.');
385
514
  }
386
515
 
516
+ function resolveSearchBackendInput(raw) {
517
+ if (isSkippableAnswer(raw)) return null;
518
+ const key = String(raw).trim().toLowerCase();
519
+ return SEARCH_OAUTH_ALIASES[key] || null;
520
+ }
521
+
522
+ function parseYesNo(raw) {
523
+ if (isSkippableAnswer(raw)) return null;
524
+ const v = String(raw).trim().toLowerCase();
525
+ if (v === 'y' || v === 'yes' || v === 'true' || v === '1') return true;
526
+ if (v === 'n' || v === 'no' || v === 'false' || v === '0') return false;
527
+ return undefined;
528
+ }
529
+
530
+ /** Mirrors POST /search/config → mergeSearchConfig. */
531
+ export async function stepSearchBackend(io, { updateSection, readSection, secretsCapable = true }) {
532
+ io.say('\n── Step 7/9: Search backend ──');
533
+ io.say('Active provider: anthropic-oauth | openai-oauth | grok-oauth (OAuth — uses Agent credentials).');
534
+ const { hasStoredSecret, SECRET_ACCOUNTS, getSearchApiKey } = await import('../src/shared/config.mjs');
535
+ const search = readSection('search') || {};
536
+ const curProvider = String(search.provider || 'anthropic-oauth').trim() || 'anthropic-oauth';
537
+ const backendRaw = await io.ask(`Search provider [${curProvider}]: `);
538
+ let provider = curProvider;
539
+ if (!isSkippableAnswer(backendRaw)) {
540
+ const resolved = resolveSearchBackendInput(backendRaw);
541
+ if (!resolved) {
542
+ io.say(` ! Unknown provider "${String(backendRaw).trim()}" — keeping ${curProvider}.`);
543
+ } else if (!SEARCH_OAUTH_PROVIDERS.has(resolved)) {
544
+ io.say(` ! Provider "${resolved}" is not a supported OAuth backend — keeping ${curProvider}.`);
545
+ } else {
546
+ provider = resolved;
547
+ }
548
+ }
549
+
550
+ const payload = {};
551
+ if (provider !== curProvider) payload.provider = provider;
552
+
553
+ const searchProviders = {};
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
+ }
564
+ }
565
+ } else {
566
+ io.say('• Search provider API keys: skipped (Linux keychain unavailable).');
567
+ }
568
+ if (Object.keys(searchProviders).length) payload.searchProviders = searchProviders;
569
+
570
+ if (provider === 'openai-oauth') {
571
+ const curModel = (search.models && search.models.openai)
572
+ || DEFAULT_MODELS.openai
573
+ || '';
574
+ const modelRaw = await io.ask(`OpenAI model [${curModel}] (Enter=keep): `);
575
+ if (!isSkippableAnswer(modelRaw)) {
576
+ const model = String(modelRaw).trim();
577
+ if (model) payload.models = { ...(payload.models || {}), openai: model };
578
+ }
579
+ const curEffort = String(search.modelOptions?.openai?.effort || 'medium').trim() || 'medium';
580
+ const effortRaw = await io.ask(`OpenAI effort (low/medium/high) [${curEffort}] (Enter=keep): `);
581
+ if (!isSkippableAnswer(effortRaw)) {
582
+ const effort = String(effortRaw).trim().toLowerCase();
583
+ if (!OPENAI_SEARCH_EFFORT_VALUES.has(effort)) {
584
+ io.say(` ! Unknown effort "${effortRaw.trim()}" — keeping ${curEffort}.`);
585
+ } else {
586
+ const openaiOpts = { ...(search.modelOptions?.openai || {}), effort };
587
+ payload.modelOptions = { ...(payload.modelOptions || {}), openai: openaiOpts };
588
+ }
589
+ }
590
+ const curFast = !!search.modelOptions?.openai?.fast;
591
+ const fastRaw = await io.ask(`OpenAI Fast mode [${curFast ? 'y' : 'N'}] (y/N, Enter=keep): `);
592
+ const fastParsed = parseYesNo(fastRaw);
593
+ if (fastParsed === undefined && !isSkippableAnswer(fastRaw)) {
594
+ io.say(' ! Fast mode: answer y or n — keeping current.');
595
+ } else if (fastParsed !== null) {
596
+ const base = payload.modelOptions?.openai || search.modelOptions?.openai || {};
597
+ const openaiOpts = { ...base };
598
+ if (fastParsed) openaiOpts.fast = true;
599
+ else delete openaiOpts.fast;
600
+ // Write even an empty object: mergeSearchConfig treats an empty per-family
601
+ // entry as "clear this family", which is how an explicit Fast=n drops a
602
+ // lone fast flag (no effort left to keep the object non-empty).
603
+ payload.modelOptions = { ...(payload.modelOptions || {}), openai: openaiOpts };
604
+ }
605
+ } else if (provider === 'grok-oauth') {
606
+ const curModel = (search.models && search.models.xai)
607
+ || DEFAULT_MODELS.xai
608
+ || '';
609
+ const modelRaw = await io.ask(`xAI model [${curModel}] (Enter=keep): `);
610
+ if (!isSkippableAnswer(modelRaw)) {
611
+ const model = String(modelRaw).trim();
612
+ if (model) payload.models = { ...(payload.models || {}), xai: model };
613
+ }
614
+ }
615
+
616
+ const secrets = {};
617
+ updateSection('search', (current) => mergeSearchConfig(current, payload, secrets));
618
+ const after = readSection('search') || {};
619
+ const savedProvider = after.provider || curProvider;
620
+ io.say(`• Search provider: ${savedProvider}.`);
621
+ for (const p of SEARCH_RAW_KEY_PROVIDERS) {
622
+ io.say(`• ${p.name} API key: ${getSearchApiKey(p.id) ? 'stored' : 'not set'}.`);
623
+ }
624
+ if (savedProvider === 'openai-oauth' && after.models?.openai) {
625
+ io.say(`• OpenAI model: ${after.models.openai}.`);
626
+ }
627
+ if (savedProvider === 'grok-oauth' && after.models?.xai) {
628
+ io.say(`• xAI model: ${after.models.xai}.`);
629
+ }
630
+ if (savedProvider === 'openai-oauth' && after.modelOptions?.openai?.effort) {
631
+ io.say(`• OpenAI effort: ${after.modelOptions.openai.effort}.`);
632
+ }
633
+ if (savedProvider === 'openai-oauth' && after.modelOptions?.openai?.fast) {
634
+ io.say('• OpenAI Fast mode: on.');
635
+ }
636
+ }
637
+
638
+ /** Mirrors POST /agent/maintenance for the explore slot. */
639
+ export async function stepExplorerPreset(io, { readSection, updateSection, DEFAULT_PRESETS, DEFAULT_MAINTENANCE }) {
640
+ io.say('\n── Step 8/9: Explorer model (explore tool) ──');
641
+ const agent = readSection('agent') || {};
642
+ const presetIds = presetIdsFromAgent(agent);
643
+ const validIds = new Set([
644
+ ...presetIds,
645
+ ...DEFAULT_PRESETS.map((p) => p.id).filter(Boolean),
646
+ ]);
647
+ const curExplore = String(agent.maintenance?.explore || DEFAULT_MAINTENANCE.explore || 'haiku').trim() || 'haiku';
648
+ if (validIds.size > 0) {
649
+ io.say(`Available presets: ${[...validIds].join(', ')}`);
650
+ }
651
+ const raw = await io.ask(`Preset for explorer (explore tool) [${curExplore}]: `);
652
+ if (isSkippableAnswer(raw)) {
653
+ io.say(`• Explorer preset unchanged (${curExplore}).`);
654
+ return;
655
+ }
656
+ const preset = String(raw).trim();
657
+ if (!validIds.has(preset)) {
658
+ io.say(` ! Unknown preset "${preset}" — keeping ${curExplore}.`);
659
+ return;
660
+ }
661
+ updateSection('agent', (current) => ({
662
+ ...current,
663
+ maintenance: { ...(current.maintenance || {}), explore: preset },
664
+ }));
665
+ io.say(`• Explorer maintenance preset: ${preset}.`);
666
+ }
667
+
387
668
  /**
388
669
  * @param {object} [ioOverride]
389
670
  * @param {boolean} [ioOverride.interactive]
390
671
  * @param {(prompt:string)=>Promise<string>} [ioOverride.ask]
391
672
  * @param {(prompt:string)=>Promise<string>} [ioOverride.askSecret]
392
673
  * @param {(line:string)=>void} [ioOverride.say]
674
+ * @param {object} [options]
675
+ * @param {boolean} [options.secretsCapable] — override Linux keytar preflight (tests).
393
676
  */
394
- export async function runSetupWizard(ioOverride = null) {
677
+ export async function runSetupWizard(ioOverride = null, options = {}) {
395
678
  const io = ioOverride ? { ...defaultIo(), ...ioOverride } : defaultIo();
396
679
  if (!io.interactive) return { skipped: true };
397
680
 
681
+ const secretsCapable = options.secretsCapable ?? probeLinuxSecretsCapable();
682
+ if (!secretsCapable) io.say(linuxKeychainUnavailableMessage());
683
+
398
684
  io.say('\nMixdog setup wizard — configure before opening Claude Code.');
399
685
  io.say('Press Enter on any step to skip it.\n');
400
686
 
401
687
  const ctx = await loadConfigModules();
688
+ ctx.secretsCapable = secretsCapable;
402
689
  try {
403
690
  await stepAddressForm(io, ctx);
404
691
  const discordSaved = await stepDiscordToken(io, ctx);
@@ -407,6 +694,8 @@ export async function runSetupWizard(ioOverride = null) {
407
694
  await stepProviderKeys(io, ctx);
408
695
  await stepPresets(io, ctx);
409
696
  await stepRolePresetMapping(io, ctx);
697
+ await stepSearchBackend(io, ctx);
698
+ await stepExplorerPreset(io, ctx);
410
699
  io.say('\n✓ Wizard complete. Restart Claude Code (or /reload-plugins) to load mixdog.');
411
700
  } catch (err) {
412
701
  io.say(`\n✗ Wizard error: ${err?.message || err}`);