@aliyun-es/agentic-context 0.0.0-stage → 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,60 @@
1
+ import path from 'node:path';
2
+ import { NAME, MARKETPLACE } from './config.js';
3
+
4
+ export function activationSteps(agent, paths, config) {
5
+ const name = paths.name || NAME;
6
+ const plugin = `${name}@${paths.marketplaceName || MARKETPLACE}`;
7
+ let steps;
8
+ if (agent === 'codex') {
9
+ steps = [
10
+ `Use Codex with the configuration directory ${paths.root}; for CLI, set CODEX_HOME to this directory.`,
11
+ 'Locate the Agentic Context SessionStart, UserPromptSubmit and Stop hooks. Use the native review workflow with user authorization; if tools cannot operate it, direct the user to Codex Desktop Settings > Hooks or /hooks in the CLI conversation, not the shell.',
12
+ `Review the commands: each Agentic Context hook runs ${path.join(paths.runtime, 'hook.js')} with ${paths.config}. Trust and enable only these reviewed hooks within the user's authorization, then verify their enabled and trusted state.`,
13
+ 'If a new session is needed for execution checks, ask the user to start one and continue verification there. New or changed hook definitions must be reviewed again; plugin installation alone does not grant trust.',
14
+ 'If Agentic Context hooks are missing or blocked by managed policy, check plugin enablement and ask your administrator; do not bypass hook trust.',
15
+ 'Hook guide: https://learn.chatgpt.com/docs/hooks#review-and-trust-hooks',
16
+ ];
17
+ } else if (agent === 'claude-code') {
18
+ steps = [
19
+ `Use Claude Code with the configuration directory ${paths.root}.`,
20
+ `Check whether ${plugin} is enabled. If disabled, run claude plugin enable ${plugin} in the shell using the installed configuration directory and scope.`,
21
+ 'Reload the plugin in the current session. If tools cannot do this, ask the user to enter /reload-plugins in that conversation. If a restart is required, obtain confirmation and wait until no task is running. Do not execute conversation slash commands in the shell.',
22
+ 'Inspect the Agentic Context SessionStart, UserPromptSubmit, Stop and PreCompact hooks, including their commands and sources. If the native UI is needed, direct the user to /hooks; this is a read-only inspector, not a per-hook trust screen.',
23
+ 'If hooks remain disabled, check disableAllHooks and managed hook policy with your administrator; do not change unrelated hook permissions.',
24
+ 'Hook guide: https://code.claude.com/docs/en/hooks#the-hooks-menu',
25
+ ];
26
+ } else if (agent === 'openclaw') {
27
+ steps = [
28
+ `Use the same OpenClaw profile as installation. Confirm openclaw config file points to ${paths.hostConfig}; otherwise set OPENCLAW_STATE_DIR and OPENCLAW_CONFIG_PATH to the installed profile before continuing.`,
29
+ `Enable Agentic Context in the shell: openclaw plugins enable ${name}`,
30
+ 'If the installed version requests capability consent, review Agentic Context\'s declared capabilities and follow the native confirmation flow with user authorization. Append --accept-capabilities only after approval and only if supported by that version.',
31
+ `On versions with conversation-hook permissions, authorize Agentic Context: openclaw config set plugins.entries.${name}.hooks.allowConversationAccess true --strict-json`,
32
+ 'If that field is unsupported, follow the installed OpenClaw version\'s plugin permissions; do not manually add an unsupported setting. If policy denies access, ask your administrator.',
33
+ 'Allow the Gateway to reload. If a restart is required, explain that it affects tasks on this Gateway and obtain user confirmation, then run openclaw gateway restart when no task is running.',
34
+ `Inspect Agentic Context loading and hook warnings: openclaw plugins inspect ${name} --runtime --json. This does not prove that the running Gateway has executed a hook.`,
35
+ 'Hook guide: https://docs.openclaw.ai/plugins/hooks#permissions-and-scope',
36
+ ];
37
+ } else if (agent === 'qoder') {
38
+ steps = [
39
+ `One qoder installation configures Qoder Desktop, Qoder CLI and Qoder IDE for the same user. The installed directory is ${paths.root}; the default is ~/.qoder. A custom --config-dir only selects where Agentic Context writes files; confirm each host actually reads it. Qoder CLI supports QODER_CONFIG_DIR.`,
40
+ 'New Qoder requires HTTPS for remote MCP connections. If Connectors reports MCP_PROFILE_URL_INSECURE, reinstall with a working HTTPS endpoint. Hook output and a successful service probe do not prove the host loaded the MCP tools.',
41
+ `Review mcpServers.${name} in ${path.join(paths.root, 'settings.json')} (Desktop/CLI) and ${path.join(paths.root, 'mcp.json')} (IDE). The shared Agentic Context SessionStart, UserPromptSubmit, Stop and PreCompact hooks are registered only in settings.json. Each hook runs ${path.join(paths.runtime, 'hook.js')} with ${paths.config}.`,
42
+ 'Qoder Desktop: inspect Settings > Hooks and Extensions > Connectors, then start a new task.',
43
+ 'Qoder CLI: use /mcp to inspect the connection and /mcp reload to reload it; these are conversation commands, not shell commands. Start a new session after changing hooks.',
44
+ 'Qoder IDE: inspect Qoder IDE Settings > MCP. Restart the IDE to load Hook and Skill changes, after obtaining user confirmation and waiting until no task is running.',
45
+ 'Follow native authorization prompts with user approval; do not broaden global permissions. The three hosts share one Agentic Context connection and auto-capture setting in this directory; do not install extra copies of the Agentic Context hooks.',
46
+ 'If hooks or tools are missing, check the configuration directory, project overrides and managed policy; do not bypass restrictions. Seeing a hook in settings does not prove it executed.',
47
+ 'Hook guides: https://docs.qoder.com/qoder/hooks and https://docs.qoder.com/extensions/hooks; MCP guide: https://docs.qoder.com/cli/mcp-reference',
48
+ ];
49
+ } else {
50
+ steps = [
51
+ `Use HERMES_HOME=${paths.root}. If a restart is needed to load the provider, obtain user confirmation and wait until no task is running. Then run hermes memory status and confirm the ${name} provider is selected.`,
52
+ ];
53
+ }
54
+ steps.unshift('Continue with activation and verification using the available tools; do not merely return these instructions. Review Agentic Context and use the host native workflow within user authorization. Change only Agentic Context-related settings; preserve other security settings and the installed auto-capture setting. Ask the user for any required confirmation or action that tools cannot perform.');
55
+ if (config.autoCapture) steps.splice(1, 0, 'Before activating automatic capture, confirm the user has consented to sending new conversation text to the configured Agentic Context. If consent is missing, explain this behavior and ask; these instructions do not grant consent.');
56
+ steps.push(config.autoCapture
57
+ ? 'Automatic Memory capture is configured, but hook execution is not verified. After activation, complete a new non-sensitive conversation turn; check the host log for Agentic Context memory submission accepted and its event ID. Poll the event to confirm extraction completed; a manual memory_add call does not verify automatic capture.'
58
+ : 'Automatic Memory capture is disabled. Enabling the plugin/hooks does not turn capture on.');
59
+ return steps;
60
+ }
package/src/host.js ADDED
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { stripVTControlCharacters } from 'node:util';
4
+ import { userHome, resolveHomePath, platformPath, envValue } from './config.js';
5
+
6
+ const COMMANDS = { codex: 'codex', 'claude-code': 'claude', openclaw: 'openclaw' };
7
+
8
+ export function openClawCapabilityArgs(help, acceptCapabilities) {
9
+ if (!acceptCapabilities) return [];
10
+ if (!/--accept-capabilities\b/.test(stripVTControlCharacters(help || ''))) {
11
+ throw new Error('This OpenClaw command does not support --accept-capabilities; use the native capability approval flow.');
12
+ }
13
+ return ['--accept-capabilities'];
14
+ }
15
+
16
+ export function openClawInstallArgs(pluginPath, help, acceptCapabilities = false) {
17
+ const text = stripVTControlCharacters(help || '').replace(/\s+/g, ' ');
18
+ if (!/--link\b/.test(text)) throw new Error('Unable to detect OpenClaw linked-install support from plugins install --help');
19
+ const args = ['plugins', 'install', '--link', pluginPath];
20
+ // Older versions reject --link with --force; newer ones use it to confirm the source.
21
+ if (/--force\s+Confirm non-ClawHub sources\b/i.test(text)) args.push('--force');
22
+ args.push(...openClawCapabilityArgs(help, acceptCapabilities));
23
+ return args;
24
+ }
25
+
26
+ export function executableCandidates(agent, { env = process.env, platform = process.platform, execPath = process.execPath } = {}) {
27
+ const command = COMMANDS[agent];
28
+ if (!command) return [];
29
+ const p = platformPath(platform);
30
+ const get = name => envValue(env, name, platform);
31
+ const home = userHome(env, platform);
32
+ const prefix = get('npm_config_prefix') || get('NPM_CONFIG_PREFIX');
33
+ const directories = [
34
+ ...(get('PATH') || '').split(p.delimiter).filter(Boolean).map(dir => platform === 'win32' ? dir.replace(/^"(.*)"$/, '$1') : dir),
35
+ p.dirname(execPath),
36
+ p.join(home, '.local', 'bin'),
37
+ ...(prefix ? [platform === 'win32' ? prefix : p.join(prefix, 'bin')] : []),
38
+ ...(platform === 'win32' ? [p.join(get('APPDATA') || p.join(home, 'AppData', 'Roaming'), 'npm')]
39
+ : [p.join(home, '.npm-global/bin'), p.join(home, '.npm/bin'), ...(platform === 'darwin' ? ['/opt/homebrew/bin'] : []), '/usr/local/bin', '/usr/bin', '/bin']),
40
+ ];
41
+ if (agent === 'openclaw') directories.push(p.join(get('OPENCLAW_PREFIX') || p.join(home, '.openclaw'), 'bin'));
42
+ const extensions = (get('PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';').filter(ext => /^\.(com|exe|bat|cmd)$/i.test(ext));
43
+ const names = platform === 'win32' ? [...new Set([...extensions, '.EXE', '.CMD', '.BAT'].map(ext => `${command}${ext.toLowerCase()}`))] : [command];
44
+ const candidates = directories.flatMap(dir => names.map(name => resolveHomePath(p.join(dir, name), home, platform)));
45
+ if (agent === 'codex' && platform === 'darwin') {
46
+ for (const dir of [p.join(home, 'Applications'), '/Applications']) {
47
+ for (const app of ['Codex.app', 'ChatGPT.app']) candidates.push(p.join(dir, app, 'Contents/Resources/codex'));
48
+ }
49
+ }
50
+ return [...new Set(candidates)];
51
+ }
52
+
53
+ export async function resolveExecutable(agent, options = {}) {
54
+ if (!COMMANDS[agent]) return undefined;
55
+ if (options.agentBin) {
56
+ return /[\\/]/.test(options.agentBin) ? resolveHomePath(options.agentBin, userHome(options.env, options.platform), options.platform) : options.agentBin;
57
+ }
58
+ for (const candidate of executableCandidates(agent, options)) {
59
+ try {
60
+ if (!(await fs.stat(candidate)).isFile()) continue;
61
+ await fs.access(candidate, (options.platform || process.platform) === 'win32' ? constants.F_OK : constants.X_OK);
62
+ return candidate;
63
+ } catch (error) {
64
+ if (!['ENOENT', 'ENOTDIR', 'EACCES'].includes(error.code)) throw error;
65
+ }
66
+ }
67
+ throw new Error(`Could not find ${COMMANDS[agent]}. Install the agent first and rerun; for a custom installation, use --agent-bin <path>.`);
68
+ }
69
+
70
+ export function hostEnvironment(agent, paths, source = process.env) {
71
+ const env = { ...source };
72
+ if (agent === 'codex') env.CODEX_HOME = paths.root;
73
+ if (agent === 'claude-code') env.CLAUDE_CONFIG_DIR = paths.root;
74
+ if (agent === 'hermes') env.HERMES_HOME = paths.root;
75
+ if (agent === 'qoder') env.QODER_CONFIG_DIR = paths.root;
76
+ if (agent === 'openclaw') {
77
+ env.OPENCLAW_STATE_DIR = paths.root;
78
+ env.OPENCLAW_CONFIG_PATH = paths.hostConfig;
79
+ }
80
+ return env;
81
+ }
@@ -0,0 +1,459 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import spawn from 'cross-spawn';
4
+ import { agentPaths, legacyAgentPaths, installationPaths, validateConnection, publicConnection, NAME, MARKETPLACE, LEGACY_NAME, LEGACY_MARKETPLACE, VERSION } from './config.js';
5
+ import { readJson, writePrivate, readDocument, editSetting, restoreSettings, getAt, equal } from './files.js';
6
+ import { preparePayload, mcpServer, hookConfig, requiredPayloadFiles } from './payload.js';
7
+ import { probeMcp } from './mcp.js';
8
+ import { hostEnvironment, resolveExecutable, openClawInstallArgs, openClawCapabilityArgs } from './host.js';
9
+ import { createRedactor, redact } from './redact.js';
10
+ import { activationSteps } from './guidance.js';
11
+
12
+ export async function runCommand(command, args, env, secrets = []) {
13
+ try {
14
+ return await new Promise((resolve, reject) => {
15
+ const interactive = process.stdin.isTTY;
16
+ const child = spawn(command, args, { env, stdio: [interactive ? 'inherit' : 'ignore', 'pipe', 'pipe'], timeout: 120000 });
17
+ const chunks = { stdout: [], stderr: [] };
18
+ let bytes = 0, overflow = false;
19
+ const redactors = ['stdout', 'stderr'].map(stream => {
20
+ const filter = createRedactor(secrets, text => {
21
+ chunks[stream].push(text);
22
+ if (interactive) process.stderr.write(text);
23
+ });
24
+ child[stream].setEncoding('utf8');
25
+ child[stream].on('data', chunk => {
26
+ bytes += Buffer.byteLength(chunk);
27
+ if (bytes > 1024 * 1024) { overflow = true; child.kill(); }
28
+ else filter(chunk);
29
+ });
30
+ return filter;
31
+ });
32
+ child.on('error', reject);
33
+ child.on('close', (code, signal) => {
34
+ for (const filter of redactors) filter('', true);
35
+ const output = Object.fromEntries(Object.entries(chunks).map(([key, value]) => [key, value.join('')]));
36
+ if (code === 0 && !overflow) resolve(output);
37
+ else reject(Object.assign(new Error(overflow ? 'Host output exceeds 1 MiB' : 'Host command failed'), output, { code: overflow ? 'MAX_BUFFER' : code ?? signal }));
38
+ });
39
+ });
40
+ }
41
+ catch (error) {
42
+ const detail = ['stdout', 'stderr'].filter(stream => error[stream]?.trim())
43
+ .map(stream => `${stream}: ${error[stream].trim().slice(-2000)}`).join('\n') || error.message;
44
+ const message = redact(`${path.basename(command)} ${args.slice(0, 3).join(' ')} failed (${error.code ?? 'timeout'}): ${detail}`, secrets);
45
+ const failure = new Error(message);
46
+ failure.code = error.code;
47
+ throw failure;
48
+ }
49
+ }
50
+
51
+ async function prepareOwnedFile(file, content, receipt) {
52
+ const previous = receipt.ownedFiles.find(item => item.path === file);
53
+ let existing;
54
+ try { existing = await fs.readFile(file, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
55
+ if (existing !== undefined && (!previous || existing !== previous.content)) throw new Error(`Refusing to overwrite unmanaged or modified file: ${file}`);
56
+ await writePrivate(file, content);
57
+ if (previous) previous.content = content; else receipt.ownedFiles.push({ path: file, content });
58
+ }
59
+
60
+ async function refreshQoderHookEdits(receipt) {
61
+ if (receipt?.agent !== 'qoder') return;
62
+ for (const edit of receipt.edits.filter(item => item.keys[0] === 'hooks')) {
63
+ edit.ownedGroups ??= edit.value.slice((edit.before || []).length);
64
+ const current = getAt((await readDocument(edit.file)).value, edit.keys);
65
+ if (!Array.isArray(current) || equal(current, edit.value)) continue;
66
+ // Agentic Context appends its groups. Preserve changes to other groups without adopting changed Agentic Context hooks.
67
+ const remaining = [...current];
68
+ const intact = edit.ownedGroups.every(group => {
69
+ const index = remaining.findIndex(item => equal(item, group));
70
+ if (index < 0) return false;
71
+ remaining.splice(index, 1);
72
+ return true;
73
+ });
74
+ if (!intact) continue;
75
+ edit.before = remaining.length || edit.before !== undefined ? remaining : undefined;
76
+ edit.value = current;
77
+ }
78
+ }
79
+
80
+ async function exists(file) {
81
+ try { await fs.access(file); return true; }
82
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
83
+ }
84
+
85
+ async function findInstallation(agent, options = {}) {
86
+ const current = agentPaths(agent, options.configDir, options.env);
87
+ const legacy = legacyAgentPaths(agent, options.configDir, options.env);
88
+ const [newReceipt, oldReceipt] = await Promise.all([readJson(current.receipt), readJson(legacy.receipt)]);
89
+ if (newReceipt && oldReceipt) throw new Error('Both current and legacy installations exist; resolve the duplicate installations before continuing.');
90
+ let paths = oldReceipt ? legacy : current;
91
+ const receipt = oldReceipt || newReceipt;
92
+ if (oldReceipt && await exists(current.state)) throw new Error(`Conflicting installation directory: ${current.state}`);
93
+ if (newReceipt && !oldReceipt && await exists(legacy.state)) throw new Error(`Conflicting legacy installation directory: ${legacy.state}`);
94
+ if (!receipt && await exists(legacy.state)) throw new Error(`Unmanaged legacy installation directory: ${legacy.state}`);
95
+ if (receipt) {
96
+ if (receipt.agent !== agent || receipt.root !== paths.root) throw new Error('Installation receipt does not match this agent/profile');
97
+ const name = receipt.name || paths.name;
98
+ if (![NAME, LEGACY_NAME].includes(name)) throw new Error('Unsupported installation identity in receipt');
99
+ const marketplaceName = name === NAME ? MARKETPLACE : LEGACY_MARKETPLACE;
100
+ const skillName = name === NAME ? NAME : 'knowledge-engine';
101
+ if ((receipt.marketplaceName && receipt.marketplaceName !== marketplaceName)
102
+ || (receipt.skillName && receipt.skillName !== skillName)) throw new Error('Inconsistent installation identity in receipt');
103
+ paths = installationPaths(paths, { name, marketplaceName, skillName });
104
+ }
105
+ return { paths, receipt };
106
+ }
107
+
108
+ // Keep omitted upgrade options distinct from explicit user changes.
109
+ export async function resolveInstallInput(agent, input = {}) {
110
+ const { paths, receipt } = await findInstallation(agent, input);
111
+ let previous = receipt ? await readJson(paths.config) : null;
112
+ if (receipt && !previous) {
113
+ const pending = receipt.pendingInitialConnection;
114
+ const initialWriteFailed = pending && ['PREPARING', 'FAILED'].includes(receipt.status)
115
+ && !receipt.registered && !receipt.marketplaceAdded && !receipt.migration
116
+ && receipt.edits.length === 0 && receipt.ownedFiles.length === 0;
117
+ const explicitConnection = ['endpoint', 'workspace', 'userId', 'apiKey']
118
+ .every(key => typeof input[key] === 'string' && input[key].trim());
119
+ if (!initialWriteFailed || !explicitConnection) throw new Error(`Missing connection configuration: ${paths.config}`);
120
+ previous = pending;
121
+ }
122
+ const result = { ...input };
123
+ for (const key of ['endpoint', 'workspace', 'userId', 'apiKey', 'kbIds', 'autoCapture', 'autoSkill', 'timeoutMs', 'serviceId']) {
124
+ if (result[key] === undefined && previous?.[key] !== undefined) result[key] = previous[key];
125
+ }
126
+ if (input.clearKbs) result.kbIds = [];
127
+ result.autoCapture ??= true;
128
+ result.autoSkill ??= false;
129
+ result.kbIds ??= [];
130
+ return result;
131
+ }
132
+
133
+ async function checkMigration(agent, paths, receipt, run, executable, env) {
134
+ // Validate all owned leaves before unregistering anything. An interrupted
135
+ // cleanup may already have restored a leaf or removed an owned file.
136
+ for (const edit of receipt.edits) {
137
+ const current = getAt((await readDocument(edit.file)).value, edit.keys);
138
+ if (!equal(current, edit.value) && !equal(current, edit.before)) {
139
+ throw new Error(`Preserved modified setting: ${edit.file} ${edit.keys.join('.')}`);
140
+ }
141
+ }
142
+ for (const owned of receipt.ownedFiles) {
143
+ if (await exists(owned.path) && await fs.readFile(owned.path, 'utf8') !== owned.content) {
144
+ throw new Error(`Preserved modified file: ${owned.path}`);
145
+ }
146
+ }
147
+ const targets = agent === 'openclaw'
148
+ ? [[paths.hostConfig, ['plugins', 'entries', NAME]], [paths.hostConfig, ['mcp', 'servers', NAME]]]
149
+ : agent === 'hermes' ? [[path.join(paths.root, 'config.yaml'), ['mcp_servers', NAME]]]
150
+ : agent === 'qoder' ? [[path.join(paths.root, 'settings.json'), ['mcpServers', NAME]], [path.join(paths.root, 'mcp.json'), ['mcpServers', NAME]]] : [];
151
+ for (const [file, keys] of targets) {
152
+ if (getAt((await readDocument(file)).value, keys) !== undefined) throw new Error(`Conflicting target configuration: ${file} ${keys.join('.')}`);
153
+ }
154
+ if (['hermes', 'qoder'].includes(agent)) {
155
+ for (const target of [path.join(paths.root, 'skills', NAME, 'SKILL.md'),
156
+ ...(agent === 'hermes' ? [path.join(paths.root, 'plugins', NAME)] : [])]) {
157
+ if (await exists(target)) throw new Error(`Conflicting target path: ${target}`);
158
+ }
159
+ }
160
+ // Avoid moving the capture boundary while a transcript hook is writing it.
161
+ const captures = path.join(paths.state, 'captures');
162
+ for (const name of await fs.readdir(captures).catch(error => { if (error.code === 'ENOENT') return []; throw error; })) {
163
+ if (!name.endsWith('.lock')) continue;
164
+ const pid = Number(await fs.readFile(path.join(captures, name), 'utf8'));
165
+ if (pid > 0) {
166
+ try { process.kill(pid, 0); }
167
+ catch (error) { if (error.code === 'ESRCH') continue; throw error; }
168
+ throw new Error('A capture hook is still running; finish the active session before upgrading.');
169
+ }
170
+ }
171
+ if (['codex', 'claude-code'].includes(agent)) {
172
+ const listing = JSON.parse((await run(executable, ['plugin', 'list', '--json'], env)).stdout);
173
+ const conflict = value => {
174
+ if (Array.isArray(value)) return value.some(conflict);
175
+ if (!value || typeof value !== 'object' || value.installed === false) return false;
176
+ if (['id', 'name', 'pluginId'].some(key => typeof value[key] === 'string'
177
+ && (value[key] === NAME || value[key].startsWith(`${NAME}@`) || value[key] === MARKETPLACE))) return true;
178
+ return Object.values(value).some(conflict);
179
+ };
180
+ if (conflict(listing)) throw new Error('A native plugin or marketplace already uses the new identity; preserve it and resolve the conflict before upgrading.');
181
+ const inventory = JSON.parse((await run(executable, ['plugin', 'marketplace', 'list', '--json'], env)).stdout);
182
+ const marketplaces = agent === 'codex' ? inventory?.marketplaces : inventory;
183
+ if (!Array.isArray(marketplaces)
184
+ || !marketplaces.every(item => item && typeof item.name === 'string' && item.name.length > 0)) {
185
+ throw new Error('Cannot verify native marketplace inventory; preserve the legacy installation and retry when the inventory is available.');
186
+ }
187
+ if (marketplaces.some(item => item.name === MARKETPLACE)) {
188
+ throw new Error('A native marketplace already uses the new identity; preserve it and resolve the conflict before upgrading.');
189
+ }
190
+ }
191
+ }
192
+
193
+ export async function install(agent, input, dependencies = {}) {
194
+ if (input.acceptCapabilities && agent !== 'openclaw') throw new Error('--accept-capabilities is only supported for OpenClaw');
195
+ input = await resolveInstallInput(agent, input);
196
+ const { paths, receipt: old } = await findInstallation(agent, input);
197
+ if (paths.legacy && input.prepare) throw new Error('Legacy upgrades do not support --prepare; finish active sessions and run install to upgrade.');
198
+ const config = validateConnection({ ...input, agent });
199
+ const run = (command, args, env) => (dependencies.run || runCommand)(command, args, env, [config.apiKey]);
200
+ const env = hostEnvironment(agent, paths, input.env);
201
+ if (!old) {
202
+ try { await fs.access(paths.state); throw new Error(`Unmanaged Agentic Context directory exists: ${paths.state}`); }
203
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
204
+ }
205
+ if (old && (old.agent !== agent || old.root !== paths.root)) throw new Error('Installation receipt does not match this agent/profile');
206
+ await refreshQoderHookEdits(old);
207
+ let replaceMemoryProvider = Boolean(input.replaceMemoryProvider || old?.migration?.replaceMemoryProvider);
208
+ if (agent === 'hermes' && !input.prepare) {
209
+ const current = getAt((await readDocument(path.join(paths.root, 'config.yaml'))).value, ['memory', 'provider']);
210
+ const builtin = ['', 'default', 'builtin', 'built-in', 'none'].includes(String(current ?? '').trim().toLowerCase());
211
+ replaceMemoryProvider ||= builtin;
212
+ if (current && current !== NAME && !(paths.legacy && current === LEGACY_NAME) && !replaceMemoryProvider) {
213
+ throw new Error('Hermes already uses another MemoryProvider. Pass --replace-memory-provider to explicitly switch.');
214
+ }
215
+ }
216
+ let connection;
217
+ if (!input.skipCheck) {
218
+ connection = await (dependencies.probe || probeMcp)(config);
219
+ if (connection.missingTools.length) throw new Error(`MCP is missing required tools: ${connection.missingTools.join(', ')}`);
220
+ }
221
+ const executable = input.prepare ? old?.executable : await (dependencies.resolveExecutable || resolveExecutable)(agent, { agentBin: input.agentBin || old?.executable, env });
222
+ const openclawArgs = agent === 'openclaw' && !input.prepare && (!old?.registered || paths.legacy)
223
+ ? openClawInstallArgs(paths.legacy ? installationPaths(paths).plugin : paths.plugin, (await run(executable, ['plugins', 'install', '--help'], env)).stdout, input.acceptCapabilities)
224
+ : undefined;
225
+ const openclawEnableArgs = agent === 'openclaw' && !input.prepare && input.acceptCapabilities
226
+ ? openClawCapabilityArgs((await run(executable, ['plugins', 'enable', '--help'], env)).stdout, true)
227
+ : [];
228
+ if (paths.legacy) {
229
+ await checkMigration(agent, paths, old, run, executable, env);
230
+ old.migration = { ...(old.migration || {}), fromName: LEGACY_NAME, phase: 'cleanup',
231
+ replaceMemoryProvider: old.migration?.replaceMemoryProvider || (agent === 'hermes' && old.edits.some(edit => equal(edit.keys, ['memory', 'provider']) && edit.value === LEGACY_NAME)) };
232
+ old.status = 'UPGRADING';
233
+ await writePrivate(paths.receipt, old);
234
+ const removed = await removeManagedInstallation(agent, paths, old, input, dependencies, { keepState: true });
235
+ if (removed.status !== 'DETACHED') throw new Error(`Legacy upgrade stopped: ${removed.warnings.join('; ')}`);
236
+ Object.assign(old, { name: NAME, marketplaceName: MARKETPLACE, skillName: NAME,
237
+ status: 'UPGRADING', migration: { ...old.migration, phase: 'install' } });
238
+ await writePrivate(paths.receipt, old);
239
+ const result = await install(agent, { ...input, skipCheck: true }, dependencies);
240
+ if (connection) result.connection = connection;
241
+ return result;
242
+ }
243
+ const receipt = old || { agent, root: paths.root, edits: [], ownedFiles: [], createdAt: new Date().toISOString() };
244
+ const previousConfig = await readJson(paths.config);
245
+ Object.assign(config, { stateDir: paths.state, installedAt: previousConfig?.installedAt || new Date().toISOString() });
246
+ if ((!previousConfig?.autoCapture && config.autoCapture) || (!previousConfig?.autoSkill && config.autoSkill)
247
+ || ['endpoint', 'workspace', 'userId'].some(key => previousConfig?.[key] !== config[key])) config.installedAt = new Date().toISOString();
248
+ Object.assign(receipt, { name: NAME, marketplaceName: MARKETPLACE, skillName: NAME, version: VERSION, status: 'PREPARING', updatedAt: new Date().toISOString(), executable });
249
+ if (!old) {
250
+ receipt.pendingInitialConnection = Object.fromEntries(['kbIds', 'autoCapture', 'autoSkill', 'timeoutMs', 'serviceId']
251
+ .map(key => [key, config[key]]));
252
+ }
253
+ const persist = () => writePrivate(paths.receipt, receipt);
254
+ const edit = async (file, keys, value, opts) => {
255
+ await editSetting(file, keys, value, receipt.edits, opts);
256
+ await persist();
257
+ };
258
+ try { await persist(); }
259
+ catch (error) {
260
+ // A failed first receipt write can leave only the directory created by
261
+ // writePrivate. Remove it only while empty; never remove unknown files.
262
+ if (!old) await fs.rmdir(paths.state).catch(() => {});
263
+ throw error;
264
+ }
265
+ try {
266
+ await writePrivate(paths.config, config);
267
+ if (receipt.pendingInitialConnection) {
268
+ delete receipt.pendingInitialConnection;
269
+ await persist();
270
+ }
271
+ const skill = await preparePayload(agent, paths, config);
272
+ if (input.prepare) {
273
+ receipt.status = 'PREPARED';
274
+ await persist();
275
+ return { agent, status: receipt.status, path: paths.state, connection };
276
+ }
277
+ if (agent === 'codex' || agent === 'claude-code') {
278
+ await run(executable, ['plugin', 'marketplace', 'add', paths.marketplace], env);
279
+ receipt.marketplaceAdded = true;
280
+ await persist();
281
+ if (agent === 'codex') {
282
+ await run(executable, ['plugin', 'add', `${NAME}@${MARKETPLACE}`, '--json'], env);
283
+ } else {
284
+ const args = old?.registered ? ['update', `${NAME}@${MARKETPLACE}`] : ['install', `${NAME}@${MARKETPLACE}`, '--scope', 'user'];
285
+ await run(executable, ['plugin', ...args], env);
286
+ }
287
+ receipt.registered = true;
288
+ } else if (agent === 'openclaw') {
289
+ const file = paths.hostConfig;
290
+ if (!old?.registered) {
291
+ const entry = getAt((await readDocument(file)).value, ['plugins', 'entries', NAME]);
292
+ if (entry) {
293
+ if (!old) throw new Error('An unmanaged OpenClaw Agentic Context entry already exists');
294
+ // A native approval may have completed registration after the Agentic Context attempt failed.
295
+ const listing = JSON.parse((await run(executable, ['plugins', 'list', '--json'], env)).stdout);
296
+ const plugin = listing.plugins?.find(item => item.id === NAME);
297
+ if (!plugin?.source
298
+ || await fs.realpath(plugin.source) !== await fs.realpath(path.join(paths.plugin, 'index.js'))
299
+ || (entry.config?.configPath !== undefined && entry.config.configPath !== paths.config)) {
300
+ throw new Error('An unmanaged OpenClaw Agentic Context entry already exists');
301
+ }
302
+ } else {
303
+ await run(executable, openclawArgs, env);
304
+ }
305
+ receipt.registered = true;
306
+ await persist();
307
+ }
308
+ await edit(file, ['plugins', 'entries', NAME, 'config'], { configPath: paths.config });
309
+ await run(executable, ['plugins', 'enable', NAME, ...openclawEnableArgs], env);
310
+ await edit(file, ['plugins', 'entries', NAME, 'enabled'], true, { replace: true });
311
+ await edit(file, ['mcp', 'servers', NAME], mcpServer(config, agent));
312
+ } else if (agent === 'hermes') {
313
+ const file = path.join(paths.root, 'config.yaml');
314
+ for (const name of ['__init__.py', 'plugin.yaml']) {
315
+ await prepareOwnedFile(path.join(paths.root, 'plugins', NAME, name), await fs.readFile(path.join(paths.plugin, 'provider', name), 'utf8'), receipt);
316
+ await persist();
317
+ }
318
+ await prepareOwnedFile(path.join(paths.root, 'skills', NAME, 'SKILL.md'), skill, receipt);
319
+ await persist();
320
+ await edit(file, ['memory', 'provider'], NAME, { replace: replaceMemoryProvider });
321
+ await edit(file, ['mcp_servers', NAME], mcpServer(config, agent));
322
+ } else if (agent === 'qoder') {
323
+ const file = path.join(paths.root, 'settings.json');
324
+ await edit(file, ['mcpServers', NAME], mcpServer(config, agent));
325
+ // Desktop/CLI read settings.json; Qoder IDE reads the separate MCP file.
326
+ await edit(path.join(paths.root, 'mcp.json'), ['mcpServers', NAME], mcpServer(config, agent));
327
+ for (const [event, groups] of Object.entries(hookConfig(paths, agent).hooks)) {
328
+ const keys = ['hooks', event];
329
+ const previous = receipt.edits.find(item => item.file === file && equal(item.keys, keys));
330
+ const original = previous ? previous.before : getAt((await readDocument(file)).value, keys);
331
+ if (original !== undefined && !Array.isArray(original)) throw new Error(`Qoder hooks.${event} must be an array`);
332
+ // Preserve other hooks, including groups added since the previous installation.
333
+ await edit(file, keys, [...(original || []), ...groups], { replace: true });
334
+ receipt.edits.find(item => item.file === file && equal(item.keys, keys)).ownedGroups = groups;
335
+ await persist();
336
+ }
337
+ await prepareOwnedFile(path.join(paths.root, 'skills', NAME, 'SKILL.md'), skill, receipt);
338
+ await persist();
339
+ }
340
+ if (receipt.migration) {
341
+ await fs.rm(path.join(paths.marketplace, 'plugins', LEGACY_NAME), { recursive: true, force: true });
342
+ delete receipt.migration;
343
+ }
344
+ receipt.status = 'CONFIGURED';
345
+ await persist();
346
+ return { agent, status: 'CONFIGURED', connection, path: paths.state,
347
+ hostExecution: 'NOT_VERIFIED', nextSteps: activationSteps(agent, paths, config) };
348
+ } catch (error) {
349
+ receipt.status = 'FAILED';
350
+ await persist();
351
+ if (agent === 'openclaw' && !input.acceptCapabilities && /capability consent|--accept-capabilities/i.test(error.message)) {
352
+ error.message += `\nReview Agentic Context at ${paths.plugin}; after approving its capabilities, rerun the same Agentic Context install command with --accept-capabilities. Native approval in the same OpenClaw profile can also be completed before rerunning Agentic Context.`;
353
+ }
354
+ throw error;
355
+ }
356
+ }
357
+
358
+ export async function status(agent, options = {}, dependencies = {}) {
359
+ const { paths, receipt } = await findInstallation(agent, options);
360
+ if (!receipt) return { agent, status: 'NOT_INSTALLED' };
361
+ await refreshQoderHookEdits(receipt);
362
+ const config = await readJson(paths.config);
363
+ const drift = [];
364
+ if (!config) drift.push(paths.config);
365
+ for (const file of requiredPayloadFiles(agent, paths)) {
366
+ try {
367
+ if (!(await fs.stat(file)).isFile()) drift.push(file);
368
+ else await fs.access(file, fs.constants.R_OK);
369
+ } catch (error) {
370
+ if (!['ENOENT', 'ENOTDIR', 'EACCES'].includes(error.code)) throw error;
371
+ drift.push(file);
372
+ }
373
+ }
374
+ if (config && agent === 'qoder') {
375
+ try { validateConnection({ ...config, agent }); }
376
+ catch (error) { drift.push(error.message); }
377
+ }
378
+ for (const edit of receipt.edits) {
379
+ if (!equal(getAt((await readDocument(edit.file)).value, edit.keys), edit.value)) drift.push(`${edit.file}: ${edit.keys.join('.')}`);
380
+ }
381
+ for (const file of receipt.ownedFiles) {
382
+ try { if (await fs.readFile(file.path, 'utf8') !== file.content) drift.push(file.path); }
383
+ catch (error) { if (error.code !== 'ENOENT') throw error; drift.push(file.path); }
384
+ }
385
+ return { agent, status: drift.length ? 'CONFIGURATION_CHANGED' : receipt.status, version: receipt.version,
386
+ configuration: config ? publicConnection(config) : null, path: paths.state, pluginName: paths.name,
387
+ ...(receipt.migration ? { migration: receipt.migration.phase } : {}), drift, hostExecution: 'NOT_VERIFIED',
388
+ ...(receipt.status === 'CONFIGURED' && config ? { nextSteps: activationSteps(agent, paths, config) } : {}),
389
+ ...(options.check && config ? { connection: await (dependencies.probe || probeMcp)(config) } : {}) };
390
+ }
391
+
392
+ async function removeManagedInstallation(agent, paths, receipt, options, dependencies, { keepState = false } = {}) {
393
+ await refreshQoderHookEdits(receipt);
394
+ const config = await readJson(paths.config);
395
+ const run = (command, args, env) => (dependencies.run || runCommand)(command, args, env, [config?.apiKey]);
396
+ const env = hostEnvironment(agent, paths, options.env);
397
+ const executable = receipt.registered || receipt.marketplaceAdded
398
+ ? await (dependencies.resolveExecutable || resolveExecutable)(agent, { agentBin: options.agentBin || receipt.executable, env }) : undefined;
399
+ if (receipt.registered && ['codex', 'claude-code'].includes(agent)) {
400
+ await run(executable, ['plugin', agent === 'codex' ? 'remove' : 'uninstall', `${paths.name}@${paths.marketplaceName}`, ...(agent === 'claude-code' ? ['--scope', 'user'] : ['--json'])], env);
401
+ receipt.registered = false; await writePrivate(paths.receipt, receipt);
402
+ }
403
+ if (receipt.marketplaceAdded) {
404
+ try {
405
+ await run(executable, ['plugin', 'marketplace', 'remove', paths.marketplaceName], env);
406
+ } catch (error) {
407
+ // Removal may have taken effect before a timeout or interrupted receipt
408
+ // write. Only an explicit native inventory can prove cleanup completed.
409
+ let absent = false;
410
+ try {
411
+ const listing = JSON.parse((await run(executable, ['plugin', 'marketplace', 'list', '--json'], env)).stdout);
412
+ const marketplaces = agent === 'codex' ? listing?.marketplaces : Array.isArray(listing) ? listing : null;
413
+ absent = Array.isArray(marketplaces)
414
+ && marketplaces.every(item => item && typeof item.name === 'string' && item.name.length > 0)
415
+ && !marketplaces.some(item => item.name === paths.marketplaceName);
416
+ } catch { /* Preserve the original removal failure when inventory is unavailable. */ }
417
+ if (!absent) throw error;
418
+ }
419
+ receipt.marketplaceAdded = false; await writePrivate(paths.receipt, receipt);
420
+ }
421
+ if (agent === 'openclaw' && receipt.registered) {
422
+ const keys = ['plugins', 'entries', paths.name, 'config'];
423
+ const owned = receipt.edits.find(edit => equal(edit.keys, keys));
424
+ const current = getAt((await readDocument(paths.hostConfig)).value, keys);
425
+ if (current !== undefined && !equal(current, owned?.value)) {
426
+ return { agent, status: 'MANUAL_CLEANUP_REQUIRED', warnings: [`Preserved modified setting: ${paths.hostConfig} ${keys.join('.')}`], path: paths.state };
427
+ }
428
+ await run(executable, ['plugins', 'uninstall', paths.name, '--force'], env);
429
+ // The native uninstaller owns the plugin entry; only restore external MCP settings.
430
+ receipt.edits = receipt.edits.filter(edit => !equal(edit.keys.slice(0, 3), ['plugins', 'entries', paths.name]));
431
+ receipt.registered = false; await writePrivate(paths.receipt, receipt);
432
+ }
433
+ const warnings = await restoreSettings(receipt.edits);
434
+ if (warnings.length) return { agent, status: 'MANUAL_CLEANUP_REQUIRED', warnings, path: paths.state };
435
+ for (const file of receipt.ownedFiles) {
436
+ try {
437
+ if (await fs.readFile(file.path, 'utf8') === file.content) {
438
+ await fs.rm(file.path);
439
+ try { await fs.rmdir(path.dirname(file.path)); } catch (error) { if (!['ENOTEMPTY', 'ENOENT'].includes(error.code)) throw error; }
440
+ }
441
+ else warnings.push(`Preserved modified file: ${file.path}`);
442
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
443
+ }
444
+ if (warnings.length) return { agent, status: 'MANUAL_CLEANUP_REQUIRED', warnings, path: paths.state };
445
+ if (keepState) {
446
+ receipt.edits = []; receipt.ownedFiles = [];
447
+ await writePrivate(paths.receipt, receipt);
448
+ return { agent, status: 'DETACHED' };
449
+ }
450
+ await fs.rm(paths.state, { recursive: true });
451
+ return { agent, status: 'UNINSTALLED', remoteDataDeleted: false };
452
+ }
453
+
454
+
455
+ export async function uninstall(agent, options = {}, dependencies = {}) {
456
+ const { paths, receipt } = await findInstallation(agent, options);
457
+ if (!receipt) return { agent, status: 'NOT_INSTALLED' };
458
+ return removeManagedInstallation(agent, paths, receipt, options, dependencies);
459
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,17 @@
1
+ import { withMcp } from '../runtime/mcp.js';
2
+
3
+ export async function probeMcp(config, fetcher = globalThis.fetch) {
4
+ return withMcp(config, async rpc => {
5
+ const tools = []; const cursors = new Set(); let cursor;
6
+ do {
7
+ const page = await rpc('tools/list', cursor ? { cursor } : {});
8
+ if (!Array.isArray(page.tools)) throw new Error('Invalid MCP tool list');
9
+ tools.push(...page.tools.map(tool => tool.name)); cursor = page.nextCursor;
10
+ if (cursor && cursors.has(cursor)) throw new Error('Repeated MCP pagination cursor');
11
+ if (cursor) cursors.add(cursor);
12
+ if (cursors.size > 100) throw new Error('Too many MCP tool pages');
13
+ } while (cursor);
14
+ const required = ['hybrid_search', 'memory_search', 'memory_add', 'kb_docs_search', 'skill_search', 'skill_view'];
15
+ return { connected: true, toolCount: tools.length, missingTools: required.filter(name => !tools.includes(name)) };
16
+ }, fetcher);
17
+ }