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
44 changes: 40 additions & 4 deletions conformance/harness/src/toolkits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,38 @@ interface ToolkitSpec {
/** react-node's real build needs `npm install` of a published adapter, so its
* gate is checked with --no-build (Toolkit class validates the scaffold). */
noBuild?: boolean;
/**
* This blueprint scaffolds a STARTING POINT, not a finished app, so `validate`
* is expected to REFUSE a fresh scaffold and name the spec as unauthored —
* that refusal is the blueprint working, not failing.
*
* Asserting `validate` passes here would demand a spec for an app that does
* not exist yet, and the only way to satisfy it would be to write a fictional
* one. The check is inverted instead, so the suite states the real contract
* rather than a wish: a starting point is not a finished app, and `validate`
* saying so on every app that is not done is the whole point of `validate`.
*
* The string says WHY for each, because "incomplete" is a claim that should
* age badly if someone finishes the blueprint and forgets this line.
*/
incompleteBecause?: string;
}

const TOOLKITS: ToolkitSpec[] = [
{ id: "blueprint-base" },
// Framework files and no runtime code at all: no entities, no operations, a
// pipeline of placeholder echoes. The author picks the stack.
{ id: "blueprint-base", incompleteBecause: "ships framework files only, no runtime code" },
// The one finished starter: a to-do app with a View, a model and operations.
{ id: "blueprint-react-node", noBuild: true },
{ id: "blueprint-python-fastapi", noBuild: true },
{ id: "blueprint-pocketbase-react", noBuild: true },
// Ships adapter hooks only. Its `tasks` collection is created by hand in
// PocketBase, and `install` prints "download the pocketbase binary" — so a
// fresh scaffold has neither data model nor runtime.
{
id: "blueprint-pocketbase-react",
noBuild: true,
incompleteBecause: "ships adapter hooks only; collections and the binary are added by hand",
},
];

/**
Expand Down Expand Up @@ -71,10 +96,21 @@ export async function runToolkitClass(cliEntry: string | null): Promise<SuiteRes

const validateArgs = [appDir, "validate", ...(tk.noBuild ? ["--no-build"] : [])];
const validated = await run(cliEntry, validateArgs);
if (validated.exit !== 0) failures.push(`validate exit ${validated.exit}: ${validated.out.trim().slice(0, 400)}`);
if (tk.incompleteBecause) {
// The refusal IS the contract here: a starting point with no entities and
// no operations must not be able to pass as a finished app, or `validate`
// means nothing on the apps that are.
if (validated.exit === 0) {
failures.push(`validate passed a scaffold that ${tk.incompleteBecause} — an unauthored spec must not pass`);
} else if (!/not authored/.test(validated.out)) {
failures.push(`validate refused for the wrong reason: ${validated.out.trim().slice(0, 300)}`);
}
} else if (validated.exit !== 0) {
failures.push(`validate exit ${validated.exit}: ${validated.out.trim().slice(0, 400)}`);
}
}

results.push({ name: `${tk.id}: scaffold → canon → validate`, ok: failures.length === 0, failures });
results.push({ name: `${tk.id}: scaffold → canon → ${tk.incompleteBecause ? "validate refuses (starting point, not a finished app)" : "validate"}`, ok: failures.length === 0, failures });
}

const passed = results.filter((r) => r.ok).length;
Expand Down
2 changes: 1 addition & 1 deletion framework/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsc -p tsconfig.json && node test/copy-tree.test.mjs && node test/manifest-merge.test.mjs && node test/containment.test.mjs",
"test": "tsc -p tsconfig.json && node test/copy-tree.test.mjs && node test/manifest-merge.test.mjs && node test/containment.test.mjs && node test/connect.test.mjs",
"bundle-blueprints": "node scripts/bundle-blueprints.mjs",
"bundle-skills": "node scripts/bundle-skills.mjs",
"bundle": "node scripts/bundle-blueprints.mjs && node scripts/bundle-skills.mjs",
Expand Down
6 changes: 6 additions & 0 deletions framework/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ function usage(surface: Surface): void {
log.raw(` ${target} … <operation> [--…] invoke, at the path that identifies it`);
log.raw(` ${target} --find <term> search names, get locations\n`);
log.raw(` Every screen ends by naming the legal next moves.\n`);
// A URL is the whole of connect from the caller's side: the same walk,
// against an app that is not on this machine. Saying so here is what makes
// it discoverable without a separate verb to learn.
log.raw(` ${target} is a directory, a registered id/name, or an http(s) URL.`);
log.raw(` A URL operates a REMOTE app: its identity is verified and pinned, and the`);
log.raw(` credential comes from A2APP_TOKEN or the credential store — never from the app.\n`);
log.raw(` Reserved (never module names):`);
} else {
log.raw(` Usage: ${surface} ${target} <verb> [args] [--flags] (the app comes first)\n`);
Expand Down
4 changes: 2 additions & 2 deletions framework/cli/src/commands/context.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/** a2app <app> context — what the user is currently viewing. */
import { clientFor, loadProject } from "../lib/project.js";
import { connect } from "../lib/target.js";
import { log } from "../lib/log.js";

