weaver-work-cli 0.1.0 → 0.1.2

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 (31) hide show
  1. package/README.md +15 -2
  2. package/dist/internal/e10/auth/commands.js +24 -3
  3. package/dist/internal/e10/auth/crypto.js +7 -22
  4. package/dist/internal/e10/auth/session.js +1 -1
  5. package/dist/shortcuts/invoice/host.js +129 -103
  6. package/dist/shortcuts/invoice/index.js +7 -2
  7. package/dist/shortcuts/invoice/manifest.js +22 -11
  8. package/dist/shortcuts/invoice/operations/ocr-preview.js +6 -3
  9. package/dist/shortcuts/invoice/operations/shared.js +24 -6
  10. package/dist/shortcuts/invoice/operations/validate-preview.js +1 -1
  11. package/docs/_catalog.md +1 -0
  12. package/docs/agent-invoice.md +12 -1
  13. package/docs/agent-skill-install.md +92 -0
  14. package/docs/e10-auth.md +7 -3
  15. package/docs/invoice.md +9 -4
  16. package/package.json +1 -1
  17. package/skill-template/domains/shared.md +6 -2
  18. package/skill-template/skill-template.md +15 -0
  19. package/skills/weaver-work-cli-invoice/SKILL.md +21 -2
  20. package/skills/weaver-work-cli-invoice/references/invoice-agent-entry.md +2 -0
  21. package/skills/weaver-work-cli-invoice/references/invoice-download.md +2 -0
  22. package/skills/weaver-work-cli-invoice/references/invoice-enterprise-list.md +6 -4
  23. package/skills/weaver-work-cli-invoice/references/invoice-file-upload.md +6 -2
  24. package/skills/weaver-work-cli-invoice/references/invoice-import.md +10 -2
  25. package/skills/weaver-work-cli-invoice/references/invoice-ocr-preview.md +2 -0
  26. package/skills/weaver-work-cli-invoice/references/invoice-personal-list.md +8 -5
  27. package/skills/weaver-work-cli-invoice/references/invoice-validation-preview.md +1 -1
  28. package/skills/weaver-work-cli-shared/SKILL.md +16 -6
  29. package/skills/weaver-work-cli-shared/references/e10-auth-and-session.md +8 -1
  30. package/skills/weaver-work-cli-shared/references/json-output-contract.md +25 -0
  31. package/skills/weaver-work-cli-shared/references/weaver-work-cli-installation.md +13 -1
package/README.md CHANGED
@@ -91,7 +91,15 @@ weaver-work-cli skills install invoice
91
91
  `skills install invoice` 会同时安装 `weaver-work-cli-shared` 和
92
92
  `weaver-work-cli-invoice`。默认目标目录是 `$CODEX_HOME/skills`,未设置
93
93
  `CODEX_HOME` 时使用 `~/.codex/skills`;需要自定义时可加
94
- `--target-dir <path>`。
94
+ `--target-dir <path>`。默认目标面向 Codex CLI;WorkBuddy 等桌面 Agent 的
95
+ 用户级 Skill 目录不是默认目标,需显式指定:
96
+
97
+ ```bash
98
+ weaver-work-cli skills install invoice --target-dir ~/.workbuddy/skills
99
+ ```
100
+
101
+ 各 Agent 环境的 skill 目录、从本地开发仓库升级到最新版的完整流程与验证
102
+ 步骤见 `docs/agent-skill-install.md`。
95
103
 
96
104
  卸载全局命令:
97
105
 
@@ -315,7 +323,10 @@ weaver-work-cli --json invoice download --fid <fid> --file-id <fileId> --output
315
323
  写操作固定使用 `prepare -> apply`:
316
324
 
