@shimatoworks/stw-agent 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -35,8 +35,28 @@ set -a && source ~/.config/tamai/stw-agent.env && set +a
35
35
  stw-agent --help
36
36
  ```
37
37
 
38
- 動詞は `projects list` / `projects context` / `slack channels` / `slack messages` /
39
- `slack next` の 5 つ。正確なオプションは常に `--help` を見る(ここに写経しない)。
38
+ 動詞は `doctor` / `projects list` / `projects context` / `slack channels` /
39
+ `slack messages` / `slack next` の 6 つ。正確なオプションは常に `--help` を見る
40
+ (ここに写経しない)。
41
+
42
+ ### まず `doctor`
43
+
44
+ ```bash
45
+ stw-agent doctor
46
+ ```
47
+
48
+ token・読み取り API への到達・token の権限・周辺ツール(`ntn` / `gog` / `hub` /
49
+ `gh` / `bun`)の有無と認証を一度に見る。各項目は
50
+ **OK / 未導入 / 未認証 / 権限なし / 到達不可 / 不明** のどれかで、
51
+ 要対応には直し方が 1 行付く。`--json` あり。
52
+
53
+ **`不明` は失敗ではない**(確かめられなかっただけ)。終了コードは
54
+ **要対応があるときだけ 1** になるので、`stw-agent doctor && 次の作業` と繋げられる。
55
+ たとえば `hub` は認証を検証する手立てが無いので、設定があっても `不明` のままになる。
56
+
57
+ 秘密は出さない。**token は値も長さも先頭数文字も出さない**(出すのは env から読んだのか
58
+ 設定ファイルから読んだのかだけ)。接続先は origin だけを出し、`STW_API_BASE` に
59
+ 資格情報(`user:password@`)が入っていれば**要求を投げる前に断る**。
40
60
 
41
61
  既定の出力は 1 行要約で、メッセージの本文は先頭だけを出す。
42
62
  会社の内部のやり取りが端末のログや transcript へ機械的に積み上がるのを避けるため。
@@ -51,6 +71,16 @@ stw-agent --help
51
71
  `stw-manage` の `docs/guides/案件コンテキストの集め方.md`
52
72
  - なぜこの形なのか・過去の判断: `stw-manage` の `docs/records/235-agent-read-api.md`
53
73
 
74
+ ## 変更履歴
75
+
76
+ - **0.2.0** — `stw-agent doctor` を追加(token・API 到達・権限・周辺ツールの一括確認)。
77
+ `STW_API_BASE` に資格情報が入っていれば要求前に拒否し、表示は origin だけにする。
78
+ `slack channels` の既定出力に、サーバが返すようになれば保存範囲(最古〜最新・件数)と
79
+ 到達状態(上限到達/さかのぼり中/読み切り/未走査)・最後の失敗が出る
80
+ - **0.1.1** — `npm i -g` した `stw-agent`(symlink)が無出力で終了する不具合を修正。
81
+ 実体パスでの直接実行は 0.1.0 でも動いていた
82
+ - 0.1.0 — 最初の公開
83
+
54
84
  ## ライセンス
55
85
 
56
86
  `UNLICENSED`(社内利用)。npm の scope は公開だが、利用許諾を与えるものではない。
package/bin/stw-agent.mjs CHANGED
@@ -31,10 +31,13 @@
31
31
  // 契約が 2 か所に増えて食い違う。**CLI が自分で断るのは「使い方」だけ**
32
32
  // (`--project` と `--channel` の排他など、要求を組み立てられない場合)。
33
33
 
34
+ import { execFile } from 'node:child_process';
35
+ import { accessSync, realpathSync } from 'node:fs';
34
36
  import { readFile } from 'node:fs/promises';
35
37
  import os from 'node:os';
36
38
  import path from 'node:path';
37
39
  import { pathToFileURL } from 'node:url';
40
+ import { promisify } from 'node:util';
38
41
 
39
42
  /** 接続先の既定。看板の仕様値。別環境は `STW_API_BASE` で上書きする。 */
40
43
  export const DEFAULT_API_BASE = 'https://api.shimatoworks.jp';
@@ -55,6 +58,7 @@ export const USAGE = `使い方:
55
58
  [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--q <text>]
56
59
  [--limit <n>] [--thread <ts>] [--cursor <c>] [--json]
57
60
  stw-agent slack next <continuationQuery> [--json]
61
+ stw-agent doctor [--json]
58
62
 
59
63
  環境変数:
60
64
  STW_AGENT_TOKEN 読み取り token(必須)。~/.config/tamai/stw-agent.env から自動で読む
