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