@clubs.place/cli 0.0.0-stage → 0.1.18

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.
Files changed (70) hide show
  1. package/.env.example +88 -0
  2. package/LICENSE +21 -0
  3. package/PROVENANCE.json +77 -0
  4. package/README.md +70 -2
  5. package/bin/clubs-nippo +7 -0
  6. package/bin/clubs-nippo-web.mjs +338 -0
  7. package/bin/clubs-nippo.mjs +527 -0
  8. package/docs/events-schema.md +76 -0
  9. package/examples/hermes-events.sample.jsonl +8 -0
  10. package/package.json +44 -4
  11. package/src/aggregate.mjs +105 -0
  12. package/src/autorun.mjs +25 -0
  13. package/src/autorun.test.mjs +43 -0
  14. package/src/cards.mjs +406 -0
  15. package/src/collect.mjs +111 -0
  16. package/src/config.mjs +173 -0
  17. package/src/generators/claude.mjs +31 -0
  18. package/src/generators/cli.mjs +81 -0
  19. package/src/generators/codex.mjs +28 -0
  20. package/src/generators/copilot.mjs +21 -0
  21. package/src/generators/gemini.mjs +20 -0
  22. package/src/generators/openai-compat.mjs +79 -0
  23. package/src/generators/openrouter.mjs +65 -0
  24. package/src/history.mjs +66 -0
  25. package/src/ingest.mjs +78 -0
  26. package/src/lemma.docHash.test.mjs +37 -0
  27. package/src/lemma.mjs +303 -0
  28. package/src/mask.mjs +45 -0
  29. package/src/mcp.mjs +269 -0
  30. package/src/narrative.mjs +191 -0
  31. package/src/prompt.mjs +184 -0
  32. package/src/prompt.test.mjs +146 -0
  33. package/src/remote-config.mjs +72 -0
  34. package/src/render.mjs +467 -0
  35. package/src/send.mjs +36 -0
  36. package/src/settings.mjs +54 -0
  37. package/src/sources/claude-code.mjs +174 -0
  38. package/src/sources/codex-cli.mjs +182 -0
  39. package/src/sources/copilot-cli.mjs +125 -0
  40. package/src/sources/cowork.mjs +193 -0
  41. package/src/sources/events-file.mjs +120 -0
  42. package/src/sources/gemini-cli.mjs +128 -0
  43. package/src/sources/hermes.mjs +427 -0
  44. package/src/store.mjs +227 -0
  45. package/src/tasks.mjs +229 -0
  46. package/src/tool-name.mjs +28 -0
  47. package/templates/report-v3.reference.html +162 -0
  48. package/web/assets/logo-clubs-white.png +0 -0
  49. package/web/assets/logo-clubs-white.svg +7 -0
  50. package/web/assets/logo-clubs-white.svg.tmp +30 -0
  51. package/web/assets/logo-clubs.svg +30 -0
  52. package/web/assets/qr.js +411 -0
  53. package/web/assets/sidebar.js +154 -0
  54. package/web/assets/twemoji.js +34 -0
  55. package/web/cards/achievements.html +230 -0
  56. package/web/cards/catalog.json +217 -0
  57. package/web/email/hero-v3.reference.html +123 -0
  58. package/web/home/index.html +269 -0
  59. package/web/preview.json +301 -0
  60. package/web/qr-selftest.html +46 -0
  61. package/web/reports/edit.html +255 -0
  62. package/web/reports/edit.reference.html +257 -0
  63. package/web/reports/index.html +196 -0
  64. package/web/settings/index.html +219 -0
  65. package/web/share/cards-reference.html +228 -0
  66. package/web/share/passport.html +214 -0
  67. package/web/share/teianbako.html +177 -0
  68. package/web/signup/index.html +326 -0
  69. package/web/tasks/index.html +229 -0
  70. package/web/verify/index.html +248 -0
