Skip to content
Open
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
8 changes: 7 additions & 1 deletion .github/workflows/cli-release-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:
- ".github/workflows/release.yml"
- ".github/scripts/openprose-smoke/**"
- "scripts/release-preflight.sh"
- "scripts/pr-preflight.sh"
- "scripts/bump-version.sh"
- ".version-bump.json"
push:
Expand All @@ -25,6 +26,7 @@ on:
- ".github/workflows/release.yml"
- ".github/scripts/openprose-smoke/**"
- "scripts/release-preflight.sh"
- "scripts/pr-preflight.sh"
- "scripts/bump-version.sh"
- ".version-bump.json"
workflow_dispatch:
Expand Down Expand Up @@ -66,9 +68,13 @@ jobs:
- name: Check harness smoke helper syntax
run: node --check tools/cli/scripts/smoke-harness.mjs

- name: Check audit policy helper syntax
run: node --check tools/cli/scripts/audit-policy.mjs

- name: Check release shell syntax
run: |
bash -n scripts/bump-version.sh
bash -n scripts/pr-preflight.sh
bash -n scripts/release-preflight.sh

- name: OpenProse versions in sync
Expand Down Expand Up @@ -283,4 +289,4 @@ jobs:
run: npm ci

- name: Audit production dependencies
run: npm audit --omit=dev
run: npm run audit:policy
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ jobs:
run: npm run build

- name: Audit production dependencies
run: npm audit --omit=dev
run: npm run audit:policy

- name: Dry-run npm publish
run: npm publish --dry-run
Expand Down
10 changes: 10 additions & 0 deletions scripts/pr-preflight.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

cd "$repo_root"
git diff --check

cd "$repo_root/tools/cli"
npm run ci:pr
36 changes: 36 additions & 0 deletions tools/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,42 @@ prose doctor --harness claude-sdk --install
- `SIGINT` and `SIGTERM` are propagated through the active harness.
- Arguments after `--` are forwarded literally, including `--harness`.

### Startup input prompting

For `prose run <local-file.prose.md>`, the CLI deterministically reads the
file-level `### Requires` section before invoking the selected harness. Missing
caller inputs declared as backtick-wrapped bullets are prompted for when stdin
is an interactive terminal:

```markdown
### Requires

- `project`: project name
- `audience`: who the run is for
```

```bash
prose run demo.prose.md
```

The same inputs can be supplied up front:

```bash
prose run demo.prose.md --project "OpenProse" --audience "contributors"
prose run demo.prose.md --project=OpenProse --audience=contributors
```

Prompted values are forwarded to the harness as ordinary caller input flags.
The CLI does not write run state for startup prompting.

In non-interactive contexts, missing caller inputs fail before harness
invocation instead of hanging. Use `--no-prompt` to require explicit inputs even
in an interactive terminal:

```bash
prose run demo.prose.md --no-prompt --project "OpenProse"
```

The tarball installer is intentionally a Node.js installer: the CLI package is
JavaScript, so the installed shim executes Node.js 18 or newer. The script
verifies release checksums by default, rejects unsafe tar paths, symlinks,
Expand Down
12 changes: 12 additions & 0 deletions tools/cli/audit-policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": 1,
"allowedAdvisories": [
{
"id": "GHSA-v2v4-37r5-5v8g",
"package": "ip-address",
"severity": "moderate",
"reason": "Transitive dependency through @anthropic-ai/claude-agent-sdk/@modelcontextprotocol/sdk. Keep PR CI deterministic while upstream publishes a non-breaking fix.",
"expires": "2026-06-30"
}
]
}
2 changes: 2 additions & 0 deletions tools/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
"build": "npm run clean && tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest --run",
"audit:policy": "node scripts/audit-policy.mjs",
"ci:pr": "npm test && npm run typecheck && npm run build && npm run audit:policy",
"smoke:harness": "node scripts/smoke-harness.mjs",
"dev": "tsx src/index.ts",
"clean": "rm -rf dist",
Expand Down
102 changes: 102 additions & 0 deletions tools/cli/scripts/audit-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const scriptDir = dirname(fileURLToPath(import.meta.url));
const cliDir = dirname(scriptDir);
const policyPath = join(cliDir, "audit-policy.json");
const policy = JSON.parse(readFileSync(policyPath, "utf8"));

