<!-- source: openspec/current/cli-core.md -->

---
title: cli-core
audience: [architect, executor, reviewer]
status: stable
owner: pavel
tldr: Shared library for the CLI orchestra — events, LLM wrapping, lineage, context, auth, and citty conventions. One import; all three CLIs.
---

# cli-core

> Shared library for the Rapoport Studio CLI orchestra. Used by `forge`, `muse`, and `maestro`.

---

## Status

**Stable. MVP shipped 2026-05-12 from change `bootstrap-cli-orchestra-mvp` Phase 1.**

`packages/cli-core/` (`@rapoport-studio/cli-core`) is the substrate package that all CLI tools in the studio import. It does not ship a binary. It is a leaf: it imports nothing internal.

---

## Where cli-core lives

`packages/cli-core/` at the monorepo root. Published internally as `@rapoport-studio/cli-core`.

The rule-of-three trigger for this extraction: `forge`, `muse`, and `maestro` all need structured event emission, LLM call wrapping, and repo-context resolution. Rather than duplicate, `cli-core` is the shared extract.

No `bin` entry — it is a library, not a CLI. It is consumed by:

- `@rapoport-studio/forge` — `cli-core/events` for build-lifecycle events (see [`forge/entities.md § Event emission`](forge/entities.md))
- `@rapoport-studio/muse` — `cli-core/llm`, `cli-core/events`, `cli-core/context`
- Maestro (currently `packages/muse/bin/maestro/`) — `cli-core/events`, `cli-core/lineage`

---

## Architecture

```
packages/cli-core/src/
events/
  kinds.ts        EventKind constants
  schema.ts       Zod schema for event envelope + per-kind payloads
  emit.ts         Append-write with proper-lockfile
llm/
  client.ts       callClaude()
  router.ts       pickModelForRole()
lineage/
  reader.ts       readLineage()
context/
  bundle.ts       Re-export from generated openspec-bundle
  repo.ts         getRepoRoot(), getCurrentLinearIssue()
auth/
  infisical.ts    getInfisicalSecret()
cli/
  runtime.ts      defineCommand()
  capabilities.ts emitCapabilities()
index.ts          Re-exports key symbols
```

Six top-level subpaths map to `package.json` `exports` entries. Each subpath is independently importable — consumers import only what they need.

---

## API surface

| Subpath | Export(s) | Description |
|---|---|---|
| `@rapoport-studio/cli-core/events` | `emit`, `EventKind`, event types | Append-write events to `~/.rapoport/events/YYYY-MM-DD.jsonl` |
| `@rapoport-studio/cli-core/llm` | `callClaude`, `pickModelForRole` | Wraps Vercel AI SDK; emits `MODEL_CALL`; **scrubs known-secret patterns from `system` + each `message.content` before transmission via `@rapoport-studio/cli-core/security`** (third rule-of-three consumer; AT-07 mitigation); emits `prompt.secret_redaction` event when matches occur; returns `{ text, tokens, costUSD }` |
| `@rapoport-studio/cli-core/lineage` | `readLineage` | Reads event files; filters by `linearId`; returns chronological list |
| `@rapoport-studio/cli-core/context` | `getRepoRoot`, `getCurrentLinearIssue` | Filesystem + git context resolution |
| `@rapoport-studio/cli-core/auth` | `getInfisicalSecret` | Wraps Infisical CLI; validates agent path |
| `@rapoport-studio/cli-core/cli` | `defineCommand` | citty wrapper with auto `--help`, `--version`, `--capabilities` |

### `callClaude(options)`

```typescript
callClaude({
  role: 'architect' | 'planner' | 'auditor',
  system: string,
  messages: CoreMessage[],
  schema?: ZodSchema,
}): Promise<{ text: string; tokens: { input: number; output: number }; costUSD: number }>
```

Emits a `MODEL_CALL` event on every call. Model is resolved via `pickModelForRole(role)`. See § Model routing.

### `emit(kind, payload, meta)`

```typescript
emit(
  kind: EventKind,
  payload: Record<string, unknown>,
  meta: { cli: string; linearId?: string; runId: string }
): Promise<void>
```

Appends a JSON line to `~/.rapoport/events/YYYY-MM-DD.jsonl`. Uses `proper-lockfile` to prevent concurrent write corruption.

### `readLineage(linearId, options?)`

```typescript
readLineage(
  linearId: string,
  options?: { since?: Date; cli?: string }
): Promise<StudioEvent[]>
```

Reads all event files in `~/.rapoport/events/` since `options.since` (default: 30 days). Filters by `linearId`. Returns chronological list.

---

## Event schema (v0)

Each event in `~/.rapoport/events/YYYY-MM-DD.jsonl` is a newline-delimited JSON object:

```json
{
  "schemaVersion": 1,
  "ts": "2026-05-12T10:00:00.000Z",
  "runId": "abc-123",
  "cli": "forge",
  "kind": "run.started",
  "linearId": "RAP-456",
  "payload": {}
}
```

**`EventKind` constants and wire values:**