@@ -0,0 +1,37 @@
1
+ // clubs-nippo — AI業務日報 (CLUBS)
2
+ // Copyright (c) 2026 FRAME00 Inc.
3
+ // SPDX-License-Identifier: MIT
4
+ // https://github.com/frame00/clubs-nippo
5
+
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { documentHash } from "./lemma.mjs";
9
+
10
+ // ── docHash 契約テスト(clubs-nippo 収集器 ↔ clubsx ingest 検証)─────────
11
+ // この docHash は clubsx の /api/nippo/ingest が受信ボディから再計算して照合する。
12
+ // 両実装がバイト一致でないと ingest が 422 で全件リジェクトされ、日報がサーバに
13
+ // 入らなくなる(無言で壊れる)。clubsx 側(src/lib/nippo/lemma.docHash.test.ts)に
14
+ // 同じ固定フィクスチャ・同じ golden のテストを置いてあり、どちらかがずれたら
15
+ // 各リポの test で検知できる。
16
+ //
17
+ // 正準形: sha256(JSON.stringify({date, aggregates, workBlocks, handoff, selfNote}))
18
+ // *キー順・対象フィールドは両リポで固定。変更時は両方+golden を同時更新する。
19
+ const FIXTURE = {
20
+ activity: {
21
+ date: "2026-07-16",
22
+ aggregates: { totalCalls: 1414, failedCalls: 43, successRate: 97, activeHours: 14.2 },
23
+ },
24
+ narrative: {
25
+ workBlocks: [
26
+ { title: "案件A", text: "本文" },
27
+ { title: "案件B", text: "本文2" },
28
+ ],
29
+ handoff: "明日にまわすもの",
30
+ selfNote: "ひとこと",
31
+ },
32
+ };
33
+ const GOLDEN = "0xee3944ce3e384f69513b6d9109d7a0062a5341101aa43b6c57505da0325bdc79";
34
+
35
+ test("documentHash 契約: 固定フィクスチャで golden と一致(clubsx と同値)", () => {
36
+ assert.equal(documentHash(FIXTURE.activity, FIXTURE.narrative), GOLDEN);
37
+ });
package/src/lemma.mjs ADDED
@@ -0,0 +1,303 @@
1
+ // clubs-nippo — AI業務日報 (CLUBS)
2
+ // Copyright (c) 2026 FRAME00 Inc.
3
+ // SPDX-License-Identifier: MIT
4
+ // https://github.com/frame00/clubs-nippo
5
+
6
+ import { createHash } from "node:crypto";
7
+
8
+ // SDK の動的インポート。無い/読めないときは null → commitDeep/proofs.submit をスキップ。
9
+ let _sdk = null;
10
+ try {
11
+ _sdk = await import("@lemmaoracle/sdk");
12
+ } catch {
13
+ // ゼロ依存のままでも動く(Phase 1 互換)。proofs.submit だけが無効になる。
14
+ }
15
+
16
+ /**
17
+ * 日報の docHash。Phase 1 互換の SHA-256(commitDeep 非導入時)。
18
+ * 集計値と本文(マスク後)だけを対象にする。
19
+ * 生ログは入力に含めない = ハッシュを再計算するのに生ログは要らない。
20
+ */
21
+ export const documentHash = (activity, narrative) =>
22
+ `0x${createHash("sha256")
23
+ .update(
24
+ JSON.stringify({
25
+ date: activity.date,
26
+ aggregates: activity.aggregates,
27
+ workBlocks: narrative.workBlocks,
28
+ handoff: narrative.handoff,
29
+ selfNote: narrative.selfNote,
30
+ }),
31
+ )
32
+ .digest("hex")}`;
33
+
34
+ const withTimeout = async (fn, ms) => {
35
+ const ctrl = new AbortController();
36
+ const timer = setTimeout(() => ctrl.abort(), ms);
37
+ try {
38
+ return await fn(ctrl.signal);
39
+ } finally {
40
+ clearTimeout(timer);
41
+ }
42
+ };
43
+
44
+ const reachable = async (base) => {
45
+ try {
46
+ const res = await withTimeout(
47
+ (signal) => fetch(`${base}/v1/health`, { signal }),
48
+ 6000,
49
+ );
50
+ return res.ok;
51
+ } catch {
52
+ return false;
53
+ }
54
+ };
55
+
56
+ const SCHEMA_ID = "clubs.nippo.v1";
57
+ const CIRCUIT_ID = "data-commitment-v1.1";
58
+ const MAX_DEPTH = 16;
59
+
60
+ /**
61
+ * 日報のスキーマが Lemma に登録されていなければ登録する。
62
+ *
63
+ * lemmaoracle/workers#106 で2つ変わった:
64
+ * - POST /v1/schemas の normalize が任意になった(正規化器を持たない日報でも登録できる)
65
+ * - POST /v1/documents は未登録スキーマを 400 で弾くようになった
66
+ *
67
+ * 後者があるので、スキーマ登録は「あると良い」ではなく「無いと落ちる」。
68
+ * GET は認証不要なので、まず存在を見てから、無いときだけ登録する。
69
+ */
70
+ const ensureSchema = async (base, apiKey) => {
71
+ try {
72
+ const existing = await withTimeout(
73
+ (signal) => fetch(`${base}/v1/schemas/${SCHEMA_ID}`, { signal }),
74
+ 8000,
75
+ );
76
+ if (existing.ok) return true;
77
+
78
+ const created = await withTimeout(
79
+ (signal) =>
80
+ fetch(`${base}/v1/schemas`, {
81
+ method: "POST",
82
+ signal,
83
+ headers: {
84
+ "Content-Type": "application/json",
85
+ Authorization: `Bearer ${apiKey}`,
86
+ },
87
+ body: JSON.stringify({
88
+ id: SCHEMA_ID,
89
+ description: "CLUBS AI業務日報(実行ログの集計+マスク済み本文のハッシュ)",
90
+ }),
91
+ }),
92
+ 10_000,
93
+ );
94
+ return created.ok;
95
+ } catch {
96
+ return false;
97
+ }
98
+ };
99
+
100
+ const STUB = (docHash, reason) => ({
101
+ status: "pending",
102
+ label: "照合予定",
103
+ docHash,
104
+ reason,
105
+ verifyUrl: null,
106
+ });
107
+
108
+ /**
109
+ * 配列を depth に満たないぶん pad で埋める(pipeline.ts 同型)。
110
+ */
111
+ const padToDepth = (arr, depth, pad) => {
112
+ const out = [...arr];
113
+ while (out.length < depth) out.push(pad);
114
+ return out;
115
+ };
116
+
117
+ /**
118
+ * commitDeep 用に日報データをフラットな path-value オブジェクトに変換する。
119
+ * 集計値は文字列化(toScalar の type tag が s: で統一されるように)。
120
+ */
121
+ const buildCommitData = (activity, narrative) => ({
122
+ date: activity.date,
123
+ agentName: activity.agentName ?? "",
124
+ totalCalls: String(activity.aggregates?.totalCalls ?? 0),
125
+ failedCalls: String(activity.aggregates?.failedCalls ?? 0),
126
+ successRate: String(activity.aggregates?.successRate ?? 0),
127
+ activeHours: String(activity.aggregates?.activeHours ?? 0),
128
+ ...Object.fromEntries(
129
+ (narrative.workBlocks ?? []).flatMap((wb, i) => [
130
+ [`workBlock${i}title`, wb.title ?? ""],
131
+ [`workBlock${i}text`, wb.text ?? ""],
132
+ ]),
133
+ ),
134
+ });
135
+
136
+ /**
137
+ * Phase 2→3: 集計値+本文を Poseidon Merkle コミット → docHash 登録 → key leaf を証明・提出。
138
+ *
139
+ * SDK が無い/失敗したときは Phase 1 互換(SHA-256 placeholder)に落ちる。
140
+ * proofs.submit の失敗は証明カウンタを回せないだけで日報の登録は阻害しない。
141
+ */
142
+ export const registerProof = async (activity, narrative, config) => {
143
+ const { base, apiKey, verifyBase } = config.lemma;
144
+
145
+ // scope ID は送らない。Lemma 側の router が API キーから scope を解決して
146
+ // X-Scope-Id を自分で差し込む(router.ts の withMiddleware)。
147
+ if (!apiKey) {
148
+ return STUB(
149
+ documentHash(activity, narrative),
150
+ "LEMMA_API_KEY が未設定",
151
+ );
152
+ }
153
+ if (!(await reachable(base))) {
154
+ return STUB(
155
+ documentHash(activity, narrative),
156
+ `Lemma API に到達できません (${base})`,
157
+ );
158
+ }
159
+
160
+ // 未登録スキーマは 400 で弾かれる(#106)。先に確保しておく。
161
+ if (!(await ensureSchema(base, apiKey))) {
162
+ return STUB(
163
+ documentHash(activity, narrative),
164
+ `スキーマ ${SCHEMA_ID} を登録できませんでした`,
165
+ );
166
+ }
167
+
168
+ // ── 1. Poseidon Merkle コミット(SDK ありの場合) ──
169
+ let docHash;
170
+ let commitment;
171
+ let commitResult = null;
172
+
173
+ if (_sdk) {
174
+ try {
175
+ const data = buildCommitData(activity, narrative);
176
+ commitResult = _sdk.commitDeep(data, { maxDepth: MAX_DEPTH });
177
+ docHash = `0x${createHash("sha256")
178
+ .update(`${commitResult.root}|${activity.date}`)
179
+ .digest("hex")}`;
180
+ commitment = {
181
+ scheme: "poseidon",
182
+ root: commitResult.root,
183
+ leaves: commitResult.leaves,
184
+ randomness: commitResult.randomness,
185
+ };
186
+ } catch (e) {
187
+ // commitDeep 失敗 → SHA-256 placeholder にフォールバック
188
+ console.error("commitDeep failed:", e?.message ?? e);
189
+ }
190
+ }
191
+
192
+ if (!commitment) {
193
+ // SDK 無し or commitDeep 失敗 → Phase 1 互換
194
+ docHash = documentHash(activity, narrative);
195
+ commitment = { scheme: "sha256-placeholder", root: docHash };
196
+ }
197
+
198
+ // ── 2. documents.register ──
199
+ const body = {
200
+ docHash,
201
+ schema: SCHEMA_ID,
202
+ cid: `local:${activity.date}`,
203
+ issuerId: activity.agentName,
204
+ subjectId: config.email.to[0] ?? activity.agentName,
205
+ commitments: commitment,
206
+ revocation: { scheme: "none", root: "" },
207
+ attributes: {
208
+ date: activity.date,
209
+ agent: activity.agentName,
210
+ totalCalls: activity.aggregates.totalCalls,
211
+ failedCalls: activity.aggregates.failedCalls,
212
+ successRate: activity.aggregates.successRate,
213
+ activeHours: activity.aggregates.activeHours,
214
+ },
215
+ };
216
+
217
+ let docResult;
218
+ try {
219
+ const res = await withTimeout(
220
+ (signal) =>
221
+ fetch(`${base}/v1/documents`, {
222
+ method: "POST",
223
+ signal,
224
+ headers: {
225
+ "Content-Type": "application/json",
226
+ Authorization: `Bearer ${apiKey}`,
227
+ },
228
+ body: JSON.stringify(body),
229
+ }),
230
+ 15_000,
231
+ );
232
+
233
+ if (!res.ok) {
234
+ const text = await res.text().catch(() => "");
235
+ return STUB(docHash, `Lemma API ${res.status}: ${text.slice(0, 160)}`);
236
+ }
237
+
238
+ docResult = await res.json();
239
+ } catch (e) {
240
+ return STUB(docHash, `Lemma 登録に失敗: ${e.message}`);
241
+ }
242
+
243
+ // ── 3. proofs.submit(key leaf = failedCalls) ──
244
+ let verificationId = null;
245
+ if (_sdk && commitResult) {
246
+ try {
247
+ const client = _sdk.create({ apiBase: base, apiKey });
248
+
249
+ // failedCalls の葉を探す
250
+ const failedIdx = commitResult.leafPreimages.findIndex(
251
+ (p) => p.name === '$["failedCalls"]',
252
+ );
253
+ if (failedIdx >= 0) {
254
+ const pre = commitResult.leafPreimages[failedIdx];
255
+ const inc = commitResult.inclusionProofs[failedIdx];
256
+
257
+ const witness = {
258
+ root: BigInt(commitResult.root),
259
+ randomness: BigInt(commitResult.randomness),
260
+ pathHash: BigInt(pre.nameHash),
261
+ valueHash: BigInt(pre.valueHash),
262
+ siblings: padToDepth(inc.siblings, MAX_DEPTH, "0x0").map((s) =>
263
+ BigInt(s),
264
+ ),
265
+ indices: padToDepth(inc.indices, MAX_DEPTH, 0),
266
+ };
267
+
268
+ const { proof, inputs } = await _sdk.prover.prove(client, {
269
+ circuitId: CIRCUIT_ID,
270
+ witness,
271
+ });
272
+
273
+ const sr = await _sdk.proofs.submit(client, {
274
+ docHash,
275
+ circuitId: CIRCUIT_ID,
276
+ proof,
277
+ inputs,
278
+ });
279
+
280
+ verificationId = sr.verificationId;
281
+ console.error(
282
+ `proofs.submit OK: ${verificationId} (failedCalls=${pre.value})`,
283
+ );
284
+ }
285
+ } catch (e) {
286
+ // proofs.submit 失敗は非ブロッキング。日報の登録は成功している。
287
+ console.error("proofs.submit failed:", e?.message ?? e);
288
+ }
289
+ }
290
+
291
+ return {
292
+ status: "verified",
293
+ label: "ログ照合済み",
294
+ docHash,
295
+ txHash: docResult.onchainTxHash ?? null,
296
+ verifyUrl: verifyBase
297
+ ? `${verifyBase.replace(/\/$/, "")}/${docHash}`
298
+ : null,
299
+ verificationId,
300
+ commitmentScheme: commitment.scheme,
301
+ reason: null,
302
+ };
303
+ };
package/src/mask.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // clubs-nippo — AI業務日報 (CLUBS)
2
+ // Copyright (c) 2026 FRAME00 Inc.
3
+ // SPDX-License-Identifier: MIT
4
+ // https://github.com/frame00/clubs-nippo
5
+
6
+ export const REDACT_TOKEN = "[[REDACTED]]";
7
+
8
+ /**
9
+ * MASK_PATTERNS にマッチした箇所を REDACT_TOKEN に置換する。
10
+ * 返り値の count は「伏せた箇所の数」で、日報の "マスク済み N箇所" 表示に使う。
11
+ */
12
+ export const maskText = (text, patterns) => {
13
+ if (typeof text !== "string" || text.length === 0 || patterns.length === 0) {
14
+ return { text: text ?? "", count: 0 };
15
+ }
16
+ let out = text;
17
+ let count = 0;
18
+ for (const re of patterns) {
19
+ re.lastIndex = 0;
20
+ out = out.replace(re, () => {
21
+ count += 1;
22
+ return REDACT_TOKEN;
23
+ });
24
+ }
25
+ return { text: out, count };
26
+ };
27
+
28
+ /** オブジェクト/配列の文字列値を再帰的にマスクする。 */
29
+ export const maskDeep = (value, patterns) => {
30
+ let count = 0;
31
+ const walk = (v) => {
32
+ if (typeof v === "string") {
33
+ const r = maskText(v, patterns);
34
+ count += r.count;
35
+ return r.text;
36
+ }
37
+ if (Array.isArray(v)) return v.map(walk);
38
+ if (v && typeof v === "object") {
39
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
40
+ }
41
+ return v;
42
+ };
43
+ const masked = walk(value);
44
+ return { value: masked, count };
45
+ };
package/src/mcp.mjs ADDED
@@ -0,0 +1,269 @@
1
+ // clubs-nippo — AI業務日報 (CLUBS)
2
+ // Copyright (c) 2026 FRAME00 Inc.
3
+ // SPDX-License-Identifier: MIT
4
+ // https://github.com/frame00/clubs-nippo
5
+
6
+ /**
7
+ * CLUBS MCP サーバー(stdio・同一バイナリ同梱=全体設計 §6 / P0 §9)。
8
+ *
9
+ * `clubs-nippo mcp` で起動し、Claude Code / Cursor / Gemini CLI 等の
10
+ * MCP クライアントから CLUBS への登録・疎通確認を対話の中で完結させる。
11
+ *
12
+ * CTO裁定(clubsx#53 D-4)に従う:
13
+ * - ツールは clubs_register / clubs_link_status の2本のみ。
14
+ * 発行系・日報の書き込み系は載せない(B-1: 自己申告の発行経路を作らない。
15
+ * 日報の取り込みは従来どおり収集器の pull 経路のみ)。
16
+ * - 出力はサーバ確定値・ローカル設定の定型フィールドのみ(自由文・他ユーザー
17
+ * 由来のテキストを返さない=注入面の最小化)。
18
+ *
19
+ * 依存ゼロ方針(このリポの流儀)のため SDK は使わず、MCP の stdio
20
+ * トランスポート(JSON-RPC 2.0・1行1メッセージ)を最小実装する。
21
+ * stdout はプロトコル専用。ログは必ず stderr へ。
22
+ */
23
+
24
+ import { createInterface } from "node:readline";
25
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { APP_HOME, ROOT } from "./config.mjs";
28
+ import { collectorConfigUrl } from "./remote-config.mjs";
29
+ import { readHistory } from "./history.mjs";
30
+
31
+ const PROTOCOL_FALLBACK = "2025-06-18";
32
+
33
+ const pkgVersion = () => {
34
+ try {
35
+ return JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
36
+ } catch {
37
+ return "0.0.0";
38
+ }
39
+ };
40
+
41
+ // ── 登録先 URL のガード(注入面対策) ─────────────────────
42
+ // MCP 経由の register は AI の文脈から呼ばれるため、細工された取込URLで
43
+ // トークンや日報の送り先を挿げ替えられないよう、既定で CLUBS のホストに限る。
44
+ // 自前ホストの開発者は CLUBS_NIPPO_ALLOW_HOST=<host> で1件だけ追加できる。
45
+ const urlAllowed = (raw) => {
46
+ try {
47
+ const u = new URL(raw);
48
+ const local = u.hostname === "localhost" || u.hostname === "127.0.0.1";
49
+ if (local) return true; // ローカル開発
50
+ if (u.protocol !== "https:") return false;
51
+ if (u.hostname === "clubs.place" || u.hostname.endsWith(".clubs.place"))
52
+ return true;
53
+ return u.hostname === process.env.CLUBS_NIPPO_ALLOW_HOST;
54
+ } catch {
55
+ return false;
56
+ }
57
+ };
58
+
59
+ /** init コマンドと同じ書式で .env を更新(既存キーは生かす)。 */
60
+ const writeEnvUpdates = (updates) => {
61
+ mkdirSync(join(APP_HOME, "out"), { recursive: true });
62
+ const envPath = join(APP_HOME, ".env");
63
+ const lines = existsSync(envPath)
64
+ ? readFileSync(envPath, "utf8").replace(/\n$/, "").split("\n")
65
+ : [
66
+ "# clubs-nippo の設定。キーの一覧は https://www.npmjs.com/package/clubs-nippo を参照。",
67
+ ];
68
+ for (const [key, value] of Object.entries(updates)) {
69
+ const row = `${key}=${value}`;
70
+ const idx = lines.findIndex((l) => l.match(new RegExp(`^\\s*${key}\\s*=`)));
71
+ if (idx >= 0) lines[idx] = row;
72
+ else lines.push(row);
73
+ }
74
+ writeFileSync(envPath, lines.join("\n") + "\n", "utf8");
75
+ return envPath;
76
+ };
77
+
78
+ const withTimeout = async (fn, ms) => {
79
+ const ctrl = new AbortController();
80
+ const timer = setTimeout(() => ctrl.abort(), ms);
81
+ try {
82
+ return await fn(ctrl.signal);
83
+ } finally {
84
+ clearTimeout(timer);
85
+ }
86
+ };
87
+
88
+ /** 連携トークンの実在確認=collector-config にサーバ照会する。 */
89
+ const serverCheck = async (ingestUrl, ingestToken) => {
90
+ const url = collectorConfigUrl(ingestUrl);
91
+ if (!url || !ingestToken) return { reachable: false, token_valid: false };
92
+ try {
93
+ const res = await withTimeout(
94
+ (signal) =>
95
+ fetch(url, {
96
+ signal,
97
+ headers: { Authorization: `Bearer ${ingestToken}` },
98
+ }),
99
+ 10_000,
100
+ );
101
+ return { reachable: true, token_valid: res.ok };
102
+ } catch {
103
+ return { reachable: false, token_valid: false };
104
+ }
105
+ };
106
+
107
+ // ── ツール定義 ────────────────────────────────────────────
108
+
109
+ const TOOLS = [
110
+ {
111
+ name: "clubs_register",
112
+ description:
113
+ "この端末を CLUBS に登録する(clubs-nippo init と同じ設定を書く)。url と token は CLUBS の接続画面(/nippo/connections)が発行した値をそのまま渡すこと。登録後にサーバへ疎通確認する。",
114
+ inputSchema: {
115
+ type: "object",
116
+ properties: {
117
+ url: {
118
+ type: "string",
119
+ description: "取込URL(接続画面が発行。…/api/nippo/ingest)",
120
+ },
121
+ token: { type: "string", description: "連携トークン(接続画面が発行)" },
122
+ source: {
123
+ type: "string",
124
+ description: "収集元(例: claude-code / cowork。省略可)",
125
+ },
126
+ agent_name: { type: "string", description: "体の表示名(省略可)" },
127
+ owner_name: { type: "string", description: "オーナー名(省略可)" },
128
+ },
129
+ required: ["url", "token"],
130
+ },
131
+ },
132
+ {
133
+ name: "clubs_link_status",
134
+ description:
135
+ "CLUBS との連携状態を返す(設定の有無・サーバ疎通・トークン有効性・ローカル最終記録日)。",
136
+ inputSchema: { type: "object", properties: {} },
137
+ },
138
+ ];
139
+
140
+ const callTool = async (name, args = {}) => {
141
+ if (name === "clubs_register") {
142
+ const url = String(args.url ?? "");
143
+ const token = String(args.token ?? "");
144
+ if (!url || !token) {
145
+ return { ok: false, error: "url と token は必須です" };
146
+ }
147
+ let parsed;
148
+ try {
149
+ parsed = new URL(url);
150
+ } catch {
151
+ return { ok: false, error: "url が不正です" };
152
+ }
153
+ if (!/\/api\/nippo\/ingest\/?$/.test(parsed.pathname)) {
154
+ return {
155
+ ok: false,
156
+ error: "url は接続画面が発行した取込URL(…/api/nippo/ingest)を渡してください",
157
+ };
158
+ }
159
+ if (!urlAllowed(url)) {
160
+ return {
161
+ ok: false,
162
+ error:
163
+ "この url のホストには登録できません(clubs.place / localhost のみ。自前ホストは CLUBS_NIPPO_ALLOW_HOST で許可)",
164
+ };
165
+ }
166
+ const updates = Object.fromEntries(
167
+ Object.entries({
168
+ NIPPO_INGEST_URL: url,
169
+ NIPPO_INGEST_TOKEN: token,
170
+ SOURCE: args.source,
171
+ AGENT_NAME: args.agent_name,
172
+ OWNER_NAME: args.owner_name,
173
+ }).filter(([, v]) => v),
174
+ );
175
+ const envPath = writeEnvUpdates(updates);
176
+ // 同一セッション内の link_status が新しい値を見られるように反映する
177
+ // (config.mjs の loadDotEnv は起動時に未設定キーしか埋めない)
178
+ for (const [key, value] of Object.entries(updates)) process.env[key] = value;
179
+ const server = await serverCheck(url, token);
180
+ return {
181
+ ok: server.token_valid,
182
+ wrote: Object.keys(updates),
183
+ env_path: envPath,
184
+ ingest_url_host: parsed.host,
185
+ server,
186
+ next: server.token_valid
187
+ ? "毎朝の自動配信は crontab に `npx -y clubs-nippo@latest tick` を登録(clubs-nippo init のヘルプ参照)"
188
+ : "トークンがサーバで確認できませんでした。接続画面で再発行して register し直してください",
189
+ };
190
+ }
191
+
192
+ if (name === "clubs_link_status") {
193
+ const ingestUrl = process.env.NIPPO_INGEST_URL ?? "";
194
+ const ingestToken = process.env.NIPPO_INGEST_TOKEN ?? "";
195
+ const configured = Boolean(ingestUrl && ingestToken);
196
+ const server = configured
197
+ ? await serverCheck(ingestUrl, ingestToken)
198
+ : { reachable: false, token_valid: false };
199
+ const history = readHistory();
200
+ return {
201
+ ok: configured && server.token_valid,
202
+ configured,
203
+ ingest_url_host: ingestUrl ? new URL(ingestUrl).host : null,
204
+ agent_name: process.env.AGENT_NAME ?? null,
205
+ server,
206
+ last_local_report_date: history.at(-1)?.date ?? null,
207
+ env_path: join(APP_HOME, ".env"),
208
+ };
209
+ }
210
+
211
+ return { ok: false, error: `unknown tool: ${name}` };
212
+ };
213
+
214
+ // ── JSON-RPC 2.0 / stdio ─────────────────────────────────
215
+
216
+ const reply = (id, result) => {
217
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
218
+ };
219
+
220
+ const replyError = (id, code, message) => {
221
+ process.stdout.write(
222
+ JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n",
223
+ );
224
+ };
225
+
226
+ export const runMcpServer = async () => {
227
+ const rl = createInterface({ input: process.stdin, terminal: false });
228
+ console.error(
229
+ `[clubs-nippo mcp] stdio server ready (tools: ${TOOLS.map((t) => t.name).join(", ")})`,
230
+ );
231
+
232
+ for await (const line of rl) {
233
+ if (!line.trim()) continue;
234
+ let msg;
235
+ try {
236
+ msg = JSON.parse(line);
237
+ } catch {
238
+ continue; // 不正な行は無視(プロトコル外のノイズ)
239
+ }
240
+ const { id, method, params } = msg;
241
+
242
+ // 通知(id なし)は initialized 含め応答しない
243
+ if (id === undefined || id === null) continue;
244
+
245
+ try {
246
+ if (method === "initialize") {
247
+ reply(id, {
248
+ protocolVersion: params?.protocolVersion ?? PROTOCOL_FALLBACK,
249
+ capabilities: { tools: { listChanged: false } },
250
+ serverInfo: { name: "clubs-nippo", version: pkgVersion() },
251
+ });
252
+ } else if (method === "ping") {
253
+ reply(id, {});
254
+ } else if (method === "tools/list") {
255
+ reply(id, { tools: TOOLS });
256
+ } else if (method === "tools/call") {
257
+ const result = await callTool(params?.name, params?.arguments ?? {});
258
+ reply(id, {
259
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
260
+ isError: result.ok === false,
261
+ });
262
+ } else {
263
+ replyError(id, -32601, `method not found: ${method}`);
264
+ }
265
+ } catch (e) {
266
+ replyError(id, -32603, e?.message ?? "internal error");
267
+ }
268
+ }
269
+ };