@helpfeel/cosense-cli 1.17.0 → 1.18.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 +4 -4
- package/src/commands/browsePageChanges.ts +175 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helpfeel/cosense-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するAgent Skillのハーネス",
|
|
5
5
|
"homepage": "https://github.com/helpfeel/cosense-cli",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"tsx": "4.23.15"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@types/node": "24.19.
|
|
30
|
+
"@types/node": "24.19.2",
|
|
31
31
|
"npm-run-all": "4.1.5",
|
|
32
|
-
"oxfmt": "0.
|
|
33
|
-
"oxlint": "1.
|
|
32
|
+
"oxfmt": "0.73.0",
|
|
33
|
+
"oxlint": "1.88.0",
|
|
34
34
|
"typescript": "7.0.2"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
|
@@ -10,23 +10,28 @@ import {
|
|
|
10
10
|
serviceAccountSuffix,
|
|
11
11
|
type UserMap
|
|
12
12
|
} from '../lib/resolveUsers.ts';
|
|
13
|
-
import { resolveCredential } from '../lib/settings.ts';
|
|
13
|
+
import { resolveCredential, type Credential } from '../lib/settings.ts';
|
|
14
14
|
|
|
15
15
|
export const browsePageChangesSummary =
|
|
16
|
-
'
|
|
16
|
+
'ページの最近の編集履歴(commit)をpageId起点で取得し、誰がいつ何をどう変えたかを自然言語で説明する。タイトル変更(リネーム)も検出する';
|
|
17
17
|
|
|
18
|
-
export const browsePageChangesHelp = `browsePageChanges -
|
|
18
|
+
export const browsePageChangesHelp = `browsePageChanges - ページの最近の編集履歴(commit)をpageId起点で取得し自然言語で説明する
|
|
19
19
|
|
|
20
20
|
Usage:
|
|
21
|
-
cosense browsePageChanges <projectUrl> <pageId> [--since <commitId>]
|
|
21
|
+
cosense browsePageChanges <projectUrl> <pageId> [--since <commitId> | [--before <commitId>] [--all]]
|
|
22
22
|
|
|
23
23
|
引数:
|
|
24
|
-
<projectUrl>
|
|
25
|
-
<pageId>
|
|
26
|
-
--since <commitId>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
<projectUrl> プロジェクトのURL(例: https://scrapbox.io/shokai)
|
|
25
|
+
<pageId> ページの不変ID。browsePage / readPage の出力に含まれる
|
|
26
|
+
--since <commitId> 指定した commitId より後の変更だけを対象にする(カーソル)。
|
|
27
|
+
browsePage / readPage 出力の commitId を控えておき、それを渡すと
|
|
28
|
+
「前回読んだ後に何が変わったか」だけを取得できる。
|
|
29
|
+
--before <commitId> 指定した commitId より前の変更を、新しい方から最大100件対象にする。
|
|
30
|
+
前回の出力の「さらに前の変更を読むコマンド」をそのまま実行すると続きを読める。
|
|
31
|
+
--all 保持中の全履歴を対象にする。履歴の長いページでは要求が何回にも分かれる。
|
|
32
|
+
--before と併用すると、その commitId より前の全履歴になる。
|
|
33
|
+
どれも省略すると、最新の変更を新しい方から最大100件対象にする。
|
|
34
|
+
--since は --before / --all と併用できない。
|
|
30
35
|
|
|
31
36
|
なぜpageId起点か:
|
|
32
37
|
ページタイトルはURLになるため、共同編集者がタイトルを変更すると旧タイトルでは読めなくなる。
|
|
@@ -34,8 +39,11 @@ Usage:
|
|
|
34
39
|
|
|
35
40
|
出力形式(Markdown plain text。JSONではない):
|
|
36
41
|
# ページ変更履歴
|
|
37
|
-
pageId /
|
|
38
|
-
|
|
42
|
+
pageId / 範囲 / commit数 / title change(範囲内の最後の変更後タイトル、無ければ なし)
|
|
43
|
+
範囲外のタイトル変更は検出しないので、無しでもリネーム済みの事がある。
|
|
44
|
+
範囲が最新の変更を含む時(--before 無し)は、タイトルが変わっていれば新URLと新Readable linkも出力する。
|
|
45
|
+
--before 付きの title change は当時のタイトルで、現在のタイトルとは限らないのでURLは出さない。
|
|
46
|
+
既定と --before(--all 無し)で範囲より前にも変更がある時は「さらに前の変更を読むコマンド」に、続きを読む完全なコマンドを示す
|
|
39
47
|
## 変更
|
|
40
48
|
時系列の変更イベントを「<日時>\t<誰>が<何を>」の形(TAB区切り)で列挙する。
|
|
41
49
|
同一行への連続した編集は1件にまとめ、変更前→最終のテキストを示す。
|
|
@@ -58,6 +66,7 @@ interface Change {
|
|
|
58
66
|
|
|
59
67
|
interface Commit {
|
|
60
68
|
id?: string;
|
|
69
|
+
parentId?: string | null;
|
|
61
70
|
changes?: Change[];
|
|
62
71
|
userId?: string;
|
|
63
72
|
created?: number;
|
|
@@ -65,28 +74,39 @@ interface Commit {
|
|
|
65
74
|
|
|
66
75
|
interface CommitsResponse {
|
|
67
76
|
commits?: Commit[];
|
|
77
|
+
hasMore?: boolean;
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
interface ParsedArgs {
|
|
71
81
|
projectUrl: string;
|
|
72
82
|
pageId: string;
|
|
73
83
|
since: string | undefined;
|
|
84
|
+
before: string | undefined;
|
|
85
|
+
all: boolean;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
const parseArgs = (args: string[]): ParsedArgs => {
|
|
77
89
|
const usage =
|
|
78
|
-
'Usage: cosense browsePageChanges <projectUrl> <pageId> [--since <commitId>]';
|
|
90
|
+
'Usage: cosense browsePageChanges <projectUrl> <pageId> [--since <commitId> | [--before <commitId>] [--all]]';
|
|
79
91
|
let since: string | undefined;
|
|
92
|
+
let before: string | undefined;
|
|
93
|
+
let all = false;
|
|
80
94
|
const positional: string[] = [];
|
|
81
95
|
for (let i = 0; i < args.length; i += 1) {
|
|
82
96
|
const arg = args[i] as string;
|
|
83
|
-
if (arg === '--since') {
|
|
97
|
+
if (arg === '--since' || arg === '--before') {
|
|
84
98
|
const value = args[i + 1];
|
|
85
99
|
if (value === undefined || value.startsWith('--')) {
|
|
86
|
-
throw new Error(
|
|
100
|
+
throw new Error(`${arg} requires a commitId\n${usage}`);
|
|
101
|
+
}
|
|
102
|
+
if (arg === '--since') {
|
|
103
|
+
since = value;
|
|
104
|
+
} else {
|
|
105
|
+
before = value;
|
|
87
106
|
}
|
|
88
|
-
since = value;
|
|
89
107
|
i += 1;
|
|
108
|
+
} else if (arg === '--all') {
|
|
109
|
+
all = true;
|
|
90
110
|
} else if (arg.startsWith('--')) {
|
|
91
111
|
throw new Error(`Unknown option: ${arg}\n${usage}`);
|
|
92
112
|
} else {
|
|
@@ -96,10 +116,15 @@ const parseArgs = (args: string[]): ParsedArgs => {
|
|
|
96
116
|
if (positional.length !== 2) {
|
|
97
117
|
throw new Error(usage);
|
|
98
118
|
}
|
|
119
|
+
if (since !== undefined && (before !== undefined || all)) {
|
|
120
|
+
throw new Error(`--since cannot be used with --before or --all\n${usage}`);
|
|
121
|
+
}
|
|
99
122
|
return {
|
|
100
123
|
projectUrl: positional[0] as string,
|
|
101
124
|
pageId: positional[1] as string,
|
|
102
|
-
since
|
|
125
|
+
since,
|
|
126
|
+
before,
|
|
127
|
+
all
|
|
103
128
|
};
|
|
104
129
|
};
|
|
105
130
|
|
|
@@ -116,6 +141,97 @@ const resolveUserName = (
|
|
|
116
141
|
|
|
117
142
|
const quote = (text: string | undefined): string => `「${text ?? ''}」`;
|
|
118
143
|
|
|
144
|
+
// 1回の応答は件数と大きさで打ち切られ、続きがある時はhasMoreが真になる
|
|
145
|
+
// headより後のcommitを古い順に、headを最後のcommitに付け替えて全部取る
|
|
146
|
+
const fetchCommitsAfter = async (
|
|
147
|
+
apiUrl: string,
|
|
148
|
+
since: string,
|
|
149
|
+
credential: Credential | undefined
|
|
150
|
+
): Promise<Commit[]> => {
|
|
151
|
+
const commits: Commit[] = [];
|
|
152
|
+
let head = since;
|
|
153
|
+
let continuing = false;
|
|
154
|
+
const requestedHeads = new Set<string>([since]);
|
|
155
|
+
while (true) {
|
|
156
|
+
const url = `${apiUrl}?head=${encodeURIComponent(head)}`;
|
|
157
|
+
const data = (await requestJson(url, { credential })) as CommitsResponse;
|
|
158
|
+
const batch = data.commits ?? [];
|
|
159
|
+
const first = batch[0];
|
|
160
|
+
// 続きが空だと、取れた分を完全な結果として出してしまうので止める。hasMoreは次の要求が空でない事まで保証しない
|
|
161
|
+
if (continuing && !first) {
|
|
162
|
+
throw new Error(`no commits follow head "${head}"`);
|
|
163
|
+
}
|
|
164
|
+
// headに続かない応答を辿ると、同じ応答を取り続けて終わらないので止める
|
|
165
|
+
if (first && first.parentId !== head) {
|
|
166
|
+
throw new Error(`commits do not follow head "${head}"`);
|
|
167
|
+
}
|
|
168
|
+
// spreadで足すと、1回の応答が大きい時に引数の上限を超える
|
|
169
|
+
for (const commit of batch) commits.push(commit);
|
|
170
|
+
if (!data.hasMore) return commits;
|
|
171
|
+
const next = batch.at(-1)?.id;
|
|
172
|
+
if (!next) {
|
|
173
|
+
throw new Error('hasMore is true but the last commit has no id');
|
|
174
|
+
}
|
|
175
|
+
// 親子が循環した応答はparentIdの確認を通るので、同じheadを2度要求しない事で止める
|
|
176
|
+
if (requestedHeads.has(next)) {
|
|
177
|
+
throw new Error(`commit "${next}" appears again as head`);
|
|
178
|
+
}
|
|
179
|
+
requestedHeads.add(next);
|
|
180
|
+
head = next;
|
|
181
|
+
continuing = true;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// 最新のcommit(beforeがあればその親)から親を辿った新しい順の応答を、allなら全部、でなければ1回分だけ取る
|
|
186
|
+
// olderCommitIdは、取った範囲より前に変更が残っている時の、続きを取るbefore
|
|
187
|
+
const fetchCommitsBefore = async (
|
|
188
|
+
apiUrl: string,
|
|
189
|
+
{ before, all }: { before: string | undefined; all: boolean },
|
|
190
|
+
credential: Credential | undefined
|
|
191
|
+
): Promise<{ commits: Commit[]; olderCommitId: string | undefined }> => {
|
|
192
|
+
const commits: Commit[] = [];
|
|
193
|
+
let cursor = before;
|
|
194
|
+
// 前の応答の最後のcommitの親。続きの応答の先頭はこれでなければならない
|
|
195
|
+
let expectedFirstId: string | null | undefined;
|
|
196
|
+
const requestedCursors = new Set<string>(before ? [before] : []);
|
|
197
|
+
while (true) {
|
|
198
|
+
const url = cursor
|
|
199
|
+
? `${apiUrl}?before=${encodeURIComponent(cursor)}`
|
|
200
|
+
: apiUrl;
|
|
201
|
+
const data = (await requestJson(url, { credential })) as CommitsResponse;
|
|
202
|
+
const batch = data.commits ?? [];
|
|
203
|
+
const first = batch[0];
|
|
204
|
+
// 古いserverはhead無しの応答を古い順に返す。隣り合う2件の親子の向きで見分け、古い100件を最新として出さない
|
|
205
|
+
const second = batch[1];
|
|
206
|
+
if (first && second && second.parentId === first.id) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
'the server returned commits from the oldest. This version of cosense-cli needs a server that returns them from the latest'
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (expectedFirstId !== undefined) {
|
|
212
|
+
if (!first) {
|
|
213
|
+
throw new Error(`no commits precede "${cursor}"`);
|
|
214
|
+
}
|
|
215
|
+
if (first.id !== expectedFirstId) {
|
|
216
|
+
throw new Error(`commits do not precede "${cursor}"`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const commit of batch) commits.push(commit);
|
|
220
|
+
const last = batch.at(-1);
|
|
221
|
+
if (!data.hasMore) return { commits, olderCommitId: undefined };
|
|
222
|
+
if (!last?.id) {
|
|
223
|
+
throw new Error('hasMore is true but the last commit has no id');
|
|
224
|
+
}
|
|
225
|
+
if (!all) return { commits, olderCommitId: last.id };
|
|
226
|
+
if (requestedCursors.has(last.id)) {
|
|
227
|
+
throw new Error(`commit "${last.id}" appears again as before`);
|
|
228
|
+
}
|
|
229
|
+
requestedCursors.add(last.id);
|
|
230
|
+
cursor = last.id;
|
|
231
|
+
expectedFirstId = last.parentId ?? null;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
119
235
|
// 時系列の変更イベント。同一行への連続updateは1件に畳む
|
|
120
236
|
type ChangeEvent =
|
|
121
237
|
| { kind: 'title'; userIds: string[]; created?: number; title: string }
|
|
@@ -201,22 +317,48 @@ const renderEvent = (event: ChangeEvent, userMap: UserMap): string => {
|
|
|
201
317
|
}
|
|
202
318
|
};
|
|
203
319
|
|
|
320
|
+
const fetchCommits = async (
|
|
321
|
+
apiUrl: string,
|
|
322
|
+
{ since, before, all }: Pick<ParsedArgs, 'since' | 'before' | 'all'>,
|
|
323
|
+
credential: Credential | undefined
|
|
324
|
+
): Promise<{ commits: Commit[]; olderCommitId: string | undefined }> => {
|
|
325
|
+
if (since !== undefined) {
|
|
326
|
+
const commits = await fetchCommitsAfter(apiUrl, since, credential);
|
|
327
|
+
return { commits, olderCommitId: undefined };
|
|
328
|
+
}
|
|
329
|
+
const { commits, olderCommitId } = await fetchCommitsBefore(
|
|
330
|
+
apiUrl,
|
|
331
|
+
{ before, all },
|
|
332
|
+
credential
|
|
333
|
+
);
|
|
334
|
+
// 新しい順の応答を時系列に戻す。同一行への連続updateの畳み方が順序に依存する
|
|
335
|
+
commits.reverse();
|
|
336
|
+
return { commits, olderCommitId };
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const describeRange = (
|
|
340
|
+
{ since, before, all }: Pick<ParsedArgs, 'since' | 'before' | 'all'>,
|
|
341
|
+
count: number
|
|
342
|
+
): string => {
|
|
343
|
+
if (since !== undefined) return `commit ${since} より後`;
|
|
344
|
+
if (all && before !== undefined) return `commit ${before} より前の全履歴`;
|
|
345
|
+
if (all) return '全履歴';
|
|
346
|
+
if (before !== undefined) return `commit ${before} より前 ${count}件`;
|
|
347
|
+
return `最新 ${count}件`;
|
|
348
|
+
};
|
|
349
|
+
|
|
204
350
|
export const browsePageChanges = async (args: string[]): Promise<void> => {
|
|
205
|
-
const { projectUrl, pageId, since } = parseArgs(args);
|
|
351
|
+
const { projectUrl, pageId, since, before, all } = parseArgs(args);
|
|
206
352
|
const { origin, projectName } = parseProjectUrlStrict(projectUrl);
|
|
207
353
|
|
|
208
|
-
|
|
209
|
-
if (since) {
|
|
210
|
-
apiUrl += `?head=${encodeURIComponent(since)}`;
|
|
211
|
-
}
|
|
354
|
+
const apiUrl = `${origin}/api/commits/${projectName}/${pageId}`;
|
|
212
355
|
const credential = resolveCredential(origin, projectName);
|
|
213
356
|
|
|
214
|
-
const [
|
|
215
|
-
|
|
357
|
+
const [{ commits, olderCommitId }, userMap] = await Promise.all([
|
|
358
|
+
fetchCommits(apiUrl, { since, before, all }, credential),
|
|
216
359
|
fetchUserMap(origin, projectName)
|
|
217
360
|
]);
|
|
218
361
|
|
|
219
|
-
const commits = data.commits ?? [];
|
|
220
362
|
const events = buildEvents(commits);
|
|
221
363
|
|
|
222
364
|
const titleEvents = events.filter(e => e.kind === 'title');
|
|
@@ -226,12 +368,18 @@ export const browsePageChanges = async (args: string[]): Promise<void> => {
|
|
|
226
368
|
|
|
227
369
|
const meta: string[] = [
|
|
228
370
|
`- pageId: ${pageId}`,
|
|
229
|
-
`- 範囲: ${since
|
|
371
|
+
`- 範囲: ${describeRange({ since, before, all }, commits.length)}`,
|
|
230
372
|
`- commit数: ${commits.length}`,
|
|
231
373
|
`- title change: ${latestTitle !== undefined ? quote(latestTitle) : 'なし'}`
|
|
232
374
|
];
|
|
375
|
+
if (olderCommitId !== undefined) {
|
|
376
|
+
meta.push(
|
|
377
|
+
`- さらに前の変更を読むコマンド: cosense browsePageChanges ${origin}/${projectName} ${pageId} --before ${olderCommitId}`
|
|
378
|
+
);
|
|
379
|
+
}
|
|
233
380
|
// タイトルが変わっていればURLも変わっている。新URLを添える
|
|
234
|
-
|
|
381
|
+
// --before 付きの範囲のタイトルは当時の物で、その後に変わっている事があるのでURLにしない
|
|
382
|
+
if (latestTitle !== undefined && before === undefined) {
|
|
235
383
|
const newUrl = `${origin}/${projectName}/${encodeTitleForUrl(latestTitle)}`;
|
|
236
384
|
meta.push(`- 新URL: ${newUrl}`);
|
|
237
385
|
const newReadableLink = `${origin}/${projectName}/${encodeTitleForReadableLink(latestTitle)}`;
|