slacklocalvibe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1143 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const readline = require("readline");
4
+ const prompts = require("prompts");
5
+ const { spawn, spawnSync } = require("child_process");
6
+ const { createLogger, LEVELS, safeError } = require("../lib/logger");
7
+ const {
8
+ wizardLogPath,
9
+ notifyLogPath,
10
+ daemonLogPath,
11
+ defaultPathEnv,
12
+ routesPath,
13
+ resolveCommandPathStrict,
14
+ } = require("../lib/paths");
15
+ const {
16
+ loadConfig,
17
+ normalizeConfig,
18
+ writeConfig,
19
+ configPath,
20
+ } = require("../lib/config");
21
+ const { createWebClient, openDmChannel, postMessage } = require("../lib/slack");
22
+ const {
23
+ updateCodexNotify,
24
+ updateClaudeStopHook,
25
+ codexConfigPath,
26
+ claudeSettingsPath,
27
+ } = require("../lib/user-config");
28
+ const { TEST_PROMPT } = require("../lib/messages");
29
+ const { installLaunchd, resolveBinaryPath } = require("../lib/launchd");
30
+
31
+ class UserExit extends Error {}
32
+
33
+ const TOTAL_STEPS = 12;
34
+ const USE_COLOR = Boolean(process.stdout.isTTY);
35
+ const ANSI = {
36
+ reset: "\x1b[0m",
37
+ bold: "\x1b[1m",
38
+ cyan: "\x1b[36m",
39
+ green: "\x1b[32m",
40
+ red: "\x1b[31m",
41
+ yellow: "\x1b[33m",
42
+ };
43
+ const SLACK_NEW_APP_URL = "https://api.slack.com/apps?new_app=1";
44
+ const SLACK_MANIFEST = `_metadata:
45
+ major_version: 1
46
+
47
+ display_information:
48
+ name: LocalVibe
49
+ description: DMで通知と返信を扱うブリッジ
50
+ background_color: "#0a0a0a"
51
+
52
+ features:
53
+ bot_user:
54
+ display_name: LocalVibe
55
+ always_online: false
56
+ app_home:
57
+ home_tab_enabled: false
58
+ messages_tab_enabled: true
59
+ messages_tab_read_only_enabled: false
60
+
61
+ oauth_config:
62
+ scopes:
63
+ bot:
64
+ - chat:write
65
+ - im:write
66
+ - im:history
67
+
68
+ settings:
69
+ socket_mode_enabled: true
70
+ is_hosted: false
71
+ token_rotation_enabled: false
72
+ event_subscriptions:
73
+ bot_events:
74
+ - message.im
75
+ org_deploy_enabled: false
76
+ `;
77
+
78
+ async function runWizard() {
79
+ const { log } = createLogger({ filePath: wizardLogPath(), scope: "wizard" });
80
+ log(LEVELS.INFO, "wizard.start");
81
+
82
+ const configSnapshot = captureConfigSnapshot();
83
+ let stagedConfigWritten = false;
84
+ let didSaveFinal = false;
85
+
86
+ let existingConfig = null;
87
+ let startFromTest = false;
88
+ try {
89
+ existingConfig = loadConfig();
90
+ } catch (error) {
91
+ log(LEVELS.ERROR, "wizard.config_parse_failed", { error: safeError(error) });
92
+ console.log("設定ファイルの読み取りに失敗しました。内容を確認してください。");
93
+ throw error;
94
+ }
95
+ const normalized = existingConfig ? normalizeConfig(existingConfig) : null;
96
+ printBanner();
97
+ console.log("CLI 公式機能活用:CLI に限らず VSCode 拡張機能などでも動作");
98
+ console.log(
99
+ "Slack返信対応:通知スレッドに返信すると resume コマンドを用いて CLI にメッセージを送信が可能"
100
+ );
101
+ console.log("");
102
+ console.log("SlackLocalVibe セットアップを開始します。");
103
+ if (normalized) {
104
+ console.log("既存設定が見つかりました。開始方法を選んでください。");
105
+ const choice = await promptSelect({
106
+ message: "どこから始めますか?",
107
+ choices: [
108
+ { title: "テストから始める", value: "test" },
109
+ { title: "リセットして最初から", value: "reset" },
110
+ { title: "終了(保存せず終了)", value: "exit" },
111
+ ],
112
+ initial: 0,
113
+ });
114
+ if (choice === "reset") {
115
+ resetStoredConfig({ log });
116
+ } else if (choice === "test") {
117
+ startFromTest = true;
118
+ console.log("既存設定を使って通知テストから開始します。");
119
+ } else {
120
+ throw new UserExit();
121
+ }
122
+ }
123
+ console.log("途中で「終了」を選んだ場合は保存せずに終了します。");
124
+ console.log("OAuthログインでの自動連携は行いません。");
125
+ console.log("操作方法: ↑↓ で選択 / Enter で決定");
126
+
127
+ try {
128
+ let dmConfig = null;
129
+ let botToken = "";
130
+ let appToken = "";
131
+ if (startFromTest) {
132
+ const dmTarget = normalized?.destinations?.dm?.target_user_id || "";
133
+ const dmEnabled = Boolean(normalized?.destinations?.dm?.enabled);
134
+ botToken = normalized?.slack?.bot_token || "";
135
+ appToken = normalized?.slack?.app_token || "";
136
+ dmConfig = { enabled: dmEnabled, targetUserId: dmTarget };
137
+
138
+ const missing = [];
139
+ if (!botToken) missing.push("Bot Token");
140
+ if (!appToken) missing.push("App-Level Token");
141
+ if (!dmTarget) missing.push("DM送信先");
142
+ if (missing.length > 0) {
143
+ console.log(
144
+ formatError(`既存設定が不完全です(${missing.join(" / ")})。`)
145
+ );
146
+ const next = await promptSelect({
147
+ message: "次の操作を選んでください",
148
+ choices: [
149
+ { title: "リセットして最初から", value: "reset" },
150
+ { title: "終了(保存せず終了)", value: "exit" },
151
+ ],
152
+ });
153
+ if (next === "reset") {
154
+ resetStoredConfig({ log });
155
+ startFromTest = false;
156
+ } else {
157
+ throw new UserExit();
158
+ }
159
+ }
160
+ }
161
+
162
+ const selectedTools = startFromTest ? ["codex", "claude"] : await stepSelectTools({ log });
163
+ const useCodex = selectedTools.includes("codex");
164
+ const useClaude = selectedTools.includes("claude");
165
+
166
+ if (!startFromTest) {
167
+ printStep(2, "あなたのユーザーIDを教えてください");
168
+ dmConfig = await stepDmDestination({ log });
169
+ printStep(3, "Slack Apps 新規作成用の manifest をコピーしました");
170
+ await offerSlackAppLinks({ log });
171
+ printStep(4, "App-Level Tokens を入力してください");
172
+ appToken = await stepAppLevelToken();
173
+ log(LEVELS.SUCCRSS, "wizard.app_token_set");
174
+ printStep(5, "OAuth Tokens を入力してください");
175
+ botToken = await stepOAuthToken();
176
+ log(LEVELS.SUCCRSS, "wizard.bot_token_set");
177
+ }
178
+
179
+ printStep(6, "Slack に通知が来ましたか?");
180
+ const testResult = await stepNotifyTest({ log, botToken, dmConfig });
181
+ botToken = testResult.botToken;
182
+ dmConfig = testResult.dmConfig;
183
+ await stepDeliveryConfirmation({ log, stage: "notify" });
184
+
185
+ let codexEnabled = false;
186
+ let claudeEnabled = false;
187
+ if (!startFromTest) {
188
+ printStep(7, "Codex/Claude の設定を行います");
189
+ console.log(
190
+ "SlackLocalVibeはSlackでスレッド返信することで、resumeコマンドを用いて各種CLIツールにメッセージを送信することができます。"
191
+ );
192
+ console.log(
193
+ "ユーザー設定ではなくプロジェクト毎の設定などを行いたい場合は手動で設定してください。"
194
+ );
195
+ if (useCodex) {
196
+ console.log("- Codex: notify に `slacklocalvibe notify --tool codex` を追加");
197
+ }
198
+ if (useClaude) {
199
+ console.log("- Claude: Stop hook に `slacklocalvibe notify --tool claude` を追加");
200
+ }
201
+
202
+ codexEnabled = useCodex ? await stepCodexConfig({ log }) : false;
203
+ claudeEnabled = useClaude ? await stepClaudeConfig({ log }) : false;
204
+
205
+ printStep(8, "Slack からの返信に対応します");
206
+ await stepReplySetup({ log });
207
+ } else {
208
+ codexEnabled = fs.existsSync(codexConfigPath());
209
+ claudeEnabled = fs.existsSync(claudeSettingsPath());
210
+ }
211
+
212
+ printStep(9, "CLI完了時のSlack通知をテストします");
213
+ console.log("テスト通知のために設定を一時保存します。終了した場合は元に戻します。");
214
+ stageConfigForTests({
215
+ log,
216
+ config: {
217
+ slack: {
218
+ bot_token: botToken,
219
+ app_token: appToken,
220
+ },
221
+ destinations: {
222
+ dm: {
223
+ enabled: dmConfig.enabled,
224
+ target_user_id: dmConfig.targetUserId,
225
+ },
226
+ },
227
+ features: {
228
+ reply_resume: true,
229
+ launchd_enabled: false,
230
+ },
231
+ },
232
+ });
233
+ stagedConfigWritten = true;
234
+ await stepReplyTest({ log, targets: selectedTools });
235
+ await stepDeliveryConfirmation({ log, stage: "reply" });
236
+
237
+ printStep(10, "Slack スレッドに返信してみよう");
238
+ await stepReplyThreadConfirmation({ log });
239
+ const replyConfig = {
240
+ enabled: true,
241
+ appToken,
242
+ };
243
+
244
+ let launchdEnabled = false;
245
+ if (replyConfig.enabled) {
246
+ printStep(11, "launchd の自動起動を設定します");
247
+ launchdEnabled = await stepLaunchd({ log });
248
+ }
249
+
250
+ const finalConfig = {
251
+ slack: {
252
+ bot_token: botToken,
253
+ app_token: replyConfig.enabled ? replyConfig.appToken : "",
254
+ },
255
+ destinations: {
256
+ dm: {
257
+ enabled: dmConfig.enabled,
258
+ target_user_id: dmConfig.targetUserId,
259
+ },
260
+ },
261
+ features: {
262
+ reply_resume: replyConfig.enabled,
263
+ launchd_enabled: replyConfig.enabled ? launchdEnabled : false,
264
+ },
265
+ };
266
+
267
+ writeConfig(finalConfig);
268
+ didSaveFinal = true;
269
+ log(LEVELS.SUCCRSS, "wizard.config_saved");
270
+
271
+ printStep(12, "完了サマリ");
272
+ printSummary({
273
+ dmConfig,
274
+ botToken,
275
+ codexEnabled,
276
+ claudeEnabled,
277
+ replyConfig,
278
+ launchdEnabled,
279
+ });
280
+ } catch (error) {
281
+ if (error instanceof UserExit) {
282
+ log(LEVELS.WARNING, "wizard.exit_without_save");
283
+ if (stagedConfigWritten && !didSaveFinal) {
284
+ try {
285
+ restoreConfigSnapshot(configSnapshot);
286
+ log(LEVELS.INFO, "wizard.config_restored");
287
+ } catch (restoreError) {
288
+ log(LEVELS.ERROR, "wizard.config_restore_failed", {
289
+ error: safeError(restoreError),
290
+ });
291
+ throw restoreError;
292
+ }
293
+ }
294
+ console.log("保存せずに終了しました。");
295
+ return;
296
+ }
297
+ log(LEVELS.ERROR, "wizard.failed", { error: safeError(error) });
298
+ if (stagedConfigWritten && !didSaveFinal) {
299
+ try {
300
+ restoreConfigSnapshot(configSnapshot);
301
+ log(LEVELS.INFO, "wizard.config_restored");
302
+ } catch (restoreError) {
303
+ log(LEVELS.ERROR, "wizard.config_restore_failed", {
304
+ error: safeError(restoreError),
305
+ });
306
+ throw restoreError;
307
+ }
308
+ }
309
+ console.log(formatError("エラーが発生しました。詳細はログを確認してください。"));
310
+ process.exitCode = 1;
311
+ }
312
+ }
313
+
314
+ async function stepDmDestination({ log }) {
315
+ console.log("Slackプロフィールで「メンバーIDをコピー」を押し、U... を取得してください。");
316
+ const targetUserId = await promptText({
317
+ message: "DM送信先のユーザーID(U...)を入力してください",
318
+ validate: (value) =>
319
+ value && value.startsWith("U") ? true : "U... 形式のユーザーIDを入力してください",
320
+ });
321
+
322
+ log(LEVELS.SUCCRSS, "wizard.dm_set");
323
+ return { enabled: true, targetUserId };
324
+ }
325
+
326
+ async function stepSelectTools({ log }) {
327
+ while (true) {
328
+ printStep(1, "対応するCLIを選択してください");
329
+ const selected = await promptMultiSelect({
330
+ message: "対応するCLIを選択してください",
331
+ choices: [
332
+ { title: "Codex", value: "codex", selected: true },
333
+ { title: "Claude", value: "claude", selected: true },
334
+ ],
335
+ });
336
+ if (selected.length === 0) {
337
+ console.log(formatError("少なくとも1つ選択してください。"));
338
+ continue;
339
+ }
340
+ log(LEVELS.SUCCRSS, "wizard.tools_selected", { tools: selected });
341
+ return selected;
342
+ }
343
+ }
344
+
345
+ async function stepReplySetup({ log }) {
346
+ console.log("Slack からの返信を受け取るには slacklocalvibe コマンドが必要です。");
347
+ while (true) {
348
+ const choice = await promptSelect({
349
+ message: "次の操作を選んでください",
350
+ choices: [
351
+ { title: "npm i -g slacklocalvibe で登録する(必須)", value: "install" },
352
+ { title: "終了(保存せず終了)", value: "exit" },
353
+ ],
354
+ initial: 0,
355
+ });
356
+ if (choice === "install") {
357
+ await ensureGlobalInstall({ log });
358
+ return;
359
+ }
360
+ throw new UserExit();
361
+ }
362
+ }
363
+
364
+ async function stepAppLevelToken() {
365
+ console.log("アプリ作成後に表示される「Basic Information」ページの少し下に");
366
+ console.log("「App-Level Tokens」があります。そこで App-Level Tokens を発行してください。");
367
+ console.log("スコープは次の3つをすべて選択してください:");
368
+ console.log("connections:write / authorizations:read / app_configurations:read");
369
+ const token = await promptToken({
370
+ message: "App-Level Tokens(xapp-...)を入力してください",
371
+ validate: (value) => {
372
+ const cleaned = value.trim().replace(/\s+/g, "");
373
+ return cleaned && cleaned.startsWith("xapp-")
374
+ ? true
375
+ : "xapp- で始まるトークンを入力してください";
376
+ },
377
+ });
378
+ return token.trim().replace(/\s+/g, "");
379
+ }
380
+
381
+ async function stepOAuthToken() {
382
+ console.log("左メニューの「Features > OAuth & Permissions」を開きます。");
383
+ console.log("「Install to Workspace(または Reinstall)」を実行して OAuth Tokens を発行します。");
384
+ console.log("発行された「Bot User OAuth Token(xoxb-...)」をコピーして入力します。");
385
+ const token = await promptToken({
386
+ message: "OAuth Tokens(xoxb-...)を入力してください",
387
+ validate: (value) => {
388
+ const cleaned = value.trim().replace(/\s+/g, "");
389
+ return cleaned && cleaned.startsWith("xoxb-")
390
+ ? true
391
+ : "xoxb- で始まるトークンを入力してください";
392
+ },
393
+ });
394
+ return token.trim().replace(/\s+/g, "");
395
+ }
396
+
397
+ async function stepNotifyTest({ log, botToken, dmConfig }) {
398
+ while (true) {
399
+ try {
400
+ await sendTestNotification({ log, botToken, dmConfig });
401
+ console.log(formatSuccess("通知テストに成功しました。"));
402
+ log(LEVELS.SUCCRSS, "wizard.notify_test_ok");
403
+ return { botToken, dmConfig };
404
+ } catch (error) {
405
+ log(LEVELS.ERROR, "wizard.notify_test_failed", { error: safeError(error) });
406
+ const errorCode = error?.code || error?.data?.error || error?.message || "unknown";
407
+ console.log(
408
+ formatError(`通知テストに失敗しました。エラーコード: ${errorCode}`)
409
+ );
410
+ const choice = await promptSelect({
411
+ message: "次の操作を選んでください",
412
+ choices: [
413
+ { title: "Bot Token を再入力する", value: "retry_token" },
414
+ { title: "送信先(DM)を見直す", value: "retry_dm" },
415
+ { title: "終了(保存せず終了)", value: "exit" },
416
+ ],
417
+ });
418
+ if (choice === "retry_token") {
419
+ botToken = await stepOAuthToken();
420
+ log(LEVELS.SUCCRSS, "wizard.bot_token_set");
421
+ continue;
422
+ }
423
+ if (choice === "retry_dm") {
424
+ dmConfig = await stepDmDestination({ log });
425
+ continue;
426
+ }
427
+ throw new UserExit();
428
+ }
429
+ }
430
+ }
431
+
432
+ async function sendTestNotification({ log, botToken, dmConfig }) {
433
+ const client = createWebClient(botToken);
434
+ const channel = await openDmChannel({
435
+ client,
436
+ log,
437
+ userId: dmConfig.targetUserId,
438
+ });
439
+ if (!channel) {
440
+ throw new Error("DM channel を取得できませんでした。");
441
+ }
442
+ await postMessage({
443
+ client,
444
+ log,
445
+ channel,
446
+ text: "SlackLocalVibe 通知テスト: OK",
447
+ });
448
+ }
449
+
450
+ async function stepCodexConfig({ log }) {
451
+ const exists = fs.existsSync(codexConfigPath());
452
+ const choice = await promptSelect({
453
+ message: "Codex(ユーザー設定)を更新しますか?",
454
+ choices: [
455
+ { title: "有効にする", value: "enable" },
456
+ { title: "いまはしない", value: "disable" },
457
+ ],
458
+ initial: exists ? 0 : 1,
459
+ });
460
+ if (choice === "disable") {
461
+ log(LEVELS.INFO, "wizard.codex_disabled");
462
+ return false;
463
+ }
464
+ while (true) {
465
+ try {
466
+ updateCodexNotify();
467
+ log(LEVELS.SUCCRSS, "wizard.codex_updated");
468
+ return true;
469
+ } catch (error) {
470
+ log(LEVELS.ERROR, "wizard.codex_update_failed", { error: safeError(error) });
471
+ const next = await promptSelect({
472
+ message: "Codex設定の更新に失敗しました。次の操作を選んでください",
473
+ choices: [
474
+ { title: "再試行", value: "retry" },
475
+ { title: "終了(保存せず終了)", value: "exit" },
476
+ ],
477
+ });
478
+ if (next === "retry") continue;
479
+ throw new UserExit();
480
+ }
481
+ }
482
+ }
483
+
484
+ async function stepClaudeConfig({ log }) {
485
+ const exists = fs.existsSync(claudeSettingsPath());
486
+ const choice = await promptSelect({
487
+ message: "Claude Code(ユーザー設定)を更新しますか?",
488
+ choices: [
489
+ { title: "有効にする", value: "enable" },
490
+ { title: "いまはしない", value: "disable" },
491
+ ],
492
+ initial: exists ? 0 : 1,
493
+ });
494
+ if (choice === "disable") {
495
+ log(LEVELS.INFO, "wizard.claude_disabled");
496
+ return false;
497
+ }
498
+ while (true) {
499
+ try {
500
+ updateClaudeStopHook();
501
+ log(LEVELS.SUCCRSS, "wizard.claude_updated");
502
+ return true;
503
+ } catch (error) {
504
+ log(LEVELS.ERROR, "wizard.claude_update_failed", { error: safeError(error) });
505
+ const next = await promptSelect({
506
+ message: "Claude設定の更新に失敗しました。次の操作を選んでください",
507
+ choices: [
508
+ { title: "再試行", value: "retry" },
509
+ { title: "終了(保存せず終了)", value: "exit" },
510
+ ],
511
+ });
512
+ if (next === "retry") continue;
513
+ throw new UserExit();
514
+ }
515
+ }
516
+ }
517
+
518
+ async function stepReplyTest({ log, targets }) {
519
+ if (!targets || targets.length === 0) {
520
+ throw new Error("テスト対象のCLIがないため返信テストを実行できません。");
521
+ }
522
+
523
+ await ensureDaemonStarted({ log });
524
+
525
+ for (const tool of targets) {
526
+ await runTestCommand({ log, tool });
527
+ }
528
+ }
529
+
530
+ async function stepDeliveryConfirmation({ log, stage }) {
531
+ const isNotify = stage === "notify";
532
+ const okLog = isNotify
533
+ ? "wizard.notify_delivery_confirmed"
534
+ : "wizard.reply_test_confirmed";
535
+ const logsLog = isNotify
536
+ ? "wizard.notify_delivery_logs_requested"
537
+ : "wizard.reply_test_logs_requested";
538
+ while (true) {
539
+ const choice = await promptSelect({
540
+ message: "Slack通知は届きましたか?",
541
+ choices: [
542
+ { title: "届いたので次へ進む", value: "ok" },
543
+ { title: "届いていないのでログを表示する", value: "logs" },
544
+ { title: "終了(保存せず終了)", value: "exit" },
545
+ ],
546
+ });
547
+ if (choice === "ok") {
548
+ log(LEVELS.SUCCRSS, okLog);
549
+ return;
550
+ }
551
+ if (choice === "logs") {
552
+ log(LEVELS.INFO, logsLog);
553
+ showLogFiles();
554
+ continue;
555
+ }
556
+ throw new UserExit();
557
+ }
558
+ }
559
+
560
+ async function stepReplyThreadConfirmation({ log }) {
561
+ console.log("Slack の通知スレッドに返信して resume が動くことを確認します。");
562
+ console.log("例: あなたはなにができる?");
563
+ while (true) {
564
+ const choice = await promptSelect({
565
+ message: "次の操作を選んでください",
566
+ choices: [
567
+ { title: "resume結果が新しいスレッドで届いた", value: "ok" },
568
+ { title: "届かなかった。ログ表示", value: "logs" },
569
+ { title: "終了", value: "exit" },
570
+ ],
571
+ });
572
+ if (choice === "ok") {
573
+ log(LEVELS.SUCCRSS, "wizard.reply_thread_confirmed");
574
+ return;
575
+ }
576
+ if (choice === "logs") {
577
+ log(LEVELS.INFO, "wizard.reply_thread_logs_requested");
578
+ showLogFiles();
579
+ continue;
580
+ }
581
+ throw new UserExit();
582
+ }
583
+ }
584
+
585
+ function startDaemonInBackground({ log }) {
586
+ try {
587
+ const binaryPath = resolveBinaryPath();
588
+ const ok = spawnDetached({
589
+ command: binaryPath,
590
+ args: ["daemon"],
591
+ cwd: process.cwd(),
592
+ log,
593
+ label: "wizard.daemon_autostart_failed",
594
+ });
595
+ if (ok) {
596
+ log(LEVELS.SUCCRSS, "wizard.daemon_autostarted", { mode: "global" });
597
+ return true;
598
+ }
599
+ } catch (error) {
600
+ log(LEVELS.ERROR, "wizard.daemon_autostart_failed", { error: safeError(error) });
601
+ }
602
+
603
+ log(LEVELS.ERROR, "wizard.daemon_autostart_unavailable");
604
+ return false;
605
+ }
606
+
607
+ async function ensureDaemonStarted({ log }) {
608
+ const started = startDaemonInBackground({ log });
609
+ if (started) return;
610
+ log(LEVELS.ERROR, "wizard.daemon_autostart_required");
611
+ console.log(formatError("daemon の自動起動に失敗しました。"));
612
+ throw new Error("daemon の自動起動に失敗しました。");
613
+ }
614
+
615
+ async function runTestCommand({ log, tool }) {
616
+ while (true) {
617
+ try {
618
+ const toolLabel = tool === "codex" ? "Codex" : "Claude";
619
+ const commandPath = resolveCommandPathStrict(tool === "codex" ? "codex" : "claude");
620
+ console.log(formatInfo(`${toolLabel} にテストコマンドを送信中...`));
621
+ console.log(`テストプロンプト:${TEST_PROMPT}`);
622
+ const result = await spawnCommand({
623
+ command: commandPath,
624
+ args:
625
+ tool === "codex"
626
+ ? ["exec", "--skip-git-repo-check", TEST_PROMPT]
627
+ : ["-p", TEST_PROMPT],
628
+ cwd: process.cwd(),
629
+ });
630
+ if (result.code !== 0) {
631
+ const detail = result.stderrText || result.stdoutText || "";
632
+ const err = new Error(`${toolLabel} テストコマンドに失敗しました。`);
633
+ err.detail = detail.trim();
634
+ err.code = result.code;
635
+ throw err;
636
+ }
637
+ log(LEVELS.SUCCRSS, "wizard.reply_test_ok", {
638
+ tool,
639
+ stdout_len: result.stdoutLen,
640
+ stderr_len: result.stderrLen,
641
+ });
642
+ console.log(
643
+ formatSuccess(
644
+ `${toolLabel} のテストコマンドを送信しました。Slack通知を確認してください。`
645
+ )
646
+ );
647
+ return;
648
+ } catch (error) {
649
+ log(LEVELS.ERROR, "wizard.reply_test_failed", { tool, error: safeError(error) });
650
+ const detail = error?.detail || error?.message || "unknown";
651
+ console.log(formatError(`${toolLabel} のテストに失敗しました。`));
652
+ console.log(formatError(`詳細: ${detail}`));
653
+ const next = await promptSelect({
654
+ message: "次の操作を選んでください",
655
+ choices: [
656
+ { title: "再試行", value: "retry" },
657
+ { title: "終了(保存せず終了)", value: "exit" },
658
+ ],
659
+ });
660
+ if (next === "retry") continue;
661
+ throw new UserExit();
662
+ }
663
+ }
664
+ }
665
+
666
+ async function stepLaunchd({ log }) {
667
+ console.log("daemon は待ち受け中心で、CPU/メモリ消費はごく小さいです。");
668
+ console.log("launchd に登録してログイン時に自動起動します。");
669
+
670
+ const firstChoice = await promptSelect({
671
+ message: "次の操作を選んでください",
672
+ choices: [
673
+ { title: "launchd に登録する(推奨)", value: "install" },
674
+ { title: "スキップして次へ進む", value: "skip" },
675
+ { title: "終了(保存せず終了)", value: "exit" },
676
+ ],
677
+ initial: 0,
678
+ });
679
+ if (firstChoice === "skip") {
680
+ log(LEVELS.INFO, "wizard.launchd_skipped");
681
+ return false;
682
+ }
683
+ if (firstChoice === "exit") {
684
+ throw new UserExit();
685
+ }
686
+
687
+ await ensureGlobalInstall({ log });
688
+
689
+ while (true) {
690
+ try {
691
+ installLaunchd();
692
+ log(LEVELS.SUCCRSS, "wizard.launchd_installed");
693
+ return true;
694
+ } catch (error) {
695
+ log(LEVELS.ERROR, "wizard.launchd_failed", { error: safeError(error) });
696
+ console.log(formatError("launchd 登録に失敗しました。"));
697
+ const detail =
698
+ error?.detail ||
699
+ error?.stderrText ||
700
+ error?.stdoutText ||
701
+ error?.message ||
702
+ "unknown";
703
+ if (detail) {
704
+ console.log(formatError(detail));
705
+ }
706
+ const next = await promptSelect({
707
+ message: "次の操作を選んでください",
708
+ choices: [
709
+ { title: "再試行", value: "retry" },
710
+ { title: "スキップして次へ進む", value: "skip" },
711
+ { title: "終了(保存せず終了)", value: "exit" },
712
+ ],
713
+ });
714
+ if (next === "retry") continue;
715
+ if (next === "skip") {
716
+ log(LEVELS.WARNING, "wizard.launchd_skipped_after_error");
717
+ return false;
718
+ }
719
+ throw new UserExit();
720
+ }
721
+ }
722
+ }
723
+
724
+ function printSummary({
725
+ dmConfig,
726
+ botToken,
727
+ codexEnabled,
728
+ claudeEnabled,
729
+ replyConfig,
730
+ launchdEnabled,
731
+ }) {
732
+ console.log("\n完了サマリ");
733
+ console.log(`- 設定ファイル: ${configPath()}`);
734
+ console.log(`- 送信先: DM (${mask(dmConfig.targetUserId)})`);
735
+ console.log(`- 通知: ${botToken ? "ON" : "OFF"}`);
736
+ console.log(`- Codex設定: ${codexEnabled ? "反映済み" : "未反映"}`);
737
+ console.log(`- Claude設定: ${claudeEnabled ? "反映済み" : "未反映"}`);
738
+ console.log(`- 返信: ${replyConfig.enabled ? "ON" : "OFF"}`);
739
+ console.log(`- 常駐: ${launchdEnabled ? "ON" : "OFF"}`);
740
+ console.log("\nでは、CodexやClaudeに依頼をしてみましょう。");
741
+ console.log(
742
+ "Slackに通知が来て、返信すればそこから会話を続けることができます。"
743
+ );
744
+ console.log(`\n${bannerLines().join("\n")}\n`);
745
+ }
746
+
747
+ function mask(value) {
748
+ if (!value) return "";
749
+ if (value.length <= 6) return `${value.slice(0, 2)}***`;
750
+ return `${value.slice(0, 4)}...${value.slice(-2)}`;
751
+ }
752
+
753
+ async function offerSlackAppLinks({ log }) {
754
+ await copyManifestToClipboard({ log });
755
+ console.log("Slack Apps 新規作成で「From a manifest(YAML)」を選び、貼り付けて作成してください。");
756
+
757
+ const choice = await promptSelect({
758
+ message: "次へ進む",
759
+ choices: [{ title: "Slack Apps 新規作成を開く", value: "new" }],
760
+ });
761
+ if (choice === "new") {
762
+ openUrl(SLACK_NEW_APP_URL);
763
+ }
764
+ }
765
+
766
+ function openUrl(url) {
767
+ const openPath = resolveCommandPathStrict("open");
768
+ const result = spawnSync(openPath, [url], {
769
+ stdio: "pipe",
770
+ env: process.env,
771
+ });
772
+ if (result.status !== 0) {
773
+ const stderrText = (result.stderr || "").toString("utf8").trim();
774
+ const stdoutText = (result.stdout || "").toString("utf8").trim();
775
+ const detail = stderrText || stdoutText || "";
776
+ const err = new Error("URL を開けませんでした。");
777
+ err.detail = detail;
778
+ throw err;
779
+ }
780
+ }
781
+
782
+ function bannerLines() {
783
+ return [
784
+ " _____ _ _ _ ___ ___ _ ",
785
+ " / ____| | | | | | | \\ \\ / (_) | ",
786
+ "| (___ | | __ _ ___| | _| | ___ ___ __ _| |\\ \\ / / _| |__ ___ ",
787
+ " \\___ \\| |/ _` |/ __| |/ / | / _ \\ / __/ _` | | \\ \\/ / | | '_ \\ / _ \\",
788
+ " ____) | | (_| | (__| <| |___| (_) | (_| (_| | | \\ / | | |_) | __/",
789
+ "|_____/|_|\\__,_|\\___|_|\\_\\_____/\\___/ \\___\\__,_|_| \\/ |_|_.__/ \\___|",
790
+ ];
791
+ }
792
+
793
+ function printBanner() {
794
+ console.log(`\n\n\n${bannerLines().join("\n")}\n\n\n`);
795
+ }
796
+
797
+ function printStep(step, title) {
798
+ console.log("\n----------------------------------------");
799
+ console.log(formatStepTitle(`[STEP ${step}/${TOTAL_STEPS}] ${title}`));
800
+ }
801
+
802
+ async function promptSelect({ message, choices, initial = 0 }) {
803
+ const response = await prompts(
804
+ {
805
+ type: "select",
806
+ name: "value",
807
+ message,
808
+ choices,
809
+ initial,
810
+ instructions: false,
811
+ },
812
+ { onCancel: () => { throw new UserExit(); } }
813
+ );
814
+ return response.value;
815
+ }
816
+
817
+ async function promptMultiSelect({ message, choices }) {
818
+ console.log("操作方法: ↑/↓ で選択、space で切替、Enter で確定");
819
+ const response = await prompts(
820
+ {
821
+ type: "multiselect",
822
+ name: "value",
823
+ message,
824
+ choices,
825
+ instructions: false,
826
+ },
827
+ { onCancel: () => { throw new UserExit(); } }
828
+ );
829
+ return response.value || [];
830
+ }
831
+
832
+ async function promptText({ message, validate }) {
833
+ return promptLine({
834
+ message,
835
+ validate,
836
+ normalize: (value) => value.trim(),
837
+ });
838
+ }
839
+
840
+ async function promptToken({ message, validate }) {
841
+ return promptLine({
842
+ message,
843
+ validate,
844
+ normalize: (value) => value.trim().replace(/\s+/g, ""),
845
+ });
846
+ }
847
+
848
+ // promptPassword uses readline to echo input and avoid multi-line paste artifacts.
849
+
850
+ function spawnCommand({ command, args, cwd }) {
851
+ return new Promise((resolve, reject) => {
852
+ const child = spawn(command, args, {
853
+ cwd,
854
+ env: { ...process.env, PATH: defaultPathEnv() },
855
+ stdio: ["ignore", "pipe", "pipe"],
856
+ });
857
+ let stdoutLen = 0;
858
+ let stderrLen = 0;
859
+ let stdoutText = "";
860
+ let stderrText = "";
861
+ const limit = 4000;
862
+ child.stdout.on("data", (chunk) => {
863
+ stdoutLen += chunk.length;
864
+ if (stdoutText.length < limit) {
865
+ stdoutText += chunk.toString("utf8").slice(0, limit - stdoutText.length);
866
+ }
867
+ });
868
+ child.stderr.on("data", (chunk) => {
869
+ stderrLen += chunk.length;
870
+ if (stderrText.length < limit) {
871
+ stderrText += chunk.toString("utf8").slice(0, limit - stderrText.length);
872
+ }
873
+ });
874
+ child.on("error", (error) => reject(error));
875
+ child.on("close", (code) =>
876
+ resolve({ code, stdoutLen, stderrLen, stdoutText, stderrText })
877
+ );
878
+ });
879
+ }
880
+
881
+ function spawnDetached({ command, args, cwd, log, label }) {
882
+ try {
883
+ const child = spawn(command, args, {
884
+ cwd,
885
+ env: { ...process.env, PATH: defaultPathEnv() },
886
+ stdio: "ignore",
887
+ detached: true,
888
+ });
889
+ child.on("error", (error) => {
890
+ log(LEVELS.ERROR, label, { error: safeError(error) });
891
+ });
892
+ child.unref();
893
+ return true;
894
+ } catch (error) {
895
+ log(LEVELS.ERROR, label, { error: safeError(error) });
896
+ return false;
897
+ }
898
+ }
899
+
900
+ async function copyManifestToClipboard({ log }) {
901
+ while (true) {
902
+ try {
903
+ const pbcopyPath = resolveCommandPathStrict("pbcopy");
904
+ const result = await spawnCommandWithInput({
905
+ command: pbcopyPath,
906
+ args: [],
907
+ cwd: process.cwd(),
908
+ input: SLACK_MANIFEST,
909
+ });
910
+ if (result.code !== 0) {
911
+ throw new Error("pbcopy に失敗しました。");
912
+ }
913
+ log(LEVELS.SUCCRSS, "wizard.manifest_copied");
914
+ return;
915
+ } catch (error) {
916
+ log(LEVELS.ERROR, "wizard.manifest_copy_failed", { error: safeError(error) });
917
+ console.log("manifest のコピーに失敗しました。");
918
+ const next = await promptSelect({
919
+ message: "次の操作を選んでください",
920
+ choices: [
921
+ { title: "再試行", value: "retry" },
922
+ { title: "終了(保存せず終了)", value: "exit" },
923
+ ],
924
+ });
925
+ if (next === "retry") continue;
926
+ throw new UserExit();
927
+ }
928
+ }
929
+ }
930
+
931
+ function spawnCommandWithInput({ command, args, cwd, input }) {
932
+ return new Promise((resolve, reject) => {
933
+ const child = spawn(command, args, {
934
+ cwd,
935
+ env: { ...process.env, PATH: defaultPathEnv() },
936
+ stdio: ["pipe", "pipe", "pipe"],
937
+ });
938
+ let stdoutLen = 0;
939
+ let stderrLen = 0;
940
+ child.stdout.on("data", (chunk) => {
941
+ stdoutLen += chunk.length;
942
+ });
943
+ child.stderr.on("data", (chunk) => {
944
+ stderrLen += chunk.length;
945
+ });
946
+ child.on("error", (error) => reject(error));
947
+ child.on("close", (code) => resolve({ code, stdoutLen, stderrLen }));
948
+ child.stdin.write(input || "");
949
+ child.stdin.end();
950
+ });
951
+ }
952
+
953
+ function captureConfigSnapshot() {
954
+ const filePath = configPath();
955
+ if (!fs.existsSync(filePath)) {
956
+ return { filePath, raw: null };
957
+ }
958
+ return { filePath, raw: fs.readFileSync(filePath, "utf8") };
959
+ }
960
+
961
+ function restoreConfigSnapshot(snapshot) {
962
+ if (!snapshot) return;
963
+ const dir = path.dirname(snapshot.filePath);
964
+ fs.mkdirSync(dir, { recursive: true });
965
+ if (snapshot.raw === null) {
966
+ if (fs.existsSync(snapshot.filePath)) {
967
+ fs.unlinkSync(snapshot.filePath);
968
+ }
969
+ return;
970
+ }
971
+ fs.writeFileSync(snapshot.filePath, snapshot.raw, "utf8");
972
+ try {
973
+ fs.chmodSync(snapshot.filePath, 0o600);
974
+ } catch (error) {
975
+ const err = new Error(
976
+ `設定ファイルの権限変更に失敗しました: ${snapshot.filePath}`
977
+ );
978
+ err.cause = error;
979
+ throw err;
980
+ }
981
+ }
982
+
983
+ function stageConfigForTests({ config, log }) {
984
+ writeConfig(config);
985
+ log(LEVELS.SUCCRSS, "wizard.config_staged");
986
+ }
987
+
988
+ function resetStoredConfig({ log }) {
989
+ const configFile = configPath();
990
+ const routesFile = routesPath();
991
+ const beforeConfig = fs.existsSync(configFile);
992
+ const beforeRoutes = fs.existsSync(routesFile);
993
+ if (beforeConfig) {
994
+ fs.unlinkSync(configFile);
995
+ }
996
+ if (beforeRoutes) {
997
+ fs.unlinkSync(routesFile);
998
+ }
999
+ log(LEVELS.SUCCRSS, "wizard.config_reset", {
1000
+ config_removed: beforeConfig,
1001
+ routes_removed: beforeRoutes,
1002
+ });
1003
+ console.log(formatSuccess("既存設定をリセットしました。"));
1004
+ }
1005
+
1006
+ function showLogFiles() {
1007
+ const targets = [
1008
+ { label: "notify", path: notifyLogPath() },
1009
+ { label: "daemon", path: daemonLogPath() },
1010
+ { label: "wizard", path: wizardLogPath() },
1011
+ ];
1012
+ console.log(formatInfo("ログの場所:"));
1013
+ for (const target of targets) {
1014
+ console.log(`- ${target.label}: ${target.path}`);
1015
+ }
1016
+ for (const target of targets) {
1017
+ console.log(formatInfo(`----- ${target.label} (${target.path}) -----`));
1018
+ if (!fs.existsSync(target.path)) {
1019
+ console.log(formatError("ログが見つかりません。"));
1020
+ continue;
1021
+ }
1022
+ const content = fs.readFileSync(target.path, "utf8");
1023
+ if (!content.trim()) {
1024
+ console.log("(空)");
1025
+ continue;
1026
+ }
1027
+ console.log(content);
1028
+ }
1029
+ }
1030
+
1031
+ async function ensureGlobalInstall({ log }) {
1032
+ while (true) {
1033
+ try {
1034
+ const installTarget = "slacklocalvibe";
1035
+ log(LEVELS.INFO, "wizard.npm_global_target", {
1036
+ target: "registry",
1037
+ });
1038
+ const npmPath = resolveCommandPathStrict("npm");
1039
+ const result = await spawnCommand({
1040
+ command: npmPath,
1041
+ args: ["install", "-g", installTarget],
1042
+ cwd: process.cwd(),
1043
+ });
1044
+ if (result.code !== 0) {
1045
+ const detail = result.stderrText || result.stdoutText || "";
1046
+ const err = new Error("npm install -g slacklocalvibe に失敗しました。");
1047
+ err.detail = detail.trim();
1048
+ throw err;
1049
+ }
1050
+ log(LEVELS.SUCCRSS, "wizard.npm_global_ok", {
1051
+ stdout_len: result.stdoutLen,
1052
+ stderr_len: result.stderrLen,
1053
+ });
1054
+ return;
1055
+ } catch (error) {
1056
+ log(LEVELS.ERROR, "wizard.npm_global_failed", { error: safeError(error) });
1057
+ const detail = error?.detail || error?.message || "unknown";
1058
+ console.log(formatError("npm -g インストールに失敗しました。"));
1059
+ console.log(formatError(`詳細: ${detail}`));
1060
+ const next = await promptSelect({
1061
+ message: "次の操作を選んでください",
1062
+ choices: [
1063
+ { title: "再試行", value: "retry" },
1064
+ { title: "終了(保存せず終了)", value: "exit" },
1065
+ ],
1066
+ });
1067
+ if (next === "retry") continue;
1068
+ throw new UserExit();
1069
+ }
1070
+ }
1071
+ }
1072
+
1073
+ async function promptPassword({ message, validate }) {
1074
+ while (true) {
1075
+ const value = await promptVisible({ message });
1076
+ const cleaned = value.trim().replace(/\s+/g, "");
1077
+ const result = validate ? validate(cleaned) : true;
1078
+ if (result === true) {
1079
+ return cleaned;
1080
+ }
1081
+ console.log(typeof result === "string" ? result : "入力が不正です。");
1082
+ }
1083
+ }
1084
+
1085
+ async function promptLine({ message, validate, normalize }) {
1086
+ while (true) {
1087
+ const value = await promptVisible({ message });
1088
+ const normalized = normalize ? normalize(value) : value;
1089
+ const cleaned = String(normalized ?? "").trim();
1090
+ const result = validate ? validate(cleaned) : true;
1091
+ if (result === true) {
1092
+ return cleaned;
1093
+ }
1094
+ console.log(typeof result === "string" ? result : "入力が不正です。");
1095
+ }
1096
+ }
1097
+
1098
+ function promptVisible({ message }) {
1099
+ return new Promise((resolve, reject) => {
1100
+ const rl = readline.createInterface({
1101
+ input: process.stdin,
1102
+ output: process.stdout,
1103
+ terminal: true,
1104
+ });
1105
+ rl.question(`${message} `, (answer) => {
1106
+ rl.history = rl.history.slice(1);
1107
+ rl.close();
1108
+ resolve(answer || "");
1109
+ });
1110
+ rl.on("SIGINT", () => {
1111
+ rl.close();
1112
+ reject(new UserExit());
1113
+ });
1114
+ });
1115
+ }
1116
+
1117
+ function styleText(text, { color = "", bold = false } = {}) {
1118
+ if (!USE_COLOR) return text;
1119
+ const codes = [];
1120
+ if (bold) codes.push(ANSI.bold);
1121
+ if (color) codes.push(color);
1122
+ return `${codes.join("")}${text}${ANSI.reset}`;
1123
+ }
1124
+
1125
+ function formatStepTitle(text) {
1126
+ return styleText(text, { color: ANSI.cyan, bold: true });
1127
+ }
1128
+
1129
+ function formatSuccess(text) {
1130
+ return styleText(text, { color: ANSI.green, bold: true });
1131
+ }
1132
+
1133
+ function formatError(text) {
1134
+ return styleText(text, { color: ANSI.red, bold: true });
1135
+ }
1136
+
1137
+ function formatInfo(text) {
1138
+ return styleText(text, { color: ANSI.yellow, bold: true });
1139
+ }
1140
+
1141
+ module.exports = {
1142
+ runWizard,
1143
+ };