@kairyou/agent-tools 0.19.0 → 0.21.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 CHANGED
@@ -116,8 +116,9 @@ compatibility and configuration.
116
116
  ### Provider usage
117
117
 
118
118
  Shows API relay / gateway balance and quota inside the agent. Supports Sub2API,
119
- One API (including OneHub and DoneHub), New API, Claude Code Hub, and OpenRouter;
120
- compatibility depends on the gateway version and enabled usage endpoints.
119
+ One API (including OneHub and DoneHub), New API, Claude Code Hub, OpenRouter, and
120
+ Command Code; compatibility depends on the gateway version and enabled usage
121
+ endpoints.
121
122
 
122
123
  ```bash
123
124
  npx -y @kairyou/agent-tools@latest usage -a claude codex opencode
@@ -139,8 +140,8 @@ configuration; official (non-relay) endpoints are skipped. If it reports
139
140
  {
140
141
  "providerUsage": {
141
142
  // auto | sub2api | openai-compatible | one-api | one-hub |
142
- // done-hub | new-api | claude-code-hub | openrouter | <custom-route-id>
143
- "preset": "auto",
143
+ // done-hub | new-api | claude-code-hub | openrouter | commandcode | <custom-route-id>
144
+ "preset": "auto", // auto-detect, or select one protocol listed above
144
145
  "days": 30, // how many recent days of spend to count
145
146
  "debug": false // true: log probes to ~/.agent-tools/logs/usage-debug.log
146
147
  }
@@ -149,7 +150,8 @@ configuration; official (non-relay) endpoints are skipped. If it reports
149
150
 
150
151
  Keep `preset` set to `auto` for automatic detection. Select a specific protocol
151
152
  only when you know which usage endpoint the gateway exposes; a configured custom
152
- route id is also accepted.
153
+ route id is also accepted. Command Code usage relies on a version-sensitive
154
+ endpoint that is not part of its documented Provider API.
153
155
 
154
156
  Output examples:
155
157
 
package/README.zh-CN.md CHANGED
@@ -114,8 +114,8 @@ npx -y @kairyou/agent-tools@latest statusline -a claude
114
114
  ### Provider usage
115
115
 
116
116
  在 agent 内查看 API 中转网关的余额和额度. 支持 Sub2API, One API (包括 OneHub
117
- 与 DoneHub), New API, Claude Code Hub 和 OpenRouter. 具体兼容性取决于网关版本
118
- 及其是否开放相应接口.
117
+ 与 DoneHub), New API, Claude Code Hub, OpenRouter Command Code. 具体兼容性
118
+ 取决于网关版本及其是否开放相应接口.
119
119
 
120
120
  ```bash
121
121
  npx -y @kairyou/agent-tools@latest usage -a claude codex opencode
