mixdog 0.7.2 → 0.7.4
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/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/setup/install.mjs +26 -6
- package/setup/wizard.mjs +418 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
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",
|
package/setup/install.mjs
CHANGED
|
@@ -24,9 +24,10 @@ import { join } from 'node:path';
|
|
|
24
24
|
import { homedir } from 'node:os';
|
|
25
25
|
import { createInterface } from 'node:readline';
|
|
26
26
|
import { spawn } from 'node:child_process';
|
|
27
|
+
import { DEFAULT_MARKETPLACE, DEFAULT_PLUGIN } from '../src/shared/plugin-paths.mjs';
|
|
27
28
|
|
|
28
|
-
const MARKETPLACE =
|
|
29
|
-
const PLUGIN_REF =
|
|
29
|
+
const MARKETPLACE = DEFAULT_MARKETPLACE;
|
|
30
|
+
const PLUGIN_REF = `${DEFAULT_PLUGIN}@${DEFAULT_MARKETPLACE}`;
|
|
30
31
|
const REPO = 'trib-plugin/mixdog'; // github owner/repo
|
|
31
32
|
const REPO_URL = 'https://github.com/trib-plugin/mixdog';
|
|
32
33
|
|
|
@@ -47,7 +48,7 @@ function isPlainObject(value) {
|
|
|
47
48
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function
|
|
51
|
+
function registerPluginInSettings() {
|
|
51
52
|
const dir = settingsDir();
|
|
52
53
|
const file = join(dir, 'settings.json');
|
|
53
54
|
|
|
@@ -88,6 +89,22 @@ function main() {
|
|
|
88
89
|
console.log(` - marketplace "${MARKETPLACE}" → github:${REPO}`);
|
|
89
90
|
console.log(` - enabled plugin "${PLUGIN_REF}"${already ? ' (was already enabled)' : ''}`);
|
|
90
91
|
console.log(`\nNext: restart Claude Code (or run /reload-plugins). mixdog loads automatically.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function main() {
|
|
95
|
+
registerPluginInSettings();
|
|
96
|
+
|
|
97
|
+
// 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
|
+
);
|
|
105
|
+
|
|
106
|
+
const { runSetupWizard } = await import('./wizard.mjs');
|
|
107
|
+
await runSetupWizard();
|
|
91
108
|
|
|
92
109
|
maybeStarNudge();
|
|
93
110
|
}
|
|
@@ -97,9 +114,9 @@ function main() {
|
|
|
97
114
|
function maybeStarNudge() {
|
|
98
115
|
if (!process.stdin.isTTY || process.env.CI) return;
|
|
99
116
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
100
|
-
rl.question('\n⭐ Found this useful? Star the repo on GitHub? (
|
|
117
|
+
rl.question('\n⭐ Found this useful? Star the repo on GitHub? (y/N) ', (answer) => {
|
|
101
118
|
const a = answer.trim().toLowerCase();
|
|
102
|
-
if (a === '
|
|
119
|
+
if (a === 'y' || a === 'yes') openRepo();
|
|
103
120
|
else console.log(`No problem — you can star it anytime at ${REPO_URL}`);
|
|
104
121
|
rl.close();
|
|
105
122
|
});
|
|
@@ -117,4 +134,7 @@ function openRepo() {
|
|
|
117
134
|
}
|
|
118
135
|
}
|
|
119
136
|
|
|
120
|
-
main()
|
|
137
|
+
main().catch((err) => {
|
|
138
|
+
console.error(err?.stack || err?.message || String(err));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|
package/setup/wizard.mjs
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive terminal setup wizard for `npx mixdog` / mixdog-install.
|
|
3
|
+
* Runs after plugin registration when stdin is a TTY and CI is unset.
|
|
4
|
+
*
|
|
5
|
+
* Callers must set process.env.CLAUDE_PLUGIN_DATA before importing this
|
|
6
|
+
* module (install.mjs does that) so src/shared/config.mjs can resolve paths.
|
|
7
|
+
*/
|
|
8
|
+
import { createInterface } from 'node:readline';
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
10
|
+
import { join, dirname, basename } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import {
|
|
13
|
+
mergeAgentConfig,
|
|
14
|
+
mergeMemoryConfig,
|
|
15
|
+
mergeConfig,
|
|
16
|
+
} from './config-merge.mjs';
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const REPO_ROOT = join(__dirname, '..');
|
|
20
|
+
const DEFAULT_USER_WORKFLOW = JSON.parse(
|
|
21
|
+
readFileSync(join(REPO_ROOT, 'defaults', 'user-workflow.json'), 'utf8'),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const AG_API_PROVIDERS = [
|
|
25
|
+
{ id: 'openai', name: 'OpenAI', env: 'OPENAI_API_KEY' },
|
|
26
|
+
{ id: 'anthropic', name: 'Anthropic', env: 'ANTHROPIC_API_KEY' },
|
|
27
|
+
{ id: 'gemini', name: 'Gemini', env: 'GEMINI_API_KEY' },
|
|
28
|
+
{ id: 'deepseek', name: 'DeepSeek', env: 'DEEPSEEK_API_KEY' },
|
|
29
|
+
{ id: 'xai', name: 'xAI', env: 'XAI_API_KEY' },
|
|
30
|
+
{ id: 'nvidia', name: 'NVIDIA', env: 'NVIDIA_API_KEY' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const WORKFLOW_ROLES = ['worker', 'reviewer', 'debugger', 'tester'];
|
|
34
|
+
|
|
35
|
+
function pluginDataDir() {
|
|
36
|
+
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
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return String(dir).trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sanitizeName(n) {
|
|
46
|
+
if (!n || typeof n !== 'string') return null;
|
|
47
|
+
if (n !== basename(n)) return null;
|
|
48
|
+
if (n.includes('..') || n.startsWith('.')) return null;
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isSkippableAnswer(raw) {
|
|
53
|
+
return raw === undefined || raw === null || String(raw).trim() === '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function defaultIo() {
|
|
57
|
+
const interactive = !!(process.stdin.isTTY && !process.env.CI);
|
|
58
|
+
if (!interactive) {
|
|
59
|
+
return {
|
|
60
|
+
interactive: false,
|
|
61
|
+
ask: async () => '',
|
|
62
|
+
askSecret: async () => '',
|
|
63
|
+
say: (line) => { if (line) console.log(line); },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
67
|
+
const ask = (prompt) => new Promise((resolve) => {
|
|
68
|
+
rl.question(prompt, (answer) => resolve(answer));
|
|
69
|
+
});
|
|
70
|
+
const askSecret = (prompt) => readHiddenLine(prompt).finally(() => {
|
|
71
|
+
rl.resume();
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
interactive: true,
|
|
75
|
+
ask,
|
|
76
|
+
askSecret,
|
|
77
|
+
say: (line) => { if (line) console.log(line); },
|
|
78
|
+
close: () => rl.close(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readHiddenLine(prompt) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const stdin = process.stdin;
|
|
85
|
+
if (!stdin.isTTY) {
|
|
86
|
+
resolve('');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const wasRaw = stdin.isRaw;
|
|
90
|
+
stdin.setRawMode(true);
|
|
91
|
+
stdin.resume();
|
|
92
|
+
process.stdout.write(prompt);
|
|
93
|
+
let value = '';
|
|
94
|
+
const onData = (chunk) => {
|
|
95
|
+
const s = chunk.toString('utf8');
|
|
96
|
+
for (const ch of s) {
|
|
97
|
+
if (ch === '\n' || ch === '\r' || ch === '\u0004') {
|
|
98
|
+
stdin.removeListener('data', onData);
|
|
99
|
+
stdin.setRawMode(wasRaw);
|
|
100
|
+
process.stdout.write('\n');
|
|
101
|
+
resolve(value);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (ch === '\u0003') {
|
|
105
|
+
stdin.removeListener('data', onData);
|
|
106
|
+
stdin.setRawMode(wasRaw);
|
|
107
|
+
reject(new Error('cancelled'));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (ch === '\u007f' || ch === '\b') {
|
|
111
|
+
value = value.slice(0, -1);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
value += ch;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
stdin.on('data', onData);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function loadConfigModules() {
|
|
122
|
+
const { ensureDataSeeds } = await import('../src/shared/seed.mjs');
|
|
123
|
+
const { readSection, updateSection } = await import('../src/shared/config.mjs');
|
|
124
|
+
const { DEFAULT_PRESETS } = await import('../src/agent/orchestrator/config.mjs');
|
|
125
|
+
const dataDir = pluginDataDir();
|
|
126
|
+
mkdirSync(dataDir, { recursive: true });
|
|
127
|
+
ensureDataSeeds(dataDir);
|
|
128
|
+
return { readSection, updateSection, DEFAULT_PRESETS, dataDir };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readUserWorkflow(dataDir) {
|
|
132
|
+
const path = join(dataDir, 'user-workflow.json');
|
|
133
|
+
if (!existsSync(path)) return structuredClone(DEFAULT_USER_WORKFLOW);
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
136
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
137
|
+
throw new SyntaxError('user-workflow root must be a JSON object');
|
|
138
|
+
}
|
|
139
|
+
return parsed;
|
|
140
|
+
} catch (parseErr) {
|
|
141
|
+
const corrupt = `${path}.corrupt-${Date.now()}`;
|
|
142
|
+
try {
|
|
143
|
+
if (existsSync(path)) renameSync(path, corrupt);
|
|
144
|
+
} catch {}
|
|
145
|
+
process.stderr.write(
|
|
146
|
+
`[wizard] user-workflow.json is malformed (${parseErr.message}). Renamed to ${corrupt}. Fix or delete before re-running setup.\n`,
|
|
147
|
+
);
|
|
148
|
+
throw new Error(
|
|
149
|
+
`[wizard] user-workflow.json is malformed (${parseErr.message}); refusing role mapping that would overwrite user data`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function writeUserWorkflow(dataDir, data) {
|
|
155
|
+
const path = join(dataDir, 'user-workflow.json');
|
|
156
|
+
const roles = Array.isArray(data?.roles) ? data.roles.slice() : [];
|
|
157
|
+
if (!roles.some((r) => r?.name === 'worker')) {
|
|
158
|
+
const seedWorker = DEFAULT_USER_WORKFLOW.roles.find((r) => r?.name === 'worker');
|
|
159
|
+
if (seedWorker) roles.unshift({ ...seedWorker });
|
|
160
|
+
}
|
|
161
|
+
const sanitizedRoles = roles.map((r) => {
|
|
162
|
+
if (!r || typeof r !== 'object') return r;
|
|
163
|
+
const name = sanitizeName(r.name);
|
|
164
|
+
if (name == null) throw new Error(`invalid role name: ${r.name}`);
|
|
165
|
+
return { ...r, name };
|
|
166
|
+
});
|
|
167
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
168
|
+
const tmp = `${path}.tmp`;
|
|
169
|
+
writeFileSync(tmp, JSON.stringify({ ...data, roles: sanitizedRoles }, null, 2) + '\n', 'utf8');
|
|
170
|
+
renameSync(tmp, path);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function presetIdsFromAgent(agentSection) {
|
|
174
|
+
const presets = Array.isArray(agentSection?.presets) ? agentSection.presets : [];
|
|
175
|
+
return presets.map((p) => p.id || p.name).filter(Boolean);
|
|
176
|
+
}
|
|
177
|
+
|
|
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;
|
|
185
|
+
}
|
|
186
|
+
const secrets = {};
|
|
187
|
+
updateSection('channels', (current) => mergeConfig(current, { discord: { token } }, secrets));
|
|
188
|
+
io.say('• Discord token saved to keychain.');
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatVoiceProgress(p) {
|
|
193
|
+
const phase = p?.phase || 'download';
|
|
194
|
+
const downloaded = Number(p?.downloaded) || 0;
|
|
195
|
+
const total = Number(p?.total) || 0;
|
|
196
|
+
if (total > 0) {
|
|
197
|
+
const pct = Math.floor((downloaded / total) * 100);
|
|
198
|
+
const mb = (n) => `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
199
|
+
return `${phase} · ${mb(downloaded)} / ${mb(total)} (${pct}%)`;
|
|
200
|
+
}
|
|
201
|
+
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
|
202
|
+
return `${phase} · received ${mb} MB`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function installVoiceRuntime(dataDir, io) {
|
|
206
|
+
const {
|
|
207
|
+
ensureWhisperRuntime,
|
|
208
|
+
ensureWhisperModel,
|
|
209
|
+
ensureFfmpegRuntime,
|
|
210
|
+
} = await import('../src/channels/lib/voice-runtime-fetcher.mjs');
|
|
211
|
+
const onProgress = (p) => {
|
|
212
|
+
const line = formatVoiceProgress(p);
|
|
213
|
+
if (line) io.say(` ${line}`);
|
|
214
|
+
};
|
|
215
|
+
await ensureWhisperRuntime(dataDir, onProgress);
|
|
216
|
+
const model = await ensureWhisperModel(dataDir, onProgress);
|
|
217
|
+
await ensureFfmpegRuntime(dataDir, onProgress);
|
|
218
|
+
return model?.modelId || 'large-v3-turbo';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Mirrors setup.html channels save: `voice` via POST /config → mergeConfig. */
|
|
222
|
+
async function stepVoiceTranscription(io, ctx, discordTokenSaved) {
|
|
223
|
+
if (!discordTokenSaved) return;
|
|
224
|
+
io.say('\n── Step 2a/7: Voice transcription (음성 전사) ──');
|
|
225
|
+
io.say('Install local Speech-to-text (whisper.cpp) for Discord voice messages.');
|
|
226
|
+
const raw = await io.ask('Enable voice transcription? [y/N] (Enter=skip): ');
|
|
227
|
+
if (isSkippableAnswer(raw)) {
|
|
228
|
+
io.say('• Skipped voice transcription.');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const a = String(raw).trim().toLowerCase();
|
|
232
|
+
if (a !== 'y' && a !== 'yes') {
|
|
233
|
+
io.say('• Skipped voice transcription.');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const { updateSection, dataDir } = ctx;
|
|
237
|
+
try {
|
|
238
|
+
io.say('• Downloading voice runtime (this may take a while)…');
|
|
239
|
+
const modelId = await installVoiceRuntime(dataDir, io);
|
|
240
|
+
const voice = { language: 'auto', model: modelId };
|
|
241
|
+
updateSection('channels', (current) => mergeConfig(current, { voice }, {}));
|
|
242
|
+
io.say('• Voice transcription runtime installed and channels voice config saved.');
|
|
243
|
+
} catch (err) {
|
|
244
|
+
io.say(`• Voice install failed: ${err?.message || err}`);
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function stepAddressForm(io, { updateSection, readSection }) {
|
|
250
|
+
io.say('\n── Step 1/7: Address form (호칭) ──');
|
|
251
|
+
const memory = readSection('memory');
|
|
252
|
+
const curTitle = memory?.user?.title || '';
|
|
253
|
+
const curName = memory?.user?.name || '';
|
|
254
|
+
const titleRaw = await io.ask(`How should Mixdog address you? user.title [${curTitle}]: `);
|
|
255
|
+
const nameRaw = await io.ask(`Your display name? user.name [${curName}]: `);
|
|
256
|
+
const title = isSkippableAnswer(titleRaw) ? '' : String(titleRaw).trim();
|
|
257
|
+
const name = isSkippableAnswer(nameRaw) ? '' : String(nameRaw).trim();
|
|
258
|
+
if (!title && !name) {
|
|
259
|
+
io.say('• Skipped address form.');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const user = {};
|
|
263
|
+
if (title) user.title = title;
|
|
264
|
+
if (name) user.name = name;
|
|
265
|
+
const secrets = {};
|
|
266
|
+
updateSection('memory', (current) => mergeMemoryConfig(current, { user }, secrets));
|
|
267
|
+
io.say('• Saved memory.user (title/name).');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function stepWebhookReceiver(io, { updateSection, readSection }) {
|
|
271
|
+
io.say('\n── Step 3/7: Inbound webhooks (ngrok receiver) ──');
|
|
272
|
+
io.say('Global webhook tunnel for inbound HTTP (channels.webhook). Per-endpoint registration is configured later in the UI.');
|
|
273
|
+
const enableRaw = await io.ask('Enable inbound webhooks? [y/N]: ');
|
|
274
|
+
if (isSkippableAnswer(enableRaw)) {
|
|
275
|
+
io.say('• Skipped webhook setup.');
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const enable = String(enableRaw).trim().toLowerCase();
|
|
279
|
+
if (enable !== 'y' && enable !== 'yes') {
|
|
280
|
+
io.say('• Skipped webhook setup.');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const { hasStoredSecret, SECRET_ACCOUNTS } = await import('../src/shared/config.mjs');
|
|
284
|
+
const channels = readSection('channels') || {};
|
|
285
|
+
const curWebhook = channels.webhook && typeof channels.webhook === 'object' ? channels.webhook : {};
|
|
286
|
+
const curDomain = String(curWebhook.domain || curWebhook.ngrokDomain || '').trim();
|
|
287
|
+
const domainBase =
|
|
288
|
+
'Domain (ngrok, e.g. your-name.ngrok-free.dev — get it at dashboard.ngrok.com/domains)';
|
|
289
|
+
const domainPrompt = curDomain
|
|
290
|
+
? `${domainBase} (current: ${curDomain}, Enter=keep): `
|
|
291
|
+
: `${domainBase}: `;
|
|
292
|
+
const domainRaw = await io.ask(domainPrompt);
|
|
293
|
+
const webhook = { enabled: true };
|
|
294
|
+
if (!isSkippableAnswer(domainRaw)) {
|
|
295
|
+
webhook.domain = String(domainRaw).trim();
|
|
296
|
+
}
|
|
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();
|
|
301
|
+
const secrets = {};
|
|
302
|
+
updateSection('channels', (current) => mergeConfig(current, { webhook }, secrets));
|
|
303
|
+
io.say('• Inbound webhook receiver saved (channels.webhook enabled/domain; authtoken in keychain).');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function stepProviderKeys(io, { updateSection }) {
|
|
307
|
+
io.say('\n── Step 4/7: Provider API keys ──');
|
|
308
|
+
io.say('Optional API keys (hidden). Enter to skip a provider.');
|
|
309
|
+
const providers = {};
|
|
310
|
+
for (const p of AG_API_PROVIDERS) {
|
|
311
|
+
const key = (await io.askSecret(`${p.name} API key (${p.env}, Enter=skip): `)).trim();
|
|
312
|
+
if (!isSkippableAnswer(key)) {
|
|
313
|
+
providers[p.id] = { apiKey: key, enabled: true };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (Object.keys(providers).length === 0) {
|
|
317
|
+
io.say('• Skipped provider keys.');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const secrets = {};
|
|
321
|
+
updateSection('agent', (current) => mergeAgentConfig(current, { providers }, secrets));
|
|
322
|
+
io.say(`• Saved ${Object.keys(providers).length} provider key(s) to keychain.`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function stepPresets(io, { readSection, updateSection, DEFAULT_PRESETS }) {
|
|
326
|
+
io.say('\n── Step 5/7: Agent presets ──');
|
|
327
|
+
const agent = readSection('agent');
|
|
328
|
+
const existing = presetIdsFromAgent(agent);
|
|
329
|
+
if (existing.length > 0) {
|
|
330
|
+
io.say(`Current presets: ${existing.join(', ')}`);
|
|
331
|
+
}
|
|
332
|
+
const raw = await io.ask('Install default Mixdog presets? [y/N] (Enter=skip): ');
|
|
333
|
+
if (isSkippableAnswer(raw)) {
|
|
334
|
+
io.say('• Kept existing presets.');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const a = String(raw).trim().toLowerCase();
|
|
338
|
+
if (a !== 'y' && a !== 'yes') {
|
|
339
|
+
io.say('• Kept existing presets.');
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const list = DEFAULT_PRESETS.map((p) => ({ ...p }));
|
|
343
|
+
updateSection('agent', (current) => {
|
|
344
|
+
const next = { ...current, presets: list };
|
|
345
|
+
const validKeys = list.map((p) => p.id).filter(Boolean);
|
|
346
|
+
if (!next.default || !validKeys.includes(next.default)) next.default = validKeys[0] || null;
|
|
347
|
+
return next;
|
|
348
|
+
});
|
|
349
|
+
io.say(`• Installed ${list.length} default presets.`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function stepRolePresetMapping(io, { readSection, dataDir }) {
|
|
353
|
+
io.say('\n── Step 6/7: Role → preset mapping ──');
|
|
354
|
+
const agent = readSection('agent');
|
|
355
|
+
const presetIds = presetIdsFromAgent(agent);
|
|
356
|
+
if (presetIds.length === 0) {
|
|
357
|
+
io.say('No presets on disk — run step 4 first or configure presets in the Mixdog UI later.');
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
io.say(`Available presets: ${presetIds.join(', ')}`);
|
|
361
|
+
const wf = readUserWorkflow(dataDir);
|
|
362
|
+
const roles = Array.isArray(wf.roles) ? wf.roles : [];
|
|
363
|
+
const byName = new Map(roles.map((r) => [r.name, r]));
|
|
364
|
+
for (const roleName of WORKFLOW_ROLES) {
|
|
365
|
+
const cur = byName.get(roleName)?.preset || DEFAULT_USER_WORKFLOW.roles.find((r) => r.name === roleName)?.preset || '';
|
|
366
|
+
const raw = await io.ask(`Preset for role "${roleName}" [${cur}]: `);
|
|
367
|
+
if (isSkippableAnswer(raw)) continue;
|
|
368
|
+
const preset = String(raw).trim();
|
|
369
|
+
if (!presetIds.includes(preset)) {
|
|
370
|
+
io.say(` ! Unknown preset "${preset}" — left "${roleName}" unchanged.`);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const entry = byName.get(roleName) || { name: roleName, permission: 'full' };
|
|
374
|
+
entry.preset = preset;
|
|
375
|
+
byName.set(roleName, entry);
|
|
376
|
+
}
|
|
377
|
+
const mergedRoles = WORKFLOW_ROLES.map((name) => byName.get(name)).filter(Boolean);
|
|
378
|
+
for (const r of roles) {
|
|
379
|
+
if (!WORKFLOW_ROLES.includes(r.name) && !mergedRoles.some((x) => x.name === r.name)) {
|
|
380
|
+
mergedRoles.push(r);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
writeUserWorkflow(dataDir, { ...wf, roles: mergedRoles });
|
|
384
|
+
io.say('• Role → preset mapping saved to user-workflow.json.');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* @param {object} [ioOverride]
|
|
389
|
+
* @param {boolean} [ioOverride.interactive]
|
|
390
|
+
* @param {(prompt:string)=>Promise<string>} [ioOverride.ask]
|
|
391
|
+
* @param {(prompt:string)=>Promise<string>} [ioOverride.askSecret]
|
|
392
|
+
* @param {(line:string)=>void} [ioOverride.say]
|
|
393
|
+
*/
|
|
394
|
+
export async function runSetupWizard(ioOverride = null) {
|
|
395
|
+
const io = ioOverride ? { ...defaultIo(), ...ioOverride } : defaultIo();
|
|
396
|
+
if (!io.interactive) return { skipped: true };
|
|
397
|
+
|
|
398
|
+
io.say('\nMixdog setup wizard — configure before opening Claude Code.');
|
|
399
|
+
io.say('Press Enter on any step to skip it.\n');
|
|
400
|
+
|
|
401
|
+
const ctx = await loadConfigModules();
|
|
402
|
+
try {
|
|
403
|
+
await stepAddressForm(io, ctx);
|
|
404
|
+
const discordSaved = await stepDiscordToken(io, ctx);
|
|
405
|
+
await stepVoiceTranscription(io, ctx, discordSaved);
|
|
406
|
+
await stepWebhookReceiver(io, ctx);
|
|
407
|
+
await stepProviderKeys(io, ctx);
|
|
408
|
+
await stepPresets(io, ctx);
|
|
409
|
+
await stepRolePresetMapping(io, ctx);
|
|
410
|
+
io.say('\n✓ Wizard complete. Restart Claude Code (or /reload-plugins) to load mixdog.');
|
|
411
|
+
} catch (err) {
|
|
412
|
+
io.say(`\n✗ Wizard error: ${err?.message || err}`);
|
|
413
|
+
throw err;
|
|
414
|
+
} finally {
|
|
415
|
+
if (typeof io.close === 'function') io.close();
|
|
416
|
+
}
|
|
417
|
+
return { skipped: false };
|
|
418
|
+
}
|