@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 +6 -6
- package/src/commands/browsePage.ts +37 -3
- package/src/commands/previewEdit.ts +81 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helpfeel/cosense-cli",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
27
|
+
"tsx": "4.22.4"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@types/node": "24.
|
|
30
|
+
"@types/node": "24.13.2",
|
|
31
31
|
"npm-run-all": "4.1.5",
|
|
32
|
-
"oxfmt": "0.
|
|
33
|
-
"oxlint": "1.
|
|
34
|
-
"typescript": "6.0.
|
|
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
|
-
'
|
|
19
|
+
'単一ページを読む。メタデータ+アイコン記法+テロメア+Infobox+本文をAIが読みやすい形式で出力する。行permalink (`#<lineId>`) 付きなら該当行をマークする';
|
|
20
20
|
|
|
21
|
-
export const browsePageHelp = `browsePage -
|
|
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 (
|
|
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 {
|
|
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
|
|
291
|
-
|
|
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
|
-
?
|
|
295
|
-
:
|
|
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 =
|
|
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(
|
|
376
|
+
parsed = JSON.parse(rawInput);
|
|
311
377
|
} catch (err) {
|
|
378
|
+
const source = inputFile ? `input file "${inputFile}"` : 'stdin';
|
|
312
379
|
throw new Error(
|
|
313
|
-
|
|
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;
|