@helpfeel/cosense-cli 1.3.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/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @helpfeel/cosense-cli
2
+
3
+ Cosenseのページを読み・調べ・編集する為のCLIとAgent Skill
4
+
5
+ ## Install
6
+
7
+ ### CLI
8
+
9
+ ```bash
10
+ npm install -g https://github.com/helpfeel/cosense-cli
11
+ cosense --help
12
+ ```
13
+
14
+ ### Claude Code Agent Skill
15
+
16
+ ```
17
+ /plugin marketplace add helpfeel/cosense-cli
18
+ ```
19
+
20
+ ```
21
+ /plugin install cosense-cli@cosense-cli
22
+ ```
23
+
24
+ Skillの実行にはCLIが必要
25
+
26
+ ## 開発
27
+
28
+ - install [direnv](https://direnv.net/)
29
+ - run `npm install`
30
+ - run `claude` or `codex`
package/bin/cosense ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { tsImport } from "tsx/esm/api";
3
+ await tsImport("../src/cli.ts", import.meta.url);
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@helpfeel/cosense-cli",
3
+ "version": "1.3.0",
4
+ "description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するCLI",
5
+ "homepage": "https://github.com/helpfeel/cosense-cli",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/helpfeel/cosense-cli.git"
10
+ },
11
+ "bin": {
12
+ "cosense": "./bin/cosense"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "src"
17
+ ],
18
+ "type": "module",
19
+ "scripts": {
20
+ "lint": "run-s lint:**",
21
+ "lint:oxfmt": "oxfmt --check",
22
+ "lint:tsc": "tsc -p tsconfig.json",
23
+ "lint:oxlint": "oxlint --report-unused-disable-directives-severity error",
24
+ "format": "oxfmt"
25
+ },
26
+ "dependencies": {
27
+ "tsx": "4.21.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "24.12.2",
31
+ "npm-run-all": "4.1.5",
32
+ "oxfmt": "0.46.0",
33
+ "oxlint": "1.61.0",
34
+ "typescript": "6.0.2"
35
+ },
36
+ "engines": {
37
+ "node": ">=24"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "registry": "https://registry.npmjs.com/"
42
+ }
43
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ browsePage,
7
+ browsePageHelp,
8
+ browsePageSummary
9
+ } from './commands/browsePage.ts';
10
+ import {
11
+ browseRelatedPages,
12
+ browseRelatedPagesHelp,
13
+ browseRelatedPagesSummary
14
+ } from './commands/browseRelatedPages.ts';
15
+ import {
16
+ list1hopLinks,
17
+ list1hopLinksHelp,
18
+ list1hopLinksSummary
19
+ } from './commands/list1hopLinks.ts';
20
+ import {
21
+ list2hopLinks,
22
+ list2hopLinksHelp,
23
+ list2hopLinksSummary
24
+ } from './commands/list2hopLinks.ts';
25
+ import {
26
+ listPages,
27
+ listPagesHelp,
28
+ listPagesSummary
29
+ } from './commands/listPages.ts';
30
+ import {
31
+ listProjects,
32
+ listProjectsHelp,
33
+ listProjectsSummary
34
+ } from './commands/listProjects.ts';
35
+ import { login, loginHelp, loginSummary } from './commands/login.ts';
36
+ import {
37
+ previewEdit,
38
+ previewEditHelp,
39
+ previewEditSummary
40
+ } from './commands/previewEdit.ts';
41
+ import {
42
+ readPage,
43
+ readPageHelp,
44
+ readPageSummary
45
+ } from './commands/readPage.ts';
46
+ import {
47
+ readProjectMembers,
48
+ readProjectMembersHelp,
49
+ readProjectMembersSummary
50
+ } from './commands/readProjectMembers.ts';
51
+ import {
52
+ submitEdit,
53
+ submitEditHelp,
54
+ submitEditSummary
55
+ } from './commands/submitEdit.ts';
56
+ import {
57
+ search1hopLinks,
58
+ search1hopLinksHelp,
59
+ search1hopLinksSummary
60
+ } from './commands/search1hopLinks.ts';
61
+ import {
62
+ search2hopLinks,
63
+ search2hopLinksHelp,
64
+ search2hopLinksSummary
65
+ } from './commands/search2hopLinks.ts';
66
+ import {
67
+ searchFullText,
68
+ searchFullTextHelp,
69
+ searchFullTextSummary
70
+ } from './commands/searchFullText.ts';
71
+ import {
72
+ searchVector,
73
+ searchVectorHelp,
74
+ searchVectorSummary
75
+ } from './commands/searchVector.ts';
76
+ import { whoami, whoamiHelp, whoamiSummary } from './commands/whoami.ts';
77
+
78
+ interface CommandSpec {
79
+ handler: (args: string[]) => Promise<void>;
80
+ summary: string;
81
+ help: string;
82
+ }
83
+
84
+ const packageJsonPath = join(
85
+ dirname(fileURLToPath(import.meta.url)),
86
+ '..',
87
+ 'package.json'
88
+ );
89
+ const { version } = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
90
+ version: string;
91
+ };
92
+
93
+ const commands: Record<string, CommandSpec> = {
94
+ login: { handler: login, summary: loginSummary, help: loginHelp },
95
+ whoami: { handler: whoami, summary: whoamiSummary, help: whoamiHelp },
96
+ listProjects: {
97
+ handler: listProjects,
98
+ summary: listProjectsSummary,
99
+ help: listProjectsHelp
100
+ },
101
+ browsePage: {
102
+ handler: browsePage,
103
+ summary: browsePageSummary,
104
+ help: browsePageHelp
105
+ },
106
+ browseRelatedPages: {
107
+ handler: browseRelatedPages,
108
+ summary: browseRelatedPagesSummary,
109
+ help: browseRelatedPagesHelp
110
+ },
111
+ readPage: { handler: readPage, summary: readPageSummary, help: readPageHelp },
112
+ readProjectMembers: {
113
+ handler: readProjectMembers,
114
+ summary: readProjectMembersSummary,
115
+ help: readProjectMembersHelp
116
+ },
117
+ listPages: {
118
+ handler: listPages,
119
+ summary: listPagesSummary,
120
+ help: listPagesHelp
121
+ },
122
+ list1hopLinks: {
123
+ handler: list1hopLinks,
124
+ summary: list1hopLinksSummary,
125
+ help: list1hopLinksHelp
126
+ },
127
+ list2hopLinks: {
128
+ handler: list2hopLinks,
129
+ summary: list2hopLinksSummary,
130
+ help: list2hopLinksHelp
131
+ },
132
+ searchVector: {
133
+ handler: searchVector,
134
+ summary: searchVectorSummary,
135
+ help: searchVectorHelp
136
+ },
137
+ searchFullText: {
138
+ handler: searchFullText,
139
+ summary: searchFullTextSummary,
140
+ help: searchFullTextHelp
141
+ },
142
+ search1hopLinks: {
143
+ handler: search1hopLinks,
144
+ summary: search1hopLinksSummary,
145
+ help: search1hopLinksHelp
146
+ },
147
+ search2hopLinks: {
148
+ handler: search2hopLinks,
149
+ summary: search2hopLinksSummary,
150
+ help: search2hopLinksHelp
151
+ },
152
+ previewEdit: {
153
+ handler: previewEdit,
154
+ summary: previewEditSummary,
155
+ help: previewEditHelp
156
+ },
157
+ submitEdit: {
158
+ handler: submitEdit,
159
+ summary: submitEditSummary,
160
+ help: submitEditHelp
161
+ }
162
+ };
163
+
164
+ const renderTopLevelHelp = (): string => {
165
+ const nameWidth = Math.max(...Object.keys(commands).map(n => n.length));
166
+ const lines = [
167
+ `cosense v${version} - Cosenseのページを読み・調べ・編集するCLI`,
168
+ '',
169
+ 'Usage:',
170
+ ' cosense <command> [args...]',
171
+ ' cosense <command> --help 個別コマンドの詳細を表示',
172
+ ' cosense --help このヘルプを表示',
173
+ ' cosense --version バージョンを表示',
174
+ '',
175
+ 'Commands:'
176
+ ];
177
+ for (const [name, { summary }] of Object.entries(commands)) {
178
+ lines.push(` ${name.padEnd(nameWidth)} ${summary}`);
179
+ }
180
+ return lines.join('\n');
181
+ };
182
+
183
+ const [, , command, ...rest] = process.argv;
184
+
185
+ if (command === '--help') {
186
+ process.stdout.write(`${renderTopLevelHelp()}\n`);
187
+ process.exit(0);
188
+ }
189
+
190
+ if (command === '--version') {
191
+ process.stdout.write(`cosense v${version}\n`);
192
+ process.exit(0);
193
+ }
194
+
195
+ const spec = command ? commands[command] : undefined;
196
+ if (!spec) {
197
+ process.stderr.write(
198
+ `invalid command${command ? `: ${command}` : ''}\n` +
199
+ 'See `cosense --help` for usage.\n'
200
+ );
201
+ process.exit(2);
202
+ }
203
+
204
+ if (rest.includes('--help')) {
205
+ process.stdout.write(`${spec.help}\n`);
206
+ process.exit(0);
207
+ }
208
+
209
+ try {
210
+ await spec.handler(rest);
211
+ } catch (err) {
212
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
213
+ process.exit(1);
214
+ }
@@ -0,0 +1,324 @@
1
+ import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
2
+ import { parsePageUrl } from '../lib/parseUrl.ts';
3
+ import { fetchRelatedPages } from '../lib/relatedPages.ts';
4
+ import {
5
+ buildGroups,
6
+ dedupAndSortByPageRank,
7
+ type Page as RelatedPage,
8
+ renderGroups
9
+ } from '../lib/relatedPagesFormat.ts';
10
+ import { requestJson } from '../lib/request.ts';
11
+ import {
12
+ enrichPageUsers,
13
+ fetchUserMap,
14
+ type UserMap
15
+ } from '../lib/resolveUsers.ts';
16
+ import { resolveCredential } from '../lib/settings.ts';
17
+
18
+ export const browsePageSummary =
19
+ '単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する';
20
+
21
+ export const browsePageHelp = `browsePage - 単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する
22
+
23
+ Usage:
24
+ cosense browsePage <pageUrl>
25
+
26
+ 引数:
27
+ <pageUrl> 読むページの完全なURL(例: https://scrapbox.io/shokai/foo)
28
+ URLに #<lineId> fragmentが付いていれば、本文のその行末に行permalinkマーカーを付与する
29
+
30
+ 出力形式(Markdown plain text。JSONではない):
31
+ # <title>
32
+
33
+ ## メタデータ
34
+ タイトル / 作成日時 / 最終更新日時 / 最終アクセス日時 / 被リンク数 / pageRank /
35
+ views / snapshot / 行数 / 文字数 / 作成者 / 最終更新者 / 関わったユーザー
36
+
37
+ ## 人間のアイコン記法
38
+ 本文中の [name.icon] のうち、現メンバーまたは退去済みメンバー (memberSnapshots) の
39
+ name に合致するものだけを [name.icon] の形で箇条書きする。
40
+ 一致が0件ならセクションごと省略
41
+
42
+ ## テロメアのサマリー
43
+ lines[] を最終更新者でグルーピングし、 displayName 更新期間 YYYY/M/D 〜 YYYY/M/D N行更新
44
+ の形式で行数降順に全員出力
45
+
46
+ ## 本文
47
+ 各行の text を改行で結合。fragment 指定行のみ末尾に #<lineId> を付与
48
+
49
+ -------------------- Related Pages --------------------
50
+ 本文と関連ページ一覧の境界を示す非Markdown区切り線。Cosenseの#hashtag記法と
51
+ 衝突しないようにMarkdown見出しを避ける。
52
+ ## 1 hop link
53
+ このページの 1-hop 近傍ページタイトル一覧。 1-hop が 0 件なら区切り線ごと省略
54
+
55
+ 非存在ページ (persistent: false):
56
+ メタデータ・アイコン・テロメアは省略。 (このページはまだ作成されていません) と
57
+ 本文(テンプレート)と Related Pages を出力する
58
+
59
+ URLに #<lineId> fragmentが指定された時:
60
+ タイトル直後に判定結果を1行出力する。
61
+ - 24文字の小文字16進数フォーマットで本文に該当行があれば、行permalinkマーカーを本文中に付ける
62
+ - フォーマット正、本文に該当行なし
63
+ - フォーマット不正 (24文字の小文字16進数でない)
64
+ `;
65
+
66
+ interface PageLine {
67
+ id?: string;
68
+ text?: string;
69
+ userId?: string;
70
+ user?: { id?: string };
71
+ updated?: number | string;
72
+ created?: number | string;
73
+ }
74
+
75
+ interface UserRef {
76
+ id?: string;
77
+ name?: string;
78
+ displayName?: string;
79
+ }
80
+
81
+ interface PageData {
82
+ title?: string;
83
+ persistent?: boolean;
84
+ pageRank?: number;
85
+ linked?: number;
86
+ views?: number;
87
+ linesCount?: number;
88
+ charsCount?: number;
89
+ snapshotCount?: number;
90
+ snapshotCreated?: number | string;
91
+ created?: number | string;
92
+ updated?: number | string;
93
+ accessed?: number | string;
94
+ lastAccessed?: number | string;
95
+ user?: UserRef | null;
96
+ lastUpdateUser?: UserRef | null;
97
+ users?: UserRef[];
98
+ lines?: PageLine[];
99
+ icons?: string[];
100
+ }
101
+
102
+ const LINE_ID_PATTERN = /^[0-9a-f]{24}$/;
103
+
104
+ const extractFragment = (input: string): string | null => {
105
+ const u = new URL(input);
106
+ if (!u.hash) return null;
107
+ const value = u.hash.slice(1);
108
+ return value === '' ? null : value;
109
+ };
110
+
111
+ const formatUserDisplay = (u: UserRef | null | undefined): string | null => {
112
+ if (!u) return null;
113
+ if (u.displayName && u.name) return `${u.displayName} (${u.name})`;
114
+ return u.displayName ?? u.name ?? u.id ?? null;
115
+ };
116
+
117
+ const formatDateYMD = (unixSec: number): string => {
118
+ const d = new Date(unixSec * 1000);
119
+ return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
120
+ };
121
+
122
+ const normalizeIndent = (text: string): string =>
123
+ text.replace(/^\s+/, m => '\t'.repeat(m.length));
124
+
125
+ interface TelomereEntry {
126
+ userId: string;
127
+ lineCount: number;
128
+ minUpdated: number;
129
+ maxUpdated: number;
130
+ }
131
+
132
+ const buildTelomere = (lines: PageLine[]): TelomereEntry[] => {
133
+ const byUser = new Map<string, TelomereEntry>();
134
+ for (const line of lines) {
135
+ const uid = line.userId;
136
+ const updated = line.updated;
137
+ if (!uid || typeof updated !== 'number') continue;
138
+ const entry = byUser.get(uid);
139
+ if (entry) {
140
+ entry.lineCount += 1;
141
+ if (updated < entry.minUpdated) entry.minUpdated = updated;
142
+ if (updated > entry.maxUpdated) entry.maxUpdated = updated;
143
+ } else {
144
+ byUser.set(uid, {
145
+ userId: uid,
146
+ lineCount: 1,
147
+ minUpdated: updated,
148
+ maxUpdated: updated
149
+ });
150
+ }
151
+ }
152
+ return [...byUser.values()].sort((a, b) => b.lineCount - a.lineCount);
153
+ };
154
+
155
+ const renderMetadata = (page: PageData): string => {
156
+ const items: string[] = [];
157
+ if (typeof page.title === 'string') items.push(`- タイトル: ${page.title}`);
158
+ if (page.created) items.push(`- 作成日時: ${page.created}`);
159
+ if (page.updated) items.push(`- 最終更新日時: ${page.updated}`);
160
+ if (page.accessed) items.push(`- 最終アクセス日時: ${page.accessed}`);
161
+ if (typeof page.linked === 'number')
162
+ items.push(`- 被リンク数: ${page.linked}`);
163
+ if (typeof page.pageRank === 'number') {
164
+ items.push(`- pageRank: ${page.pageRank}`);
165
+ }
166
+ if (typeof page.views === 'number') items.push(`- views: ${page.views}`);
167
+ if (typeof page.snapshotCount === 'number' && page.snapshotCount > 0) {
168
+ const latest = page.snapshotCreated ? `、最新 ${page.snapshotCreated}` : '';
169
+ items.push(`- snapshot: ${page.snapshotCount} 件${latest}`);
170
+ }
171
+ if (typeof page.linesCount === 'number') {
172
+ items.push(`- 行数: ${page.linesCount}`);
173
+ }
174
+ if (typeof page.charsCount === 'number') {
175
+ items.push(`- 文字数: ${page.charsCount}`);
176
+ }
177
+ const author = formatUserDisplay(page.user);
178
+ if (author) items.push(`- 作成者: ${author}`);
179
+ const lastUpdater = formatUserDisplay(page.lastUpdateUser);
180
+ if (lastUpdater) items.push(`- 最終更新者: ${lastUpdater}`);
181
+ if (page.users && page.users.length > 0) {
182
+ items.push('- 関わったユーザー:');
183
+ for (const u of page.users) {
184
+ const f = formatUserDisplay(u);
185
+ if (f) items.push(` - ${f}`);
186
+ }
187
+ }
188
+ return `## メタデータ\n\n${items.join('\n')}`;
189
+ };
190
+
191
+ const renderHumanIcons = (
192
+ icons: string[] | undefined,
193
+ userMap: UserMap
194
+ ): string | null => {
195
+ if (!icons || icons.length === 0) return null;
196
+ const memberNames = new Set<string>();
197
+ for (const info of userMap.values()) {
198
+ if (info.name) memberNames.add(info.name);
199
+ }
200
+ const human = icons.filter(name => memberNames.has(name));
201
+ if (human.length === 0) return null;
202
+ return `## 人間のアイコン記法\n\n${human.map(name => `- [${name}.icon]`).join('\n')}`;
203
+ };
204
+
205
+ const renderTelomere = (
206
+ entries: TelomereEntry[],
207
+ userMap: UserMap
208
+ ): string | null => {
209
+ if (entries.length === 0) return null;
210
+ const lines = entries.map(e => {
211
+ const info = userMap.get(e.userId);
212
+ const name = info?.displayName ?? info?.name ?? e.userId;
213
+ return `- ${name}\t更新期間 ${formatDateYMD(e.minUpdated)} 〜 ${formatDateYMD(e.maxUpdated)}\t${e.lineCount}行更新`;
214
+ });
215
+ return `## テロメアのサマリー\n\n${lines.join('\n')}`;
216
+ };
217
+
218
+ interface BodyRender {
219
+ body: string;
220
+ matchedFragment: boolean;
221
+ }
222
+
223
+ const renderBody = (lines: PageLine[], fragment: string | null): BodyRender => {
224
+ let matchedFragment = false;
225
+ const out: string[] = [];
226
+ for (const line of lines) {
227
+ const text = normalizeIndent(line.text ?? '');
228
+ if (fragment && line.id === fragment) {
229
+ matchedFragment = true;
230
+ out.push(`${text}\t#${line.id}`);
231
+ } else {
232
+ out.push(text);
233
+ }
234
+ }
235
+ return { body: out.join('\n'), matchedFragment };
236
+ };
237
+
238
+ const renderRelatedPages = (hopValue: unknown): string | null => {
239
+ const links1hop = (hopValue as { links1hop?: RelatedPage[] }).links1hop;
240
+ const pages = dedupAndSortByPageRank(links1hop);
241
+ if (pages.length === 0) return null;
242
+ return `-------------------- Related Pages --------------------\n\n## 1 hop link\n\n${renderGroups(buildGroups(pages))}`;
243
+ };
244
+
245
+ export const browsePage = async (args: string[]): Promise<void> => {
246
+ if (args.length !== 1) {
247
+ throw new Error('Usage: cosense browsePage <pageUrl>');
248
+ }
249
+ const [input] = args as [string];
250
+ const fragment = extractFragment(input);
251
+ const { origin, projectName, encodedTitle } = parsePageUrl(input);
252
+ const apiUrl = `${origin}/api/pages/v2/${projectName}/${encodedTitle}`;
253
+ const credential = resolveCredential(origin, projectName);
254
+
255
+ // 必要なAPIのいずれかが失敗したら exit 1 で落とす。 graceful degradation
256
+ // させると「アイコンが0件」「1-hopが0件」と区別がつかなくなり、AIに誤った
257
+ // 文脈を渡してしまう
258
+ const [page, userMap, hopValue] = await Promise.all([
259
+ requestJson(apiUrl, { credential }) as Promise<PageData>,
260
+ fetchUserMap(origin, projectName),
261
+ fetchRelatedPages(input, 1)
262
+ ]);
263
+
264
+ const title = page.title ?? '';
265
+ const persistent = page.persistent !== false;
266
+ const sections: string[] = [`# ${title}`];
267
+
268
+ if (!persistent) {
269
+ sections.push('(このページはまだ作成されていません)');
270
+ const { body } = renderBody(page.lines ?? [], null);
271
+ sections.push(`## 本文(テンプレート)\n\n${body}`);
272
+ const related = renderRelatedPages(hopValue);
273
+ if (related) sections.push(related);
274
+ process.stdout.write(`${sections.join('\n\n')}\n`);
275
+ return;
276
+ }
277
+
278
+ // 本文を先にrenderしてfragment一致状況を取得し、タイトル直後の説明文に反映する
279
+ const validFragment = fragment !== null && LINE_ID_PATTERN.test(fragment);
280
+ const { body, matchedFragment } = renderBody(
281
+ page.lines ?? [],
282
+ validFragment ? fragment : null
283
+ );
284
+ if (fragment !== null) {
285
+ if (!validFragment) {
286
+ sections.push(`#${fragment} は行IDとしてフォーマットが正しくない`);
287
+ } else if (matchedFragment) {
288
+ sections.push(
289
+ `指定された行ID #${fragment} に合致する行が存在する。本文のセクション内で示す`
290
+ );
291
+ } else {
292
+ sections.push(
293
+ `指定された行ID #${fragment} に合致する行は本文に存在しなかった`
294
+ );
295
+ }
296
+ }
297
+
298
+ // telomere は line.updated が unix秒のうちに集計する
299
+ const telomere = buildTelomere(page.lines ?? []);
300
+
301
+ enrichTimestampsOf(page as Record<string, unknown>, [
302
+ 'created',
303
+ 'updated',
304
+ 'accessed',
305
+ 'lastAccessed',
306
+ 'snapshotCreated'
307
+ ]);
308
+ enrichPageUsers(page, userMap);
309
+
310
+ sections.push(renderMetadata(page));
311
+
312
+ const iconsSection = renderHumanIcons(page.icons, userMap);
313
+ if (iconsSection) sections.push(iconsSection);
314
+
315
+ const telomereSection = renderTelomere(telomere, userMap);
316
+ if (telomereSection) sections.push(telomereSection);
317
+
318
+ sections.push(`## 本文\n\n${body}`);
319
+
320
+ const related = renderRelatedPages(hopValue);
321
+ if (related) sections.push(related);
322
+
323
+ process.stdout.write(`${sections.join('\n\n')}\n`);
324
+ };
@@ -0,0 +1,79 @@
1
+ import {
2
+ buildGroups,
3
+ dedupAndSortByPageRank,
4
+ type Page,
5
+ renderGroups
6
+ } from '../lib/relatedPagesFormat.ts';
7
+ import { fetchRelatedPages } from '../lib/relatedPages.ts';
8
+
9
+ export const browseRelatedPagesSummary =
10
+ '1-hop+2-hopのタイトル一覧を眺める。単独のページだけを見ていては掴みきれない文脈が浮かび上がる';
11
+
12
+ export const browseRelatedPagesHelp = `browseRelatedPages - 1-hop+2-hopのタイトル一覧を眺める。単独のページだけを見ていては掴みきれない文脈が浮かび上がる
13
+
14
+ Usage:
15
+ cosense browseRelatedPages <pageUrl>
16
+
17
+ 引数:
18
+ <pageUrl> 対象ページの完全なURL
19
+
20
+ 出力:
21
+ 1-hop と 2-hop の関連ページそれぞれを pageRank 降順で並べたタイトルの一覧。
22
+ 他のコマンドと異なり、JSON ではなく Markdown 形式で出力する。
23
+
24
+ # Related Pages
25
+
26
+ ## 1 hop link
27
+
28
+ - タイトル
29
+ - 日記 2025-01-01
30
+ 他、48件の日記を省略します
31
+
32
+ ## 2 hop link
33
+
34
+ - タイトル
35
+ `;
36
+
37
+ export const browseRelatedPages = async (args: string[]): Promise<void> => {
38
+ if (args.length !== 1) {
39
+ throw new Error('Usage: cosense browseRelatedPages <pageUrl>');
40
+ }
41
+ const [url] = args as [string];
42
+
43
+ const [result1hop, result2hop] = await Promise.allSettled([
44
+ fetchRelatedPages(url, 1),
45
+ fetchRelatedPages(url, 2)
46
+ ]);
47
+
48
+ if (result1hop.status === 'rejected' && result2hop.status === 'rejected') {
49
+ throw result1hop.reason;
50
+ }
51
+
52
+ // seen は 1-hop と 2-hop で共有して、 1-hop に出たページが 2-hop にも再掲されないようにする
53
+ const seen = new Set<string>();
54
+ const pages1hop =
55
+ result1hop.status === 'fulfilled'
56
+ ? dedupAndSortByPageRank(
57
+ (result1hop.value as { links1hop?: Page[] }).links1hop,
58
+ seen
59
+ )
60
+ : [];
61
+ const pages2hop =
62
+ result2hop.status === 'fulfilled'
63
+ ? dedupAndSortByPageRank(
64
+ (result2hop.value as { links2hop?: Page[] }).links2hop,
65
+ seen
66
+ )
67
+ : [];
68
+
69
+ const sections: string[] = [];
70
+ if (pages1hop.length > 0) {
71
+ sections.push(`## 1 hop link\n\n${renderGroups(buildGroups(pages1hop))}`);
72
+ }
73
+ if (pages2hop.length > 0) {
74
+ sections.push(`## 2 hop link\n\n${renderGroups(buildGroups(pages2hop))}`);
75
+ }
76
+ if (sections.length > 0) {
77
+ process.stdout.write(`# Related Pages\n\n${sections.join('\n\n')}\n`);
78
+ }
79
+ };