@finchip.ai/foga 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -53,6 +53,11 @@ usage:
53
53
  one command to a live cell: the wallet (made on the first run, reused after), anvil, the local world, a cell born with
54
54
  the wallet's keys and its two skills, two tenders, the node serving HTTP and MCP, and the MCP client configurations
55
55
  printed with every path filled in (~/.foga/quickstart)
56
+ foga join [--chain sepolia | --world <deployed.json>] [--rpc <url>] [--name <cell>] [--dir <dir>] [--wallet <dir>]
57
+ [--listen <host:port> | --no-serve] [--public-url <url>] [--run stub|anthropic] [--no-wait] [--min-wei <n>] [--capital-wei <n>] [--exit]
58
+ one command to a cell that lives on a public chain: the wallet (made once, the same address on every chain), the
59
+ chain's world from the package, a wait for the identity to be funded, the birth, the skills minted and offered, the
60
+ documents on chain, the node served (or run with a model); resumable, in ~/.foga/cells/<network>/<name>, never cleared
56
61
  foga demo [--port <n>] [--keep] [--genesis <T0..T9>] start anvil, deploy the local world, run the doctor (and the genesis)
57
62
  foga demo --loop-d [--model stub|anthropic|external] [--model-name <name>] [--seed <n>] [--no-recovery] [--port <n>] [--keep]
