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
5 changes: 5 additions & 0 deletions .changeset/library-entry-point.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"balade": minor
---

Add a library entry point: `import { generate, check, build } from "balade"` runs the three commands from a script or a CI job without a terminal. `generate` takes the command's options plus an explicit `model` and `onProgress`; it never prompts — an unresolved model, a missing credential or an existing same-head walkthrough without `force: true` rejects with a tagged error whose `message` is the sentence the command prints. `check` returns the report, `build` the outcome. The published package now carries type declarations and an `exports` map.
70 changes: 62 additions & 8 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ one folder per CLI verb under `commands/`, the concept folders — `walkthrough/
`authoring/` (the versioned authoring package: typed data plus its renderings),
`pi/` (the Pi adapter and agent sessions), `agent/` (shared provider/model
configuration), `server/` (live session runtime) — and the root files `cli.ts`,
`shell.ts`, `state.ts`, `terminal.ts`, `failure.ts`, `presence.ts`, `submission.ts`.
`library.ts`, `shell.ts`, `state.ts`, `terminal.ts`, `failure.ts`, `presence.ts`,
`submission.ts`.

All imports flow one direction; peers never import each other:

```
cli.ts entry + layer wiring
cli.ts library.ts entries + layer wiring — the executable and the package export
commands/ server/ orchestrators — the ONLY places product concepts compose
agent/ → pi, presence, terminal
pi/ → authoring, git (type imports), contract, shell
Expand All @@ -33,9 +34,11 @@ shell.ts state.ts terminal.ts failure.ts presence.ts submission.ts
1. `Command.make` appears only in `commands/<verb>/index.ts` (plus the root
`balade` command in `cli.ts`). `ls src/commands` **is** the CLI surface.
2. A file lives in `commands/<verb>/` only if that verb is its sole importer.
Nothing outside `commands/` may import from `commands/` (except `cli.ts`).
The review lifecycle shared by `open` and a successful generation therefore
lives in `server/review.ts`, not under either verb.
Nothing outside `commands/` may import from `commands/` (except the two
entries, `cli.ts` and `library.ts`). The review lifecycle shared by `open`
and a successful generation therefore lives in `server/review.ts`, not
under either verb. Nothing imports an entry: importing `cli.ts` would run
it, importing `library.ts` would wire a second service stack.
3. `walkthrough/`, `git/`, `preset/` are autonomous: they import only
`contract/` and root ports (`walkthrough/` may additionally import
`preset/` — the tag catalog is an extension of the format). Concepts compose
Expand All @@ -58,8 +61,56 @@ translate them, and `contract/` must import nothing internal, in that order.

Enforced two ways: oxlint's `import/no-cycle` (import plugin, `.oxlintrc.json`)
rejects file cycles, and `test/architecture.test.ts` walks the real `src/`
import graph and asserts the rules above. What would move this: a second
renderer or a published API, which would force `contract/` to version.
import graph and asserts the rules above. The published API (`library.ts`,
below) did not version `contract/`: the package's own 0.x version is the API
version, and `src/contract/types.ts` reaches consumers only as re-exported
types. What would still move this: a second renderer.

## The library entry composes the command pipelines without a terminal

