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
4 changes: 3 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
```
┌───────────────── capability core (src/core/capabilities.ts) ─────────────────┐
│ READS (no keys): facts · cookbook · call · doctor · tx · preset │
│ ACTIONS (key-gated): deploy · send · fund · bridge · activate · verify
│ ACTIONS (CLI-only): new · deploy · send · fund · bridge · activate · verify │
└───────────────────────────┬───────────────────────────┬─────────────────────┘
rome <cmd> (CLI — all commands) rome mcp (stdio MCP server)
humans + agent shell-outs MCP-native agents — READS ONLY
Expand Down Expand Up @@ -74,6 +74,7 @@ Chains resolve by id, name, or slug (`200010`, `hadrian`, `Rome Hadrian`) — by
| `bridge <chain> --from <src> --amount <usdc> [--intent gas\|wrapper]` | `ROME_EVM_KEY` | bridge USDC **in** as gas or wUSDC |
| `bridge <chain> --to <dest> --amount <usdc> [--recipient 0x…]` | `ROME_EVM_KEY` | bridge wUSDC **out**: burn on Rome → claim handle for the destination (you claim there) |
| `activate <chain>` | `ROME_EVM_KEY` | one-time PDA funding required before the first bridge **out** (idempotent; inbound needs none) |
| `new <app-name> [--chain <chain>]` | *none* (keyless) | scaffold a dual-lane app — wraps `create-rome-app`, pre-wires the chain from the registry into `.env`, prints the lifecycle next-steps (fund → deploy → demo → verify). CLI-only: MCP never writes to disk |
| `verify <chain> [--path solidity]` | `ROME_EVM_KEY` + `ROME_SOLANA_KEY` | the **both-lane works-gate**: deploy a probe, drive it from the EVM lane *and* the Solana lane, assert parity |
| `verify <chain> --path solana-program` | `ROME_EVM_KEY` | the **cross-VM works-gate**: deploy a thin CPI wrapper; an EVM-lane call drives a Solana program (SPL Memo) via CPI. `--solana-rpc` adds the Solana-log deep check |
| `verify <chain> --path from-home --from <src> --amount <usdc>` | `ROME_EVM_KEY` | the **round-trip works-gate**: bridge in (wrapper) → act on Rome → bridge out to claim-ready. Waits on Circle attestation (~20 min); needs an activated account |
Expand Down Expand Up @@ -105,6 +106,7 @@ src/
actions.ts call (read) · deploy · send (viem + submitRomeTx)
bridge.ts fund · bridge in/out — the CCTP flow engines (orchestrate the SDK)
activate.ts one-time PDA funding for bridge-out (SimpleActivator) + the check
new.ts scaffold front door — wraps create-rome-app + chain pre-wiring
doctor.ts preflight checklist
tx.ts cross-VM diagnosis (rome_solanaTxForEvmTx; no debug_trace)
verify.ts the path-aware works-gate (+ probe.ts: bundled Store + CPI-Memo probes)
Expand Down
26 changes: 25 additions & 1 deletion docs/GUIDES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Concrete, copy-paste recipes: real commands with real output, and how to fold `r
- [Every command, with real output](#every-command-with-real-output)
- [Integrate into an AI agent (MCP)](#integrate-into-an-ai-agent-mcp)
- [The agent grounding loop](#the-agent-grounding-loop)
- [Start an app (`rome new`)](#start-an-app-rome-new)
- [Fund a wallet from another chain (`fund` / `bridge`)](#fund-a-wallet-from-another-chain-fund--bridge)
- [Bridge out of Rome (`bridge --to`) + `activate`](#bridge-out-of-rome-bridge---to--activate)
- [Prove it works — `rome verify`](#prove-it-works--rome-verify)
Expand Down Expand Up @@ -169,7 +170,7 @@ npm install -g github:rome-protocol/rome-cli#v0.8.0
rome mcp # starts the stdio server; your client lists the READ tools: facts_chain,
# facts_tokens, facts_contracts, facts_gas, facts_balance, facts_programs,
# cookbook_cpi_recipe, cookbook_patterns, cookbook_errors, call, doctor, tx, preset
# (actions — deploy/send/fund/bridge/activate/verify — are CLI-only, never on MCP)
# (actions — new/deploy/send/fund/bridge/activate/verify — are CLI-only, never on MCP)
```

**4. Use it in a prompt.** Now the agent can call the tools instead of hallucinating:
Expand Down Expand Up @@ -202,6 +203,29 @@ The agent now writes against `aerarium`'s pattern with Hadrian's real RPC and th

---

## Start an app (`rome new`)

The scaffold front door. Wraps [`create-rome-app`](https://github.com/rome-protocol/create-rome-app) (the canonical dual-lane scaffolder — a Vault contract + both lanes + a Vite UI), then adds what the scaffolder can't know: your **chain**, resolved from the registry and pre-wired into the app's `.env`. Keyless — it signs nothing (the key stays out until you fund/deploy).

```console
$ rome new my-app --chain hadrian
{
"app": "my-app",
"chainId": 200010,
"chainName": "Rome Hadrian",
"next": [
"cd my-app && npm install",
"# fund the wallets in .env (gas is USDC — bridge it in; no faucet):",
"rome fund hadrian --from base-sepolia --amount 1",
"npm run deploy # deploy the Vault to Rome Hadrian",
"npm run demo # the funded dual-lane proof (MetaMask + Phantom → one Vault)",
"rome verify hadrian # the works-gate, any path"
]
}
```

The `next` steps are the whole lifecycle in this CLI's own commands — scaffold → fund → deploy → prove. Templates live in `create-rome-app` (today: the dual-lane Vault, i.e. the *bring-your-idea* path); as per-path templates land there, `new` grows matching flags.

## Fund a wallet from another chain (`fund` / `bridge`)

The **from-home** path: you hold USDC on another chain and want onto Rome. `fund` bridges it in as **native gas**; `bridge --intent wrapper` brings it in as **wUSDC**. Both use Circle CCTP, orchestrated through [`@rome-protocol/sdk`](https://github.com/rome-protocol/rome-sdk-ts)'s `bridge` module — you sign only the source-chain burn; Rome's sponsor pays the settle.
Expand Down
21 changes: 20 additions & 1 deletion src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { doctor } from "./doctor.js";
import { diagnoseTx } from "./tx.js";
import { verifyHandler } from "./verify.js";
import { activateHandler } from "./activate.js";
import { newHandler } from "./new.js";
import { getPreset } from "./presets.js";
import { type Deps } from "./deps.js";

Expand Down Expand Up @@ -46,13 +47,16 @@ function mkCap(
summary: string,
args: ArgSpec[],
handler: Capability["handler"],
requiresKey?: boolean,
): Capability {
// id stays group.command (stable). CLI + MCP names use single-verb form for verbs.
// MCP tool names use underscores only (some clients reject hyphens); derivation is
// deterministic → alignment holds (see alignment.test.ts).
// requiresKey defaults by kind; a keyless action (e.g. `new` — writes to disk,
// signs nothing) overrides it while staying CLI-only.
const cliPath = verb ? command : `${group} ${command}`;
const mcpTool = (verb ? command : `${group}_${command}`).replace(/-/g, "_");
return { id: `${group}.${command}`, group, command, verb, cliPath, mcpTool, summary, kind, requiresKey: kind === "action", args, handler };
return { id: `${group}.${command}`, group, command, verb, cliPath, mcpTool, summary, kind, requiresKey: requiresKey ?? kind === "action", args, handler };
}

// grouped read (CLI+MCP) / grouped action (CLI-only, key).
Expand All @@ -63,6 +67,9 @@ const verbCap = (group: string, command: string, summary: string, args: ArgSpec[
mkCap("read", true, group, command, summary, args, handler);
const verbAction = (group: string, command: string, summary: string, args: ArgSpec[], handler: Capability["handler"]) =>
mkCap("action", true, group, command, summary, args, handler);
// keyless action: CLI-only (never MCP) but needs no signing key (e.g. scaffolding).
const verbActionKeyless = (group: string, command: string, summary: string, args: ArgSpec[], handler: Capability["handler"]) =>
mkCap("action", true, group, command, summary, args, handler, false);

const chainArg: ArgSpec = { name: "chain", required: true, description: "chain id, name, or slug (e.g. 200010 or hadrian)" };

Expand Down Expand Up @@ -213,6 +220,18 @@ export const CAPABILITIES: Capability[] = [
(a) => bridgeHandler(a),
),

// ── new: scaffold front door (wraps create-rome-app; keyless, CLI-only) ──
verbActionKeyless(
"new",
"new",
"Scaffold a dual-lane Rome app (wraps create-rome-app) with the chain pre-wired from the registry, then the lifecycle next-steps: fund → deploy → demo → verify. No key needed. e.g. rome new my-app --chain hadrian",
[
{ name: "name", required: true, description: "app name (becomes the directory + package name)" },
{ name: "chain", required: false, description: "Rome chain to pre-wire (id, name, or slug; default hadrian)" },
],
(a) => newHandler(a),
),

// ── activate: one-time PDA funding, required before the first bridge OUT ──
verbAction(
"activate",
Expand Down
95 changes: 95 additions & 0 deletions src/core/new.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getChainFacts } from "./facts.js";

// `rome new <app-name> [--chain <chain>]` — the scaffold front door. WRAPS
// create-rome-app (the scaffolder stays canonical — we never rebuild it), then adds
// the Rome-unique glue: resolve the chain through the registry, pre-wire it into the
// app's .env, and hand back the lifecycle next-steps (fund → deploy → demo → verify).
// CLI-only (the MCP surface never writes to disk) but KEYLESS — it signs nothing.

const SCAFFOLDER = "github:rome-protocol/create-rome-app"; // floats on main; pin when it tags

export interface NewDeps {
targetExists(appName: string): boolean;
/** Run the canonical scaffolder (npx create-rome-app <app-name>). */
scaffold(appName: string): Promise<void>;
/** Write <app>/.env from .env.example with CHAIN_ID pre-wired. */
writeChainEnv(appName: string, chainId: number): Promise<void>;
}

export interface NewResult {
app: string;
chainId: number;
chainName: string;
next: string[];
}

/** .env.example → .env content with CHAIN_ID set (uncommented; appended if absent). */
export function projectChainEnv(exampleContent: string, chainId: number): string {
const line = `CHAIN_ID=${chainId}`;
if (/^#?\s*CHAIN_ID=.*$/m.test(exampleContent)) {
return exampleContent.replace(/^#?\s*CHAIN_ID=.*$/m, line);
}
return exampleContent.replace(/\n*$/, "\n") + line + "\n";
}

/** Validate → scaffold (canonical) → pre-wire the chain → grounded next steps. */
export async function runNew(appName: string, chain: string | number | undefined, deps: NewDeps): Promise<NewResult> {
if (!appName || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(appName)) {
throw new Error(`Invalid app name "${appName}" — use letters/digits/dashes (it becomes a directory + package name).`);
}
const c = getChainFacts(chain ?? "hadrian"); // throws "Unknown chain …" BEFORE any npx spend
if (deps.targetExists(appName)) {
throw new Error(`"${appName}" already exists here — pick another name or remove it first.`);
}

await deps.scaffold(appName);
await deps.writeChainEnv(appName, c.chainId);

const slug = String(chain ?? "hadrian");
return {
app: appName,
chainId: c.chainId,
chainName: c.name,
next: [
`cd ${appName} && npm install`,
`# fund the wallets in .env (gas is USDC — bridge it in; no faucet):`,
`rome fund ${slug} --from base-sepolia --amount 1`,
`npm run deploy # deploy the Vault to ${c.name}`,
`npm run demo # the funded dual-lane proof (MetaMask + Phantom → one Vault)`,
`rome verify ${slug} # the works-gate, any path`,
],
};
}

const pExecFile = promisify(execFile);

/** Real deps: npx the canonical scaffolder + fs env projection. */
export function defaultNewDeps(): NewDeps {
return {
targetExists: (appName) => existsSync(join(process.cwd(), appName)),
async scaffold(appName) {
try {
await pExecFile("npx", ["-y", SCAFFOLDER, appName], { cwd: process.cwd(), timeout: 300_000 });
} catch (e) {
const err = e as { stderr?: string; message?: string };
throw new Error(`create-rome-app failed: ${(err.stderr || err.message || "").trim().slice(0, 400)}`);
}
},
async writeChainEnv(appName, chainId) {
const dir = join(process.cwd(), appName);
const example = join(dir, ".env.example");
const content = existsSync(example) ? readFileSync(example, "utf8") : "";
writeFileSync(join(dir, ".env"), projectChainEnv(content, chainId));
},
};
}

/** `rome new <app-name> [--chain <chain>]` handler — keyless; CLI-only. */
export async function newHandler(args: Record<string, string>): Promise<NewResult> {
if (!args.name) throw new Error("Missing app name. Usage: rome new <app-name> [--chain <chain>]");
return runNew(args.name, args.chain, defaultNewDeps());
}
7 changes: 5 additions & 2 deletions test/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ describe("capability kinds", () => {
}
});

it("there is at least one action, and actions require a key", () => {
it("there is at least one action; every action requires a key EXCEPT the explicit keyless set", () => {
const actions = CAPABILITIES.filter((c) => c.kind === "action");
expect(actions.length).toBeGreaterThan(0);
for (const a of actions) expect(a.requiresKey).toBe(true);
// `new` scaffolds to disk but signs nothing — the ONLY keyless action. Anything
// else keyless is a bug: signing actions must fail fast on a missing env key.
const KEYLESS = new Set(["new.new"]);
for (const a of actions) expect(a.requiresKey, a.id).toBe(!KEYLESS.has(a.id));
});
});

Expand Down
93 changes: 93 additions & 0 deletions test/new.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from "vitest";
import { runNew, projectChainEnv, type NewDeps } from "../src/core/new.js";
import { CAPABILITIES } from "../src/core/capabilities.js";
import { buildMcpTools } from "../src/mcp.js";

// `rome new <app-name> [--chain <chain>]` — scaffold a dual-lane app by WRAPPING
// create-rome-app (the scaffolder stays canonical), then pre-wire the chosen chain
// into .env and hand back grounded next steps (fund → deploy → demo → verify).
// CLI-only (MCP never writes to disk) but KEYLESS — signs nothing.

function deps(over: Partial<NewDeps> = {}, order: string[] = []): NewDeps {
return {
targetExists: vi.fn(() => false),
scaffold: vi.fn(async () => void order.push("scaffold")),
writeChainEnv: vi.fn(async () => void order.push("env")),
...over,
};
}

describe("runNew — scaffold, pre-wire chain, grounded next steps", () => {
it("resolves the chain, scaffolds, writes the env, and returns lifecycle next-steps", async () => {
const order: string[] = [];
const d = deps({}, order);
const r = await runNew("my-app", "hadrian", d);
expect(order).toEqual(["scaffold", "env"]);
expect(d.writeChainEnv).toHaveBeenCalledWith("my-app", 200010);
expect(r.app).toBe("my-app");
expect(r.chainId).toBe(200010);
const next = r.next.join("\n");
expect(next).toMatch(/rome fund/);
expect(next).toMatch(/npm run deploy/);
expect(next).toMatch(/npm run demo/);
expect(next).toMatch(/rome verify/);
});

it("fails fast on an unknown chain BEFORE scaffolding (no npx spend)", async () => {
const d = deps();
await expect(runNew("my-app", "not-a-chain", d)).rejects.toThrow(/[Uu]nknown chain/);
expect(d.scaffold).not.toHaveBeenCalled();
});

it("rejects a bad app name (empty / path-y) before scaffolding", async () => {
const d = deps();
await expect(runNew("", "hadrian", d)).rejects.toThrow(/app name/i);
await expect(runNew("../evil", "hadrian", d)).rejects.toThrow(/app name/i);
expect(d.scaffold).not.toHaveBeenCalled();
});

it("refuses to scaffold over an existing directory", async () => {
const d = deps({ targetExists: vi.fn(() => true) });
await expect(runNew("taken", "hadrian", d)).rejects.toThrow(/exists/);
expect(d.scaffold).not.toHaveBeenCalled();
});
});

describe("projectChainEnv — .env.example → .env with the chain pre-wired", () => {
it("uncomments/sets CHAIN_ID, preserving the rest of the file", () => {
const example = "# Chain — defaults to Rome Hadrian.\n# CHAIN_ID=200010\nPROXY_URL=\nEVM_KEY=0xYOUR_EVM_PRIVATE_KEY\n";
const out = projectChainEnv(example, 121214);
expect(out).toContain("CHAIN_ID=121214");
expect(out).not.toMatch(/^# CHAIN_ID=/m);
expect(out).toContain("PROXY_URL=");
expect(out).toContain("EVM_KEY=0xYOUR_EVM_PRIVATE_KEY");
});

it("appends CHAIN_ID when the example has no CHAIN_ID line at all", () => {
const out = projectChainEnv("PROXY_URL=\n", 200010);
expect(out).toMatch(/^CHAIN_ID=200010$/m);
expect(out).toContain("PROXY_URL=");
});
});

describe("new is CLI-only but KEYLESS", () => {
it("registered as a keyless verb action, absent from the MCP surface", () => {
const cap = CAPABILITIES.find((c) => c.id === "new.new");
expect(cap?.kind).toBe("action");
expect(cap?.requiresKey).toBe(false); // scaffolding signs nothing
expect(cap?.verb).toBe(true);
expect(new Set(buildMcpTools().map((t) => t.name)).has("new")).toBe(false);
});

it("the handler runs with NO ROME_EVM_KEY in the environment (fails only on its own args)", async () => {
const prev = process.env.ROME_EVM_KEY;
delete process.env.ROME_EVM_KEY;
try {
const cap = CAPABILITIES.find((c) => c.id === "new.new")!;
// missing app name → its own arg error, NOT a key error
await expect(Promise.resolve(cap.handler({ chain: "hadrian" }))).rejects.toThrow(/app name/i);
} finally {
if (prev !== undefined) process.env.ROME_EVM_KEY = prev;
}
});
});
Loading