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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ The code follows strict inward-pointing layering — **Controllers -> Services -
| `index intent create <content>` | Create a signal |
| `index intent update <id> <text>` | Update a signal |
| `index intent link <id> <network>` | Link a signal to a network |
| `index intent links <id>` | List networks linked to a signal |
| `index opportunity list` | List your opportunities |
| `index opportunity accept/reject <id>` | Act on an opportunity |
| `index opportunity discover <query>` | Discover new opportunities |
Expand Down
7 changes: 7 additions & 0 deletions docs/specs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`

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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ index intent update <id> "revised text" # Update a signal (runs full pipelin
index intent archive <id> # Archive a signal
index intent link <id> <network-id> # Link a signal to a network
index intent unlink <id> <network-id> # Unlink a signal from a network
index intent links <id> # List networks linked to a signal
```

### `index negotiation`
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/args.parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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":
Expand Down
70 changes: 67 additions & 3 deletions packages/cli/src/intent.command.ts
Original file line number Diff line number Diff line change
@@ -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 = `
Expand All @@ -18,13 +18,23 @@ Usage:
index intent archive <id> Archive a signal (accepts short ID)
index intent link <id> <network-id> Link a signal to a network
index intent unlink <id> <network-id> Unlink a signal from a network
index intent links <id> 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(
Expand Down Expand Up @@ -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<void> {
if (!id) {
output.error("Missing signal ID. Usage: index intent links <id>", 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();
}

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/tests/args.parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/tests/tool-calls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }),
Expand Down