Decided on [#153](https://github.com/basaltbytes/balade/issues/153).
`src/library.ts` is the package's `exports["."]`: `generate`, `check` and
`build` as promises, each with an Effect-returning variant
(`generateWalkthrough`, `checkWalkthrough`, `buildWalkthrough`) and one
`liveLayer` — Node services, the process executor, the Pi author adapter and
the live context resolver; no terminal, browser or agent presence. The layer
is provided per call rather than held in a `ManagedRuntime`, so a finished
call leaves no handle open and a script exits on its own; the CLI never had
to care because `NodeRuntime.runMain` exits the process.

The paid pipeline is shared: `runGeneration`, `checkOne` and `runBuild` are
the same functions the commands run, and `GenerationProgress` reaches
`onProgress` unfiltered — the terminal renderer is one consumer of those
events. What the library does not share is the command's *interactive*
pre-flight, and the CLI is therefore not a literal wrapper over `generate()`:
the replace prompt sits between inspecting existing walkthroughs and the paid
turn, and the model picker between the plan and the run. Both pre-flights are
composed from the same functions (`parsePrTarget`, `resolvePullHead`,
`inspectExistingWalkthroughs`, `planSupersession`); the library answers the
two questions with typed errors instead — `ExistingWalkthroughUndecided`
naming the files (`force: true` replaces, keeping the superseded copy), and
model resolution through `resolveAgentModel` in `agent/model.ts`, which
matches an explicit `{ providerId, modelId }` or the saved preference and
fails `AgentModelUnresolved` (listing what is available) or
`NoProviderAuthenticated`. It never logs in and never rewrites the preference:
a CI job naming a model must not become the user's default. Local checks fail
before the pull request head is fetched.

A rejected promise carries the tagged error itself — `_tag`, fields,
`instanceof` — and the library attaches the sentence the CLI would print as
its `message` at the boundary (`withMessage`), because the error classes are
shared with the CLI, whose messages live at *its* boundary, and `message` is
what every promise consumer reads. The result is the CLI's `GenerationResult`
unchanged; the pull-request `notices` the command prints as warnings ride in
it, so a script sees a degraded `gh` the way an operator does.

The build emits declarations (`tsconfig.build.json`, `declaration: true`),
which forced one explicit return type in `src/pi/inspection.ts`: Pi's tool
definitions carry typebox parameter types that a `.d.ts` cannot name
portably. The package smoke test type-checks and runs a consumer against the
packed tarball. What would move this: an Effect caller needing services
beyond `liveLayer`, or a `--progress json` flag, which would consume the same
events from the CLI side.

## The payload contract is Effect Schema

Expand Down Expand Up @@ -1092,7 +1143,10 @@ preview keeps the source package version instead of using `--previewVersion`:
the executable reports a build-time version synced by the release flow, while
the preview is selected by its pkg.pr.new URL and does not enter a dependency
range or project lockfile. What would move this: publishing a library API whose
preview must participate in dependency resolution.
preview must participate in dependency resolution. [#153](https://github.com/basaltbytes/balade/issues/153)
published that API (`exports["."]`), so the trigger now exists; the workflow
stays as it is until a preview actually needs to enter a consumer's lockfile,
which a `pnpm dlx`/`npx` trial of the preview does not.

## Mermaid draws the logic; the sink does not trust mermaid

Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,39 @@ balade; if the installed skill is stale, `check` reports the version mismatch.

Use `--out <dir>` for another skill layout (other coding agent harnesses). The npm package also includes the rendered skill under `dist/skill/`.

## Library

The package exports the three commands as functions, so a script or a CI job
calls them instead of spawning the executable and parsing its output:

```ts
import { build, check, generate } from "balade";

const result = await generate({
repository: "/path/to/clone", // defaults to the working directory
pullRequest: 96, // a number, "#96", or the PR URL
model: { providerId: "openai-codex", modelId: "gpt-5.4" },
onProgress: (event) => console.log(event._tag),
});
// result.file, result.report, result.usage, result.repairs, result.timing,
// result.superseded, result.siblings, result.notices

const report = await check(result.file); // the report `check --json` prints
const outcome = await build(result.file, { out: "review.html" });
```

`generate` takes the same options as the command: `preset`, `lang`,
`guidance`, `budget`, `directory`, `force` and `headInstructions`
(`"omit-changed"` by default; `"trust-changed"` is the flag's opt-in). `model`
is optional: without it, the preference saved by `balade agent setup` applies.

Nothing on this path prompts. A model that isn't authenticated, an existing
walkthrough for the same head without `force: true`, or a pull request that
can't be resolved rejects the promise with a tagged error — `error._tag` names
the case, its fields carry the details, and `error.message` is the sentence
the command would have printed. `onProgress` receives the events the command
renders, in order.

## CI

This workflow validates walkthroughs changed by a pull request:
Expand Down
8 changes: 5 additions & 3 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ PR head. An instruction file changed by the PR is omitted and reported unless
the reviewer passes `--trust-head-instructions` after inspecting it. Files that
contain a project-context closing tag are rejected regardless of that flag
(`src/pi/authoring.ts`, `src/pi/project-context.ts`; see
[#61](https://github.com/basaltbytes/balade/issues/61)).
[#61](https://github.com/basaltbytes/balade/issues/61)). The library entry
(`src/library.ts`) keeps the same default: `headInstructions` is
`"omit-changed"` unless the caller writes `"trust-changed"`.

Linked issues are fetched with the reviewer's own GitHub token. Same-repository
issues stay under author-stated intent; cross-repository issues remain available
Expand Down Expand Up @@ -385,8 +387,8 @@ they describe.
provenance is classified. A malformed location drops the optional GitHub
enrichment with a notice instead of becoming a guessed third-party label.
- Generation admits changed PR-head `AGENTS.md` and `CLAUDE.md` files only when
explicitly trusted with `--trust-head-instructions`; clarification always
omits them. Project-context closing tags are rejected before interpolation in
explicitly trusted with `--trust-head-instructions` (the library's
`headInstructions: "trust-changed"`); clarification always omits them. Project-context closing tags are rejected before interpolation in
both workflows.
- The snapshot is `git archive <pin>` with lexical, symlink and realpath
containment (`src/pi/snapshot.ts:135-172`, tested in
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@
"dist"
],
"type": "module",
"exports": {
".": {
"types": "./dist/library.d.ts",
"default": "./dist/library.js"
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public",
"provenance": true
Expand Down
58 changes: 58 additions & 0 deletions scripts/npm-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,64 @@ grep -Fqi "Not inside a git repository" "$GENERATE_STDERR"
# Zero-arg check outside a git repository reports and exits 0.
"$BIN" check | grep -qi "nothing to check"

# The package is also a library: the three calls type-check and run from a
# consumer project, rejections are the tagged errors with a sentence attached,
# and a finished call leaves nothing keeping the process alive.
cat >"$PROJECT/consumer.ts" <<'EOF_CONSUMER'
import { build, check, generate, type GenerationResult } from "balade";

export async function walkthrough(pullRequest: number): Promise<GenerationResult> {
const result = await generate({
pullRequest,
model: { providerId: "openai-codex", modelId: "gpt-5.4" },
headInstructions: "omit-changed",
onProgress: (event) => console.log(event._tag),
});
await check(result.file);
await build(result.file, { out: "review.html" });
return result;
}
EOF_CONSUMER
cat >"$PROJECT/tsconfig.json" <<'EOF_TSCONFIG'
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2023",
"strict": true,
"exactOptionalPropertyTypes": true,
"noEmit": true,
"skipLibCheck": true,
"types": []
},
"files": ["consumer.ts"]
}
EOF_TSCONFIG
"$ROOT/node_modules/.bin/tsc" -p "$PROJECT/tsconfig.json"
cat >"$PROJECT/consumer.mjs" <<'EOF_RUNTIME'
import { build, check, generate } from "balade";

const rejection = (promise) => promise.then(() => null, (error) => error);
const invalid = await rejection(generate({ pullRequest: "not-a-pull-request" }));
if (invalid?._tag !== "PullTargetInvalid" || !invalid.message.includes("pull request")) {
throw new Error(`generate did not reject typed: ${String(invalid)}`);
}
const report = await check("missing.md");
if (report.ok !== false || report.diagnostics.length === 0) {
throw new Error("check did not report the missing file");
}
const unreadable = await rejection(build("missing.md", { out: "missing.html" }));
if (unreadable?._tag !== "WalkthroughFileReadFailed" || !unreadable.message.includes("could not read")) {
throw new Error(`build did not reject typed: ${String(unreadable)}`);
}
setTimeout(() => {
console.error("the library left a handle open after its calls settled");
process.exit(1);
}, 15_000).unref();
console.log("library smoke passed");
EOF_RUNTIME
node "$PROJECT/consumer.mjs" | grep -q "library smoke passed"

# A real install writes the shared convention; .claude/ only once it exists.
SKILL_REPO="$TMP_ROOT/skill-repo"
mkdir -p "$SKILL_REPO"
Expand Down
87 changes: 87 additions & 0 deletions src/agent/model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** Provider/model lifecycle shared by generation, live Q&A, setup and logout. */

import { Context, Effect, Option, Schema, Semaphore } from "effect";
import { sanitizeTerminalText } from "../terminal.js";
import {
AuthorModel as AuthorModelSchema,
type AuthorLoginMethod,
Expand All @@ -26,6 +27,15 @@ export class AgentModelSelectionCancelled extends Schema.TaggedErrorClass<AgentM
{},
) {}

/**
* Authenticated models exist, but none is the one named. `available` lists
* what a caller could name instead.
*/
export class AgentModelUnresolved extends Schema.TaggedErrorClass<AgentModelUnresolved>()(
"AgentModelUnresolved",
{ requested: Schema.String, available: Schema.Array(AuthorModelSchema) },
) {}

export class AgentModelReady extends Schema.TaggedClass<AgentModelReady>()("AgentModelReady", {
model: AuthorModelSchema,
}) {}
Expand All @@ -46,6 +56,12 @@ export type ModelSelection =
| { readonly _tag: "UsePreference" }
| { readonly _tag: "Choose"; readonly filter: ModelFilter };

/** A provider and model named outright — what a script passes; nothing partial, nothing to pick from. */
export interface ExplicitModel {
readonly providerId: string;
readonly modelId: string;
}

export type AgentModelNotice =
| { readonly _tag: "SetupRequired" }
| { readonly _tag: "PreferenceReadFailed" }
Expand Down Expand Up @@ -74,6 +90,13 @@ export type AgentModelConfigurationError =
| NoProviderAuthenticated
| AgentModelSelectionCancelled;

/** The non-interactive resolution's failures: nothing here can be answered by a prompt. */
export type AgentModelResolutionError =
| AuthorDiscoveryFailed
| AuthorPreferenceReadFailed
| NoProviderAuthenticated
| AgentModelUnresolved;

export type AgentLogoutError = AuthorCredentialReadFailed | AuthorLogoutFailed;
export type AgentModelError = AgentModelConfigurationError | AgentLogoutError;

Expand Down Expand Up @@ -172,6 +195,31 @@ export const readAgentModelState = Effect.fn("readAgentModelState")(function* (
: new AgentModelSetupRequired();
});

/**
* The resolution a script gets: an explicit model matches one authenticated
* model or fails naming the available ones; absent, the saved preference
* stands or fails the same way. Nothing here prompts, logs in or rewrites the
* preference — that workflow is `configure`, behind a terminal.
*/
export const resolveAgentModel = Effect.fn("resolveAgentModel")(function* (
author: WalkthroughAuthorPort,
requested: Option.Option<ExplicitModel>,
) {
const available = yield* author.availableModels;
const wanted = Option.match(requested, {
onNone: () => "the saved model preference",
onSome: (model) => `${model.providerId}/${model.modelId}`,
});
if (available.length === 0) return yield* new NoProviderAuthenticated({ requested: wanted });
const selected = Option.isSome(requested)
? Option.fromNullishOr(matchingModels(available, requested.value)[0])
: preferredModel(available, yield* author.modelPreference);
if (Option.isNone(selected)) {
return yield* new AgentModelUnresolved({ requested: wanted, available });
}
return selected.value;
});

export const makeAgentModelManager = Effect.fn("makeAgentModelManager")(function* (
author: WalkthroughAuthorPort,
interaction: AgentModelInteraction,
Expand Down Expand Up @@ -301,3 +349,42 @@ function loginRank(method: AuthorLoginMethod): number {
function requestedModel(filter: ModelFilter): string {
return `${filter.providerId ?? "any provider"}/${filter.modelId ?? "any model"}`;
}

/** The sentences every boundary — terminal or promise — prints for these failures. */
export function noProviderMessage(requested: string): string {
return (
`No authenticated agent model matches ${requested}. ` +
"Run `balade agent setup` interactively to authenticate and choose one."
);
}

export function loginErrorMessage(error: LoginFailed): string {
switch (error.reason) {
case "oauth":
return `The ${error.provider} subscription login did not complete. Retry \`balade agent setup\`.`;
case "auth":
return `The ${error.provider} credential was rejected. Check the account or API key and retry \`balade agent setup\`.`;
case "provider":
return `The ${error.provider} provider could not start. Check its configuration and retry \`balade agent setup\`.`;
case "unknown":
return `The ${error.provider} provider could not authenticate. Retry \`balade agent setup\`.`;
}
}

export function agentModelErrorMessage(error: AgentModelError): string {
switch (error._tag) {
case "AuthorDiscoveryFailed":
return "Agent providers and models could not be loaded. Check the installation and try again.";
case "LoginFailed":
return loginErrorMessage(error);
case "LoginCancelled":
case "AgentModelSelectionCancelled":
return "Agent setup cancelled.";
case "NoProviderAuthenticated":
return noProviderMessage(error.requested);
case "AuthorCredentialReadFailed":
return "Stored agent logins could not be read. Check ~/.balade/pi/auth.json and try again.";
case "AuthorLogoutFailed":
return `The stored ${sanitizeTerminalText(error.provider)} login could not be removed. Check ~/.balade/pi/auth.json and try again.`;
}
}
Loading
Loading