58
63
  the above, then Loop D: a new cell born, a demander minting cases a-g, the node with a model (or served for an
@@ -525,6 +530,51 @@ async function main() {
525
530
  return; // a stop that was asked for: both children are gone
526
531
  fail(`${why}; the quickstart is over`);
527
532
  }
533
+ if (group === "join") {
534
+ const { runJoin } = await import("./node/join.js");
535
+ const run = str(flags, "run");
536
+ if (run && run !== "stub" && run !== "anthropic")
537
+ fail("--run must be stub or anthropic");
538
+ let result;
539
+ try {
540
+ result = await runJoin({
541
+ chain: str(flags, "chain"),
542
+ world: str(flags, "world"),
543
+ rpc: str(flags, "rpc"),
544
+ name: str(flags, "name"),
545
+ dir: str(flags, "dir"),
546
+ wallet: str(flags, "wallet"),
547
+ listen: flags["no-serve"] ? null : str(flags, "listen"),
548
+ publicUrl: str(flags, "public-url"),
549
+ run: run ?? null,
550
+ wait: !flags["no-wait"],
551
+ minWei: str(flags, "min-wei") ? BigInt(str(flags, "min-wei")) : undefined,
552
+ capitalWei: str(flags, "capital-wei") ? BigInt(str(flags, "capital-wei")) : undefined,
553
+ exit: Boolean(flags.exit),
554
+ });
555
+ }
556
+ catch (e) {
557
+ fail(e.message);
558
+ }
559
+ if (flags.exit || !result.nodeUrl) {
560
+ await result.stop();
561
+ return;
562
+ }
563
+ let asked = false;
564
+ const stopping = () => {
565
+ if (asked)
566
+ return;
567
+ asked = true;
568
+ console.error("join: stopping the node; the cell stays");
569
+ result.stop().finally(() => process.exit(0));
570
+ };
571
+ process.on("SIGINT", stopping);
572
+ process.on("SIGTERM", stopping);
573
+ const why = await result.ended;
574
+ if (why === "stopped")
575
+ return;
576
+ fail(`${why}; start it again with the same command`);
577
+ }
528
578
  if (group === "demo") {
529
579
  await demo({ port: Number(str(flags, "port") ?? 8545), keep: Boolean(flags.keep), genesis: str(flags, "genesis"), loopD: flags["loop-d"] ? loopDFlags(flags) : undefined });
530
580
  return;
@@ -0,0 +1,82 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { repoRoot } from "../util/fs.js";
4
+ export const CHAINS = {
5
+ sepolia: {
6
+ name: "sepolia",
7
+ chainId: 11155111,
8
+ world: "deployments/sepolia.example.json",
9
+ rpcEnv: "SEPOLIA_RPC_URL",
10
+ rpc: ["https://ethereum-sepolia-rpc.publicnode.com", "https://rpc.sepolia.org", "https://1rpc.io/sepolia"],
11
+ faucets: ["https://cloud.google.com/application/web3/faucet/ethereum/sepolia", "https://www.alchemy.com/faucets/ethereum-sepolia", "https://sepoliafaucet.com"],
12
+ explorer: "https://sepolia.etherscan.io",
13
+ testnet: true,
14
+ },
15
+ "base-sepolia": {
16
+ name: "base-sepolia",
17
+ chainId: 84532,
18
+ world: null,
19
+ rpcEnv: "BASE_SEPOLIA_RPC_URL",
20
+ rpc: ["https://sepolia.base.org", "https://base-sepolia-rpc.publicnode.com"],
21
+ faucets: ["https://docs.base.org/base-chain/tools/network-faucets", "https://cloud.google.com/application/web3/faucet/base/sepolia"],
22
+ explorer: "https://sepolia.basescan.org",
23
+ testnet: true,
24
+ },
25
+ "optimism-sepolia": {
26
+ name: "optimism-sepolia",
27
+ chainId: 11155420,
28
+ world: null,
29
+ rpcEnv: "OPTIMISM_SEPOLIA_RPC_URL",
30
+ rpc: ["https://sepolia.optimism.io", "https://optimism-sepolia-rpc.publicnode.com"],
31
+ faucets: ["https://console.optimism.io/faucet", "https://cloud.google.com/application/web3/faucet/optimism/sepolia"],
32
+ explorer: "https://sepolia-optimism.etherscan.io",
33
+ testnet: true,
34
+ },
35
+ "arbitrum-sepolia": {
36
+ name: "arbitrum-sepolia",
37
+ chainId: 421614,
38
+ world: null,
39
+ rpcEnv: "ARBITRUM_SEPOLIA_RPC_URL",
40
+ rpc: ["https://sepolia-rollup.arbitrum.io/rpc", "https://arbitrum-sepolia-rpc.publicnode.com"],
41
+ faucets: ["https://docs.arbitrum.io/for-devs/dev-tools-and-resources/chain-info", "https://cloud.google.com/application/web3/faucet/arbitrum/sepolia"],
42
+ explorer: "https://sepolia.arbiscan.io",
43
+ testnet: true,
44
+ },
45
+ anvil: {
46
+ name: "anvil",
47
+ chainId: 31337,
48
+ world: null,
49
+ rpcEnv: "FOGA_RPC_URL",
50
+ rpc: ["http://127.0.0.1:8545"],
51
+ faucets: [],
52
+ explorer: null,
53
+ testnet: true,
54
+ },
55
+ };
56
+ /** The chain by name, or null; names are case-insensitive. */
57
+ export function chainByName(name) {
58
+ return CHAINS[name.toLowerCase()] ?? null;
59
+ }
60
+ /** The chain whose id this is, or null. */
61
+ export function chainById(chainId) {
62
+ return Object.values(CHAINS).find((c) => c.chainId === chainId) ?? null;
63
+ }
64
+ /** The absolute path of the packaged world for a chain, or null when none ships or the file is missing. */
65
+ export function packagedWorld(chain) {
66
+ if (!chain.world)
67
+ return null;
68
+ const path = join(repoRoot(), chain.world);
69
+ return existsSync(path) ? path : null;
70
+ }
71
+ /** The RPC for a chain: the one given, the chain's own variable, the general one, then the first public endpoint. */
72
+ export function resolveRpc(chain, given) {
73
+ if (given)
74
+ return { url: given, source: "flag" };
75
+ if (chain && process.env[chain.rpcEnv])
76
+ return { url: process.env[chain.rpcEnv], source: "env" };
77
+ if (process.env.FOGA_RPC_URL)
78
+ return { url: process.env.FOGA_RPC_URL, source: "env" };
79
+ if (chain?.rpc[0])
80
+ return { url: chain.rpc[0], source: "public" };
81
+ throw new Error("no RPC: pass --rpc <url> or set FOGA_RPC_URL");
82
+ }
@@ -62,6 +62,17 @@ export async function waitForRpc(rpc, timeoutMs, giveUp) {
62
62
  throw new Error(`RPC at ${rpc} did not come up within ${timeoutMs} ms`);
63
63
  }
64
64
  /** Why a port cannot be used for a new anvil: another RPC already answers there, or something else holds it. */
65
+ /**
66
+ * The first port from `from` (inclusive) that nothing on 127.0.0.1 accepts connections on, trying `tries` ports in a
67
+ * row; null when all are taken. For the defaults a quickstart falls back on when its usual port is busy — the next
68
+ * one up rather than an ephemeral one, so the URL it prints is still one a person can remember.
69
+ */
70
+ export async function nextFreePort(from, tries = 20) {
71
+ for (let p = from; p < from + tries; p++)
72
+ if (!(await portInUse(p)))
73
+ return p;
74
+ return null;
75
+ }
65
76
  export async function portProblem(port) {
66
77
  if (!(await portInUse(port)))
67
78
  return null;
@@ -12,7 +12,7 @@ import { defaultCatalog, OPERATOR_CALLER } from "../tools/index.js";
12
12
  import { ensureWallet, ensuredKey, walletDir } from "../node/wallet.js";
13
13
  import { repoRoot } from "../util/fs.js";
14
14
  import { deployWorld } from "../world/deploy.js";
15
- import { ANVIL_INSTALL_HINT, anvilBinary, anvilVersion, launchAnvil, portInUse, portProblem, stopChild } from "./anvil.js";
15
+ import { ANVIL_INSTALL_HINT, anvilBinary, anvilVersion, launchAnvil, nextFreePort, portInUse, portProblem, stopChild } from "./anvil.js";
16
16
  import { birthCell } from "./birth.js";
17
17
  import { mintCases } from "./demander.js";
18
18
  import { cleanEnv, ownCli, waitForHttp } from "./loop-d.js";
@@ -49,29 +49,48 @@ export async function runQuickstart(o = {}) {
49
49
  const out = o.out ?? ((text) => console.log(text));
50
50
  const root = repoRoot();
51
51
  const dir = resolve(o.dir ?? defaultQuickstartDir());
52
- const port = o.port ?? 8545;
53
- const listen = o.listen === undefined ? "127.0.0.1:8787" : o.listen;
54
52
  const name = o.name ?? "quick";
55
- const rpc = `http://127.0.0.1:${port}`;
56
53
  const walletPath = resolve(o.wallet ?? walletDir());
57
- const nodeUrlPlanned = listen ? (() => {
58
- const i = listen.lastIndexOf(":");
59
- const host = listen.slice(0, i) || "127.0.0.1";
60
- return `http://${host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host}:${listen.slice(i + 1)}`;
61
- })() : null;
62
54
  // --- say what is wrong before touching anything
63
55
  const anvilBin = anvilBinary();
64
56
  const version = anvilVersion(anvilBin.path);
65
57
  if (!version)
66
58
  throw new Error(ANVIL_INSTALL_HINT);
67
- const problem = await portProblem(port);
68
- if (problem)
69
- throw new Error(problem);
59
+ // The default ports give way when something else holds them — a served cell on 8787 (the reference cell's own box,
60
+ // or a node of yours), another chain on 8545 — so the bare command works on any machine; the next free port up is
61
+ // taken and said so. A port asked for by name (--port, --listen) is not moved: that one is refused as before.
62
+ let port = o.port ?? 8545;
63
+ if (o.port === undefined && (await portInUse(port))) {
64
+ const next = await nextFreePort(port + 1);
65
+ if (next === null)
66
+ throw new Error(`ports ${port}-${port + 20} are all in use; pass --port <n> for the chain`);
67
+ log(`quickstart: port ${port} is in use (another chain or node); anvil takes ${next} instead`);
68
+ port = next;
69
+ }
70
+ else {
71
+ const problem = await portProblem(port);
72
+ if (problem)
73
+ throw new Error(problem);
74
+ }
75
+ let listen = o.listen === undefined ? "127.0.0.1:8787" : o.listen;
70
76
  if (listen) {
71
77
  const lp = Number(listen.slice(listen.lastIndexOf(":") + 1));
72
- if (await portInUse(lp))
73
- throw new Error(`port ${lp} is in use; pass --listen <host:port> for the node (or --no-serve to run no node)`);
78
+ if (await portInUse(lp)) {
79
+ if (o.listen !== undefined)
80
+ throw new Error(`port ${lp} is in use; pass --listen <host:port> for the node (or --no-serve to run no node)`);
81
+ const next = await nextFreePort(lp + 1);
82
+ if (next === null)
83
+ throw new Error(`ports ${lp}-${lp + 20} are all in use; pass --listen <host:port> for the node (or --no-serve)`);
84
+ log(`quickstart: port ${lp} is in use (a served cell, perhaps); the node listens on ${next} instead`);
85
+ listen = `127.0.0.1:${next}`;
86
+ }
74
87
  }
88
+ const rpc = `http://127.0.0.1:${port}`;
89
+ const nodeUrlPlanned = listen ? (() => {
90
+ const i = listen.lastIndexOf(":");
91
+ const host = listen.slice(0, i) || "127.0.0.1";
92
+ return `http://${host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host}:${listen.slice(i + 1)}`;
93
+ })() : null;
75
94
  if (walletPath === dir || walletPath.startsWith(dir + sep))
76
95
  throw new Error(`the wallet (${walletPath}) must live outside the quickstart directory (${dir}), which is cleared on every run`);
77
96
  if (existsSync(dir)) {
@@ -145,7 +145,11 @@ export function buildMcpServer(o) {
145
145
  // how to become a cell: every cell carries the genome's instructions, so an agent that meets one can make itself one
146
146
  const quickstartDoc = join(repoRoot(), "docs", "quickstart.md");
147
147
  if (existsSync(quickstartDoc)) {
148
- server.registerResource("quickstart", "foga://docs/quickstart", { title: "Become a cell", description: "How any agent makes itself a FOGA cell: one command, its own wallet, the same program this node runs.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: readFileSync(quickstartDoc, "utf8") }] }));
148
+ server.registerResource("quickstart", "foga://docs/quickstart", { title: "A local try", description: "How any agent makes itself a FOGA cell on a local chain in one command: its own wallet, the same program this node runs.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: readFileSync(quickstartDoc, "utf8") }] }));
149
+ }
150
+ const joinDoc = join(repoRoot(), "docs", "join.md");
151
+ if (existsSync(joinDoc)) {
152
+ server.registerResource("join", "foga://docs/join", { title: "Become a cell", description: "How any agent becomes a FOGA cell that lives on a public chain in one command (foga join): the wallet, the funding, the birth, the card, work over the chain, other chains with the same key.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: readFileSync(joinDoc, "utf8") }] }));
149
153
  }
150
154
  server.registerPrompt("become-a-cell", { title: "Become a cell", description: "Replicate: make yourself a FOGA cell on your own machine, with your own wallet — the same program as this node, your own identity.", argsSchema: z.object({}) }, () => ({
151
155
  messages: [
@@ -155,13 +159,15 @@ export function buildMcpServer(o) {
155
159
  type: "text",
156
160
  text: [
157
161
  `You are talking to cell ${node.config.name}${cell ? ` (account ${cell.address})` : ""} through the Finch OG Agent node software, version ${o.version}. You can become a cell yourself: the program is public, the identity you make is your own, and nothing of this cell's identity is copied.`,
158
- "On a machine where you can run commands (Node 22.13 or newer, and Foundry's anvil for a local chain):",
159
- "1. A key of your own, first and once: npx -y @finchip.ai/foga wallet new (one signer key in ~/.foga/wallet — your identity, which fills every role a cell needs; never overwritten; a failure here never blocks the next step)",
160
- "2. A living cell on a local chain: npx -y @finchip.ai/foga quickstart (anvil, the world, a cell born with your wallet's keys, two tenders, the node served; it prints the MCP client configuration to connect a model to the cell)",
161
- "3. Connect your model: paste the printed configuration into your MCP client (Claude Desktop, Claude Code, Cursor, or any client with the HTTP form); the stdio form acts with your operator key, the HTTP form with the printed Bearer key.",
162
- "4. On a public chain, when you are ready: fund your key, then foga cell new --wallet ~/.foga/wallet --world <world.json> … and foga cell init --config <cell.config.json> --wallet ~/.foga/wallet. (For a cold-owner / hot-operator / separate-guardian split, make the wallet with `foga wallet new --cell` instead; it is optional hardening, not a requirement.)",
163
- `5. With the wallet alone you can also call this cell as yourself: npx -y @finchip.ai/foga wallet sign --url ${node.config.runtime.http.publicUrl ?? "<this node's URL>"} prints an Authorization header (FOGA-EIP712) for its /api, /mcp and /a2a; what it is granted is this cell's policy.`,
164
- "The guide is the resource foga://docs/quickstart. In PowerShell, quote the package name: npx -y '@finchip.ai/foga' quickstart.",
162
+ "On a machine where you can run commands (Node 22.13 or newer; Foundry's anvil only for the local try):",
163
+ "1. A key of your own, first and once: npx -y @finchip.ai/foga wallet new (one signer key in ~/.foga/wallet — your identity, the same address on every EVM chain; never overwritten)",
164
+ "2. A cell that lives, on a public chain: npx -y @finchip.ai/foga join --chain sepolia (it prints the address to fund — test ether, free — and waits; then the birth, your two skills minted and offered, your documents on chain, the node served; the cell stays in ~/.foga/cells/sepolia/<name>, and the same command resumes it. Add --run stub to take tenders on your own without a model, --listen 0.0.0.0:8787 --public-url https://<host> to be reachable from outside.)",
165
+ " A local try first, if you like: npx -y @finchip.ai/foga quickstart (anvil: a cell on a chain that ends with the process; nothing persists but the wallet)",
166
+ "3. Say who you are: npx -y @finchip.ai/foga cell card --config ~/.foga/cells/sepolia/<name>/cell.config.json prints your card — chain, account, agent id, skills, where a tender for you is minted — to hand to any agent. Work reaches you over the chain: a demander mints an ERC-8414 tender for one of your skills; your node finds it, and you (or the model) take it.",
167
+ "4. Connect your model: paste the printed MCP configuration into your client (the stdio form acts with your operator key, the HTTP form with the printed Bearer key; Claude Code wants \"type\": \"http\" on the HTTP form, Cursor and most others just \"url\").",
168
+ "5. Another chain, the same key: join --chain <name> where a world ships (sepolia today), or foga world deploy --world worlds/<chain>.json once on a chain without one, then join --world <its address book>.",
169
+ `6. With the wallet alone you can also call this cell as yourself: npx -y @finchip.ai/foga wallet sign --url ${node.config.runtime.http.publicUrl ?? "<this node's URL>"} prints an Authorization header (FOGA-EIP712) for its /api, /mcp and /a2a; what it is granted is this cell's policy.`,
170
+ "The guides are the resource foga://docs/quickstart and docs/join.md in the package. In PowerShell, quote the package name: npx -y '@finchip.ai/foga' join --chain sepolia.",
165
171
  "What you get is a complete body: the ten standards, the catalog that decides, the acceptance (foga doctor, foga demo --loop-d). The mind driving it stays yours.",
166
172
  ].join("\n"),
167
173
  },
@@ -0,0 +1,67 @@
1
+ import { endpointsOf } from "../a2a/card.js";
2
+ import { chainById } from "../core/chains.js";
3
+ import { FOGA_COVENANTS } from "./propagation.js";
4
+ import { PACKAGE_NAME } from "../util/version.js";
5
+ export function cellCard(node, o) {
6
+ const cfg = node.config;
7
+ const world = node.world;
8
+ const chain = chainById(node.chainId);
9
+ const ep = endpointsOf(node);
10
+ const base = cfg.runtime.http.publicUrl?.replace(/\/+$/, "") ?? null;
11
+ const has = (id) => {
12
+ try {
13
+ return world.address(id);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ };
19
+ const id = cfg.identity;
20
+ return {
21
+ schema: "foga.card/1",
22
+ name: cfg.name,
23
+ chain: { chainId: node.chainId, network: world.file.network, worldId: world.file.worldId, explorer: chain?.explorer ?? null },
24
+ identity: {
25
+ account: id.account ?? null,
26
+ agentId: id.agentId ?? null,
27
+ registry: has("identity8004/IdentityRegistry"),
28
+ owner: id.owner,
29
+ operator: id.operator,
30
+ guardian: id.guardian,
31
+ oneKey: id.owner === id.operator && id.operator === id.guardian,
32
+ },
33
+ covenants: { program: FOGA_COVENANTS, world: world.covenants() },
34
+ skills: Object.entries(cfg.skills.held).map(([sid, h]) => ({ id: sid, tokenId: String(h.tokenId), packageHash: h.packageHash ?? "", offered: cfg.skills.offered.includes(sid) })),
35
+ work: { taskToken: has("8414/TaskToken"), minRewardWei: String(cfg.policy.tenders.minRewardWei), acceptance: cfg.policy.tenders.acceptance, counterpartyMinLevel: cfg.policy.tenders.counterpartyMinLevel },
36
+ standing: id.standing ? { schemeId: id.standing.schemeId, issuers: id.standing.issuers } : null,
37
+ endpoints: { mcp: ep.mcp, a2a: ep.a2a, web: ep.web, manifest: base ? `${base}/.well-known/foga-cell.json` : null, card: base ? `${base}/.well-known/agent-card.json` : null },
38
+ program: { package: PACKAGE_NAME, version: o.version, join: `npx -y ${PACKAGE_NAME} join --chain ${chain?.name ?? "<chain>"}` },
39
+ };
40
+ }
41
+ /** The card as a few lines an agent can paste to another agent. */
42
+ export function formatCard(c) {
43
+ const lines = [];
44
+ const where = c.chain.explorer && c.identity.account ? ` (${c.chain.explorer}/address/${c.identity.account})` : "";
45
+ lines.push(`I am ${c.name}, a FOGA cell on ${c.chain.network} (chain ${c.chain.chainId}), world ${c.chain.worldId}.`);
46
+ if (c.identity.account)
47
+ lines.push(` account ${c.identity.account}${where}`);
48
+ else
49
+ lines.push(" account not born yet (foga cell init)");
50
+ if (c.identity.agentId)
51
+ lines.push(` identity ERC-8004 agent ${c.identity.agentId}${c.identity.registry ? ` in registry ${c.identity.registry}` : ""}`);
52
+ lines.push(` roles ${c.identity.oneKey ? `one key, every role: ${c.identity.owner}` : `owner ${c.identity.owner}, operator ${c.identity.operator}, guardian ${c.identity.guardian}`}`);
53
+ lines.push(` covenants ${c.covenants.world.join(", ")}`);
54
+ const offered = c.skills.filter((s) => s.offered);
55
+ const heldOnly = c.skills.filter((s) => !s.offered);
56
+ lines.push(` skills ${offered.length ? offered.map((s) => `${s.id} (token ${s.tokenId})`).join(", ") + " — offered" : "none offered"}${heldOnly.length ? `; held: ${heldOnly.map((s) => s.id).join(", ")}` : ""}`);
57
+ if (c.standing)
58
+ lines.push(` standing scheme ${c.standing.schemeId.slice(0, 10)}… by ${c.standing.issuers.join(", ")}`);
59
+ if (c.work.taskToken)
60
+ lines.push(` work mint an ERC-8414 tender on TaskToken ${c.work.taskToken} for one of my offered skills, reward ≥ ${c.work.minRewardWei} wei, acceptance ${c.work.acceptance.join("/")}${c.work.counterpartyMinLevel ? `, your KYA level ≥ ${c.work.counterpartyMinLevel}` : ""}; I find it on chain, run the skill, submit, settle`);
61
+ if (c.endpoints.mcp)
62
+ lines.push(` reach me MCP ${c.endpoints.mcp} · A2A ${c.endpoints.a2a} · manifest ${c.endpoints.manifest}`);
63
+ else
64
+ lines.push(" reach me on chain (no public URL); a tender is the handshake");
65
+ lines.push(` program ${c.program.package} ${c.program.version} — become one too: ${c.program.join}`);
66
+ return lines.join("\n");
67
+ }