@helpfeel/cosense-cli 1.12.0 → 1.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helpfeel/cosense-cli",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
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.23.10"
27
+ "tsx": "4.23.12"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "24.13.3",
31
31
  "npm-run-all": "4.1.5",
32
- "oxfmt": "0.61.0",
33
- "oxlint": "1.76.0",
32
+ "oxfmt": "0.62.0",
33
+ "oxlint": "1.77.0",
34
34
  "typescript": "7.0.2"
35
35
  },
36
36
  "engines": {
package/src/cli.ts CHANGED
@@ -17,6 +17,11 @@ import {
17
17
  browseRelatedPagesHelp,
18
18
  browseRelatedPagesSummary
19
19
  } from './commands/browseRelatedPages.ts';
20
+ import {
21
+ deleteFile,
22
+ deleteFileHelp,
23
+ deleteFileSummary
24
+ } from './commands/deleteFile.ts';
20
25
  import {
21
26
  downloadFile,
22
27
  downloadFileHelp,
@@ -43,6 +48,11 @@ import {
43
48
  listProjectsSummary
44
49
  } from './commands/listProjects.ts';
45
50
  import { login, loginHelp, loginSummary } from './commands/login.ts';
51
+ import {
52
+ previewDelete,
53
+ previewDeleteHelp,
54
+ previewDeleteSummary
55
+ } from './commands/previewDelete.ts';
46
56
  import {
47
57
  previewEdit,
48
58
  previewEditHelp,
@@ -154,6 +164,11 @@ const commands: Record<string, CommandSpec> = {
154
164
  summary: uploadFileSummary,
155
165
  help: uploadFileHelp
156
166
  },
167
+ deleteFile: {
168
+ handler: deleteFile,
169
+ summary: deleteFileSummary,
170
+ help: deleteFileHelp
171
+ },
157
172
  readProjectMembers: {
158
173
  handler: readProjectMembers,
159
174
  summary: readProjectMembersSummary,
@@ -199,6 +214,11 @@ const commands: Record<string, CommandSpec> = {
199
214
  summary: previewEditSummary,
200
215
  help: previewEditHelp
201
216
  },
217
+ previewDelete: {
218
+ handler: previewDelete,
219
+ summary: previewDeleteSummary,
220
+ help: previewDeleteHelp
221
+ },
202
222
  submitEdit: {
203
223
  handler: submitEdit,
204
224
  summary: submitEditSummary,
@@ -242,7 +262,9 @@ if (command === '--version') {
242
262
  process.exit(0);
243
263
  }
244
264
 
245
- const spec = command ? commands[command] : undefined;
265
+ // cosense constructorやcosense __proto__はObject.prototypeのpropertyを引いてしまう
266
+ const spec =
267
+ command && Object.hasOwn(commands, command) ? commands[command] : undefined;
246
268
  if (!spec) {
247
269
  process.stderr.write(
248
270
  `invalid command${command ? `: ${command}` : ''}\n` +
@@ -0,0 +1,81 @@
1
+ import { parseFileUrl } from '../lib/parseUrl.ts';
2
+ import { requestJson } from '../lib/request.ts';
3
+ import { resolveFileCredential } from '../lib/resolveFileCredential.ts';
4
+
5
+ export const deleteFileSummary = 'ファイルをprojectから削除する';
6
+
7
+ export const deleteFileHelp = `deleteFile - ファイルをprojectから削除する
8
+
9
+ Usage:
10
+ cosense deleteFile <fileUrl> [--project <projectUrl>]
11
+
12
+ 引数:
13
+ <fileUrl> ファイルのURL(例: https://scrapbox.io/files/5f151efbacbb17001a58f120.pdf)。query/hashは付けない
14
+
15
+ オプション:
16
+ --project <projectUrl> Service Account認証で削除する時に、ファイルが属するprojectのURLを指定する(例: https://scrapbox.io/example)。省略時はPersonal Access Tokenを使う
17
+
18
+ 出力(JSON):
19
+ success boolean 削除に成功したらtrue
20
+
21
+ 削除後、ファイルが所属するproject内でこのファイルを埋め込んでいるページの本文はサーバーが自動修正するため、クライアント側での本文修正は不要。
22
+
23
+ 例:
24
+ cosense deleteFile 'https://scrapbox.io/files/5f151efbacbb17001a58f120.pdf'
25
+
26
+ HTTPエラー:
27
+ 401/403: 認証・権限が無い。projectのmember権限(またはprojectのService Account)が必要
28
+ 404: fileIdに対応するファイルが存在しない
29
+ `;
30
+
31
+ interface ParsedArgs {
32
+ fileUrl: string;
33
+ project?: string;
34
+ }
35
+
36
+ const parseArgs = (args: string[]): ParsedArgs => {
37
+ const usage = 'Usage: cosense deleteFile <fileUrl> [--project <projectUrl>]';
38
+ let project: string | undefined;
39
+ const positional: string[] = [];
40
+ for (let i = 0; i < args.length; i += 1) {
41
+ const arg = args[i] as string;
42
+ if (arg === '--project') {
43
+ if (project !== undefined) {
44
+ throw new Error(`--project specified multiple times\n${usage}`);
45
+ }
46
+ const value = args[i + 1];
47
+ if (value === undefined || value.startsWith('--')) {
48
+ throw new Error(`--project requires a value\n${usage}`);
49
+ }
50
+ project = value;
51
+ i += 1;
52
+ } else if (arg.startsWith('--')) {
53
+ throw new Error(`Unknown option: ${arg}\n${usage}`);
54
+ } else {
55
+ positional.push(arg);
56
+ }
57
+ }
58
+ if (positional.length !== 1) {
59
+ throw new Error(usage);
60
+ }
61
+ return { fileUrl: positional[0] as string, project };
62
+ };
63
+
64
+ export const deleteFile = async (args: string[]): Promise<void> => {
65
+ const { fileUrl, project } = parseArgs(args);
66
+ const { origin, fileId } = parseFileUrl(fileUrl);
67
+ const credential = resolveFileCredential(origin, project);
68
+ if (!credential) {
69
+ throw new Error(
70
+ `No credential found for ${origin}. Run \`cosense login ${origin}\` to authenticate.`
71
+ );
72
+ }
73
+ const data = (await requestJson(`${origin}/api/gcs/${fileId}`, {
74
+ credential,
75
+ method: 'DELETE'
76
+ })) as { success?: unknown };
77
+ if (data?.success !== true) {
78
+ throw new Error(`unexpected delete response: ${JSON.stringify(data)}`);
79
+ }
80
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
81
+ };
@@ -0,0 +1,76 @@
1
+ import { parseProjectUrlStrict } from '../lib/parseUrl.ts';
2
+ import { requestJson } from '../lib/request.ts';
3
+ import { resolveCredential } from '../lib/settings.ts';
4
+
5
+ export const previewDeleteSummary =
6
+ 'ページ削除をdry-runしてpreviewIdを取得する';
7
+
8
+ export const previewDeleteHelp = `previewDelete - ページ削除をdry-runしてpreviewIdを取得する
9
+
10
+ Usage:
11
+ cosense previewDelete <projectUrl> <pageId>
12
+
13
+ 引数:
14
+ <projectUrl> プロジェクトのURL (例: https://scrapbox.io/shokai)。 末尾に余分なpathがあるとerror
15
+ <pageId> 削除対象ページのID。 readPage 出力の top-level "id" field から取得する
16
+
17
+ 戻り値(plain text):
18
+ previewId / expireAt / status (delete) / project / title のヘッダー + 削除されるpage全体。
19
+ preview は dry-run なのでこの段階では削除されない。
20
+ previewId を submitEdit に渡すと削除が確定する。5分で expire する。
21
+
22
+ HTTPエラー:
23
+ HTTP 401 認証なし
24
+ HTTP 403 権限不足(PAT利用時、projectのmemberでない 等)
25
+ HTTP 404 pageId に対応するpageが存在しない / pageId が不正な形式
26
+ `;
27
+
28
+ interface PagePreview {
29
+ title?: string;
30
+ lines?: { id: string; text: string }[];
31
+ }
32
+
33
+ interface PreviewResponse {
34
+ previewId: string;
35
+ expireAt: string;
36
+ pagePreview: PagePreview | null;
37
+ pageDelete?: boolean;
38
+ }
39
+
40
+ export const previewDelete = async (args: string[]): Promise<void> => {
41
+ if (args.length !== 2) {
42
+ throw new Error('Usage: cosense previewDelete <projectUrl> <pageId>');
43
+ }
44
+ const [projectUrl, pageId] = args as [string, string];
45
+
46
+ const { origin, projectName } = parseProjectUrlStrict(projectUrl);
47
+ // projectに紐づくService Accountがあればそれを、無ければPATを使う(読み取りと同じ)
48
+ const credential = resolveCredential(origin, projectName);
49
+
50
+ const apiUrl = `${origin}/api/pages/v2/${projectName}/page-edit-for-ai/preview`;
51
+ const response = (await requestJson(apiUrl, {
52
+ credential,
53
+ method: 'POST',
54
+ body: { pageId, changes: [{ deleted: true }] }
55
+ })) as PreviewResponse;
56
+
57
+ // 削除は破壊的操作なので、サーバーが削除previewとして受理した事を確認してから表示する
58
+ if (response.pageDelete !== true) {
59
+ throw new Error(
60
+ 'server did not mark this preview as a page deletion. Do not submit the previewId.'
61
+ );
62
+ }
63
+
64
+ const lines: string[] = [];
65
+ lines.push(`previewId: ${response.previewId}`);
66
+ lines.push(`expireAt: ${response.expireAt}`);
67
+ lines.push('status: delete');
68
+ lines.push(`project: ${projectName}`);
69
+ lines.push(`title: ${response.pagePreview?.title ?? ''}`);
70
+ lines.push('');
71
+ lines.push('page (will be deleted):');
72
+ for (const line of response.pagePreview?.lines ?? []) {
73
+ lines.push(` ${line.text}`);
74
+ }
75
+ process.stdout.write(`${lines.join('\n')}\n`);
76
+ };
@@ -4,16 +4,16 @@ import { requestJson } from '../lib/request.ts';
4
4
  import { resolveCredential } from '../lib/settings.ts';
5
5
 
6
6
  export const submitEditSummary =
7
- 'previewEditで取得したpreviewIdを使ってページ編集を確定する';
7
+ 'previewEdit/previewDeleteで取得したpreviewIdを使ってページ編集・削除を確定する';
8
8
 
9
- export const submitEditHelp = `submitEdit - previewEditで取得したpreviewIdを使ってページ編集を確定する
9
+ export const submitEditHelp = `submitEdit - previewEdit/previewDeleteで取得したpreviewIdを使ってページ編集・削除を確定する
10
10
 
11
11
  Usage:
12
12
  cosense submitEdit <projectUrl> <previewId>
13
13
 
14
14
  引数:
15
15
  <projectUrl> プロジェクトのURL (例: https://scrapbox.io/shokai)。 末尾に余分なpathがあるとerror
16
- <previewId> previewEdit の戻り値の previewId
16
+ <previewId> previewEdit または previewDelete の戻り値の previewId
17
17
 
18
18
  戻り値(plain text):
19
19
  commitId: <生成されたcommitのID>
@@ -28,6 +28,11 @@ Usage:
28
28
  titleChanged: "<変更前title>" -> "<変更後title>"
29
29
  新旧タイトルはそれぞれJSON string
30
30
 
31
+ previewDelete の previewId を確定した時は、代わりに以下を出力する:
32
+ commitId: <生成されたcommitのID>
33
+ title: <削除されたpage title>
34
+ deleted: true
35
+
31
36
  HTTPエラー:
32
37
  HTTP 400 preview を生成した時と違う project の URL を渡している
33
38
  HTTP 401 認証なし
@@ -36,6 +41,9 @@ HTTPエラー:
36
41
  HTTP 409 {"error":"NotFastForward","latest":...}
37
42
  preview生成後にページが更新された。最新stateを再取得して ops を作り直し、
38
43
  previewEdit からやり直す必要がある
44
+ HTTP 409 {"error":"NotFastForward","latest":null}
45
+ 対象ページが既に存在しない。削除の確定でこれが返った場合、ページは別経路で
46
+ 削除済みであり、目的の状態は達成されている。リトライ不要
39
47
  HTTP 409 {"error":"DuplicateTitle"}
40
48
  preview→submit の間に他人が同名ページを作った (race condition)
41
49
 
@@ -45,6 +53,7 @@ previewId は1回限り (consume-on-submit)。submit 後・5分 expire 後・con
45
53
  interface SubmitResponse {
46
54
  commitId: string;
47
55
  page: { title?: string } | null;
56
+ pageDeleted?: { title?: string };
48
57
  titleChanged?: { from?: string; to?: string };
49
58
  }
50
59
 
@@ -64,6 +73,15 @@ export const submitEdit = async (args: string[]): Promise<void> => {
64
73
  body: { previewId }
65
74
  })) as SubmitResponse;
66
75
 
76
+ // pageDeletedはページ削除commitの確定時に返る。削除されたページにURLは無い
77
+ const pageDeleted = response.pageDeleted;
78
+ if (pageDeleted) {
79
+ process.stdout.write(
80
+ `commitId: ${response.commitId}\ntitle: ${pageDeleted.title ?? ''}\ndeleted: true\n`
81
+ );
82
+ return;
83
+ }
84
+
67
85
  const title = response.page?.title;
68
86
  if (typeof title !== 'string') {
69
87
  throw new Error(
@@ -20,13 +20,13 @@ Usage:
20
20
  <filePath> アップロードするローカルファイルのパス
21
21
 
22
22
  オプション:
23
- --content-type <type> ファイルのMIME type。省略時は拡張子から推定し、不明な拡張子はapplication/octet-streamになる
23
+ --content-type <type> ファイルのMIME type
24
24
 
25
25
  出力(JSON):
26
- embedUrl string ページ本文への埋め込みに使うファイルURL
27
- originalname string アップロードしたファイル名
28
- contentType string embedUrlが配信するファイルのMIME type
29
- size number ファイルのbyte数
26
+ embedUrl string ページ本文への埋め込みに使うファイルURL
27
+ originalname string アップロードしたファイル名
28
+ contentType string? ファイルのMIME type
29
+ size number ファイルのbyte数
30
30
 
31
31
  例:
32
32
  cosense uploadFile 'https://scrapbox.io/example' ./photo.png
@@ -55,7 +55,7 @@ const parseArgs = (args: string[]): ParsedArgs => {
55
55
  throw new Error(`--content-type specified multiple times\n${usage}`);
56
56
  }
57
57
  const value = args[i + 1];
58
- if (value === undefined || value.startsWith('--')) {
58
+ if (!value || value.startsWith('--')) {
59
59
  throw new Error(`--content-type requires a value\n${usage}`);
60
60
  }
61
61
  contentType = value;
@@ -81,11 +81,11 @@ const parseArgs = (args: string[]): ParsedArgs => {
81
81
  const uploadToSignedUrl = async (
82
82
  url: string,
83
83
  body: Uint8Array,
84
- contentType: string
84
+ contentType: string | undefined
85
85
  ): Promise<void> => {
86
86
  const res = await fetch(url, {
87
87
  method: 'PUT',
88
- headers: { 'Content-Type': contentType },
88
+ headers: contentType ? { 'Content-Type': contentType } : {},
89
89
  body
90
90
  });
91
91
  if (res.ok) {
@@ -149,10 +149,10 @@ export const uploadFile = async (args: string[]): Promise<void> => {
149
149
  let resultContentType = contentType;
150
150
  if (uploadRequest.embedUrl) {
151
151
  // 同一ファイルがアップロード済みの場合、serverはupload-requestで即embedUrlを返す。
152
- // embedUrlが配信するのは保存済みファイルなので、contentTypeはローカル推定よりserver値を優先する
152
+ // embedUrlが配信するのは保存済みファイルなので、ローカル推定の型は当てにならない
153
153
  embedUrl = uploadRequest.embedUrl;
154
154
  originalname = uploadRequest.originalname ?? name;
155
- resultContentType = uploadRequest.contentType ?? contentType;
155
+ resultContentType = uploadRequest.contentType?.trim() || undefined;
156
156
  } else {
157
157
  const { signedUrl, fileId } = uploadRequest;
158
158
  if (!signedUrl || !fileId) {
@@ -1,7 +1,8 @@
1
1
  import { extname } from 'node:path';
2
2
 
3
- // アップロード時に申告するContent-Typeを拡張子から推定する最小マップ。
4
- // 網羅せず、外れる型は--content-typeで上書きしてもらう
3
+ // アップロード時に申告するContent-Typeを拡張子から推定するマップ。網羅はしないが、
4
+ // browsePageが<cosense:file type="...">でファイル種別をAIに伝えられるよう、判明した型は足していく。
5
+ // ここに無い型は--content-typeで上書きしてもらう
5
6
  const MIME_TYPES: Record<string, string> = {
6
7
  png: 'image/png',
7
8
  jpg: 'image/jpeg',
@@ -20,6 +21,13 @@ const MIME_TYPES: Record<string, string> = {
20
21
  weba: 'audio/webm',
21
22
  aac: 'audio/aac',
22
23
  pdf: 'application/pdf',
24
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
25
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
26
+ xlsm: 'application/vnd.ms-excel.sheet.macroEnabled.12',
27
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
28
+ doc: 'application/msword',
29
+ xls: 'application/vnd.ms-excel',
30
+ ppt: 'application/vnd.ms-powerpoint',
23
31
  txt: 'text/plain',
24
32
  md: 'text/markdown',
25
33
  csv: 'text/csv',
@@ -29,7 +37,11 @@ const MIME_TYPES: Record<string, string> = {
29
37
  zip: 'application/zip'
30
38
  };
31
39
 
32
- export const contentTypeForFile = (filePath: string): string => {
40
+ // 推定できない時にapplication/octet-streamを申告しない。octet-streamで申告するとserverが
41
+ // embedUrlから拡張子を落とすため、URLからファイル種別が読み取れなくなり、ページ本文での
42
+ // 埋め込み表示の判定もできなくなる
43
+ export const contentTypeForFile = (filePath: string): string | undefined => {
33
44
  const ext = extname(filePath).slice(1).toLowerCase();
34
- return MIME_TYPES[ext] ?? 'application/octet-stream';
45
+ // a.constructorやa.__proto__はObject.prototypeのpropertyを引いてしまう
46
+ return Object.hasOwn(MIME_TYPES, ext) ? MIME_TYPES[ext] : undefined;
35
47
  };
@@ -40,7 +40,7 @@ export class HttpError extends Error {
40
40
 
41
41
  interface RequestOptions {
42
42
  credential?: Credential;
43
- method?: 'GET' | 'POST';
43
+ method?: 'GET' | 'POST' | 'DELETE';
44
44
  body?: unknown;
45
45
  }
46
46