const audit = spawnSync("npm", ["audit", "--omit=dev", "--json"], {
cwd: cliDir,
encoding: "utf8",
});

if (audit.error) {
throw audit.error;
}

let report;
try {
report = JSON.parse(audit.stdout);
} catch (error) {
process.stderr.write(audit.stderr);
process.stderr.write(audit.stdout);
throw new Error(`Failed to parse npm audit JSON: ${error.message}`);
}

const today = new Date().toISOString().slice(0, 10);
const allowances = new Map(
(policy.allowedAdvisories ?? []).map((entry) => [`${entry.package}:${entry.id}`, entry]),
);

const findings = extractFindings(report);
const failures = [];
const allowed = [];

for (const finding of findings) {
const allowance = allowances.get(`${finding.packageName}:${finding.id}`);
if (!allowance) {
failures.push(`${finding.packageName} ${finding.id} ${finding.severity}: ${finding.title}`);
continue;
}
if (allowance.severity !== finding.severity) {
failures.push(
`${finding.packageName} ${finding.id} severity changed from ${allowance.severity} to ${finding.severity}`,
);
continue;
}
if (allowance.expires < today) {
failures.push(`${finding.packageName} ${finding.id} allowance expired on ${allowance.expires}`);
continue;
}
allowed.push(`${finding.packageName} ${finding.id} allowed until ${allowance.expires}`);
}

if (failures.length > 0) {
process.stderr.write("Production dependency audit failed policy:\n");
for (const failure of failures) {
process.stderr.write(`- ${failure}\n`);
}
process.exitCode = 1;
} else {
const count = findings.length;
process.stdout.write(`Production dependency audit passed policy (${count} advisory finding${count === 1 ? "" : "s"}).\n`);
for (const entry of allowed) {
process.stdout.write(`- ${entry}\n`);
}
}

