@helpfeel/cosense-cli 1.4.5 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helpfeel/cosense-cli",
3
- "version": "1.4.5",
3
+ "version": "1.5.0",
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"
@@ -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;
@@ -21,8 +21,9 @@ Usage:
21
21
 
22
22
  各 Page の field:
23
23
  title string ページタイトル
24
- image string サムネイル画像URL
24
+ image string | null サムネイル画像URL
25
25
  score number 類似度スコア(高いほど近い)
26
+ linked number? 被リンク数
26
27
  exists boolean 実体のあるページなら true。false の場合は空ページ(リンク記法だけ存在)
27
28
 
28
29
  exists=true のページのみ追加で付くfield:
@@ -31,7 +32,6 @@ Usage:
31
32
  lastUpdateUser User | null 最終更新者
32
33
  users Array<User> 更新者リスト
33
34
  views number 閲覧数
34
- linked number 被リンク数
35
35
  created string 作成日時
36
36
  updated string 更新日時
37
37
  pageRank number PageRank
@@ -52,7 +52,7 @@ User の field(user / lastUpdateUser / users[] で共通):
52
52
  {
53
53
  "pages": [
54
54
  { "id": "...", "title": "vibe coding", "score": 0.833, "exists": true },
55
- { "title": "bug修正", "score": 0.811, "exists": false }
55
+ { "title": "bug修正", "score": 0.811, "linked": 3, "exists": false }
56
56
  ]
57
57
  }
58
58
  `;