diff --git a/packages/cli/package.json b/packages/cli/package.json index 187fc03..92f9988 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,15 +3,21 @@ "version": "0.0.0", "private": true, "type": "module", - "exports": { ".": "./src/index.ts" },"bin":{"harness":"./dist/cli.js"}, + "exports": { ".": "./src/index.ts" }, + "bin": { "harness": "./dist/cli.js" }, "scripts": { - "build": "echo 'build: not yet implemented'", + "build": "tsdown", "typecheck": "tsc --noEmit", "lint": "echo 'lint: not yet implemented'", "test": "vitest run", "clean": "rm -rf dist" }, + "dependencies": { + "@any-harness/sdk": "workspace:*" + }, "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts new file mode 100644 index 0000000..52bc057 --- /dev/null +++ b/packages/cli/src/api.ts @@ -0,0 +1,221 @@ +/** + * Pinned cross-workstream API surface for `@any-harness/sdk`. + * + * These names are the Wave-2 interface pin from + * `.agents/plans/2026-10-07-v2-foundation.md`: both the sdk (W8) and the cli + * (W9) code against them and drift is reconciled at merge. The method-level + * shapes below are the cli-side reading of that pin — the canonical + * definitions land with the sdk; until then `sdk-bind.ts` casts through + * `unknown` so this file is the single point of drift. + * + * Bridge wire types (`Extension`, `ExtensionKind`, `ManifestRef`) are pinned + * verbatim by `spec/bridge/operations.md` §1. + */ + +export type ExtensionKind = + | "skill" + | "mcp" + | "plugin" + | "hook" + | "command" + | "agent" + | "rule"; + +/** Pointer to a plugin.json + version, per spec/manifest.md + spec/lockfile.md. */ +export interface ManifestRef { + name: string; + version: string; + /** Integrity value as recorded in extensions.lock (e.g. "sha256-…"). */ + integrity?: string; +} + +/** One installed unit in the store (spec/bridge/operations.md §1). */ +export interface Extension { + /** Stable id: "@" — also the extensions.lock key. */ + id: string; + kind: ExtensionKind; + manifest: ManifestRef; + enabled: boolean; + provides?: ExtensionKind[]; + supported?: boolean; +} + +/** Source coordinates produced by `resolveSource` (spec/lockfile.md §3.3). */ +export interface SourceRef { + type: "git" | "github" | "registry" | "local"; + /** Canonical source identifier for its type. */ + uri: string; + /** Floating ref to pin at install (branch/tag); resolved to a SHA by install. */ + ref?: string; + /** Subpath within the source where the package lives (monorepo sources). */ + path?: string; +} + +/** Who invoked a mutating operation (spec/trust.md §4.2 audit `actor`). */ +export type Actor = "user" | "agent" | "daemon"; + +export interface InstallOptions { + /** Materialization target for skill-kind components (store-layout.md §5.1). */ + skillTarget?: "shared" | "store"; + /** Audit actor classification; the store records it on trust events. */ + actor?: Actor; +} + +export interface VerifyResult { + ok: boolean; + /** Integrity recorded in extensions.lock. */ + expected?: string; + /** Integrity recomputed over the installed tree. */ + actual?: string; + details?: string; +} + +export interface DoctorFinding { + severity: "info" | "warn" | "error"; + /** Stable kebab-case finding code (e.g. "lock-corrupt", "orphan-package"). */ + code: string; + message: string; + path?: string; + extension?: string; +} + +export interface DoctorReport { + ok: boolean; + findings: DoctorFinding[]; +} + +/** The store object `createStore` returns (pinned method names). */ +export interface Store { + list(): Promise; + install(source: SourceRef, opts?: InstallOptions): Promise; + remove(name: string): Promise; + setEnabled(name: string, enabled: boolean): Promise; + materialize(name: string): Promise<{ materializedTo: string[] }>; + verify(name: string): Promise; + doctor(): Promise; +} + +/** + * Filesystem port — the only fs the sdk store ever sees. Implemented over + * `node:fs` here; the sdk ships an in-memory implementation for tests and + * browser/kv hosts provide their own. + * + * Member-level shape is provisional pending W8: names chosen to cover the + * L0 requirements (atomic staged writes, lockfile/digest reads, skills/ + * materialization, symlink-with-copy-fallback). + */ +export interface FsPort { + /** UTF-8 file contents; throws `not-found` when absent. */ + readFile(path: string): Promise; + /** Raw bytes for digest computation (spec/lockfile.md §4). */ + readFileBytes(path: string): Promise; + /** Create parent dirs and write; implementations SHOULD write atomically + * (sibling temp + rename per store-layout.md §7.1). */ + writeFile(path: string, data: string | Uint8Array): Promise; + /** Append UTF-8 bytes (audit.log `O_APPEND` semantics). */ + appendFile(path: string, data: string): Promise; + exists(path: string): Promise; + stat(path: string): Promise<{ + kind: "file" | "directory" | "symlink" | "other"; + size: number; + mtimeMs: number; + }>; + /** Entry names (not full paths) inside a directory; throws when absent. */ + list(path: string): Promise; + /** `mkdir -p` semantics; no error when already present. */ + mkdir(path: string): Promise; + /** Recursive remove; no error when absent. */ + remove(path: string): Promise; + rename(from: string, to: string): Promise; + /** + * Create `path` as a symlink to `target`. Implementations without symlink + * support (scriptc island: no `symlinkSync`) MAY throw; callers fall back + * to copying per the `ln`+copy rule in AGENTS.md §7. + */ + symlink?(target: string, path: string): Promise; + readlink?(path: string): Promise; + /** Recursive copy of a file or directory tree (symlink fallback path). */ + copy(from: string, to: string): Promise; +} + +export interface ExecResult { + code: number; + stdout: string; + stderr: string; +} + +export interface ExecOptions { + cwd?: string; + env?: Record; + /** Kill the process after this many ms; result reports a non-zero code. */ + timeoutMs?: number; +} + +/** + * Process port — git ops shell out to the `git` binary per AGENTS.md §7; + * `exec` takes one executable token + argv (no shell), `run` is the + * shell-string convenience form for trusted, internally-built commands. + */ +export interface ExecPort { + exec(file: string, args?: string[], opts?: ExecOptions): Promise; + run(command: string, opts?: ExecOptions): Promise; +} + +/** Pinned: `createStore(root, ports)` — the host injects both ports. */ +export interface StorePorts { + fs: FsPort; + exec: ExecPort; +} + +/* ── JSON-RPC envelope (spec/bridge/protocol.md §2) ─────────────────── */ + +export interface JsonRpcRequest { + jsonrpc: "2.0"; + /** Absent on notifications. */ + id?: string | number; + method: string; + params?: Record; +} + +export interface JsonRpcErrorBody { + code: number; + message: string; + data?: Record; +} + +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: string | number | null; + result?: unknown; + error?: JsonRpcErrorBody; +} + +/** Pinned bridge ops (spec/bridge/operations.md). */ +export const BRIDGE_METHODS = [ + "capabilities.negotiate", + "extensions.list", + "extensions.get", + "hooks.invoke", + "commands.resolve", + "skills.materialize", + "tools.call", + "events.notify", +] as const; + +export type BridgeMethod = (typeof BRIDGE_METHODS)[number]; + +/** Ops always permitted regardless of caller scope (transports.md §3.3). */ +export const ALWAYS_ALLOWED_METHODS: ReadonlySet = new Set([ + "capabilities.negotiate", + "events.notify", +]); + +/** The full pinned sdk surface the cli binds to (see sdk-bind.ts). */ +export interface SdkApi { + createStore(root: string, ports: StorePorts): Store; + resolveSource(input: string): SourceRef; + handleBridgeRequest( + store: Store, + req: JsonRpcRequest, + ): Promise; +} diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts new file mode 100644 index 0000000..47085ba --- /dev/null +++ b/packages/cli/src/args.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { + assertNoUnknownFlags, + flagBool, + flagEnum, + flagString, + parseArgs, +} from "./args.js"; +import { CliError } from "./errors.js"; + +describe("parseArgs", () => { + it("collects positionals and boolean flags", () => { + const a = parseArgs(["add", "owner/repo", "-y", "--json"]); + expect(a.positionals).toEqual(["add", "owner/repo"]); + expect(a.flags.y).toBe(true); + expect(a.flags.json).toBe(true); + }); + + it("parses --key value and --key=value", () => { + const a = parseArgs(["--skill-target", "store", "--limit=10"]); + expect(a.flags["skill-target"]).toBe("store"); + expect(a.flags.limit).toBe("10"); + }); + + it("treats --no-x as false", () => { + expect(parseArgs(["--no-json"]).flags.json).toBe(false); + }); + + it("honors the -- separator", () => { + const a = parseArgs(["remove", "--", "--weird-name"]); + expect(a.positionals).toEqual(["remove", "--weird-name"]); + }); + + it("bundles short boolean flags", () => { + const a = parseArgs(["-y"]); + expect(a.flags.y).toBe(true); + }); +}); + +describe("flag helpers", () => { + it("flagEnum rejects bad values with a usage error", () => { + expect(() => + flagEnum({ f: "bogus" }, "f", ["shared", "store"] as const), + ).toThrow(CliError); + expect(flagEnum({ f: "store" }, "f", ["shared", "store"] as const)).toBe( + "store", + ); + }); + + it("flagString picks first defined", () => { + expect(flagString({ a: "x" }, "missing", "a")).toBe("x"); + expect(flagString({ a: true }, "a")).toBeUndefined(); + }); + + it("assertNoUnknownFlags throws on typos", () => { + expect(() => + assertNoUnknownFlags({ json: true, jsno: true }, ["json"]), + ).toThrow(CliError); + }); + + it("flagBool finds aliases", () => { + expect(flagBool({ yes: true }, "y", "yes")).toBe(true); + expect(flagBool({}, "y", "yes")).toBe(false); + }); +}); diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts new file mode 100644 index 0000000..c393d3c --- /dev/null +++ b/packages/cli/src/args.ts @@ -0,0 +1,120 @@ +/** + * Minimal flag parser — hand-rolled per the scriptc-island dependency rules + * (no cleye/clack-class prompt or arg libs). Supports: + * + * --flag boolean true + * --no-flag boolean false + * --key value string value + * --key=value string value + * -y boolean shorthand (short flags are never combined) + * -- everything after is positional + */ +import { CliError } from "./errors.js"; + +export interface ParsedArgs { + /** Non-flag arguments in order. */ + positionals: string[]; + flags: Record; +} + +export const parseArgs = (argv: string[]): ParsedArgs => { + const positionals: string[] = []; + const flags: Record = {}; + let rest = false; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (rest) { + positionals.push(arg); + continue; + } + if (arg === "--") { + rest = true; + continue; + } + if (arg.startsWith("--")) { + const body = arg.slice(2); + const eq = body.indexOf("="); + if (eq !== -1) { + flags[body.slice(0, eq)] = body.slice(eq + 1); + continue; + } + if (body.startsWith("no-") && body.length > 3) { + flags[body.slice(3)] = false; + continue; + } + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("-")) { + flags[body] = next; + i += 1; + continue; + } + flags[body] = true; + continue; + } + if (arg.startsWith("-") && arg.length > 1 && arg !== "-") { + // Short flags: single char booleans (no bundling, no values). + for (const ch of arg.slice(1)) { + flags[ch] = true; + } + continue; + } + positionals.push(arg); + } + return { positionals, flags }; +}; + +export const flagBool = ( + flags: ParsedArgs["flags"], + ...names: string[] +): boolean => names.some((n) => flags[n] === true); + +export const flagString = ( + flags: ParsedArgs["flags"], + ...names: string[] +): string | undefined => { + for (const n of names) { + const v = flags[n]; + if (typeof v === "string") return v; + } + return undefined; +}; + +export const flagEnum = ( + flags: ParsedArgs["flags"], + name: string, + values: readonly T[], +): T | undefined => { + const raw = flagString(flags, name); + if (raw === undefined) return undefined; + if ((values as readonly string[]).includes(raw)) return raw as T; + throw new CliError( + "invalid-params", + `invalid --${name} value "${raw}" (expected: ${values.join(" | ")})`, + ); +}; + +/** Reject flags the command doesn't know about (typo safety). */ +export const assertNoUnknownFlags = ( + flags: ParsedArgs["flags"], + known: readonly string[], +): void => { + const knownSet = new Set(known); + for (const key of Object.keys(flags)) { + if (!knownSet.has(key)) { + throw new CliError("usage", `unknown flag --${key}`); + } + } +}; + +export const requirePositional = ( + args: ParsedArgs, + index: number, + what: string, +): string => { + const value = args.positionals[index]; + if (value === undefined || value === "") { + throw new CliError("usage", `missing required argument <${what}>`); + } + return value; +}; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..40dfe3c --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * `harness` — the AnyHarness CLI. Flag-driven only (scriptc island): every + * confirm takes `-y`, all output supports `--json`, git ops shell out to + * the `git` binary, logging is a console shim on stderr. + */ +import process from "node:process"; +import readline from "node:readline"; + +import { parseArgs, type ParsedArgs } from "./args.js"; +import { cmdAdd } from "./commands/add.js"; +import { cmdAudit } from "./commands/audit.js"; +import { cmdDoctor } from "./commands/doctor.js"; +import { cmdDisable, cmdEnable } from "./commands/enable.js"; +import { cmdList } from "./commands/list.js"; +import { cmdRemove } from "./commands/remove.js"; +import { cmdServe } from "./commands/serve.js"; +import { cmdVerify } from "./commands/verify.js"; +import { detectActor, type CliDeps } from "./deps.js"; +import { CliError, toCliError } from "./errors.js"; +import { flagBool, flagString } from "./args.js"; +import { createLogger, emitError, stdoutWriters } from "./output.js"; +import { createNodePorts } from "./ports/index.js"; +import { resolveStoreRoot } from "./root.js"; +import { sdkApi } from "./sdk-bind.js"; + +const USAGE = `harness — AnyHarness package manager + bridge server + +usage: + harness add [--skill-target shared|store] [-y] [--json] + harness remove [-y] [--json] + harness list [--all] [--kinds skill,mcp] [--json] + harness enable [--json] + harness disable [--json] + harness verify [--json] + harness doctor [--json] + harness audit [--json] [--limit N] [--event E] [--actor A] [--extension X] + harness serve [--transport stdio] + +global flags: + --root store root (default: $ANYHARNESS_STORE, + $ANYHARNESS_HOME/harness, or ~/.agents/harness) + --json machine-readable output on stdout + -h, --help this text + --version print version + +env: + ANYHARNESS_STORE explicit store root (highest precedence) + ANYHARNESS_HOME relocate the ~/.agents root + ANYHARNESS_LOG debug | info | warn | error (default: info) + ANYHARNESS_ACTOR user | agent | daemon (audit attribution override) +`; + +const ttyConfirm = (w: (s: string) => void): ((q: string) => Promise) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return (question) => + new Promise((resolve) => { + rl.question(`${question} [y/N] `, (answer) => { + w(""); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +}; + +const stdinLines = async function* (): AsyncIterable { + const rl = readline.createInterface({ input: process.stdin }); + for await (const line of rl) yield line; +}; + +const dispatch = async ( + command: string, + deps: CliDeps, + args: ParsedArgs, +): Promise => { + switch (command) { + case "add": + return cmdAdd(deps, args); + case "remove": + return cmdRemove(deps, args); + case "list": + return cmdList(deps, args); + case "enable": + return cmdEnable(deps, args); + case "disable": + return cmdDisable(deps, args); + case "verify": + return cmdVerify(deps, args); + case "doctor": + return cmdDoctor(deps, args); + case "audit": + return cmdAudit(deps, args); + case "serve": + return cmdServe(deps, args, stdinLines(), (line) => { + process.stdout.write(`${line}\n`); + }); + default: + throw new CliError("usage", `unknown command "${command}"`); + } +}; + +const main = async (): Promise => { + const w = stdoutWriters(process); + const argv = process.argv.slice(2); + const args = parseArgs(argv); + + if (flagBool(args.flags, "h", "help") || argv.length === 0) { + w.out(USAGE); + return; + } + if (flagBool(args.flags, "version")) { + w.out("harness 0.0.0 (anyharness v2)"); + return; + } + + const command = args.positionals[0]; + if (command === undefined) { + w.out(USAGE); + return; + } + // The command name is positional[0]; remaining positionals shift down. + const cmdArgs: ParsedArgs = { + positionals: args.positionals.slice(1), + flags: args.flags, + }; + + const json = flagBool(args.flags, "json"); + const interactive = process.stdin.isTTY === true; + const logLevel = ( + ["debug", "info", "warn", "error"] as const + ).find((l) => l === process.env.ANYHARNESS_LOG) ?? "info"; + + try { + const ports = createNodePorts(); + const storeRoot = resolveStoreRoot( + process.env, + process.env.HOME ?? "", + flagString(args.flags, "root"), + ); + const deps: CliDeps = { + store: sdkApi.createStore(storeRoot, ports), + resolveSource: sdkApi.resolveSource, + handleBridgeRequest: sdkApi.handleBridgeRequest, + fs: ports.fs, + w, + log: createLogger(w, logLevel), + env: process.env, + storeRoot, + interactive, + actor: + command === "serve" + ? "daemon" + : detectActor(process.env, interactive), + confirm: interactive ? ttyConfirm((s) => w.err(s)) : undefined, + setExitCode: (code) => { + process.exitCode = code; + }, + }; + await dispatch(command, deps, cmdArgs); + } catch (err) { + const cliErr = toCliError(err); + emitError(w, cliErr, json); + process.exitCode = cliErr.exitCode; + } +}; + +await main(); diff --git a/packages/cli/src/commands.test.ts b/packages/cli/src/commands.test.ts new file mode 100644 index 0000000..c8178c5 --- /dev/null +++ b/packages/cli/src/commands.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; + +import { parseArgs } from "./args.js"; +import { cmdAdd } from "./commands/add.js"; +import { cmdAudit, parseAuditLog } from "./commands/audit.js"; +import { cmdDoctor } from "./commands/doctor.js"; +import { cmdDisable, cmdEnable } from "./commands/enable.js"; +import { cmdList } from "./commands/list.js"; +import { cmdRemove } from "./commands/remove.js"; +import { cmdVerify } from "./commands/verify.js"; +import { CliError } from "./errors.js"; +import { createMemoryFs } from "./testing/memory-fs.js"; +import { createMockStore } from "./testing/mock-store.js"; +import { createTestDeps } from "./testing/deps.js"; +import type { Extension } from "./api.js"; + +const seedExt = (name: string, enabled = true): Extension => ({ + id: `${name}@1.0.0`, + kind: "plugin", + manifest: { name, version: "1.0.0" }, + enabled, + provides: ["skill", "command"], +}); + +describe("harness add", () => { + it("installs with -y and --json", async () => { + const t = createTestDeps(); + await cmdAdd(t.deps, parseArgs(["acme/tools", "-y", "--json"])); + expect(t.store.installs).toHaveLength(1); + expect(t.store.installs[0].source).toEqual({ + type: "github", + uri: "acme/tools", + }); + const out = JSON.parse(t.out[0]); + expect(out.installed.id).toBe("tools@1.0.0"); + }); + + it("forwards --skill-target and actor to store.install", async () => { + const t = createTestDeps({ actor: "agent" }); + await cmdAdd( + t.deps, + parseArgs(["acme/tools", "-y", "--skill-target", "store"]), + ); + expect(t.store.installs[0].opts).toEqual({ + skillTarget: "store", + actor: "agent", + }); + }); + + it("refuses non-interactive without -y", async () => { + const t = createTestDeps({ interactive: false }); + await expect( + cmdAdd(t.deps, parseArgs(["acme/tools"])), + ).rejects.toMatchObject({ kind: "confirmation-required", exitCode: 5 }); + expect(t.store.installs).toHaveLength(0); + }); + + it("prompts on interactive TTY and honors the answer", async () => { + const yes = createTestDeps({ + interactive: true, + confirm: async () => true, + }); + await cmdAdd(yes.deps, parseArgs(["acme/tools"])); + expect(yes.store.installs).toHaveLength(1); + + const no = createTestDeps({ + interactive: true, + confirm: async () => false, + }); + await expect(cmdAdd(no.deps, parseArgs(["acme/tools"]))).rejects.toThrow( + CliError, + ); + expect(no.store.installs).toHaveLength(0); + }); +}); + +describe("harness remove", () => { + it("removes with -y", async () => { + const t = createTestDeps({ store: createMockStore([seedExt("tools")]) }); + await cmdRemove(t.deps, parseArgs(["tools", "-y"])); + expect(t.out[0]).toBe("removed tools"); + expect(await t.deps.store.list()).toHaveLength(0); + }); + + it("requires confirmation", async () => { + const t = createTestDeps({ store: createMockStore([seedExt("tools")]) }); + await expect(cmdRemove(t.deps, parseArgs(["tools"]))).rejects.toMatchObject( + { kind: "confirmation-required" }, + ); + expect(await t.deps.store.list()).toHaveLength(1); + }); +}); + +describe("harness list", () => { + it("prints a table of enabled extensions by default", async () => { + const store = createMockStore([ + seedExt("alpha"), + seedExt("beta", false), + ]); + const t = createTestDeps({ store }); + await cmdList(t.deps, parseArgs([])); + expect(t.out[0]).toContain("alpha"); + expect(t.out[0]).not.toContain("beta"); + }); + + it("--all includes disabled; --json emits the extension array", async () => { + const store = createMockStore([seedExt("beta", false)]); + const t = createTestDeps({ store }); + await cmdList(t.deps, parseArgs(["--all", "--json"])); + const out = JSON.parse(t.out[0]); + expect(out.extensions).toHaveLength(1); + expect(out.extensions[0].manifest.name).toBe("beta"); + }); + + it("--kinds filters by primary kind or provides", async () => { + const store = createMockStore([ + seedExt("with-skill"), + { ...seedExt("no-extra"), provides: undefined }, + ]); + const t = createTestDeps({ store }); + await cmdList(t.deps, parseArgs(["--kinds", "skill", "--json"])); + const out = JSON.parse(t.out[0]); + expect(out.extensions.map((e: Extension) => e.manifest.name)).toEqual([ + "with-skill", + ]); + }); +}); + +describe("harness enable / disable", () => { + it("toggles enabled state", async () => { + const t = createTestDeps({ store: createMockStore([seedExt("tools")]) }); + await cmdDisable(t.deps, parseArgs(["tools"])); + expect(t.out[0]).toBe("disabled tools@1.0.0"); + await cmdEnable(t.deps, parseArgs(["tools"])); + expect(t.out[1]).toBe("enabled tools@1.0.0"); + }); +}); + +describe("harness verify", () => { + it("passes on matching integrity", async () => { + const t = createTestDeps({ store: createMockStore([seedExt("tools")]) }); + await cmdVerify(t.deps, parseArgs(["tools"])); + expect(t.out[0]).toContain("ok"); + }); + + it("raises trust-violation on mismatch", async () => { + const store = createMockStore([seedExt("tools")]); + store.entries.get("tools")!.tampered = true; + const t = createTestDeps({ store }); + await expect(cmdVerify(t.deps, parseArgs(["tools"]))).rejects.toMatchObject( + { kind: "trust-violation", exitCode: 4 }, + ); + }); +}); + +describe("harness doctor", () => { + it("reports healthy", async () => { + const t = createTestDeps(); + await cmdDoctor(t.deps, parseArgs([])); + expect(t.out[0]).toBe("store healthy — no findings"); + expect(t.exitCode).toBeUndefined(); + }); + + it("exits 1 on error findings", async () => { + const store = createMockStore(); + store.doctor = async () => ({ + ok: false, + findings: [ + { severity: "error", code: "lock-corrupt", message: "extensions.lock is corrupt" }, + ], + }); + const t = createTestDeps({ store }); + await cmdDoctor(t.deps, parseArgs(["--json"])); + expect(JSON.parse(t.out[0]).findings).toHaveLength(1); + expect(t.exitCode).toBe(1); + }); +}); + +describe("harness audit", () => { + const LOG = [ + JSON.stringify({ ts: "t1", event: "install", actor: "user", extension: { name: "a", version: "1" } }), + '{"broken":', // partial line — skipped + JSON.stringify({ ts: "t2", event: "exec.deny", actor: "agent", extension: { name: "b", version: "2" }, decision: "deny" }), + ].join("\n"); + + it("reads audit.log via the fs port, skipping partial lines", async () => { + const fs = createMemoryFs({ "/test/.agents/harness/audit.log": LOG }); + const t = createTestDeps(); + t.deps.fs = fs; + await cmdAudit(t.deps, parseArgs(["--json"])); + const out = JSON.parse(t.out[0]); + expect(out.events).toHaveLength(2); + expect(out.total).toBe(2); + }); + + it("filters by --event and --limit", async () => { + const fs = createMemoryFs({ "/test/.agents/harness/audit.log": LOG }); + const t = createTestDeps(); + t.deps.fs = fs; + await cmdAudit(t.deps, parseArgs(["--event", "exec.deny", "--json"])); + const out = JSON.parse(t.out[0]); + expect(out.events).toHaveLength(1); + expect(out.events[0].actor).toBe("agent"); + }); + + it("handles a missing log", async () => { + const t = createTestDeps(); + await cmdAudit(t.deps, parseArgs([])); + expect(t.out[0]).toBe("no audit log yet"); + }); +}); + +describe("parseAuditLog", () => { + it("tolerates empty input", () => { + expect(parseAuditLog("")).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts new file mode 100644 index 0000000..13feaf4 --- /dev/null +++ b/packages/cli/src/commands/add.ts @@ -0,0 +1,58 @@ +/** + * `harness add ` — install an extension. + * + * Source resolution and the install itself are sdk operations; the cli + * owns the flag surface and the trust discipline around them: + * - `--skill-target shared|store` picks the skill materialization root + * (shared `~/.agents/skills/` is the spec default — store-layout §5.1) + * - `-y` attests the mutating op; without it an interactive TTY gets a + * y/N prompt and a non-interactive caller is refused + * - the resolved `actor` rides along so the sdk's trust layer can keep + * agent-installed executables dormant (trust.md §4.2) + */ +import type { CliDeps } from "../deps.js"; +import { + assertNoUnknownFlags, + flagBool, + flagEnum, + requirePositional, + type ParsedArgs, +} from "../args.js"; +import { confirm } from "../confirm.js"; +import { emit, table } from "../output.js"; + +const KNOWN = ["skill-target", "y", "yes", "json"] as const; + +export const cmdAdd = async (deps: CliDeps, args: ParsedArgs): Promise => { + assertNoUnknownFlags(args.flags, KNOWN); + const sourceInput = requirePositional(args, 0, "source"); + const skillTarget = flagEnum(args.flags, "skill-target", [ + "shared", + "store", + ] as const); + const yes = flagBool(args.flags, "y", "yes"); + const json = flagBool(args.flags, "json"); + + const source = deps.resolveSource(sourceInput); + await confirm(deps, `install ${source.type} source ${source.uri}?`, { yes }); + + const extension = await deps.store.install(source, { + skillTarget, + actor: deps.actor, + }); + + emit( + deps.w, + { installed: extension }, + () => + table([ + ["installed", extension.id], + ["kind", extension.kind], + [ + "provides", + extension.provides?.length ? extension.provides.join(",") : "-", + ], + ]), + json, + ); +}; diff --git a/packages/cli/src/commands/audit.ts b/packages/cli/src/commands/audit.ts new file mode 100644 index 0000000..2e6192c --- /dev/null +++ b/packages/cli/src/commands/audit.ts @@ -0,0 +1,94 @@ +/** + * `harness audit` — read the trust audit log. + * + * `~/.agents/harness/audit.log` is append-only JSONL (trust.md §6.1). The + * pinned Store surface has no audit op, so the cli reads it directly + * through the fs port — read-only, tolerant of the partial last line a + * crashed writer may leave (§6.1: skip and continue). + */ +import type { CliDeps } from "../deps.js"; +import { + assertNoUnknownFlags, + flagBool, + flagString, + type ParsedArgs, +} from "../args.js"; +import { emit, table } from "../output.js"; + +const KNOWN = ["json", "limit", "event", "actor", "extension"] as const; + +export interface AuditEvent { + ts: string; + event: string; + actor: string; + extension?: { name: string; version: string }; + decision?: string; + integrity?: string; + source?: Record; + details?: Record; +} + +export const parseAuditLog = (text: string): AuditEvent[] => { + const events: AuditEvent[] = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (trimmed === "") continue; + try { + const parsed = JSON.parse(trimmed) as AuditEvent; + if (typeof parsed.ts === "string" && typeof parsed.event === "string") { + events.push(parsed); + } + } catch { + // Partial line from a crashed writer — skip and continue (§6.1). + } + } + return events; +}; + +export const cmdAudit = async ( + deps: CliDeps, + args: ParsedArgs, +): Promise => { + assertNoUnknownFlags(args.flags, KNOWN); + const json = flagBool(args.flags, "json"); + const eventFilter = flagString(args.flags, "event"); + const actorFilter = flagString(args.flags, "actor"); + const extensionFilter = flagString(args.flags, "extension"); + const limitRaw = flagString(args.flags, "limit"); + const limit = limitRaw === undefined ? 50 : Number.parseInt(limitRaw, 10); + + const path = `${deps.storeRoot}/audit.log`; + const exists = await deps.fs.exists(path); + if (!exists) { + emit(deps.w, { events: [] }, () => "no audit log yet", json); + return; + } + + const text = await deps.fs.readFile(path); + let events = parseAuditLog(text); + if (eventFilter) events = events.filter((e) => e.event === eventFilter); + if (actorFilter) events = events.filter((e) => e.actor === actorFilter); + if (extensionFilter) { + events = events.filter((e) => e.extension?.name === extensionFilter); + } + const shown = limit >= 0 ? events.slice(-limit) : events; + + emit( + deps.w, + { events: shown, total: events.length }, + () => { + if (shown.length === 0) return "no audit events"; + return table([ + ["TS", "EVENT", "ACTOR", "EXTENSION", "DECISION"], + ...shown.map((e) => [ + e.ts, + e.event, + e.actor, + e.extension ? `${e.extension.name}@${e.extension.version}` : "-", + e.decision ?? "-", + ]), + ]); + }, + json, + ); +}; diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 0000000..05737f7 --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,38 @@ +import type { CliDeps } from "../deps.js"; +import { assertNoUnknownFlags, flagBool, type ParsedArgs } from "../args.js"; +import { emit, table } from "../output.js"; + +const KNOWN = ["json"] as const; + +export const cmdDoctor = async ( + deps: CliDeps, + args: ParsedArgs, +): Promise => { + assertNoUnknownFlags(args.flags, KNOWN); + const json = flagBool(args.flags, "json"); + + const report = await deps.store.doctor(); + emit( + deps.w, + report, + () => { + if (report.findings.length === 0) return "store healthy — no findings"; + return table([ + ["SEVERITY", "CODE", "PATH", "MESSAGE"], + ...report.findings.map((f) => [ + f.severity, + f.code, + f.path ?? f.extension ?? "-", + f.message, + ]), + ]); + }, + json, + ); + + // Doctor exits non-zero on error-severity findings — findings themselves + // are the report; the exit code is the signal for scripts. + if (report.findings.some((f) => f.severity === "error")) { + deps.setExitCode(1); + } +}; diff --git a/packages/cli/src/commands/enable.ts b/packages/cli/src/commands/enable.ts new file mode 100644 index 0000000..d3f372c --- /dev/null +++ b/packages/cli/src/commands/enable.ts @@ -0,0 +1,34 @@ +import type { CliDeps } from "../deps.js"; +import { + assertNoUnknownFlags, + flagBool, + requirePositional, + type ParsedArgs, +} from "../args.js"; +import { emit } from "../output.js"; + +const KNOWN = ["json"] as const; + +const setEnabled = async ( + deps: CliDeps, + args: ParsedArgs, + enabled: boolean, +): Promise => { + assertNoUnknownFlags(args.flags, KNOWN); + const name = requirePositional(args, 0, "name"); + const json = flagBool(args.flags, "json"); + + const extension = await deps.store.setEnabled(name, enabled); + emit( + deps.w, + { extension }, + () => `${enabled ? "enabled" : "disabled"} ${extension.id}`, + json, + ); +}; + +export const cmdEnable = (deps: CliDeps, args: ParsedArgs): Promise => + setEnabled(deps, args, true); + +export const cmdDisable = (deps: CliDeps, args: ParsedArgs): Promise => + setEnabled(deps, args, false); diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts new file mode 100644 index 0000000..de0a86f --- /dev/null +++ b/packages/cli/src/commands/list.ts @@ -0,0 +1,51 @@ +import type { CliDeps } from "../deps.js"; +import { + assertNoUnknownFlags, + flagBool, + type ParsedArgs, +} from "../args.js"; +import { emit, table } from "../output.js"; + +const KNOWN = ["json", "all", "kinds"] as const; + +export const cmdList = async ( + deps: CliDeps, + args: ParsedArgs, +): Promise => { + assertNoUnknownFlags(args.flags, KNOWN); + const json = flagBool(args.flags, "json"); + const all = flagBool(args.flags, "all"); + const kindsRaw = args.flags.kinds; + const kinds = + typeof kindsRaw === "string" + ? kindsRaw.split(",").map((k) => k.trim()) + : undefined; + + let extensions = await deps.store.list(); + if (!all) extensions = extensions.filter((e) => e.enabled); + if (kinds) { + extensions = extensions.filter( + (e) => + kinds.includes(e.kind) || + e.provides?.some((p) => kinds.includes(p)) === true, + ); + } + + emit( + deps.w, + { extensions }, + () => { + if (extensions.length === 0) return "no extensions installed"; + return table([ + ["NAME", "KIND", "VERSION", "ENABLED"], + ...extensions.map((e) => [ + e.manifest.name, + e.kind, + e.manifest.version, + e.enabled ? "yes" : "no", + ]), + ]); + }, + json, + ); +}; diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts new file mode 100644 index 0000000..9aa474a --- /dev/null +++ b/packages/cli/src/commands/remove.ts @@ -0,0 +1,26 @@ +import type { CliDeps } from "../deps.js"; +import { + assertNoUnknownFlags, + flagBool, + requirePositional, + type ParsedArgs, +} from "../args.js"; +import { confirm } from "../confirm.js"; +import { emit } from "../output.js"; + +const KNOWN = ["y", "yes", "json", "keep-data"] as const; + +export const cmdRemove = async ( + deps: CliDeps, + args: ParsedArgs, +): Promise => { + assertNoUnknownFlags(args.flags, KNOWN); + const name = requirePositional(args, 0, "name"); + const yes = flagBool(args.flags, "y", "yes"); + const json = flagBool(args.flags, "json"); + + await confirm(deps, `remove extension "${name}"?`, { yes }); + await deps.store.remove(name); + + emit(deps.w, { removed: name }, () => `removed ${name}`, json); +}; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts new file mode 100644 index 0000000..4650b8d --- /dev/null +++ b/packages/cli/src/commands/serve.ts @@ -0,0 +1,58 @@ +/** + * `harness serve [--transport stdio]` — run the bridge server. + * + * v0.1 ships the stdio transport only (the primary one — transports.md §1); + * `--transport` accepts `stdio` and rejects anything else with a usage + * error rather than silently ignoring it. Config (`[policy]` + + * `[[serve.caller]]`) is loaded from `/config.toml`. + */ +import type { CliDeps } from "../deps.js"; +import { assertNoUnknownFlags, flagEnum, type ParsedArgs } from "../args.js"; +import { + callersFromToml, + defaultPolicy, + parseToml, + policyFromToml, +} from "../config.js"; +import { CliError } from "../errors.js"; +import { serveStdio } from "../serve/stdio.js"; +import { STDIO_CALLER } from "../serve/authz.js"; + +const KNOWN = ["transport", "json"] as const; + +export const cmdServe = async ( + deps: CliDeps, + args: ParsedArgs, + lines: AsyncIterable, + write: (line: string) => void, +): Promise => { + assertNoUnknownFlags(args.flags, KNOWN); + const transport = flagEnum(args.flags, "transport", ["stdio"] as const); + if (transport !== undefined && transport !== "stdio") { + throw new CliError("usage", `unsupported transport "${transport}"`); + } + + const configPath = `${deps.storeRoot}/config.toml`; + let policy = defaultPolicy(); + let callers: ReturnType = []; + if (await deps.fs.exists(configPath)) { + const root = parseToml(await deps.fs.readFile(configPath)); + policy = policyFromToml(root); + callers = callersFromToml(root); + } + + deps.log.info( + `harness serve: stdio transport, caller "${STDIO_CALLER}", store ${deps.storeRoot}`, + ); + + await serveStdio({ + store: deps.store, + handleRequest: deps.handleBridgeRequest, + callers, + callerName: STDIO_CALLER, + policy, + log: deps.log, + lines, + write, + }); +}; diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts new file mode 100644 index 0000000..e2ab99b --- /dev/null +++ b/packages/cli/src/commands/verify.ts @@ -0,0 +1,43 @@ +import type { CliDeps } from "../deps.js"; +import { + assertNoUnknownFlags, + flagBool, + requirePositional, + type ParsedArgs, +} from "../args.js"; +import { CliError } from "../errors.js"; +import { emit, table } from "../output.js"; + +const KNOWN = ["json"] as const; + +export const cmdVerify = async ( + deps: CliDeps, + args: ParsedArgs, +): Promise => { + assertNoUnknownFlags(args.flags, KNOWN); + const name = requirePositional(args, 0, "name"); + const json = flagBool(args.flags, "json"); + + const result = await deps.store.verify(name); + emit( + deps.w, + { name, ...result }, + () => + table([ + ["extension", name], + ["integrity", result.ok ? "ok" : "MISMATCH"], + ["expected", result.expected ?? "-"], + ["actual", result.actual ?? "-"], + ]), + json, + ); + + if (!result.ok) { + // trust.md §3.2 — mismatch means untrusted; remediation is reinstall. + throw new CliError( + "trust-violation", + `integrity mismatch for "${name}": reinstall with \`harness add --reinstall\` (expected ${result.expected ?? "?"}, got ${result.actual ?? "?"})`, + { details: { expected: result.expected, actual: result.actual } }, + ); + } +}; diff --git a/packages/cli/src/config.test.ts b/packages/cli/src/config.test.ts new file mode 100644 index 0000000..cd54d4d --- /dev/null +++ b/packages/cli/src/config.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { + callersFromToml, + defaultPolicy, + parseToml, + policyFromToml, + resolveExecDecision, +} from "./config.js"; + +const CONFIG = ` +# trust + serve config +[policy] +exec.setup = "deny" +exec.hooks = "ask" +exec.mcp = "allow" +exec.skillScripts = "ask" +exec.nonInteractive = "deny" +sources.allow = ["acme/*", "github.com/trusted/repo"] +sources.deny = ["evil/*"] + +[[serve.caller]] +name = "maki" +token = "ahrt_abc" +allow = ["extensions.list", "extensions.get"] +deny = ["hooks.invoke"] + +[[serve.caller]] +name = "guest" +allow = ["*"] +`; + +describe("parseToml", () => { + it("parses tables, arrays-of-tables, strings, arrays, comments", () => { + const root = parseToml(CONFIG); + const policy = root.policy as Record; + expect(policy["exec.setup"]).toBe("deny"); + const serve = root.serve as Record; + expect(Array.isArray(serve.caller)).toBe(true); + }); +}); + +describe("policyFromToml", () => { + it("reads the spec-shipped defaults when [policy] is absent", () => { + const p = policyFromToml({}); + expect(p).toEqual(defaultPolicy()); + expect(p.execNonInteractive).toBe("deny"); + }); + + it("maps config.toml policy keys", () => { + const p = policyFromToml(parseToml(CONFIG)); + expect(p.execSetup).toBe("deny"); + expect(p.execHooks).toBe("ask"); + expect(p.execMcp).toBe("allow"); + expect(p.execSkillScripts).toBe("ask"); + expect(p.execNonInteractive).toBe("deny"); + expect(p.sourcesAllow).toEqual(["acme/*", "github.com/trusted/repo"]); + expect(p.sourcesDeny).toEqual(["evil/*"]); + }); + + it("supports [policy.exec] sub-table form", () => { + const p = policyFromToml(parseToml(`[policy.exec]\nsetup = "allow"\n`)); + expect(p.execSetup).toBe("allow"); + }); +}); + +describe("callersFromToml", () => { + it("reads [[serve.caller]] entries", () => { + const callers = callersFromToml(parseToml(CONFIG)); + expect(callers).toHaveLength(2); + expect(callers[0]).toEqual({ + name: "maki", + token: "ahrt_abc", + allow: ["extensions.list", "extensions.get"], + deny: ["hooks.invoke"], + }); + expect(callers[1]).toEqual({ name: "guest", allow: ["*"], deny: undefined }); + }); + + it("returns [] without serve config", () => { + expect(callersFromToml({})).toEqual([]); + }); +}); + +describe("resolveExecDecision", () => { + it("resolves ask by interactivity", () => { + const p = defaultPolicy(); + expect(resolveExecDecision("ask", true, p)).toBe("ask"); + expect(resolveExecDecision("ask", false, p)).toBe("deny"); + expect(resolveExecDecision("ask", false, { ...p, execNonInteractive: "allow" })).toBe("allow"); + expect(resolveExecDecision("deny", true, p)).toBe("deny"); + expect(resolveExecDecision("allow", false, p)).toBe("allow"); + }); +}); diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000..9067d62 --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,293 @@ +/** + * `~/.agents/harness/config.toml` — namespaced config per store-layout.md + * §5.5. Two owned prefixes exist today: + * + * [policy] trust.md §4.1 — exec/script policy + source allowlists + * [[serve.caller]] bridge/transports.md §3 — per-caller op scoping + * + * TOML is parsed by a deliberately small hand-rolled subset reader — no + * parser dep in the scriptc island. Supported: `[table]`, + * `[[array-of-tables]]`, `key = "string" | true | false | | ["a",…]`, + * `#` comments, dotted keys are NOT supported (nothing we own needs them). + */ +import { CliError } from "./errors.js"; + +export type TomlValue = string | number | boolean | string[]; +export interface TomlTable { + [key: string]: TomlValue | TomlTable | TomlTable[]; +} + +const parseScalar = (raw: string): string | number | boolean => { + if (raw === "true") return true; + if (raw === "false") return false; + if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10); + if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) { + return raw.slice(1, -1).replace(/\\(["\\])/g, "$1"); + } + if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2) { + return raw.slice(1, -1); + } + throw new CliError("config-invalid", `unsupported TOML value: ${raw}`); +}; + +const parseValue = (raw: string): TomlValue => { + const trimmed = raw.trim(); + if (trimmed.startsWith("[")) { + if (!trimmed.endsWith("]")) { + throw new CliError("config-invalid", `unterminated array: ${raw}`); + } + const inner = trimmed.slice(1, -1).trim(); + if (inner === "") return []; + return inner.split(",").map((item) => { + const v = parseScalar(item.trim()); + if (typeof v !== "string") { + throw new CliError( + "config-invalid", + `arrays support string members only: ${raw}`, + ); + } + return v; + }); + } + return parseScalar(trimmed); +}; + +/** Strip a `#` comment that is not inside quotes. */ +const stripComment = (line: string): string => { + let inDouble = false; + let inSingle = false; + for (let i = 0; i < line.length; i += 1) { + const ch = line[i]; + if (ch === '"' && !inSingle && line[i - 1] !== "\\") inDouble = !inDouble; + else if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === "#" && !inDouble && !inSingle) return line.slice(0, i); + } + return line; +}; + +export const parseToml = (text: string): TomlTable => { + const root: TomlTable = {}; + // Cursor to the table that bare `key = value` lines currently write into. + let cursor = root; + + text.split("\n").forEach((rawLine, idx) => { + const line = stripComment(rawLine).trim(); + if (line === "") return; + + const arrayTable = line.match(/^\[\[([\w.-]+)\]\]$/); + const table = line.match(/^\[([\w.-]+)\]$/); + if (arrayTable) { + const path = arrayTable[1].split("."); + const parent = dig(root, path.slice(0, -1), idx); + const key = path[path.length - 1]; + const existing = parent[key]; + const list: TomlTable[] = Array.isArray(existing) + ? existing.filter( + (e): e is TomlTable => typeof e === "object" && e !== null, + ) + : []; + const entry: TomlTable = {}; + list.push(entry); + parent[key] = list; + cursor = entry; + return; + } + if (table) { + cursor = dig(root, table[1].split("."), idx); + return; + } + + const eq = line.indexOf("="); + if (eq === -1) { + throw new CliError( + "config-invalid", + `config.toml line ${idx + 1}: expected key = value`, + ); + } + const key = line.slice(0, eq).trim(); + if (key === "") { + throw new CliError( + "config-invalid", + `config.toml line ${idx + 1}: empty key`, + ); + } + cursor[key] = parseValue(line.slice(eq + 1)); + }); + + return root; +}; + +const dig = (root: TomlTable, path: string[], line: number): TomlTable => { + let cursor = root; + for (const segment of path) { + const next = cursor[segment]; + if (next === undefined) { + const created: TomlTable = {}; + cursor[segment] = created; + cursor = created; + } else if (typeof next === "object" && !Array.isArray(next)) { + cursor = next; + } else { + throw new CliError( + "config-invalid", + `config.toml line ${line + 1}: [${path.join(".")}] conflicts with a value`, + ); + } + } + return cursor; +}; + +/* ── Typed views over the two owned tables ──────────────────────────── */ + +export type PolicyValue = "deny" | "ask" | "allow"; + +export interface Policy { + execSetup: PolicyValue; + execHooks: PolicyValue; + execMcp: PolicyValue; + execSkillScripts: PolicyValue; + /** What `ask` resolves to with no human to answer (trust.md §4.1). */ + execNonInteractive: "deny" | "allow"; + sourcesAllow: string[]; + sourcesDeny: string[]; +} + +/** Spec-required shipped defaults (trust.md §4.1). */ +export const defaultPolicy = (): Policy => ({ + execSetup: "ask", + execHooks: "ask", + execMcp: "ask", + execSkillScripts: "ask", + execNonInteractive: "deny", + sourcesAllow: [], + sourcesDeny: [], +}); + +export interface ServeCaller { + name: string; + /** Bearer credential; stored hashed per transports.md §3.1. */ + token?: string; + /** Method allowlist; absent = full op set. */ + allow?: string[]; + deny?: string[]; +} + +const policyValue = (raw: TomlValue | undefined, key: string): PolicyValue => { + if (raw === undefined) return "ask"; + if (raw === "deny" || raw === "ask" || raw === "allow") return raw; + throw new CliError( + "config-invalid", + `[policy] ${key} must be deny | ask | allow, got ${JSON.stringify(raw)}`, + ); +}; + +const stringList = (raw: TomlValue | undefined, key: string): string[] => { + if (raw === undefined) return []; + if (Array.isArray(raw)) return raw; + throw new CliError( + "config-invalid", + `${key} must be an array of strings`, + ); +}; + +export const policyFromToml = (root: TomlTable): Policy => { + const table = root.policy; + const policy = defaultPolicy(); + if (table === undefined) return policy; + if (typeof table !== "object" || Array.isArray(table)) { + throw new CliError("config-invalid", "[policy] must be a table"); + } + const exec = + typeof table.exec === "object" && !Array.isArray(table.exec) + ? table.exec + : undefined; + const sources = + typeof table.sources === "object" && !Array.isArray(table.sources) + ? table.sources + : undefined; + + // Accept both `[policy.exec]` sub-tables and `exec.setup`-style flat keys + // flattened by hand (our subset parser keeps them distinct anyway). + const execVal = (key: string): TomlValue | undefined => { + const flat = table[`exec.${key}`]; + return exec?.[key] !== undefined + ? (exec[key] as TomlValue) + : (flat as TomlValue | undefined); + }; + + policy.execSetup = policyValue(execVal("setup"), "exec.setup"); + policy.execHooks = policyValue(execVal("hooks"), "exec.hooks"); + policy.execMcp = policyValue(execVal("mcp"), "exec.mcp"); + policy.execSkillScripts = policyValue( + execVal("skillScripts"), + "exec.skillScripts", + ); + const ni = execVal("nonInteractive"); + if (ni !== undefined) { + if (ni !== "deny" && ni !== "allow") { + throw new CliError( + "config-invalid", + `[policy] exec.nonInteractive must be deny | allow`, + ); + } + policy.execNonInteractive = ni; + } + policy.sourcesAllow = stringList( + sources?.["allow"] as TomlValue | undefined ?? + (table["sources.allow"] as TomlValue | undefined), + "sources.allow", + ); + policy.sourcesDeny = stringList( + sources?.["deny"] as TomlValue | undefined ?? + (table["sources.deny"] as TomlValue | undefined), + "sources.deny", + ); + return policy; +}; + +export const callersFromToml = (root: TomlTable): ServeCaller[] => { + const serve = root.serve; + if (serve === undefined || typeof serve !== "object" || Array.isArray(serve)) { + return []; + } + const callers = (serve as TomlTable).caller; + if (callers === undefined) return []; + if (!Array.isArray(callers)) { + throw new CliError("config-invalid", "serve.caller must be an array of tables"); + } + const tables = callers.filter( + (e): e is TomlTable => typeof e === "object" && e !== null, + ); + return tables.map((entry, i) => { + const name = entry.name; + if (typeof name !== "string" || name === "") { + throw new CliError( + "config-invalid", + `[[serve.caller]] #${i + 1}: name is required`, + ); + } + return { + name, + token: typeof entry.token === "string" ? entry.token : undefined, + allow: + entry.allow === undefined + ? undefined + : stringList(entry.allow as TomlValue, `serve.caller[${name}].allow`), + deny: + entry.deny === undefined + ? undefined + : stringList(entry.deny as TomlValue, `serve.caller[${name}].deny`), + }; + }); +}; + +/** What `ask` resolves to when `actor` cannot answer a prompt (trust.md §4.1). */ +export const resolveExecDecision = ( + value: PolicyValue, + interactive: boolean, + policy: Policy, +): "allow" | "deny" | "ask" => { + if (value === "allow") return "allow"; + if (value === "deny") return "deny"; + return interactive ? "ask" : policy.execNonInteractive; +}; diff --git a/packages/cli/src/confirm.ts b/packages/cli/src/confirm.ts new file mode 100644 index 0000000..297a953 --- /dev/null +++ b/packages/cli/src/confirm.ts @@ -0,0 +1,31 @@ +/** + * Mutating-operation confirmation: + * + * `-y` → proceed (caller attested) + * interactive TTY → hand-rolled y/N prompt (no prompt libs — AGENTS.md §7) + * non-interactive → refuse with confirmation-required + * + * (`exec.nonInteractive` in config.toml is deliberately NOT consulted here: + * trust.md §4.1 scopes it to executable-class `ask` resolution, not to + * confirming store mutations — `-y` is the only non-interactive attestation.) + */ +import type { CliDeps } from "./deps.js"; +import { CliError } from "./errors.js"; + +export const confirm = async ( + deps: CliDeps, + question: string, + opts: { yes: boolean }, +): Promise => { + if (opts.yes) return; + if (deps.interactive && deps.confirm) { + const ok = await deps.confirm(question); + if (ok) return; + throw new CliError("confirmation-required", "aborted by user"); + } + throw new CliError( + "confirmation-required", + `${question} — confirmation required in non-interactive context; re-run with -y`, + { details: { hint: "pass -y to proceed" } }, + ); +}; diff --git a/packages/cli/src/deps.ts b/packages/cli/src/deps.ts new file mode 100644 index 0000000..d2272f1 --- /dev/null +++ b/packages/cli/src/deps.ts @@ -0,0 +1,58 @@ +/** + * Everything a command needs that isn't argv — injected so tests run the + * full command surface against an in-memory Store mock and never touch the + * real sdk, filesystem, or TTY. + */ +import type { + Actor, + FsPort, + JsonRpcRequest, + JsonRpcResponse, + SourceRef, + Store, +} from "./api.js"; +import type { Logger, OutWriters } from "./output.js"; + +export interface CliDeps { + store: Store; + resolveSource: (input: string) => SourceRef; + handleBridgeRequest: ( + store: Store, + req: JsonRpcRequest, + ) => Promise; + /** Node ports — the cli owns these implementations. */ + fs: FsPort; + w: OutWriters; + log: Logger; + env: Record; + /** Resolved `~/.agents/harness` directory. */ + storeRoot: string; + /** stdin is an interactive TTY (confirm prompts allowed). */ + interactive: boolean; + actor: Actor; + /** + * Interactive y/N prompt on the controlling TTY. Only invoked when + * `interactive` is true and `-y` was not passed; the cli wires a + * hand-rolled readline prompt (no prompt libs — AGENTS.md §7). + */ + confirm?: (question: string) => Promise; + /** Set the process exit code (doctor uses it to signal findings). */ + setExitCode: (code: number) => void; +} + +/** + * trust.md §4.2 actor classification: interactive TTY → `user`; anything + * else (piped stdin, CI, agent-invoked) → `agent`; the bridge server is + * `daemon`. `ANYHARNESS_ACTOR` overrides for tests/hosts that know better. + */ +export const detectActor = ( + env: Record, + interactive: boolean, +): Actor => { + const forced = env.ANYHARNESS_ACTOR; + if (forced === "user" || forced === "agent" || forced === "daemon") { + return forced; + } + if (env.CI === "true" || env.CI === "1") return "agent"; + return interactive ? "user" : "agent"; +}; diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts new file mode 100644 index 0000000..308b57f --- /dev/null +++ b/packages/cli/src/errors.ts @@ -0,0 +1,43 @@ +/** + * CLI error model. Every failure leaves through `CliError`: `kind` is a + * stable kebab-case machine string (mirrors `data.kind` on the bridge), + * `exitCode` maps to process exit codes: + * + * 0 success · 1 runtime failure · 2 usage · 3 not-found · + * 4 policy/trust refusal · 5 confirmation required + */ +export class CliError extends Error { + readonly kind: string; + readonly exitCode: number; + readonly details?: Record; + + constructor( + kind: string, + message: string, + opts: { exitCode?: number; details?: Record } = {}, + ) { + super(message); + this.name = "CliError"; + this.kind = kind; + this.exitCode = opts.exitCode ?? exitCodeForKind(kind); + this.details = opts.details; + } +} + +const KIND_EXIT: Record = { + usage: 2, + "invalid-params": 2, + "not-found": 3, + "policy-denied": 4, + "trust-violation": 4, + "confirmation-required": 5, +}; + +const exitCodeForKind = (kind: string): number => KIND_EXIT[kind] ?? 1; + +/** Coerce any thrown value into a CliError for the top-level handler. */ +export const toCliError = (err: unknown): CliError => { + if (err instanceof CliError) return err; + const message = err instanceof Error ? err.message : String(err); + return new CliError("internal", message); +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cb0ff5c..fbe52d7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1 +1,25 @@ -export {}; +export * from "./api.js"; +export { parseArgs, flagBool, flagString, flagEnum } from "./args.js"; +export type { ParsedArgs } from "./args.js"; +export { CliError, toCliError } from "./errors.js"; +export { + callersFromToml, + defaultPolicy, + parseToml, + policyFromToml, + resolveExecDecision, +} from "./config.js"; +export type { Policy, PolicyValue, ServeCaller, TomlTable } from "./config.js"; +export { detectActor } from "./deps.js"; +export type { CliDeps } from "./deps.js"; +export { createLogger, emit, emitError, table } from "./output.js"; +export type { Logger, OutWriters } from "./output.js"; +export { authorize, STDIO_CALLER } from "./serve/authz.js"; +export { serveStdio } from "./serve/stdio.js"; +export { createNodePorts } from "./ports/index.js"; +export { createFsPort } from "./ports/fs.js"; +export { createExecPort } from "./ports/exec.js"; +export { parseAuditLog } from "./commands/audit.js"; +export { resolveStoreRoot } from "./root.js"; +export { createMockStore } from "./testing/mock-store.js"; +export type { MockStore } from "./testing/mock-store.js"; diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts new file mode 100644 index 0000000..2557568 --- /dev/null +++ b/packages/cli/src/output.ts @@ -0,0 +1,111 @@ +/** + * Output + logging surface. The scriptc island bans logtape-class deps and + * assumes nothing about TTY capability, so this is a console shim over two + * injected writers: + * + * - data goes to `out` (stdout): command results, JSON envelopes + * - diagnostics go to `err` (stderr): logs, prompts, warnings + * + * `harness serve` additionally must keep stdout protocol-clean (NDJSON + * only), so every diagnostic path lives here behind `err`. + */ +export interface OutWriters { + out: (text: string) => void; + err: (text: string) => void; +} + +export const stdoutWriters = (proc: { + stdout: { write(s: string): unknown }; + stderr: { write(s: string): unknown }; +}): OutWriters => ({ + out: (text) => { + proc.stdout.write(`${text}\n`); + }, + err: (text) => { + proc.stderr.write(`${text}\n`); + }, +}); + +/** Emit the command result: pretty JSON under --json, human text otherwise. */ +export const emit = ( + w: OutWriters, + result: unknown, + human: () => string, + json: boolean, +): void => { + if (json) { + w.out(JSON.stringify(result, null, 2)); + } else { + w.out(human()); + } +}; + +/** Emit an error: structured envelope under --json, plain line otherwise. */ +export const emitError = ( + w: OutWriters, + error: { kind: string; message: string; details?: Record }, + json: boolean, +): void => { + if (json) { + w.out( + JSON.stringify({ + error: { kind: error.kind, message: error.message, ...error.details }, + }), + ); + } else { + w.err(`error: ${error.message}`); + } +}; + +export type LogLevel = "debug" | "info" | "warn" | "error"; + +const LEVEL_RANK: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +/** Console shim — level-gated stderr logging (`ANYHARNESS_LOG` env). */ +export interface Logger { + debug(msg: string): void; + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; +} + +export const createLogger = ( + w: OutWriters, + level: LogLevel = "info", +): Logger => { + const write = + (at: LogLevel) => + (msg: string): void => { + if (LEVEL_RANK[at] >= LEVEL_RANK[level]) w.err(`[${at}] ${msg}`); + }; + return { + debug: write("debug"), + info: write("info"), + warn: write("warn"), + error: write("error"), + }; +}; + +/** Plain aligned table for human output (no terminal-columns dep). */ +export const table = (rows: string[][]): string => { + if (rows.length === 0) return ""; + const widths: number[] = []; + for (const row of rows) { + row.forEach((cell, i) => { + widths[i] = Math.max(widths[i] ?? 0, cell.length); + }); + } + return rows + .map((row) => + row + .map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]))) + .join(" ") + .trimEnd(), + ) + .join("\n"); +}; diff --git a/packages/cli/src/ports.test.ts b/packages/cli/src/ports.test.ts new file mode 100644 index 0000000..c7df6fb --- /dev/null +++ b/packages/cli/src/ports.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createExecPort } from "./ports/exec.js"; +import { createFsPort } from "./ports/fs.js"; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "harness-cli-ports-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("ExecPort", () => { + const exec = createExecPort(); + + it("exec runs one token + argv (no shell)", async () => { + const res = await exec.exec("echo", ["hello"]); + expect(res.code).toBe(0); + expect(res.stdout.trim()).toBe("hello"); + }); + + it("exec surfaces non-zero exits as results, not throws", async () => { + const res = await exec.exec("false"); + expect(res.code).not.toBe(0); + }); + + it("the git binary is reachable (island rule: git via exec)", async () => { + const res = await exec.exec("git", ["--version"]); + expect(res.code).toBe(0); + expect(res.stdout).toContain("git version"); + }); + + it("run honors cwd", async () => { + const res = await exec.run("pwd", { cwd: dir }); + expect(res.stdout.trim()).toBe(dir); + }); +}); + +describe("FsPort", () => { + const exec = createExecPort(); + const fs = createFsPort(exec); + + it("writes atomically and reads back", async () => { + const p = join(dir, "a", "b.txt"); + await fs.writeFile(p, "hello"); + expect(await fs.readFile(p)).toBe("hello"); + const s = await stat(p); + expect(s.isFile()).toBe(true); + }); + + it("appendFile appends", async () => { + const p = join(dir, "log.txt"); + await fs.appendFile(p, "one\n"); + await fs.appendFile(p, "two\n"); + expect(await fs.readFile(p)).toBe("one\ntwo\n"); + }); + + it("exists/stat/list/mkdir/remove", async () => { + const sub = join(dir, "pkg"); + expect(await fs.exists(sub)).toBe(false); + await fs.mkdir(sub); + await fs.writeFile(join(sub, "plugin.json"), "{}"); + expect(await fs.exists(sub)).toBe(true); + expect((await fs.stat(sub)).kind).toBe("directory"); + expect((await fs.stat(join(sub, "plugin.json"))).kind).toBe("file"); + expect(await fs.list(sub)).toEqual(["plugin.json"]); + await fs.remove(sub); + expect(await fs.exists(sub)).toBe(false); + }); + + it("symlink via ln creates a real link; copy is the fallback", async () => { + const target = join(dir, "target.txt"); + const link = join(dir, "link.txt"); + await fs.writeFile(target, "x"); + await fs.symlink!(target, link); + expect(await fs.readlink!(link)).toBe(target); + expect(await readFile(link, "utf8")).toBe("x"); + const copied = join(dir, "copied.txt"); + await fs.copy(target, copied); + expect(await fs.readFile(copied)).toBe("x"); + }); +}); diff --git a/packages/cli/src/ports/exec.ts b/packages/cli/src/ports/exec.ts new file mode 100644 index 0000000..fe86f22 --- /dev/null +++ b/packages/cli/src/ports/exec.ts @@ -0,0 +1,66 @@ +/** + * Node ExecPort — `node:child_process` under the island rules: `exec` + * spawns one executable token + argv with no shell (this is how git ops + * reach the `git` binary); `run` is the shell form for internally-built + * commands only. Non-zero exits surface as `{code, stderr}` results, + * never as thrown errors — a failing git probe is data, not a crash. + */ +import { execFile, exec as execShell } from "node:child_process"; +import { promisify } from "node:util"; + +import type { ExecOptions, ExecPort, ExecResult } from "../api.js"; + +const pExecFile = promisify(execFile); +const pExec = promisify(execShell); + +interface ChildError { + code?: number | string | null; + stdout?: string; + stderr?: string; +} + +const childOptions = (options: ExecOptions, maxBuffer: number) => ({ + cwd: options.cwd, + env: options.env ? { ...process.env, ...options.env } : undefined, + timeout: options.timeoutMs, + maxBuffer, +}); + +const settle = async ( + promise: Promise<{ stdout: string | Buffer; stderr: string | Buffer }>, +): Promise => { + try { + const { stdout, stderr } = await promise; + return { code: 0, stdout: String(stdout), stderr: String(stderr) }; + } catch (err) { + const e = err as ChildError; + return { + code: typeof e.code === "number" ? e.code : 1, + stdout: e.stdout ?? "", + stderr: e.stderr ?? String(err), + }; + } +}; + +export const createExecPort = (opts?: { + maxBuffer?: number; +}): ExecPort => { + const maxBuffer = opts?.maxBuffer ?? 16 * 1024 * 1024; + return { + exec: (file, args = [], options: ExecOptions = {}) => + settle( + pExecFile(file, args, childOptions(options, maxBuffer)) as Promise<{ + stdout: string; + stderr: string; + }>, + ), + + run: (command, options: ExecOptions = {}) => + settle( + pExec(command, childOptions(options, maxBuffer)) as Promise<{ + stdout: string; + stderr: string; + }>, + ), + }; +}; diff --git a/packages/cli/src/ports/fs.ts b/packages/cli/src/ports/fs.ts new file mode 100644 index 0000000..2a3ed3a --- /dev/null +++ b/packages/cli/src/ports/fs.ts @@ -0,0 +1,104 @@ +/** + * Node FsPort — the only fs implementation the cli injects into the sdk + * store. Writes are atomic (sibling temp + rename, store-layout.md §7.1); + * symlink goes through `ln` with a copy fallback per the scriptc-island + * rule (no `symlinkSync` — AGENTS.md §7). + */ +import { + appendFile as fsAppendFile, + copyFile, + cp, + lstat, + mkdir as fsMkdir, + readFile as fsReadFile, + readdir, + readlink as fsReadlink, + rename as fsRename, + rm, + stat as fsStat, + writeFile as fsWriteFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { randomBytes } from "node:crypto"; + +import type { ExecPort, FsPort } from "../api.js"; + +const kindOf = (mode: { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; +}): "file" | "directory" | "symlink" | "other" => { + if (mode.isSymbolicLink()) return "symlink"; + if (mode.isFile()) return "file"; + if (mode.isDirectory()) return "directory"; + return "other"; +}; + +export const createFsPort = (exec: ExecPort): FsPort => ({ + readFile: (path) => fsReadFile(path, "utf8"), + + readFileBytes: async (path) => new Uint8Array(await fsReadFile(path)), + + writeFile: async (path, data) => { + // Atomic write: sibling temp + rename (store-layout.md §7.1). + await fsMkdir(dirname(path), { recursive: true }); + const tmp = join( + dirname(path), + `.${randomBytes(6).toString("hex")}.tmp`, + ); + try { + await fsWriteFile(tmp, data); + await fsRename(tmp, path); + } catch (err) { + await rm(tmp, { force: true }).catch(() => undefined); + throw err; + } + }, + + appendFile: (path, data) => fsAppendFile(path, data, "utf8"), + + exists: async (path) => { + try { + await fsStat(path); + return true; + } catch { + return false; + } + }, + + stat: async (path) => { + const s = await lstat(path); + return { kind: kindOf(s), size: s.size, mtimeMs: s.mtimeMs }; + }, + + list: (path) => readdir(path), + + mkdir: async (path) => { + await fsMkdir(path, { recursive: true }); + }, + + remove: (path) => rm(path, { recursive: true, force: true }), + + rename: (from, to) => fsRename(from, to), + + symlink: async (target, path) => { + // `ln -s` via exec — no fs.symlink on the island. Failure (no `ln`, + // restricted fs) is left for the caller's copy fallback. + const res = await exec.exec("ln", ["-s", target, path]); + if (res.code !== 0) { + throw new Error(`ln -s failed (${res.code}): ${res.stderr.trim()}`); + } + }, + + readlink: (path) => fsReadlink(path), + + copy: async (from, to) => { + const s = await lstat(from); + if (s.isDirectory()) { + await cp(from, to, { recursive: true }); + } else { + await fsMkdir(dirname(to), { recursive: true }); + await copyFile(from, to); + } + }, +}); diff --git a/packages/cli/src/ports/index.ts b/packages/cli/src/ports/index.ts new file mode 100644 index 0000000..93b8e0b --- /dev/null +++ b/packages/cli/src/ports/index.ts @@ -0,0 +1,9 @@ +import type { StorePorts } from "../api.js"; +import { createExecPort } from "./exec.js"; +import { createFsPort } from "./fs.js"; + +/** Node-side `StorePorts` — fs first, exec second (fs.symlink uses `ln`). */ +export const createNodePorts = (): StorePorts => { + const exec = createExecPort(); + return { fs: createFsPort(exec), exec }; +}; diff --git a/packages/cli/src/root.ts b/packages/cli/src/root.ts new file mode 100644 index 0000000..5224e4a --- /dev/null +++ b/packages/cli/src/root.ts @@ -0,0 +1,15 @@ +/** + * Store-root resolution — store-layout.md §3.5. Precedence: + * `--root` flag > `ANYHARNESS_STORE` > `ANYHARNESS_HOME`/harness > + * `~/.agents/harness`. + */ +export const resolveStoreRoot = ( + env: Record, + home: string, + rootFlag?: string, +): string => { + if (rootFlag) return rootFlag; + if (env.ANYHARNESS_STORE) return env.ANYHARNESS_STORE; + const agentsRoot = env.ANYHARNESS_HOME ?? `${home}/.agents`; + return `${agentsRoot}/harness`; +}; diff --git a/packages/cli/src/sdk-bind.ts b/packages/cli/src/sdk-bind.ts new file mode 100644 index 0000000..6b9f050 --- /dev/null +++ b/packages/cli/src/sdk-bind.ts @@ -0,0 +1,34 @@ +/** + * The ONE file that imports `@any-harness/sdk`. + * + * W8 implements the sdk in parallel against the same pinned names + * (`api.ts`); while `packages/sdk` is still a stub (`export {}`) this + * module casts the namespace through `unknown` so typecheck stays green + * and every call site gets a clear runtime error instead of + * `undefined is not a function`. When the sdk lands, the cast is a no-op. + */ +import * as sdk from "@any-harness/sdk"; + +import type { SdkApi } from "./api.js"; +import { CliError } from "./errors.js"; + +const bound = sdk as unknown as Partial; + +const requireFn = (name: K): SdkApi[K] => { + const fn = bound[name] as unknown; + if (typeof fn !== "function") { + throw new CliError( + "sdk-unavailable", + `@any-harness/sdk does not export ${String(name)} yet — the sdk workstream (W8) has not merged; this is expected on the feat/cli branch`, + ); + } + return fn as SdkApi[K]; +}; + +/** Lazily resolves each pinned export; throws CliError("sdk-unavailable"). */ +export const sdkApi: SdkApi = { + createStore: (...args) => requireFn("createStore")(...args), + resolveSource: (...args) => requireFn("resolveSource")(...args), + handleBridgeRequest: (...args) => + requireFn("handleBridgeRequest")(...args), +}; diff --git a/packages/cli/src/serve.test.ts b/packages/cli/src/serve.test.ts new file mode 100644 index 0000000..944166d --- /dev/null +++ b/packages/cli/src/serve.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; + +import { defaultPolicy, type Policy } from "./config.js"; +import { authorize, STDIO_CALLER } from "./serve/authz.js"; +import { serveStdio, type StdioServeOptions } from "./serve/stdio.js"; +import { createLogger } from "./output.js"; +import { createMockStore } from "./testing/mock-store.js"; + +const lines = async function* (input: string[]): AsyncIterable { + for (const l of input) yield l; +}; + +const run = async ( + input: string[], + opts: { + callers?: Parameters[0]["callers"]; + policy?: Policy; + handle?: StdioServeOptions["handleRequest"]; + } = {}, +) => { + const written: string[] = []; + const errs: string[] = []; + await serveStdio({ + store: createMockStore(), + handleRequest: + opts.handle ?? + (async (_s, req) => ({ + jsonrpc: "2.0", + id: req.id ?? null, + result: { echoed: req.method }, + })), + callers: opts.callers ?? [], + callerName: STDIO_CALLER, + policy: opts.policy ?? defaultPolicy(), + log: createLogger({ out: () => {}, err: (s) => errs.push(s) }, "debug"), + lines: lines(input), + write: (l) => written.push(l), + }); + return { written: written.map((l) => JSON.parse(l)), errs }; +}; + +const req = (id: number, method: string, params = {}) => + JSON.stringify({ jsonrpc: "2.0", id, method, params }); + +describe("authorize", () => { + it("grants everything to an unscoped caller (stdio owner)", () => { + expect(authorize(undefined, "hooks.invoke")).toEqual({ ok: true }); + expect(authorize({ name: "owner" }, "tools.call")).toEqual({ ok: true }); + }); + + it("auto-grants negotiate and notify", () => { + const caller = { name: "x", allow: ["extensions.list"] }; + expect(authorize(caller, "capabilities.negotiate").ok).toBe(true); + expect(authorize(caller, "events.notify").ok).toBe(true); + }); + + it("deny wins over allow; * allows all", () => { + const caller = { + name: "x", + allow: ["*"], + deny: ["tools.call"], + }; + expect(authorize(caller, "tools.call")).toEqual({ + ok: false, + op: "tools.call", + }); + expect(authorize(caller, "extensions.list").ok).toBe(true); + }); + + it("scopes down sandboxed callers", () => { + const caller = { name: "guest", allow: ["extensions.list"] }; + expect(authorize(caller, "extensions.list").ok).toBe(true); + expect(authorize(caller, "skills.materialize")).toEqual({ + ok: false, + op: "skills.materialize", + }); + }); +}); + +describe("serveStdio", () => { + it("dispatches valid requests and writes responses", async () => { + const { written } = await run([req(1, "extensions.list")]); + expect(written).toEqual([ + { jsonrpc: "2.0", id: 1, result: { echoed: "extensions.list" } }, + ]); + }); + + it("answers malformed JSON with -32700", async () => { + const { written } = await run(["{not json"]); + expect(written[0].error.code).toBe(-32700); + expect(written[0].error.data.kind).toBe("parse-error"); + }); + + it("answers non-envelope input with -32600", async () => { + const { written } = await run([JSON.stringify({ id: 1, method: "x" })]); + expect(written[0].error.code).toBe(-32600); + }); + + it("enforces caller scope with -32012 before dispatch", async () => { + let dispatched = 0; + const { written } = await run([req(1, "hooks.invoke")], { + callers: [{ name: "owner", allow: ["extensions.list"] }], + handle: async (_s, r) => { + dispatched += 1; + return { jsonrpc: "2.0", id: r.id ?? null, result: {} }; + }, + }); + expect(dispatched).toBe(0); + expect(written[0].error.code).toBe(-32012); + expect(written[0].error.data).toMatchObject({ + kind: "forbidden", + op: "hooks.invoke", + }); + }); + + it("policy gate: hooks.invoke denied when exec.hooks=deny", async () => { + const { written } = await run([req(9, "hooks.invoke")], { + policy: { ...defaultPolicy(), execHooks: "deny" }, + }); + expect(written[0].error.code).toBe(-32007); + expect(written[0].error.data.policy).toBe("exec.hooks"); + }); + + it("policy gate: ask resolves to exec.nonInteractive (deny default)", async () => { + // exec.hooks = "ask" (default) + nonInteractive deny → -32007 + const { written } = await run([req(9, "hooks.invoke")]); + expect(written[0].error.code).toBe(-32007); + // …but allow lets it through to dispatch + const ok = await run([req(9, "tools.call")], { + policy: { ...defaultPolicy(), execMcp: "ask", execNonInteractive: "allow" }, + }); + expect(ok.written[0].result).toEqual({ echoed: "tools.call" }); + }); + + it("notifications are dispatched but never answered", async () => { + const seen: string[] = []; + const { written } = await run( + [JSON.stringify({ jsonrpc: "2.0", method: "events.notify", params: {} })], + { + handle: async (_s, r) => { + seen.push(r.method); + return { jsonrpc: "2.0", id: null, result: {} }; + }, + }, + ); + expect(seen).toEqual(["events.notify"]); + expect(written).toHaveLength(0); + }); + + it("handler throws surface as -32603 without killing the loop", async () => { + const { written } = await run( + [req(1, "extensions.list"), req(2, "extensions.list")], + { + handle: async (_s, r) => { + if (r.id === 1) throw new Error("boom"); + return { jsonrpc: "2.0", id: r.id ?? null, result: { ok: 1 } }; + }, + }, + ); + expect(written[0].error.code).toBe(-32603); + expect(written[1].result).toEqual({ ok: 1 }); + }); +}); diff --git a/packages/cli/src/serve/authz.ts b/packages/cli/src/serve/authz.ts new file mode 100644 index 0000000..6fbf886 --- /dev/null +++ b/packages/cli/src/serve/authz.ts @@ -0,0 +1,42 @@ +/** + * Per-caller op authorization — spec/bridge/transports.md §3. + * + * Enforcement is per request, before dispatch: + * - `capabilities.negotiate` and `events.notify` are auto-granted to + * every caller (§3.3 — negotiate can't be denied, request.cancelled + * rides the notify op) + * - caller's `deny` wins over `allow`; absent `allow` = full op set; + * `"*"` is legal in `allow` only + * - denied → `-32012` `forbidden` with `data.op` naming the method + * + * stdio identity is the implicit `"owner"` caller (§3.2): full op set — + * unless the operator has written an explicit `[[serve.caller]]` entry + * named `owner`, in which case its scope applies (a deliberate opt-in + * narrowing, not a default). + */ +import { ALWAYS_ALLOWED_METHODS } from "../api.js"; +import type { ServeCaller } from "../config.js"; + +export const STDIO_CALLER = "owner"; + +export type AuthzDecision = { ok: true } | { ok: false; op: string }; + +export const authorize = ( + caller: ServeCaller | undefined, + method: string, +): AuthzDecision => { + if (ALWAYS_ALLOWED_METHODS.has(method)) return { ok: true }; + if (caller === undefined) return { ok: true }; + if (caller.deny?.includes(method)) return { ok: false, op: method }; + if (caller.allow === undefined) return { ok: true }; + if (caller.allow.includes("*") || caller.allow.includes(method)) { + return { ok: true }; + } + return { ok: false, op: method }; +}; + +/** Look up a configured caller by name (audit/scope key). */ +export const callerByName = ( + callers: ServeCaller[], + name: string, +): ServeCaller | undefined => callers.find((c) => c.name === name); diff --git a/packages/cli/src/serve/stdio.ts b/packages/cli/src/serve/stdio.ts new file mode 100644 index 0000000..b4768ac --- /dev/null +++ b/packages/cli/src/serve/stdio.ts @@ -0,0 +1,171 @@ +/** + * stdio transport — spec/bridge/transports.md §1. + * + * Framing: newline-delimited JSON, one message per line on stdin/stdout; + * stderr is free-form diagnostics. stdin EOF = shutdown → exit 0. + * + * Per line: + * 1. JSON.parse — malformed → `-32700` parse-error response + * 2. envelope check — non-request → `-32600` invalid-request + * 3. authz — outside the caller's scope → `-32012` forbidden + * 4. exec-policy gate — trust.md §4.1 `ask`→`exec.nonInteractive` in this + * non-interactive daemon context → `-32007` policy-denied + * 5. dispatch → `handleBridgeRequest(store, req)` (sdk; owns handshake, + * method dispatch, and op semantics) + * + * Notifications (no `id`) are dispatched but never answered (protocol §2). + * Anything the handler throws surfaces as `-32603` internal — a panic must + * not kill the NDJSON loop. + */ +import type { + JsonRpcRequest, + JsonRpcResponse, + Store, +} from "../api.js"; +import type { Policy, ServeCaller } from "../config.js"; +import type { Logger } from "../output.js"; +import { authorize } from "./authz.js"; + +export interface StdioServeOptions { + store: Store; + handleRequest: (store: Store, req: JsonRpcRequest) => Promise; + /** Caller entries from `[[serve.caller]]`; resolved by callerName. */ + callers: ServeCaller[]; + /** Caller name this transport resolved ("owner" on stdio). */ + callerName: string; + policy: Policy; + log: Logger; + /** Input lines — one NDJSON message each. */ + lines: AsyncIterable; + /** Response sink — protocol messages only. */ + write: (line: string) => void; +} + +const respond = ( + write: (line: string) => void, + res: JsonRpcResponse, +): void => { + write(JSON.stringify(res)); +}; + +const errorRes = ( + id: string | number | null, + code: number, + kind: string, + message: string, + data?: Record, +): JsonRpcResponse => ({ + jsonrpc: "2.0", + id, + error: { code, message, data: { kind, ...data } }, +}); + +/** Ops that can drive executables — gated by the trust policy (§4.1). */ +const EXEC_GATED: Record> = { + "hooks.invoke": "execHooks", + "tools.call": "execMcp", +}; + +const policyGate = ( + method: string, + policy: Policy, +): { denied: false } | { denied: true; policyKey: string } => { + const key = EXEC_GATED[method]; + if (key === undefined) return { denied: false }; + const value = policy[key]; + if (value === "allow") return { denied: false }; + if (value === "deny") return { denied: true, policyKey: `exec.${key.slice(4).toLowerCase()}` }; + // "ask" with no TTY resolves to exec.nonInteractive (default deny). + if (policy.execNonInteractive === "allow") return { denied: false }; + return { denied: true, policyKey: `exec.${key.slice(4).toLowerCase()}` }; +}; + +const isNotification = (req: JsonRpcRequest): boolean => req.id === undefined; + +export const serveStdio = async (opts: StdioServeOptions): Promise => { + const caller = opts.callers.find((c) => c.name === opts.callerName); + + for await (const raw of opts.lines) { + const line = raw.trim(); + if (line === "") continue; + + let req: JsonRpcRequest; + try { + req = JSON.parse(line) as JsonRpcRequest; + } catch { + respond(opts.write, errorRes(null, -32700, "parse-error", "malformed JSON")); + continue; + } + + if ( + typeof req !== "object" || + req === null || + req.jsonrpc !== "2.0" || + typeof req.method !== "string" + ) { + respond( + opts.write, + errorRes(req?.id ?? null, -32600, "invalid-request", "not a JSON-RPC 2.0 envelope"), + ); + continue; + } + + const notification = isNotification(req); + + // Authorization — per request, before dispatch (transports.md §3.3). + const authz = authorize(caller, req.method); + if (!authz.ok) { + opts.log.warn( + `forbidden: caller "${opts.callerName}" attempted ${req.method}`, + ); + if (!notification) { + respond( + opts.write, + errorRes(req.id ?? null, -32012, "forbidden", "operation not permitted for this caller", { + op: req.method, + }), + ); + } + continue; + } + + // Trust-policy gate — `ask` cannot prompt through a bridge session; + // it resolves to exec.nonInteractive (deny by default) per trust.md §4.1. + const gate = policyGate(req.method, opts.policy); + if (gate.denied) { + if (!notification) { + respond( + opts.write, + errorRes( + req.id ?? null, + -32007, + "policy-denied", + `${req.method} refused by script policy (${gate.policyKey} → deny in non-interactive context)`, + { policy: gate.policyKey }, + ), + ); + } + continue; + } + + try { + const res = await opts.handleRequest(opts.store, req); + if (!notification) respond(opts.write, res); + } catch (err) { + opts.log.error( + `handler threw on ${req.method}: ${err instanceof Error ? err.message : String(err)}`, + ); + if (!notification) { + respond( + opts.write, + errorRes( + req.id ?? null, + -32603, + "internal", + err instanceof Error ? err.message : "internal error", + ), + ); + } + } + } +}; diff --git a/packages/cli/src/testing/deps.ts b/packages/cli/src/testing/deps.ts new file mode 100644 index 0000000..8212f8c --- /dev/null +++ b/packages/cli/src/testing/deps.ts @@ -0,0 +1,71 @@ +/** Test-side CliDeps factory — captures out/err, wires the mock store. */ +import type { CliDeps } from "../deps.js"; +import type { Store } from "../api.js"; +import { createLogger } from "../output.js"; +import { createMockStore, type MockStore } from "./mock-store.js"; +import { createMemoryFs } from "./memory-fs.js"; + +export interface TestDeps { + deps: CliDeps; + store: MockStore; + out: string[]; + err: string[]; + exitCode: number | undefined; +} + +export const createTestDeps = ( + opts: { + store?: Store; + interactive?: boolean; + actor?: CliDeps["actor"]; + confirm?: (q: string) => Promise; + env?: Record; + storeRoot?: string; + } = {}, +): TestDeps => { + const out: string[] = []; + const err: string[] = []; + const store = + opts.store !== undefined && "installs" in opts.store + ? (opts.store as MockStore) + : createMockStore(); + const state: { code: number | undefined } = { code: undefined }; + const w = { out: (s: string) => out.push(s), err: (s: string) => err.push(s) }; + const deps: CliDeps = { + store: opts.store ?? store, + resolveSource: (input) => { + if (input.startsWith("local:")) { + return { type: "local", uri: input.slice(6) }; + } + if (input.includes("/") && !input.includes("://")) { + return { type: "github", uri: input }; + } + return { type: "git", uri: input }; + }, + handleBridgeRequest: async (_store, req) => ({ + jsonrpc: "2.0", + id: req.id ?? null, + result: { echoed: req.method }, + }), + fs: createMemoryFs(), + w, + log: createLogger(w, "error"), + env: opts.env ?? {}, + storeRoot: opts.storeRoot ?? "/test/.agents/harness", + interactive: opts.interactive ?? false, + actor: opts.actor ?? "user", + confirm: opts.confirm, + setExitCode: (code) => { + state.code = code; + }, + }; + return { + deps, + store, + out, + err, + get exitCode() { + return state.code; + }, + }; +}; diff --git a/packages/cli/src/testing/memory-fs.ts b/packages/cli/src/testing/memory-fs.ts new file mode 100644 index 0000000..4240085 --- /dev/null +++ b/packages/cli/src/testing/memory-fs.ts @@ -0,0 +1,83 @@ +/** In-memory FsPort for tests — flat map of path → content. */ +import type { FsPort } from "../api.js"; + +export const createMemoryFs = ( + seed: Record = {}, +): FsPort & { files: Map } => { + const files = new Map(Object.entries(seed)); + const dirs = new Set(); + + const parentDirs = (path: string): string[] => { + const parts = path.split("/").filter(Boolean); + const out: string[] = []; + for (let i = 1; i < parts.length; i += 1) { + out.push(`/${parts.slice(0, i).join("/")}`); + } + return out; + }; + + const fs: FsPort & { files: Map } = { + files, + readFile: async (path) => { + const v = files.get(path); + if (v === undefined) throw new Error(`not found: ${path}`); + return v; + }, + readFileBytes: async (path) => { + const v = files.get(path); + if (v === undefined) throw new Error(`not found: ${path}`); + return new TextEncoder().encode(v); + }, + writeFile: async (path, data) => { + for (const d of parentDirs(path)) dirs.add(d); + files.set(path, typeof data === "string" ? data : new TextDecoder().decode(data)); + }, + appendFile: async (path, data) => { + files.set(path, (files.get(path) ?? "") + data); + }, + exists: async (path) => files.has(path) || dirs.has(path), + stat: async (path) => { + if (files.has(path)) { + return { kind: "file", size: files.get(path)!.length, mtimeMs: 0 }; + } + if (dirs.has(path)) return { kind: "directory", size: 0, mtimeMs: 0 }; + throw new Error(`not found: ${path}`); + }, + list: async (path) => { + const prefix = path.endsWith("/") ? path : `${path}/`; + const names = new Set(); + for (const key of files.keys()) { + if (key.startsWith(prefix)) { + names.add(key.slice(prefix.length).split("/")[0]); + } + } + return [...names]; + }, + mkdir: async (path) => { + dirs.add(path); + }, + remove: async (path) => { + files.delete(path); + dirs.delete(path); + const prefix = `${path}/`; + for (const key of [...files.keys()]) { + if (key.startsWith(prefix)) files.delete(key); + } + for (const d of [...dirs]) { + if (d.startsWith(prefix)) dirs.delete(d); + } + }, + rename: async (from, to) => { + const v = files.get(from); + if (v === undefined) throw new Error(`not found: ${from}`); + files.delete(from); + files.set(to, v); + }, + copy: async (from, to) => { + const v = files.get(from); + if (v === undefined) throw new Error(`not found: ${from}`); + files.set(to, v); + }, + }; + return fs; +}; diff --git a/packages/cli/src/testing/mock-store.ts b/packages/cli/src/testing/mock-store.ts new file mode 100644 index 0000000..e3979b0 --- /dev/null +++ b/packages/cli/src/testing/mock-store.ts @@ -0,0 +1,75 @@ +/** + * Hand-rolled in-memory Store for tests — implements the pinned Store + * surface (`api.ts`) without touching `@any-harness/sdk` (stub until W8). + * Not part of the shipped cli surface beyond tests; exported so sibling + * workstreams can reuse it for their own cli-side tests. + */ +import type { + DoctorReport, + Extension, + InstallOptions, + SourceRef, + Store, + VerifyResult, +} from "../api.js"; + +export interface MockStore extends Store { + /** Test inspection: lock-entry-shaped records keyed by extension name. */ + entries: Map; + /** Test inspection: install calls as received. */ + installs: { source: SourceRef; opts?: InstallOptions }[]; +} + +const idOf = (name: string, version: string): string => `${name}@${version}`; + +export const createMockStore = ( + seed: Extension[] = [], +): MockStore => { + const entries = new Map(); + for (const e of seed) entries.set(e.manifest.name, { extension: e, tampered: false }); + const installs: { source: SourceRef; opts?: InstallOptions }[] = []; + + const find = (name: string) => { + const entry = entries.get(name); + if (!entry) throw new Error(`extension not found: ${name}`); + return entry; + }; + + return { + entries, + installs, + list: async () => [...entries.values()].map((e) => e.extension), + install: async (source, opts) => { + installs.push({ source, ...(opts ? { opts } : {}) }); + const name = source.path?.split("/").pop() ?? source.uri.split("/").pop() ?? source.uri; + const extension: Extension = { + id: idOf(name, "1.0.0"), + kind: "plugin", + manifest: { name, version: "1.0.0" }, + enabled: true, + }; + entries.set(name, { extension, tampered: false }); + return extension; + }, + remove: async (name) => { + find(name); + entries.delete(name); + }, + setEnabled: async (name, enabled) => { + const entry = find(name); + entry.extension = { ...entry.extension, enabled }; + return entry.extension; + }, + materialize: async (name) => { + find(name); + return { materializedTo: [`~/.agents/skills/${name}`] }; + }, + verify: async (name): Promise => { + const entry = find(name); + return entry.tampered + ? { ok: false, expected: "sha256-aaa", actual: "sha256-bbb" } + : { ok: true, expected: "sha256-aaa", actual: "sha256-aaa" }; + }, + doctor: async (): Promise => ({ ok: true, findings: [] }), + }; +}; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index c2104f6..7c9df92 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "types": ["node"] }, "include": ["src/**/*"] } diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts new file mode 100644 index 0000000..ffb080e --- /dev/null +++ b/packages/cli/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/cli.ts"], + format: ["esm"], + // ".js" output (package is type:module) — ci.yml smoke-tests dist/cli.js. + outExtensions: () => ({ js: ".js", dts: ".d.ts" }), + // Workspace dep stays external — resolved through pnpm at runtime. + deps: { neverBundle: ["@any-harness/sdk"] }, + sourcemap: true, + clean: true, + minify: false, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51ca6fe..3426756 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: ai: specifier: ^7.0.0 version: 7.0.130 + tsdown: + specifier: ^0.22.3 + version: 0.22.14 tsx: specifier: ^4.22.4 version: 4.23.15 @@ -58,7 +61,17 @@ importers: version: 4.1.11(@types/node@22.20.5)(vite@8.3.3(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) packages/cli: + dependencies: + '@any-harness/sdk': + specifier: workspace:* + version: link:../sdk devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.20.5 + tsdown: + specifier: 'catalog:' + version: 0.22.14(tsx@4.23.15)(typescript@6.0.3) typescript: specifier: 'catalog:' version: 6.0.3 @@ -286,6 +299,9 @@ packages: '@oxc-project/types@0.152.0': resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + '@quansync/fs@1.1.0': + resolution: {integrity: sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==} + '@rolldown/binding-android-arm-eabi@1.2.12': resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -436,12 +452,175 @@ packages: '@workflow/serde@4.1.0': resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@yuku-codegen/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} + cpu: [arm64] + os: [android] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-/u+REDMI4a0/lsJXTM4c53/w31OGZLOReZIyg62uhgLs0kc8NHsj/nOcxTdlQjq5gi0zhdkccD9LaLTXcdzPvw==} + cpu: [arm64] + os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-sMMzFOwCo4WXR+/6zIBThOocSC50iIIZZdfiIDbaLvj0Ax/rWt/iavyfEAqajyvzydLyCqR/ZItdLWSRlu1umw==} + cpu: [x64] + os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-MpdpKXix9P+Y1rKgjvcNeNtGjXeL1CmttNhYINrWls8kRpm4xM/oBGTmn6w7to8lAlwj5jm8q03dQPl5mRv4Qw==} + cpu: [x64] + os: [freebsd] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-rr1srFLlPAmC1vtxfc9C1YLDe3iH09YjfSeeIidBqKhzx1MATjOAq4mjlRUOnhr/L27MotWIYOFKwVsd5JZFOg==} + cpu: [arm] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-eAufXh8qBRpiSO6ueaMDL+yyoXIGLhpUce72YbcACtZU2qhExwBIJyEtQf5kH2Ki0X2aqkSjSfog4OPtKXan3Q==} + cpu: [arm] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-gw4w6wPoHObBrdIC4duVWLmJOvpdE25j5D7yrM5mACNlK4klRz/lv8hK+ssQk9EJHBgZjSaqZIJVFgqNYbfv7A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-L68N6Y4XkqcIaKo3Ra88JEvBEH4AHff44A4INcrxeVWZ8CZtu2tCpfVxe3hR8qQQMcvBSQlDn24KpkCKEhVvfA==} + cpu: [arm64] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-dzyAbltJmf3Cqlb8HcFuYIf5Yn0fl1vTr3XJ9HiVNNvOlhqPSArOqtw9vI1p6/VTXTjrMLXlU+s+/kNHiIy/Cw==} + cpu: [x64] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-Otw4MH3404q0Bbvl+YTdW9aoUV5vXmUw8260bWvt1XlaoIX/ceSgI4ygheRDMfBPoHt6FDayQaO9OLVUZAgkFA==} + cpu: [x64] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-qo/jyrzryiBuKEsFiuWaBCBe3tRMynQ0qFWFgOEjcCMQeZfBm+wKiVEUEFXXLc7bh8YezguAWp0Mtnhq4ARNyA==} + cpu: [arm64] + os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-D5lDsVDx6m00E6bWySlWdH72Ca4TPSaphDqB6QjU6MpuNLIJqoGoatYyq2rOmBE8Zv/kunot/o58KGL03P3eiA==} + cpu: [x64] + os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-parser/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-eGKYiUDX7Y0V7tDTmg+JTVnXnjMqfXXsorZ+EDf5kxwchQ3Or1HS14MzI2fw+jFhHR85fCWt+mtX33Yao73hIQ==} + cpu: [arm64] + os: [android] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-Re0RHelKLnjEURulY2/KxW+Ngb8zuNA4BRZuMwgGQNzVumT6u4U2N2hc01oeYVNVof0i7GrXE4UCNBgbpRRnjQ==} + cpu: [arm64] + os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-Hn8DROtQkjlA1ACbPgj4a7eP9IuVOI504oiTwpkWPbpaDWD9KdmnVYCqW+1LfenNK/g7O9NhWGpXEdaCNX7lIA==} + cpu: [x64] + os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-bAP2OV8wRuzplX/jYxv9+vvqQT8JxyNphI8fLfXGL054Xs+4/J5u33cIm3y4rxY8rdoLmmdiJs2Tq7r7lrDRfA==} + cpu: [x64] + os: [freebsd] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-kTYwJQQgmZeAWdDIWabiReIZMpmfLueIj1tCmjStUtFGhR1Z0qwxonKVfUC4N7h/VhGGzLZ//7O1kgt1QKqgCg==} + cpu: [arm] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-uL4jE8HPT2BLlxAXyD10LqgPuXa9eDa0BKpCdSANmzIJghq/2eZo3/gQNtaxPZMupWoxjYzSad9IXrwu7aYPXQ==} + cpu: [arm] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-3gVN4pWSKZmXiNX7cU164dR9MPvesCHnlH6nPfpK+yQsCuYphjKKplcb4SnZBrhiqmXbgb2HR0c2TS0IZUPhgA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-S0mwfEjoLpxzXeZw802Wa4RaELsQiPtWqG6INcy8j4GtvNFtl4LCX3eGO1XLn9pyLAISLzTRyU3zUCBUPin8lg==} + cpu: [arm64] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-lnbWdPmerE5D1uH1G4IEZKnPzCrWCStRGrtgpSIe1RibAo5bZIjDbbbPYXmMHCEh4F+x/JaJpElh26a3r+BPbg==} + cpu: [x64] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-769uwndMvMzUvATWbAcEvyLHKA+DzhHSCl/obBUrRdYfRo26yxui6S8y3z7uJ+Naup7UKrDxrpK7OnQkxkl9KQ==} + cpu: [x64] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-mEB/9PlaAkisJ6KWGz0zvywXoU6+80dTlR2LwS7s/jcXXoU6fm2+sitBZXtqu3+Q4DcDgPxM45uWMCzPs0TSRw==} + cpu: [arm64] + os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-8vNB2DP0ou61nGb8tc/qfi41gfyDXz1MHr2zqL3nR+cJ6CEbiuWV/l/a/vv151gCgiZLLAyGkQGENpozdg716w==} + cpu: [x64] + os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-toolchain/types@0.8.7': + resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} + ai@7.0.130: resolution: {integrity: sha512-QkBkCogl46jXUg0SrSsdqQlOI2ZVMCjfnbBQS8t3gE21U/SO17oNMVFyjmcC9z20wOoYUA+2O2vsso0maoS7dw==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + ansis@4.4.0: + resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==} + engines: {node: '>=14'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -450,6 +629,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} @@ -477,6 +660,19 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -510,9 +706,20 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + hookable@6.1.2: + resolution: {integrity: sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==} + + import-without-cache@0.4.1: + resolution: {integrity: sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A==} + engines: {node: ^22.18.0 || >=24.0.0} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -640,6 +847,9 @@ packages: resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} engines: {node: ^10 || ^12 || >=14} + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} @@ -647,6 +857,28 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown-plugin-dts@0.27.14: + resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + rolldown@1.2.12: resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -680,6 +912,44 @@ packages: resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} engines: {node: '>=14.0.0'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tsdown@0.22.14: + resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.14 + '@tsdown/exe': 0.22.14 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + tsx@4.23.15: resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} engines: {node: '>=18.0.0'} @@ -693,6 +963,9 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -765,6 +1038,10 @@ packages: uploadthing: optional: true + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + vite@8.3.3: resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -854,6 +1131,15 @@ packages: engines: {node: '>=8'} hasBin: true + yuku-ast@0.8.7: + resolution: {integrity: sha512-h6+4bDfyootiMB9vckk5uKo5r5j0GHrkr17FQTDNfEsFT3DWlN9uu1HJwQwc64pgmLCI945fWM3lbTIqxjT3GQ==} + + yuku-codegen@0.8.7: + resolution: {integrity: sha512-adwDZSh8oVDzhE6Du9PwVWxcOxeV0e2EVhUuMKWfhSY4wkrDq9eqixlxFF3l/XGUV1E7UFzhpz9393MUumkyNw==} + + yuku-parser@0.8.7: + resolution: {integrity: sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ==} + zod@4.6.5: resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} @@ -967,6 +1253,10 @@ snapshots: '@oxc-project/types@0.152.0': {} + '@quansync/fs@1.1.0': + dependencies: + quansync: 1.0.0 + '@rolldown/binding-android-arm-eabi@1.2.12': optional: true @@ -1074,6 +1364,80 @@ snapshots: '@workflow/serde@4.1.0': {} + '@yuku-codegen/binding-android-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-x64@0.8.7': + optional: true + + '@yuku-parser/binding-android-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-win32-x64@0.8.7': + optional: true + + '@yuku-toolchain/types@0.8.7': {} + ai@7.0.130(zod@4.6.5): dependencies: '@ai-sdk/gateway': 4.0.106(zod@4.6.5) @@ -1081,6 +1445,8 @@ snapshots: '@ai-sdk/provider-utils': 5.0.56(zod@4.6.5) zod: 4.6.5 + ansis@4.4.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -1088,6 +1454,8 @@ snapshots: assertion-error@2.0.1: {} + cac@7.0.0: {} + chai@6.3.0: {} chokidar@5.0.0: @@ -1108,6 +1476,10 @@ snapshots: detect-libc@2.1.2: {} + dts-resolver@3.0.0: {} + + empathic@2.1.0: {} + es-module-lexer@2.3.2: {} esbuild@0.28.2: @@ -1154,6 +1526,10 @@ snapshots: fsevents@2.3.3: optional: true + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + h3@1.15.11: dependencies: cookie-es: 1.2.3 @@ -1166,6 +1542,10 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + hookable@6.1.2: {} + + import-without-cache@0.4.1: {} + iron-webcrypto@1.2.1: {} json-schema@0.4.0: {} @@ -1255,10 +1635,28 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 + quansync@1.0.0: {} + radix3@1.1.2: {} readdirp@5.1.1: {} + resolve-pkg-maps@1.0.0: {} + + rolldown-plugin-dts@0.27.14(rolldown@1.2.12)(typescript@6.0.3): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.2.1 + rolldown: 1.2.12 + yuku-ast: 0.8.7 + yuku-codegen: 0.8.7 + yuku-parser: 0.8.7 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - oxc-resolver + rolldown@1.2.12: dependencies: '@oxc-project/types': 0.152.0 @@ -1299,6 +1697,34 @@ snapshots: tinyrainbow@3.2.0: {} + tree-kill@1.2.2: {} + + tsdown@0.22.14(tsx@4.23.15)(typescript@6.0.3): + dependencies: + ansis: 4.4.0 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.1.0 + hookable: 6.1.2 + import-without-cache: 0.4.1 + obug: 2.2.1 + picomatch: 4.0.7 + rolldown: 1.2.12 + rolldown-plugin-dts: 0.27.14(rolldown@1.2.12)(typescript@6.0.3) + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.3.2 + optionalDependencies: + tsx: 4.23.15 + typescript: 6.0.3 + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - oxc-resolver + - vue-tsc + tsx@4.23.15: dependencies: esbuild: 0.28.2 @@ -1309,6 +1735,11 @@ snapshots: ufo@1.6.4: {} + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.1.0 + quansync: 1.0.0 + uncrypto@0.1.3: {} undici-types@6.21.0: {} @@ -1326,6 +1757,8 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.4 + verkit@0.3.2: {} + vite@8.3.3(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15): dependencies: lightningcss: 1.33.0 @@ -1371,4 +1804,43 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + yuku-ast@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + + yuku-codegen@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-x64': 0.8.7 + '@yuku-codegen/binding-freebsd-x64': 0.8.7 + '@yuku-codegen/binding-linux-arm-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm-musl': 0.8.7 + '@yuku-codegen/binding-linux-arm64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm64-musl': 0.8.7 + '@yuku-codegen/binding-linux-x64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-x64-musl': 0.8.7 + '@yuku-codegen/binding-win32-arm64': 0.8.7 + '@yuku-codegen/binding-win32-x64': 0.8.7 + + yuku-parser@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + yuku-ast: 0.8.7 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.8.7 + '@yuku-parser/binding-darwin-arm64': 0.8.7 + '@yuku-parser/binding-darwin-x64': 0.8.7 + '@yuku-parser/binding-freebsd-x64': 0.8.7 + '@yuku-parser/binding-linux-arm-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm-musl': 0.8.7 + '@yuku-parser/binding-linux-arm64-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm64-musl': 0.8.7 + '@yuku-parser/binding-linux-x64-gnu': 0.8.7 + '@yuku-parser/binding-linux-x64-musl': 0.8.7 + '@yuku-parser/binding-win32-arm64': 0.8.7 + '@yuku-parser/binding-win32-x64': 0.8.7 + zod@4.6.5: {}