317
325
  ```bash
318
- echo '{"file":"./invoice.pdf","validate":false,"syncToOa":false}' \
326
+ echo '{"file":"./invoice.pdf"}' \
327
+ | weaver-work-cli --json invoice run invoice.ocr.preview --input -
328
+
329
+ echo '{"file":"./invoice.pdf","validate":true,"syncToOa":true}' \
319
330
  | weaver-work-cli --json invoice import prepare --input -
320
331
 
321
332
  echo '{"file":"./invoice.pdf","continuation":"<prepare返回值>","confirm":true}' \
@@ -324,6 +335,8 @@ echo '{"file":"./invoice.pdf","continuation":"<prepare返回值>","confirm":true
324
335
 
325
336
  Agent 应优先调用 `weaver-work-cli --json invoice run <operation> --input -`,并读取 `weaver-work-cli invoice schema` 获取 operation 契约。共享、转让和标签接口因缺少稳定 ID 来源和写后回查契约,当前不暴露给 Agent。
326
337
 
338
+ 输出处理参考飞书 CLI 的 Agent 友好实践,但当前项目仍是轻量输出层:`ctx.output.write` 在 JSON 模式下直接把完整 envelope 打到 stdout,失败写 stderr;暂未提供通用 `--jq`、`--format table/ndjson` 或 `--page-all`。因此业务 Skill 会预先要求 Agent 控制响应量:列表默认 `page_size=10`,按业务分页字段继续;给用户只渲染摘要、关键字段、数量和下一步,超长详情/OCR/调试 JSON 优先落本地文件并返回路径。
339
+
327
340
  如需交付通用 Agent Skill 包:
328
341
 
329
342
  ```bash
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { createServer } from 'node:http';
3
3
  import { CliError, ExitCode } from '../../../core/errors.js';
4
+ import { clearInvoiceTokenForSession } from '../../../shortcuts/invoice/host.js';
4
5
  import { buildE10CookieHeader, E10AuthSession, clearProfileAuthFile, extractRawSetCookie, findAuthFile, getActiveProfile, getProfileAuthPath, getProfileDir, listProfiles, profileNameFromUrl, readProfileConfig, readTeamsCheckUserInfo, saveAuthFile, saveProfileConfig, setActiveProfile, } from './session.js';
5
6
  import { getE10AuthRoot } from './paths.js';
6
7
  const DEFAULT_AGENT_TYPE = 'weaver-work-cli';
@@ -83,6 +84,15 @@ function formatLoginResult(result) {
83
84
  'authFile: ' + result.authFile,
84
85
  ].join('\n');
85
86
  }
87
+ async function clearInvoiceTokenCache(profile, authData) {
88
+ if (!authData.baseUrl || !authData.userId)
89
+ return;
90
+ await clearInvoiceTokenForSession({
91
+ baseUrl: normalizeUrl(authData.baseUrl),
92
+ profile,
93
+ userId: authData.userId,
94
+ }).catch(() => false);
95
+ }
86
96
  function shellQuote(value) {
87
97
  return `'${value.replace(/'/g, "'\\''")}'`;
88
98
  }
@@ -118,7 +128,7 @@ async function resolveUserInfo(baseUrl, eteamsId, rawCookie = '', agentType = ''
118
128
  'content-type': 'application/json',
119
129
  eteamsid: eteamsId,
120
130
  cookie: buildE10CookieHeader(rawCookie, eteamsId, agentType),
121
- 'user-agent': agentType ? `AgentType=${agentType},IsAgent=true` : 'e10-login',
131
+ 'user-agent': agentType ? `AgentType=${agentType},IsAgent=true` : DEFAULT_AGENT_TYPE,
122
132
  origin: baseUrl,
123
133
  },
124
134
  redirect: 'manual',
@@ -201,6 +211,7 @@ async function oidcExchange(code, redirectUri, baseUrl, tenantKeyFallback, agent
201
211
  };
202
212
  const profile = profileHint || profileNameFromUrl(base);
203
213
  setActiveProfile(profile);
214
+ await clearInvoiceTokenCache(profile, authData);
204
215
  const authFile = saveAuthFile(authData, profile);
205
216
  saveProfileConfig(base, base, profile, tenantKey);