| Constant | Wire value | Payload fields | Description |
|---|---|---|---|
| `RUN_STARTED` | `run.started` | — | CLI run begins |
| `RUN_FINISHED` | `run.finished` | `outcome`, `durationMs`, `totalCostUSD` | CLI run ends |
| `MODEL_CALL` | `model.call` | `model`, `role`, `inputTokens`, `outputTokens`, `costUSD` | LLM call |
| `SPEC_DRAFTED` | `spec.drafted` | `slug`, `path` | Muse wrote a change triplet |
| `PR_OPENED` | `pr.opened` | `url`, `branch` | Forge opened a PR |

`schemaVersion: 1` from day 0. Breaking schema changes bump the version; `readLineage` parses by version.

---

## Model routing

`pickModelForRole(role)` maps roles to Anthropic model IDs:

| Role | Model |
|---|---|
| `'architect'` | `claude-opus-4-8` |
| `'planner'` | `claude-sonnet-4-6` |
| `'auditor'` | `claude-sonnet-4-6` |

Per-agent model bindings are authoritative at [`openspec/_methodology/tools/agent-model-mapping.md § Per-agent default + fallback`](../_methodology/tools/agent-model-mapping.md). The role→model mapping above is the cli-core runtime expression of those bindings.

`cli-core`'s `architect | planner | auditor` roles are this library's own vocabulary for `callClaude`; they are **not** the same set as Forge's per-role model tiering (`architect | builder | reviewer`, see [`forge/spec.md § resolveModel`](./forge/spec.md)). `cli-core` is the substrate event/LLM library; Forge layers its own role-tiering on top. Neither is wrong — they are different layers.

---

## Boundaries

Access scope declared as an explicit allowlist following [`openspec/_methodology/agent-access-scope.md`](../_methodology/agent-access-scope.md). Anything not declared is denied. Future tool additions are denied by default unless explicitly added to the allowlist below.

`@rapoport-studio/cli-core` is a leaf: **it imports nothing internal**. It is consumed by other packages; it does not consume them.

**What cli-core may access:**

| Resource | Read | Write | Notes |
|---|---|---|---|
| `~/.rapoport/events/` | yes | yes (append only) | Each CLI writes events prefixed by its `cli` field; no cross-CLI deletion |
| `openspec/` | yes (bundle re-export only) | no | Via `context/bundle.ts` re-export from `apps/studio/src/generated/openspec-bundle.ts` |
| Filesystem (repo root) | yes (git metadata only) | no | `getRepoRoot()` via `git rev-parse`; `getCurrentLinearIssue()` via branch name parse |
| Infisical CLI | yes (`agentPath`-scoped secrets only) | no | `getInfisicalSecret()` validates caller-declared `agentPath`; rejects mismatched paths |
| Anthropic API | yes (via Vercel AI SDK) | yes (token spend) | `callClaude()` route only; no direct HTTP calls outside the wrapper |

**Why this list, not more:** cli-core is a shared substrate. It intentionally avoids GitHub, Linear, Supabase, and Cloudflare access — those are higher-level concerns owned by the consuming CLIs. Keeping cli-core access minimal reduces the blast radius of any confused-deputy vulnerability (per `bootstrap-cli-orchestra-mvp` proposal.md § Constraints and the Meta Sev-1 March 2026 reference).

---

## Test coverage

Vitest suite at `packages/cli-core/`. Run with `pnpm --filter @rapoport-studio/cli-core test`.

| Test | What it covers |
|---|---|
| Events append/read roundtrip | `emit()` writes a valid JSON line; `readLineage()` reads it back |
| Lineage filtering | `readLineage(linearId)` returns only matching events |
| Schema parse | Zod schema rejects malformed events; accepts valid ones |
| Capability JSON shape | `emitCapabilities()` returns parseable JSON with expected keys |

Known gap: no tests for `callClaude()` integration (requires live Anthropic key). Unit tests mock the Vercel AI SDK.

---

## Related capabilities

- [`openspec/current/forge/README.md`](forge/README.md) — Forge consumes `cli-core/events` for build-lifecycle event emission; see `forge/entities.md § Event emission` for call sites.
- [`openspec/current/muse.md`](muse.md) — Muse consumes `cli-core/llm`, `cli-core/events`, `cli-core/context` for all LLM calls and event emission.
- [`openspec/current/agents/maestro.md`](agents/maestro.md) — Maestro (via `packages/muse/bin/maestro/`) consumes `cli-core/events` and `cli-core/lineage`.
- [`openspec/_methodology/tools/agent-model-mapping.md`](../_methodology/tools/agent-model-mapping.md) — authoritative per-agent model bindings; cli-core's `pickModelForRole` is the runtime expression of these bindings.
- [`openspec/_methodology/agent-access-scope.md`](../_methodology/agent-access-scope.md) — access scope methodology; filled-in tables for Forge, Muse, and Maestro live there; cli-core's own boundary is declared in § Boundaries above.

---

## Conventions

1. **Import via subpath, not from index.** Use `@rapoport-studio/cli-core/events` not `@rapoport-studio/cli-core`. Each subpath is independently importable; importing the index adds all subpath dependencies.

