Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 65 additions & 188 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,194 +1,67 @@
/**
* Pinned cross-workstream API surface for `@any-harness/sdk`.
* Cli-facing view of the `@any-harness/sdk` surface.
*
* 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.
* Wave-2 reconciliation: W8 landed the canonical types in `packages/sdk`
* — this file now re-exports them type-only (erased at compile time, so
* `sdk-bind.ts` stays the ONE runtime importer). Anything the cli needs
* that the sdk does not export stays defined here.
*
* Bridge wire types (`Extension`, `ExtensionKind`, `ManifestRef`) are pinned
* verbatim by `spec/bridge/operations.md` §1.
* Bridge wire types (`Extension`, `ExtensionKind`, `ManifestRef`) are
* pinned verbatim by `spec/bridge/operations.md` §1 — the sdk owns them.
*/

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: "<name>@<version>" — 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<Extension[]>;
install(source: SourceRef, opts?: InstallOptions): Promise<Extension>;
remove(name: string): Promise<void>;
setEnabled(name: string, enabled: boolean): Promise<Extension>;
materialize(name: string): Promise<{ materializedTo: string[] }>;
verify(name: string): Promise<VerifyResult>;
doctor(): Promise<DoctorReport>;
}

/**
* 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<string>;
/** Raw bytes for digest computation (spec/lockfile.md §4). */
readFileBytes(path: string): Promise<Uint8Array>;
/** 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<void>;
/** Append UTF-8 bytes (audit.log `O_APPEND` semantics). */
appendFile(path: string, data: string): Promise<void>;
exists(path: string): Promise<boolean>;
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<string[]>;
/** `mkdir -p` semantics; no error when already present. */
mkdir(path: string): Promise<void>;
/** Recursive remove; no error when absent. */
remove(path: string): Promise<void>;
rename(from: string, to: string): Promise<void>;
/**
* 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<void>;
readlink?(path: string): Promise<string>;
/** Recursive copy of a file or directory tree (symlink fallback path). */
copy(from: string, to: string): Promise<void>;
}

export interface ExecResult {
code: number;
stdout: string;
stderr: string;
}