206
217
  return {
@@ -455,6 +466,7 @@ export function registerE10AuthCommands(parent, action) {
455
466
  passportUrl,
456
467
  cookieSavedAt: Date.now(),
457
468
  };
469
+ await clearInvoiceTokenCache(profile, authData);
458
470
  const authFile = saveAuthFile(authData, profile);
459
471
  saveProfileConfig(baseUrl, passportUrl, profile, tenantKey);
460
472
  const result = {
@@ -475,9 +487,18 @@ export function registerE10AuthCommands(parent, action) {
475
487
  auth.command('logout')
476
488
  .alias('clear')
477
489
  .description('Clear saved E10 auth for the current or selected profile')
478
- .action(action((ctx) => {
490
+ .action(action(async (ctx) => {
479
491
  const opts = ctx.getGlobalOptions();
480
- const result = clearProfileAuthFile(opts.profile || getActiveProfile());
492
+ const profile = opts.profile || getActiveProfile();
493
+ const authFile = getProfileAuthPath(profile);
494
+ const session = E10AuthSession.fromFile(authFile, opts.baseUrl, opts.passportUrl);
495
+ if (session) {
496
+ await clearInvoiceTokenCache(profile, {
497
+ baseUrl: session.getBaseUrl(),
498
+ userId: session.userId,
499
+ });
500
+ }
501
+ const result = clearProfileAuthFile(profile);
481
502
  const text = result.cleared
482
503
  ? 'Cleared E10 auth.\nprofile: ' + result.profile + '\nauthFile: ' + result.authFile
483
504
  : 'No E10 auth to clear.\nprofile: ' + result.profile + '\nauthFile: ' + result.authFile;
@@ -1,6 +1,5 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
2
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
3
  import { resolve } from 'node:path';
5
4
  import { Entry as KeyringEntry } from '@napi-rs/keyring';
6
5
  import { writeWithRetry } from '../../../core/write-with-retry.js';
@@ -9,15 +8,11 @@ const ALGORITHM = 'aes-256-gcm';
9
8
  const IV_LENGTH = 12;
10
9
  const TAG_LENGTH = 16;
11
10
  const KEYCHAIN_SERVICE = 'weaver-work-cli';
12
- const LEGACY_KEYCHAIN_SERVICE = 'e10-cli';
13
11
  const KEYCHAIN_ACCOUNT = 'auth-key';
14
12
  let encKey = null;
15
13
  function getKeyFilePath() {
16
14
  return resolve(getE10AuthRoot(), '.key');
17
15
  }
18
- function getLegacyKeyFilePath() {
19
- return resolve(homedir(), '.e10-cli', '.key');
20
- }
21
16
  function readKeychainKey(service) {
22
17
  try {
23
18
  const entry = new KeyringEntry(service, KEYCHAIN_ACCOUNT);
@@ -73,13 +68,6 @@ function getEncKey() {
73
68
  writeWithRetry(keyFile, encKey.toString('base64'), { mode: 0o600 });
74
69
  return encKey;
75
70
  }
76
- function getDecryptKeys() {
77
- const keys = [getEncKey()];
78
- const legacyKey = readKeychainKey(LEGACY_KEYCHAIN_SERVICE) || readKeyFile(getLegacyKeyFilePath());
79
- if (legacyKey && !keys.some((key) => key.equals(legacyKey)))
80
- keys.push(legacyKey);
81
- return keys;
82
- }
83
71
  export function encryptAuthData(plaintext) {
84
72
  const iv = randomBytes(IV_LENGTH);
85
73
  const cipher = createCipheriv(ALGORITHM, getEncKey(), iv, { authTagLength: TAG_LENGTH });
@@ -95,17 +83,14 @@ export function decryptAuthData(wrapper) {
95
83
  const iv = combined.subarray(0, IV_LENGTH);
96
84
  const tag = combined.subarray(combined.length - TAG_LENGTH);
97
85
  const encrypted = combined.subarray(IV_LENGTH, combined.length - TAG_LENGTH);
98
- for (const key of getDecryptKeys()) {
99
- try {
100
- const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
101
- decipher.setAuthTag(tag);
102
- return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8');
103
- }
104
- catch {
105
- // Try the next known key.
106
- }
86
+ try {
87
+ const decipher = createDecipheriv(ALGORITHM, getEncKey(), iv, { authTagLength: TAG_LENGTH });
88
+ decipher.setAuthTag(tag);
89
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8');
90
+ }
91
+ catch {
92
+ return null;
107
93
  }
108
- return null;
109
94
  }
110
95
  export function parseAuthFile(raw) {
111
96
  try {
@@ -254,7 +254,7 @@ export class E10AuthSession {
254
254
  return buildE10CookieHeader(raw, this.eteamsId, this.agentType);
255
255
  }
256
256
  uaHeader() {
257
- return this.agentType ? `AgentType=${this.agentType},IsAgent=true` : 'e10-login';
257
+ return this.agentType ? `AgentType=${this.agentType},IsAgent=true` : 'weaver-work-cli';
258
258
  }
259
259
  toJSON() {
260
260
  return {
@@ -5,6 +5,7 @@ import { getActiveProfile } from '../../internal/e10/auth/session.js';
5
5
  import { InvoiceCliError, invoiceAuthError, invoiceNetworkError, invoiceValidationError, } from './errors.js';
6
6
  const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
7
7
  const TOKEN_SERVICE = 'cn.weaver.weaver-work-cli.invoice-token';
8
+ const INVOICE_SESSION_EXPIRED_CODE = '-2';
8
9
  function assertPath(requestPath) {
9
10
  if (typeof requestPath !== 'string' || !requestPath.startsWith('/') || requestPath.startsWith('//')) {
10
11
  throw invoiceValidationError('path_invalid', '请求路径必须是相对绝对路径');
@@ -63,6 +64,10 @@ function responseCode(value) {
63
64
  const root = responseRoot(value);
64
65
  return root?.ret ?? root?.code;
65
66
  }
67
+ function isInvoiceSessionExpired(value) {
68
+ const code = responseCode(value);
69
+ return code !== undefined && String(code) === INVOICE_SESSION_EXPIRED_CODE;
70
+ }
66
71
  function tokenAccount(session) {
67
72
  return createHash('sha256')
68
73
  .update(`${session.profile}\n${session.baseUrl}\n${session.userId}`)
@@ -109,6 +114,9 @@ function createInvoiceTokenStore(session) {
109
114
  },
110
115
  };
111
116
  }
117
+ export async function clearInvoiceTokenForSession(session) {
118
+ return createInvoiceTokenStore(session).clear();
119
+ }
112
120
  function inferProfile(ctx) {
113
121
  const options = ctx.getGlobalOptions();
114
122
  return options.profile || getActiveProfile();
@@ -125,14 +133,16 @@ function requestHeaders(session, invoiceToken) {
125
133
  headers.token = invoiceToken;
126
134
  return headers;
127
135
  }
128
- export function createInvoiceHost(ctx) {
136
+ export function createInvoiceHost(ctx, dependencies = {}) {
129
137
  let e10Session = null;
130
138
  let sessionContext = null;
131
139
  let invoiceToken = null;
132
140
  let tokenStore = null;
141
+ const requireSession = dependencies.requireSession || requireE10Session;
142
+ const tokenStoreFactory = dependencies.tokenStoreFactory || createInvoiceTokenStore;
133
143
  async function ensureSession() {
134
144
  if (!e10Session) {
135
- e10Session = await requireE10Session(ctx);
145
+ e10Session = await requireSession(ctx);
136
146
  sessionContext = {
137
147
  baseUrl: e10Session.getBaseUrl(),
138
148
  profile: inferProfile(ctx),
@@ -148,7 +158,7 @@ export function createInvoiceHost(ctx) {
148
158
  async function ensureInvoiceToken(timeoutMs = 30_000) {
149
159
  await ensureSession();
150
160
  const context = await getSession();
151
- tokenStore ||= createInvoiceTokenStore(context);
161
+ tokenStore ||= tokenStoreFactory(context);
152
162
  if (invoiceToken)
153
163
  return invoiceToken;
154
164
  invoiceToken = await tokenStore.load();
@@ -182,7 +192,31 @@ export function createInvoiceHost(ctx) {
182
192
  }
183
193
  return ensureInvoiceToken(timeoutMs);
184
194
  }
185
- async function invoiceRequest(request) {
195
+ async function clearCachedInvoiceToken() {
196
+ invoiceToken = null;
197
+ await tokenStore?.clear().catch(() => false);
198
+ }
199
+ function businessError(value) {
200
+ const code = responseCode(value);
201
+ return new InvoiceCliError('api', 'business_error', responseMessage(value), {
202
+ code: String(code),
203
+ details: { ret: code },
204
+ exitCode: 1,
205
+ });
206
+ }
207
+ async function handleBusinessResponse(value, retry, canRetry) {
208
+ const code = responseCode(value);
209
+ if (code === undefined || code === 0 || code === 200)
210
+ return value;
211
+ if (isInvoiceSessionExpired(value)) {
212
+ await clearCachedInvoiceToken();
213
+ if (canRetry)
214
+ return retry();
215
+ throw invoiceAuthError('invoice_session_expired', '发票业务登录态已失效,已清理本地发票 Token;请重新运行 weaver-work-cli auth login --base-url <域名> 后再试', { ret: code });
216
+ }
217
+ throw businessError(value);
218
+ }
219
+ async function invoiceRequest(request, canRetryExpiredToken = true) {
186
220
  const { method = 'POST', path, query, body, needsInvoiceToken = true, timeoutMs = 30_000, } = request;
187
221
  assertPath(path);
188
222
  const token = await ensureContext(needsInvoiceToken, timeoutMs, path);
@@ -220,118 +254,110 @@ export function createInvoiceHost(ctx) {
220
254
  exitCode: 1,
221
255
  });
222
256
  }
223
- const code = responseCode(value);
224
- if (code !== undefined && code !== 0 && code !== 200) {
225
- throw new InvoiceCliError('api', 'business_error', responseMessage(value), {
226
- code: String(code),
227
- details: { ret: code },
257
+ return handleBusinessResponse(value, () => invoiceRequest(request, false), canRetryExpiredToken && Boolean(token));
258
+ }
259
+ async function invoiceMultipartRequest(request, canRetryExpiredToken = true) {
260
+ const { method = 'POST', path, query, fields = {}, file, needsInvoiceToken = true, timeoutMs = 30_000, } = request;
261
+ if (!file && !fields.url) {
262
+ throw invoiceValidationError('input_invalid', 'multipart 请求需要文件或 URL');
263
+ }
264
+ assertPath(path);
265
+ const token = await ensureContext(needsInvoiceToken, timeoutMs, path);
266
+ const session = await ensureSession();
267
+ const form = new FormData();
268
+ for (const [key, value] of Object.entries(fields)) {
269
+ if (value === undefined || value === null)
270
+ continue;
271
+ form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
272
+ }
273
+ if (file) {
274
+ const content = await readFile(file.path);
275
+ form.append('file', new Blob([content], { type: file.mimeType || 'application/octet-stream' }), file.name);
276
+ }
277
+ const response = await fetchWithTimeout(withQuery(session.getBaseUrl(), path, query), {
278
+ method,
279
+ headers: requestHeaders(session, token),
280
+ body: form,
281
+ redirect: 'manual',
282
+ }, timeoutMs);
283
+ const text = await response.text();
284
+ if (response.status === 401 || response.status === 302) {
285
+ throw invoiceAuthError('session_expired', 'E10 登录态已失效,请先向用户确认 E10 登录域名,再运行 weaver-work-cli auth login --base-url <域名> 完成登录', { httpStatus: response.status });
286
+ }
287
+ let value = {};
288
+ try {
289
+ value = text ? JSON.parse(text) : {};
290
+ }
291
+ catch {
292
+ throw invoiceNetworkError('protocol', `接口返回不是合法 JSON(HTTP ${response.status})`, { retryable: false });
293
+ }
294
+ if (response.status < 200 || response.status >= 300) {
295
+ throw new InvoiceCliError('api', 'http_error', `泛微接口 HTTP ${response.status}`, {
296
+ code: response.status,
297
+ details: { status: response.status, message: responseMessage(value) },
228
298
  exitCode: 1,
229
299
  });
230
300
  }
231
- return value;
301
+ return handleBusinessResponse(value, () => invoiceMultipartRequest(request, false), canRetryExpiredToken && Boolean(token));
232
302
  }
233
- return {
234
- invoiceRequest,
235
- async invoiceMultipartRequest(request) {
236
- const { method = 'POST', path, query, fields = {}, file, needsInvoiceToken = true, timeoutMs = 30_000, } = request;
237
- if (!file && !fields.url) {
238
- throw invoiceValidationError('input_invalid', 'multipart 请求需要文件或 URL');
239
- }
240
- assertPath(path);
241
- const token = await ensureContext(needsInvoiceToken, timeoutMs, path);
242
- const session = await ensureSession();
243
- const form = new FormData();
244
- for (const [key, value] of Object.entries(fields)) {
245
- if (value === undefined || value === null)
246
- continue;
247
- form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
248
- }
249
- if (file) {
250
- const content = await readFile(file.path);
251
- form.append('file', new Blob([content], { type: file.mimeType || 'application/octet-stream' }), file.name);
252
- }
253
- const response = await fetchWithTimeout(withQuery(session.getBaseUrl(), path, query), {
254
- method,
255
- headers: requestHeaders(session, token),
256
- body: form,
257
- redirect: 'manual',
258
- }, timeoutMs);
259
- const text = await response.text();
260
- if (response.status === 401 || response.status === 302) {
261
- throw invoiceAuthError('session_expired', 'E10 登录态已失效,请先向用户确认 E10 登录域名,再运行 weaver-work-cli auth login --base-url <域名> 完成登录', { httpStatus: response.status });
262
- }
303
+ async function invoiceDownload({ id, fid, needLog = false, timeoutMs = 30_000 }, canRetryExpiredToken = true) {
304
+ const token = await ensureContext(true, timeoutMs, '/api/inc/file/download');
305
+ const session = await ensureSession();
306
+ const url = withQuery(session.getBaseUrl(), '/api/inc/file/download', { id, fid, needLog });
307
+ const response = await fetchWithTimeout(url, {
308
+ method: 'GET',
309
+ headers: {
310
+ ...requestHeaders(session, token),
311
+ Accept: 'application/octet-stream, application/pdf, */*',
312
+ },
313
+ redirect: 'manual',
314
+ }, timeoutMs);
315
+ if (response.status === 401 || response.status === 302) {
316
+ throw invoiceAuthError('session_expired', 'E10 登录态已失效,请先向用户确认 E10 登录域名,再运行 weaver-work-cli auth login --base-url <域名> 完成登录', { httpStatus: response.status });
317
+ }
318
+ const contentType = response.headers.get('content-type') || 'application/octet-stream';
319
+ const declaredLength = Number(response.headers.get('content-length'));
320
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_DOWNLOAD_BYTES) {
321
+ throw invoiceNetworkError('response_too_large', `下载文件超过 ${MAX_DOWNLOAD_BYTES} 字节限制`, { retryable: false });
322
+ }
323
+ const content = Buffer.from(await response.arrayBuffer());
324
+ const looksLikeJson = /^[\s\r\n]*[\[{]/u.test(content.subarray(0, 64).toString('utf8'));
325
+ if (response.status < 200 || response.status >= 300 || contentType.includes('json') || looksLikeJson) {
263
326
  let value = {};
264
327
  try {
265
- value = text ? JSON.parse(text) : {};
328
+ value = JSON.parse(content.toString('utf8'));
266
329
  }
267
330
  catch {
268
- throw invoiceNetworkError('protocol', `接口返回不是合法 JSON(HTTP ${response.status})`, { retryable: false });
269
- }
270
- if (response.status < 200 || response.status >= 300) {
271
- throw new InvoiceCliError('api', 'http_error', `泛微接口 HTTP ${response.status}`, {
272
- code: response.status,
273
- details: { status: response.status, message: responseMessage(value) },
274
- exitCode: 1,
275
- });
276
- }
277
- const code = responseCode(value);
278
- if (code !== undefined && code !== 0 && code !== 200) {
279
- throw new InvoiceCliError('api', 'business_error', responseMessage(value), {
280
- code: String(code),
281
- details: { ret: code },
282
- exitCode: 1,
283
- });
284
- }
285
- return value;
286
- },
287
- async invoiceDownload({ id, fid, needLog = false, timeoutMs = 30_000 }) {
288
- const token = await ensureContext(true, timeoutMs, '/api/inc/file/download');
289
- const session = await ensureSession();
290
- const url = withQuery(session.getBaseUrl(), '/api/inc/file/download', { id, fid, needLog });
291
- const response = await fetchWithTimeout(url, {
292
- method: 'GET',
293
- headers: {
294
- ...requestHeaders(session, token),
295
- Accept: 'application/octet-stream, application/pdf, */*',
296
- },
297
- redirect: 'manual',
298
- }, timeoutMs);
299
- if (response.status === 401 || response.status === 302) {
300
- throw invoiceAuthError('session_expired', 'E10 登录态已失效,请先向用户确认 E10 登录域名,再运行 weaver-work-cli auth login --base-url <域名> 完成登录', { httpStatus: response.status });
331
+ value = {};
301
332
  }
302
- const contentType = response.headers.get('content-type') || 'application/octet-stream';
303
- const declaredLength = Number(response.headers.get('content-length'));
304
- if (Number.isFinite(declaredLength) && declaredLength > MAX_DOWNLOAD_BYTES) {
305
- throw invoiceNetworkError('response_too_large', `下载文件超过 ${MAX_DOWNLOAD_BYTES} 字节限制`, { retryable: false });
333
+ if (isInvoiceSessionExpired(value)) {
334
+ await clearCachedInvoiceToken();
335
+ if (canRetryExpiredToken && token)
336
+ return invoiceDownload({ id, fid, needLog, timeoutMs }, false);
337
+ throw invoiceAuthError('invoice_session_expired', '发票业务登录态已失效,已清理本地发票 Token;请重新运行 weaver-work-cli auth login --base-url <域名> 后再试', { ret: responseCode(value), status: response.status });
306
338
  }
307
- const content = Buffer.from(await response.arrayBuffer());
308
- const looksLikeJson = /^[\s\r\n]*[\[{]/u.test(content.subarray(0, 64).toString('utf8'));
309
- if (response.status < 200 || response.status >= 300 || contentType.includes('json') || looksLikeJson) {
310
- let value = {};
311
- try {
312
- value = JSON.parse(content.toString('utf8'));
313
- }
314
- catch {
315
- value = {};
316
- }
317
- throw new InvoiceCliError('api', 'download_failed', responseMessage(value) || `下载失败(HTTP ${response.status})`, {
318
- code: response.status,
319
- details: { status: response.status, ret: responseCode(value) },
320
- exitCode: 1,
321
- });
322
- }
323
- if (!content.length) {
324
- throw invoiceNetworkError('protocol', '下载接口返回空文件', { retryable: false });
325
- }
326
- if (content.length > MAX_DOWNLOAD_BYTES) {
327
- throw invoiceNetworkError('response_too_large', `下载文件超过 ${MAX_DOWNLOAD_BYTES} 字节限制`, { retryable: false });
328
- }
329
- return { content, contentType };
330
- },
339
+ throw new InvoiceCliError('api', 'download_failed', responseMessage(value) || `下载失败(HTTP ${response.status})`, {
340
+ code: response.status,
341
+ details: { status: response.status, ret: responseCode(value) },
342
+ exitCode: 1,
343
+ });
344
+ }
345
+ if (!content.length) {
346
+ throw invoiceNetworkError('protocol', '下载接口返回空文件', { retryable: false });
347
+ }
348
+ if (content.length > MAX_DOWNLOAD_BYTES) {
349
+ throw invoiceNetworkError('response_too_large', `下载文件超过 ${MAX_DOWNLOAD_BYTES} 字节限制`, { retryable: false });
350
+ }
351
+ return { content, contentType };
352
+ }
353
+ return {
354
+ invoiceRequest,
355
+ invoiceMultipartRequest,
356
+ invoiceDownload,
331
357
  getSession,
332
358
  invalidateToken() {
333
359
  invoiceToken = null;
334
- void tokenStore?.clear();
360
+ void clearCachedInvoiceToken();
335
361
  },
336
362
  };
337
363
  }
@@ -48,6 +48,11 @@ function intOption(value) {
48
48
  const parsed = Number(value);
49
49
  return Number.isInteger(parsed) ? parsed : undefined;
50
50
  }
51
+ function stringOption(value) {
52
+ if (value === undefined || value === null || value === '')
53
+ return undefined;
54
+ return String(value);
55
+ }
51
56
  function successEnvelope(operation, result) {
52
57
  return {
53
58
  schemaVersion: 1,
@@ -111,7 +116,7 @@ export const invoiceShortcut = {
111
116
  content: opts.content,
112
117
  page_size: intOption(opts.pageSize),
113
118
  start_pos: intOption(opts.startPos),
114
- sreim: intOption(opts.sreim),
119
+ sreim: stringOption(opts.sreim),
115
120
  }));
116
121
  }));
117
122
  const enterprise = invoice.command('enterprise').description('Enterprise invoice folder commands');
@@ -128,7 +133,7 @@ export const invoiceShortcut = {
128
133
  content: opts.content,
129
134
  page_size: intOption(opts.pageSize),
130
135
  start_pos: intOption(opts.startPos),
131
- sreim: intOption(opts.sreim),
136
+ sreim: stringOption(opts.sreim),
132
137
  }));
133
138
  }));
134
139
  invoice.command('get')
@@ -20,10 +20,10 @@ const listProperties = {
20
20
  total_end: { type: 'string', pattern: '^\\d+(\\.\\d{1,2})?$' },
21
21
  attribute: { type: 'integer', enum: [0, 1, 2] },
22
22
  sreim: {
23
- type: 'integer',
24
- enum: [0, 1, 2, 3, 4],
25
- default: 3,
26
- description: '报销状态:0=全部发票,1=发票报销中,2=报销完成,3=未报销发票,4=不可报销',
23
+ type: 'string',
24
+ enum: ['0', '1', '2', '3', '4'],
25
+ default: '3',
26
+ description: '报销状态筛选。默认不要传本字段,CLI 会使用 "3"=未报销发票;只有用户明确要求全部/报销中/已报销/不可报销时才传。取值:"0"=全部发票,"1"=发票报销中,"2"=报销完成,"3"=未报销发票,"4"=不可报销',
27
27
  },
28
28
  valids: { type: 'array', items: { type: 'integer', enum: [0, 1, 2, 3, 4, 5] } },
29
29
  sources: { type: 'array', items: { type: 'integer' } },
@@ -43,21 +43,26 @@ const listProperties = {
43
43
  required: ['sort_type', 'sort'],
44
44
  },
45
45
  },
46
- bill_type: { type: 'integer' },
46
+ bill_type: {
47
+ type: 'integer',
48
+ enum: [0, 1],
49
+ default: 0,
50
+ description: '票据类型:0=发票,1=凭证。默认不要传本字段,CLI 会使用 0 查询发票;只有用户明确要求查凭证时才传 1。',
51
+ },
47
52
  };
48
53
  export const invoiceOperations = [
49
54
  {
50
55
  name: 'invoice.list',
51
56
  risk: 'read',
52
57
  scope: 'personal',
53
- fixed: { flag: 0, defaultSreim: 3 },
58
+ fixed: { flag: 0, defaultSreim: '3', defaultBillType: 0 },
54
59
  inputSchema: { type: 'object', additionalProperties: false, properties: listProperties },
55
60
  },
56
61
  {
57
62
  name: 'invoice.enterprise.list',
58
63
  risk: 'read',
59
64
  scope: 'enterprise',
60
- fixed: { flag: 6, defaultSreim: 3 },
65
+ fixed: { flag: 6, defaultSreim: '3', defaultBillType: 0 },
61
66
  inputSchema: { type: 'object', additionalProperties: false, properties: listProperties },
62
67
  },
63
68
  {
@@ -76,7 +81,7 @@ export const invoiceOperations = [
76
81
  {
77
82
  name: 'invoice.upload',
78
83
  risk: 'external-artifact',
79
- fixed: { ocr: 1 },
84
+ fixed: { ocr: 0 },
80
85
  inputSchema: {
81
86
  type: 'object',
82
87
  additionalProperties: false,
@@ -106,7 +111,7 @@ export const invoiceOperations = [
106
111
  {
107
112
  name: 'invoice.validate.preview',
108
113
  risk: 'external-read',
109
- fixed: { flag: 100, is_save: 1, needLog: false },
114
+ fixed: { flag: 100, is_save: 1, needLog: true },
110
115
  inputSchema: {
111
116
  type: 'object',
112
117
  additionalProperties: false,
@@ -125,8 +130,14 @@ export const invoiceOperations = [
125
130
  additionalProperties: false,
126
131
  properties: {
127
132
  file: { type: 'string', minLength: 1 },
128
- validate: { type: 'boolean' },
129
- syncToOa: { type: 'boolean' },
133
+ validate: {
134
+ type: 'boolean',
135
+ description: '是否在导入时请求服务端查验。正常上传发票流程默认应传 true;只有用户明确要求跳过查验时才传 false。',
136
+ },
137
+ syncToOa: {
138
+ type: 'boolean',
139
+ description: '是否同步到 OA。validate=true 时必须为 true;正常导入推荐传 true。',
140
+ },
130
141
  },
131
142
  required: ['file', 'validate', 'syncToOa'],
132
143
  },
@@ -1,4 +1,5 @@
1
1
  import { InvoiceCliError, invoiceValidationError } from '../errors.js';
2
+ import { toErrorEnvelope } from '../../../core/errors.js';
2
3
  import { previewOcr, uploadFromInput, uploadInvoiceFile, workflow } from './shared.js';
3
4
  export class InvoiceOcrPreviewOperation {
4
5
  name = 'invoice.ocr.preview';
@@ -10,16 +11,18 @@ export class InvoiceOcrPreviewOperation {
10
11
  throw invoiceValidationError('input_ambiguous', 'upload 与 file/url 只能选择一种来源');
11
12
  }
12
13
  const suppliedUpload = uploadFromInput(input);
13
- const upload = suppliedUpload || await uploadInvoiceFile(input, host);
14
+ const upload = suppliedUpload || await uploadInvoiceFile(input, host, { ocr: true });
14
15
  let ocr;
15
16
  try {
16
17
  ocr = await previewOcr(upload, host);
17
18
  }
18
19
  catch (error) {
19
20
  if (!suppliedUpload) {
20
- throw new InvoiceCliError('partial', 'artifact_uploaded', '文件已上传但 OCR 预览失败,不能自动重复上传', {
21
+ const cause = toErrorEnvelope(error);
22
+ throw new InvoiceCliError('partial', 'artifact_uploaded', `文件已上传但 OCR 预览失败:${cause.message}`, {
23
+ code: cause.code,
21
24
  partialData: { upload, workflow: workflow('invoice.ocr.preview', 'OCR_RUNNING') },
22
- details: { stage: 'ocr' },
25
+ details: { stage: 'ocr', cause },
23
26
  exitCode: 11,
24
27
  });
25
28
  }