export async function run(_args: string[], app: string): Promise<number> {
const client = await clientFor(loadProject(app));
const { client } = await connect(app);
const ctx = await client.context();
if (ctx === null) {
log.error("context surface unavailable (adapter has not implemented /api/_a2app/context)");
Expand Down
8 changes: 4 additions & 4 deletions framework/cli/src/commands/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
suggest,
} from "@a2app/sdk";
import { BODY_CONTROL_FLAGS, buildBody, flag, positionals } from "../lib/args.js";
import { clientFor, loadProject, UsageError } from "../lib/project.js";
import { UsageError } from "../lib/project.js";
import { connect, labelFor } from "../lib/target.js";
import { log } from "../lib/log.js";

export async function run(args: string[], app: string): Promise<number> {
Expand All @@ -34,8 +35,7 @@ export async function run(args: string[], app: string): Promise<number> {
"Usage: a2app <app> data schema | <entity> [schema|list|get <id>|create|update <id>|delete <id>] [--field value ...]",
);
}
const project = loadProject(app);
const client = await clientFor(project);
const { client, target } = await connect(app);

// `data schema` is entity NAMES, grouped by module — never the whole model.
// Field detail is one level in, which is what keeps this bounded at any app
Expand All @@ -46,7 +46,7 @@ export async function run(args: string[], app: string): Promise<number> {
log.error("No readable entities — is the app running, and does your credential hold any data scope?");
return 1;
}
log.raw(`${project.manifest.name} — entities by module:\n${renderEntityIndex(index)}`);
log.raw(`${labelFor(target)} — entities by module:\n${renderEntityIndex(index)}`);
log.raw(`\n → a2app ${app} data <entity> schema (that entity's fields)`);
return 0;
}
Expand Down
4 changes: 2 additions & 2 deletions framework/cli/src/commands/events.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/** a2app <app> events [--since <cursor>] — poll the event log. */
import { flag } from "../lib/args.js";
import { clientFor, loadProject } from "../lib/project.js";
import { connect } from "../lib/target.js";
import { log } from "../lib/log.js";