@@ -136,8 +136,8 @@ npx -y @kairyou/agent-tools@latest usage -a claude codex opencode
136
136
  {
137
137
  "providerUsage": {
138
138
  // auto | sub2api | openai-compatible | one-api | one-hub |
139
- // done-hub | new-api | claude-code-hub | openrouter | <自定义 route id>
140
- "preset": "auto",
139
+ // done-hub | new-api | claude-code-hub | openrouter | commandcode | <自定义 route id>
140
+ "preset": "auto", // 自动探测, 也可指定上面列出的协议
141
141
  "days": 30, // 统计最近多少天的消耗
142
142
  "debug": false // true: 探测过程写入 ~/.agent-tools/logs/usage-debug.log
143
143
  }
@@ -145,7 +145,8 @@ npx -y @kairyou/agent-tools@latest usage -a claude codex opencode
145
145
  ```
146
146
 
147
147
  保持 `preset: "auto"` 即可自动探测. 只有明确知道网关开放的是哪种用量协议时,
148
- 才指定相应的内置 preset 或已配置的自定义 route id.
148
+ 才指定相应的内置 preset 或已配置的自定义 route id. Command Code 用量查询依赖
149
+ 未包含在公开 Provider API 中的接口, 可能随版本变化.
149
150
 
150
151
  显示效果示例:
151
152
 
@@ -226,6 +226,31 @@ export function formatOpenRouterLine(data) {
226
226
  return parts.join(" | ");
227
227
  }
228
228
 
229
+ function formatCredits(value) {
230
+ return typeof value === "number" && Number.isFinite(value)
231
+ ? value.toLocaleString("en-US", { maximumFractionDigits: 1 })
232
+ : "";
233
+ }
234
+
235
+ function formatCommandCodeWindow(label, window) {
236
+ if (!window || !Number.isFinite(window.used) || !Number.isFinite(window.cap)) return "";
237
+ const reset = window.resetAt ? compactDurationUntil(window.resetAt) : "";
238
+ return `${label} ${formatCredits(window.used)}/${formatCredits(window.cap)}${reset ? ` ⟳${reset}` : ""}`;
239
+ }
240
+
241
+ export function formatCommandCodeLine(data) {
242
+ const credits = data?.credits || {};
243
+ const limits = data?.windowLimits || {};
244
+ const parts = [];
245
+ if (Number.isFinite(credits.monthlyCredits)) parts.push(`credits ${formatCredits(credits.monthlyCredits)}`);
246
+ const fiveHour = formatCommandCodeWindow("5h", limits.fiveHour);
247
+ const weekly = formatCommandCodeWindow("W", limits.weekly);
248
+ if (fiveHour) parts.push(fiveHour);
249
+ if (weekly) parts.push(weekly);
250
+ if (parts.length === 0) throw new Error("Command Code billing payload has no usage fields");
251
+ return parts.join(" | ");
252
+ }
253
+
229
254
  export function formatOneApiBillingLine(limit, used) {
230
255
  if (!hasSpendableOneApiLimit(limit)) return `used ${formatMoney(used)}`;
231
256
  return `balance ${formatMoney(Math.max(0, limit - used))} | used ${formatMoney(used)}/${formatMoney(limit)}`;
@@ -30,6 +30,7 @@ import {
30
30
  hasSpendableOneApiLimit,
31
31
  formatOpenRouterLine,
32
32
  formatClaudeCodeHubLine,
33
+ formatCommandCodeLine,
33
34
  } from "./format.mjs";
34
35
  import { readRouteCache } from "./cache.mjs";
35
36
 
@@ -170,6 +171,22 @@ async function fetchOpenRouterUsage(context) {
170
171
  throw lastError || new Error("OpenRouter usage unavailable");
171
172
  }
172
173
 
174
+ // Command Code exposes subscription credits and rolling windows through the
175
+ // same internal endpoint used by its `/usage` overlay. This route is opt-in
176
+ // via the commandcode preset or the official host and is intentionally kept
177
+ // out of generic probing because the endpoint is not a documented Provider API.
178
+ async function fetchCommandCodeUsage(context) {
179
+ const base = cleanBaseUrl(context.baseUrl)
180
+ .replace(/\/provider\/v1$/i, "")
181
+ .replace(/\/provider$/i, "")
182
+ .replace(/\/v1$/i, "");
183
+ const json = await requestJson(joinUrl(base, "/alpha/billing/credits"), {
184
+ key: context.key,
185
+ name: "Command Code billing credits",
186
+ });
187
+ return usageResult(context, "commandcode-billing", formatCommandCodeLine(json), json);
188
+ }
189
+
173
190
  const USAGE_ROUTES = {
174
191
  "v1-usage": {
175
192
  id: "v1-usage",
@@ -196,6 +213,11 @@ const USAGE_ROUTES = {
196
213
  path: "/api/v1/me/quota",
197
214
  run: fetchClaudeCodeHubUsage,
198
215
  },
216
+ "commandcode": {
217
+ id: "commandcode",
218
+ path: "/alpha/billing/credits",
219
+ run: fetchCommandCodeUsage,
220
+ },
199
221
  };
200
222
 
201
223
  // User-authored gateway routes, declared in config.jsonc:
@@ -292,6 +314,7 @@ async function usageRouteIds(context) {
292
314
  "done-hub": ["oneapi-billing"],
293
315
  "openrouter": ["openrouter"],
294
316
  "claude-code-hub": ["claude-code-hub"],
317
+ "commandcode": ["commandcode"],
295
318
  };
296
319
  if (routes[preset]) return routes[preset];
297
320
 
@@ -302,6 +325,8 @@ async function usageRouteIds(context) {
302
325
  const customIds = (await customRoutes()).map((route) => route.id);
303
326
  const builtinIds = hostIncludes(context.baseUrl, "openrouter.ai")
304
327
  ? ["openrouter"]
328
+ : hostIncludes(context.baseUrl, "commandcode.ai")
329
+ ? ["commandcode"]
305
330
  : ["v1-usage", "newapi-token", "oneapi-billing", "claude-code-hub"];
306
331
  return [...new Set([...customIds, ...builtinIds])];
307
332
  }
@@ -1490,6 +1490,26 @@ function formatOpenRouterLine(data) {
1490
1490
  if (parts.length === 0) throw new Error("OpenRouter payload has no usage fields");
1491
1491
  return parts.join(" | ");
1492
1492
  }
1493
+ function formatCredits(value) {
1494
+ return typeof value === "number" && Number.isFinite(value) ? value.toLocaleString("en-US", { maximumFractionDigits: 1 }) : "";
1495
+ }
1496
+ function formatCommandCodeWindow(label, window) {
1497
+ if (!window || !Number.isFinite(window.used) || !Number.isFinite(window.cap)) return "";
1498
+ const reset = window.resetAt ? compactDurationUntil(window.resetAt) : "";
1499
+ return `${label} ${formatCredits(window.used)}/${formatCredits(window.cap)}${reset ? ` \u27F3${reset}` : ""}`;
1500
+ }
1501
+ function formatCommandCodeLine(data) {
1502
+ const credits = data?.credits || {};
1503
+ const limits = data?.windowLimits || {};
1504
+ const parts = [];
1505
+ if (Number.isFinite(credits.monthlyCredits)) parts.push(`credits ${formatCredits(credits.monthlyCredits)}`);
1506
+ const fiveHour = formatCommandCodeWindow("5h", limits.fiveHour);
1507
+ const weekly = formatCommandCodeWindow("W", limits.weekly);
1508
+ if (fiveHour) parts.push(fiveHour);
1509
+ if (weekly) parts.push(weekly);
1510
+ if (parts.length === 0) throw new Error("Command Code billing payload has no usage fields");
1511
+ return parts.join(" | ");
1512
+ }
1493
1513
  function formatOneApiBillingLine(limit, used) {
1494
1514
  if (!hasSpendableOneApiLimit(limit)) return `used ${formatMoney(used)}`;
1495
1515
  return `balance ${formatMoney(Math.max(0, limit - used))} | used ${formatMoney(used)}/${formatMoney(limit)}`;
@@ -1706,6 +1726,14 @@ async function fetchOpenRouterUsage(context) {
1706
1726
  }
1707
1727
  throw lastError || new Error("OpenRouter usage unavailable");
1708
1728
  }
1729
+ async function fetchCommandCodeUsage(context) {
1730
+ const base = cleanBaseUrl(context.baseUrl).replace(/\/provider\/v1$/i, "").replace(/\/provider$/i, "").replace(/\/v1$/i, "");
1731
+ const json = await requestJson(joinUrl(base, "/alpha/billing/credits"), {
1732
+ key: context.key,
1733
+ name: "Command Code billing credits"
1734
+ });
1735
+ return usageResult(context, "commandcode-billing", formatCommandCodeLine(json), json);
1736
+ }
1709
1737
  var USAGE_ROUTES = {
1710
1738
  "v1-usage": {
1711
1739
  id: "v1-usage",
@@ -1731,6 +1759,11 @@ var USAGE_ROUTES = {
1731
1759
  id: "claude-code-hub",
1732
1760
  path: "/api/v1/me/quota",
1733
1761
  run: fetchClaudeCodeHubUsage
1762
+ },
1763
+ "commandcode": {
1764
+ id: "commandcode",
1765
+ path: "/alpha/billing/credits",
1766
+ run: fetchCommandCodeUsage
1734
1767
  }
1735
1768
  };
1736
1769
  var CUSTOM_ROUTE_HELPERS = { requestJson, agentConfig };
@@ -1806,12 +1839,13 @@ async function usageRouteIds(context) {
1806
1839
  "one-hub": ["oneapi-billing"],
1807
1840
  "done-hub": ["oneapi-billing"],
1808
1841
  "openrouter": ["openrouter"],
1809
- "claude-code-hub": ["claude-code-hub"]
1842
+ "claude-code-hub": ["claude-code-hub"],
1843
+ "commandcode": ["commandcode"]
1810
1844
  };
1811
1845
  if (routes[preset]) return routes[preset];
1812
1846
  if (preset !== "auto") return (await routeRegistry())[preset] ? [preset] : [];
1813
1847
  const customIds = (await customRoutes()).map((route) => route.id);
1814
- const builtinIds = hostIncludes(context.baseUrl, "openrouter.ai") ? ["openrouter"] : ["v1-usage", "newapi-token", "oneapi-billing", "claude-code-hub"];
1848
+ const builtinIds = hostIncludes(context.baseUrl, "openrouter.ai") ? ["openrouter"] : hostIncludes(context.baseUrl, "commandcode.ai") ? ["commandcode"] : ["v1-usage", "newapi-token", "oneapi-billing", "claude-code-hub"];
1815
1849
  return [.../* @__PURE__ */ new Set([...customIds, ...builtinIds])];
1816
1850
  }
1817
1851
  async function cachedUsageRoute(context, registry) {
@@ -4,6 +4,9 @@ Advanced guide for [provider usage](../../README.md#provider-usage): write your
4
4
  usage probe for relays the built-in presets cannot reach (e.g. cookie-authenticated
5
5
  gateways) without modifying package code.
6
6
 
7
+ Built-in presets, including `commandcode`, are documented in the main
8
+ [provider usage](../../README.md#provider-usage) section.
9
+
7
10
  ## Declare a route
8
11
 
9
12
  Write a route module and list it in `providerUsage.routes` (paths resolve against
@@ -6,6 +6,8 @@
6
6
 
7
7
  编写路由模块, 并在 `providerUsage.routes` 里声明(相对 `~/.agent-tools` 解析). 声明的路由优先探测; `"preset"` 填路由 id 可直接选中.
8
8
 
9
+ 包括 `commandcode` 在内的内置 preset 见主文档的[Provider usage](../../README.zh-CN.md#provider-usage)章节.
10
+
9
11
  ```jsonc
10
12
  {
11
13
  "providerUsage": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kairyou/agent-tools",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Reusable Agent Skills, plus runtime capabilities (statusline, provider usage, vision) for Codex, Claude Code, and opencode.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: at-zentao
3
- description: "Work ZenTao bugs/tasks end to end, manage task lifecycle and hours, or read a linked story as development context: fetch details, handle the user's request, verify changes, and ask before committing or writing back to ZenTao. Supports single items and sequential batches. Use when the user references ZenTao (禅道) bugs, tasks, stories, requirements, task status, or task hours."
3
+ description: "Handle ZenTao (禅道) Bugs and Tasks end to end, including updating or writing back an item after code changes, managing Task status and hours, and reading linked Stories. Use for referenced ZenTao items, requirements, status changes, time entries, or post-implementation synchronization."
4
4
  argument-hint: "bug <id> [request] | task <id> [request] | story <id> | bugs | tasks | export bug|task <id>"
5
5
  ---
6
6
 
@@ -72,6 +72,7 @@ node <skill-root>/scripts/zentao-cli.mjs get bug <id>
72
72
  node <skill-root>/scripts/zentao-cli.mjs get task <id>
73
73
  node <skill-root>/scripts/zentao-cli.mjs get story <id>
74
74
  node <skill-root>/scripts/zentao-cli.mjs get bug <id> --download-dir <path>
75
+ node <skill-root>/scripts/zentao-cli.mjs hours task <id>
75
76
  ```
76
77
 
77
78
  `get` downloads token-gated inline images and attachments into a temporary
@@ -89,6 +90,7 @@ node <skill-root>/scripts/zentao-cli.mjs start task <id>
89
90
  node <skill-root>/scripts/zentao-cli.mjs pause task <id>
90
91
  node <skill-root>/scripts/zentao-cli.mjs resume task <id>
91
92
  node <skill-root>/scripts/zentao-cli.mjs log-hours task <id>
93
+ node <skill-root>/scripts/zentao-cli.mjs edit-hours task <id> <effort-id>
92
94
  node <skill-root>/scripts/zentao-cli.mjs finish task <id>
93
95
  ```
94
96
 
@@ -99,6 +101,7 @@ Input shapes:
99
101
  {"resolution":"fixed","resolvedBuild":"trunk","comment":"Root cause and result, commit abc1234."}
100
102
  {"realStarted":"2026-08-11 09:00:00","comment":"Started implementation."}
101
103
  {"date":"2026-08-11","consumed":2,"left":14,"work":"Implemented the first part of the task."}
104
+ {"work":"Corrected work description, commit abc1234."}
102
105
  {"currentConsumed":1.5,"realStarted":"2026-08-11 09:00:00","finishedDate":"2026-08-11 10:30:00"}
103
106
  ```
104
107
 
@@ -109,7 +112,9 @@ computes a task's total consumed hours from its current ZenTao value.
109
112
  accepts `realStarted` and otherwise uses the current time. The CLI preserves
110
113
  the task's current hours when starting or resuming it.
111
114
  `log-hours` defaults `date` to today, requires positive remaining hours,
112
- and keeps the task open. Use `finish` when the task is complete.
115
+ and keeps the task open. `hours` is read-only. `edit-hours` preserves omitted
116
+ fields from the existing record and updates it through ZenTao's native effort
117
+ workflow. Use `finish` when the task is complete.
113
118
 
114
119
  ## Usage
115
120
 
@@ -128,6 +133,9 @@ lifecycle requests such as `开始`, `暂停`, `继续`, `start`, `pause`, and
128
133
  `resume`, and time-entry requests such as `填工时`, `记录工时`, `log hours`,
129
134
  and `worklog`. Users do not need to know the internal CLI commands. Reuse any
130
135
  date, hours, or work description already supplied instead of asking twice.
136
+ Recognize corrections to an existing time entry. Run `hours`, identify the
137
+ record from returned ids and values, and ask when more than one record could
138
+ match; never guess or call `log-hours` again to compensate for a mistake.
131
139
 
132
140
  If a list response includes pager data showing more items than returned, tell
133
141
  the user the shown and total counts. Do not silently imply the list is complete.
@@ -180,6 +188,11 @@ Bug resolutions are `fixed`, `notrepro`, `duplicate`, `bydesign`, `external`,
180
188
  write-back comment is one sentence containing root cause, change summary, and
181
189
  the commit hash when committed.
182
190
 
191
+ Immediately before confirming any ZenTao write that cites the latest commit,
192
+ run `git rev-parse HEAD` and `git log -1 --format=%h`. Do not reuse a hash from
193
+ earlier conversation. If HEAD is not the item-specific commit, identify the
194
+ relevant commit and tell the user instead of blindly citing HEAD.
195
+
183
196
  For tasks, ask whether to record the current work after the verified result.
184
197
  For an incomplete task, collect the actual `consumed` hours and work date. When
185
198
  the task has a numeric current `left`, suggest the new `left` by subtracting the
@@ -191,6 +204,15 @@ collect `currentConsumed` and draft a `finish` write. Never infer consumed
191
204
  hours. Show all submitted values and require the same explicit ZenTao
192
205
  confirmation before either write.
193
206
 
207
+ To correct an existing time entry, use `hours` to select its effort id. Show
208
+ the current and proposed `date`, `consumed`, `left`, and `work`, then obtain
209
+ explicit confirmation and call `edit-hours` once. Preserve every field the
210
+ user did not change. Stop when ownership cannot be verified or the edit route
211
+ is unsupported. If the proposed `left` is zero, explicitly warn that ZenTao
212
+ may change the task status as part of its native recalculation. Never edit an
213
+ action comment as a substitute for correcting the underlying work-hour record,
214
+ and never delete a record.
215
+
194
216
  When the user wants to record hours and pause, show both exact writes in one
195
217
  confirmation, then run `log-hours` before `pause`. Stop if the hour write fails.
196
218
  Do not add lifecycle support for `activate`, `cancel`, or `close`.
@@ -459,10 +459,13 @@ async function readInput() {
459
459
  return value;
460
460
  }
461
461
 
462
- function formBody(fields) {
462
+ function formBody(fields, includeEmpty = []) {
463
463
  const body = new URLSearchParams();
464
+ const keepEmpty = new Set(includeEmpty);
464
465
  for (const [key, value] of Object.entries(fields)) {
465
- if (value !== undefined && value !== null && value !== "") body.set(key, String(value));
466
+ if (value !== undefined && value !== null && (value !== "" || keepEmpty.has(key))) {
467
+ body.set(key, String(value));
468
+ }
466
469
  }
467
470
  return body;
468
471
  }
@@ -484,8 +487,8 @@ async function workhourVariant(client, id) {
484
487
  ];
485
488
  for (const variant of variants) {
486
489
  try {
487
- decodeLegacy(await client.json(variant.route));
488
- return variant;
490
+ const form = decodeLegacy(await client.json(variant.route));
491
+ return { ...variant, form };
489
492
  } catch (error) {
490
493
  const unavailable = error instanceof CliError && (
491
494
  (error.code === "http_error" && error.status === 404) ||
@@ -500,6 +503,88 @@ async function workhourVariant(client, id) {
500
503
  );
501
504
  }
502
505
 
506
+ function effortRecords(form) {
507
+ const candidates = [
508
+ form?.efforts,
509
+ form?.estimates,
510
+ form?.workhours,
511
+ form?.taskEfforts,
512
+ form?.task?.efforts,
513
+ form?.task?.estimates,
514
+ ];
515
+ const collection = candidates.find((value) => value && typeof value === "object");
516
+ if (!collection) return [];
517
+ const entries = Array.isArray(collection)
518
+ ? collection.map((value) => [null, value])
519
+ : Object.entries(collection);
520
+ const records = entries
521
+ .filter(([, value]) => value && typeof value === "object")
522
+ .map(([key, value]) => ({
523
+ ...(value.id === undefined && /^\d+$/.test(key || "") ? { id: Number(key) } : {}),
524
+ ...pick(value, ["id", "date", "consumed", "left", "work"]),
525
+ }));
526
+ return records;
527
+ }
528
+
529
+ function effortFromEditForm(form) {
530
+ const effort = form?.effort || form?.estimate || form?.workhour ||
531
+ (form?.id !== undefined ? form : null);
532
+ if (!effort || typeof effort !== "object") {
533
+ throw new CliError("response_error", "ZenTao returned no editable work-hour record");
534
+ }
535
+ return effort;
536
+ }
537
+
538
+ function verifyEffortTask(effort, taskId) {
539
+ if (effort.objectType !== undefined && effort.objectType !== "task") {
540
+ throw new CliError("ownership_error", "The work-hour record does not belong to a task");
541
+ }
542
+ const owner = effort.objectID ?? effort.task ?? effort.taskID;
543
+ if (owner === undefined || String(owner) !== String(taskId)) {
544
+ throw new CliError("ownership_error", "The work-hour record does not belong to the requested task");
545
+ }
546
+ }
547
+
548
+ async function editableEffortVariant(client, effortId) {
549
+ const variants = [
550
+ { route: `task-editeffort-${effortId}.json`, legacy: false },
551
+ { route: `task-editestimate-${effortId}.json`, legacy: true },
552
+ ];
553
+ for (const variant of variants) {
554
+ try {
555
+ const form = decodeLegacy(await client.json(variant.route));
556
+ return { ...variant, effort: effortFromEditForm(form) };
557
+ } catch (error) {
558
+ const unavailable = error instanceof CliError && (
559
+ (error.code === "http_error" && error.status === 404) ||
560
+ error.code === "response_error"
561
+ );
562
+ if (!unavailable) throw error;
563
+ }
564
+ }
565
+ throw new CliError(
566
+ "unsupported_version",
567
+ "ZenTao exposes neither editEffort nor editEstimate for this work-hour record"
568
+ );
569
+ }
570
+
571
+ function effortFields(effort, input) {
572
+ const date = effortDate(input.date ?? effort.date);
573
+ const consumed = Number(input.consumed ?? effort.consumed);
574
+ const left = Number(input.left ?? effort.left);
575
+ const work = input.work ?? effort.work ?? "";
576
+ if (!Number.isFinite(consumed) || consumed <= 0) {
577
+ throw new CliError("usage_error", "consumed must be positive");
578
+ }
579
+ if (!Number.isFinite(left) || left < 0) {
580
+ throw new CliError("usage_error", "left must be zero or positive");
581
+ }
582
+ if (typeof work !== "string") {
583
+ throw new CliError("usage_error", "work must be a string");
584
+ }
585
+ return { date, consumed, left, work: work.trim() };
586
+ }
587
+
503
588
  function localDateTime(date = new Date()) {
504
589
  const part = (value) => String(value).padStart(2, "0");
505
590
  return `${date.getFullYear()}-${part(date.getMonth() + 1)}-${part(date.getDate())} ${part(date.getHours())}:${part(date.getMinutes())}:${part(date.getSeconds())}`;
@@ -537,7 +622,9 @@ function help() {
537
622
  zentao-cli.mjs start task <id> # JSON on stdin
538
623
  zentao-cli.mjs pause task <id> # JSON on stdin
539
624
  zentao-cli.mjs resume task <id> # JSON on stdin
625
+ zentao-cli.mjs hours task <id>
540
626
  zentao-cli.mjs log-hours task <id> # JSON on stdin
627
+ zentao-cli.mjs edit-hours task <id> <effort-id> # JSON on stdin
541
628
  zentao-cli.mjs finish task <id> # JSON on stdin`;
542
629
  }
543
630
 
@@ -702,6 +789,36 @@ export async function run(argv, { env = process.env } = {}) {
702
789
  return legacyResult(response);
703
790
  }
704
791
 
792
+ if (command === "hours") {
793
+ if (args[0] !== "task") throw new CliError("usage_error", "hours supports tasks only");
794
+ const id = positiveId(args[1]);
795
+ const variant = await workhourVariant(client, id);
796
+ return { records: effortRecords(variant.form) };
797
+ }
798
+
799
+ if (command === "edit-hours") {
800
+ if (args[0] !== "task") throw new CliError("usage_error", "edit-hours supports tasks only");
801
+ const taskId = positiveId(args[1]);
802
+ const effortId = positiveId(args[2]);
803
+ const input = await readInput();
804
+ const allowed = ["date", "consumed", "left", "work"];
805
+ if (!allowed.some((field) => Object.hasOwn(input, field))) {
806
+ throw new CliError("usage_error", "at least one work-hour field is required");
807
+ }
808
+ if (input.work !== undefined && typeof input.work !== "string") {
809
+ throw new CliError("usage_error", "work must be a string when provided");
810
+ }
811
+ const variant = await editableEffortVariant(client, effortId);
812
+ verifyEffortTask(variant.effort, taskId);
813
+ const fields = effortFields(variant.effort, input);
814
+ const response = await client.json(variant.route, {
815
+ method: "POST",
816
+ headers: { "content-type": "application/x-www-form-urlencoded" },
817
+ body: formBody(fields, ["work"]),
818
+ });
819
+ return legacyResult(response);
820
+ }
821
+
705
822
  if (command === "finish") {
706
823
  if (args[0] !== "task") throw new CliError("usage_error", "finish supports tasks only");
707
824
  const id = positiveId(args[1]);