@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 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
- $skill-installer https://github.com/helpfeel/cosense-cli
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.9.0",
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.22.4"
27
+ "tsx": "4.23.0"
28
28
  },
29
29
  "devDependencies": {
30
- "@types/node": "24.13.2",
30
+ "@types/node": "24.13.3",
31
31
  "npm-run-all": "4.1.5",
32
- "oxfmt": "0.56.0",
33
- "oxlint": "1.71.0",
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 = (lines: PageLine[], fragment: string | null): BodyRender => {
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
+ };