diff --git a/.gitignore b/.gitignore index 0ac8cf7..ae68eca 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ coverage/ # npm pack output *.tgz +.zcode/plans/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1bbc3a8..40d097c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ Examples: ## Reporting Bugs -Open a [GitHub Issue](https://github.com/zcode-org/zcode-acp-server/issues) with: +Open a [GitHub Issue](https://github.com/william0wang/zcode-acp/issues) with: - ZCode CLI version (`zcode --version`) - Editor + version (Zed, JetBrains, ...) - Node.js version (`node --version`) diff --git a/README.md b/README.md index d82b5d1..ad889f3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # zcode-acp-server -[![CI](https://github.com/zcode-org/zcode-acp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/zcode-org/zcode-acp-server/actions/workflows/ci.yml) +[![CI](https://github.com/william0wang/zcode-acp/actions/workflows/ci.yml/badge.svg)](https://github.com/william0wang/zcode-acp/actions/workflows/ci.yml) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) English | **[简体中文](README.zh-CN.md)** @@ -87,6 +87,34 @@ automatically. Point `ZCODE_BIN` at the bundled `zcode.cjs`: | `ZCODE_BASE_URL` | _(from config)_ | Override the provider base URL | | `ZCODE_ACP_DEBUG` | _(unset)_ | Set to `1` to enable verbose diagnostic logs (event flow, probe loops, status updates). Default is quiet — only warnings (backend pipe errors, command/permission failures, lock timeouts) are emitted. Enable this when diagnosing bridge issues; the logs appear in `Zed.log` prefixed with `[zcode-acp]`. | +## Standalone Quota CLI + +Besides the ACP server, the package ships a `zcode-quota` bin that queries +your GLM Coding Plan usage **from the terminal** — no editor or running server +needed. It reads the same `~/.zcode/v2/config.json` for credentials. + +```bash +# One-shot: print the card and exit +zcode-quota + +# Live monitor: clear the screen and refresh every 30s (default) +zcode-quota -w + +# Refresh at a custom interval (seconds; minimum 10) +zcode-quota --watch --interval 60 +``` + +The watch mode clears and redraws the card in place, like `top`/`htop`. Press +`Ctrl-C` to exit. The 10s minimum exists because the quota API is cached for +10s internally — a shorter interval would just keep returning the stale cached +value. + +When the package isn't globally installed, run the built file directly: + +```bash +node dist/bin/quota.js -w +``` + ## ACP Registry This server is compatible with the [ACP Registry](https://agentclientprotocol.com/get-started/registry). It advertises a single `agent`-type auth method at `initialize` time — the GLM API key is read from `~/.zcode/v2/config.json` by the ZCode backend, so **no editor-side credentials are required**. @@ -157,6 +185,23 @@ recorded in [CHANGELOG.md](CHANGELOG.md). - [ZCode](https://zcode.z.ai) / [Zhipu Z.AI](https://z.ai) — the GLM model and ZCode CLI - [zcode-open-bridge](https://github.com/tizerluo/zcode-open-bridge) — reference implementation that informed this server's design +## Privacy + +**No telemetry or tracking** — the server reports nothing to anyone. The only +runtime dependency beyond the ACP SDK is `zod`. + +Your prompts, code, and file contents are relayed between the editor and the +ZCode backend over **local pipes**; that data reaches the GLM cloud API only +because the ZCode backend itself sends it there for inference — this server +adds no extra destinations. + +| Concern | What & why | +| ------- | ---------- | +| Network | Only one outbound request in the whole codebase: the quota GET (`open.bigmodel.cn` / `api.z.ai`), carrying just your API key — needed to fetch your usage numbers, sends no user content | +| Credentials | API key read from `~/.zcode/v2/config.json` to authenticate the ZCode subprocess and quota request. Never logged, never written elsewhere. OAuth handled entirely by the ZCode subprocess | +| Disk | No new files created. Writes only to the existing `~/.zcode/v2/tasks-index.sqlite` — this **syncs sessions to the ZCode app** so they appear in its history list and full-text search (stores the session title and first prompt) | +| Logging | Diagnostics to stderr for troubleshooting bridge issues. Even with `ZCODE_ACP_DEBUG=1`, no prompts/code/keys are ever logged | + ## License Apache-2.0. This project follows the same license as the upstream ACP specification. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5de0097..7bab79d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # zcode-acp-server -[![CI](https://github.com/zcode-org/zcode-acp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/zcode-org/zcode-acp-server/actions/workflows/ci.yml) +[![CI](https://github.com/william0wang/zcode-acp/actions/workflows/ci.yml/badge.svg)](https://github.com/william0wang/zcode-acp/actions/workflows/ci.yml) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) **[English](README.md)** | 简体中文 @@ -86,6 +86,33 @@ ZCode CLI 内置于桌面应用中,默认不会加到 `PATH`。用 `ZCODE_BIN` | `ZCODE_BASE_URL` | _(来自 config) | 覆盖 provider 的 base URL | | `ZCODE_ACP_DEBUG` | _(未设置) | 设为 `1` 可开启详细诊断日志(事件流、探测循环、状态更新)。默认安静——只输出警告类日志(后端管道错误、命令/权限失败、锁等待超时)。诊断桥接问题时开启;日志出现在 `Zed.log` 中,前缀为 `[zcode-acp]`。 | +## 独立配额查询 CLI(zcode-quota) + +除了 ACP server,本包还附带一个 `zcode-quota` 命令,可在**终端**里直接查询 +GLM Coding Plan 用量——无需编辑器,也无需 server 运行。它读取同一个 +`~/.zcode/v2/config.json` 获取凭证。 + +```bash +# 一次性:打印卡片后退出 +zcode-quota + +# 常驻监控:清屏并每 30s 刷新(默认) +zcode-quota -w + +# 自定义刷新间隔(秒,最小 10) +zcode-quota --watch --interval 60 +``` + +watch 模式会原地清屏重绘卡片,效果类似 `top`/`htop`。按 `Ctrl-C` 退出。 +之所以设最小间隔 10s,是因为配额 API 内部有 10s 缓存——更短的间隔只会一直 +返回过期的缓存值,没有意义。 + +未全局安装时,可直接运行构建产物: + +```bash +node dist/bin/quota.js -w +``` + ## ACP Registry 本服务端兼容 [ACP Registry](https://agentclientprotocol.com/get-started/registry)。它在 `initialize` 时声明一个 `agent` 类型的认证方法——GLM API key 由 ZCode 后端从 `~/.zcode/v2/config.json` 读取,**编辑器侧无需配置任何凭据**。 @@ -154,6 +181,21 @@ commit 约定和 PR 检查清单。重要变更记录在 [CHANGELOG.md](CHANGELO - [ZCode](https://zcode.z.ai) / [智谱 Z.AI](https://z.ai) —— GLM 模型与 ZCode CLI - [zcode-open-bridge](https://github.com/tizerluo/zcode-open-bridge) —— 参考实现,本项目的设计借鉴了它的桥接架构 +## 隐私 + +**无遥测、无追踪** —— 本服务端不向任何方上报任何信息。除 ACP SDK 外唯一运行时依赖是 +`zod`。 + +你的提示词、代码、文件内容通过**本地管道**在编辑器与 ZCode 后端之间中转;这些数据会到达 +GLM 云端 API,仅因 ZCode 后端本身为推理而发送——本服务端不增加任何额外去向。 + +| 方面 | 做什么 & 为什么 | +| ---- | --------------- | +| 网络 | 全代码库仅一处对外请求:配额 GET(`open.bigmodel.cn` / `api.z.ai`),只带 API key —— 为查询用量数字,不发送用户内容 | +| 凭据 | API key 从 `~/.zcode/v2/config.json` 读取,用于认证 ZCode 子进程和配额请求。从不记录日志、从不写入别处。OAuth 完全由 ZCode 子进程处理 | +| 磁盘 | 不创建任何新文件。只写入已存在的 `~/.zcode/v2/tasks-index.sqlite` —— 这是**将会话同步到 ZCode App**,使其出现在历史列表和全文搜索中(存会话标题和首条提示词) | +| 日志 | 诊断信息输出到 stderr,用于排查桥接问题。即使开启 `ZCODE_ACP_DEBUG=1`,也绝不记录提示词/代码/密钥 | + ## 许可证 Apache-2.0。本项目沿用上游 ACP 规范的同一许可证。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 13e92db..c6e1a38 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -121,6 +121,20 @@ This ensures the snapshot diff does not re-emit tools already handled by the event path, preventing Bash terminal output from being overwritten by a content-less ToolCallNew. +## Data & Privacy + +**No telemetry, no analytics, no third-party network calls.** The server is a +local relay: prompts, code, and tool outputs pass through process memory on +their way between the editor and the ZCode subprocess, but reach the GLM cloud +API only because the ZCode backend itself sends them for inference. + +| Concern | Detail | +| ------- | ------ | +| Network | One outbound request in the whole codebase — `src/quota/client.ts` GET to the quota API, Bearer token only, no body | +| Credentials | API key from `~/.zcode/v2/config.json` (authenticates the subprocess + quota request), never logged. OAuth handled by the ZCode subprocess, not this server | +| Disk | No new files. Writes only to the existing `~/.zcode/v2/tasks-index.sqlite` — syncs sessions into the ZCode app's history & search (session title + first prompt) | +| Logging | `log()`/`warn()` → stderr only for troubleshooting; even with `ZCODE_ACP_DEBUG=1`, no prompts/code/keys are logged | + ## Module Responsibilities ### `backend/` — ZCode process communication diff --git a/package.json b/package.json index 90cbe5d..dab79b9 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,11 @@ "author": "ZCode ACP Server Contributors", "repository": { "type": "git", - "url": "https://github.com/zcode-org/zcode-acp-server.git" + "url": "https://github.com/william0wang/zcode-acp.git" }, - "homepage": "https://github.com/zcode-org/zcode-acp-server#readme", + "homepage": "https://github.com/william0wang/zcode-acp#readme", "bugs": { - "url": "https://github.com/zcode-org/zcode-acp-server/issues" + "url": "https://github.com/william0wang/zcode-acp/issues" }, "keywords": [ "acp", @@ -23,7 +23,8 @@ "zcode" ], "bin": { - "zcode-acp-server": "dist/index.js" + "zcode-acp-server": "dist/index.js", + "zcode-quota": "dist/bin/quota.js" }, "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/bin/quota.ts b/src/bin/quota.ts new file mode 100644 index 0000000..e5d754a --- /dev/null +++ b/src/bin/quota.ts @@ -0,0 +1,257 @@ +#!/usr/bin/env node + +/** + * Standalone CLI for querying GLM Coding Plan usage — a thin wrapper over the + * same `queryQuota()` + `formatQuotaPlain()` the `/quota` slash command uses. + * + * No ACP server, no zcode subprocess: only reads `~/.zcode/v2/config.json` for + * credentials and hits the quota HTTP API directly. + * + * Usage: + * zcode-quota one-shot: print the card and exit + * zcode-quota -w watch mode (default 30s refresh) + * zcode-quota -w -i 60 watch with a 60s interval + * zcode-quota --watch --interval 15 + * zcode-quota -h | --help show help + * + * The refresh interval has a 10s floor: the in-memory quota cache TTL is 10s, + * and a shorter interval would just keep returning the cached value. + */ + +import process from "node:process"; + +import { clearCache } from "../quota/cache.js"; +import { formatQuotaPlain, queryQuota } from "../quota/index.js"; + +/** Minimum watch interval (ms). Equals the quota cache TTL. */ +const MIN_INTERVAL_MS = 10_000; +/** Default watch interval (ms). */ +const DEFAULT_INTERVAL_MS = 30_000; + +// ANSI escape sequences used by watch mode (no external dep — Local First). +const ANSI = { + clearScreen: "\x1B[2J\x1B[H", // clear + cursor home + clearLine: "\x1B[2K", // clear the entire current line + hideCursor: "\x1B[?25l", + showCursor: "\x1B[?25h", +} as const; + +/** Parsed CLI options. Exported for unit testing. */ +export interface CliOptions { + watch: boolean; + intervalMs: number; + /** True when the user-supplied interval was below the 10s floor and raised. */ + intervalClamped: boolean; + help: boolean; +} + +/** Human-readable usage text. */ +const HELP_TEXT = `Usage: zcode-quota [options] + +Query GLM Coding Plan usage from the terminal. Reads credentials from +~/.zcode/v2/config.json (created by the ZCode app) — no server needed. + +Options: + -w, --watch Watch mode: clear the screen and refresh periodically. + -i, --interval Refresh interval for watch mode (default 30, min 10). + -h, --help Show this help and exit. + +Examples: + zcode-quota # print once and exit + zcode-quota -w # live monitor, refresh every 30s + zcode-quota -w -i 60 # refresh every 60s`; + +/** + * Clamp a raw interval (seconds, optional) to a valid ms value. Returns the + * clamped value plus whether a user-supplied value was raised to the floor. + * + * Exported for unit testing. + */ +export function resolveIntervalMs(seconds: number | undefined): { ms: number; clamped: boolean } { + const requested = (seconds ?? DEFAULT_INTERVAL_MS / 1000) * 1000; + if (requested < MIN_INTERVAL_MS) { + return { ms: MIN_INTERVAL_MS, clamped: seconds !== undefined }; + } + return { ms: requested, clamped: false }; +} + +/** + * Parse argv into {@link CliOptions}. Supports `-w`/`--watch`, `-h`/`--help`, + * `-i `/`--interval ` (space), `--interval=`, and `-i` (attached). + * Unknown flags are ignored. Exported for unit testing. + */ +export function parseArgs(argv: readonly string[]): CliOptions { + let watch = false; + let help = false; + let interval: number | undefined; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case "-w": + case "--watch": + watch = true; + break; + case "-h": + case "--help": + help = true; + break; + case "-i": + case "--interval": { + const next = argv[i + 1]; + if (next !== undefined) { + const n = Number(next); + if (Number.isFinite(n)) interval = n; + i++; // consume the value + } + break; + } + default: + // Support `-i` / `--interval=` attached forms. + if (arg?.startsWith("--interval=")) { + const n = Number(arg.slice("--interval=".length)); + if (Number.isFinite(n)) interval = n; + } else if (arg?.startsWith("-i") && arg.length > 2) { + const n = Number(arg.slice(2)); + if (Number.isFinite(n)) interval = n; + } + break; + } + } + + const resolved = resolveIntervalMs(interval); + return { watch, help, intervalMs: resolved.ms, intervalClamped: resolved.clamped }; +} + +/** Format the current wall-clock as `HH:MM:SS` for the watch freshness stamp. */ +function timestamp(): string { + const d = new Date(); + const p = (n: number) => String(n).padStart(2, "0"); + return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} + +/** + * Sleep helper. Accepts an AbortSignal so SIGINT can break the wait early. + * + * NOTE: the timer is deliberately NOT `unref()`-ed. In watch mode this sleep + * is the only thing keeping the event loop alive between ticks; an unref'd + * timer lets Node exit immediately after the first frame renders, which + * silently kills the monitor. Only a real abort (Ctrl-C) should end the wait. + */ +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(t); + reject(new Error("aborted")); + }, + { once: true }, + ); + }); +} + +/** Render the footer line (the last line of a watch frame). */ +function renderFooter(updatedAt: string, remainingSec: number): string { + return ` updated ${updatedAt} · refresh in ${remainingSec}s … Ctrl-C to exit`; +} + +/** + * Full redraw of one watch frame: clear screen, card body, blank line, and the + * initial footer (counting down from `intervalSec`). `updatedAt` is captured + * at query time so it stays fixed while only the countdown ticks. + */ +function renderFrame(plain: string, updatedAt: string, intervalSec: number): string { + return `${ANSI.clearScreen}${plain}\n\n${renderFooter(updatedAt, intervalSec)}`; +} + +/** + * Run the watch loop until the process is interrupted. Each tick clears the + * cache (so the displayed value is fresh, not a stale cache hit), queries, and + * redraws. Between ticks a per-second countdown rewrites only the footer line + * so the card body doesn't flicker. Errors from queryQuota are shown in-frame + * and retried on the next tick rather than crashing the monitor (queryQuota + * itself never throws — it degrades to `unavailable` — so this is defence in + * depth). + */ +async function runWatch(intervalMs: number): Promise { + const intervalSec = Math.round(intervalMs / 1000); + const controller = new AbortController(); + const restore = (): void => { + process.stdout.write(ANSI.showCursor); + process.stdout.write("\n"); + }; + // Ctrl-C: stop the loop, restore the cursor, exit cleanly. + const onInt = (): void => { + controller.abort(); + restore(); + process.exit(0); + }; + process.on("SIGINT", onInt); + + process.stdout.write(ANSI.hideCursor); + try { + while (!controller.signal.aborted) { + clearCache(); // bypass cache — always show the live value + const result = await queryQuota(); + const updatedAt = timestamp(); + // Full redraw of the whole frame (card + footer counting down from max). + process.stdout.write(renderFrame(formatQuotaPlain(result), updatedAt, intervalSec)); + // Countdown: each second rewrite only the footer line, leaving the card + // untouched. \r returns to column 0; \x1B[2K clears the line. + for (let remaining = intervalSec - 1; remaining > 0; remaining--) { + await sleep(1000, controller.signal).catch(() => undefined); + if (controller.signal.aborted) break; + process.stdout.write(`\r${ANSI.clearLine}${renderFooter(updatedAt, remaining)}`); + } + } + } finally { + restore(); + process.off("SIGINT", onInt); + } +} + +/** Print the card once and exit. Non-success → stderr + exit 1. */ +async function runOnce(): Promise { + const result = await queryQuota(); + if (result.kind !== "success") { + process.stderr.write(formatQuotaPlain(result) + "\n"); + process.exit(1); + } + process.stdout.write(formatQuotaPlain(result) + "\n"); +} + +async function main(): Promise { + const opts = parseArgs(process.argv.slice(2)); + + if (opts.help) { + process.stdout.write(HELP_TEXT + "\n"); + return; + } + + if (opts.intervalClamped) { + process.stderr.write(`zcode-quota: interval below 10s raised to 10s (cache TTL is 10s)\n`); + } + + if (opts.watch) { + await runWatch(opts.intervalMs); + } else { + await runOnce(); + } +} + +// Only auto-run when invoked directly (not when imported by tests). In an ESM +// build there is no `require.main`, so fall back to a heuristic: if argv[1] +// (the executed script) ends with this file's path, we are the entry point. +const invokedDirectly = (() => { + const entry = process.argv[1] ?? ""; + return entry.endsWith("bin/quota.js") || entry.endsWith("bin/quota.ts"); +})(); + +if (invokedDirectly) { + main().catch((err) => { + process.stderr.write(`zcode-quota: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); + }); +} diff --git a/src/quota/format.ts b/src/quota/format.ts index bdaaf26..df3cab0 100644 --- a/src/quota/format.ts +++ b/src/quota/format.ts @@ -142,3 +142,16 @@ export function formatQuota(result: QuotaResult): string { const body = result.items.flatMap(formatItem); return ["```text", header, divider, ...body, "```"].join("\n"); } + +/** + * {@link formatQuota} without the ```text fence — for raw terminal output. + * + * The fence is only useful inside an editor chat frame (where it triggers a + * bordered, copy-able code block). A real terminal renders the fence as + * literal ```` ``` ```` characters, so the standalone CLI strips it. + * Non-success kinds are already unfenced short prose, returned unchanged. + */ +export function formatQuotaPlain(result: QuotaResult): string { + const card = formatQuota(result); + return result.kind === "success" ? card.replace(/^```text\n/, "").replace(/\n```$/, "") : card; +} diff --git a/src/quota/index.ts b/src/quota/index.ts index 0c05937..f8f7506 100644 --- a/src/quota/index.ts +++ b/src/quota/index.ts @@ -40,8 +40,8 @@ export async function queryQuota(): Promise { return result; } -// Re-exports for consumers (slash handler + tests). -export { formatQuota } from "./format.js"; +// Re-exports for consumers (slash handler + CLI + tests). +export { formatQuota, formatQuotaPlain } from "./format.js"; export { parseLimit, parseQuotaEnvelope } from "./parse.js"; export { renderBar } from "./format.js"; export type { QuotaItem, QuotaResult, RawLimit } from "./types.js"; diff --git a/tests/cli-quota.test.ts b/tests/cli-quota.test.ts new file mode 100644 index 0000000..4f5df34 --- /dev/null +++ b/tests/cli-quota.test.ts @@ -0,0 +1,91 @@ +/** + * Tests for the `zcode-quota` standalone CLI: argv parsing and interval + * clamping. The watch loop itself is an I/O loop (sleep + redraw) and is left + * to manual verification; only the pure helpers are unit-tested. + * + * The bin module guards its top-level `main()` behind an `invokedDirectly` + * check (entry path ends with `bin/quota.js`), so importing it here does NOT + * fire a real query. + */ + +import { describe, expect, it } from "vitest"; + +import { parseArgs, resolveIntervalMs } from "../src/bin/quota.js"; + +describe("resolveIntervalMs", () => { + it("defaults to 30s when no interval given", () => { + expect(resolveIntervalMs(undefined)).toEqual({ ms: 30_000, clamped: false }); + }); + + it("passes through values >= 10s unchanged", () => { + expect(resolveIntervalMs(10)).toEqual({ ms: 10_000, clamped: false }); + expect(resolveIntervalMs(45)).toEqual({ ms: 45_000, clamped: false }); + expect(resolveIntervalMs(120)).toEqual({ ms: 120_000, clamped: false }); + }); + + it("clamps values below 10s to the floor and flags them", () => { + expect(resolveIntervalMs(5)).toEqual({ ms: 10_000, clamped: true }); + expect(resolveIntervalMs(1)).toEqual({ ms: 10_000, clamped: true }); + expect(resolveIntervalMs(0)).toEqual({ ms: 10_000, clamped: true }); + }); + + it("does NOT flag the default 30s as clamped", () => { + // Important: defaulting to 30s is not a "clamp" — the user supplied nothing. + expect(resolveIntervalMs(undefined).clamped).toBe(false); + }); +}); + +describe("parseArgs", () => { + it("empty argv → one-shot mode with default interval", () => { + const opts = parseArgs([]); + expect(opts.watch).toBe(false); + expect(opts.help).toBe(false); + expect(opts.intervalMs).toBe(30_000); + expect(opts.intervalClamped).toBe(false); + }); + + it("-w enables watch", () => { + expect(parseArgs(["-w"]).watch).toBe(true); + expect(parseArgs(["--watch"]).watch).toBe(true); + }); + + it("-h / --help enables help", () => { + expect(parseArgs(["-h"]).help).toBe(true); + expect(parseArgs(["--help"]).help).toBe(true); + }); + + it("-i sets the interval (space form)", () => { + expect(parseArgs(["-w", "-i", "60"]).intervalMs).toBe(60_000); + expect(parseArgs(["--watch", "--interval", "15"]).intervalMs).toBe(15_000); + }); + + it("-i attached form", () => { + expect(parseArgs(["-i20"]).intervalMs).toBe(20_000); + }); + + it("--interval= attached form", () => { + expect(parseArgs(["--interval=45"]).intervalMs).toBe(45_000); + }); + + it("clamps a below-floor interval and sets intervalClamped", () => { + const opts = parseArgs(["-w", "-i", "3"]); + expect(opts.intervalMs).toBe(10_000); + expect(opts.intervalClamped).toBe(true); + }); + + it("does not flag clamped when interval is omitted (default 30s)", () => { + expect(parseArgs(["-w"]).intervalClamped).toBe(false); + }); + + it("ignores unknown flags", () => { + const opts = parseArgs(["--bogus", "-w", "--unknown", "x"]); + expect(opts.watch).toBe(true); + expect(opts.intervalMs).toBe(30_000); + }); + + it("non-numeric interval value is ignored (falls back to default)", () => { + const opts = parseArgs(["-i", "abc"]); + expect(opts.intervalMs).toBe(30_000); + expect(opts.intervalClamped).toBe(false); + }); +}); diff --git a/tests/quota.test.ts b/tests/quota.test.ts index a18cad9..cb24657 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -21,7 +21,7 @@ vi.mock("../src/backend/credentials.js", () => ({ })); import { clearCache, getCached, setCached, setClock } from "../src/quota/cache.js"; -import { formatQuota, renderBar } from "../src/quota/format.js"; +import { formatQuota, formatQuotaPlain, renderBar } from "../src/quota/format.js"; import { parseLimit, parseQuotaEnvelope } from "../src/quota/parse.js"; import type { QuotaResult } from "../src/quota/types.js"; import { resolveQuotaHost } from "../src/quota/client.js"; @@ -258,6 +258,27 @@ describe("formatQuota", () => { }); }); +describe("formatQuotaPlain", () => { + it("strips the ```text fence from a success card", () => { + const result: QuotaResult = { + kind: "success", + level: "pro", + items: [{ key: "token_5h", label: "5h", usedPercent: 18, leftPercent: 82 }], + }; + const plain = formatQuotaPlain(result); + expect(plain.startsWith("```")).toBe(false); + expect(plain.endsWith("```")).toBe(false); + expect(plain).toContain("GLM Coding Plan · Pro"); + expect(plain).toContain("18%"); + }); + + it("returns non-success fallbacks unchanged (already unfenced)", () => { + const unavailable = formatQuotaPlain({ kind: "unavailable" }); + expect(unavailable).toBe(formatQuota({ kind: "unavailable" })); + expect(unavailable).not.toContain("```"); + }); +}); + describe("cache", () => { beforeEach(() => { clearCache();