diff --git a/README.md b/README.md index 0c329ed112..893356e1cf 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ The code follows strict inward-pointing layering — **Controllers -> Services - | `index intent create ` | Create a signal | | `index intent update ` | Update a signal | | `index intent link ` | Link a signal to a network | +| `index intent links ` | List networks linked to a signal | | `index opportunity list` | List your opportunities | | `index opportunity accept/reject ` | Act on an opportunity | | `index opportunity discover ` | Discover new opportunities | diff --git a/docs/specs/cli-reference.md b/docs/specs/cli-reference.md index 18246099e2..d50efa3135 100644 --- a/docs/specs/cli-reference.md +++ b/docs/specs/cli-reference.md @@ -194,6 +194,13 @@ The `index intent` command exposes subcommands for managing intents (user-facing 2. Calls `delete_intent_index` tool via Tool HTTP API with `{ intentId, networkId }`. 3. Prints "Signal unlinked from network." on success, error on failure. +### `index intent links ` + +1. Resolves short ID to full UUID via `GET /api/intents/:id`. +2. Calls `GET /api/networks` to enumerate networks visible to the authenticated user. +3. Calls `read_intent_indexes` with `{ intentId, networkId }` for each visible network. +4. Renders the linked networks as a network table. JSON mode returns `{ intentId, networks }`. + --- ## Negotiation diff --git a/packages/cli/README.md b/packages/cli/README.md index 7ed597d389..02472954fb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -73,6 +73,7 @@ index intent update "revised text" # Update a signal (runs full pipelin index intent archive # Archive a signal index intent link # Link a signal to a network index intent unlink # Unlink a signal from a network +index intent links # List networks linked to a signal ``` ### `index negotiation` diff --git a/packages/cli/src/args.parser.ts b/packages/cli/src/args.parser.ts index 603e7d75bd..d2c67378d0 100644 --- a/packages/cli/src/args.parser.ts +++ b/packages/cli/src/args.parser.ts @@ -316,7 +316,7 @@ export function parseArgs(args: string[]): ParsedCommand { return result; } -const INTENT_SUBCOMMANDS = new Set(["list", "show", "create", "archive", "update", "link", "unlink"]); +const INTENT_SUBCOMMANDS = new Set(["list", "show", "create", "archive", "update", "link", "unlink", "links"]); /** * Parse intent-specific positional arguments into subcommand, ID, or content. @@ -336,6 +336,7 @@ function parseIntentArgs(positionals: string[], result: ParsedCommand): void { switch (result.subcommand) { case "show": case "archive": + case "links": result.intentId = rest[0]; break; case "create": diff --git a/packages/cli/src/intent.command.ts b/packages/cli/src/intent.command.ts index 91a30753c3..d5447f9af4 100644 --- a/packages/cli/src/intent.command.ts +++ b/packages/cli/src/intent.command.ts @@ -1,12 +1,12 @@ /** * Intent (signal) command handlers for the Index CLI. * - * Implements: list, show, create, archive subcommands. + * Implements signal listing, mutation, and network-link subcommands. * Follows the same handleX(client, subcommand, positionals, options) * pattern as network.command.ts and conversation.command.ts. */ -import type { ApiClient } from "./api.client"; +import type { ApiClient, Network } from "./api.client"; import * as output from "./output"; const INTENT_HELP = ` @@ -18,13 +18,23 @@ Usage: index intent archive Archive a signal (accepts short ID) index intent link Link a signal to a network index intent unlink Unlink a signal from a network + index intent links List networks linked to a signal `; +type IntentNetworkLink = { + intentId?: string; + networkId?: string; +}; + +type IntentNetworkReadData = { + links?: IntentNetworkLink[]; +}; + /** * Route an intent subcommand to the appropriate handler. * * @param client - Authenticated API client. - * @param subcommand - The subcommand (list, show, create, archive). + * @param subcommand - The intent subcommand. * @param options - Additional options (intentId, intentContent, archived, limit, json). */ export async function handleIntent( @@ -171,7 +181,61 @@ export async function handleIntent( output.success("Signal unlinked from network."); return; } + + case "links": { + await intentLinks(client, options.intentId, options.json); + return; + } + } +} + +async function intentLinks(client: ApiClient, id: string | undefined, json?: boolean): Promise { + if (!id) { + output.error("Missing signal ID. Usage: index intent links ", 1); + return; + } + + const intent = await client.getIntent(id); + const networks = await client.listNetworks(); + const results = await Promise.all( + networks.map(async (network) => ({ + network, + result: await client.callTool("read_intent_indexes", { + intentId: intent.id, + networkId: network.id, + }), + })), + ); + + const failed = results.find(({ result }) => !result.success); + if (failed) { + if (json) { + console.log(JSON.stringify(failed.result)); + return; + } + output.error(failed.result.error ?? "Failed to list linked networks", 1); + return; + } + + const linkedNetworks: Network[] = results + .filter(({ network, result }) => { + const links = (result.data as IntentNetworkReadData | undefined)?.links ?? []; + return links.some((link) => link.intentId === intent.id && link.networkId === network.id); + }) + .map(({ network }) => network); + + if (json) { + console.log(JSON.stringify({ intentId: intent.id, networks: linkedNetworks })); + return; + } + + output.heading("Linked networks"); + if (linkedNetworks.length === 0) { + output.dim(" No linked networks found."); + } else { + output.networkTable(linkedNetworks); } + console.log(); } /** diff --git a/packages/cli/tests/args.parser.spec.ts b/packages/cli/tests/args.parser.spec.ts index 33e9be66f9..937e881638 100644 --- a/packages/cli/tests/args.parser.spec.ts +++ b/packages/cli/tests/args.parser.spec.ts @@ -194,6 +194,13 @@ describe("parseArgs", () => { expect(result.intentId).toBe("intent-id"); expect(result.targetId).toBe("network-id"); }); + + it("parses intent links", () => { + const result = parseArgs(["intent", "links", "intent-id"]); + expect(result.command).toBe("intent"); + expect(result.subcommand).toBe("links"); + expect(result.intentId).toBe("intent-id"); + }); }); // ── Opportunity commands ─────────────────────────────────────────── diff --git a/packages/cli/tests/tool-calls.spec.ts b/packages/cli/tests/tool-calls.spec.ts index 77bb732d89..f8c7a2ef08 100644 --- a/packages/cli/tests/tool-calls.spec.ts +++ b/packages/cli/tests/tool-calls.spec.ts @@ -245,6 +245,42 @@ describe("CLI tool call contracts", () => { }); }); + it("links checks the resolved intent against each accessible network", async () => { + mock.onRest("GET", "/api/intents/abc123", () => + Response.json({ intent: { id: "full-uuid-abc123", payload: "test", status: "active" } }), + ); + mock.onRest("GET", "/api/networks", () => + Response.json({ + networks: [ + { id: "index-456", title: "Builders", memberCount: 3 }, + { id: "index-789", title: "Researchers", memberCount: 2 }, + ], + }), + ); + mock.setToolResponse("read_intent_indexes", { + success: true, + data: { + links: [{ intentId: "full-uuid-abc123", networkId: "index-456" }], + count: 1, + }, + }); + + await handleIntent(client, "links", { + intentId: "abc123", + json: true, + }); + + expect(mock.toolCalls).toHaveLength(2); + expect(mock.toolCalls.map((c) => c.toolName)).toEqual([ + "read_intent_indexes", + "read_intent_indexes", + ]); + expect(mock.toolCalls.map((c) => c.query)).toEqual([ + { intentId: "full-uuid-abc123", networkId: "index-456" }, + { intentId: "full-uuid-abc123", networkId: "index-789" }, + ]); + }); + it("archive calls delete_intent with intentId (CLI: intent archive)", async () => { mock.onRest("GET", "/api/intents/abc123", () => Response.json({ intent: { id: "full-uuid-abc123", payload: "test", status: "active" } }),