dsh-agy-cli 0.0.0-stage → 0.12.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/LICENSE +30 -0
- package/README.en.md +27 -0
- package/README.md +94 -2
- package/agents/dsh-agy-image-view.md +17 -0
- package/agents/dsh-agy-mcp-executor.md +32 -0
- package/agents/dsh-agy-read-only.md +20 -0
- package/agents/dsh-agy-tool-free.md +16 -0
- package/agents/dsh-agy-workspace-write.md +23 -0
- package/bin/dsh-agy-cli.js +9 -0
- package/bin/win32-x64/agy-launcher.exe +0 -0
- package/cordis.patch.yml +16 -0
- package/dist/client.js +4259 -0
- package/lib/agent-installer.d.ts +66 -0
- package/lib/agent-installer.js +147 -0
- package/lib/agent-presets.d.ts +42 -0
- package/lib/agent-presets.js +130 -0
- package/lib/agents-cli.d.ts +3 -0
- package/lib/agents-cli.js +110 -0
- package/lib/agy/diagnostics.d.ts +29 -0
- package/lib/agy/diagnostics.js +109 -0
- package/lib/agy/errors.d.ts +20 -0
- package/lib/agy/errors.js +77 -0
- package/lib/agy/experimental-transport.d.ts +79 -0
- package/lib/agy/experimental-transport.js +612 -0
- package/lib/agy/keepalive.d.ts +140 -0
- package/lib/agy/keepalive.js +424 -0
- package/lib/agy/limiter.d.ts +32 -0
- package/lib/agy/limiter.js +111 -0
- package/lib/agy/log.d.ts +214 -0
- package/lib/agy/log.js +503 -0
- package/lib/agy/models.d.ts +49 -0
- package/lib/agy/models.js +203 -0
- package/lib/agy/overrides.d.ts +17 -0
- package/lib/agy/overrides.js +73 -0
- package/lib/agy/parser.d.ts +102 -0
- package/lib/agy/parser.js +492 -0
- package/lib/agy/persistent-transport.d.ts +101 -0
- package/lib/agy/persistent-transport.js +753 -0
- package/lib/agy/process.d.ts +144 -0
- package/lib/agy/process.js +452 -0
- package/lib/agy/redact.d.ts +3 -0
- package/lib/agy/redact.js +24 -0
- package/lib/agy/stream-protocol.d.ts +50 -0
- package/lib/agy/stream-protocol.js +156 -0
- package/lib/agy/windows-launcher.d.ts +21 -0
- package/lib/agy/windows-launcher.js +76 -0
- package/lib/diagnostics.d.ts +78 -0
- package/lib/diagnostics.js +162 -0
- package/lib/doctor.d.ts +138 -0
- package/lib/doctor.js +923 -0
- package/lib/dsh/context.d.ts +133 -0
- package/lib/dsh/context.js +352 -0
- package/lib/dsh-home.d.ts +6 -0
- package/lib/dsh-home.js +11 -0
- package/lib/index.d.ts +43 -0
- package/lib/index.js +256 -0
- package/lib/mcp/agy-registration.d.ts +70 -0
- package/lib/mcp/agy-registration.js +245 -0
- package/lib/mcp/bridge.d.ts +73 -0
- package/lib/mcp/bridge.js +295 -0
- package/lib/mcp/endpoint.d.ts +41 -0
- package/lib/mcp/endpoint.js +142 -0
- package/lib/mcp/protocol.d.ts +107 -0
- package/lib/mcp/protocol.js +197 -0
- package/lib/mcp/tool-projection.d.ts +64 -0
- package/lib/mcp/tool-projection.js +168 -0
- package/lib/provider/agy.d.ts +153 -0
- package/lib/provider/agy.js +2159 -0
- package/lib/provider/config.d.ts +226 -0
- package/lib/provider/config.js +294 -0
- package/lib/provider/error-codes.d.ts +22 -0
- package/lib/provider/error-codes.js +29 -0
- package/lib/provider/image-bridge.d.ts +60 -0
- package/lib/provider/image-bridge.js +290 -0
- package/lib/provider/mock.d.ts +15 -0
- package/lib/provider/mock.js +78 -0
- package/lib/provider/prompt-budget.d.ts +45 -0
- package/lib/provider/prompt-budget.js +596 -0
- package/lib/provider/serialize.d.ts +15 -0
- package/lib/provider/serialize.js +82 -0
- package/lib/provider/tool-protocol.d.ts +80 -0
- package/lib/provider/tool-protocol.js +848 -0
- package/lib/provider/tool-schema-file.d.ts +11 -0
- package/lib/provider/tool-schema-file.js +27 -0
- package/lib/session/file-store.d.ts +56 -0
- package/lib/session/file-store.js +186 -0
- package/lib/session/store.d.ts +49 -0
- package/lib/session/store.js +62 -0
- package/lib/web/router.d.ts +19 -0
- package/lib/web/router.js +906 -0
- package/package.json +112 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { appendFileSync } from 'node:fs';
|
|
2
|
+
import { AgyAdapter } from './provider/agy.js';
|
|
3
|
+
import { BundleConfig, Config as ConfigSchema, createConfigSchema, } from './provider/config.js';
|
|
4
|
+
import { MockAdapter } from './provider/mock.js';
|
|
5
|
+
import { AgyModelDiscovery } from './agy/models.js';
|
|
6
|
+
import { appendAgyDiagnosticRecord, AGY_DIAGNOSTIC_LOG_PATH } from './agy/log.js';
|
|
7
|
+
import { configuredModels, readConfigFlag } from './provider/config.js';
|
|
8
|
+
import { SessionRegistry } from './session/store.js';
|
|
9
|
+
import { FileSessionStore, resolveSessionStorePath } from './session/file-store.js';
|
|
10
|
+
export const name = 'dsh-agy-cli';
|
|
11
|
+
export const inject = ['llm', 'sessions', 'workspaceRegistry', 'sandboxPolicy', 'permissionPresets', 'approval'];
|
|
12
|
+
/** Programmatic library default: enabled=false, toolPolicy=reject. */
|
|
13
|
+
export const Config = ConfigSchema;
|
|
14
|
+
export { AgyAdapter, MockAdapter };
|
|
15
|
+
export { AgyModelDiscovery, mergeModelCatalog, parseAgyModels } from './agy/models.js';
|
|
16
|
+
export { diagnoseAgy } from './agy/diagnostics.js';
|
|
17
|
+
export { diagnoseProvider } from './diagnostics.js';
|
|
18
|
+
export { DshContextError, diagnoseDshContext, readDshContextServices, resolveDshContext } from './dsh/context.js';
|
|
19
|
+
export { appendToolProtocolPrompt, createStructuredToolProtocol, parseStructuredEnvelope, renderToolProtocolPrompt, StructuredResponseAccumulator, ToolProtocolError, TOOL_PROTOCOL_LIMITS, } from './provider/tool-protocol.js';
|
|
20
|
+
export { stageToolSchema } from './provider/tool-schema-file.js';
|
|
21
|
+
export { diagnoseProfile, runDoctor } from './doctor.js';
|
|
22
|
+
export { AgyImageBridgeError, DEFAULT_IMAGE_MAX_BYTES, DEFAULT_IMAGE_MAX_COUNT, IMAGE_MEDIA_TYPES, prepareAgyPrompts, } from './provider/image-bridge.js';
|
|
23
|
+
import { startDshMcpBridge, } from './mcp/bridge.js';
|
|
24
|
+
import { noteRegistration, setupWebRoutes } from './web/router.js';
|
|
25
|
+
import { findMissingAgentTemplates, } from './agent-installer.js';
|
|
26
|
+
import { agentTemplatesForRequest } from './agent-presets.js';
|
|
27
|
+
import { AGENT_PRESET_IDS, getAgentPreset, listAgentPresets, readAgentPresetTemplate, requireAgentPreset, } from './agent-presets.js';
|
|
28
|
+
export { AGENT_PRESET_IDS, agentTemplatesForRequest, getAgentPreset, listAgentPresets, readAgentPresetTemplate, requireAgentPreset, };
|
|
29
|
+
export { DEFAULT_AGENT_DIRECTORY, AgentInstallError, describeAgentPresets, findMissingAgentTemplates, installAgentPreset, isAgentPreset, } from './agent-installer.js';
|
|
30
|
+
export { formatAgentsHelp, runAgentsCli } from './agents-cli.js';
|
|
31
|
+
export { BundleConfig, ConfigSchema, createConfigSchema };
|
|
32
|
+
function resolveSettingsConfig(ctx, config) {
|
|
33
|
+
// DSH 0.2.0 removed SettingsProvider.register(): the harness's SettingsForms
|
|
34
|
+
// projects the plugin's exported `Config` schema automatically, and `config`
|
|
35
|
+
// already is the live merged value. Direct library callers compose the LLM
|
|
36
|
+
// service alone, which keeps working unchanged.
|
|
37
|
+
void ctx;
|
|
38
|
+
return config;
|
|
39
|
+
}
|
|
40
|
+
export function apply(ctx, config) {
|
|
41
|
+
const activeConfig = resolveSettingsConfig(ctx, config);
|
|
42
|
+
const modelDiscovery = activeConfig.modelDiscovery === 'off' ? undefined : new AgyModelDiscovery({
|
|
43
|
+
...(activeConfig.agyPath?.trim() ? { executable: activeConfig.agyPath.trim() } : {}),
|
|
44
|
+
...(activeConfig.modelDiscoveryTtlMs === undefined ? {} : { ttlMs: activeConfig.modelDiscoveryTtlMs }),
|
|
45
|
+
...(activeConfig.modelDiscoveryTimeoutMs === undefined ? {} : { timeoutMs: activeConfig.modelDiscoveryTimeoutMs }),
|
|
46
|
+
});
|
|
47
|
+
// Settings panel: expose the local AGY CLI as the Antigravity CLI provider.
|
|
48
|
+
// The namespace is the plugin name; settingsPath [] means the whole section is the profile.
|
|
49
|
+
//
|
|
50
|
+
// The outcome is recorded, not discarded: the Models page attaches the AGY
|
|
51
|
+
// card only to a directory row whose `settingsNs` is exactly this string, and
|
|
52
|
+
// a refused registration (DUPLICATE_DIRECTORY when this profile mounts the
|
|
53
|
+
// plugin twice) used to be swallowed — which made "no card on the Models page"
|
|
54
|
+
// indistinguishable from "card rendered empty". `/plugins/agy-cli/diagnose`
|
|
55
|
+
// now reports both the refusal and the directory it actually has.
|
|
56
|
+
const registerProviderDirectory = () => {
|
|
57
|
+
try {
|
|
58
|
+
ctx.llm.registerConfigurableProviders([{
|
|
59
|
+
provider: 'agy',
|
|
60
|
+
displayName: 'Antigravity CLI',
|
|
61
|
+
settingsNs: 'dsh-agy-cli',
|
|
62
|
+
settingsPath: [],
|
|
63
|
+
}]);
|
|
64
|
+
noteRegistration('configurableProviders', 'ok');
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
noteRegistration('configurableProviders', error instanceof Error ? error.message : String(error));
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
if (!registerProviderDirectory()) {
|
|
73
|
+
// Why a retry: a refused registration means another mount of this plugin
|
|
74
|
+
// already holds the `agy` entry. If that mount is disposed afterwards (the
|
|
75
|
+
// bundle mount being replaced by the patch override), the directory loses the
|
|
76
|
+
// entry and the Models page keeps listing the provider with an EMPTY
|
|
77
|
+
// settingsNs — a row that renders no card. Retrying once after the loader
|
|
78
|
+
// settles repairs that order dependence without a host restart.
|
|
79
|
+
ctx.effect(() => {
|
|
80
|
+
const timers = [3_000, 10_000].map((delay) => {
|
|
81
|
+
const timer = setTimeout(() => { registerProviderDirectory(); }, delay);
|
|
82
|
+
if (typeof timer.unref === 'function')
|
|
83
|
+
timer.unref();
|
|
84
|
+
return timer;
|
|
85
|
+
});
|
|
86
|
+
return () => { for (const timer of timers)
|
|
87
|
+
clearTimeout(timer); };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
ctx.llm.registerModelDiscovery('dsh-agy-cli', async (request) => {
|
|
92
|
+
// If editing an existing agy route, prefer our already-known catalog; otherwise discover via AGY CLI.
|
|
93
|
+
// request.provider being 'agy' or undefined both mean this plugin owns the draft.
|
|
94
|
+
if (request.provider !== undefined && request.provider !== 'agy')
|
|
95
|
+
return [];
|
|
96
|
+
const configured = configuredModels(activeConfig);
|
|
97
|
+
const result = activeConfig.modelDiscovery === 'off'
|
|
98
|
+
? { models: configured, source: 'static', stale: false }
|
|
99
|
+
: await modelDiscovery.discover(configured);
|
|
100
|
+
return result.models.map(m => ({
|
|
101
|
+
id: m.id,
|
|
102
|
+
...(m.name === undefined ? {} : { name: m.name }),
|
|
103
|
+
...(m.contextWindow === undefined ? {} : { contextWindow: m.contextWindow }),
|
|
104
|
+
}));
|
|
105
|
+
});
|
|
106
|
+
noteRegistration('modelDiscovery', 'ok');
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
noteRegistration('modelDiscovery', error instanceof Error ? error.message : String(error));
|
|
110
|
+
}
|
|
111
|
+
// Mount web HTTP routes for GUI control panel & status polling (/plugins/agy-cli/*)
|
|
112
|
+
try {
|
|
113
|
+
setupWebRoutes(ctx, activeConfig, modelDiscovery);
|
|
114
|
+
noteRegistration('webRoutes', 'ok');
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
noteRegistration('webRoutes', error instanceof Error ? error.message : String(error));
|
|
118
|
+
}
|
|
119
|
+
// Settings are resolved before the enabled check, so disabled profiles can
|
|
120
|
+
// still be edited from the configuration surface.
|
|
121
|
+
if (!readConfigFlag(activeConfig.enabled))
|
|
122
|
+
return;
|
|
123
|
+
const provider = activeConfig.provider ?? 'agy';
|
|
124
|
+
if (provider === 'agy-mock') {
|
|
125
|
+
ctx.llm.registerAdapter([provider], new MockAdapter(activeConfig));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const logger = ctx.logger('dsh-agy-cli');
|
|
129
|
+
// AGY exits with `unknown agent "<name>" (available: ...)` *before* it reads
|
|
130
|
+
// any input when the profile named on the command line has no template on
|
|
131
|
+
// disk. The child then dies while its stdin is being written, and the adapter
|
|
132
|
+
// reports that as AGY_INPUT_FAILED — an error naming the wrong component.
|
|
133
|
+
// Report the real cause once, at startup, naming the exact missing files.
|
|
134
|
+
// This only warns: installing templates is a deliberate user action, and
|
|
135
|
+
// writing into another application's config directory unprompted is not
|
|
136
|
+
// something a provider should do.
|
|
137
|
+
ctx.effect(() => {
|
|
138
|
+
let cancelled = false;
|
|
139
|
+
void (async () => {
|
|
140
|
+
try {
|
|
141
|
+
const wanted = agentTemplatesForRequest({
|
|
142
|
+
toolPolicy: activeConfig.toolPolicy === 'agy-owned'
|
|
143
|
+
? 'agy-owned'
|
|
144
|
+
: activeConfig.toolPolicy === 'dsh-owned' ? 'dsh-owned' : 'reject',
|
|
145
|
+
agentPreset: activeConfig.agentPreset,
|
|
146
|
+
agent: activeConfig.agent,
|
|
147
|
+
imageInput: activeConfig.imageInput === 'experimental' ? 'experimental' : 'off',
|
|
148
|
+
});
|
|
149
|
+
const missing = (await Promise.all(wanted.map(async (entry) => {
|
|
150
|
+
if (entry.preset === undefined)
|
|
151
|
+
return undefined;
|
|
152
|
+
const absent = await findMissingAgentTemplates([entry.preset]);
|
|
153
|
+
return absent.length === 0 ? undefined : absent[0];
|
|
154
|
+
}))).filter((entry) => entry !== undefined);
|
|
155
|
+
if (cancelled || missing.length === 0)
|
|
156
|
+
return;
|
|
157
|
+
logger.warn('AGY agent template(s) missing: %s. AGY rejects an unknown agent before reading any input, '
|
|
158
|
+
+ 'which surfaces as AGY_INPUT_FAILED. Install them with: npx dsh-agy-cli agents install %s --apply', missing.map(entry => `${entry.agentName} (${entry.targetPath})`).join(', '), missing.map(entry => entry.preset).join(' '));
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
logger.warn('could not check AGY agent templates: %s', error instanceof Error ? error.message : String(error));
|
|
162
|
+
}
|
|
163
|
+
})();
|
|
164
|
+
return () => {
|
|
165
|
+
cancelled = true;
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
// AttachmentStore is optional for the text-only path. Use the reflection
|
|
169
|
+
// lookup instead of reading ctx.attachments directly: Cordis requires every
|
|
170
|
+
// direct service property access to be declared in `inject`.
|
|
171
|
+
// The MCP tool bridge hands DSH tools to AGY out of band (so their schemas
|
|
172
|
+
// never enter the prompt) and routes every execution back through the host's
|
|
173
|
+
// own tool pipeline. It starts as an effect so the registration is removed
|
|
174
|
+
// again when this fiber unloads.
|
|
175
|
+
let toolBridge;
|
|
176
|
+
let toolBridgeError;
|
|
177
|
+
const wantsBridge = activeConfig.mcpBridge !== false
|
|
178
|
+
&& activeConfig.toolPolicy === 'dsh-owned';
|
|
179
|
+
if (wantsBridge) {
|
|
180
|
+
ctx.effect(() => {
|
|
181
|
+
let cancelled = false;
|
|
182
|
+
void (async () => {
|
|
183
|
+
const bridge = await startDshMcpBridge({
|
|
184
|
+
webServer: ctx.get('webServer'),
|
|
185
|
+
tools: ctx.get('tools'),
|
|
186
|
+
// Late-bound on purpose: this plugin loads before the Session
|
|
187
|
+
// controller bundle finishes mounting, and a value captured here
|
|
188
|
+
// stays undefined for the life of the host.
|
|
189
|
+
sessionControllerLookup: () => ctx.get('sessionController'),
|
|
190
|
+
...((activeConfig.mcpToolAllowlist ?? []).length > 0 ? { toolAllowlist: activeConfig.mcpToolAllowlist } : {}),
|
|
191
|
+
serverVersion: '0.0.0',
|
|
192
|
+
logger: message => {
|
|
193
|
+
logger.info(message);
|
|
194
|
+
// The host logger has no file sink, so a bridge that silently loses
|
|
195
|
+
// the session agent leaves no evidence. Mirror into the diagnostic
|
|
196
|
+
// log the rest of this provider already writes.
|
|
197
|
+
try {
|
|
198
|
+
appendFileSync(AGY_DIAGNOSTIC_LOG_PATH, `${JSON.stringify({ event: 'bridge.log', at: Date.now(), message })}\n`, 'utf8');
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Diagnostics must never break the bridge.
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
if (cancelled) {
|
|
206
|
+
await bridge.dispose();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
toolBridge = bridge;
|
|
210
|
+
if (!bridge.active) {
|
|
211
|
+
toolBridgeError = bridge.inactiveReason;
|
|
212
|
+
logger.warn('dsh mcp bridge inactive: %s', bridge.inactiveReason ?? 'unknown reason');
|
|
213
|
+
}
|
|
214
|
+
})().catch(error => {
|
|
215
|
+
toolBridgeError = error instanceof Error ? error.message : String(error);
|
|
216
|
+
logger.warn('dsh mcp bridge failed to start: %s', toolBridgeError);
|
|
217
|
+
});
|
|
218
|
+
return async () => {
|
|
219
|
+
cancelled = true;
|
|
220
|
+
const bridge = toolBridge;
|
|
221
|
+
toolBridge = undefined;
|
|
222
|
+
await bridge?.dispose();
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
ctx.llm.registerAdapter([provider], new AgyAdapter(activeConfig, {
|
|
227
|
+
logger: record => {
|
|
228
|
+
try {
|
|
229
|
+
logger.info('%s', JSON.stringify(record));
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
appendAgyDiagnosticRecord(record);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
// With sessionMode 'resume' the sessionId -> conversationId map must outlive
|
|
236
|
+
// the plugin: AGY keeps every conversation on disk, so a durable map turns
|
|
237
|
+
// the first turn after a host restart from a full-history re-serialization
|
|
238
|
+
// (pinned at the input limit, missing the upstream prompt cache) into an
|
|
239
|
+
// incremental one. 'full' never reads the map, so it stays in memory.
|
|
240
|
+
...(activeConfig.sessionMode === 'resume'
|
|
241
|
+
? { sessionRegistry: new SessionRegistry(new FileSessionStore({ path: resolveSessionStorePath(activeConfig.sessionStorePath) })) }
|
|
242
|
+
: {}),
|
|
243
|
+
// Resolve lazily because attachments is optional and can be composed after
|
|
244
|
+
// this plugin. Text-only requests remain independent of the service.
|
|
245
|
+
resolveAttachmentStore: () => ctx.get('attachments'),
|
|
246
|
+
resolveToolBridge: () => {
|
|
247
|
+
if (!wantsBridge)
|
|
248
|
+
return undefined;
|
|
249
|
+
if (toolBridge?.active === true)
|
|
250
|
+
return toolBridge;
|
|
251
|
+
return undefined;
|
|
252
|
+
},
|
|
253
|
+
dshContext: ctx,
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** The MCP server name AGY shows for this bridge. */
|
|
2
|
+
export declare const AGY_MCP_SERVER_NAME = "dsh";
|
|
3
|
+
export interface AgyMcpEntry {
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly token: string;
|
|
6
|
+
/** Exposed tool names, used to scope the AGY-side allow rules. */
|
|
7
|
+
readonly toolNames?: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
export interface AgyMcpConfigFile {
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly root: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export interface AgyMcpRegistrationOptions {
|
|
14
|
+
/** Overrides the AGY MCP-config root; tests pass a temporary directory. */
|
|
15
|
+
readonly configRoot?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Overrides the AGY settings root. Settings and MCP config live one level
|
|
18
|
+
* apart, so this is a separate knob; defaulting it to `configRoot` keeps
|
|
19
|
+
* single-directory test fixtures working.
|
|
20
|
+
*/
|
|
21
|
+
readonly settingsRoot?: string;
|
|
22
|
+
readonly logger?: (message: string) => void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The directory AGY reads `mcp_config.json` and `agents/` from.
|
|
26
|
+
*
|
|
27
|
+
* It is the `gemini-root-config` child of the CLI data directory. `agy mcp
|
|
28
|
+
* list`, `agy agents`, and `agy mcp add` all read and write this nested root —
|
|
29
|
+
* verified by adding a server with the CLI and watching only the nested copy
|
|
30
|
+
* change on disk.
|
|
31
|
+
*
|
|
32
|
+
* `HOME`/`USERPROFILE` overrides do not relocate this either: the CLI resolves
|
|
33
|
+
* it through the Windows known-folder API, so tests must pass `configRoot`
|
|
34
|
+
* rather than trying to sandbox the process environment.
|
|
35
|
+
*/
|
|
36
|
+
export declare function agyConfigRoot(): string;
|
|
37
|
+
/**
|
|
38
|
+
* The directory AGY reads `settings.json` from.
|
|
39
|
+
*
|
|
40
|
+
* Settings live one level *above* the MCP config, not beside it. Measured with
|
|
41
|
+
* a signed probe rule: a rule added to the nested `settings.json` never reaches
|
|
42
|
+
* the `Allow:[...]` table AGY logs at startup, while the same rule added to the
|
|
43
|
+
* outer file shows up immediately. Only the outer file is ever loaded.
|
|
44
|
+
*
|
|
45
|
+
* Getting this wrong is silent in the worst way: the nested file looks correct
|
|
46
|
+
* on disk, and every MCP tool call is then auto-denied in headless mode. AGY
|
|
47
|
+
* logs the real table as `CLI settings initialized: permissions=&{Allow:[...]}`,
|
|
48
|
+
* which is the fastest way to confirm which file won.
|
|
49
|
+
*/
|
|
50
|
+
export declare function agySettingsRoot(): string;
|
|
51
|
+
/** True when the entry was written by this plugin. */
|
|
52
|
+
export declare function isOwnedEntry(value: unknown): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The single allow rule this plugin needs: the server-scoped wildcard. AGY
|
|
55
|
+
* matches `mcp(<server>/*)` (the syntax its own binary documents, e.g.
|
|
56
|
+
* `mcp(chrome_devtools/*)`), so every DSH tool the bridge reports is granted
|
|
57
|
+
* without enumerating names — and a DSH tool added later needs no config edit.
|
|
58
|
+
*/
|
|
59
|
+
export declare function agyAllowRule(_toolName?: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* Write (or replace) this plugin's AGY MCP server entry. Returns the config
|
|
62
|
+
* path so callers can report it. Foreign entries are preserved untouched.
|
|
63
|
+
*/
|
|
64
|
+
export declare function registerAgyMcpServer(entry: AgyMcpEntry, options?: AgyMcpRegistrationOptions): Promise<AgyMcpConfigFile>;
|
|
65
|
+
/**
|
|
66
|
+
* Remove this plugin's AGY MCP server entry, leaving every other entry alone.
|
|
67
|
+
* Returns true when an entry was actually removed.
|
|
68
|
+
*/
|
|
69
|
+
export declare function unregisterAgyMcpServer(options?: AgyMcpRegistrationOptions): Promise<boolean>;
|
|
70
|
+
//# sourceMappingURL=agy-registration.d.ts.map
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register the DSH tool bridge as an MCP server inside the AGY CLI.
|
|
3
|
+
*
|
|
4
|
+
* AGY keeps its MCP server table in its own config root
|
|
5
|
+
* (`~/.gemini/antigravity-cli/gemini-root-config/`, see {@link agyConfigRoot})
|
|
6
|
+
* and its settings one level above that root (see {@link agySettingsRoot}), so
|
|
7
|
+
* this is the one place where the plugin writes outside the DSH profile.
|
|
8
|
+
* Three rules keep that honest:
|
|
9
|
+
*
|
|
10
|
+
* 1. every write is attributed — the entry is named and tagged by this plugin,
|
|
11
|
+
* so it can be identified again on the next start;
|
|
12
|
+
* 2. registration is bound to the plugin lifecycle: the entry is written when
|
|
13
|
+
* the provider becomes active and removed when it stops, so uninstalling or
|
|
14
|
+
* disabling the plugin does not leave a live endpoint behind;
|
|
15
|
+
* 3. an allow rule is written alongside the entry, because headless AGY cannot
|
|
16
|
+
* prompt for the `mcp` permission and auto-denies every MCP call without one
|
|
17
|
+
* (stderr: `a tool required the "mcp" permission that headless mode cannot
|
|
18
|
+
* prompt for, so it was auto-denied`). The rule is the server-scoped
|
|
19
|
+
* wildcard `mcp(<server>/*)` — the syntax AGY itself documents for its
|
|
20
|
+
* bundled servers (`mcp(chrome_devtools/*)` appears in the CLI binary).
|
|
21
|
+
* `mcp(<server>)` alone does not match, and the bare `mcp(*)` would hand
|
|
22
|
+
* AGY every MCP server on the machine. Enumerating one rule per tool was
|
|
23
|
+
* the original approach and is what forced this plugin to rewrite the file
|
|
24
|
+
* every time DSH changed its tool set; the wildcard makes DSH-side grants
|
|
25
|
+
* the only control point.
|
|
26
|
+
*
|
|
27
|
+
* The edit is a read-modify-write of the AGY config with a backup, matching
|
|
28
|
+
* what the AGY CLI itself does for `agy mcp add`. The AGY CLI is used only to
|
|
29
|
+
* locate the config root, never to mutate it: `agy mcp add` cannot express the
|
|
30
|
+
* "replace my previous entry" semantics this needs, and it has no equivalent
|
|
31
|
+
* for the permission rules.
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync } from 'node:fs';
|
|
34
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
import { homedir } from 'node:os';
|
|
37
|
+
/** The MCP server name AGY shows for this bridge. */
|
|
38
|
+
export const AGY_MCP_SERVER_NAME = 'dsh';
|
|
39
|
+
/** Marks an entry as owned by this plugin so cleanup never touches foreign servers. */
|
|
40
|
+
const OWNER_TAG = 'dsh-agy-cli';
|
|
41
|
+
/**
|
|
42
|
+
* The directory AGY reads `mcp_config.json` and `agents/` from.
|
|
43
|
+
*
|
|
44
|
+
* It is the `gemini-root-config` child of the CLI data directory. `agy mcp
|
|
45
|
+
* list`, `agy agents`, and `agy mcp add` all read and write this nested root —
|
|
46
|
+
* verified by adding a server with the CLI and watching only the nested copy
|
|
47
|
+
* change on disk.
|
|
48
|
+
*
|
|
49
|
+
* `HOME`/`USERPROFILE` overrides do not relocate this either: the CLI resolves
|
|
50
|
+
* it through the Windows known-folder API, so tests must pass `configRoot`
|
|
51
|
+
* rather than trying to sandbox the process environment.
|
|
52
|
+
*/
|
|
53
|
+
export function agyConfigRoot() {
|
|
54
|
+
const explicit = process.env.DSH_AGY_CONFIG_ROOT;
|
|
55
|
+
if (typeof explicit === 'string' && explicit.trim().length > 0)
|
|
56
|
+
return explicit.trim();
|
|
57
|
+
return join(homedir(), '.gemini', 'antigravity-cli', 'gemini-root-config');
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The directory AGY reads `settings.json` from.
|
|
61
|
+
*
|
|
62
|
+
* Settings live one level *above* the MCP config, not beside it. Measured with
|
|
63
|
+
* a signed probe rule: a rule added to the nested `settings.json` never reaches
|
|
64
|
+
* the `Allow:[...]` table AGY logs at startup, while the same rule added to the
|
|
65
|
+
* outer file shows up immediately. Only the outer file is ever loaded.
|
|
66
|
+
*
|
|
67
|
+
* Getting this wrong is silent in the worst way: the nested file looks correct
|
|
68
|
+
* on disk, and every MCP tool call is then auto-denied in headless mode. AGY
|
|
69
|
+
* logs the real table as `CLI settings initialized: permissions=&{Allow:[...]}`,
|
|
70
|
+
* which is the fastest way to confirm which file won.
|
|
71
|
+
*/
|
|
72
|
+
export function agySettingsRoot() {
|
|
73
|
+
const explicit = process.env.DSH_AGY_SETTINGS_ROOT;
|
|
74
|
+
if (typeof explicit === 'string' && explicit.trim().length > 0)
|
|
75
|
+
return explicit.trim();
|
|
76
|
+
return join(homedir(), '.gemini', 'antigravity-cli');
|
|
77
|
+
}
|
|
78
|
+
function isRecord(value) {
|
|
79
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
80
|
+
}
|
|
81
|
+
function mcpConfigPath(options) {
|
|
82
|
+
return join(options.configRoot ?? agyConfigRoot(), 'mcp_config.json');
|
|
83
|
+
}
|
|
84
|
+
function settingsPath(options) {
|
|
85
|
+
return join(options.settingsRoot ?? options.configRoot ?? agyConfigRoot(), 'settings.json');
|
|
86
|
+
}
|
|
87
|
+
async function readConfig(path) {
|
|
88
|
+
if (!existsSync(path))
|
|
89
|
+
return { mcpServers: {} };
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
92
|
+
if (!isRecord(parsed))
|
|
93
|
+
return { mcpServers: {} };
|
|
94
|
+
const servers = parsed.mcpServers;
|
|
95
|
+
return { ...parsed, mcpServers: isRecord(servers) ? { ...servers } : {} };
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
// Never overwrite a config that exists but cannot be parsed: that file is
|
|
99
|
+
// the user's, and silently replacing it would drop their other servers.
|
|
100
|
+
throw new Error(`AGY MCP config at ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** True when the entry was written by this plugin. */
|
|
104
|
+
export function isOwnedEntry(value) {
|
|
105
|
+
if (!isRecord(value))
|
|
106
|
+
return false;
|
|
107
|
+
const headers = value.headers;
|
|
108
|
+
if (!isRecord(headers))
|
|
109
|
+
return false;
|
|
110
|
+
return headers['x-dsh-owner'] === OWNER_TAG;
|
|
111
|
+
}
|
|
112
|
+
function entryFor(entry) {
|
|
113
|
+
return {
|
|
114
|
+
type: 'http',
|
|
115
|
+
url: entry.url,
|
|
116
|
+
headers: {
|
|
117
|
+
'x-dsh-owner': OWNER_TAG,
|
|
118
|
+
authorization: `Bearer ${entry.token}`,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async function writeConfig(path, root) {
|
|
123
|
+
await mkdir(dirname(path), { recursive: true });
|
|
124
|
+
const backup = `${path}.dsh-agy-cli.bak`;
|
|
125
|
+
if (existsSync(path) && !existsSync(backup)) {
|
|
126
|
+
await writeFile(backup, await readFile(path), { encoding: 'utf8' });
|
|
127
|
+
}
|
|
128
|
+
const temporary = `${path}.dsh-agy-cli.tmp`;
|
|
129
|
+
await writeFile(temporary, `${JSON.stringify(root, null, 2)}\n`, { encoding: 'utf8' });
|
|
130
|
+
await rename(temporary, path);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The single allow rule this plugin needs: the server-scoped wildcard. AGY
|
|
134
|
+
* matches `mcp(<server>/*)` (the syntax its own binary documents, e.g.
|
|
135
|
+
* `mcp(chrome_devtools/*)`), so every DSH tool the bridge reports is granted
|
|
136
|
+
* without enumerating names — and a DSH tool added later needs no config edit.
|
|
137
|
+
*/
|
|
138
|
+
export function agyAllowRule(_toolName) {
|
|
139
|
+
return `mcp(${AGY_MCP_SERVER_NAME}/*)`;
|
|
140
|
+
}
|
|
141
|
+
function isAllowRuleForThisBridge(rule) {
|
|
142
|
+
return rule.startsWith(`mcp(${AGY_MCP_SERVER_NAME}/`);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Read the AGY settings file. A file that exists but cannot be parsed is an
|
|
146
|
+
* error rather than an empty document: overwriting it would drop the user's
|
|
147
|
+
* trusted workspaces and unrelated permission rules.
|
|
148
|
+
*/
|
|
149
|
+
async function readSettings(path) {
|
|
150
|
+
if (!existsSync(path))
|
|
151
|
+
return {};
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
154
|
+
return isRecord(parsed) ? { ...parsed } : {};
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
throw new Error(`AGY settings at ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Add this bridge's allow rules to the AGY settings, replacing any rules left
|
|
162
|
+
* by a previous run and preserving every other entry verbatim.
|
|
163
|
+
*/
|
|
164
|
+
async function writeAllowRules(rules, options) {
|
|
165
|
+
const path = settingsPath(options);
|
|
166
|
+
const root = await readSettings(path);
|
|
167
|
+
const permissions = isRecord(root.permissions) ? { ...root.permissions } : {};
|
|
168
|
+
const existing = Array.isArray(permissions.allow) ? permissions.allow : [];
|
|
169
|
+
const foreign = existing.filter((rule) => typeof rule === 'string' && !isAllowRuleForThisBridge(rule));
|
|
170
|
+
if (rules.length === 0 && foreign.length === existing.length)
|
|
171
|
+
return path;
|
|
172
|
+
permissions.allow = [...foreign, ...rules];
|
|
173
|
+
await writeConfig(path, { ...root, permissions });
|
|
174
|
+
options.logger?.(`allowed ${rules.length} AGY MCP tool(s) in ${path}`);
|
|
175
|
+
return path;
|
|
176
|
+
}
|
|
177
|
+
/** Drop this bridge's allow rules, leaving foreign rules untouched. */
|
|
178
|
+
async function clearAllowRules(options) {
|
|
179
|
+
const path = settingsPath(options);
|
|
180
|
+
if (!existsSync(path))
|
|
181
|
+
return false;
|
|
182
|
+
const root = await readSettings(path);
|
|
183
|
+
if (!isRecord(root.permissions))
|
|
184
|
+
return false;
|
|
185
|
+
const permissions = { ...root.permissions };
|
|
186
|
+
const existing = Array.isArray(permissions.allow) ? permissions.allow : [];
|
|
187
|
+
const foreign = existing.filter((rule) => typeof rule === 'string' && !isAllowRuleForThisBridge(rule));
|
|
188
|
+
if (foreign.length === existing.length)
|
|
189
|
+
return false;
|
|
190
|
+
if (foreign.length === 0)
|
|
191
|
+
delete permissions.allow;
|
|
192
|
+
else
|
|
193
|
+
permissions.allow = foreign;
|
|
194
|
+
if (Object.keys(permissions).length === 0)
|
|
195
|
+
delete root.permissions;
|
|
196
|
+
else
|
|
197
|
+
root.permissions = permissions;
|
|
198
|
+
await writeConfig(path, root);
|
|
199
|
+
options.logger?.(`removed this plugin's AGY allow rules from ${path}`);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Write (or replace) this plugin's AGY MCP server entry. Returns the config
|
|
204
|
+
* path so callers can report it. Foreign entries are preserved untouched.
|
|
205
|
+
*/
|
|
206
|
+
export async function registerAgyMcpServer(entry, options = {}) {
|
|
207
|
+
const path = mcpConfigPath(options);
|
|
208
|
+
const root = await readConfig(path);
|
|
209
|
+
const servers = root.mcpServers;
|
|
210
|
+
servers[AGY_MCP_SERVER_NAME] = entryFor(entry);
|
|
211
|
+
await writeConfig(path, { ...root, mcpServers: servers });
|
|
212
|
+
options.logger?.(`registered AGY MCP server "${AGY_MCP_SERVER_NAME}" at ${path}`);
|
|
213
|
+
await writeAllowRules([agyAllowRule()], options);
|
|
214
|
+
return { path, root };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Remove this plugin's AGY MCP server entry, leaving every other entry alone.
|
|
218
|
+
* Returns true when an entry was actually removed.
|
|
219
|
+
*/
|
|
220
|
+
export async function unregisterAgyMcpServer(options = {}) {
|
|
221
|
+
const path = mcpConfigPath(options);
|
|
222
|
+
if (!existsSync(path))
|
|
223
|
+
return false;
|
|
224
|
+
const root = await readConfig(path);
|
|
225
|
+
const servers = root.mcpServers;
|
|
226
|
+
const current = servers[AGY_MCP_SERVER_NAME];
|
|
227
|
+
if (current === undefined) {
|
|
228
|
+
// The entry can be gone while the allow rules survive a crashed session;
|
|
229
|
+
// clearing them is still this plugin's job.
|
|
230
|
+
await clearAllowRules(options).catch(() => false);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
// Only remove an entry this plugin wrote; a user-managed server that happens
|
|
234
|
+
// to be named "dsh" is theirs to keep.
|
|
235
|
+
if (!isOwnedEntry(current)) {
|
|
236
|
+
options.logger?.(`AGY MCP server "${AGY_MCP_SERVER_NAME}" is not owned by this plugin; leaving it in place`);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
delete servers[AGY_MCP_SERVER_NAME];
|
|
240
|
+
await writeConfig(path, { ...root, mcpServers: servers });
|
|
241
|
+
options.logger?.(`removed AGY MCP server "${AGY_MCP_SERVER_NAME}" from ${path}`);
|
|
242
|
+
await clearAllowRules(options).catch(() => false);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
//# sourceMappingURL=agy-registration.js.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type DshWebServerLike, type McpEndpoint } from './endpoint.js';
|
|
2
|
+
export type { DshWebServerLike, McpEndpoint };
|
|
3
|
+
import { agyConfigRoot, agySettingsRoot, AGY_MCP_SERVER_NAME } from './agy-registration.js';
|
|
4
|
+
import { type DshToolRegistryLike } from './tool-projection.js';
|
|
5
|
+
/** The subset of the DSH tool registry this bridge uses. */
|
|
6
|
+
export interface DshToolRuntimeLike extends DshToolRegistryLike {
|
|
7
|
+
execute(execution: {
|
|
8
|
+
readonly callId: unknown;
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly arguments: unknown;
|
|
11
|
+
readonly agent?: unknown;
|
|
12
|
+
readonly signal: AbortSignal;
|
|
13
|
+
}): Promise<DshToolExecutionResultLike>;
|
|
14
|
+
}
|
|
15
|
+
export interface DshToolExecutionResultLike {
|
|
16
|
+
readonly isError: boolean;
|
|
17
|
+
readonly error?: {
|
|
18
|
+
readonly message?: unknown;
|
|
19
|
+
} | undefined;
|
|
20
|
+
readonly value?: unknown;
|
|
21
|
+
readonly content?: readonly unknown[];
|
|
22
|
+
}
|
|
23
|
+
/** The host service that resolves a session id to its live Agent. */
|
|
24
|
+
export interface DshSessionControllerLike {
|
|
25
|
+
resolveAgent(sessionId: unknown): Promise<unknown>;
|
|
26
|
+
}
|
|
27
|
+
export interface DshMcpBridgeOptions {
|
|
28
|
+
readonly webServer: DshWebServerLike;
|
|
29
|
+
readonly tools: DshToolRuntimeLike;
|
|
30
|
+
readonly sessionController?: DshSessionControllerLike | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Late-bound `sessionController` lookup.
|
|
33
|
+
*
|
|
34
|
+
* The bridge starts while this plugin loads, and the Session controller is
|
|
35
|
+
* mounted by a different bundle: reading the service once at start can pin
|
|
36
|
+
* `undefined` for the whole host lifetime, which silently degrades every
|
|
37
|
+
* scoped query to the global tool view (the file and shell tools live on the
|
|
38
|
+
* agent plane). Resolving per turn removes the load-order dependency.
|
|
39
|
+
*/
|
|
40
|
+
readonly sessionControllerLookup?: (() => DshSessionControllerLike | undefined) | undefined;
|
|
41
|
+
/** Optional allowlist of DSH tool names exposed to AGY. */
|
|
42
|
+
readonly toolAllowlist?: readonly string[] | undefined;
|
|
43
|
+
readonly serverVersion: string;
|
|
44
|
+
/** Overrides the AGY MCP-config root; tests pass a temporary directory. */
|
|
45
|
+
readonly agyConfigRoot?: string | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Overrides the AGY settings root. `agySettingsRoot()` is the production
|
|
48
|
+
* default (the outer directory); this knob only exists so a test can
|
|
49
|
+
* keep both files in one temporary directory.
|
|
50
|
+
*/
|
|
51
|
+
readonly agySettingsRoot?: string | undefined;
|
|
52
|
+
readonly logger?: (message: string) => void;
|
|
53
|
+
}
|
|
54
|
+
export interface DshMcpBridge {
|
|
55
|
+
/** The bridge is live: endpoint mounted and AGY config entry written. */
|
|
56
|
+
readonly active: boolean;
|
|
57
|
+
/** Why the bridge is not active, when it is not. */
|
|
58
|
+
readonly inactiveReason: string | undefined;
|
|
59
|
+
readonly endpoint: McpEndpoint | undefined;
|
|
60
|
+
/** URL AGY reaches the bridge on; undefined until the bridge is live. */
|
|
61
|
+
readonly url: string | undefined;
|
|
62
|
+
/** Point the bridge at the DSH session owning the next AGY turn. */
|
|
63
|
+
readonly setSession: (sessionId: string) => void;
|
|
64
|
+
readonly dispose: () => Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Bring the bridge up. Every failure mode resolves to `active: false` with a
|
|
68
|
+
* reason instead of throwing, so the provider can fall back to the prompt
|
|
69
|
+
* contract path when the carrier or the tool registry is not available.
|
|
70
|
+
*/
|
|
71
|
+
export declare function startDshMcpBridge(options: DshMcpBridgeOptions): Promise<DshMcpBridge>;
|
|
72
|
+
export { AGY_MCP_SERVER_NAME, agyConfigRoot, agySettingsRoot };
|
|
73
|
+
//# sourceMappingURL=bridge.d.ts.map
|