function extractFindings(report) {
const findings = [];
const seen = new Set();

for (const vulnerability of Object.values(report.vulnerabilities ?? {})) {
for (const via of vulnerability.via ?? []) {
if (!via || typeof via !== "object") continue;
const id = advisoryId(via);
const packageName = via.name ?? vulnerability.name;
const key = `${packageName}:${id}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({
id,
packageName,
severity: via.severity ?? vulnerability.severity,
title: via.title ?? "(no title)",
});
}
}

return findings.sort((left, right) => `${left.packageName}:${left.id}`.localeCompare(`${right.packageName}:${right.id}`));
}

function advisoryId(via) {
if (typeof via.url === "string") {
const match = via.url.match(/GHSA-[A-Za-z0-9-]+/);
if (match) return match[0];
}
return String(via.source ?? via.title ?? "unknown-advisory");
}
15 changes: 14 additions & 1 deletion tools/cli/src/commands/base.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Command } from "@oclif/core";
import type { CommandName } from "../prose/index.js";
import { canonicalPrompt, CommandModelError, usageFor } from "../prose/index.js";
import { resolveStartupInputs, type PromptInputLike, type StartupInputReader } from "../prose/startup-inputs.js";
import { createHarness, type HarnessName } from "../harnesses/index.js";
import type { Harness, WritableStreamLike } from "../harnesses/types.js";
import { ensureOpenProseSkill, loadOpenProseSkillBootstrap, type OpenProseSkillBootstrap } from "../skills/open-prose.js";
Expand All @@ -23,8 +24,10 @@ export interface ForwardRunOptions {
env: Readonly<Record<string, string | undefined>>;
stdout: WritableStreamLike;
stderr: WritableStreamLike;
stdin?: PromptInputLike;
signal?: AbortSignal;
harnessFactory?: (name: string) => Harness;
startupInputReader?: StartupInputReader;
skillBootstrap?: SkillBootstrapLoader | false;
skillPreflight?: SkillPreflight | false;
}
Expand Down Expand Up @@ -52,6 +55,7 @@ export abstract class ProseForwardCommand extends Command {
env: process.env,
stdout: process.stdout,
stderr: process.stderr,
stdin: process.stdin,
signal: controller.signal,
});
if (exitCode !== 0) {
Expand Down Expand Up @@ -88,7 +92,16 @@ function isOclifExit(error: unknown): boolean {

export async function runForwardedProseCommand(options: ForwardRunOptions): Promise<number> {
const { harness, args } = splitHarnessArgs(options.argv, options.env, options.command);
const prompt = canonicalPrompt(options.command, args);
canonicalPrompt(options.command, args);
const startupInputs = await resolveStartupInputs({
command: options.command,
args,
cwd: options.cwd,
stderr: options.stderr,
...(options.stdin === undefined ? {} : { stdin: options.stdin }),
...(options.startupInputReader === undefined ? {} : { inputReader: options.startupInputReader }),
});
const prompt = canonicalPrompt(options.command, startupInputs.args);
if (shouldRunSkillPreflight(options)) {
await runSkillPreflight(harness, options);
}
Expand Down
16 changes: 15 additions & 1 deletion tools/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,21 @@ import { fileURLToPath } from "node:url";
import { normalizeEntrypointArgv } from "./commands/base.js";

export { normalizeEntrypointArgv, runForwardedProseCommand, splitHarnessArgs } from "./commands/base.js";
export { supportedCommands, canonicalPrompt, CommandModelError, usageFor } from "./prose/index.js";
export {
CommandModelError,
canonicalPrompt,
parseRunCallerInputArgs,
readCallerInterface,
resolveStartupInputs,
supportedCommands,
usageFor,
type CallerInterfaceInput,
type PromptInputLike,
type ResolveStartupInputsOptions,
type ResolveStartupInputsResult,
type StartupInputPromptRequest,
type StartupInputReader,
} from "./prose/index.js";
export {
ATTACHED_OPENPROSE_ROOT_PATH,
OPENPROSE_JUDGE_SOURCE_PATH,
Expand Down
11 changes: 11 additions & 0 deletions tools/cli/src/prose/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ export {
usageFor,
type CommandName,
} from "./command-model.js";
export {
parseRunCallerInputArgs,
readCallerInterface,
resolveStartupInputs,
type CallerInterfaceInput,
type PromptInputLike,
type ResolveStartupInputsOptions,
type ResolveStartupInputsResult,
type StartupInputPromptRequest,
type StartupInputReader,
} from "./startup-inputs.js";
export {
ATTACHED_OPENPROSE_ROOT_PATH,
USER_OPENPROSE_ROOT_PATH,
Expand Down
23 changes: 22 additions & 1 deletion tools/cli/src/prose/openprose-root.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { stat } from "node:fs/promises";
import { homedir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { dirname, relative, resolve, sep } from "node:path";

export const ATTACHED_OPENPROSE_ROOT_PATH = ".agents/prose";
export const USER_OPENPROSE_ROOT_PATH = "~/.agents/prose";
const TEMP_ROOTS = uniquePaths([tmpdir(), "/tmp", "/private/tmp"]);

export type OpenProseRootMode = "native" | "attached" | "user";

Expand Down Expand Up @@ -117,10 +118,14 @@ async function findEnclosingAttachedRoot(cwd: string): Promise<string | undefine
}

async function findNativeRepositoryRoot(cwd: string): Promise<string | undefined> {
const tempBoundary = findTempWorkspaceBoundary(cwd);
for (let current = resolve(cwd); ; current = dirname(current)) {
if ((await pathExists(resolve(current, "prose.lock"))) || (await pathExists(resolve(current, ".git")))) {
return current;
}
if (tempBoundary !== undefined && isSamePath(current, tempBoundary)) {
return undefined;
}
if (dirname(current) === current) {
return undefined;
}
Expand All @@ -135,3 +140,19 @@ function isSameOrInside(path: string, parent: string): boolean {
function isSamePath(left: string, right: string): boolean {
return resolve(left) === resolve(right);
}

function findTempWorkspaceBoundary(cwd: string): string | undefined {
const absoluteCwd = resolve(cwd);
for (const tempRoot of TEMP_ROOTS) {
if (!isSameOrInside(absoluteCwd, tempRoot)) {
continue;
}
const [firstSegment] = relative(tempRoot, absoluteCwd).split(sep).filter(Boolean);
return firstSegment === undefined ? tempRoot : resolve(tempRoot, firstSegment);
}
return undefined;
}

function uniquePaths(paths: string[]): string[] {
return [...new Set(paths.map((path) => resolve(path)))];
}
Loading
Loading