@functional-systems/lambdadb-cli 0.1.4 → 0.1.5-dev.20
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/CHANGELOG.md +19 -0
- package/CONTRIBUTING.md +27 -0
- package/README.md +210 -3
- package/dist/agent-config.d.ts +21 -0
- package/dist/agent-config.js +124 -0
- package/dist/agent-config.js.map +1 -0
- package/dist/agents.d.ts +113 -0
- package/dist/agents.js +196 -0
- package/dist/agents.js.map +1 -0
- package/dist/cli.js +47 -12
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/init.d.ts +31 -0
- package/dist/init.js +211 -0
- package/dist/init.js.map +1 -0
- package/dist/mcp.d.ts +4 -0
- package/dist/mcp.js +46 -0
- package/dist/mcp.js.map +1 -0
- package/dist/runtime.d.ts +8 -0
- package/dist/runtime.js +8 -0
- package/dist/runtime.js.map +1 -1
- package/package.json +7 -3
- package/skills/lambdadb/SKILL.md +35 -0
- package/src/agent-config.ts +112 -0
- package/src/agents.ts +212 -0
- package/src/cli.ts +42 -8
- package/src/config.ts +2 -2
- package/src/init.ts +185 -0
- package/src/mcp.ts +45 -0
- package/src/runtime.ts +9 -0
package/src/agents.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { delimiter, join, resolve } from 'node:path';
|
|
4
|
+
import { jsonConfig, strictJsonConfig, tomlConfig, type McpLaunch } from './agent-config.js';
|
|
5
|
+
import { InputError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
export type AgentScope = 'project' | 'user';
|
|
8
|
+
export type AgentContext = { cwd: string; home: string; env: NodeJS.ProcessEnv; platform?: NodeJS.Platform };
|
|
9
|
+
type AgentPaths = { mcp: string; skill?: string };
|
|
10
|
+
export type AgentAdapter = {
|
|
11
|
+
id: string;
|
|
12
|
+
executable: string | readonly string[];
|
|
13
|
+
scopes?: readonly AgentScope[];
|
|
14
|
+
detectionDirectories(context: AgentContext): string[];
|
|
15
|
+
paths(scope: AgentScope, context: AgentContext): AgentPaths | Promise<AgentPaths>;
|
|
16
|
+
configure(path: string, text: string | undefined, launch: McpLaunch): string;
|
|
17
|
+
instructions: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const codexServer = ({ command, args, apiKeyEnv }: McpLaunch) => ({ command, args, env_vars: [apiKeyEnv] });
|
|
21
|
+
const claudeServer = ({ command, args, apiKeyEnv }: McpLaunch) => ({
|
|
22
|
+
type: 'stdio', command, args, env: { [apiKeyEnv]: `\${${apiKeyEnv}}` },
|
|
23
|
+
});
|
|
24
|
+
const cursorServer = ({ command, args, apiKeyEnv }: McpLaunch) => ({
|
|
25
|
+
type: 'stdio', command, args, env: { [apiKeyEnv]: `\${env:${apiKeyEnv}}` },
|
|
26
|
+
});
|
|
27
|
+
const geminiServer = ({ command, args, apiKeyEnv }: McpLaunch) => ({
|
|
28
|
+
command, args, env: { [apiKeyEnv]: `\${${apiKeyEnv}}` },
|
|
29
|
+
});
|
|
30
|
+
const opencodeServer = ({ command, args, apiKeyEnv }: McpLaunch) => ({
|
|
31
|
+
type: 'local', command: [command, ...args], environment: { [apiKeyEnv]: `{env:${apiKeyEnv}}` },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function vscodeUserDirectory({ cwd, home, env, platform = process.platform }: AgentContext): string {
|
|
35
|
+
if (env.VSCODE_PORTABLE) return resolve(cwd, env.VSCODE_PORTABLE, 'user-data', 'User');
|
|
36
|
+
const appData = env.VSCODE_APPDATA ?? (platform === 'darwin' ? join(home, 'Library', 'Application Support')
|
|
37
|
+
: platform === 'win32' ? env.APPDATA ?? join(home, 'AppData', 'Roaming')
|
|
38
|
+
: env.XDG_CONFIG_HOME ?? join(home, '.config'));
|
|
39
|
+
return resolve(cwd, appData, 'Code', 'User');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function geminiDirectory({ cwd, home, env }: AgentContext): string {
|
|
43
|
+
return resolve(cwd, env.GEMINI_CLI_HOME ?? home, '.gemini');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function opencodeDirectory({ cwd, home, env }: AgentContext): string {
|
|
47
|
+
return resolve(cwd, env.XDG_CONFIG_HOME ?? join(home, '.config'), 'opencode');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function opencodePaths(scope: AgentScope, context: AgentContext): Promise<AgentPaths> {
|
|
51
|
+
const root = scope === 'user' ? opencodeDirectory(context) : context.cwd;
|
|
52
|
+
const json = join(root, 'opencode.json');
|
|
53
|
+
const jsonc = join(root, 'opencode.jsonc');
|
|
54
|
+
const [hasJson, hasJsonc] = await Promise.all([exists(json), exists(jsonc)]);
|
|
55
|
+
if (hasJson && hasJsonc) throw new InputError(`Both ${json} and ${jsonc} exist. Consolidate the active OpenCode configuration before running init so no LambdaDB entry is shadowed.`);
|
|
56
|
+
return { mcp: hasJsonc ? jsonc : json, skill: join(scope === 'user' ? root : join(root, '.opencode'), 'skills', 'lambdadb', 'SKILL.md') };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function opencodeMapPath(value: Record<string, unknown>): readonly string[] {
|
|
60
|
+
const mcp = value.mcp;
|
|
61
|
+
if (mcp && typeof mcp === 'object' && !Array.isArray(mcp) && 'servers' in mcp) {
|
|
62
|
+
const servers = mcp.servers;
|
|
63
|
+
// A V1 server may itself be named "servers". Preserve that server's shape.
|
|
64
|
+
const legacyServer = servers && typeof servers === 'object' && 'type' in servers && typeof servers.type === 'string';
|
|
65
|
+
if (!legacyServer) {
|
|
66
|
+
if ('lambdadb' in mcp) throw new InputError('OpenCode has both a V1 LambdaDB entry and a V2 MCP server map. Consolidate the LambdaDB entry before rerunning init.');
|
|
67
|
+
return ['mcp', 'servers'];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// V1 configuration remains supported by V2; never migrate unrelated settings.
|
|
71
|
+
return ['mcp'];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function copilotDirectory({ cwd, home, env }: AgentContext): string {
|
|
75
|
+
return resolve(cwd, env.COPILOT_HOME ?? join(home, '.copilot'));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function copilotPaths(scope: AgentScope, context: AgentContext): Promise<AgentPaths> {
|
|
79
|
+
if (scope === 'user') return { mcp: join(copilotDirectory(context), 'mcp-config.json'),
|
|
80
|
+
skill: join(copilotDirectory(context), 'skills', 'lambdadb', 'SKILL.md') };
|
|
81
|
+
const rootConfig = join(context.cwd, '.mcp.json');
|
|
82
|
+
const sharedConfig = join(context.cwd, '.github', 'mcp.json');
|
|
83
|
+
return { mcp: await exists(rootConfig) || !await exists(sharedConfig) ? rootConfig : sharedConfig,
|
|
84
|
+
skill: join(context.cwd, '.github', 'skills', 'lambdadb', 'SKILL.md') };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function copilotMapPath(value: Record<string, unknown>): readonly string[] {
|
|
88
|
+
if ('mcpServers' in value || Object.keys(value).length === 0) return ['mcpServers'];
|
|
89
|
+
// Copilot also reads bare maps. Do not hide those entries behind a new wrapper.
|
|
90
|
+
const bare = Object.values(value).every(entry => entry && typeof entry === 'object' && !Array.isArray(entry)
|
|
91
|
+
&& ('command' in entry || 'url' in entry || 'type' in entry));
|
|
92
|
+
if (!bare) throw new InputError('Copilot MCP configuration must contain mcpServers or a bare map of server definitions. Fix it before rerunning init.');
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function clineSettingsDirectory(context: AgentContext): string {
|
|
97
|
+
return join(vscodeUserDirectory(context), 'globalStorage', 'saoudrizwan.claude-dev', 'settings');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Register new agents here; init consumes this contract without agent-specific branches.
|
|
101
|
+
export const agentAdapters = [
|
|
102
|
+
{
|
|
103
|
+
id: 'codex', executable: 'codex',
|
|
104
|
+
detectionDirectories: ({ cwd, home, env }) => [env.CODEX_HOME ?? join(home, '.codex'), join(cwd, '.codex')],
|
|
105
|
+
paths(scope, { cwd, home, env }) {
|
|
106
|
+
const root = scope === 'user' ? home : cwd;
|
|
107
|
+
const directory = scope === 'user' ? resolve(env.CODEX_HOME ?? join(root, '.codex')) : join(root, '.codex');
|
|
108
|
+
return { mcp: join(directory, 'config.toml'), skill: join(root, '.agents', 'skills', 'lambdadb', 'SKILL.md') };
|
|
109
|
+
},
|
|
110
|
+
configure: (path, text, launch) => tomlConfig(path, text, launch, codexServer),
|
|
111
|
+
instructions: 'Codex: trust the project if using project scope, start a new session, and check /mcp.',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'claude', executable: 'claude',
|
|
115
|
+
detectionDirectories: ({ cwd, home, env }) => [env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), join(cwd, '.claude')],
|
|
116
|
+
paths(scope, { cwd, home, env }) {
|
|
117
|
+
const root = scope === 'user' ? home : cwd;
|
|
118
|
+
const directory = scope === 'user' ? resolve(env.CLAUDE_CONFIG_DIR ?? join(root, '.claude')) : join(root, '.claude');
|
|
119
|
+
return { mcp: scope === 'user' ? join(env.CLAUDE_CONFIG_DIR ? directory : root, '.claude.json') : join(root, '.mcp.json'),
|
|
120
|
+
skill: join(directory, 'skills', 'lambdadb', 'SKILL.md') };
|
|
121
|
+
},
|
|
122
|
+
configure: (path, text, launch) => jsonConfig(path, text, launch, claudeServer),
|
|
123
|
+
instructions: 'Claude Code: restart and approve the project MCP server if prompted; check /mcp.',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'cursor', executable: ['cursor', 'cursor-agent'],
|
|
127
|
+
detectionDirectories: ({ cwd, home }) => [join(home, '.cursor'), join(cwd, '.cursor')],
|
|
128
|
+
paths(scope, { cwd, home }) {
|
|
129
|
+
const root = scope === 'user' ? home : cwd;
|
|
130
|
+
return { mcp: join(root, '.cursor', 'mcp.json'), skill: join(root, '.cursor', 'skills', 'lambdadb', 'SKILL.md') };
|
|
131
|
+
},
|
|
132
|
+
configure: (path, text, launch) => jsonConfig(path, text, launch, cursorServer),
|
|
133
|
+
instructions: 'Cursor: restart/reload and check MCP settings.',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: 'vscode', executable: 'code',
|
|
137
|
+
detectionDirectories: context => [join(context.home, '.vscode'), join(context.cwd, '.vscode'), vscodeUserDirectory(context)],
|
|
138
|
+
paths(scope, context) {
|
|
139
|
+
return { mcp: scope === 'user' ? join(vscodeUserDirectory(context), 'mcp.json') : join(context.cwd, '.vscode', 'mcp.json'),
|
|
140
|
+
skill: join(scope === 'user' ? context.home : context.cwd, scope === 'user' ? '.copilot' : '.github', 'skills', 'lambdadb', 'SKILL.md') };
|
|
141
|
+
},
|
|
142
|
+
configure: (path, text, launch) => jsonConfig(path, text, launch, cursorServer, ['servers']),
|
|
143
|
+
instructions: 'VS Code: reload the window, trust the workspace, and use MCP: List Servers to start/check lambdadb. Init targets the Local harness and the default user profile, not portable Copilot or remote profiles.',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: 'gemini', executable: 'gemini',
|
|
147
|
+
detectionDirectories: context => [geminiDirectory(context), join(context.cwd, '.gemini')],
|
|
148
|
+
paths(scope, context) {
|
|
149
|
+
const root = scope === 'user' ? geminiDirectory(context) : join(context.cwd, '.gemini');
|
|
150
|
+
return { mcp: join(root, 'settings.json'), skill: join(root, 'skills', 'lambdadb', 'SKILL.md') };
|
|
151
|
+
},
|
|
152
|
+
configure: (path, text, launch) => jsonConfig(path, text, launch, geminiServer),
|
|
153
|
+
instructions: 'Gemini CLI: restart or run /mcp refresh and /skills reload, trust the workspace if prompted, and check /mcp and /skills list.',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: 'opencode', executable: 'opencode',
|
|
157
|
+
detectionDirectories: context => [opencodeDirectory(context), join(context.cwd, '.opencode'), join(context.cwd, 'opencode.json'), join(context.cwd, 'opencode.jsonc')],
|
|
158
|
+
paths: opencodePaths,
|
|
159
|
+
configure: (path, text, launch) => jsonConfig(path, text, launch, opencodeServer, opencodeMapPath),
|
|
160
|
+
instructions: 'OpenCode: restart and run opencode mcp list. Init preserves existing V2 mcp.servers maps; new files use V1 syntax supported by V1 and V2. Custom config files and organization policies may override these settings.',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: 'copilot', executable: 'copilot',
|
|
164
|
+
detectionDirectories: context => [copilotDirectory(context), join(context.cwd, '.github', 'mcp.json')],
|
|
165
|
+
paths: copilotPaths,
|
|
166
|
+
configure: (path, text, launch) => strictJsonConfig(path, text, launch, claudeServer, copilotMapPath),
|
|
167
|
+
instructions: 'GitHub Copilot CLI: restart from the environment holding the selected key, trust the repository, then check /mcp and run /skills reload. Project MCP settings override user settings.',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'kiro', executable: ['kiro', 'kiro-cli'],
|
|
171
|
+
detectionDirectories: ({ cwd, home }) => [join(home, '.kiro'), join(cwd, '.kiro')],
|
|
172
|
+
paths(scope, { cwd, home }) {
|
|
173
|
+
const directory = join(scope === 'user' ? home : cwd, '.kiro');
|
|
174
|
+
return { mcp: join(directory, 'settings', 'mcp.json'), skill: join(directory, 'skills', 'lambdadb', 'SKILL.md') };
|
|
175
|
+
},
|
|
176
|
+
configure: (path, text, launch) => jsonConfig(path, text, launch, geminiServer),
|
|
177
|
+
instructions: 'Kiro IDE/CLI: restart from the environment holding the selected key and check /mcp. Approve the selected variable in Mcp Approved Env Vars when prompted. Custom agents must include the MCP JSON and skill resources; init does not alter agent permissions or definitions.',
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: 'cline', executable: [], scopes: ['user'],
|
|
181
|
+
detectionDirectories: context => [clineSettingsDirectory(context)],
|
|
182
|
+
paths(_scope, context) {
|
|
183
|
+
return { mcp: join(clineSettingsDirectory(context), 'cline_mcp_settings.json'),
|
|
184
|
+
skill: join(context.home, '.cline', 'skills', 'lambdadb', 'SKILL.md') };
|
|
185
|
+
},
|
|
186
|
+
configure: (path, text, launch) => strictJsonConfig(path, text, launch, cursorServer),
|
|
187
|
+
instructions: 'Cline VS Code extension: reload VS Code from the environment holding the selected key and check MCP Servers and the Skills menu. Init targets the default stable VS Code profile, not Cline CLI or other editors/profiles. No tools are auto-approved.',
|
|
188
|
+
},
|
|
189
|
+
] as const satisfies readonly AgentAdapter[];
|
|
190
|
+
|
|
191
|
+
export type Agent = typeof agentAdapters[number]['id'];
|
|
192
|
+
export const agentNames = agentAdapters.map(adapter => adapter.id).join(',');
|
|
193
|
+
|
|
194
|
+
async function exists(path: string, executable = false) {
|
|
195
|
+
try { await access(path, executable ? constants.X_OK : constants.F_OK); return true; }
|
|
196
|
+
catch { return false; }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function detectAgents(context: AgentContext): Promise<Agent[]> {
|
|
200
|
+
const paths = (context.env.PATH ?? '').split(delimiter);
|
|
201
|
+
const found = await Promise.all(agentAdapters.map(async adapter => {
|
|
202
|
+
const executables = typeof adapter.executable === 'string' ? [adapter.executable] : adapter.executable;
|
|
203
|
+
const commands = executables.flatMap(executable => process.platform === 'win32'
|
|
204
|
+
? [executable + '.exe', executable + '.cmd', executable] : [executable]);
|
|
205
|
+
const matches = await Promise.all([
|
|
206
|
+
...adapter.detectionDirectories(context).map(path => exists(path)),
|
|
207
|
+
...paths.flatMap(path => commands.map(command => exists(join(path, command), true))),
|
|
208
|
+
]);
|
|
209
|
+
return matches.some(Boolean) ? adapter.id : undefined;
|
|
210
|
+
}));
|
|
211
|
+
return found.filter((agent): agent is Agent => agent !== undefined);
|
|
212
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -6,7 +6,10 @@ import { createInput, parseRef, queryInput, readJson, readJsonl, updateInput } f
|
|
|
6
6
|
import { importDocuments, planBatches } from './import-workflow.js';
|
|
7
7
|
import { definitelyRejected, describeError, InputError, isInputError } from './errors.js';
|
|
8
8
|
import { Output, type Envelope } from './output.js';
|
|
9
|
-
import { runtime } from './runtime.js';
|
|
9
|
+
import { checkConnection, runtime } from './runtime.js';
|
|
10
|
+
import { initialize } from './init.js';
|
|
11
|
+
import { serveMcp } from './mcp.js';
|
|
12
|
+
import { agentNames } from './agents.js';
|
|
10
13
|
|
|
11
14
|
const output = new Output();
|
|
12
15
|
output.protect(process.env.LAMBDADB_API_KEY);
|
|
@@ -17,7 +20,7 @@ let mutationStarted = false;
|
|
|
17
20
|
|
|
18
21
|
const program = new Command()
|
|
19
22
|
.name('lambdadb')
|
|
20
|
-
.description('Project-scoped LambdaDB CLI.
|
|
23
|
+
.description('Project-scoped LambdaDB CLI. Only init prompts; all other commands are non-interactive.')
|
|
21
24
|
.version(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version)
|
|
22
25
|
.option('--json', 'Emit one versioned JSON result on stdout; diagnostics stay on stderr')
|
|
23
26
|
.option('--endpoint <url>', 'API origin (overrides LAMBDADB_ENDPOINT and saved configuration)')
|
|
@@ -51,6 +54,11 @@ function scoped(command: Command): string {
|
|
|
51
54
|
return collection;
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
program.hook('preSubcommand', (_parent, command) => {
|
|
58
|
+
// Reserve MCP stdout even when subcommand option parsing fails before preAction.
|
|
59
|
+
if (command.name() === 'mcp') commandName = 'mcp';
|
|
60
|
+
});
|
|
61
|
+
|
|
54
62
|
program.hook('preAction', (_parent, command) => {
|
|
55
63
|
output.json = !!command.optsWithGlobals().json;
|
|
56
64
|
commandName = command.name();
|
|
@@ -64,14 +72,37 @@ program.command('configure')
|
|
|
64
72
|
result(saved, 'Configuration saved. Supply the key in the selected environment variable, then run doctor.');
|
|
65
73
|
});
|
|
66
74
|
|
|
75
|
+
program.command('init')
|
|
76
|
+
.description('Connect an existing project and install agent MCP configuration and a usage skill')
|
|
77
|
+
.option('--non-interactive', 'Require explicit agent/scope choices; never prompt')
|
|
78
|
+
.option('--agents <names>', `Comma-separated ${agentNames} subset, or none`)
|
|
79
|
+
.option('--scope <scope>', 'Agent configuration scope: project (current directory) or user')
|
|
80
|
+
.option('--write-tools', 'Explicitly enable MCP write tools (default: read-only)')
|
|
81
|
+
.addHelpText('after', '\nObtain endpoint, project and a project API key manually from https://console.lambdadb.ai.\nExport the key securely before running init; no key value is saved.\nAutomation example:\n lambdadb init --non-interactive --agents codex,claude,cursor --scope project --endpoint https://api.lambdadb.ai --project my-project --json')
|
|
82
|
+
.action(async (_opts, cmd: Command) => {
|
|
83
|
+
const data = await initialize(cmd.optsWithGlobals(), output);
|
|
84
|
+
target = { endpoint: data.settings.endpoint, project: data.settings.project };
|
|
85
|
+
result(data, 'Initialization complete. Authentication and collection-list access verified; search readiness is not verified.');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
program.command('mcp')
|
|
89
|
+
.description('Launch the existing stdio MCP package with runtime credential forwarding (used by init)')
|
|
90
|
+
.option('--write-tools', 'Explicitly enable MCP write tools (default: read-only)')
|
|
91
|
+
.action(async (_opts, cmd: Command) => {
|
|
92
|
+
const opts = cmd.optsWithGlobals();
|
|
93
|
+
if (opts.json) throw new InputError('The mcp command uses stdout exclusively for MCP; remove --json.');
|
|
94
|
+
const config = await resolveConfig(opts, true);
|
|
95
|
+
output.protect(config.apiKey);
|
|
96
|
+
if ([`\${${config.apiKeyEnv}}`, `\${env:${config.apiKeyEnv}}`, `{env:${config.apiKeyEnv}}`].includes(config.apiKey)) {
|
|
97
|
+
throw new InputError(`The agent did not expand ${config.apiKeyEnv}. Export it securely in the agent launch environment and restart the agent.`);
|
|
98
|
+
}
|
|
99
|
+
process.exitCode = await serveMcp(config, !!opts.writeTools);
|
|
100
|
+
});
|
|
101
|
+
|
|
67
102
|
program.command('doctor')
|
|
68
103
|
.description('Check configuration, authentication and project collection-list access')
|
|
69
104
|
.action(async (_opts, cmd: Command) => connected(cmd, async ({ client, options }) => {
|
|
70
|
-
await
|
|
71
|
-
result({ checks: [
|
|
72
|
-
{ name: 'configuration', status: 'passed' },
|
|
73
|
-
{ name: 'authentication_and_project_collection_list', status: 'passed' },
|
|
74
|
-
], writesVerified: false, queryReadinessVerified: false }, 'Connection verified using the project collection-list API.');
|
|
105
|
+
result(await checkConnection({ client, options }), 'Connection verified using the project collection-list API.');
|
|
75
106
|
}));
|
|
76
107
|
|
|
77
108
|
const collections = program.command('collections').description('List, describe, create and update collections');
|
|
@@ -218,7 +249,10 @@ try {
|
|
|
218
249
|
? new InputError('Invalid or incomplete command. Run lambdadb --help or the command-specific --help.') : error;
|
|
219
250
|
const unknownWrite = mutationStarted && !definitelyRejected(failure);
|
|
220
251
|
const exitCode = isInputError(failure) ? 2 : unknownWrite ? 5 : 3;
|
|
221
|
-
|
|
252
|
+
if (commandName === 'mcp') {
|
|
253
|
+
const safe = describeError(failure);
|
|
254
|
+
output.diagnostic(`${safe.message} ${safe.hint}`);
|
|
255
|
+
} else output.emit({
|
|
222
256
|
schemaVersion: 1, command: commandName, ok: false, target,
|
|
223
257
|
...(unknownWrite ? { data: { state: 'unknown', searchable: 'not_verified' } } : {}),
|
|
224
258
|
error: describeError(failure),
|
package/src/config.ts
CHANGED
|
@@ -72,8 +72,8 @@ export async function settings(options: Options, creating = false): Promise<Requ
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export async function resolveConfig(options: Options): Promise<Config> {
|
|
76
|
-
const selected = await settings(options);
|
|
75
|
+
export async function resolveConfig(options: Options, creating = false): Promise<Config> {
|
|
76
|
+
const selected = await settings(options, creating);
|
|
77
77
|
const apiKey = process.env[selected.apiKeyEnv];
|
|
78
78
|
if (!apiKey || !apiKey.trim() || /[\r\n]/.test(apiKey)) throw new InputError('The selected API-key environment variable is missing or invalid. Supply it through your shell or CI secret manager.');
|
|
79
79
|
return {
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, dirname, join } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { configPath, loadSettings, resolveConfig, saveSettings, settings, type Options, type Settings } from './config.js';
|
|
7
|
+
import { InputError } from './errors.js';
|
|
8
|
+
import type { Output } from './output.js';
|
|
9
|
+
import { checkConnection, runtime } from './runtime.js';
|
|
10
|
+
import { mcpPackage } from './mcp.js';
|
|
11
|
+
import { mcpLaunch } from './agent-config.js';
|
|
12
|
+
import { agentAdapters, agentNames, detectAgents, type Agent, type AgentAdapter, type AgentContext, type AgentScope } from './agents.js';
|
|
13
|
+
|
|
14
|
+
type InitOptions = Options & { nonInteractive?: boolean; agents?: string; scope?: string; writeTools?: boolean; json?: boolean };
|
|
15
|
+
type Change = { path: string; before: string | undefined; after: string; kind: string };
|
|
16
|
+
type PlannedChange = Change & { destination: string; aliases: string[] };
|
|
17
|
+
const version = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version as string;
|
|
18
|
+
|
|
19
|
+
function selectedAgents(value: string | undefined): Agent[] {
|
|
20
|
+
if (value === 'none') return [];
|
|
21
|
+
const values = value?.split(',').map(v => v.trim());
|
|
22
|
+
if (!values?.length || values.some(v => !agentAdapters.some(adapter => adapter.id === v))) {
|
|
23
|
+
throw new InputError(`Choose --agents ${agentNames} (comma-separated subset), or --agents none.`);
|
|
24
|
+
}
|
|
25
|
+
return [...new Set(values)] as Agent[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function availableScopes(chosen: Agent[]): AgentScope[] {
|
|
29
|
+
return (['project', 'user'] as const).filter(scope => chosen.every(agent => {
|
|
30
|
+
const adapter: AgentAdapter = agentAdapters.find(adapter => adapter.id === agent)!;
|
|
31
|
+
return !adapter.scopes || adapter.scopes.includes(scope);
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readOptional(path: string): Promise<string | undefined> {
|
|
36
|
+
try { return await readFile(path, 'utf8'); }
|
|
37
|
+
catch (error) {
|
|
38
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
39
|
+
throw new InputError(`Cannot read ${path}. Check file permissions.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fileIdentity(path: string): Promise<string> {
|
|
44
|
+
try { return await realpath(path); }
|
|
45
|
+
catch (error) {
|
|
46
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new InputError(`Cannot resolve configuration path: ${path}.`);
|
|
47
|
+
const info = await lstat(path).catch(error => {
|
|
48
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
49
|
+
throw new InputError(`Cannot resolve configuration path: ${path}.`);
|
|
50
|
+
});
|
|
51
|
+
if (info?.isSymbolicLink()) throw new InputError(`Configuration symbolic link has a missing target: ${path}. Restore its target before rerunning init.`);
|
|
52
|
+
return join(await fileIdentity(dirname(path)), basename(path));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function plan(selected: Required<Settings>, chosen: Agent[], scope: AgentScope, options: InitOptions, context: AgentContext): Promise<PlannedChange[]> {
|
|
57
|
+
const changes: Change[] = [];
|
|
58
|
+
const path = configPath(options);
|
|
59
|
+
changes.push({ path, before: await readOptional(path), after: JSON.stringify(selected, null, 2) + '\n', kind: 'CLI settings (no key)' });
|
|
60
|
+
const skill = await readFile(new URL('../skills/lambdadb/SKILL.md', import.meta.url), 'utf8');
|
|
61
|
+
for (const agent of chosen) {
|
|
62
|
+
const adapter: AgentAdapter = agentAdapters.find(adapter => adapter.id === agent)!;
|
|
63
|
+
const paths = await adapter.paths(scope, context);
|
|
64
|
+
const before = await readOptional(paths.mcp);
|
|
65
|
+
const after = adapter.configure(paths.mcp, before, mcpLaunch(selected, !!options.writeTools, version));
|
|
66
|
+
changes.push({ path: paths.mcp, before, after, kind: `${agent} MCP (${scope})` });
|
|
67
|
+
const skillPath = paths.skill;
|
|
68
|
+
if (!skillPath) continue;
|
|
69
|
+
const previousSkill = await readOptional(skillPath);
|
|
70
|
+
// Preserve existing skills, including user edits, rather than taking ownership.
|
|
71
|
+
changes.push({ path: skillPath, before: previousSkill, after: previousSkill ?? skill, kind: `${agent} skill (${scope}${previousSkill && previousSkill !== skill ? ', existing skill preserved' : ''})` });
|
|
72
|
+
}
|
|
73
|
+
const unique = new Map<string, PlannedChange>();
|
|
74
|
+
for (const change of changes) {
|
|
75
|
+
const identity = await fileIdentity(change.path);
|
|
76
|
+
const previous = unique.get(identity);
|
|
77
|
+
if (!previous) { unique.set(identity, { ...change, destination: identity, aliases: [] }); continue; }
|
|
78
|
+
if (change.path === configPath(options) || previous.path === configPath(options)) {
|
|
79
|
+
throw new InputError('The CLI --config path overlaps an agent configuration or skill path. Choose a separate CLI configuration path and rerun init.');
|
|
80
|
+
}
|
|
81
|
+
if (previous.before !== change.before || previous.after !== change.after) {
|
|
82
|
+
throw new InputError(`Agents request conflicting changes to ${change.path}. Select them separately and inspect the shared file before retrying.`);
|
|
83
|
+
}
|
|
84
|
+
previous.kind += `; ${change.kind}`;
|
|
85
|
+
if (change.path !== previous.path && !previous.aliases.includes(change.path)) previous.aliases.push(change.path);
|
|
86
|
+
}
|
|
87
|
+
return [...unique.values()];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function apply(changes: PlannedChange[], options: Options) {
|
|
91
|
+
// Detect concurrent edits before writing anything. Individual writes are atomic;
|
|
92
|
+
// a disk/permission failure can still leave a partial install, safe to rerun.
|
|
93
|
+
for (const change of changes) {
|
|
94
|
+
for (const path of [change.path, ...change.aliases]) {
|
|
95
|
+
if (await fileIdentity(path) !== change.destination) throw new InputError(`Configuration changed during init: ${path}. Rerun init to review the new plan.`);
|
|
96
|
+
}
|
|
97
|
+
if (await readOptional(change.destination) !== change.before) {
|
|
98
|
+
throw new InputError(`Configuration changed during init: ${change.path}. Rerun init to review the new plan.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const change of changes.filter(change => change.before !== change.after)) {
|
|
102
|
+
if (change.path === configPath(options)) { await saveSettings({ ...options, config: change.destination }); continue; }
|
|
103
|
+
const temp = `${change.destination}.${randomUUID()}.tmp`;
|
|
104
|
+
try {
|
|
105
|
+
await mkdir(dirname(change.destination), { recursive: true });
|
|
106
|
+
await writeFile(temp, change.after, { flag: 'wx', mode: 0o600 });
|
|
107
|
+
await rename(temp, change.destination);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new InputError(`Cannot update ${change.path}. Check permissions and rerun init; earlier files may already have been updated.`);
|
|
110
|
+
} finally { await rm(temp, { force: true }).catch(() => {}); }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function initialize(options: InitOptions, output: Output) {
|
|
115
|
+
const interactive = !options.nonInteractive && !options.json && !!process.stdin.isTTY && !!process.stderr.isTTY;
|
|
116
|
+
if (!interactive && !options.nonInteractive) {
|
|
117
|
+
throw new InputError(`Init needs a terminal. For automation or --json, pass --non-interactive --agents ${agentNames} (or none) --scope project (or user).`);
|
|
118
|
+
}
|
|
119
|
+
const context = { cwd: process.cwd(), home: homedir(), env: process.env };
|
|
120
|
+
const detected = await detectAgents(context);
|
|
121
|
+
output.diagnostic('Manual Console step: open https://console.lambdadb.ai, select an existing project, and obtain its API endpoint, project name and API key. Export the key securely; init never accepts or saves a key value.');
|
|
122
|
+
const prompt = interactive ? createInterface({ input: process.stdin, output: process.stderr }) : undefined;
|
|
123
|
+
const ask = async (label: string, fallback?: string) => {
|
|
124
|
+
const cancellation = new AbortController();
|
|
125
|
+
const cancel = () => cancellation.abort();
|
|
126
|
+
prompt!.once('SIGINT', cancel);
|
|
127
|
+
prompt!.once('close', cancel);
|
|
128
|
+
try {
|
|
129
|
+
const answer = (await prompt!.question(`${label}${fallback ? ` [${fallback}]` : ''}: `, { signal: cancellation.signal })).trim();
|
|
130
|
+
return answer || fallback;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (cancellation.signal.aborted) throw new InputError('Init canceled. No files were changed.');
|
|
133
|
+
throw error;
|
|
134
|
+
} finally {
|
|
135
|
+
prompt!.removeListener('SIGINT', cancel);
|
|
136
|
+
prompt!.removeListener('close', cancel);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
try {
|
|
140
|
+
if (interactive) {
|
|
141
|
+
const saved = await loadSettings(configPath(options));
|
|
142
|
+
options.endpoint = await ask('API endpoint', options.endpoint ?? process.env.LAMBDADB_ENDPOINT ?? saved.endpoint);
|
|
143
|
+
options.project = await ask('Existing project name', options.project ?? process.env.LAMBDADB_PROJECT ?? saved.project);
|
|
144
|
+
options.apiKeyEnv = await ask('Environment variable holding your API key (not its value)', options.apiKeyEnv ?? process.env.LAMBDADB_API_KEY_ENV ?? saved.apiKeyEnv ?? 'LAMBDADB_API_KEY');
|
|
145
|
+
output.protect(options.apiKeyEnv ? process.env[options.apiKeyEnv] : undefined);
|
|
146
|
+
output.diagnostic(`Detected agents (heuristic): ${detected.join(', ') || 'none'}. You may also select an undetected agent.`);
|
|
147
|
+
options.agents ??= await ask(`Agents: ${agentNames} (comma-separated), or none`, detected.join(',') || 'none');
|
|
148
|
+
const scopes = availableScopes(selectedAgents(options.agents));
|
|
149
|
+
options.scope ??= await ask(`Agent configuration scope: ${scopes.join(' or ')}`, scopes[0]);
|
|
150
|
+
if (options.writeTools === undefined) options.writeTools = (await ask('Enable MCP write tools? Type yes to opt in', 'no')) === 'yes';
|
|
151
|
+
}
|
|
152
|
+
const chosen = selectedAgents(options.agents);
|
|
153
|
+
if (!['project', 'user'].includes(options.scope ?? '')) throw new InputError('Specify --scope project or --scope user. CLI settings still use --config or the normal user configuration path.');
|
|
154
|
+
if (!availableScopes(chosen).includes(options.scope as AgentScope)) {
|
|
155
|
+
throw new InputError(`The selected agents do not all support --scope ${options.scope}. Use --scope ${availableScopes(chosen).join(' or --scope ')} or remove agents that do not support the selected scope.`);
|
|
156
|
+
}
|
|
157
|
+
// Use the existing precedence and validation for both saved settings and auth.
|
|
158
|
+
const selected = await settings(options, true);
|
|
159
|
+
options = { ...options, ...selected };
|
|
160
|
+
output.protect(process.env[selected.apiKeyEnv]);
|
|
161
|
+
if (!process.env[selected.apiKeyEnv]?.trim()) throw new InputError(`Export ${selected.apiKeyEnv} securely in this shell, then rerun init. Obtain a project API key in the LambdaDB Console; never pass its value as a flag.`);
|
|
162
|
+
const config = await resolveConfig(options, true);
|
|
163
|
+
const changes = await plan(selected, chosen, options.scope as AgentScope, options, context);
|
|
164
|
+
const files = changes.map(change => ({ path: change.path, ...(change.aliases.length ? { aliases: change.aliases } : {}), ...(change.destination !== change.path ? { destination: change.destination } : {}), kind: change.kind,
|
|
165
|
+
action: change.before === change.after ? 'unchanged' : change.before === undefined ? 'create' : 'update' }));
|
|
166
|
+
for (const file of files) output.diagnostic(`${file.action}: ${[file.path, ...(file.aliases ?? [])].join(', ')}${file.destination ? ` -> ${file.destination}` : ''} — ${file.kind}`);
|
|
167
|
+
output.diagnostic(`MCP: ${mcpPackage}; write tools ${options.writeTools ? 'enabled (explicit opt-in)' : 'disabled'}. Agents must inherit ${selected.apiKeyEnv}; only its name is stored.`);
|
|
168
|
+
if (interactive && (await ask('Apply these changes after verifying collection-list access? Type yes', 'no')) !== 'yes') {
|
|
169
|
+
throw new InputError('Init canceled. No files were changed.');
|
|
170
|
+
}
|
|
171
|
+
const ctx = runtime(config);
|
|
172
|
+
let connection;
|
|
173
|
+
try { connection = await checkConnection(ctx); } finally { ctx.close(); }
|
|
174
|
+
await apply(changes, options);
|
|
175
|
+
const nextSteps = [
|
|
176
|
+
`Restart/reload selected agents from an environment containing ${selected.apiKeyEnv} and npx on PATH. GUI launches may not inherit shell exports.`,
|
|
177
|
+
agentAdapters.filter(adapter => chosen.includes(adapter.id)).map(adapter => adapter.instructions).join(' '),
|
|
178
|
+
'Agent prompt: Use LambdaDB MCP to list collections, inspect the collection I choose, then read up to five documents at branch:main with query omitted. Help me search an actual indexed field. Do not create or modify data.',
|
|
179
|
+
'CLI alternative: run lambdadb collections list --json and lambdadb collections describe --collection NAME --json. Save {"size":5} in first-query.json, then run lambdadb query --collection NAME --ref branch:main --file first-query.json --json. Replace NAME with the collection you chose.',
|
|
180
|
+
'If the project has no collections/data, populate it deliberately outside init. Connection success does not establish search readiness.',
|
|
181
|
+
];
|
|
182
|
+
return { ...connection, settings: selected, detectedAgents: detected, agents: chosen, scope: options.scope,
|
|
183
|
+
writeTools: !!options.writeTools, files, nextSteps };
|
|
184
|
+
} finally { prompt?.close(); }
|
|
185
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import type { Config } from './config.js';
|
|
3
|
+
import { InputError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
// Stable stdio package; this CLI only adapts credentials and process lifecycle.
|
|
6
|
+
export const mcpPackage = '@functional-systems/lambdadb-mcp@0.1.4';
|
|
7
|
+
|
|
8
|
+
export function mcpEnvironment(config: Config, writeTools: boolean): NodeJS.ProcessEnv {
|
|
9
|
+
return {
|
|
10
|
+
...process.env,
|
|
11
|
+
LAMBDADB_BASE_URL: config.endpoint,
|
|
12
|
+
LAMBDADB_PROJECT_NAME: config.project,
|
|
13
|
+
LAMBDADB_PROJECT_API_KEY: config.apiKey,
|
|
14
|
+
LAMBDADB_MCP_ENABLE_WRITE_TOOLS: String(writeTools),
|
|
15
|
+
LAMBDADB_DEBUG: 'false',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function serveMcp(config: Config, writeTools: boolean): Promise<number> {
|
|
20
|
+
// Never emit CLI results on stdout: it belongs exclusively to the MCP transport.
|
|
21
|
+
const child = spawn('npx', ['-y', mcpPackage], {
|
|
22
|
+
env: mcpEnvironment(config, writeTools), stdio: 'inherit', detached: process.platform !== 'win32',
|
|
23
|
+
// Windows needs the npm-provided .cmd launcher. Arguments contain no secrets.
|
|
24
|
+
...(process.platform === 'win32' ? { shell: true } : {}),
|
|
25
|
+
});
|
|
26
|
+
const forward = (signal: NodeJS.Signals) => {
|
|
27
|
+
try {
|
|
28
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
|
|
29
|
+
else child.kill(signal);
|
|
30
|
+
} catch { /* The child may already have exited. */ }
|
|
31
|
+
};
|
|
32
|
+
const interrupt = () => forward('SIGINT');
|
|
33
|
+
const terminate = () => forward('SIGTERM');
|
|
34
|
+
process.once('SIGINT', interrupt);
|
|
35
|
+
process.once('SIGTERM', terminate);
|
|
36
|
+
try {
|
|
37
|
+
return await new Promise<number>((resolve, reject) => {
|
|
38
|
+
child.once('error', () => reject(new InputError('Cannot start npx. Install Node.js/npm and make npx available on the agent PATH.')));
|
|
39
|
+
child.once('exit', (code, signal) => resolve(code ?? (signal === 'SIGINT' ? 130 : 143)));
|
|
40
|
+
});
|
|
41
|
+
} finally {
|
|
42
|
+
process.removeListener('SIGINT', interrupt);
|
|
43
|
+
process.removeListener('SIGTERM', terminate);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -38,3 +38,12 @@ export function runtime(config: Config) {
|
|
|
38
38
|
},
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
// Both doctor and init verify access without implying collection/query readiness.
|
|
43
|
+
export async function checkConnection(ctx: Pick<ReturnType<typeof runtime>, 'client' | 'options'>) {
|
|
44
|
+
await ctx.client.listCollections({ size: 1 }, ctx.options);
|
|
45
|
+
return { checks: [
|
|
46
|
+
{ name: 'configuration', status: 'passed' },
|
|
47
|
+
{ name: 'authentication_and_project_collection_list', status: 'passed' },
|
|
48
|
+
], writesVerified: false, queryReadinessVerified: false };
|
|
49
|
+
}
|