@@ -62,6 +66,8 @@ export const USAGE = `使い方:
62
66
 
63
67
  set -a && source ~/.config/tamai/stw-agent.env && set +a
64
68
 
69
+ 困ったらまず stw-agent doctor(token・API 到達・ntn / gog / hub / gh / bun をまとめて見る)。
70
+
65
71
  読み方の約束・許可の範囲・エラーの意味は stw-manage の
66
72
  docs/guides/案件コンテキストの集め方.md を見る。
67
73
  既定の出力は 1 行要約(本文は先頭だけ)。全文が要るときだけ --json を付ける。
@@ -192,7 +198,55 @@ export async function configFromEnv(env, readFileImpl = readFile) {
192
198
  );
193
199
  }
194
200
 
195
- return { token, baseUrl: (baseUrl || DEFAULT_API_BASE).replace(/\/+$/, '') };
201
+ // **接続先を検証してから返す。** token より後に見るのは、doctor
202
+ // 「token が無い」と「接続先の設定が壊れている」を取り違えないため。
203
+ return { token, baseUrl: assertSafeBaseUrl(baseUrl || DEFAULT_API_BASE) };
204
+ }
205
+
206
+ /**
207
+ * 接続先 URL の検査。**資格情報入りの URL は要求を投げる前に断る**(R1 codex2 High 1)。
208
+ *
209
+ * `https://user:password@host` のような形は、`fetch` が拒否しなくても
210
+ * エラー経路や診断の表示に丸ごと出てしまう。受け取らないのが一番確実である。
211
+ * query と hash も接続先には要らないので落とす(値が載っていても運ばない)。
212
+ */
213
+ export function assertSafeBaseUrl(raw) {
214
+ const trimmed = String(raw).replace(/\/+$/, '');
215
+ let url;
216
+ try {
217
+ url = new URL(trimmed);
218
+ } catch {
219
+ throw cliError(
220
+ 'STW_API_BASE が URL として読めません(https://host の形で指定してください)。',
221
+ 'base_url_invalid',
222
+ );
223
+ }
224
+ if (url.username !== '' || url.password !== '') {
225
+ // **値そのものは出さない。** 「どこが悪いか」だけを言う。
226
+ throw cliError(
227
+ 'STW_API_BASE に資格情報(user:password@)を含めないでください。' +
228
+ 'token は STW_AGENT_TOKEN で渡します。',
229
+ 'base_url_credentials',
230
+ );
231
+ }
232
+ if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
233
+ throw cliError('STW_API_BASE は https を使ってください。', 'base_url_insecure');
234
+ }
235
+ return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
236
+ }
237
+
238
+ /**
239
+ * 表示してよい接続先の文字列。**origin だけ**(userinfo / query / hash は出さない)。
240
+ *
241
+ * `configFromEnv()` を通っていれば資格情報は入っていないが、表示側でも重ねて絞る
242
+ * ——「設定を読む経路が増えたときに、表示だけが古い前提のまま残る」を防ぐ。
243
+ */
244
+ export function safeBaseLabel(baseUrl) {
245
+ try {
246
+ return new URL(String(baseUrl)).origin;
247
+ } catch {
248
+ return '接続先設定が不正';
249
+ }
196
250
  }
197
251
 
198
252
  // ---------------------------------------------------------------------------
