@helpfeel/cosense-cli 1.4.6 → 1.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helpfeel/cosense-cli",
3
- "version": "1.4.6",
3
+ "version": "1.5.1",
4
4
  "description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するAgent Skill用のCLI",
5
5
  "homepage": "https://github.com/helpfeel/cosense-cli",
6
6
  "license": "MIT",
@@ -24,14 +24,14 @@
24
24
  "format": "oxfmt"
25
25
  },
26
26
  "dependencies": {
27
- "tsx": "4.21.0"
27
+ "tsx": "4.22.4"
28
28
  },
29
29
  "devDependencies": {
30
- "@types/node": "24.12.2",
30
+ "@types/node": "24.13.2",
31
31
  "npm-run-all": "4.1.5",
32
- "oxfmt": "0.46.0",
33
- "oxlint": "1.61.0",
34
- "typescript": "6.0.2"
32
+ "oxfmt": "0.54.0",
33
+ "oxlint": "1.70.0",
34
+ "typescript": "6.0.3"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=24"
@@ -16,9 +16,9 @@ import {
16
16
  import { resolveCredential } from '../lib/settings.ts';
17
17
 
18
18
  export const browsePageSummary =
19
- '単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する。行permalink (`#<lineId>`) 付きなら該当行をマークする';
19
+ '単一ページを読む。メタデータ+アイコン記法+テロメア+Infobox+本文をAIが読みやすい形式で出力する。行permalink (`#<lineId>`) 付きなら該当行をマークする';
20
20
 
21
- export const browsePageHelp = `browsePage - 単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する。行permalink (#<lineId>) 付きなら該当行をマークする
21
+ export const browsePageHelp = `browsePage - 単一ページを読む。メタデータ+アイコン記法+テロメア+Infobox+本文をAIが読みやすい形式で出力する。行permalink (#<lineId>) 付きなら該当行をマークする
22
22
 
23
23
  Usage:
24
24
  cosense browsePage <pageUrl>
@@ -46,6 +46,12 @@ Usage:
46
46
  lines[] を最終更新者でグルーピングし、 displayName 更新期間 YYYY/M/D 〜 YYYY/M/D N行更新
47
47
  の形式で行数降順に全員出力
48
48
 
49
+ ## Infobox
50
+ このページ本文から抜き出された Infobox を出力する。各 Infobox を ### <title> の
51
+ 見出しと - キー: 値 の箇条書きで表す。値はCosense記法のまま。
52
+ hallucination または truncated と判定された Infobox は除外する。
53
+ 0件の場合はセクションごと省略
54
+
49
55
  ## 本文
50
56
  各行の text を改行で結合。fragment 指定行のみ末尾に #<lineId> を付与
51
57
 
@@ -56,7 +62,7 @@ Usage:
56
62
  このページの 1-hop 近傍ページタイトル一覧。 1-hop が 0 件なら区切り線ごと省略
57
63
 
58
64
  persistent: false の時:
59
- メタデータ・アイコン・テロメアは省略。 (このページはまだ作成されていません) と
65
+ メタデータ・アイコン・テロメア・Infoboxは省略。 (このページはまだ作成されていません) と
60
66
  本文(テンプレート)と Related Pages を出力する
61
67
 
62
68
  URLに #<lineId> fragmentが指定された時:
@@ -81,6 +87,13 @@ interface UserRef {
81
87
  displayName?: string;
82
88
  }
83
89
 
90
+ interface InfoboxResult {
91
+ title?: string;
92
+ infobox?: Record<string, string>;
93
+ hallucination?: boolean;
94
+ truncated?: boolean;
95
+ }
96
+
84
97
  interface PageData {
85
98
  id?: string;
86
99
  commitId?: string;
@@ -102,6 +115,7 @@ interface PageData {
102
115
  users?: UserRef[];
103
116
  lines?: PageLine[];
104
117
  icons?: string[];
118
+ infoboxResult?: InfoboxResult[];
105
119
  }
106
120
 
107
121
  const LINE_ID_PATTERN = /^[0-9a-f]{24}$/;
@@ -224,6 +238,23 @@ const renderTelomere = (
224
238
  return `## テロメアのサマリー\n\n${lines.join('\n')}`;
225
239
  };
226
240
 
241
+ const renderInfobox = (results: InfoboxResult[] | undefined): string | null => {
242
+ if (!results) return null;
243
+ const blocks: string[] = [];
244
+ for (const result of results) {
245
+ // hallucination=AI補完の不確実値、truncated=抽出失敗で値が不完全。どちらも
246
+ // 信頼できないので丸ごと除外する
247
+ if (result.hallucination || result.truncated) continue;
248
+ const rows = Object.entries(result.infobox ?? {}).map(
249
+ ([key, value]) => `- ${key}: ${value}`
250
+ );
251
+ if (rows.length === 0) continue;
252
+ blocks.push(`### ${result.title ?? ''}\n\n${rows.join('\n')}`);
253
+ }
254
+ if (blocks.length === 0) return null;
255
+ return `## Infobox\n\n${blocks.join('\n\n')}`;
256
+ };
257
+
227
258
  interface BodyRender {
228
259
  body: string;
229
260
  matchedFragment: boolean;
@@ -324,6 +355,9 @@ export const browsePage = async (args: string[]): Promise<void> => {
324
355
  const telomereSection = renderTelomere(telomere, userMap);
325
356
  if (telomereSection) sections.push(telomereSection);
326
357
 
358
+ const infoboxSection = renderInfobox(page.infoboxResult);
359
+ if (infoboxSection) sections.push(infoboxSection);
360
+
327
361
  sections.push(`## 本文\n\n${body}`);
328
362
 
329
363
  const related = renderRelatedPages(hopValue);
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
2
3
  import { parseProjectUrlStrict } from '../lib/parseUrl.ts';
3
4
  import { requestJson } from '../lib/request.ts';
4
5
  import { resolveUserCredential } from '../lib/settings.ts';
@@ -11,6 +12,8 @@ export const previewEditHelp = `previewEdit - ページ編集opsをdry-runして
11
12
  Usage:
12
13
  cosense previewEdit <projectUrl> <pageId> < ops.json 既存ページの編集 (stdinはops JSON)
13
14
  cosense previewEdit --new <projectUrl> < body.txt 新規ページ作成 (stdinはプレーンテキスト本文)
15
+ cosense previewEdit --input-file ops.json <projectUrl> <pageId>
16
+ cosense previewEdit --new --input-file body.txt <projectUrl>
14
17
  printf '%s' '<opsJSON>' | cosense previewEdit <projectUrl> <pageId>
15
18
  printf '%s' '<text>' | cosense previewEdit --new <projectUrl>
16
19
 
@@ -22,6 +25,8 @@ Usage:
22
25
  オプション:
23
26
  --new stdin をプレーンテキスト本文として受け取り、 新規ページを作る。 改行で複数行に分割され、
24
27
  1行目が page title、 2行目以降が本文として扱われる。 ops JSON を組み立てる必要は無い
28
+ --input-file <path>
29
+ stdin の代わりに UTF-8 テキストファイルから入力を読む。 指定時は stdin を読まない
25
30
 
26
31
  stdinから受け取る入力形式(既存ページ編集モード, JSON):
27
32
  {
@@ -234,23 +239,75 @@ const readStdin = async (): Promise<string> => {
234
239
  return Buffer.concat(chunks).toString('utf8');
235
240
  };
236
241
 
242
+ // --input-file はバイト列を読んで UTF-8 として厳格に decode する。 TextDecoder の既定は
243
+ // ignoreBOM: false なので先頭 BOM を除去し、 fatal: true で BOM付き UTF-16 や不正バイトを
244
+ // 例外で弾く。 ただし BOM なし UTF-16LE は各バイトが偶然 valid UTF-8 になり fatal をすり抜けて
245
+ // NUL 混じり文字列になる事があるので、 decode 後に NUL を弾く。 stdin 経由 (readStdin の
246
+ // toString('utf8')) は不正バイトを置換して通すが、 こちらは「文字化けしたまま書き込み成功」を
247
+ // 防ぐため早期に失敗させる。
248
+ const readInputFileUtf8 = async (path: string): Promise<string> => {
249
+ let bytes: Buffer;
250
+ try {
251
+ bytes = await readFile(path);
252
+ } catch (err) {
253
+ throw new Error(
254
+ `--input-file: failed to read "${path}": ${err instanceof Error ? err.message : String(err)}`
255
+ );
256
+ }
257
+ let text: string;
258
+ try {
259
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
260
+ } catch {
261
+ throw new Error(
262
+ `--input-file: "${path}" is not valid UTF-8. Rewrite the file as UTF-8 (not UTF-16) and retry.`
263
+ );
264
+ }
265
+ if (text.includes('\u0000')) {
266
+ throw new Error(
267
+ `--input-file: "${path}" contains NUL, which suggests UTF-16 or binary, not UTF-8 text. Rewrite the file as UTF-8 (not UTF-16) and retry.`
268
+ );
269
+ }
270
+ return text;
271
+ };
272
+
237
273
  interface ParsedArgs {
238
274
  isNew: boolean;
239
275
  projectUrl: string;
240
276
  pageId: string | undefined;
277
+ inputFile: string | undefined;
241
278
  }
242
279
 
243
280
  const parseArgs = (args: string[]): ParsedArgs => {
244
281
  const usage =
245
282
  'Usage: cosense previewEdit <projectUrl> <pageId> < ops.json (use --new <projectUrl> < body.txt for new pages)';
246
283
  let isNew = false;
284
+ let inputFile: string | undefined;
247
285
  const positional: string[] = [];
248
- for (const arg of args) {
286
+ for (let i = 0; i < args.length; i++) {
287
+ const arg = args[i] as string;
249
288
  if (arg === '--new') {
250
289
  if (isNew) {
251
290
  throw new Error(`Duplicate option: --new\n${usage}`);
252
291
  }
253
292
  isNew = true;
293
+ } else if (arg === '--input-file') {
294
+ if (inputFile !== undefined) {
295
+ throw new Error(`Duplicate option: --input-file\n${usage}`);
296
+ }
297
+ // 空文字を素通しすると後段の truthy 判定 (!inputFile / inputFile ? ...) で
298
+ // 「未指定」と同じ扱いになり、 stdin を読んでしまう。 値欠落として弾く
299
+ const value = args[++i];
300
+ if (value === undefined || value === '') {
301
+ throw new Error(`Missing value for --input-file\n${usage}`);
302
+ }
303
+ // 次トークンが別オプション (--new 等) の時はパスの書き忘れとみなし、 黙って
304
+ // ファイル名として消費しない。 `--` 始まりの実ファイルは ./--name で渡せる
305
+ if (value.startsWith('--')) {
306
+ throw new Error(
307
+ `--input-file expects a file path, but got "${value}". A path must immediately follow --input-file.\n${usage}`
308
+ );
309
+ }
310
+ inputFile = value;
254
311
  } else if (arg.startsWith('--')) {
255
312
  throw new Error(`Unknown option: ${arg}\n${usage}`);
256
313
  } else {
@@ -263,7 +320,12 @@ const parseArgs = (args: string[]): ParsedArgs => {
263
320
  'Usage: cosense previewEdit --new <projectUrl> < body.txt'
264
321
  );
265
322
  }
266
- return { isNew, projectUrl: positional[0] as string, pageId: undefined };
323
+ return {
324
+ isNew,
325
+ projectUrl: positional[0] as string,
326
+ pageId: undefined,
327
+ inputFile
328
+ };
267
329
  }
268
330
  if (positional.length !== 2) {
269
331
  throw new Error(usage);
@@ -271,28 +333,32 @@ const parseArgs = (args: string[]): ParsedArgs => {
271
333
  return {
272
334
  isNew,
273
335
  projectUrl: positional[0] as string,
274
- pageId: positional[1] as string
336
+ pageId: positional[1] as string,
337
+ inputFile
275
338
  };
276
339
  };
277
340
 
278
341
  export const previewEdit = async (args: string[]): Promise<void> => {
279
- const { isNew, projectUrl, pageId } = parseArgs(args);
342
+ const { isNew, projectUrl, pageId, inputFile } = parseArgs(args);
280
343
 
281
- if (process.stdin.isTTY) {
344
+ if (!inputFile && process.stdin.isTTY) {
282
345
  throw new Error(
283
346
  isNew
284
- ? 'previewEdit --new reads plain text body from stdin. Pipe it in, e.g. `printf "Title\\nbody\\n" | cosense previewEdit --new <projectUrl>`.'
285
- : 'previewEdit reads ops JSON from stdin. Pipe it in, e.g. `cosense previewEdit <projectUrl> <pageId> < ops.json`.'
347
+ ? 'previewEdit --new reads plain text body from stdin or --input-file. Pipe it in, e.g. `printf "Title\\nbody\\n" | cosense previewEdit --new <projectUrl>`, or pass a UTF-8 file with `--input-file body.txt`.'
348
+ : 'previewEdit reads ops JSON from stdin or --input-file. Pipe it in, e.g. `cosense previewEdit <projectUrl> <pageId> < ops.json`, or pass a UTF-8 file with `--input-file ops.json`.'
286
349
  );
287
350
  }
288
351
 
289
352
  const { origin, projectName } = parseProjectUrlStrict(projectUrl);
290
- const stdinRaw = await readStdin();
291
- if (!stdinRaw.trim()) {
353
+ const rawInput = inputFile
354
+ ? await readInputFileUtf8(inputFile)
355
+ : await readStdin();
356
+ if (!rawInput.trim()) {
357
+ const source = inputFile ? `input file "${inputFile}"` : 'stdin';
292
358
  throw new Error(
293
359
  isNew
294
- ? 'stdin is empty. Pipe page body (plain text) to stdin.'
295
- : 'stdin is empty. Pipe ops JSON to stdin.'
360
+ ? `${source} is empty. Provide page body (plain text).`
361
+ : `${source} is empty. Provide ops JSON.`
296
362
  );
297
363
  }
298
364
 
@@ -302,15 +368,16 @@ export const previewEdit = async (args: string[]): Promise<void> => {
302
368
  // 慣習で末尾の単一改行 (LF または CRLF) だけ取り除く
303
369
  let ops: unknown;
304
370
  if (isNew) {
305
- const body = stdinRaw.replace(/\r?\n$/, '');
371
+ const body = rawInput.replace(/\r?\n$/, '');
306
372
  ops = [{ insertBefore: '_end', text: body }];
307
373
  } else {
308
374
  let parsed: unknown;
309
375
  try {
310
- parsed = JSON.parse(stdinRaw);
376
+ parsed = JSON.parse(rawInput);
311
377
  } catch (err) {
378
+ const source = inputFile ? `input file "${inputFile}"` : 'stdin';
312
379
  throw new Error(
313
- `stdin is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
380
+ `${source} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
314
381
  );
315
382
  }
316
383
  ops = (parsed as { ops?: unknown }).ops;