@ryubyte/dsh-a2a 0.1.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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * dsh-a2a — persisted configuration (`a2a.json`).
3
+ *
4
+ * Runtime-editable configuration lives in a per-profile `a2a.json`, so the
5
+ * plugin's UI changes survive restarts without touching the composition
6
+ * (`cordis.patch.yml` / `package.json` stay pure entry points).
7
+ *
8
+ * File resolution order:
9
+ * 1. `process.cwd()/a2a.json` — profile dir when launched via `dsh --profile <x>`
10
+ * 2. `~/.dsh/profiles/<cwd-basename>/a2a.json` — fallback when cwd is not the profile dir
11
+ * 3. `$DSH_HOME/a2a.json` — last-resort global location
12
+ *
13
+ * Shape (all fields optional):
14
+ * {
15
+ * "mode": "both", // client | server | both (default both)
16
+ * "timeoutMs": 8000, // client global default (ms)
17
+ * "mapSkills": true, // per-skill tools vs one agent-wide tool
18
+ * "agents": [{ "name", "agentCardUrl", "bearerToken?", "timeoutMs?", "mapSkills?", "enabled?" }],
19
+ * "server": { "baseUrl"?, "agentName"?, "agentDescription"?, "agentVersion"?,
20
+ * "endpointPath"?, "skills"?: [{ "id","name","description"?, "tags"? }] },
21
+ * "dashboard": true
22
+ * }
23
+ */
24
+
25
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
26
+ import { dirname, join } from 'node:path';
27
+ import { homedir } from 'node:os';
28
+ import type { A2APluginConfig } from './index.js';
29
+
30
+ export const A2A_CONFIG_FILENAME = 'a2a.json';
31
+
32
+ export interface PersistedAgentConfig {
33
+ name: string;
34
+ agentCardUrl: string;
35
+ bearerToken?: string;
36
+ timeoutMs?: number;
37
+ mapSkills?: boolean;
38
+ /** False disables the connection without removing the entry (default true). */
39
+ enabled?: boolean;
40
+ }
41
+
42
+ export interface PersistedServerConfig {
43
+ /** Explicitly enable the inbound server (default off). */
44
+ enabled?: boolean;
45
+ baseUrl?: string;
46
+ agentName?: string;
47
+ agentDescription?: string;
48
+ agentVersion?: string;
49
+ endpointPath?: string;
50
+ skills?: Array<{ id: string; name: string; description: string; tags?: string[]; examples?: string[] }>;
51
+ }
52
+
53
+ export interface PersistedA2AConfig {
54
+ mode?: 'client' | 'server' | 'both';
55
+ timeoutMs?: number;
56
+ mapSkills?: boolean;
57
+ agents?: PersistedAgentConfig[];
58
+ server?: PersistedServerConfig;
59
+ dashboard?: boolean;
60
+ }
61
+
62
+ /**
63
+ * Parse `--profile <name>` (or `--profile=<name>`) from the process argv.
64
+ *
65
+ * dsh keeps its launcher flags in the process command line (`node …/dsh
66
+ * --profile a2a-test --port 3083`), so the active profile name is directly
67
+ * observable no matter which directory the process was started from. This is
68
+ * the most reliable way to locate the per-profile config — far better than
69
+ * guessing from cwd's basename.
70
+ */
71
+ export function profileNameFromArgv(): string | undefined {
72
+ const argv = process.argv ?? [];
73
+ for (let i = 0; i < argv.length; i++) {
74
+ const a = argv[i];
75
+ if (a === '--profile' && i + 1 < argv.length) {
76
+ const v = argv[i + 1];
77
+ if (v && !v.startsWith('-')) return v;
78
+ } else if (a.startsWith('--profile=')) {
79
+ const v = a.slice('--profile='.length);
80
+ if (v) return v;
81
+ }
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ /**
87
+ * Resolve the a2a.json path for the current process, without depending on
88
+ * the launch directory. The plugin must work no matter where `dsh` was
89
+ * started from (dsh does NOT chdir into the profile directory), so this
90
+ * prefers the profile named in argv (`--profile <name>`), then walks
91
+ * outward from cwd, then falls back to the profile dir that matches cwd's
92
+ * basename, then the harness home.
93
+ *
94
+ * Order:
95
+ * 0. `$DSH_HOME/profiles/<argv --profile>/a2a.json` — the active profile
96
+ * as named on the command line (most authoritative; works from ANY cwd)
97
+ * 1. `cwd/a2a.json` — explicit when launched inside a profile dir
98
+ * 2. outward from cwd: the nearest ancestor holding `a2a.json` — covers
99
+ * launching from e.g. a repo checkout whose parent chain includes the
100
+ * profile, or any subdir of the profile
101
+ * 3. `$DSH_HOME/profiles/<cwd-basename>/a2a.json` — started from the
102
+ * profile's *parent* (e.g. `~/.dsh/profiles`) or when the profile dir
103
+ * is cwd but the file hasn't been created yet
104
+ * 4. `$DSH_HOME/a2a.json` — last-resort global location
105
+ *
106
+ * Writes always go to the *resolved* path (the one we read), so a config
107
+ * loaded from any step is saved back to the same place, never re-resolved
108
+ * to cwd.
109
+ */
110
+ export function resolveConfigPath(): string {
111
+ const cwd = process.cwd();
112
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
113
+ const base = cwd.split(/[\\/]/).pop() ?? '';
114
+
115
+ // 0. Active profile from argv — authoritative regardless of cwd.
116
+ const profileName = profileNameFromArgv();
117
+ if (profileName && profileName !== '.' && profileName !== '..' && !profileName.includes('/') && !profileName.includes('\\')) {
118
+ const argvFile = join(home, 'profiles', profileName, A2A_CONFIG_FILENAME);
119
+ if (existsSync(argvFile)) return argvFile;
120
+ }
121
+
122
+ // 1. cwd (most explicit).
123
+ const cwdFile = join(cwd, A2A_CONFIG_FILENAME);
124
+ if (existsSync(cwdFile)) return cwdFile;
125
+
126
+ // 2. Nearest ancestor of cwd holding an a2a.json (walk upward, max 12 levels).
127
+ let dir = cwd;
128
+ for (let i = 0; i < 12; i++) {
129
+ const up = join(dir, A2A_CONFIG_FILENAME);
130
+ if (existsSync(up)) return up;
131
+ const parent = dirname(dir);
132
+ if (parent === dir) break;
133
+ dir = parent;
134
+ }
135
+
136
+ // 3. Profile dir matching cwd's basename (started from profiles/ or home).
137
+ const profileFile = join(home, 'profiles', base, A2A_CONFIG_FILENAME);
138
+ if (base && existsSync(profileFile)) return profileFile;
139
+
140
+ // 4. Global fallback.
141
+ const globalFile = join(home, A2A_CONFIG_FILENAME);
142
+ if (existsSync(globalFile)) return globalFile;
143
+
144
+ // Nothing exists yet: prefer writing to cwd (an explicitly cd'd profile
145
+ // dir or a repo that wants its config local); the profile fallback is next.
146
+ return cwdFile;
147
+ }
148
+
149
+ /** Read the persisted config (empty object when absent or unreadable). */
150
+ export function loadConfig(): PersistedA2AConfig {
151
+ const path = resolveConfigPath();
152
+ if (!existsSync(path)) return {};
153
+ try {
154
+ const raw = readFileSync(path, 'utf8');
155
+ const parsed = JSON.parse(raw) as PersistedA2AConfig;
156
+ return parsed && typeof parsed === 'object' ? parsed : {};
157
+ } catch (err) {
158
+ console.error(`[a2a] failed to read ${path}: ${(err as Error).message}`);
159
+ return {};
160
+ }
161
+ }
162
+
163
+ /** Atomically write the persisted config (creates the directory when needed). */
164
+ export function saveConfig(config: PersistedA2AConfig): { ok: boolean; path: string; message?: string } {
165
+ const path = resolveConfigPath();
166
+ try {
167
+ const dir = dirname(path);
168
+ mkdirSync(dir, { recursive: true });
169
+ const tmp = `${path}.tmp`;
170
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', 'utf8');
171
+ // rename is atomic on POSIX; write then move avoids torn reads.
172
+ renameSync(tmp, path);
173
+ return { ok: true, path };
174
+ } catch (err) {
175
+ return { ok: false, path, message: (err as Error).message };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Merge persisted config on top of the in-composition config, so the file
181
+ * (UI-editable, survives restart) wins over static defaults.
182
+ */
183
+ export function mergePersisted(base: A2APluginConfig, persisted: PersistedA2AConfig): A2APluginConfig {
184
+ const merged: A2APluginConfig = { ...base };
185
+ if (persisted.mode) merged.mode = persisted.mode;
186
+ if (persisted.timeoutMs !== undefined) merged.timeoutMs = persisted.timeoutMs;
187
+ if (persisted.mapSkills !== undefined) merged.mapSkills = persisted.mapSkills;
188
+ if (persisted.dashboard !== undefined) merged.dashboard = persisted.dashboard;
189
+
190
+ const pServer = persisted.server;
191
+ if (pServer) {
192
+ if (pServer.enabled !== undefined) merged.serverEnabled = pServer.enabled;
193
+ if (pServer.baseUrl) merged.baseUrl = pServer.baseUrl;
194
+ if (pServer.agentName) merged.agentName = pServer.agentName;
195
+ if (pServer.agentDescription) merged.agentDescription = pServer.agentDescription;
196
+ if (pServer.agentVersion) merged.agentVersion = pServer.agentVersion;
197
+ if (pServer.endpointPath) merged.endpointPath = pServer.endpointPath;
198
+ if (pServer.skills) merged.skills = pServer.skills;
199
+ }
200
+ return merged;
201
+ }
package/src/card.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * A2A (Agent2Agent) Protocol v1.0 — AgentCard discovery and validation.
3
+ *
4
+ * Reproduces the protocol's discovery contract: fetch the manifest at
5
+ * `/.well-known/agent-card.json` (or a configured URL). Per the spec, when a
6
+ * path is configured without a well-known segment the client SHOULD probe the
7
+ * well-known location first, then fall back to the configured path.
8
+ */
9
+
10
+ import type { AgentCard, AgentInterface } from './protocol.js';
11
+ import { A2AError } from './errors.js';
12
+
13
+ export interface AgentCardOptions {
14
+ /** How long a single fetch may take (ms). Default 15000. */
15
+ timeoutMs?: number;
16
+ /** Optional bearer token sent as `Authorization: Bearer <token>`. */
17
+ bearerToken?: string;
18
+ /** Optional custom fetch implementation (for tests). */
19
+ fetchImpl?: typeof fetch;
20
+ }
21
+
22
+ export function isAgentCard(value: unknown): value is AgentCard {
23
+ if (typeof value !== 'object' || value === null) return false;
24
+ const v = value as Record<string, unknown>;
25
+ return (
26
+ typeof v.name === 'string' &&
27
+ typeof v.description === 'string' &&
28
+ typeof v.version === 'string' &&
29
+ Array.isArray(v.supportedInterfaces) &&
30
+ typeof v.capabilities === 'object' &&
31
+ v.capabilities !== null &&
32
+ Array.isArray(v.defaultInputModes) &&
33
+ Array.isArray(v.defaultOutputModes) &&
34
+ Array.isArray(v.skills)
35
+ );
36
+ }
37
+
38
+ function normalizeUrl(base: string, path: string): string {
39
+ const b = base.endsWith('/') ? base.slice(0, -1) : base;
40
+ const p = path.startsWith('/') ? path : `/${path}`;
41
+ return `${b}${p}`;
42
+ }
43
+
44
+ /**
45
+ * Fetch and validate an agent's AgentCard.
46
+ *
47
+ * Discovery order (per spec §AgentCard discovery):
48
+ * 1. Probe `GET {origin}/.well-known/agent-card.json` first.
49
+ * 2. Fall back to the exact configured URL.
50
+ * A configured URL that already ends in `agent-card.json` is used directly.
51
+ */
52
+ export async function fetchAgentCard(
53
+ baseUrl: string,
54
+ options: AgentCardOptions = {},
55
+ ): Promise<AgentCard> {
56
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
57
+ const timeoutMs = options.timeoutMs ?? 15000;
58
+ const headers: Record<string, string> = { Accept: 'application/json' };
59
+ if (options.bearerToken) headers.Authorization = `Bearer ${options.bearerToken}`;
60
+
61
+ const wellKnown = normalizeUrl(baseUrl, '/.well-known/agent-card.json');
62
+ const isExactCard =
63
+ baseUrl.endsWith('agent-card.json') || baseUrl.endsWith('agent-card.json/');
64
+
65
+ const candidates = isExactCard ? [baseUrl] : [wellKnown, baseUrl];
66
+
67
+ let lastError: Error | undefined;
68
+ for (const url of candidates) {
69
+ const ctrl = new AbortController();
70
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
71
+ try {
72
+ const res = await fetchImpl(url, { headers, signal: ctrl.signal });
73
+ if (res.status === 404) {
74
+ lastError = new A2AError(
75
+ 404,
76
+ `AgentCard not found at ${url}`,
77
+ undefined,
78
+ true,
79
+ );
80
+ continue;
81
+ }
82
+ if (!res.ok) {
83
+ throw new A2AError(
84
+ res.status,
85
+ `AgentCard request failed (${res.status}) at ${url}`,
86
+ undefined,
87
+ true,
88
+ );
89
+ }
90
+ const json: unknown = await res.json();
91
+ if (!isAgentCard(json)) {
92
+ throw new A2AError(
93
+ 400,
94
+ `Invalid AgentCard at ${url}: missing required fields`,
95
+ undefined,
96
+ true,
97
+ );
98
+ }
99
+ return json;
100
+ } catch (err) {
101
+ if (err instanceof Error && err.name === 'AbortError') {
102
+ throw new A2AError(408, `AgentCard fetch timed out after ${timeoutMs}ms at ${url}`, undefined, true);
103
+ }
104
+ lastError = err instanceof Error ? err : new Error(String(err));
105
+ } finally {
106
+ clearTimeout(timer);
107
+ }
108
+ }
109
+ throw (
110
+ lastError ??
111
+ new A2AError(404, `AgentCard could not be fetched from ${baseUrl}`, undefined, true)
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Pick the best interface to talk to an agent from its AgentCard.
117
+ *
118
+ * Preference order: a JSONRPC interface first, then gRPC, then any other
119
+ * binding; the card's own ordering ("first entry is preferred") is respected
120
+ * within each binding class. Returns the interface and a normalized base URL
121
+ * to post JSON-RPC requests to.
122
+ */
123
+ export function pickInterface(
124
+ card: AgentCard,
125
+ preferred?: string,
126
+ ): { iface: AgentInterface; url: string } {
127
+ const ifaces = card.supportedInterfaces ?? [];
128
+ if (ifaces.length === 0) {
129
+ throw new A2AError(
130
+ -32000,
131
+ `AgentCard for "${card.name}" declares no supportedInterfaces`,
132
+ );
133
+ }
134
+ const want = preferred ?? 'JSONRPC';
135
+ const ordered = [...ifaces].sort((a, b) => {
136
+ const ab = a.protocolBinding === want ? 0 : a.protocolBinding === 'GRPC' ? 1 : 2;
137
+ const bb = b.protocolBinding === want ? 0 : b.protocolBinding === 'GRPC' ? 1 : 2;
138
+ return ab - bb;
139
+ });
140
+ const iface = ordered[0];
141
+ const url = iface.url.endsWith('/') ? iface.url.slice(0, -1) : iface.url;
142
+ return { iface, url };
143
+ }