export interface ExecOptions {
cwd?: string;
env?: Record<string, string>;
/** 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<ExecResult>;
run(command: string, opts?: ExecOptions): Promise<ExecResult>;
}

/** 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<string, unknown>;
}

export interface JsonRpcErrorBody {
code: number;
message: string;
data?: Record<string, unknown>;
}

export interface JsonRpcResponse {
jsonrpc: "2.0";
id: string | number | null;
result?: unknown;
error?: JsonRpcErrorBody;
}
// Re-exported for cli consumers (type-only — erased, so sdk-bind stays
// the single runtime importer).
export type {
Actor,
ApproveExec,
AuditEvent,
AuditRecord,
DoctorFinding,
DoctorReport,
ExecOptions,
ExecPort,
ExecResult,
Extension,
ExtensionKind,
FsDirent,
FsPort,
FsStat,
InstallOptions,
InstallResult,
JsonRpcError,
JsonRpcRequest,
JsonRpcResponse,
ListOptions,
ManifestRef,
MaterializeOptions,
MaterializeResult,
SourceRef,
Store,
StoreEntry,
StoreNotification,
StoreOptions,
StorePaths,
StorePorts,
TrustPolicy,
VerifyResult,
} from "@any-harness/sdk";

// Local aliases for use in this file's own declarations (re-exports do
// not bind names in-module).
import type {
JsonRpcRequest,
JsonRpcResponse,
SourceRef,
Store,
StoreOptions,
StorePorts,
} from "@any-harness/sdk";

/** Alias kept for the cli's envelope terminology (sdk name: JsonRpcError). */
export type { JsonRpcError as JsonRpcErrorBody } from "@any-harness/sdk";

/* ── cli-local constants (pinned by spec/bridge/operations.md) ──────── */

/** Pinned bridge ops (spec/bridge/operations.md). */
export const BRIDGE_METHODS = [
Expand All @@ -210,9 +83,13 @@ export const ALWAYS_ALLOWED_METHODS: ReadonlySet<string> = new Set([
"events.notify",
]);

/** The full pinned sdk surface the cli binds to (see sdk-bind.ts). */
/** The pinned sdk surface the cli binds to (see sdk-bind.ts). */
export interface SdkApi {
createStore(root: string, ports: StorePorts): Store;
createStore(
root: string,
ports: StorePorts,
options?: StoreOptions,
): Store;
resolveSource(input: string): SourceRef;
handleBridgeRequest(
store: Store,
Expand Down
28 changes: 20 additions & 8 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ import { createLogger, emitError, stdoutWriters } from "./output.js";
import { createNodePorts } from "./ports/index.js";
import { resolveStoreRoot } from "./root.js";
import { sdkApi } from "./sdk-bind.js";
import type { ApproveExec } from "./api.js";

const USAGE = `harness — AnyHarness package manager + bridge server

usage:
harness add <source> [--skill-target shared|store] [-y] [--json]
harness add <source> [--skill-target shared|store] [--update] [-y] [--json]
harness remove <name> [-y] [--json]
harness list [--all] [--kinds skill,mcp] [--json]
harness enable <name> [--json]
Expand All @@ -38,8 +39,8 @@ usage:
harness serve [--transport stdio]

global flags:
--root <dir> store root (default: $ANYHARNESS_STORE,
$ANYHARNESS_HOME/harness, or ~/.agents/harness)
--root <dir> agents root (default: $ANYHARNESS_STORE,
$ANYHARNESS_HOME, or ~/.agents)
--json machine-readable output on stdout
-h, --help this text
--version print version
Expand Down Expand Up @@ -139,8 +140,22 @@ const main = async (): Promise<void> => {
process.env.HOME ?? "",
flagString(args.flags, "root"),
);
const actor =
command === "serve"
? ("daemon" as const)
: detectActor(process.env, interactive);
const deps: CliDeps = {
store: sdkApi.createStore(storeRoot, ports),
store: sdkApi.createStore(storeRoot, ports, {
actor,
approveExec: interactive
? async (req: Parameters<ApproveExec>[0]) => {
const ask = ttyConfirm((s) => w.err(s));
return ask(
`allow ${req.execClass} exec from ${req.extension.name}@${req.extension.version}: ${req.command} ${req.args.join(" ")} (${req.reason})?`,
);
}
: undefined,
}),
resolveSource: sdkApi.resolveSource,
handleBridgeRequest: sdkApi.handleBridgeRequest,
fs: ports.fs,
Expand All @@ -149,10 +164,7 @@ const main = async (): Promise<void> => {
env: process.env,
storeRoot,
interactive,
actor:
command === "serve"
? "daemon"
: detectActor(process.env, interactive),
actor,
confirm: interactive ? ttyConfirm((s) => w.err(s)) : undefined,
setExitCode: (code) => {
process.exitCode = code;
Expand Down
29 changes: 14 additions & 15 deletions packages/cli/src/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ 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";
Expand Down Expand Up @@ -42,7 +41,8 @@ describe("harness add", () => {
parseArgs(["acme/tools", "-y", "--skill-target", "store"]),
);
expect(t.store.installs[0].opts).toEqual({
skillTarget: "store",
installTarget: "packages",
update: undefined,
actor: "agent",
});
});
Expand Down Expand Up @@ -177,26 +177,25 @@ describe("harness doctor", () => {
});

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;
const EVENTS = [
{ ts: "t1", event: "install", actor: "user", extension: { name: "a", version: "1" } },
{ ts: "t2", event: "exec.deny", actor: "agent", extension: { name: "b", version: "2" }, decision: "deny" },
] as const;

it("reads audit records via the store, skipping partial lines", async () => {
const store = createMockStore();
store.audit.push(...EVENTS);
const t = createTestDeps({ store });
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;
const store = createMockStore();
store.audit.push(...EVENTS);
const t = createTestDeps({ store });
await cmdAudit(t.deps, parseArgs(["--event", "exec.deny", "--json"]));
const out = JSON.parse(t.out[0]);
expect(out.events).toHaveLength(1);
Expand Down
Loading
Loading