gangtise-openapi-cli 0.50.0 → 0.52.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.
Files changed (43) hide show
  1. package/README.md +94 -49
  2. package/dist/src/cli.js +29 -8
  3. package/dist/src/commands/ai.js +27 -12
  4. package/dist/src/commands/alternative.js +2 -2
  5. package/dist/src/commands/bond.js +4 -4
  6. package/dist/src/commands/fund.js +1 -1
  7. package/dist/src/commands/fundamental.js +11 -11
  8. package/dist/src/commands/indicator.js +5 -5
  9. package/dist/src/commands/insight.js +38 -17
  10. package/dist/src/commands/quote.js +6 -6
  11. package/dist/src/commands/raw.js +3 -3
  12. package/dist/src/commands/reference.js +35 -3
  13. package/dist/src/commands/shared.js +20 -8
  14. package/dist/src/commands/tool.js +9 -4
  15. package/dist/src/commands/update.js +11 -0
  16. package/dist/src/commands/vault.js +17 -16
  17. package/dist/src/core/client.js +21 -4
  18. package/dist/src/core/download.js +12 -3
  19. package/dist/src/core/driveUpload.js +6 -2
  20. package/dist/src/core/dryRun.js +68 -0
  21. package/dist/src/core/endpoints.js +87 -6
  22. package/dist/src/core/errors.js +2 -2
  23. package/dist/src/core/etfComponents.js +65 -8
  24. package/dist/src/core/exitStatus.js +4 -0
  25. package/dist/src/core/fileParse.js +10 -3
  26. package/dist/src/core/printer.js +39 -7
  27. package/dist/src/core/statePaths.js +41 -0
  28. package/dist/src/core/transport.js +7 -0
  29. package/gangtise-openapi/SKILL.md +43 -156
  30. package/gangtise-openapi/references/billing.md +142 -0
  31. package/gangtise-openapi/references/commands/ai.md +26 -0
  32. package/gangtise-openapi/references/commands/fund.md +3 -3
  33. package/gangtise-openapi/references/commands/fundamental.md +2 -1
  34. package/gangtise-openapi/references/commands/indicator.md +106 -15
  35. package/gangtise-openapi/references/commands/insight.md +31 -6
  36. package/gangtise-openapi/references/commands/reference-and-lookup.md +42 -4
  37. package/gangtise-openapi/references/commands/tool.md +2 -2
  38. package/gangtise-openapi/references/commands/vault.md +26 -4
  39. package/gangtise-openapi/references/errors.md +3 -3
  40. package/gangtise-openapi/references/examples.md +3 -3
  41. package/gangtise-openapi/references/lookup-ids.md +13 -0
  42. package/gangtise-openapi/references/response-schema.md +8 -2
  43. package/package.json +2 -2
@@ -2,15 +2,18 @@ import { Command, Option } from "commander";
2
2
  import { POLL_MAX_ATTEMPTS } from "../core/asyncContent.js";
3
3
  import { collectList, maybeArray, parseNumberOption, parseOptionalNumberOption } from "../core/args.js";
4
4
  import { ValidationError } from "../core/errors.js";
5
+ import { assertOutputFilePath } from "../core/download.js";
5
6
  import { fetchFileParseResult, pollFileParseResult, submitFileParse } from "../core/fileParse.js";
6
7
  import { markFailed } from "../core/exitStatus.js";
7
- import { emit, withClient } from "./shared.js";
8
+ import { emit, withClient, OUTPUT_HELP } from "./shared.js";
8
9
  export const tool = new Command("tool").description("Research tool APIs: PDF parsing");
9
10
  tool.command("file-parse").description("Parse a PDF into Markdown + images (async; 0.8 credits/page, billed at submit)")
10
11
  .requiredOption("--file <path>", "PDF to upload (max 100MB / 500 pages)")
11
12
  .option("--wait", "Wait for the parse to finish and save the result ZIP (blocking, up to ~5 min)")