2. **`runId` is per-CLI-invocation, not per-event.** Generate one UUID at process start; pass it to every `emit()` call in that run. `readLineage` groups by `runId` to reconstruct a run's event stream.

3. **`cli` field is the binary name string.** Pass `'forge'`, `'muse'`, or `'maestro'` — lowercase, no version suffix. This is the primary partition key for `maestro metrics`.

4. **Event emission is append-only. Never delete events.** Events are audit log entries; deleting them breaks lineage reconstruction. If a run must be voided, emit a `RUN_FINISHED` with `outcome: 'cancelled'`.

5. **Model routing is declarative.** Call `pickModelForRole(role)` instead of hardcoding model IDs. The mapping is a single source of truth; hardcoded model strings bypass it and create drift.

6. **Per-CLI Infisical paths.** Every CLI has its own secret path (`/forge/*`, `/muse/*`, `/maestro/*`). The `agentPath` parameter in `getInfisicalSecret()` is validated. Cross-agent secret reads are rejected. See `bootstrap-cli-orchestra-mvp` proposal.md § Constraints for the confused-deputy rationale.

---

## Bootstrap CLI Orchestra MVP (applied 2026-05-12 from change `bootstrap-cli-orchestra-mvp`)

Phase 1 of `bootstrap-cli-orchestra-mvp` created `packages/cli-core/` with the minimum surface used by Forge, Muse, and Maestro. The sections above reflect the state as shipped. Archive pointer: [`openspec/archive/2026-05-12-bootstrap-cli-orchestra-mvp/`](../archive/2026-05-12-bootstrap-cli-orchestra-mvp/).

---

## Security primitives (applied 2026-05-26 from change `add-muse-prompt-secret-redaction`)

New subpath `@rapoport-studio/cli-core/security` hosts shared security primitives consumed by Forge, Muse, and Maestro. Initial surface:

- `SCRUB_PATTERNS` — seven regex entries (Anthropic / GitHub PAT / GitHub OAuth / GitHub server / Linear / JWT / long hex). `D-FPSR-1` carry-over: floor, not ceiling — extend by amendment to this table.
- `scrubString(text)` / `scrubSystem(stringOrBlocks)` — pure helpers returning the redacted value + per-pattern match counts.
- `scrubAnthropicMessageCreateParams(params)` — operates on the `Anthropic.MessageCreateParams` shape; touches `system` (string or `SystemBlockLike[]`) and each `messages[].content` (string or `ContentBlock[]` — only `text` variants scrubbed; `image`/`tool_use`/`tool_result` pass through).
- `wrapAnthropicWithSecretScrub(client, opts)` — Proxy factory that intercepts only `messages.create` and `messages.stream` on the wrapped client; everything else (`models`, future SDK surfaces) falls through. On scrub hits, emits one `prompt.secret_redaction` event with pattern + count — never the value (`D-FPSR-2`).

**Rule-of-three lift rationale.** Three consumers triggered the lift: Forge (shipped a local copy in `packages/forge/src/core/secret-scrub.ts`, RAP-1167-era / AT-07 ship); Muse (consumed via the wrap in `shared/client.ts`); cli-core's own `llm/client.ts § callClaude` (wired 2026-05-26 — see *cli-core/llm scrub wire-in* below). Hosting in cli-core avoids the per-package mirror drift that hit `packages/muse/src/shared/models.ts` opus alias (closed by PR #1781). Pattern matches the `@rapoport-studio/cli-core/orchestra` precedent (PR #1140 P4 lifted `db_journal.ts` out of `packages/muse/src/modes/maestro/`).

**cli-core/llm scrub wire-in (applied 2026-05-26).** `callClaude` (Vercel AI SDK path) now scrubs `opts.system` + each `opts.messages[].content` via `scrubString` before invoking `generateText` / `generateObject`. The AI-SDK params shape uses plain string content, so the shape-aware `scrubAnthropicMessageCreateParams` helper isn't needed here — `scrubString` is sufficient. Redactions aggregate into one `prompt.secret_redaction` event per call. Closes the rule-of-three story: every Anthropic-bound surface in the studio (Forge `callClaude`, Muse `createClient` chokepoint, apps/studio `createScrubbedAnthropic` chokepoint, cli-core/llm `callClaude` here) goes through the same pattern table. Test coverage in `packages/cli-core/src/llm/__tests__/client.test.ts` — five new cases covering system + per-message scrub, event aggregation, clean-path no-emit, and `generateObject` path.

**Package boundary update.** Muse may now import `@rapoport-studio/cli-core/security` in addition to the existing `/orchestra` and `/context` subpaths — extends the muse-→-cli-core carve-out already documented in CLAUDE.md § Package boundaries.

**`@anthropic-ai/sdk` is a peer dependency** (`peerDependenciesMeta.optional: true`) — cli-core type-checks the wrap against the Anthropic types but does not bundle the SDK; consumers bring their own version. Matches the existing AI-SDK-not-Anthropic-SDK boundary.

Source-of-truth: [`openspec/archive/2026-05-26-add-muse-prompt-secret-redaction/`](../archive/2026-05-26-add-muse-prompt-secret-redaction/).
