@gitkraken/agent-tools
v0.2.0
Published
Host configuration and path helpers for AI coding agent integrations, with an in-memory filesystem for testing.
Readme
@gitkraken/agent-tools
The current scaffold exports AgentHostConfig, AgentHostPaths, AgentOrigin, joinPath, and a testing MemoryFileSystem. Client detection, MCP and skill configuration, and hook configuration are planned for later layers.
The package is ports & adapters: it never touches the real filesystem, the environment, or a child process directly. AgentHostConfig describes the host-provided platform, paths, filesystem port, and optional process facts. joinPath joins path segments using / without importing node:path.
import { joinPath } from '@gitkraken/agent-tools';
const settingsPath = joinPath('/Users/me', '.config', 'settings.json');Install
pnpm add @gitkraken/agent-toolsHost configuration types
Import the configuration types when implementing a host adapter:
import type { AgentHostConfig } from '@gitkraken/agent-tools';
const config: AgentHostConfig = {
platform: 'darwin', // 'darwin' | 'linux' | 'win32'
paths: {
home: '/Users/me',
configHome: '/Users/me/.config', // resolved $XDG_CONFIG_HOME
appSupport: '/Users/me/Library/Application Support',
// claudeConfigDir, copilotHome, grokHome, piAgentDir: resolved env overrides, when set
},
fs: myFileSystemPort, // @gitkraken/shared-tools FileSystemPort
resolveExecutable: (command) => myPathLookup(command), // optional: a client's binary on PATH
openCodeMajor: 2, // optional: the installed OpenCode major version (0/undefined = unknown); does not select the MCP layout
};MCP install/uninstall
import { getMcpCliInstallCommand, inspectMcpInstall, installMcp, uninstallMcp } from '@gitkraken/agent-tools';
// 1. Try the client's own CLI first, when it has one. `undefined` means go straight to step 2.
const request = { client: 'vscode', executablePath: '/usr/local/bin/gk', args: ['mcp', '--host=vscode'] } as const;
const argv = await getMcpCliInstallCommand(request, config);
if (argv) {
const ok = await myHost.run(argv); // your own process spawn
if (ok) return;
}
// 2. Fall back to a direct, comment-preserving config-file write.
const installed = await installMcp(request, config);
// { client, path, change: 'created' | 'updated' | 'unchanged', format, location, entry, removedLocations }
// Drift check, e.g. for a status command:
const status = await inspectMcpInstall(request, config);
// { state: 'missing' | 'drifted' | 'current', reason?, key?, entry?, path, location }
const removal = await uninstallMcp({ client: 'vscode' }, config);
// { client, removed, files: [{ path, change, removedLocations, reason? }] }installMcp/uninstallMcp/inspectMcpInstall accept an optional configPath to target a specific file directly instead of the client's resolved config path.
getMcpCliInstallCommand returns undefined when a shortcut cannot do the job: the client has none, a configPath is set, the client is Cursor (its config file is always written directly), or Codex's config still holds the legacy ['mcp_servers.GitKraken'] table. installMcp and uninstallMcp throw unsupported client for an unknown id (installMcp also for the uninstall-only gemini), and throw without writing when a config file does not parse (inspectMcpInstall reports invalid-config instead). A leading UTF-8 BOM is accepted and kept.
Re-running installMcp when the entry is already in place writes nothing and reports change: 'unchanged'. inspectMcpInstall says why an entry is not current (reason, and the drifting key) and exposes the on-disk entry, so a host can carry user-added args forward before reinstalling.
OpenCode layouts
OpenCode reads MCP servers from /mcp/<name> (v1) or /mcp/servers/<name> (v2). Omitting openCode writes v2 and prunes a v1 entry, as in 0.1.1. A host that probed the installed OpenCode should pass a policy on every OpenCode call (inspect before and after repair, and install). The library never runs the probe or compares versions:
// Verified earlier-v1 OpenCode -> v1; verified >= 1.18.24, or a v2 client -> v2.
const policy = { mode: 'authoritative', layout: 'v1' } as const;
// Probe failed or unparseable: follow what the config already uses, never prune.
const unknown = { mode: 'infer-existing' } as const;
await installMcp({ ...request, openCode: policy }, config);
generateConfigEntry('opencode', '/usr/local/bin/gk', ['mcp'], { openCode: policy });
// { type: 'local', command: ['/usr/local/bin/gk', 'mcp'], enabled: true } (v2 uses `disabled: false`)
clientJSONPath('opencode', { openCode: policy }); // '/mcp/GitKraken'authoritativewrites the selected layout and removes the other layout's GitKraken entry.infer-existingfollows the layout the config already uses and repairs that entry in place; with both layouts present it selects v1, because OpenCode's compatibility reader retains it over the native entry. It leaves the duplicate alone. It never authorizes pruning, even if you copy the reportedopenCodeLayoutelsewhere; passinfer-existingunchanged through both inspections and the install.infer-existingthrowsOpenCodeMcpLayoutUnknownErrorwhen there is no entry to follow, and in the read-free helpers (generateConfigEntry,clientJSONPath), which cannot read the config. To generate an entry without a probe result, choose a documented fallback yourself, e.g.{ mode: 'authoritative', layout: 'v1' }; generating performs no write or cleanup. A non-object GitKraken entry is treated as corruption and refused, not skipped. A malformed policy throwsTypeError.inspectMcpInstallcompares the selected layout; an entry found only in the other layout isdrifted/legacy-locationwith that entry exposed for argv repair, and a current entry with a prunable duplicate isdrifted/legacy-locationwith the selected entry. Withinfer-existingthe compared entry is the v1 one when both layouts are present.openCodeDuplicateLocationreports the pointer of the other layout's entry whenever both hold one, in either mode; it is never pruned byinfer-existing.infer-existingwith no entry in either layout (including a missing or blank file) ismissing/layout-unknown, the case whereinstallMcpwould throwOpenCodeMcpLayoutUnknownError. Argv merging stays in the host: re-runinspectMcpInstallwith the effective argv beforeinstallMcp.- A migration writes the selected entry first and the removal second. If the removal fails (or the file changed in between),
installMcpthrowsOpenCodeMcpCleanupError;error.resultdescribes what was committed, with noremovedLocations. This is not success, since an older reader can still reject a leftover nativeserversobject. Run the install again to finish.AbortErroris rethrown unchanged. - Refused without writing: a non-object root,
mcpormcp.servers; a v2 write wheremcp.serversis a flat server namedservers(an object whosetypeorenabledis a scalar, array or null).inspectMcpInstallreports these same files asmissing/unsafe-config, so a host can tell before installing. Custom fields inside a replaced GitKraken entry are not carried across layouts. - Safe migration needs your
FileSystemPort.writeFileto leave the previous file intact when it rejects (write a temp file, then rename). YourreadFilemust also return exactly the byteswriteFilewas given (no newline normalization, appended trailing newline, or BOM changes): the migration compares the re-read file against what it wrote before the destructive second write, and a port that does not round-trip turns the migration intoOpenCodeMcpCleanupError(retrying completes it). The library cannot check this and holds no lock against concurrent writers. uninstallMcpignores the policy and removes both pointers, except that aGitKrakenkey on a flat server namedserversis not a v2 entry and is left alone;configPathconfines every read and write to that file.
Codex TOML edits preserve surrounding text and comments by splicing table blocks. When an entry needs to change but its valid TOML representation cannot be edited safely this way (for example, a drifted inline or dotted mcp_servers.GitKraken assignment), install or uninstall throws an error naming the config path and leaves the file untouched. A semantically current entry remains a no-op even when it uses an unusual representation.
JSON/JSONC config files are edited losslessly via jsonc-parser (comments and formatting outside the edited range survive); Codex's TOML config is edited by splicing raw text, the same way gkcli does, because a full parse/marshal round trip drops comments and reorders sibling tables.
Hook install/uninstall
import { HookConfigError, installHooks, uninstallHooks } from '@gitkraken/agent-tools';
try {
const installed = await installHooks({ host: 'codex', executablePath: '/usr/local/bin/gk' }, config);
// { host, path, events, blockingEvents, files: [{ path, change: 'created' | 'updated' | 'unchanged' | 'removed' }] }
const removed = await uninstallHooks('codex', config, ['PreToolUse']);
// { host, path, removedEvents, remainingEvents, files, reason?: 'file-missing' | 'not-installed' }
} catch (error) {
if (error instanceof HookConfigError) {
console.error(error.code, error.path); // e.g. 'invalid-json', 'wrong-shape', 'unmanaged-file'
}
}Only GitKraken-owned hooks are added or removed; other entries and keys in the hooks file are preserved, and a file that is not valid JSON (or, for the OpenCode and Pi relays, not written by GitKraken) is refused without being modified.
Client detection
import { allClients, clientDisplayName, detectClient, getClientInstallation } from '@gitkraken/agent-tools';
for (const client of allClients()) {
// 15 installable clients; 'gemini' is retained separately as legacy, uninstall-only.
const detection = await detectClient(client, config); // { detected, source?: 'executable' | 'config-file' | 'config-dir', reason?, checkedPaths, ... }
const installation = await getClientInstallation(client, config); // { installed, paths }: the files that carry a GitKraken entry
console.log(clientDisplayName(client), detection.detected, installation.installed);
}Skills
import { installSkill, SkillConflictError, uninstallSkill } from '@gitkraken/agent-tools';
const request = { agent: 'cursor', name: 'commit-composer', files: { 'SKILL.md': skillMarkdown } } as const;
// action: 'create' | 'replace' | 'unchanged'. path is the skill directory; files lists each content file
// and the .gk-skill.json manifest as created, updated or unchanged (empty when nothing needed writing).
const { action, path, files, status } = await installSkill(request, config);
try {
// action: 'remove' | 'absent'. files lists what was removed, including the directory once it was left empty.
await uninstallSkill(request, config);
} catch (error) {
// Modified or foreign content requires `force`. Errors carry `code`, `operation` and `path`.
if (error instanceof SkillConflictError && error.code === 'modified') {
await uninstallSkill(request, config, { force: true });
}
}status is the skill's state before the action: state is 'missing' | 'current' | 'older-release' | 'modified', reason is a kebab-case code (for example 'content-changed' or 'unowned') and is absent when the skill is current and clean, and path is the skill directory.
The host supplies bundled skill content. The library records ownership in .gk-skill.json through the injected filesystem port and protects modified files during install and uninstall; see ARCHITECTURE.md.
Testing
import { MemoryFileSystem } from '@gitkraken/agent-tools/testing';
const fs = new MemoryFileSystem({ '/Users/me/.cursor/mcp.json': '{}' });