12
- .option("--output <path>", "Where to save the result ZIP (used with --wait)")
13
+ .option("--output <path>", "Where to save the result ZIP (a file path, not a directory; used with --wait)")
13
14
  .action((options) => withClient(async (client) => {
15
+ if (options.wait && options.output)
16
+ await assertOutputFilePath(options.output);
14
17
  const taskId = await submitFileParse(client, options.file);
15
18
  if (!options.wait) {
16
19
  process.stderr.write(`File parse task submitted. taskId: ${taskId}\n`);
@@ -25,8 +28,10 @@ tool.command("file-parse").description("Parse a PDF into Markdown + images (asyn
25
28
  }));
26
29
  tool.command("file-parse-check").description("Download a finished file-parse result ZIP by taskId (free)")
27
30
  .requiredOption("--task-id <id>", "taskId from 'tool file-parse'")
28
- .option("--output <path>", "Where to save the result ZIP")
31
+ .option("--output <path>", "Where to save the result ZIP (a file path, not a directory)")
29
32
  .action((options) => withClient(async (client) => {
33
+ if (options.output)
34
+ await assertOutputFilePath(options.output);
30
35
  if (await fetchFileParseResult(client, options.taskId, options.output) === "pending") {
31
36
  process.stdout.write(`${JSON.stringify({ taskId: options.taskId, status: "pending", hint: "Parse not finished yet, retry in ~1 minute" })}\n`);
32
37
  }
@@ -39,7 +44,7 @@ tool.command("web-search").description("Search the public web for research: dedu
39
44
  .option("--site <domain>", "Restrict to a registered domain or subdomain, e.g. csrc.gov.cn (repeatable, max 10, OR-ed)", collectList, [])
40
45
  .option("--include-content", "Return page body as Markdown; caps --size at 5")
41
46
  .option("--max-content-chars <number>", "Max chars per body, 1000-20000 (only with --include-content)")
42
- .option("--format <format>", "Output format", "table").option("--output <path>")
47
+ .option("--format <format>", "Output format", "table").option("--output <path>", OUTPUT_HELP)
43
48
  .action((options) => emit(options, (client) => {
44
49
  const size = parseNumberOption(options.size ?? (options.includeContent ? "5" : "10"), "--size", { integer: true, min: 1, max: 20 });
45
50
  if (options.includeContent && size > 5)
@@ -1,6 +1,7 @@
1
1
  import os from "node:os";
2
2
  import { Command } from "commander";
3
3
  import { isVersionNewer } from "../core/args.js";
4
+ import { isDryRun } from "../core/dryRun.js";
4
5
  import { installVersion, refreshSkillCopies } from "../core/selfUpdate.js";
5
6
  import { latestVersion, PACKAGE_NAME, REGISTRY } from "../core/updateCheck.js";
6
7
  import { CLI_VERSION, PACKAGE_ROOT } from "../version.js";
@@ -9,6 +10,16 @@ export const update = new Command("update")
9
10
  .option("--check", "Only report whether a newer version is published; install nothing")
10
11
  .option("--no-skill", "Leave the Agent Skill copies as they are")
11
12
  .action(async (options) => {
13
+ if (isDryRun()) {
14
+ const steps = [`look up the latest published version on ${REGISTRY}`];
15
+ if (!options.check) {
16
+ steps.push(`if newer than ${CLI_VERSION}: npm install -g ${PACKAGE_NAME}@<latest> --registry=${REGISTRY}`);
17
+ if (options.skill)
18
+ steps.push("refresh older copies of the Agent Skill");
19
+ }
20
+ process.stdout.write(`${JSON.stringify({ dryRun: true, command: "update", steps, note: "Nothing was looked up or installed." }, null, 2)}\n`);
21
+ return;
22
+ }
12
23
  const latest = await latestVersion(undefined, 15_000, Date.now(), true);
13
24
  if (!latest)
14
25
  throw new Error(`Could not reach ${REGISTRY} to look up the latest version. Check the network and retry, or install directly: npm install -g ${PACKAGE_NAME}@latest --registry=${REGISTRY}`);
@@ -3,13 +3,13 @@ import { collectList, collectNames, parseFrom, parseSize } from "../core/args.js
3
3
  import { uploadDriveFile } from "../core/driveUpload.js";
4
4
  import { ValidationError } from "../core/errors.js";
5
5
  import { flagFailedItems } from "../core/normalize.js";
6
- import { emit, addDownloadCommand, assertConfirmed, field, value, required, requiredList, list, numberList, format, output, from, size, startTime, endTime, query, RESEARCH_AREA_CITIC_OR_DIRECTION } from "./shared.js";
6
+ import { emit, addDownloadCommand, assertConfirmed, field, value, required, requiredList, list, numberList, format, output, from, size, startTime, endTime, query, RESEARCH_AREA_CITIC_OR_DIRECTION, OUTPUT_HELP } from "./shared.js";
7
7
  export const vault = new Command("vault").description("Vault APIs");
8
8
  query(vault, "drive-list", {
9
9
  endpoint: "vault.drive.list",
10
10
  cache: { endpointKey: "vault.drive.list", idField: "fileId" },
11
11
  fields: [
12
- from(), size(), startTime(), endTime(), value("--keyword <text>", undefined, "keyword"),
12
+ from(), size(), startTime(), endTime(), value("--keyword <text>", "Keyword", "keyword"),
13
13
  numberList("--file-type <number>", "File type", "fileTypeList"),
14
14
  numberList("--space-type <number>", "Space type", "spaceTypeList"),
15
15
  format(), output(),
@@ -32,7 +32,7 @@ vault.command("drive-upload").description("Upload a file to the AI drive (free;
32
32
  .addOption(driveSpaceOption("Target space: 1=my drive 2=tenant drive (shared with your whole tenant)"))
33
33
  .option("--folder-id <id>", "Target folder ID in that space; omit (or 'root') for the space's root")
34
34
  .option("--title <name>", "Name in the drive, max 200 characters; defaults to the local file name")
35
- .option("--format <format>", "Output format", "json").option("--output <path>")
35
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
36
36
  .action((options) => emit(options, (client) => uploadDriveFile(client, options.file, { spaceType: Number(options.spaceType), folderId: options.folderId, title: options.title })));
37
37
  query(vault, "drive-create-folder", {
38
38
  description: "Create a drive folder (free). Same-name folders are allowed, so re-running creates a second one",
@@ -57,7 +57,7 @@ query(vault, "drive-rename", {
57
57
  vault.command("drive-move-file").description("Move drive files into a folder of the SAME space (free)")
58
58
  .requiredOption("--file-id <id>", "File ID (repeat or comma-separate)", collectList)
59
59
  .requiredOption("--target-folder-id <id>", "Destination folder ID, or 'root'. Files of the other space land in failList (空间不一致)")
60
- .option("--format <format>", "Output format", "json").option("--output <path>")
60
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
61
61
  .action((options) => emit(options, async (client) => {
62
62
  const data = await client.call("vault.drive.move-file", { fileIdList: options.fileId, targetFolderId: options.targetFolderId });
63
63
  flagFailedItems(data, "vault drive-move-file");
@@ -77,7 +77,7 @@ vault.command("drive-copy").description("Copy files, or one folder with everythi
77
77
  .option("--target-folder-id <id>", "Destination folder ID in the other space, or 'root' for that space's root")
78
78
  .option("--folder-id <id>", "Source folder ID; use with --target-parent-id")
79
79
  .option("--target-parent-id <id>", "Destination parent folder ID in the other space, or 'root' for that space's root")
80
- .option("--format <format>", "Output format", "json").option("--output <path>")
80
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
81
81
  .action((options) => {
82
82
  const byFile = options.fileId.length > 0 || options.targetFolderId !== undefined;
83
83
  const byFolder = options.folderId !== undefined || options.targetParentId !== undefined;
@@ -101,7 +101,7 @@ vault.command("drive-copy").description("Copy files, or one folder with everythi
101
101
  vault.command("drive-delete-file").description("Delete drive files — irreversible (free)")
102
102
  .requiredOption("--file-id <id>", "File ID to delete (repeat or comma-separate)", collectList)
103
103
  .option("--yes", "Required: confirm the deletion")
104
- .option("--format <format>", "Output format", "json").option("--output <path>")
104
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
105
105
  .action(async (options) => {
106
106
  assertConfirmed("vault.drive.delete-file", Boolean(options.yes), options.fileId.join("、"));
107
107
  await emit(options, async (client) => {
@@ -113,7 +113,7 @@ vault.command("drive-delete-file").description("Delete drive files — irreversi
113
113
  vault.command("drive-delete-folder").description("Delete a drive folder AND every subfolder and file inside it — irreversible (free)")
114
114
  .requiredOption("--folder-id <id>", "Folder ID to delete")
115
115
  .option("--yes", "Required: confirm the deletion")
116
- .option("--format <format>", "Output format", "json").option("--output <path>")
116
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
117
117
  .action(async (options) => {
118
118
  assertConfirmed("vault.drive.delete-folder", Boolean(options.yes), options.folderId);
119
119
  await emit(options, (client) => client.call("vault.drive.delete-folder", { folderId: options.folderId }));
@@ -122,7 +122,7 @@ query(vault, "record-list", {
122
122
  endpoint: "vault.record.list",
123
123
  cache: { endpointKey: "vault.record.list", idField: "recordId" },
124
124
  fields: [
125
- from(), size(), startTime(), endTime(), value("--keyword <text>", undefined, "keyword"),
125
+ from(), size(), startTime(), endTime(), value("--keyword <text>", "Keyword", "keyword"),
126
126
  list("--category <name>", "Recording type: upload/link/mobile/gtNote/pc/share", "categoryList"),
127
127
  numberList("--space-type <number>", "Space type: 1=my records / 2=tenant records", "spaceTypeList"),
128
128
  format(), output(),
@@ -133,7 +133,7 @@ query(vault, "my-conference-list", {
133
133
  endpoint: "vault.my-conference.list",
134
134
  cache: { endpointKey: "vault.my-conference.list", idField: "conferenceId" },
135
135
  fields: [
136
- from(), size(), startTime(), endTime(), value("--keyword <text>", undefined, "keyword"),
136
+ from(), size(), startTime(), endTime(), value("--keyword <text>", "Keyword", "keyword"),
137
137
  list("--research-area <id>", RESEARCH_AREA_CITIC_OR_DIRECTION, "researchAreaList"),
138
138
  list("--security <code>", "Security code", "securityList"),
139
139
  list("--institution <id>", "Institution ID", "institutionList"),
@@ -146,15 +146,16 @@ addDownloadCommand(vault, { endpointKey: "vault.my-conference.download", name: "
146
146
  query(vault, "wechat-message-list", {
147
147
  endpoint: "vault.wechat-message.list",
148
148
  fields: [
149
- from(), size(), startTime(), endTime(), value("--keyword <text>", undefined, "keyword"),
149
+ from(), size(), startTime(), endTime(), value("--keyword <text>", "Keyword", "keyword"),
150
150
  list("--security <code>", "Security code (e.g. 000001.SZ)", "securityList"),
151
151
  list("--wechat-group-id <id>", "WeChat group ID", "wechatGroupIdList"),
152
152
  list("--industry <id>", "Industry ID -- citicIndustry codes (1008001xx) only; swIndustry codes and unknown values are rejected with 100005", "industryIdList"),
153
- list("--category <name>", "Message type: text/image/documents/url", "categoryList"),
153
+ list("--category <name>", "Message type: text/image/document/url/audio/video; any other value is rejected with 100005", "categoryList"),
154
154
  list("--tag <name>", "Tag: roadShow/research/strategyMeeting/meetingSummary/industryComment/companyComment/earningsReview", "tagList"),
155
155
  format(), output(),
156
156
  ],
157
157
  });
158
+ addDownloadCommand(vault, { endpointKey: "vault.wechat-message.download", name: "wechat-message-download", idOption: "--msg-id", idField: "msgId", fallbackPrefix: "wechat-file" });
158
159
  query(vault, "wechat-chatroom-list", {
159
160
  endpoint: "vault.wechat-chatroom.list",
160
161
  fields: [
@@ -192,7 +193,7 @@ vault.command("stock-pool-add-stock")
192
193
  .requiredOption("--pool-id <id>", "Target pool ID from 'stock-pool-list'")
193
194
  .requiredOption("--security <code>", "Security code, e.g. 600519.SH (repeat or comma-separate)", collectList)
194
195
  .option("--format <format>", "Output format", "table")
195
- .option("--output <path>")
196
+ .option("--output <path>", OUTPUT_HELP)
196
197
  .action((options) => emit(options, async (client) => {
197
198
  const data = await client.call("vault.stock-pool.add-stock", { poolId: options.poolId, securityCodeList: options.security });
198
199
  flagFailedItems(data, "vault stock-pool-add-stock");
@@ -202,7 +203,7 @@ vault.command("stock-pool-remove-stock")
202
203
  .requiredOption("--pool-id <id>", "Target pool ID from 'stock-pool-list'")
203
204
  .requiredOption("--security <code>", "Security code, e.g. 600519.SH (repeat or comma-separate)", collectList)
204
205
  .option("--format <format>", "Output format", "table")
205
- .option("--output <path>")
206
+ .option("--output <path>", OUTPUT_HELP)
206
207
  .action((options) => emit(options, async (client) => {
207
208
  const data = await client.call("vault.stock-pool.remove-stock", { poolId: options.poolId, securityCodeList: options.security });
208
209
  flagFailedItems(data, "vault stock-pool-remove-stock");
@@ -212,7 +213,7 @@ vault.command("stock-pool-delete")
212
213
  .requiredOption("--pool-id <id>", "Pool ID to delete; repeat or comma-separate for multiple", collectList)
213
214
  .option("--yes", "Required: confirm the deletion")
214
215
  .option("--format <format>", "Output format", "table")
215
- .option("--output <path>")
216
+ .option("--output <path>", OUTPUT_HELP)
216
217
  .action(async (options) => {
217
218
  assertConfirmed("vault.stock-pool.delete", Boolean(options.yes), options.poolId.join("、"));
218
219
  await emit(options, async (client) => {
@@ -226,7 +227,7 @@ vault.command("stock-note-list").description("List your stock notes: all, by sec
226
227
  .option("--note-id <id>", "Note ID (repeat or comma-separate); not together with --security", collectList)
227
228
  .option("--from <number>", "Starting offset", "0")
228
229
  .option("--size <number>", "Total rows to return; omit to fetch all")
229
- .option("--format <format>", "Output format", "json").option("--output <path>")
230
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
230
231
  .action((options) => {
231
232
  if (options.security?.length && options.noteId?.length)
232
233
  throw new ValidationError("pass --security or --note-id, not both");
@@ -255,7 +256,7 @@ query(vault, "stock-note-update", {
255
256
  vault.command("stock-note-delete").description("Delete stock notes — irreversible (free)")
256
257
  .requiredOption("--note-id <id>", "Note ID to delete; repeat or comma-separate, max 100", collectList)
257
258
  .option("--yes", "Required: confirm the deletion")
258
- .option("--format <format>", "Output format", "json").option("--output <path>")
259
+ .option("--format <format>", "Output format", "json").option("--output <path>", OUTPUT_HELP)
259
260
  .action(async (options) => {
260
261
  assertConfirmed("vault.stock-note.delete", Boolean(options.yes), options.noteId.join("、"));
261
262
  await emit(options, async (client) => {
@@ -9,13 +9,16 @@ import { ApiError, attachEnvelopeTraceId, markStructural, ValidationError } from
9
9
  import { COSTLY_FETCH_CREDITS, ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
10
10
  import { getLookupData } from "./lookupData/index.js";
11
11
  import { stagingPath } from "./output.js";
12
- import { decodeResponseBody, getDispatcher, isVerbose, JSON_TOTAL_FACTOR, logTiming, markAuthReplay, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, TRANSFER_TOTAL_FACTOR, withRetry } from "./transport.js";
12
+ import { decodeResponseBody, getDispatcher, isVerbose, JSON_TOTAL_FACTOR, logTiming, markAuthReplay, noteRequestSent, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, TRANSFER_TOTAL_FACTOR, withRetry } from "./transport.js";
13
13
  import { attachRowSink } from "./rowSink.js";
14
14
  import { markIncomplete } from "./exitStatus.js";
15
+ import { isDryRun, stopForDryRun } from "./dryRun.js";
15
16
  const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008", "999002"]);
16
17
  function hasExpectedShape(expects, payload) {
17
18
  if (expects === "array")
18
19
  return Array.isArray(payload);
20
+ if (expects === "object")
21
+ return Boolean(payload && typeof payload === "object" && !Array.isArray(payload));
19
22
  return Boolean(payload && typeof payload === "object" && Array.isArray(payload.list));
20
23
  }
21
24
  const PROBE_ABOVE_CREDITS = 50;
@@ -79,7 +82,7 @@ export class GangtiseClient {
79
82
  return this.refreshPromise;
80
83
  }
81
84
  async doTokenRefresh() {
82
- const credentials = requireAccessCredentials(this.config.accessKey, this.config.secretKey);
85
+ const credentials = isDryRun() ? { accessKey: "", secretKey: "" } : requireAccessCredentials(this.config.accessKey, this.config.secretKey);
83
86
  const envelope = await this.requestJson(ENDPOINTS["auth.login"], {
84
87
  accessKey: credentials.accessKey,
85
88
  secretKey: credentials.secretKey,
@@ -472,6 +475,8 @@ export class GangtiseClient {
472
475
  if (endpoint.path.startsWith('/guide/')) {
473
476
  return this.readLocalLookup(endpoint);
474
477
  }
478
+ if (isDryRun())
479
+ return stopForDryRun({ endpoint, url: this.buildUrl(endpoint.path), body });
475
480
  const dispatcher = getDispatcher();
476
481
  const url = this.buildUrl(endpoint.path);
477
482
  const authState = {};
@@ -488,6 +493,7 @@ export class GangtiseClient {
488
493
  usedAuthorization = await this.getAuthorizationHeader();
489
494
  headers.Authorization = usedAuthorization;
490
495
  }
496
+ noteRequestSent();
491
497
  const startedAt = Date.now();
492
498
  const response = await request(url, {
493
499
  method: endpoint.method,
@@ -562,6 +568,10 @@ export class GangtiseClient {
562
568
  if (!endpoint || endpoint.kind !== 'upload') {
563
569
  throw new ApiError(`Not an upload endpoint: ${endpointKey}`);
564
570
  }
571
+ if (isDryRun()) {
572
+ const sent = Object.fromEntries(Object.entries(fields ?? {}).filter(([, value]) => value !== undefined));
573
+ return stopForDryRun({ endpoint, url: this.buildUrl(endpoint.path), body: sent, file: { name: file.filename, bytes: file.data.byteLength } });
574
+ }
565
575
  const form = new FormData();
566
576
  form.append('file', new Blob([file.data], { type: file.contentType ?? 'application/octet-stream' }), file.filename);
567
577
  for (const [name, value] of Object.entries(fields ?? {})) {
@@ -580,6 +590,7 @@ export class GangtiseClient {
580
590
  const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
581
591
  return withRetry(async () => {
582
592
  const authorization = await this.getAuthorizationHeader();
593
+ noteRequestSent();
583
594
  const startedAt = Date.now();
584
595
  const signal = AbortSignal.timeout(timeoutMs * TRANSFER_TOTAL_FACTOR);
585
596
  let currentUrl = url;
@@ -621,7 +632,8 @@ export class GangtiseClient {
621
632
  }
622
633
  const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
623
634
  const retryAfterMs = parseRetryAfterMs(response.headers['retry-after'], Date.now());
624
- if (contentType?.includes('application/json')) {
635
+ const rawFile = endpoint.fileOnSuccess === true && response.statusCode < 300;
636
+ if (!rawFile && contentType?.includes('application/json')) {
625
637
  const text = await response.body.text();
626
638
  logTiming(`${endpoint.method} ${endpoint.path} (json)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
627
639
  let parsed;
@@ -650,7 +662,7 @@ export class GangtiseClient {
650
662
  }
651
663
  return { text: JSON.stringify(data, null, 2), contentType };
652
664
  }
653
- if (contentType?.includes('text/plain') || contentType?.includes('text/html')) {
665
+ if (!rawFile && (contentType?.includes('text/plain') || contentType?.includes('text/html'))) {
654
666
  const text = await response.body.text();
655
667
  logTiming(`${endpoint.method} ${endpoint.path} (text)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
656
668
  if (response.statusCode >= 400) {
@@ -730,6 +742,11 @@ export class GangtiseClient {
730
742
  if (endpoint.kind === 'upload') {
731
743
  throw new ValidationError(`${endpointKey} takes a file upload — use its dedicated command ('gangtise tool file-parse --file <path>' / 'gangtise vault drive-upload --file <path>'); 'raw call' cannot send files`);
732
744
  }
745
+ if (isDryRun() && !endpoint.path.startsWith('/guide/')) {
746
+ const url = this.buildUrl(endpoint.path);
747
+ Object.entries(query ?? {}).forEach(([key, value]) => url.searchParams.set(key, String(value)));
748
+ return stopForDryRun({ endpoint, url, body });
749
+ }
733
750
  if (endpoint.kind === 'download') {
734
751
  return this.download(endpoint, query ?? {}, options, body);
735
752
  }
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
- import { dirname, extname } from "node:path";
3
- import { DownloadError } from "./errors.js";
2
+ import { dirname, extname, join, sep } from "node:path";
3
+ import { DownloadError, ValidationError } from "./errors.js";
4
4
  import { saveOutputIfNeeded, stagingPath, trackClaim } from "./output.js";
5
+ import { assertNotStatePath } from "./statePaths.js";
5
6
  import { extractTitles, lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE, writeTitleCache } from "./titleCache.js";
6
7
  function sanitizeFilename(name) {
7
8
  return name.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "_");
@@ -147,7 +148,7 @@ async function downloadUrlTo(url, outputPath) {
147
148
  const { pipeline } = await import("node:stream/promises");
148
149
  const { dirname } = await import("node:path");
149
150
  const { request } = await import("undici");
150
- const { getDispatcher, logTiming, TRANSFER_TOTAL_FACTOR, withRetry } = await import("./transport.js");
151
+ const { getDispatcher, logTiming, noteRequestSent, TRANSFER_TOTAL_FACTOR, withRetry } = await import("./transport.js");
151
152
  const { loadConfig } = await import("./config.js");
152
153
  const timeoutMs = loadConfig().timeoutMs;
153
154
  await fs.mkdir(dirname(outputPath), { recursive: true });
@@ -162,6 +163,7 @@ async function downloadUrlTo(url, outputPath) {
162
163
  };
163
164
  const partPath = stagingPath(outputPath);
164
165
  await withRetry(async () => {
166
+ noteRequestSent();
165
167
  const startedAt = Date.now();
166
168
  const requestOptions = {
167
169
  method: "GET",
@@ -206,6 +208,13 @@ async function downloadUrlTo(url, outputPath) {
206
208
  throw error;
207
209
  }
208
210
  }
211
+ export async function assertOutputFilePath(output) {
212
+ const endsAsDirectory = output.endsWith("/") || output.endsWith(sep);
213
+ if (endsAsDirectory || (await fs.stat(output).catch(() => undefined))?.isDirectory()) {
214
+ throw new ValidationError(`--output ${output} is a directory: pass a file path (e.g. ${join(output, "<name>")}), or omit --output for an automatic name in the current directory`);
215
+ }
216
+ await assertNotStatePath(output, "--output");
217
+ }
209
218
  export function autoDownloadName(file, fallbackName) {
210
219
  return truncateFilename(file.filename ? sanitizeFilename(file.filename) : sanitizeFilename(fallbackName) + extFromContentType(file.contentType));
211
220
  }
@@ -1,12 +1,15 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { ValidationError } from "./errors.js";
4
+ import { assertNotStatePath } from "./statePaths.js";
4
5
  export const DRIVE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
5
6
  export const DRIVE_NAME_MAX = 200;
6
7
  export async function uploadDriveFile(client, filePath, options) {
7
- const resolved = path.resolve(filePath);
8
+ const shownAs = path.resolve(filePath);
9
+ let resolved;
8
10
  let stat;
9
11
  try {
12
+ resolved = await fs.realpath(filePath);
10
13
  stat = await fs.stat(resolved);
11
14
  }
12
15
  catch {
@@ -14,12 +17,13 @@ export async function uploadDriveFile(client, filePath, options) {
14
17
  }
15
18
  if (!stat.isFile())
16
19
  throw new ValidationError(`Not a file: ${filePath}`);
20
+ await assertNotStatePath(resolved, "--file");
17
21
  if (stat.size === 0)
18
22
  throw new ValidationError(`File is empty: ${filePath}`);
19
23
  if (stat.size > DRIVE_UPLOAD_MAX_BYTES) {
20
24
  throw new ValidationError(`File is ${(stat.size / 1024 / 1024).toFixed(1)}MB — the drive accepts at most 100MB per file`);
21
25
  }
22
- const filename = path.basename(resolved);
26
+ const filename = path.basename(shownAs);
23
27
  const name = options.title ?? filename;
24
28
  if (name.length > DRIVE_NAME_MAX) {
25
29
  throw new ValidationError(`File name is ${name.length} characters — the drive accepts at most ${DRIVE_NAME_MAX} (an emoji counts as 2); pass a shorter --title`);
@@ -0,0 +1,68 @@
1
+ import { removeStagingFiles } from "./output.js";
2
+ let dryRun = false;
3
+ let shown = false;
4
+ export function setDryRun(enabled) {
5
+ dryRun = enabled;
6
+ }
7
+ export function isDryRun() {
8
+ return dryRun;
9
+ }
10
+ const toCents = (credits) => Math.round(credits * 100) / 100;
11
+ function maxCredits(endpoint, body) {
12
+ const billing = endpoint.billing;
13
+ if (!billing)
14
+ return undefined;
15
+ const fields = body && typeof body === "object" ? body : {};
16
+ if (billing.per === "call" || billing.per === "document")
17
+ return billing.price;
18
+ if (endpoint.estimateBilledUnits) {
19
+ try {
20
+ return toCents(endpoint.estimateBilledUnits(fields) * billing.price);
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ if (billing.per === "row" && endpoint.pagination?.enabled)
27
+ return typeof fields.size === "number" ? toCents(fields.size * billing.price) : undefined;
28
+ if (!billing.maxUnits)
29
+ return undefined;
30
+ const asked = billing.unitsFrom?.reduce((n, key) => n + (Array.isArray(fields[key]) ? fields[key].length : 0), 0);
31
+ return toCents(Math.min(billing.maxUnits, asked || billing.maxUnits) * billing.price);
32
+ }
33
+ export function describeRequest(plan) {
34
+ const { endpoint } = plan;
35
+ return {
36
+ dryRun: true,
37
+ endpoint: endpoint.key,
38
+ method: endpoint.method,
39
+ url: plan.url.toString(),
40
+ body: endpoint.key === "auth.login" ? "(credentials not shown)" : plan.body ?? null,
41
+ file: plan.file,
42
+ pagination: endpoint.pagination?.enabled
43
+ ? `The CLI pages this list itself, at most ${endpoint.pagination.maxPageSize} rows a request, until size rows or the total; without size it fetches every row`
44
+ : undefined,
45
+ billing: endpoint.billing
46
+ ? { per: endpoint.billing.per, price: endpoint.billing.price, ...(Number.isFinite(endpoint.billing.maxUnits) ? { maxUnits: endpoint.billing.maxUnits } : {}) }
47
+ : null,
48
+ maxCredits: maxCredits(endpoint, plan.body),
49
+ needsYes: endpoint.destructive ? true : undefined,
50
+ note: "Nothing was sent. Only the command's first request is shown; any later request depends on its answer.",
51
+ };
52
+ }
53
+ export function describeNoRequest(output) {
54
+ return {
55
+ dryRun: true,
56
+ endpoint: null,
57
+ output: output ?? null,
58
+ note: "This command sends no request. Under --dry-run it prints only this and writes no file.",
59
+ };
60
+ }
61
+ export function stopForDryRun(plan) {
62
+ if (!shown) {
63
+ shown = true;
64
+ removeStagingFiles();
65
+ process.stdout.write(`${JSON.stringify(describeRequest(plan), null, 2)}\n`, () => process.exit(0));
66
+ }
67
+ return new Promise(() => { });
68
+ }