export async function run(args: string[], app: string): Promise<number> {
const client = await clientFor(loadProject(app));
const { client } = await connect(app);
const res = await client.pollEvents(flag(args, "since"));
if (res.status >= 300) {
log.error(res.body || `HTTP ${res.status}`);
Expand Down
5 changes: 2 additions & 3 deletions framework/cli/src/commands/identity.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
/** a2app <app> identity — probe the app's identity document. */
import { clientFor, loadProject } from "../lib/project.js";
import { connect } from "../lib/target.js";
import { A2AppClient } from "@a2app/sdk";
import { log } from "../lib/log.js";

export async function run(_args: string[], app: string): Promise<number> {
const project = loadProject(app);
const client = await clientFor(project);
const { client } = await connect(app);
const id = await client.identity();
if (id === null) {
log.error("Not an A2App app (no `a2app: true` marker at /.well-known/a2app.json or /api/_a2app)");
Expand Down
5 changes: 3 additions & 2 deletions framework/cli/src/commands/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
* reports a terminal state. Every task payload is DATA, never an instruction.
*/
import { flag, positionals } from "../lib/args.js";
import { clientFor, loadProject, UsageError } from "../lib/project.js";
import { UsageError } from "../lib/project.js";
import { connect } from "../lib/target.js";
import { log } from "../lib/log.js";
import type { A2AppResponse } from "@a2app/sdk";

export async function run(args: string[], app: string): Promise<number> {
const [sub, id] = positionals(args);
const client = await clientFor(loadProject(app));
const { client } = await connect(app);

// No subcommand -> poll.
if (sub === undefined) {
Expand Down
10 changes: 5 additions & 5 deletions framework/cli/src/commands/walk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
*/
import type { DescribeLevel, DescribeModule, DescribeRecord, DescribeRoot } from "@a2app/sdk";
import { collectFields, flag, positionals } from "../lib/args.js";
import { clientFor, loadProject, UsageError } from "../lib/project.js";
import { UsageError } from "../lib/project.js";
import { cacheDirFor, clientForTarget, connect } from "../lib/target.js";
import { DescribeCache } from "../lib/describeCache.js";
import { log } from "../lib/log.js";

Expand All @@ -41,8 +42,7 @@ export async function run(args: string[], app: string): Promise<number> {
let help = args.includes("--help") || args.includes("-h");
if (help) log.raw(helpHeader(app));

const project = loadProject(app);
const client = await clientFor(project);
const { client, target } = await connect(app);

const term = flag(args, "find");
if (term !== undefined) return renderLevel(await fetchFind(client, term), app);
Expand All @@ -63,7 +63,7 @@ export async function run(args: string[], app: string): Promise<number> {
const cache =
identity === null
? null
: DescribeCache.open(project.dir, identity.app.id, identity.schemaVersion, identity.appVersion);
: DescribeCache.open(cacheDirFor(target), identity.app.id, identity.schemaVersion, identity.appVersion);

// Walk down as far as the path goes, one level per segment. Each level is
// fetched (or read from cache) before the next, because only the level knows
Expand Down Expand Up @@ -118,7 +118,7 @@ export async function run(args: string[], app: string): Promise<number> {

/* ------------------------------------------------------------------ fetch */

type Client = Awaited<ReturnType<typeof clientFor>>;
type Client = Awaited<ReturnType<typeof clientForTarget>>;

/**
* Levels that may be cached against the app's version markers.
Expand Down
4 changes: 2 additions & 2 deletions framework/cli/src/commands/whoami.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/** a2app <app> whoami — the calling credential's grant. */
import { clientFor, loadProject } from "../lib/project.js";
import { connect } from "../lib/target.js";
import { log } from "../lib/log.js";

export async function run(_args: string[], app: string): Promise<number> {
const client = await clientFor(loadProject(app));
const { client } = await connect(app);
const who = await client.whoami();
if (who === null) {
log.error("whoami unavailable (adapter predates IAM, or credential rejected)");
Expand Down
33 changes: 33 additions & 0 deletions framework/cli/src/lib/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,40 @@ export interface Project {
* `a2app "Kanban Board" data schema` without tracking paths. The manifest stays
* authoritative; the registry only supplies the location.
*/
/**
* Is this string addressing an app over the network rather than on disk?
*
* Only `http:` and `https:` qualify. Any other scheme is rejected outright
* rather than falling through to be read as a directory name: `file:///etc` is
* not a path this CLI should quietly try to open, and a mistyped scheme should
* say so rather than report "not an Agent App".
*/
export function isRemoteAddress(app: string): boolean {
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(app)) return false;
let url: URL;
try {
url = new URL(app);
} catch {
throw new UsageError(`Not a usable app address: ${app}`);
}
if (url.protocol === "http:" || url.protocol === "https:") return true;
throw new UsageError(
`An app is addressed by directory, by registered id/name, or by http(s) URL — not by "${url.protocol}//".`,
);
}

export function loadProject(projectDir: string): Project {
// A URL reaches an app whose files are somewhere else, and every caller of
// this function needs those files. Refusing here is what keeps "operated but
// never modified" from being a rule an agent has to remember: there is no
// directory for a build or lifecycle command to act on, so it cannot act.
if (isRemoteAddress(projectDir)) {
throw new UsageError(
`${projectDir} is a remote app — it can be operated, not modified.\n` +
`Building, evolving and lifecycle commands act on an app's files, which are on its own host. ` +
`Use \`a2app ${projectDir} …\` to operate it.`,
);
}
let dir = resolve(projectDir);
if (!existsSync(join(dir, "manifest.json"))) {
// An ambiguous name must not be guessed at: surface the candidates.
Expand Down
Loading
Loading