@@ -349,15 +403,83 @@ export function formatProjectLines(response) {
349
403
  * (guide §5)。読み手が判断できる最小限として、最終同期の時刻・失敗の有無・
350
404
  * さかのぼりの途中かを出す。
351
405
  */
352
- export function formatSyncState(channel) {
406
+ export function formatSyncState(channel, { includeBackfill = true } = {}) {
353
407
  if (channel.syncHealth === 'never_synced') return '同期 まだ(未取り込み)';
354
408
  const at = channel.lastSyncAt ? formatJst(channel.lastSyncAt) : '不明';
355
409
  const failed = channel.syncHealth === 'error' ? '・失敗あり' : '';
356
410
  // 古い側を読み切っていない = 過去の発言がまだ揃っていない。
357
- const backfill = channel.backfillDone === false ? '・さかのぼり中' : '';
411
+ // **到達状態を別の列で出すときは重ねない**(`includeBackfill: false`)。
412
+ const backfill = includeBackfill && channel.backfillDone === false ? '・さかのぼり中' : '';
358
413
  return `同期 ${at}${failed}${backfill}`;
359
414
  }
360
415
 
416
+ /**
417
+ * 保存済みの範囲(最古〜最新・件数)。
418
+ *
419
+ * 値は `channel.coverage`(shared の `SlackSyncCoverage`)から読む。
420
+ * **サーバがこの塊を返さないうちは何も出さない。** CLI は新しい版でも古い
421
+ * デプロイ先を相手にするので、無い項目を「0 件」や「不明」と書くと、
422
+ * 「まだ返していない」と「本当に空」が混ざる。
423
+ */
424
+ export function formatStoredRange(channel) {
425
+ const coverage = channel?.coverage;
426
+ if (!coverage) return '';
427
+
428
+ const { oldestStoredAt, newestStoredAt, storedMessageCount, storedThreadCount } = coverage;
429
+ const count = typeof storedMessageCount === 'number' ? storedMessageCount : null;
430
+ if (count === 0 || (!oldestStoredAt && !newestStoredAt)) {
431
+ return count === null ? '保存 なし' : `保存 ${count} 件`;
432
+ }
433
+ const from = oldestStoredAt ? formatJst(oldestStoredAt) : '?';
434
+ const to = newestStoredAt ? formatJst(newestStoredAt) : '?';
435
+ const messages = count === null ? '' : ` ${count} 件`;
436
+ // スレッドは「返信のある親の数」。0 のときは黙る(無い方が普通なので)。
437
+ const threads = typeof storedThreadCount === 'number' && storedThreadCount > 0
438
+ ? `(スレッド ${storedThreadCount})`
439
+ : '';
440
+ return `保存 ${from}〜${to}${messages}${threads}`;
441
+ }
442
+
443
+ /**
444
+ * どこまで遡れたか。
445
+ *
446
+ * 「全部読めた」を 1 つの真偽値に畳まない(看板 01M1V8PNF9HE6W288PCE8VJXRE)。
447
+ * フリープランの上限に当たって**それ以上は取れない**のか、まだ遡っている途中なのか、
448
+ * そもそも始まっていないのかは別のことである。
449
+ *
450
+ * `backfillDone` は `coverage` ではなく**チャンネル直下**(shared の互換フィールド)。
451
+ */
452
+ export function formatReachState(channel) {
453
+ const coverage = channel?.coverage;
454
+ if (!coverage) return '';
455
+
456
+ const { backfillTargetReachedAt, backfillStartedAt, scannedThrough } = coverage;
457
+ if (backfillTargetReachedAt) {
458
+ // 上限に当たった = これ以上は Slack 側に無い。ここが「読み切った」の意味。
459
+ return `到達 上限まで(${formatJst(backfillTargetReachedAt)})`;
460
+ }
461
+ if (channel.backfillDone === true) return '到達 読み切り';
462
+ if (backfillStartedAt || scannedThrough) {
463
+ const through = scannedThrough ? `(${formatJst(scannedThrough)} まで走査)` : '';
464
+ return `到達 さかのぼり中${through}`;
465
+ }
466
+ // **始まっていないものを「さかのぼり中」と言わない。** 走査の記録が 1 つも無い。
467
+ return '到達 未走査';
468
+ }
469
+
470
+ /**
471
+ * いま抱えている失敗(`coverage.lastFailure`)。
472
+ *
473
+ * **code だけを出す。** shared 側が `SLACK_SYNC_FAILURE_CODE_RE` で
474
+ * `snake_case` の短い token に絞っているので、利用者の値は入らない。
475
+ */
476
+ export function formatLastFailure(channel) {
477
+ const failure = channel?.coverage?.lastFailure;
478
+ if (!failure?.code) return '';
479
+ const at = failure.at ? `・${formatJst(failure.at)}` : '';
480
+ return `失敗 ${failure.code}${at}`;
481
+ }
482
+
361
483
  export function formatChannelLines(response) {
362
484
  const lines = (response.items ?? []).map((channel) => {
363
485
  const projects = channel.projectSlugs?.length ? channel.projectSlugs.join(',') : '-';
@@ -365,7 +487,12 @@ export function formatChannelLines(response) {
365
487
  const archived = channel.isArchived ? ' アーカイブ済み' : '';
366
488
  // bot が居ないチャンネルは本文が 1 件も取れない。件数 0 の理由を先に言う。
367
489
  const member = channel.isMember === false ? ' bot未参加' : '';
368
- return `${channel.channelId} #${channel.name} 案件=${projects}${shared}${archived}${member} ${formatSyncState(channel)}`;
490
+ // 保存範囲と到達状態はサーバが返したときだけ足す(古い API でも壊れない)。
491
+ const reach = formatReachState(channel);
492
+ const extra = [formatStoredRange(channel), reach, formatLastFailure(channel)].filter(Boolean);
493
+ const tail = extra.length ? ` ${extra.join(' ')}` : '';
494
+ const sync = formatSyncState(channel, { includeBackfill: reach === '' });
495
+ return `${channel.channelId} #${channel.name} 案件=${projects}${shared}${archived}${member} ${sync}${tail}`;
369
496
  });
370
497
  if (lines.length === 0) lines.push('(該当なし)');
371
498
  if (response.nextCursor) lines.push(`次: --cursor '${response.nextCursor}'`);
@@ -469,6 +596,486 @@ export function filterProjects(items, q) {
469
596
  );
470
597
  }
471
598
 
599
+ // ---------------------------------------------------------------------------
600
+ // doctor
601
+ // ---------------------------------------------------------------------------
602
+ //
603
+ // 「取得できない」理由が**未導入なのか・未認証なのか・権限が無いのか**を、
604
+ // 個別に確かめなくても一目で分かるようにする(Hermes の指摘・看板
605
+ // 01M1V8PNV2DVZ5EC7JBWE1Z19J)。
606
+ //
607
+ // ## 秘密を出さない
608
+ //
609
+ // token は値も長さも先頭数文字も出さない(先頭 4 文字は「どの token か」の特定に足りる)。
610
+ // 出すのは**どこから読んだか**(env か設定ファイルのパス)だけ。
611
+ // `gog` のアカウントは件数とドメインだけにする —— 「どの account 群で認証しているか」は
612
+ // 診断に要るが、メールアドレスそのものを doctor の出力へ並べる必要は無い。
613
+ //
614
+ // ## 判定できないことを OK にしない
615
+ //
616
+ // token が無ければ API の検査は**実行できない**ので `unknown`(不明)にする。
617
+ // 「未実施」を OK と書くと、doctor を通したのに動かない、が起きる。
618
+
619
+ /** 表示の語彙。看板の受け入れ条件どおり 6 つに固定する。 */
620
+ export const DOCTOR_LABELS = {
621
+ ok: 'OK',
622
+ missing: '未導入',
623
+ unauthenticated: '未認証',
624
+ forbidden: '権限なし',
625
+ unreachable: '到達不可',
626
+ unknown: '不明',
627
+ };
628
+
629
+ function check(id, label, status, detail, fix = '') {
630
+ return { id, label, status, detail, fix };
631
+ }
632
+
633
+ const execFileAsync = promisify(execFile);
634
+
635
+ /**
636
+ * 外部コマンドを 1 本走らせる。
637
+ *
638
+ * 返すのは `{ outcome, code, stdout }` だけで、**stderr は返さない**
639
+ * (認証エラーの本文に token やアカウントが載る CLI があるため)。
640
+ *
641
+ * `outcome` を 3 つに分けるのが要点(R1 codex2 Medium 2)。
642
+ *
643
+ * - `missing` … `ENOENT`。コマンドが無い
644
+ * - `exit` … **起動できて、終了コードが返った**。非 0 は「そのコマンドが断った」結果
645
+ * - `error` … タイムアウトや spawn の失敗。**何も判定できていない**
646
+ *
647
+ * これを混ぜていたので、`ETIMEDOUT` に「認証をやり直せ」と案内していた。
648
+ */
649
+ export async function probeCommand(runner, file, args, timeoutMs = 5000) {
650
+ try {
651
+ const { stdout } = await runner(file, args, { timeout: timeoutMs, encoding: 'utf8' });
652
+ return { outcome: 'exit', code: 0, stdout: String(stdout ?? '') };
653
+ } catch (error) {
654
+ if (error?.code === 'ENOENT') return { outcome: 'missing', code: null, stdout: '' };
655
+ // **数値の終了コード = 起動できて、そのコマンドが断った**(認証の判定に使える)。
656
+ // 文字列(`ETIMEDOUT` など)や killed = 実行できていない(何も判定できていない)。
657
+ if (typeof error?.code === 'number' && !error?.killed) {
658
+ return { outcome: 'exit', code: error.code, stdout: String(error?.stdout ?? '') };
659
+ }
660
+ return {
661
+ outcome: 'error',
662
+ code: error?.killed ? 'ETIMEDOUT' : (error?.code ?? 'unknown'),
663
+ stdout: '',
664
+ };
665
+ }
666
+ }
667
+
668
+ /**
669
+ * 「起動できなかった」ときの共通の見せ方。
670
+ *
671
+ * **未認証とは言わない。** 判定できていないので `到達不可` にして、
672
+ * 手で叩いて確かめる道を示す(R1 codex2 Medium 2)。
673
+ */
674
+ function unverifiedByError(id, label, command, probe) {
675
+ return check(
676
+ id,
677
+ label,
678
+ 'unreachable',
679
+ `${command} を実行できなかった(${probe.code})`,
680
+ `時間をおいて再実行する。続くなら手で ${command} を叩いて確かめる`,
681
+ );
682
+ }
683
+
684
+ /** `gog auth list` の出力 → 件数とドメイン(**アドレスそのものは持たない**)。 */
685
+ export function summarizeGogAccounts(stdout) {
686
+ const emails = String(stdout)
687
+ .split('\n')
688
+ .map((line) => line.split('\t')[0]?.trim())
689
+ .filter((value) => value && value.includes('@'));
690
+ const domains = [...new Set(emails.map((email) => email.split('@')[1]))];
691
+ return { count: emails.length, domains };
692
+ }
693
+
694
+ /** Node のメジャー版。`v22.23.2` → 22。 */
695
+ export function nodeMajor(version) {
696
+ const match = /^v?(\d+)\./.exec(String(version));
697
+ return match ? Number(match[1]) : null;
698
+ }
699
+
700
+ /** 実行環境(ホスト・ユーザー・OS・node)。 */
701
+ function checkEnvironment(env, platform) {
702
+ const user = env.USER || env.LOGNAME || '不明';
703
+ return check(
704
+ 'environment',
705
+ '実行環境',
706
+ 'ok',
707
+ `${platform.hostname} / ${user} / ${platform.type} ${platform.arch}`,
708
+ );
709
+ }
710
+
711
+ function checkNode(version) {
712
+ const major = nodeMajor(version);
713
+ if (major === null) return check('node', 'Node', 'unknown', String(version));
714
+ if (major < 20) {
715
+ return check(
716
+ 'node',
717
+ 'Node',
718
+ 'missing',
719
+ `${version}(20 以上が要る)`,
720
+ 'Node 20 以上へ上げる(mise なら mise use -g node@22)',
721
+ );
722
+ }
723
+ return check('node', 'Node', 'ok', String(version));
724
+ }
725
+
726
+ /**
727
+ * token の有無と**読み込み元**。値は出さない。
728
+ *
729
+ * 「env に入っている」と「設定ファイルから読んだ」を分けるのは、
730
+ * `source` を忘れているのか配布されていないのかで直し方が違うため。
731
+ */
732
+ function checkToken(config, envFile) {
733
+ if (!config) {
734
+ return check(
735
+ 'token',
736
+ 'STW_AGENT_TOKEN',
737
+ 'missing',
738
+ `env にも ${envFile} にも無い`,
739
+ `配布は たま(1Password の stw-manage / PJ開設 service token)。受け取ったら ${envFile} に置く`,
740
+ );
741
+ }
742
+ return check('token', 'STW_AGENT_TOKEN', 'ok', `読み込み元: ${config.source}`);
743
+ }
744
+
745
+ /** HTTP の結果 → 状態の語彙。 */
746
+ export function statusFromHttp(status) {
747
+ if (status === 200) return 'ok';
748
+ if (status === 401) return 'unauthenticated';
749
+ if (status === 403) return 'forbidden';
750
+ return 'unreachable';
751
+ }
752
+
753
+ /** API を 1 本叩いて `{ status, requestId }` を返す(doctor 用。例外にしない)。 */
754
+ async function probeApi(api, requestPath, query) {
755
+ try {
756
+ const payload = await api(requestPath, query);
757
+ return { status: 200, requestId: payload?.requestId ?? null };
758
+ } catch (error) {
759
+ if (error instanceof ApiError) {
760
+ return { status: error.status, requestId: error.payload?.requestId ?? null };
761
+ }
762
+ return { status: null, requestId: null, transport: error?.message ?? 'unknown' };
763
+ }
764
+ }
765
+
766
+ /**
767
+ * 検査を全部走らせて結果の配列を返す。
768
+ *
769
+ * 依存はすべて引数で受ける(テストから execFile と fetch を差し替えるため)。
770
+ */
771
+ export async function runDoctor({
772
+ env,
773
+ readFileImpl = readFile,
774
+ fetchImpl = globalThis.fetch,
775
+ sleepImpl = defaultSleep,
776
+ execFileImpl = execFileAsync,
777
+ platform = {
778
+ hostname: os.hostname(),
779
+ type: os.type(),
780
+ arch: os.arch(),
781
+ homedir: os.homedir(),
782
+ version: process.version,
783
+ },
784
+ fileExists = (target) => {
785
+ try {
786
+ accessSync(target);
787
+ return true;
788
+ } catch {
789
+ return false;
790
+ }
791
+ },
792
+ } = {}) {
793
+ const results = [];
794
+ const envFile = env.STW_AGENT_ENV_FILE || defaultEnvFilePath(platform.homedir);
795
+
796
+ results.push(checkEnvironment(env, platform));
797
+ results.push(checkNode(platform.version));
798
+
799
+ // token は configFromEnv と**同じ経路**で解決する(doctor だけ別の読み方をしない)。
800
+ // 失敗の理由は 2 通りあり、直し方が違うので区別する(R1 codex2 High 1)。
801
+ let config = null;
802
+ let configError = null;
803
+ try {
804
+ const resolved = await configFromEnv(env, readFileImpl);
805
+ config = {
806
+ ...resolved,
807
+ source: env.STW_AGENT_TOKEN?.trim() ? '環境変数 STW_AGENT_TOKEN' : envFile,
808
+ };
809
+ } catch (error) {
810
+ configError = error;
811
+ }
812
+
813
+ // 接続先の設定が悪いだけなら、token は解決できている(`configFromEnv` は
814
+ // token を先に見る)。**「token が無い」と混同しない。**
815
+ const baseUrlBroken = configError !== null && configError.code !== 'token_missing';
816
+ results.push(baseUrlBroken ? check('token', 'STW_AGENT_TOKEN', 'ok', '読み込み済み') : checkToken(config, envFile));
817
+
818
+ if (baseUrlBroken) {
819
+ // **例外 message をそのまま出す。** これは CLI が書いた固定文で、
820
+ // 設定値そのもの(=資格情報を含みうる)は入っていない。
821
+ const skip = 'STW_API_BASE を直してから doctor をやり直す';
822
+ results.push(check('api', '読み取り API', 'unreachable', configError.message.split('\n')[0], skip));
823
+ results.push(check('scope', 'token の権限', 'unknown', '接続先が不正なので未実施', skip));
824
+ } else if (!config) {
825
+ const skip = 'token を設定してから doctor をやり直す';
826
+ results.push(check('api', '読み取り API', 'unknown', 'token が無いので未実施', skip));
827
+ results.push(check('scope', 'token の権限', 'unknown', 'token が無いので未実施', skip));
828
+ } else {
829
+ const api = createAgentApi(config, fetchImpl, sleepImpl);
830
+ const projects = await probeApi(api, '/api/agent/projects', { limit: 1 });
831
+ // **origin だけを出す。** 設定に userinfo / query が付いていても運ばない。
832
+ const where = safeBaseLabel(config.baseUrl);
833
+ if (projects.status === null) {
834
+ results.push(
835
+ check('api', '読み取り API', 'unreachable', `${where} へ接続できない`, 'ネットワークと STW_API_BASE を確認する'),
836
+ );
837
+ results.push(check('scope', 'token の権限', 'unknown', 'API へ到達できないので未判定'));
838
+ } else {
839
+ const apiStatus = statusFromHttp(projects.status);
840
+ results.push(
841
+ check(
842
+ 'api',
843
+ '読み取り API',
844
+ apiStatus,
845
+ `HTTP ${projects.status} / requestId ${projects.requestId ?? '-'} / ${where}`,
846
+ apiStatus === 'ok' ? '' : doctorApiFix(projects.status),
847
+ ),
848
+ );
849
+
850
+ // scope は**応答から分かる範囲**で見る。200 なら持っている、403 なら無い。
851
+ // それ以外(401 や到達不可)は「不明」で、持っていないとは言い切らない。
852
+ const slack = await probeApi(api, '/api/agent/slack/channels', { limit: 1 });
853
+ const projectsScope = scopeVerdict(projects.status);
854
+ const slackScope = scopeVerdict(slack.status);
855
+ // **「不明」を「権限なし」に丸めない**(R1 codex2 Medium 3)。403 を見たときだけ
856
+ // 権限なし、全部 200 のときだけ OK、それ以外は不明。API 障害を権限の問題として
857
+ // 報告すると、直し方の案内が嘘になる。
858
+ const verdicts = [projectsScope.status, slackScope.status];
859
+ const scopeStatus = verdicts.includes('forbidden')
860
+ ? 'forbidden'
861
+ : verdicts.every((value) => value === 'ok')
862
+ ? 'ok'
863
+ : 'unknown';
864
+ // 判定できなかった側は理由まで出す(HTTP か、そもそも届かなかったのか)。
865
+ const slackWhy =
866
+ slackScope.status === 'unknown'
867
+ ? slack.status === null
868
+ ? '(接続できず)'
869
+ : `(HTTP ${slack.status})`
870
+ : '';
871
+ const detail = `方式 Bearer service token / projects:read=${projectsScope.label} / slack:read=${slackScope.label}${slackWhy}`;
872
+ results.push(
873
+ check(
874
+ 'scope',
875
+ 'token の権限',
876
+ scopeStatus,
877
+ detail,
878
+ slackScope.status === 'forbidden'
879
+ ? 'Slack を読む必要があるなら たま に slack:read と対象チャンネルの許可を頼む'
880
+ : scopeStatus === 'unknown'
881
+ ? '判定できなかった側は API が復旧してから確かめる'
882
+ : '',
883
+ ),
884
+ );
885
+ }
886
+ }
887
+
888
+ results.push(...(await checkTools(execFileImpl, env, platform, fileExists)));
889
+ return results;
890
+ }
891
+
892
+ function doctorApiFix(status) {
893
+ if (status === 401) return 'token が失効しているか値が違う。たま に再発行を頼む';
894
+ if (status === 403) return 'token に必要な scope が無い。たま に権限を頼む';
895
+ if (status === 404) return '読み取り機能が無効か、STW_API_BASE が違う';
896
+ return 'API 側の状態を確認する(時間をおいて再試行)';
897
+ }
898
+
899
+ function scopeVerdict(status) {
900
+ if (status === 200) return { status: 'ok', label: 'あり' };
901
+ if (status === 403) return { status: 'forbidden', label: '権限なし' };
902
+ return { status: 'unknown', label: '不明' };
903
+ }
904
+
905
+ /** 周辺ツール(ntn / gog / hub / gh / bun)。 */
906
+ async function checkTools(execFileImpl, env, platform, fileExists) {
907
+ const results = [];
908
+
909
+ const ntn = await probeCommand(execFileImpl, 'ntn', ['doctor']);
910
+ if (ntn.outcome === 'missing') {
911
+ results.push(
912
+ check('ntn', 'ntn(Notion CLI)', 'missing', 'コマンドが無い', 'npm i -g @notionhq/ntn か、Notion コネクタを使う'),
913
+ );
914
+ } else if (ntn.outcome === 'error') {
915
+ results.push(unverifiedByError('ntn', 'ntn(Notion CLI)', 'ntn doctor', ntn));
916
+ } else {
917
+ results.push(
918
+ ntn.code === 0
919
+ ? check('ntn', 'ntn(Notion CLI)', 'ok', 'ntn doctor 正常')
920
+ : check('ntn', 'ntn(Notion CLI)', 'unauthenticated', `ntn doctor が exit ${ntn.code}`, 'ntn の認証をやり直す(ntn doctor の指示に従う)'),
921
+ );
922
+ }
923
+
924
+ const gog = await probeCommand(execFileImpl, 'gog', ['auth', 'list']);
925
+ if (gog.outcome === 'missing') {
926
+ results.push(check('gog', 'gog(Google CLI)', 'missing', 'コマンドが無い', 'gog を入れる(brew install gog)'));
927
+ } else if (gog.outcome === 'error') {
928
+ results.push(unverifiedByError('gog', 'gog(Google CLI)', 'gog auth list', gog));
929
+ } else if (gog.code !== 0) {
930
+ results.push(check('gog', 'gog(Google CLI)', 'unauthenticated', `gog auth list が exit ${gog.code}`, 'gog auth login でアカウントを足す'));
931
+ } else {
932
+ const { count, domains } = summarizeGogAccounts(gog.stdout);
933
+ // **列挙できた = 資格情報が保存されている**、まで。いま使えるかは確かめていない
934
+ // (R1 codex2 Medium 2)。断定しない言い方にする。
935
+ results.push(
936
+ count === 0
937
+ ? check('gog', 'gog(Google CLI)', 'unauthenticated', 'アカウントが 1 件も無い', 'gog auth login でアカウントを足す')
938
+ : check('gog', 'gog(Google CLI)', 'ok', `登録 ${count} 件(${domains.join(', ')})/有効性は未検証`),
939
+ );
940
+ }
941
+
942
+ // **hub は `--version` を持たない**(未対応サブコマンドでも exit 0 になる)ので、
943
+ // 終了コードで認証を判定できない。かといって**env の存在だけで「認証済み」とも
944
+ // 言わない**(secret 欠落・失効・API 未到達を何も見ていない。R1 codex2 Medium 2)。
945
+ // 設定の有無だけを事実として出し、状態は「未検証」にする。
946
+ const hub = await probeCommand(execFileImpl, 'hub', []);
947
+ if (hub.outcome === 'missing') {
948
+ results.push(check('hub', 'hub(看板 CLI)', 'missing', 'コマンドが無い', 'hub を入れる(tools/hub.mjs か配布物)'));
949
+ } else if (hub.outcome === 'error') {
950
+ results.push(unverifiedByError('hub', 'hub(看板 CLI)', 'hub', hub));
951
+ } else {
952
+ const hubEnv = path.join(platform.homedir, '.config', 'tamai', 'hub-tasks.env');
953
+ const verify = 'hub task list --project stw-manage で実際に確認する';
954
+ if (env.TASKS_CLIENT_ID) {
955
+ results.push(check('hub', 'hub(看板 CLI)', 'unknown', '認証 env 読込済み・認証未検証', verify));
956
+ } else if (fileExists(hubEnv)) {
957
+ results.push(
958
+ check('hub', 'hub(看板 CLI)', 'unknown', `認証 env あり・未読込(${hubEnv})`, `set -a && source ${hubEnv} && set +a`),
959
+ );
960
+ } else {
961
+ results.push(check('hub', 'hub(看板 CLI)', 'unauthenticated', '認証設定が見当たらない', `set -a && source ${hubEnv} && set +a`));
962
+ }
963
+ }
964
+
965
+ const gh = await probeCommand(execFileImpl, 'gh', ['auth', 'status']);
966
+ if (gh.outcome === 'missing') {
967
+ results.push(check('gh', 'gh(GitHub CLI)', 'missing', 'コマンドが無い', 'brew install gh'));
968
+ } else if (gh.outcome === 'error') {
969
+ results.push(unverifiedByError('gh', 'gh(GitHub CLI)', 'gh auth status', gh));
970
+ } else {
971
+ results.push(
972
+ gh.code === 0
973
+ ? check('gh', 'gh(GitHub CLI)', 'ok', '認証済み')
974
+ : check('gh', 'gh(GitHub CLI)', 'unauthenticated', `gh auth status が exit ${gh.code}`, 'gh auth login'),
975
+ );
976
+ }
977
+
978
+ results.push(await checkBun(execFileImpl, platform, fileExists));
979
+ return results;
980
+ }
981
+
982
+ /**
983
+ * bun の有無と PATH。
984
+ *
985
+ * **「入っているのに PATH に無い」を「未導入」と混同しない**(Hermes が踏んだ事故。
986
+ * `~/.bun/bin` が PATH に無く、bun があるのに使えなかった)。
987
+ */
988
+ async function checkBun(execFileImpl, platform, fileExists) {
989
+ const bun = await probeCommand(execFileImpl, 'bun', ['--version']);
990
+ if (bun.outcome === 'exit' && bun.code === 0) {
991
+ return check('bun', 'bun', 'ok', `v${bun.stdout.trim()}`);
992
+ }
993
+ if (bun.outcome === 'error') return unverifiedByError('bun', 'bun', 'bun --version', bun);
994
+
995
+ const homeBun = path.join(platform.homedir, '.bun', 'bin', 'bun');
996
+ if (fileExists(homeBun)) {
997
+ return check(
998
+ 'bun',
999
+ 'bun',
1000
+ 'missing',
1001
+ `${homeBun} はあるが PATH から呼べない`,
1002
+ `PATH に ${path.dirname(homeBun)} を足す(シェルの rc に export PATH="$HOME/.bun/bin:$PATH")`,
1003
+ );
1004
+ }
1005
+ if (bun.outcome === 'missing') {
1006
+ return check('bun', 'bun', 'missing', 'コマンドが無い', 'curl -fsSL https://bun.sh/install | bash');
1007
+ }
1008
+ return check('bun', 'bun', 'unknown', `bun --version が exit ${bun.code}`, 'bun の導入を確認する');
1009
+ }
1010
+
1011
+ /**
1012
+ * 端末での表示幅。**全角を 2 で数える**(日本語のラベルが混ざるので、
1013
+ * `String.length` で桁を揃えると列がずれる)。
1014
+ */
1015
+ export function displayWidth(text) {
1016
+ let width = 0;
1017
+ for (const char of String(text)) {
1018
+ const code = char.codePointAt(0);
1019
+ // CJK・かな・全角記号のざっくり判定。厳密な East Asian Width 表は持ち込まない。
1020
+ const wide =
1021
+ (code >= 0x1100 && code <= 0x115f) ||
1022
+ (code >= 0x2e80 && code <= 0xa4cf) ||
1023
+ (code >= 0xac00 && code <= 0xd7a3) ||
1024
+ (code >= 0xf900 && code <= 0xfaff) ||
1025
+ (code >= 0xfe30 && code <= 0xfe6f) ||
1026
+ (code >= 0xff00 && code <= 0xff60) ||
1027
+ (code >= 0xffe0 && code <= 0xffe6);
1028
+ width += wide ? 2 : 1;
1029
+ }
1030
+ return width;
1031
+ }
1032
+
1033
+ function padDisplay(text, width) {
1034
+ return `${text}${' '.repeat(Math.max(0, width - displayWidth(text)))}`;
1035
+ }
1036
+
1037
+ /**
1038
+ * 人が読む表。**1 項目 1 行 +(要対応なら)直し方 1 行**。
1039
+ *
1040
+ * 先頭の列は状態そのもの(OK / 未導入 / 未認証 / 権限なし / 到達不可 / 不明)にして、
1041
+ * 目で追う対象を 1 つにする。
1042
+ */
1043
+ export function formatDoctorLines(results) {
1044
+ const statusWidth = Math.max(...results.map((item) => displayWidth(DOCTOR_LABELS[item.status] ?? item.status)));
1045
+ const labelWidth = Math.max(...results.map((item) => displayWidth(item.label)));
1046
+ const lines = [];
1047
+ for (const item of results) {
1048
+ const status = DOCTOR_LABELS[item.status] ?? item.status;
1049
+ lines.push(`${padDisplay(status, statusWidth)} ${padDisplay(item.label, labelWidth)} ${item.detail}`);
1050
+ if (item.fix) lines.push(`${' '.repeat(statusWidth + 2)}${padDisplay('', labelWidth)} → ${item.fix}`);
1051
+ }
1052
+ const { blocking, unverified } = splitDoctorResults(results);
1053
+ lines.push('');
1054
+ if (blocking.length > 0) {
1055
+ lines.push(`要対応 ${blocking.length} 件: ${blocking.map((item) => item.label).join(' / ')}`);
1056
+ }
1057
+ if (unverified.length > 0) {
1058
+ lines.push(`未検証 ${unverified.length} 件: ${unverified.map((item) => item.label).join(' / ')}`);
1059
+ }
1060
+ if (blocking.length === 0 && unverified.length === 0) lines.push('すべて OK。');
1061
+ return lines.join('\n');
1062
+ }
1063
+
1064
+ /**
1065
+ * 「直すべきもの」と「確かめられなかったもの」を分ける。
1066
+ *
1067
+ * **`unknown` は失敗ではない。** 判定できなかっただけなので、終了コードにも
1068
+ * 「要対応」の数にも入れない —— これを混ぜると、正常な機械でも doctor が常に
1069
+ * 非 0 になり、`stw-agent doctor && 次の作業` が使えなくなる(R1 codex2 Medium 2 で
1070
+ * hub を「未検証」にした結果、この区別が要るようになった)。
1071
+ */
1072
+ export function splitDoctorResults(results) {
1073
+ return {
1074
+ blocking: results.filter((item) => item.status !== 'ok' && item.status !== 'unknown'),
1075
+ unverified: results.filter((item) => item.status === 'unknown'),
1076
+ };
1077
+ }
1078
+
472
1079
  const COMMANDS = {
473
1080
  'projects list': {
474
1081
  options: ['q', 'limit', 'cursor'],
@@ -549,6 +1156,35 @@ const COMMANDS = {
549
1156
  * **cursor を組み立て直さない。** cursor は版・route・並び順・query hash を含む
550
1157
  * 不透明な値で、手で作ると `400 invalid_cursor` になる(guide §3)。
551
1158
  */
1159
+ /**
1160
+ * 周辺ツールと認証・閲覧可否の一括確認。
1161
+ *
1162
+ * **token が無くても動く唯一のコマンド。** 「token が無い」こと自体を
1163
+ * 報告するのが仕事なので、`configFromEnv` の例外で止めない(`needsToken: false`)。
1164
+ */
1165
+ doctor: {
1166
+ options: [],
1167
+ needsToken: false,
1168
+ async run({ json, stdout, dependencies }) {
1169
+ const results = await runDoctor({
1170
+ env: dependencies.env,
1171
+ readFileImpl: dependencies.readFileImpl,
1172
+ fetchImpl: dependencies.fetchImpl,
1173
+ sleepImpl: dependencies.sleepImpl,
1174
+ execFileImpl: dependencies.execFileImpl,
1175
+ });
1176
+ stdout.write(
1177
+ json
1178
+ ? `${JSON.stringify({ checks: results }, null, 2)}\n`
1179
+ : `${formatDoctorLines(results)}\n`,
1180
+ );
1181
+ // **要対応があれば非 0 で終わる**(`brew doctor` / `npm doctor` と同じ流儀)。
1182
+ // `stw-agent doctor && 次の作業` と繋げられるようにするため。
1183
+ // **「不明」は数えない**(確かめられなかっただけで、壊れてはいない)。
1184
+ return splitDoctorResults(results).blocking.length > 0 ? 1 : 0;
1185
+ },
1186
+ },
1187
+
552
1188
  'slack next': {
553
1189
  options: [],
554
1190
  async run({ api, positional, json, stdout }) {
@@ -592,25 +1228,40 @@ async function execute(argv, dependencies) {
592
1228
  group === '-h'
593
1229
  ) {
594
1230
  dependencies.stdout.write(USAGE);
595
- return;
1231
+ return 0;
596
1232
  }
597
1233
 
598
- const name = `${group} ${verb ?? ''}`.trim();
599
- const command = COMMANDS[name];
1234
+ // **1 語のコマンド(`doctor`)を先に見る。** `${group} ${verb}` だけで引くと、
1235
+ // `doctor --json` の `--json` が動詞として繋がって「未対応」になる。
1236
+ const single = COMMANDS[group];
1237
+ const name = single ? group : `${group} ${verb ?? ''}`.trim();
1238
+ const command = single ?? COMMANDS[name];
600
1239
  if (!command) throw usageError(`未対応のサブコマンドです: ${name}`);
601
1240
 
602
- const { options, positional, json } = parseArgs(rest, command.options);
603
- const config = await configFromEnv(dependencies.env, dependencies.readFileImpl);
604
- const api = createAgentApi(config, dependencies.fetchImpl, dependencies.sleepImpl);
605
-
606
- await command.run({
607
- api,
608
- options,
609
- positional,
610
- json,
611
- stdout: dependencies.stdout,
612
- stderr: dependencies.stderr,
613
- });
1241
+ const args = single ? [verb, ...rest].filter((value) => value !== undefined) : rest;
1242
+ const { options, positional, json } = parseArgs(args, command.options);
1243
+ // **doctor だけは token を要らない**(無いことを報告するのが仕事)。
1244
+ const api =
1245
+ command.needsToken === false
1246
+ ? null
1247
+ : createAgentApi(
1248
+ await configFromEnv(dependencies.env, dependencies.readFileImpl),
1249
+ dependencies.fetchImpl,
1250
+ dependencies.sleepImpl,
1251
+ );
1252
+
1253
+ // コマンドが終了コードを返したらそれを使う(既定は 0)。
1254
+ return (
1255
+ (await command.run({
1256
+ api,
1257
+ options,
1258
+ positional,
1259
+ json,
1260
+ stdout: dependencies.stdout,
1261
+ stderr: dependencies.stderr,
1262
+ dependencies,
1263
+ })) ?? 0
1264
+ );
614
1265
  }
615
1266
 
616
1267
  export async function runCli(argv, overrides = {}) {
@@ -619,19 +1270,54 @@ export async function runCli(argv, overrides = {}) {
619
1270
  fetchImpl: overrides.fetchImpl ?? globalThis.fetch,
620
1271
  readFileImpl: overrides.readFileImpl ?? readFile,
621
1272
  sleepImpl: overrides.sleepImpl ?? defaultSleep,
1273
+ execFileImpl: overrides.execFileImpl ?? execFileAsync,
622
1274
  stdout: overrides.stdout ?? process.stdout,
623
1275
  stderr: overrides.stderr ?? process.stderr,
624
1276
  };
625
1277
 
626
1278
  try {
627
- await execute(argv, dependencies);
628
- return 0;
1279
+ return await execute(argv, dependencies);
629
1280
  } catch (error) {
630
1281
  dependencies.stderr.write(`${formatFailure(error)}\n`);
631
1282
  return 1;
632
1283
  }
633
1284
  }
634
1285
 
635
- const isDirectExecution =
636
- process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
637
- if (isDirectExecution) process.exitCode = await runCli(process.argv.slice(2));
1286
+ /**
1287
+ * この実行が「CLI として直接呼ばれた」ものか(ライブラリとして import されたのではなく)。
1288
+ *
1289
+ * ## symlink 経由で無出力になっていた(0.1.0 の不具合)
1290
+ *
1291
+ * `npm i -g` が作る `~/.npm-global/bin/stw-agent` は
1292
+ * `../lib/node_modules/@shimatoworks/stw-agent/bin/stw-agent.mjs` への **symlink** である。
1293
+ * このとき Node が渡す値は 2 つで食い違う。
1294
+ *
1295
+ * ```text
1296
+ * process.argv[1] /Users/x/.npm-global/bin/stw-agent ← 呼ばれた名前(symlink)
1297
+ * import.meta.url file:///Users/x/.npm-global/lib/…/bin/stw-agent.mjs ← 実体
1298
+ * ```
1299
+ *
1300
+ * Node は既定でモジュールの**実体パス**で `import.meta.url` を作るので、
1301
+ * `argv[1]` をそのまま URL 化して比べると一致しない。判定が false になり、
1302
+ * **何も実行せず exit 0**(無出力)になっていた。
1303
+ *
1304
+ * symlink は bin 自体だけでなく**途中のディレクトリ**にもある(macOS の `/tmp` は
1305
+ * `/private/tmp` への symlink)。だから「realpath に直してから比べる」で両方に効く。
1306
+ *
1307
+ * `--preserve-symlinks-main` を付けて起動した場合は逆に `import.meta.url` が
1308
+ * symlink 側になるので、**素の比較も残して両方を許す**。
1309
+ */
1310
+ export function isDirectExecution(argv1, moduleUrl) {
1311
+ if (!argv1) return false;
1312
+ if (pathToFileURL(argv1).href === moduleUrl) return true;
1313
+ try {
1314
+ return pathToFileURL(realpathSync(argv1)).href === moduleUrl;
1315
+ } catch {
1316
+ // argv[1] が実在しない(`node --eval` など)。**ここで落とさない。**
1317
+ return false;
1318
+ }
1319
+ }
1320
+
1321
+ if (isDirectExecution(process.argv[1], import.meta.url)) {
1322
+ process.exitCode = await runCli(process.argv.slice(2));
1323
+ }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@shimatoworks/stw-agent",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for the Shimatoworks project-context read API (project registry entries and Slack discussion).",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
7
7
  "bin": {
8
- "stw-agent": "./bin/stw-agent.mjs"
8
+ "stw-agent": "bin/stw-agent.mjs"
9
9
  },
10
10
  "files": [
11
11
  "bin/stw-agent.mjs",