@helpfeel/cosense-cli 1.9.0 → 1.10.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/README.md +13 -1
- package/package.json +5 -5
- package/src/commands/browsePage.ts +38 -5
- package/src/lib/fileMarkup.ts +169 -0
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Cosenseのページを読み・調べ・編集するAgent SkillとCLI
|
|
|
6
6
|
|
|
7
7
|
### for Claude Code
|
|
8
8
|
|
|
9
|
+
Claude Codeを起動し、インストールコマンドを実行する
|
|
10
|
+
|
|
9
11
|
```
|
|
10
12
|
/plugin marketplace add helpfeel/cosense-cli
|
|
11
13
|
```
|
|
@@ -14,10 +16,20 @@ Cosenseのページを読み・調べ・編集するAgent SkillとCLI
|
|
|
14
16
|
/plugin install cosense-cli@cosense-cli
|
|
15
17
|
```
|
|
16
18
|
|
|
19
|
+
Claude CodeはSkillの自動更新が設定可能です。 `/plugins` からMarketplaceを選択し、有効化してください。
|
|
20
|
+
|
|
17
21
|
### for Codex
|
|
18
22
|
|
|
23
|
+
Codexに入らず、ターミナルでインストールコマンドを実行する
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
npx skills install helpfeel/cosense-cli --agent codex
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
CodexはSkillの自動更新ができません。手動更新してください。
|
|
30
|
+
|
|
19
31
|
```
|
|
20
|
-
|
|
32
|
+
npx skills update cosense
|
|
21
33
|
```
|
|
22
34
|
|
|
23
35
|
## Install CLI
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helpfeel/cosense-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するAgent Skill用のCLI",
|
|
5
5
|
"homepage": "https://github.com/helpfeel/cosense-cli",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,13 +24,13 @@
|
|
|
24
24
|
"format": "oxfmt"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"tsx": "4.
|
|
27
|
+
"tsx": "4.23.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@types/node": "24.13.
|
|
30
|
+
"@types/node": "24.13.3",
|
|
31
31
|
"npm-run-all": "4.1.5",
|
|
32
|
-
"oxfmt": "0.
|
|
33
|
-
"oxlint": "1.
|
|
32
|
+
"oxfmt": "0.58.0",
|
|
33
|
+
"oxlint": "1.74.0",
|
|
34
34
|
"typescript": "6.0.3"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
|
|
2
|
+
import { applyFileMarkup, fetchFileMarkups } from '../lib/fileMarkup.ts';
|
|
2
3
|
import { renderLiterateDatabase } from '../lib/literateDatabase.ts';
|
|
3
4
|
import { parsePageUrl } from '../lib/parseUrl.ts';
|
|
4
5
|
import { fetchRelatedPages } from '../lib/relatedPages.ts';
|
|
@@ -55,7 +56,28 @@ Usage:
|
|
|
55
56
|
0件の場合はセクションごと省略
|
|
56
57
|
|
|
57
58
|
## 本文
|
|
58
|
-
各行の text を改行で結合。fragment 指定行のみ末尾に #<lineId>
|
|
59
|
+
各行の text を改行で結合。fragment 指定行のみ末尾に #<lineId> を付与。
|
|
60
|
+
本文中の、ページと同じhostのアップロードファイルURL (https://<host>/files/<fileId>[.<ext>])
|
|
61
|
+
は、囲むbracketごと1行の <cosense:file> タグに展開する。
|
|
62
|
+
type string? ファイルのContent-Type
|
|
63
|
+
url string 元のファイルURL
|
|
64
|
+
originalname string? アップロード時のファイル名
|
|
65
|
+
size number? ファイルのbyte数
|
|
66
|
+
description string? ファイルから抽出されたテキスト(画像のOCR、PDFの本文等)。
|
|
67
|
+
先頭2000文字まで、超えた分は … に切り詰める。
|
|
68
|
+
改行・"・バックスラッシュは \\n \\" \\\\ にエスケープ
|
|
69
|
+
本文中のGyazo URL (https://gyazo.com/<hash> とその変種) も同様に
|
|
70
|
+
<cosense:gyazo> タグに展開する。
|
|
71
|
+
type string photo または video
|
|
72
|
+
url string 元のGyazo URL
|
|
73
|
+
thumbnail string curl等で直接取得できるプレビュー画像URL。photoは縮小版、
|
|
74
|
+
videoは動画から切り出された静止ポスター
|
|
75
|
+
image string? photoの原寸画像URL
|
|
76
|
+
width number? 原寸の横px
|
|
77
|
+
height number? 原寸の縦px
|
|
78
|
+
title string? Gyazoに設定されたタイトル
|
|
79
|
+
ファイル・Gyazoの情報を取得できなかった時(非公開・削除済み等)や、別host・query/hash
|
|
80
|
+
付きのURLはタグ化されず、そのまま残る
|
|
59
81
|
|
|
60
82
|
-------------------- Related Pages --------------------
|
|
61
83
|
本文と関連ページ一覧の境界を示す非Markdown区切り線。Cosenseの#hashtag記法と
|
|
@@ -274,11 +296,15 @@ interface BodyRender {
|
|
|
274
296
|
matchedFragment: boolean;
|
|
275
297
|
}
|
|
276
298
|
|
|
277
|
-
const renderBody = (
|
|
299
|
+
const renderBody = (
|
|
300
|
+
lines: PageLine[],
|
|
301
|
+
fragment: string | null,
|
|
302
|
+
fileMarkups: Map<string, string>
|
|
303
|
+
): BodyRender => {
|
|
278
304
|
let matchedFragment = false;
|
|
279
305
|
const out: string[] = [];
|
|
280
306
|
for (const line of lines) {
|
|
281
|
-
const text = normalizeIndent(line.text ?? '');
|
|
307
|
+
const text = applyFileMarkup(normalizeIndent(line.text ?? ''), fileMarkups);
|
|
282
308
|
if (fragment && line.id === fragment) {
|
|
283
309
|
matchedFragment = true;
|
|
284
310
|
out.push(`${text}\t#${line.id}`);
|
|
@@ -336,7 +362,7 @@ export const browsePage = async (args: string[]): Promise<void> => {
|
|
|
336
362
|
|
|
337
363
|
if (!persistent) {
|
|
338
364
|
sections.push('(このページはまだ作成されていません)');
|
|
339
|
-
const { body } = renderBody(page.lines ?? [], null);
|
|
365
|
+
const { body } = renderBody(page.lines ?? [], null, new Map());
|
|
340
366
|
sections.push(`## 本文(テンプレート)\n\n${body}`);
|
|
341
367
|
const related = renderRelatedPages(hopValue, page);
|
|
342
368
|
if (related) sections.push(related);
|
|
@@ -344,11 +370,18 @@ export const browsePage = async (args: string[]): Promise<void> => {
|
|
|
344
370
|
return;
|
|
345
371
|
}
|
|
346
372
|
|
|
373
|
+
const fileMarkups = await fetchFileMarkups(
|
|
374
|
+
(page.lines ?? []).map(line => line.text ?? ''),
|
|
375
|
+
origin,
|
|
376
|
+
credential
|
|
377
|
+
);
|
|
378
|
+
|
|
347
379
|
// 本文を先にrenderしてfragment一致状況を取得し、タイトル直後の説明文に反映する
|
|
348
380
|
const validFragment = fragment !== null && LINE_ID_PATTERN.test(fragment);
|
|
349
381
|
const { body, matchedFragment } = renderBody(
|
|
350
382
|
page.lines ?? [],
|
|
351
|
-
validFragment ? fragment : null
|
|
383
|
+
validFragment ? fragment : null,
|
|
384
|
+
fileMarkups
|
|
352
385
|
);
|
|
353
386
|
if (fragment !== null) {
|
|
354
387
|
if (!validFragment) {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { requestJson } from './request.ts';
|
|
2
|
+
import type { Credential } from './settings.ts';
|
|
3
|
+
|
|
4
|
+
// 本文中のGCSアップロードファイルURL。末尾の (?![^\s[\]]) 境界で、直後に空白・bracket・
|
|
5
|
+
// 行末以外(?・#・余分なpath等)が続くURLは弾き、clean URLだけをマッチする。query/hash
|
|
6
|
+
// 付き等はマッチせず本文にそのまま残る。拡張子は .tar.gz 等の多重ドットも丸ごと取る
|
|
7
|
+
const FILE_URL_PATTERN =
|
|
8
|
+
/https?:\/\/[^\s[\]]+\/files\/([0-9a-f]{24})(?:\.[A-Za-z0-9]+)*(?![^\s[\]])/g;
|
|
9
|
+
|
|
10
|
+
// Gyazo URLの変種(i.gyazo.com/<hash>.<ext>、/raw付き、t.gyazo.com/teams/<team>/<hash>)
|
|
11
|
+
// もまとめてマッチし、hashでoEmbedを引く。FILE_URL_PATTERNと同じ末尾境界でquery/hash付きは残す
|
|
12
|
+
const GYAZO_URL_PATTERN =
|
|
13
|
+
/https?:\/\/(?:[a-z0-9-]+\.)?gyazo\.com\/(?:teams\/[^\s/[\]]+\/)?([0-9a-f]{32})(?:\/raw|\.[A-Za-z0-9]+)?(?![^\s[\]])/g;
|
|
14
|
+
|
|
15
|
+
// 本文の行を1回のスキャンで両方置換するための結合パターン
|
|
16
|
+
const EMBED_URL_PATTERN = new RegExp(
|
|
17
|
+
`${FILE_URL_PATTERN.source}|${GYAZO_URL_PATTERN.source}`,
|
|
18
|
+
'g'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
interface FileInfo {
|
|
22
|
+
contentType?: string;
|
|
23
|
+
originalname?: string;
|
|
24
|
+
size?: number;
|
|
25
|
+
text?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface GyazoOEmbed {
|
|
29
|
+
type?: string;
|
|
30
|
+
url?: string;
|
|
31
|
+
thumbnail_url?: string;
|
|
32
|
+
// 読めないhashでも200が返り、width/heightが空文字になる。numberであることが
|
|
33
|
+
// 実体の存在確認になる
|
|
34
|
+
width?: number | string;
|
|
35
|
+
height?: number | string;
|
|
36
|
+
title?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// browsePage出力全体の肥大を防ぐ。全文が必要ならreadFileInfoで取れる
|
|
40
|
+
const DESCRIPTION_MAX_CHARS = 2000;
|
|
41
|
+
|
|
42
|
+
// 雰囲気マークアップを1行に収めて本文の行構造を保つ
|
|
43
|
+
const escapeAttr = (value: string): string =>
|
|
44
|
+
value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n');
|
|
45
|
+
|
|
46
|
+
const renderFileTag = (url: string, info: FileInfo): string => {
|
|
47
|
+
const attrs: string[] = [];
|
|
48
|
+
if (info.contentType) attrs.push(`type="${escapeAttr(info.contentType)}"`);
|
|
49
|
+
attrs.push(`url="${escapeAttr(url)}"`);
|
|
50
|
+
if (info.originalname) {
|
|
51
|
+
attrs.push(`originalname="${escapeAttr(info.originalname)}"`);
|
|
52
|
+
}
|
|
53
|
+
if (typeof info.size === 'number') attrs.push(`size="${info.size}"`);
|
|
54
|
+
const text = info.text?.trim();
|
|
55
|
+
if (text) {
|
|
56
|
+
const truncated =
|
|
57
|
+
text.length > DESCRIPTION_MAX_CHARS
|
|
58
|
+
? `${text.slice(0, DESCRIPTION_MAX_CHARS)}…`
|
|
59
|
+
: text;
|
|
60
|
+
attrs.push(`description="${escapeAttr(truncated)}"`);
|
|
61
|
+
}
|
|
62
|
+
return `<cosense:file ${attrs.join(' ')}>`;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const renderGyazoTag = (
|
|
66
|
+
url: string,
|
|
67
|
+
hash: string,
|
|
68
|
+
info: GyazoOEmbed
|
|
69
|
+
): string | null => {
|
|
70
|
+
const attrs: string[] = [`type="${escapeAttr(info.type ?? '')}"`];
|
|
71
|
+
attrs.push(`url="${escapeAttr(url)}"`);
|
|
72
|
+
if (info.type === 'photo') {
|
|
73
|
+
if (typeof info.width !== 'number' || !info.url) return null;
|
|
74
|
+
attrs.push(`thumbnail="https://gyazo.com/${hash}/thumb/1000"`);
|
|
75
|
+
attrs.push(`image="${escapeAttr(info.url)}"`);
|
|
76
|
+
} else if (info.type === 'video') {
|
|
77
|
+
if (!info.thumbnail_url) return null;
|
|
78
|
+
attrs.push(`thumbnail="${escapeAttr(info.thumbnail_url)}"`);
|
|
79
|
+
} else {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (typeof info.width === 'number') attrs.push(`width="${info.width}"`);
|
|
83
|
+
if (typeof info.height === 'number') attrs.push(`height="${info.height}"`);
|
|
84
|
+
if (info.title) attrs.push(`title="${escapeAttr(info.title)}"`);
|
|
85
|
+
return `<cosense:gyazo ${attrs.join(' ')}>`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const collectMatches = (
|
|
89
|
+
lines: string[],
|
|
90
|
+
pattern: RegExp
|
|
91
|
+
): Map<string, string> => {
|
|
92
|
+
const found = new Map<string, string>();
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
for (const m of line.matchAll(pattern)) {
|
|
95
|
+
found.set(m[0], m[1] as string);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return found;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const fetchFileMarkups = async (
|
|
102
|
+
lines: string[],
|
|
103
|
+
pageOrigin: string,
|
|
104
|
+
pageCredential: Credential | undefined
|
|
105
|
+
): Promise<Map<string, string>> => {
|
|
106
|
+
const files = collectMatches(lines, FILE_URL_PATTERN);
|
|
107
|
+
const gyazos = collectMatches(lines, GYAZO_URL_PATTERN);
|
|
108
|
+
const markups = new Map<string, string>();
|
|
109
|
+
|
|
110
|
+
// ページと同じhostのアップロードファイルだけタグ展開する。cross-originのURLに
|
|
111
|
+
// ページのcredential(env COSENSE_PATを含む)を送るとPAT漏洩になるため、別host・
|
|
112
|
+
// 不正URL・取得失敗(404・権限なし・非公開Gyazo等)はいずれもURLのまま本文に残す。
|
|
113
|
+
// 従来の出力と同じに劣化するだけで、AIはURLからファイルの存在を認知できる
|
|
114
|
+
const fileTasks = [...files].map(async ([url, fileId]) => {
|
|
115
|
+
try {
|
|
116
|
+
if (new URL(url).origin !== pageOrigin) return;
|
|
117
|
+
const info = (await requestJson(`${pageOrigin}/api/gcs/${fileId}/info`, {
|
|
118
|
+
credential: pageCredential
|
|
119
|
+
})) as FileInfo;
|
|
120
|
+
markups.set(url, renderFileTag(url, info));
|
|
121
|
+
} catch {}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// 同じhashの変種URLが複数あってもoEmbedは1回だけ引く
|
|
125
|
+
const gyazoInfos = new Map<string, Promise<GyazoOEmbed | null>>();
|
|
126
|
+
for (const hash of new Set(gyazos.values())) {
|
|
127
|
+
gyazoInfos.set(
|
|
128
|
+
hash,
|
|
129
|
+
requestJson(
|
|
130
|
+
`https://api.gyazo.com/api/oembed?url=https://gyazo.com/${hash}`
|
|
131
|
+
).then(
|
|
132
|
+
info => info as GyazoOEmbed,
|
|
133
|
+
() => null
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const gyazoTasks = [...gyazos].map(async ([url, hash]) => {
|
|
138
|
+
const info = await gyazoInfos.get(hash);
|
|
139
|
+
if (!info) return;
|
|
140
|
+
const markup = renderGyazoTag(url, hash, info);
|
|
141
|
+
if (markup) markups.set(url, markup);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await Promise.all([...fileTasks, ...gyazoTasks]);
|
|
145
|
+
return markups;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const applyFileMarkup = (
|
|
149
|
+
line: string,
|
|
150
|
+
markups: Map<string, string>
|
|
151
|
+
): string => {
|
|
152
|
+
if (markups.size === 0) return line;
|
|
153
|
+
let out = '';
|
|
154
|
+
let cursor = 0;
|
|
155
|
+
for (const m of line.matchAll(EMBED_URL_PATTERN)) {
|
|
156
|
+
const markup = markups.get(m[0]);
|
|
157
|
+
if (!markup) continue;
|
|
158
|
+
let start = m.index;
|
|
159
|
+
let end = start + m[0].length;
|
|
160
|
+
// 画像記法 [url] や強調 [[url]] はbracketごとタグに置き換える
|
|
161
|
+
while (line[start - 1] === '[' && line[end] === ']') {
|
|
162
|
+
start -= 1;
|
|
163
|
+
end += 1;
|
|
164
|
+
}
|
|
165
|
+
out += line.slice(cursor, start) + markup;
|
|
166
|
+
cursor = end;
|
|
167
|
+
}
|
|
168
|
+
return out + line.slice(cursor);
|
|
169
|
+
};
|