Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ jobs:
run: |
Remove-Item Env:HOME -ErrorAction SilentlyContinue
bun scripts/windows-smoke.ts
- name: Harnesses get working MCP entries (Codex, Antigravity) in a throwaway home
shell: pwsh
run: |
$t = Join-Path $env:RUNNER_TEMP 'home'
New-Item -ItemType Directory -Force $t | Out-Null
$env:USERPROFILE = $t
$env:DATACORE_ROOT = Join-Path $t 'Data'
Remove-Item Env:HOME -ErrorAction SilentlyContinue
bun scripts/harness-smoke.ts
Get-Content (Join-Path $t '.codex\config.toml')
- name: Built CLI detects installed tools (HOME unset, as on a user's machine)
shell: pwsh
run: |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@datacore-one/cli",
"version": "2.5.1",
"version": "2.6.0",
"description": "CLI for setting up and managing Datacore installations",
"type": "module",
"main": "dist/index.js",
Expand Down
36 changes: 36 additions & 0 deletions scripts/harness-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* End-to-end: a machine with Codex and Antigravity installed gets both MCP
* servers registered in each, with commands that exist. Uses a throwaway
* home; run with HOME/USERPROFILE and DATACORE_ROOT pointing at temp dirs.
*/
import { existsSync, mkdirSync, readFileSync } from 'fs'
import { join } from 'path'
import { homeDir } from '../src/lib/exec'
import { configureOtherHarnesses, mcpServerEntries } from '../src/lib/upgrade'

const home = homeDir()
mkdirSync(join(home, '.codex'), { recursive: true })
mkdirSync(join(home, '.gemini', 'config'), { recursive: true })
const result = { updated: [] as string[], warnings: [] as string[], alreadyCurrent: [] as string[] }
configureOtherHarnesses(false, result)
console.log('updated:', result.updated, 'warnings:', result.warnings)

let failed = 0
const check = (name: string, ok: boolean, detail: unknown) => { console.log(`${ok ? 'ok ' : 'FAIL'} ${name}: ${detail}`); if (!ok) failed++ }
const toml = readFileSync(join(home, '.codex', 'config.toml'), 'utf-8')
check('codex has datacore', toml.includes('[mcp_servers.datacore]'), '')
check('codex has plur', toml.includes('[mcp_servers.plur]'), '')
const agy = JSON.parse(readFileSync(join(home, '.gemini', 'config', 'mcp_config.json'), 'utf-8'))
check('antigravity has both', !!agy.mcpServers?.datacore && !!agy.mcpServers?.plur, Object.keys(agy.mcpServers ?? {}))
const plur = mcpServerEntries().plur!
// Strict in CI, where the workflow installs @plur-ai/mcp first; locally the
// throwaway home has no PLUR in it, so only check what could be found.
if (process.env.CI || plur.command.includes('/') || plur.command.includes('\\')) {
check('plur entry launches something that exists', existsSync(plur.command) && (!plur.args || existsSync(plur.args[0]!)), JSON.stringify(plur))
}
if (process.platform === 'win32') check('Windows entry runs node, not a .cmd shim', /node\.exe$/i.test(plur.command), plur.command)
const again = { updated: [] as string[], warnings: [] as string[], alreadyCurrent: [] as string[] }
configureOtherHarnesses(false, again)
check('second run changes nothing', again.updated.length === 0, again.alreadyCurrent)
if (failed) process.exit(1)
console.log('harness smoke passed')
16 changes: 11 additions & 5 deletions src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,20 @@ Description:
All steps are idempotent — safe to run repeatedly.

Steps:
1. Git pull all repos (root + spaces)
0. Update this CLI if npm has a newer one, then continue under it
1. Git pull all repos (root + spaces); on a forked install, merge
datacore-one/datacore into your fork and push it (merge, never rebase;
stops without changing anything on uncommitted changes or a conflict)
2. Git pull all installed modules
3. Install/update MCP server (npm install -g @datacore-one/mcp)
4. Configure MCP for Claude Code/Desktop (asks on first run)
5. Rebuild CLAUDE.md from layers
6. Update installation snapshot
3. Install or upgrade the MCP servers (@datacore-one/mcp, @plur-ai/mcp)
4. Configure MCP for Claude Code/Desktop (asks on first run) and every
other installed harness: Cursor, Codex, Antigravity, Gemini CLI, Windsurf
5. Enable the git safety hooks if they are off
6. Rebuild CLAUDE.md from layers
7. Update installation snapshot

Options:
--skip-self-update Keep this CLI version
--skip-modules Skip module updates
--skip-deps Skip dependency installation
--yes, -y Non-interactive (default to Claude Code)
Expand Down
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { listModules, installModule, updateModules, removeModule } from './lib/m
import { createSnapshot, saveSnapshot, loadSnapshot, diffSnapshot, restoreFromSnapshot, lockFileExists } from './lib/snapshot'
import * as app from './lib/app'
import { VERSION } from './version'
import { maybeNotifyUpdate, selfUpdate } from './lib/selfupdate'
import { homeDir } from './lib/exec'


Expand Down Expand Up @@ -144,6 +145,16 @@ async function handleMeta(
break
}

// Update the CLI first and let the new version do the rest: otherwise the
// update logic that runs is always the one being replaced.
if (flags['skip-self-update'] !== true) {
const code = await selfUpdate(VERSION, process.argv.slice(2), format === 'human')
if (code !== null) {
process.exitCode = code
break
}
}

const updateResult = await updateDatacore({
stream: format === 'human',
skipModules: flags['skip-modules'] === true,
Expand Down Expand Up @@ -1220,6 +1231,9 @@ async function main() {
switch (parsed.type) {
case 'meta':
await handleMeta(parsed.command, parsed.args, parsed.flags, format)
// `update` just did the check itself; everything else gets the one-line
// notice (human terminals only, at most one registry request a day).
if (format === 'human' && parsed.command !== 'update') await maybeNotifyUpdate(VERSION)
break

case 'resource':
Expand Down
176 changes: 176 additions & 0 deletions src/lib/harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* Register Datacore and PLUR with every AI harness on the machine.
*
* init wired Claude Code and Claude Desktop only, so a user working in Cursor,
* Codex or Antigravity finished a clean install with an agent that had no
* datacore_* or plur_* tools — and no error anywhere to say so.
*
* Rules, the same ones the Claude writers in upgrade.ts follow:
* - a harness is configured only if it is already installed (its config
* directory exists); nothing is installed on the user's behalf;
* - servers are ADDED when missing and never overwritten, so a user's own
* entry wins;
* - a config that does not parse is refused, never replaced.
*
* Cursor goes through .datacore/adapters/cursor/install.py, which owns the
* Cursor-specific parts (the ~40-tool cap, tool profiles, hooks) and writes a
* workspace config for ~/Data.
*/

import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
import { execFileSync, homeDir } from './exec'
import { findPython } from './python'

export interface McpEntry {
command: string
args?: string[]
}

export interface HarnessConfig {
name: string
label: string
/** The config file to write. */
path: string
/** Present only when the harness is installed. */
detect: string
format: 'json' | 'toml'
}

export function harnessConfigs(home: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): HarnessConfig[] {
const desktopDir = platform === 'darwin'
? join(home, 'Library', 'Application Support', 'Claude')
: platform === 'win32'
? join(env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'Claude')
: join(home, '.config', 'Claude')
return [
{ name: 'claude-desktop', label: 'Claude Desktop', path: join(desktopDir, 'claude_desktop_config.json'), detect: desktopDir, format: 'json' },
{ name: 'codex', label: 'Codex', path: join(home, '.codex', 'config.toml'), detect: join(home, '.codex'), format: 'toml' },
{ name: 'antigravity', label: 'Antigravity', path: join(home, '.gemini', 'config', 'mcp_config.json'), detect: join(home, '.gemini', 'config'), format: 'json' },
{ name: 'gemini-cli', label: 'Gemini CLI', path: join(home, '.gemini', 'settings.json'), detect: join(home, '.gemini', 'settings.json'), format: 'json' },
{ name: 'windsurf', label: 'Windsurf', path: join(home, '.codeium', 'windsurf', 'mcp_config.json'), detect: join(home, '.codeium', 'windsurf'), format: 'json' },
]
}

/** Add missing servers under `mcpServers`. Throws on a config that does not parse. */
export function addServersJson(text: string | null, servers: Record<string, McpEntry>): { text: string; added: string[] } {
const config = (text && text.trim() ? JSON.parse(text) : {}) as Record<string, unknown>
if (typeof config !== 'object' || config === null || Array.isArray(config)) throw new Error('config is not a JSON object')
const existing = (config.mcpServers ?? {}) as Record<string, unknown>
const added: string[] = []
for (const [name, entry] of Object.entries(servers)) {
if (!existing[name]) {
existing[name] = entry
added.push(name)
}
}
config.mcpServers = existing
return { text: JSON.stringify(config, null, 2) + '\n', added }
}

function tomlString(s: string): string {
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}

/** Append a `[mcp_servers.<name>]` table per missing server; the rest is kept byte for byte. */
export function addServersToml(text: string | null, servers: Record<string, McpEntry>): { text: string; added: string[] } {
let out = text ?? ''
const added: string[] = []
for (const [name, entry] of Object.entries(servers)) {
const header = new RegExp(`^\\[mcp_servers\\.(${name}|"${name}")\\]\\s*$`, 'm')
if (header.test(out)) continue
if (out && !out.endsWith('\n')) out += '\n'
if (out) out += '\n'
out += `[mcp_servers.${name}]\ncommand = ${tomlString(entry.command)}\n`
if (entry.args?.length) out += `args = [${entry.args.map(tomlString).join(', ')}]\n`
added.push(name)
}
return { text: out, added }
}

/**
* On Windows an npm "binary" is a .cmd shim, and most MCP clients spawn
* without a shell, which Node refuses for .cmd files. So the entry runs node
* on the package's script directly.
*/
export function windowsMcpEntry(nodePath: string, script: string): McpEntry {
return { command: nodePath, args: [script] }
}

/** The script a .cmd shim would run: <prefix>\node_modules\<pkg>\<bin>. */
export function windowsScriptFor(npmPrefix: string, pkg: string, bin: string): string | null {
try {
const pkgDir = join(npmPrefix, 'node_modules', ...pkg.split('/'))
const manifest = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8')) as { bin?: string | Record<string, string> }
const rel = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[bin]
return rel ? join(pkgDir, rel) : null
} catch {
return null
}
}

export interface WireResult {
configured: string[]
alreadyCurrent: string[]
warnings: string[]
}

/**
* Write `servers` into every installed harness. `servers` comes from the same
* builder the Claude Code config uses, so every harness launches the same
* binaries.
*/
export function wireHarnesses(servers: Record<string, McpEntry>, dataDir: string, opts: {
home?: string
platform?: NodeJS.Platform
env?: NodeJS.ProcessEnv
cursorAdapter?: boolean
/** Harnesses configured elsewhere (Claude Desktop has its own flow in upgrade.ts). */
skip?: string[]
} = {}): WireResult {
const home = opts.home ?? homeDir()
const platform = opts.platform ?? process.platform
const env = opts.env ?? process.env
const res: WireResult = { configured: [], alreadyCurrent: [], warnings: [] }

for (const h of harnessConfigs(home, platform, env)) {
if (opts.skip?.includes(h.name) || !existsSync(h.detect)) continue
try {
const before = existsSync(h.path) ? readFileSync(h.path, 'utf-8') : null
const r = h.format === 'toml' ? addServersToml(before, servers) : addServersJson(before, servers)
if (r.added.length === 0) {
res.alreadyCurrent.push(h.label)
continue
}
mkdirSync(dirname(h.path), { recursive: true })
writeFileSync(h.path, r.text)
res.configured.push(`${h.label} (${r.added.join(', ')})`)
} catch (e) {
res.warnings.push(`${h.label}: ${h.path} could not be read as ${h.format.toUpperCase()} — left untouched (${(e as Error).message})`)
}
}

if (opts.cursorAdapter !== false) {
const cursor = wireCursor(home, dataDir)
if (cursor) (cursor.ok ? res.configured : res.warnings).push(cursor.message)
}
return res
}

/** Cursor, through the adapter that knows its limits. Null when Cursor is absent. */
function wireCursor(home: string, dataDir: string): { ok: boolean; message: string } | null {
if (!existsSync(join(home, '.cursor'))) return null
const adapter = join(dataDir, '.datacore', 'adapters', 'cursor', 'install.py')
if (!existsSync(adapter)) {
return { ok: false, message: 'Cursor: this installation predates the Cursor adapter — run `datacore update`, then `datacore init` again' }
}
const python = findPython(dataDir)
if (!python) return { ok: false, message: 'Cursor: no Python 3.10+ found to run the Cursor adapter' }
try {
execFileSync(python, [adapter, 'install', '--root', dataDir], { cwd: dataDir, stdio: 'pipe', timeout: 60000 })
return { ok: true, message: `Cursor (open ${dataDir} as the workspace)` }
} catch (e) {
const err = String((e as { stderr?: Buffer }).stderr ?? (e as Error).message).trim().split('\n').pop()
return { ok: false, message: `Cursor adapter failed: ${err}` }
}
}
4 changes: 4 additions & 0 deletions src/lib/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,10 @@ function configureMcpServer(isTTY: boolean | undefined, result: InitResult): voi

configureMcpForDesktop(!!isTTY, updateResult)
configureMcpForCode(!!isTTY, updateResult)
// Every other harness on the machine: Cursor, Codex, Antigravity, Gemini
// CLI, Windsurf. A Cursor user's install used to finish with no tools.
const { configureOtherHarnesses } = require('./upgrade')
configureOtherHarnesses(!!isTTY, updateResult)

// Transfer warnings to init result
for (const w of updateResult.warnings) {
Expand Down
Loading
Loading