@shimatoworks/stw-agent 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.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @shimatoworks/stw-agent
2
+
3
+ CLI for the Shimatoworks project-context read API. It fetches a project's registry
4
+ entries (Notion / Drive / Slack / repo) and the Slack discussion attached to them, so an
5
+ agent or a person can get the background of a project in one command instead of
6
+ assembling `curl` calls by hand. It has no dependencies and needs a read token issued by
7
+ Shimatoworks; without one it does nothing. The API contract, the permission model and the
8
+ error semantics live in the `stw-manage` repository, not here.
9
+
10
+ ## インストール
11
+
12
+ ```bash
13
+ npm i -g @shimatoworks/stw-agent
14
+ ```
15
+
16
+ `stw-agent` コマンドが PATH に入る。Node 20 以上が要る。
17
+
18
+ ## token
19
+
20
+ 読み取り token(`STW_AGENT_TOKEN`)が要る。**発行と配布は たま**が行う。
21
+ 自分で作らない。無ければ人に頼む。
22
+
23
+ ```bash
24
+ set -a && source ~/.config/tamai/stw-agent.env && set +a
25
+ ```
26
+
27
+ `~/.config/tamai/stw-agent.env` に置いてあれば CLI が自動で読むので、
28
+ 上の `source` は毎回は要らない。接続先を変えるときだけ `STW_API_BASE` を指定する。
29
+
30
+ **token は CLI のどの出力にも出ない**(stdout / stderr / エラー文のいずれにも)。
31
+
32
+ ## 使い方
33
+
34
+ ```bash
35
+ stw-agent --help
36
+ ```
37
+
38
+ 動詞は `projects list` / `projects context` / `slack channels` / `slack messages` /
39
+ `slack next` の 5 つ。正確なオプションは常に `--help` を見る(ここに写経しない)。
40
+
41
+ 既定の出力は 1 行要約で、メッセージの本文は先頭だけを出す。
42
+ 会社の内部のやり取りが端末のログや transcript へ機械的に積み上がるのを避けるため。
43
+ **全文が要るときだけ `--json`** を付ける。
44
+
45
+ ## 契約と権限
46
+
47
+ 読める範囲(案件との紐付き・`visibility`・チャンネルの種別)はサーバ側の SQL が決める。
48
+ 期間や検索語の妥当性もサーバが判定する。CLI は要求を組み立てて表示するだけである。
49
+
50
+ - 契約・権限・エラーの意味・読むときの約束:
51
+ `stw-manage` の `docs/guides/案件コンテキストの集め方.md`
52
+ - なぜこの形なのか・過去の判断: `stw-manage` の `docs/records/235-agent-read-api.md`
53
+
54
+ ## ライセンス
55
+
56
+ `UNLICENSED`(社内利用)。npm の scope は公開だが、利用許諾を与えるものではない。
@@ -0,0 +1,637 @@
1
+ #!/usr/bin/env node
2
+
3
+ // stw-agent — AI 向け読み取り API の CLI(看板 01M1V38MP4XBB5JJ866869KQ81)。
4
+ //
5
+ // **CLI が唯一の実装**(hub の `tools/hub.mjs` と同じ流儀)。skill や MCP を足す場合も
6
+ // ここを呼ぶだけの薄いラッパにし、ロジックを二重に持たない。
7
+ //
8
+ // stw-agent projects list [--q <text>] [--limit <n>] [--cursor <c>] [--json]
9
+ // stw-agent projects context <slug> [--include-slack <true|false>] [--from --to --q --limit] [--json]
10
+ // stw-agent slack channels [--project <slug>] [--limit <n>] [--cursor <c>] [--json]
11
+ // stw-agent slack messages (--project <slug> | --channel <id>) [--from --to --q --limit --thread <ts>] [--cursor <c>] [--json]
12
+ // stw-agent slack next <continuationQuery> [--json]
13
+ //
14
+ // ## 何を出すか
15
+ //
16
+ // 既定は**人と AI が読める 1 行形式**で、メッセージは「時刻・投稿者・本文の先頭・sourceUrl」。
17
+ // 本文を丸ごと出さないのは、会社の内部のやり取りが端末のログや transcript へ機械的に
18
+ // 積み上がるのを避けるため(guide §4「読むときの約束」)。**全文が要るときは `--json`** で、
19
+ // そのとき初めて本文がそのまま出る。
20
+ //
21
+ // ## 出してはいけないもの
22
+ //
23
+ // - **token はどこにも出さない**(stdout / stderr / エラーメッセージ / URL の再掲)
24
+ // - **cursor は stdout にだけ出す。** 次のページを取るには値を渡すしかないので隠せないが、
25
+ // エラー経路(stderr)には URL ごと出さない。cursor は手で作らず、返ってきた値を渡す
26
+ //
27
+ // ## 契約はサーバが持つ
28
+ //
29
+ // 期間の妥当性・`q` の長さ・`threadTs` の組み合わせなどはサーバの zod が判定する
30
+ // (`packages/shared/src/schemas/agent-read.ts`)。CLI 側で先回りして検証すると、
31
+ // 契約が 2 か所に増えて食い違う。**CLI が自分で断るのは「使い方」だけ**
32
+ // (`--project` と `--channel` の排他など、要求を組み立てられない場合)。
33
+
34
+ import { readFile } from 'node:fs/promises';
35
+ import os from 'node:os';
36
+ import path from 'node:path';
37
+ import { pathToFileURL } from 'node:url';
38
+
39
+ /** 接続先の既定。看板の仕様値。別環境は `STW_API_BASE` で上書きする。 */
40
+ export const DEFAULT_API_BASE = 'https://api.shimatoworks.jp';
41
+
42
+ /** 429 を受けたときに待つ上限(秒)。これを超える `Retry-After` は待たずに諦める。 */
43
+ const MAX_RETRY_AFTER_SECONDS = 60;
44
+
45
+ /** テキスト表示で出す本文の長さ。全文は `--json`。 */
46
+ const TEXT_PREVIEW_CHARS = 120;
47
+
48
+ export const USAGE = `使い方:
49
+ stw-agent projects list [--q <text>] [--limit <n>] [--cursor <c>] [--json]
50
+ stw-agent projects context <slug> [--include-slack <true|false>]
51
+ [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>]
52
+ [--q <text>] [--limit <n>] [--json]
53
+ stw-agent slack channels [--project <slug>] [--limit <n>] [--cursor <c>] [--json]
54
+ stw-agent slack messages (--project <slug> | --channel <id>)
55
+ [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--q <text>]
56
+ [--limit <n>] [--thread <ts>] [--cursor <c>] [--json]
57
+ stw-agent slack next <continuationQuery> [--json]
58
+
59
+ 環境変数:
60
+ STW_AGENT_TOKEN 読み取り token(必須)。~/.config/tamai/stw-agent.env から自動で読む
61
+ STW_API_BASE 接続先(既定 ${DEFAULT_API_BASE})
62
+
63
+ set -a && source ~/.config/tamai/stw-agent.env && set +a
64
+
65
+ 読み方の約束・許可の範囲・エラーの意味は stw-manage の
66
+ docs/guides/案件コンテキストの集め方.md を見る。
67
+ 既定の出力は 1 行要約(本文は先頭だけ)。全文が要るときだけ --json を付ける。
68
+ 期間を省略すると当日を含む直近 30 日。続きは continuationQuery を slack next へそのまま渡す。
69
+ `;
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // 引数
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export function cliError(message, code = 'cli_error') {
76
+ const error = new Error(message);
77
+ error.code = code;
78
+ return error;
79
+ }
80
+
81
+ export function usageError(message) {
82
+ return cliError(`${message}\n\n${USAGE.trimEnd()}`, 'usage_error');
83
+ }
84
+
85
+ /**
86
+ * `--name value` / `--name=value` / `--json` を解釈する。
87
+ *
88
+ * `--` の後ろはすべて位置引数(`--` で始まる slug や検索語を渡せるようにする)。
89
+ * 知らないオプションは**黙って捨てずに落とす** —— 綴り違いが「絞ったつもりで全件」になる。
90
+ */
91
+ export function parseArgs(args, allowed = []) {
92
+ const options = {};
93
+ const positional = [];
94
+ let json = false;
95
+
96
+ for (let index = 0; index < args.length; index += 1) {
97
+ const token = args[index];
98
+ if (token === '--') {
99
+ positional.push(...args.slice(index + 1));
100
+ break;
101
+ }
102
+ if (!token.startsWith('--')) {
103
+ positional.push(token);
104
+ continue;
105
+ }
106
+
107
+ const at = token.indexOf('=');
108
+ const name = at === -1 ? token.slice(2) : token.slice(2, at);
109
+ const inline = at === -1 ? undefined : token.slice(at + 1);
110
+
111
+ if (name === 'json') {
112
+ if (inline !== undefined) throw usageError('--json は値を取りません');
113
+ json = true;
114
+ continue;
115
+ }
116
+ if (!allowed.includes(name)) throw usageError(`未対応のオプションです: --${name}`);
117
+
118
+ let value = inline;
119
+ if (value === undefined) {
120
+ value = args[index + 1];
121
+ if (value === undefined || value.startsWith('--')) {
122
+ // `--q --json` のような取り違えを黙って通さない(`--q=--foo` の形なら渡せる)。
123
+ throw usageError(`--${name} には値が必要です`);
124
+ }
125
+ index += 1;
126
+ }
127
+ options[name] = value;
128
+ }
129
+
130
+ return { options, positional, json };
131
+ }
132
+
133
+ /** `--include-slack` のような真偽値。`true` / `false` 以外は落とす。 */
134
+ export function parseBooleanOption(name, value) {
135
+ if (value === undefined) return undefined;
136
+ if (value === 'true') return true;
137
+ if (value === 'false') return false;
138
+ throw usageError(`--${name} は true か false で指定してください`);
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // 設定
143
+ // ---------------------------------------------------------------------------
144
+
145
+ export function defaultEnvFilePath(homedir = os.homedir()) {
146
+ return path.join(homedir, '.config', 'tamai', 'stw-agent.env');
147
+ }
148
+
149
+ /** `KEY=value`(`export` 付き・引用符・行末コメントに耐える)。hub の env 読みと同じ形。 */
150
+ export function parseEnvFile(text) {
151
+ const values = {};
152
+ for (const rawLine of String(text).split('\n')) {
153
+ const line = rawLine.trim();
154
+ if (line === '' || line.startsWith('#')) continue;
155
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
156
+ if (!match) continue;
157
+ const raw = match[2].trim();
158
+ const quoted = /^(['"])([\s\S]*)\1\s*(?:#.*)?$/.exec(raw);
159
+ values[match[1]] = quoted === null ? raw.replace(/\s+#.*$/, '').trim() : quoted[2];
160
+ }
161
+ return values;
162
+ }
163
+
164
+ /**
165
+ * token と接続先を決める。env が優先で、無ければ設定ファイルを読む。
166
+ *
167
+ * **token が無ければここで止める。** 未設定のまま叩くと 401 が返るだけで、
168
+ * 「配布されていない」のか「読み込み忘れ」なのかが分からない。
169
+ */
170
+ export async function configFromEnv(env, readFileImpl = readFile) {
171
+ let token = env.STW_AGENT_TOKEN?.trim();
172
+ let baseUrl = env.STW_API_BASE?.trim();
173
+
174
+ const envFile = env.STW_AGENT_ENV_FILE || defaultEnvFilePath();
175
+ if (!token) {
176
+ try {
177
+ const fromFile = parseEnvFile(await readFileImpl(envFile, 'utf8'));
178
+ token ||= fromFile.STW_AGENT_TOKEN?.trim();
179
+ baseUrl ||= fromFile.STW_API_BASE?.trim();
180
+ } catch {
181
+ // 読めない場合は下の未設定エラーで期待パスを案内する。
182
+ }
183
+ }
184
+
185
+ if (!token) {
186
+ throw cliError(
187
+ 'STW_AGENT_TOKEN を設定してください。\n' +
188
+ `認証ファイル ${envFile} があれば自動で読み込みます。\n` +
189
+ '手動で設定する場合: set -a && source ~/.config/tamai/stw-agent.env && set +a\n' +
190
+ 'token の配布は たま(1Password の stw-manage / PJ開設 service token)。',
191
+ 'token_missing',
192
+ );
193
+ }
194
+
195
+ return { token, baseUrl: (baseUrl || DEFAULT_API_BASE).replace(/\/+$/, '') };
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // API
200
+ // ---------------------------------------------------------------------------
201
+
202
+ export class ApiError extends Error {
203
+ constructor(status, payload, requestPath) {
204
+ super(`agent API returned ${status}`);
205
+ this.status = status;
206
+ this.payload = payload;
207
+ // **query は持たない**(cursor が stderr へ出る経路を作らない)。
208
+ this.requestPath = requestPath;
209
+ }
210
+ }
211
+
212
+ /** `Retry-After`(秒 or HTTP date)をミリ秒へ。読めない・長すぎるなら `null`。 */
213
+ export function retryAfterMs(header, nowMs = Date.now()) {
214
+ if (typeof header !== 'string' || header.trim() === '') return null;
215
+ const seconds = Number(header.trim());
216
+ if (Number.isFinite(seconds)) {
217
+ if (seconds < 0 || seconds > MAX_RETRY_AFTER_SECONDS) return null;
218
+ return Math.ceil(seconds * 1000);
219
+ }
220
+ const at = Date.parse(header);
221
+ if (Number.isNaN(at)) return null;
222
+ const waitMs = at - nowMs;
223
+ if (waitMs <= 0) return 0;
224
+ if (waitMs > MAX_RETRY_AFTER_SECONDS * 1000) return null;
225
+ return waitMs;
226
+ }
227
+
228
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
229
+
230
+ /**
231
+ * GET を 1 本投げる。
232
+ *
233
+ * **429 は `Retry-After` を尊重して 1 回だけ待って再試行する。** 枠は token ごとに
234
+ * 60 回/分・1,000 回/時で、複数のワーカーが同じ token を共有する(guide §2)。
235
+ * 何度も粘ると相手の枠を食うだけなので、1 回で駄目なら人へ返す。
236
+ */
237
+ export function createAgentApi(config, fetchImpl = globalThis.fetch, sleepImpl = defaultSleep) {
238
+ return async function request(requestPath, query = {}) {
239
+ const search = new URLSearchParams();
240
+ for (const [key, value] of Object.entries(query)) {
241
+ if (value !== undefined && value !== null && value !== '') search.set(key, String(value));
242
+ }
243
+ const suffix = search.toString();
244
+ const url = `${config.baseUrl}${requestPath}${suffix ? `?${suffix}` : ''}`;
245
+
246
+ const send = () =>
247
+ fetchImpl(url, {
248
+ method: 'GET',
249
+ headers: {
250
+ // token はここだけに載せる。ログにも例外にも持ち出さない。
251
+ authorization: `Bearer ${config.token}`,
252
+ accept: 'application/json',
253
+ },
254
+ });
255
+
256
+ let response = await send();
257
+ if (response.status === 429) {
258
+ const waitMs = retryAfterMs(response.headers?.get?.('retry-after'));
259
+ if (waitMs !== null) {
260
+ await sleepImpl(waitMs);
261
+ response = await send();
262
+ }
263
+ }
264
+
265
+ const text = await response.text();
266
+ let payload = null;
267
+ if (text !== '') {
268
+ try {
269
+ payload = JSON.parse(text);
270
+ } catch {
271
+ throw cliError(`API から JSON ではない応答を受信しました (${response.status})`);
272
+ }
273
+ }
274
+ // **query を落としてから**エラーへ載せる(cursor が stderr へ出る経路を作らない)。
275
+ if (!response.ok) throw new ApiError(response.status, payload, requestPath.split('?')[0]);
276
+ return payload;
277
+ };
278
+ }
279
+
280
+ /** 人が読む 1 行 + 手がかり。**token も cursor も入れない**。 */
281
+ export function formatFailure(error) {
282
+ if (!(error instanceof ApiError)) {
283
+ return `error: ${error instanceof Error ? error.message : String(error)}`;
284
+ }
285
+ const code = typeof error.payload?.code === 'string' ? error.payload.code : 'unknown';
286
+ const message =
287
+ typeof error.payload?.error === 'string' && error.payload.error !== ''
288
+ ? error.payload.error
289
+ : `agent API returned ${error.status}`;
290
+ const requestId = typeof error.payload?.requestId === 'string' ? error.payload.requestId : '-';
291
+
292
+ const lines = [
293
+ `error: ${message}`,
294
+ ` HTTP ${error.status} ${code} / requestId ${requestId} / ${error.requestPath}`,
295
+ ];
296
+ if (error.status === 401) {
297
+ lines.push(' token が無効か失効しています(STW_AGENT_TOKEN)。配布は たま。');
298
+ }
299
+ if (error.status === 403) {
300
+ lines.push(' token に scope がありません(projects:read / slack:read)。');
301
+ }
302
+ if (error.status === 404) {
303
+ lines.push(' 対象が無いか、この token の許可範囲外です。403 との違いは guide §3 を見る。');
304
+ }
305
+ if (error.status === 429) {
306
+ lines.push(' 枠(60 回/分・1,000 回/時)を超えました。token は他のワーカーと共有です。');
307
+ }
308
+ return lines.join('\n');
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // 表示
313
+ // ---------------------------------------------------------------------------
314
+
315
+ /** 改行と連続空白を潰して 1 行にし、長ければ切る。**全文は `--json`**。 */
316
+ export function previewText(text, limit = TEXT_PREVIEW_CHARS) {
317
+ const flat = String(text ?? '')
318
+ .replace(/\s+/g, ' ')
319
+ .trim();
320
+ if (flat.length <= limit) return flat;
321
+ return `${flat.slice(0, limit)}…`;
322
+ }
323
+
324
+ /** ISO 8601 → JST の `MM/DD HH:MM`。並べたときに縦が揃う長さにする。 */
325
+ export function formatJst(iso) {
326
+ const at = Date.parse(iso ?? '');
327
+ if (Number.isNaN(at)) return '----/-- --:--';
328
+ const jst = new Date(at + 9 * 60 * 60 * 1000);
329
+ const pad = (value) => String(value).padStart(2, '0');
330
+ return `${pad(jst.getUTCMonth() + 1)}/${pad(jst.getUTCDate())} ${pad(jst.getUTCHours())}:${pad(jst.getUTCMinutes())}`;
331
+ }
332
+
333
+ export function formatProjectLines(response) {
334
+ const lines = (response.items ?? []).map((project) => {
335
+ const parent = project.parentSlug ? ` 親=${project.parentSlug}` : '';
336
+ const aliases = project.aliases?.length ? ` 別名=${project.aliases.join(',')}` : '';
337
+ return `${project.slug} ${project.displayName} [${project.lifecycle}/${project.kind}] 入口${project.resourceCount}件${parent}${aliases}`;
338
+ });
339
+ if (lines.length === 0) lines.push('(該当なし)');
340
+ if (response.nextCursor) lines.push(`次: --cursor '${response.nextCursor}'`);
341
+ return lines.join('\n');
342
+ }
343
+
344
+ /**
345
+ * 取り込みの状態を短く 1 語で。
346
+ *
347
+ * **既定の出力に必ず入れる。** これが無いと「空だった」と「読めていない」を画面から
348
+ * 区別できず、同期の止まったチャンネルを「議論が無かった」と読んでしまう
349
+ * (guide §5)。読み手が判断できる最小限として、最終同期の時刻・失敗の有無・
350
+ * さかのぼりの途中かを出す。
351
+ */
352
+ export function formatSyncState(channel) {
353
+ if (channel.syncHealth === 'never_synced') return '同期 まだ(未取り込み)';
354
+ const at = channel.lastSyncAt ? formatJst(channel.lastSyncAt) : '不明';
355
+ const failed = channel.syncHealth === 'error' ? '・失敗あり' : '';
356
+ // 古い側を読み切っていない = 過去の発言がまだ揃っていない。
357
+ const backfill = channel.backfillDone === false ? '・さかのぼり中' : '';
358
+ return `同期 ${at}${failed}${backfill}`;
359
+ }
360
+
361
+ export function formatChannelLines(response) {
362
+ const lines = (response.items ?? []).map((channel) => {
363
+ const projects = channel.projectSlugs?.length ? channel.projectSlugs.join(',') : '-';
364
+ const shared = channel.sharedChannel ? ' 共有' : '';
365
+ const archived = channel.isArchived ? ' アーカイブ済み' : '';
366
+ // bot が居ないチャンネルは本文が 1 件も取れない。件数 0 の理由を先に言う。
367
+ const member = channel.isMember === false ? ' bot未参加' : '';
368
+ return `${channel.channelId} #${channel.name} 案件=${projects}${shared}${archived}${member} ${formatSyncState(channel)}`;
369
+ });
370
+ if (lines.length === 0) lines.push('(該当なし)');
371
+ if (response.nextCursor) lines.push(`次: --cursor '${response.nextCursor}'`);
372
+ return lines.join('\n');
373
+ }
374
+
375
+ /**
376
+ * メッセージ 1 件 1 行。**本文は先頭だけ**(全文は `--json`)。
377
+ *
378
+ * `sourceUrl` を必ず添えるのは、guide §4 が「各判断に URL を付ける」を求めているため。
379
+ * 生成できていない(`sourceUrlKind: 'unavailable'`)ときは `-` を出して、
380
+ * **出典を書けないことを黙らせない**。
381
+ */
382
+ export function formatMessageLines(response, { showChannel = true } = {}) {
383
+ const lines = [];
384
+ const effective = response.effectiveQuery;
385
+ if (effective) {
386
+ lines.push(`期間 ${effective.from}〜${effective.to}${effective.q ? ` / q=${effective.q}` : ''}`);
387
+ }
388
+ const thread = response.thread;
389
+ if (thread) {
390
+ lines.push(`スレッド ${thread.rootTs}(親: ${thread.parentState})`);
391
+ }
392
+
393
+ for (const item of response.items ?? []) {
394
+ const where = showChannel ? `#${item.channelName} ` : '';
395
+ const reply = item.thread?.isReply ? '↳' : ' ';
396
+ const source = item.sourceUrl ?? '-';
397
+ lines.push(
398
+ `${formatJst(item.postedAt)} ${reply}${where}${item.author?.displayName ?? '不明'}: ${previewText(item.text)} ${source}`,
399
+ );
400
+ }
401
+ if ((response.items ?? []).length === 0) lines.push('(該当なし)');
402
+
403
+ if (response.continuationQuery) {
404
+ lines.push(`次: stw-agent slack next '${response.continuationQuery}'`);
405
+ } else if (response.nextCursor) {
406
+ lines.push(`次: --cursor '${response.nextCursor}'`);
407
+ }
408
+ return lines.join('\n');
409
+ }
410
+
411
+ export function formatContextLines(response) {
412
+ const project = response.project;
413
+ const lines = [
414
+ `${project.slug} ${project.displayName} [${project.lifecycle}/${project.kind}]`,
415
+ ];
416
+ if (response.parent) lines.push(`親: ${response.parent.slug}(${response.parent.displayName})`);
417
+ if (response.children?.length) {
418
+ lines.push(`子: ${response.children.map((child) => child.slug).join(', ')}`);
419
+ }
420
+
421
+ lines.push('', '入口:');
422
+ for (const resource of response.resources ?? []) {
423
+ const canonical = resource.isCanonical ? '正本 ' : '';
424
+ const pending = resource.idPending ? ' ID未確認' : '';
425
+ const shared = resource.sharedWith?.length
426
+ ? ` 共有=${resource.sharedWith.map((other) => other.slug).join(',')}`
427
+ : '';
428
+ lines.push(
429
+ ` ${canonical}${resource.system}/${resource.role} ${resource.label} ${resource.url ?? '-'}${pending}${shared}`,
430
+ );
431
+ }
432
+ if ((response.resources ?? []).length === 0) lines.push(' (company の入口なし)');
433
+
434
+ if (!response.slack) {
435
+ lines.push('', 'Slack: 取得していません(--include-slack false)');
436
+ return lines.join('\n');
437
+ }
438
+
439
+ lines.push('', `Slack チャンネル(${response.slack.channels.length} 件):`);
440
+ lines.push(formatChannelLines({ items: response.slack.channels, nextCursor: null }));
441
+ lines.push('', 'Slack 直近:');
442
+ lines.push(
443
+ formatMessageLines({
444
+ items: response.slack.messages.items,
445
+ nextCursor: response.slack.messages.nextCursor,
446
+ continuationQuery: response.slack.messages.continuationQuery,
447
+ effectiveQuery: response.slack.effectiveQuery,
448
+ }),
449
+ );
450
+ return lines.join('\n');
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // サブコマンド
455
+ // ---------------------------------------------------------------------------
456
+
457
+ /**
458
+ * `GET /api/agent/projects` は `q` を受け付けない(`.strict()`)ので、
459
+ * **`--q` は手元で絞る**。guide §4 の手順 1「名称と `aliases` を照合する」を
460
+ * CLI 側で肩代わりするもので、サーバへ未対応の query を送らない。
461
+ */
462
+ export function filterProjects(items, q) {
463
+ if (!q) return items;
464
+ const needle = q.toLowerCase();
465
+ return items.filter((project) =>
466
+ [project.slug, project.displayName, ...(project.aliases ?? [])]
467
+ .filter((value) => typeof value === 'string')
468
+ .some((value) => value.toLowerCase().includes(needle)),
469
+ );
470
+ }
471
+
472
+ const COMMANDS = {
473
+ 'projects list': {
474
+ options: ['q', 'limit', 'cursor'],
475
+ async run({ api, options, json, stdout }) {
476
+ const response = await api('/api/agent/projects', {
477
+ limit: options.limit,
478
+ cursor: options.cursor,
479
+ });
480
+ const filtered = filterProjects(response.items ?? [], options.q);
481
+ if (json) {
482
+ stdout.write(`${JSON.stringify({ ...response, items: filtered }, null, 2)}\n`);
483
+ return;
484
+ }
485
+ stdout.write(`${formatProjectLines({ ...response, items: filtered })}\n`);
486
+ },
487
+ },
488
+
489
+ 'projects context': {
490
+ options: ['include-slack', 'from', 'to', 'q', 'limit'],
491
+ async run({ api, options, positional, json, stdout }) {
492
+ const slug = positional[0];
493
+ if (!slug) throw usageError('案件 slug を指定してください');
494
+ const includeSlack = parseBooleanOption('include-slack', options['include-slack']);
495
+ const response = await api(`/api/agent/projects/${encodeURIComponent(slug)}/context`, {
496
+ includeSlack: includeSlack === undefined ? undefined : String(includeSlack),
497
+ from: options.from,
498
+ to: options.to,
499
+ q: options.q,
500
+ limit: options.limit,
501
+ });
502
+ stdout.write(
503
+ json ? `${JSON.stringify(response, null, 2)}\n` : `${formatContextLines(response)}\n`,
504
+ );
505
+ },
506
+ },
507
+
508
+ 'slack channels': {
509
+ options: ['project', 'limit', 'cursor'],
510
+ async run({ api, options, json, stdout }) {
511
+ const response = await api('/api/agent/slack/channels', {
512
+ project: options.project,
513
+ limit: options.limit,
514
+ cursor: options.cursor,
515
+ });
516
+ stdout.write(
517
+ json ? `${JSON.stringify(response, null, 2)}\n` : `${formatChannelLines(response)}\n`,
518
+ );
519
+ },
520
+ },
521
+
522
+ 'slack messages': {
523
+ options: ['project', 'channel', 'from', 'to', 'q', 'limit', 'thread', 'cursor'],
524
+ async run({ api, options, json, stdout }) {
525
+ // **要求を組み立てられない**のは使い方の誤りなので、CLI が断る。
526
+ // 期間や `q` の妥当性はサーバの契約なので、こちらでは判定しない。
527
+ if (Boolean(options.project) === Boolean(options.channel)) {
528
+ throw usageError('--project か --channel のどちらか一方を指定してください');
529
+ }
530
+ const response = await api('/api/agent/slack/messages', {
531
+ project: options.project,
532
+ channel: options.channel,
533
+ threadTs: options.thread,
534
+ from: options.from,
535
+ to: options.to,
536
+ q: options.q,
537
+ limit: options.limit,
538
+ cursor: options.cursor,
539
+ });
540
+ stdout.write(
541
+ json ? `${JSON.stringify(response, null, 2)}\n` : `${formatMessageLines(response)}\n`,
542
+ );
543
+ },
544
+ },
545
+
546
+ /**
547
+ * 返ってきた `continuationQuery` をそのまま次の要求にする。
548
+ *
549
+ * **cursor を組み立て直さない。** cursor は版・route・並び順・query hash を含む
550
+ * 不透明な値で、手で作ると `400 invalid_cursor` になる(guide §3)。
551
+ */
552
+ 'slack next': {
553
+ options: [],
554
+ async run({ api, positional, json, stdout }) {
555
+ const query = positional[0];
556
+ if (!query) {
557
+ throw usageError('continuationQuery をそのまま渡してください(前回の出力の「次:」行)');
558
+ }
559
+ const response = await requestRawQuery(api, '/api/agent/slack/messages', query);
560
+ stdout.write(
561
+ json ? `${JSON.stringify(response, null, 2)}\n` : `${formatMessageLines(response)}\n`,
562
+ );
563
+ },
564
+ },
565
+ };
566
+
567
+ /**
568
+ * `continuationQuery` を**そのまま**(再エンコードせずに)付けて投げる。
569
+ *
570
+ * `URLSearchParams` を通すと `%2B` などの表現が変わり、cursor の署名と食い違いうる。
571
+ * サーバが返した文字列は既に query として正しいので、触らずに繋ぐ。
572
+ */
573
+ export async function requestRawQuery(api, requestPath, rawQuery) {
574
+ const query = rawQuery.startsWith('?') ? rawQuery.slice(1) : rawQuery;
575
+ // 形の検査だけ行い(query として読めること)、送るのは元の文字列。
576
+ if (Object.keys(Object.fromEntries(new URLSearchParams(query))).length === 0) {
577
+ throw usageError('continuationQuery が空です(前回の出力の「次:」行をそのまま渡す)');
578
+ }
579
+ return api(`${requestPath}?${query}`, {});
580
+ }
581
+
582
+ // ---------------------------------------------------------------------------
583
+ // 入口
584
+ // ---------------------------------------------------------------------------
585
+
586
+ async function execute(argv, dependencies) {
587
+ const [group, verb, ...rest] = argv;
588
+ if (
589
+ group === undefined ||
590
+ group === 'help' ||
591
+ group === '--help' ||
592
+ group === '-h'
593
+ ) {
594
+ dependencies.stdout.write(USAGE);
595
+ return;
596
+ }
597
+
598
+ const name = `${group} ${verb ?? ''}`.trim();
599
+ const command = COMMANDS[name];
600
+ if (!command) throw usageError(`未対応のサブコマンドです: ${name}`);
601
+
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
+ });
614
+ }
615
+
616
+ export async function runCli(argv, overrides = {}) {
617
+ const dependencies = {
618
+ env: overrides.env ?? process.env,
619
+ fetchImpl: overrides.fetchImpl ?? globalThis.fetch,
620
+ readFileImpl: overrides.readFileImpl ?? readFile,
621
+ sleepImpl: overrides.sleepImpl ?? defaultSleep,
622
+ stdout: overrides.stdout ?? process.stdout,
623
+ stderr: overrides.stderr ?? process.stderr,
624
+ };
625
+
626
+ try {
627
+ await execute(argv, dependencies);
628
+ return 0;
629
+ } catch (error) {
630
+ dependencies.stderr.write(`${formatFailure(error)}\n`);
631
+ return 1;
632
+ }
633
+ }
634
+
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));
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@shimatoworks/stw-agent",
3
+ "version": "0.1.0",
4
+ "description": "CLI for the Shimatoworks project-context read API (project registry entries and Slack discussion).",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "bin": {
8
+ "stw-agent": "./bin/stw-agent.mjs"
9
+ },
10
+ "files": [
11
+ "bin/stw-agent.mjs",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/shimatoworks-jp/stw-manage.git",
20
+ "directory": "packages/stw-agent"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "test": "node --test",
27
+ "lint": "eslint"
28
+ }
29
+ }