diff --git a/package.json b/package.json index 460501f..f82ea99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@datacore-one/cli", - "version": "2.6.0", + "version": "2.6.1", "description": "CLI for setting up and managing Datacore installations", "type": "module", "main": "dist/index.js", diff --git a/src/index.ts b/src/index.ts index 47a4f41..affa0a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { createSnapshot, saveSnapshot, loadSnapshot, diffSnapshot, restoreFromSn import * as app from './lib/app' import { VERSION } from './version' import { maybeNotifyUpdate, selfUpdate } from './lib/selfupdate' +import { richOutput } from './lib/output' import { homeDir } from './lib/exec' @@ -148,7 +149,7 @@ async function handleMeta( // 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') + const code = await selfUpdate(VERSION, process.argv.slice(2), format === 'human' && richOutput(!!process.stdout.isTTY)) if (code !== null) { process.exitCode = code break @@ -404,7 +405,7 @@ async function handleResource( const visibility = (flags.visibility as string) === 'public' ? 'public' : 'private' let remote = flags.remote as string | undefined let remoteUrl = flags.url as string | undefined - const interactive = process.stdin.isTTY === true && format !== 'json' && flags.yes !== true + const interactive = richOutput(process.stdin.isTTY === true) && format !== 'json' && flags.yes !== true let space try { diff --git a/src/lib/animation.ts b/src/lib/animation.ts index 1ded1c1..2aa705d 100644 --- a/src/lib/animation.ts +++ b/src/lib/animation.ts @@ -2,6 +2,8 @@ * Terminal animations for that hacker aesthetic. */ +import { richOutput } from './output' + const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] const MATRIX_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789' @@ -236,9 +238,8 @@ export async function completionSequence( opts: { enabled?: boolean } = {}, ): Promise { const enabled = opts.enabled ?? ( - !!process.stdout.isTTY + richOutput(!!process.stdout.isTTY) && !process.env.DATACORE_NO_ANIMATION - && !process.env.CI ) if (!enabled) { diff --git a/src/lib/init.ts b/src/lib/init.ts index d7b6cb8..e064848 100644 --- a/src/lib/init.ts +++ b/src/lib/init.ts @@ -20,6 +20,7 @@ import { existsSync, mkdirSync, writeFileSync, symlinkSync, copyFileSync, readdirSync, readFileSync, statSync, cpSync } from 'fs' import { join, basename } from 'path' import { execFileSync, homeDir, npmBinCandidates, refreshWindowsPath, runShell } from './exec' +import { agentDriving, richOutput } from './output' import { createInterface } from 'readline' import { detectPlatform, getInstallCommand, type Platform } from './platform' import { AVAILABLE_MODULES, installModule, listModules } from './module' @@ -1107,7 +1108,8 @@ export function isInitialized(): boolean { export async function initDatacore(options: InitOptions = {}): Promise { const { nonInteractive = false, skipChecks = false, stream = false, verbose = false, force = false } = options const answers = options.answers - const isTTY = stream && process.stdout.isTTY + // Not merely isTTY: an agent's terminal is a real pseudo-terminal too. See output.ts. + const isTTY = stream && richOutput(!!process.stdout.isTTY) // Answers supplied => deliberately non-interactive, and legitimately so. const interactive = isTTY && !nonInteractive && !answers @@ -1130,6 +1132,20 @@ export async function initDatacore(options: InitOptions = {}): Promise = [ + ['CURSOR_AGENT', 'Cursor'], // https://cursor.com/docs/agent/tools/terminal + ['CLAUDECODE', 'Claude Code'], + ['CODEX_SANDBOX', 'Codex'], + ['GEMINI_CLI', 'Gemini CLI'], +] + +/** The harness whose agent is driving this terminal, or null. */ +export function agentDriving(env: NodeJS.ProcessEnv = process.env): string | null { + for (const [key, name] of AGENT_MARKERS) { + if (env[key]) return name + } + return null +} + +/** + * Animations, streamed installer output and interactive prompts: only for a + * real terminal that no agent is driving. DATACORE_PLAIN=1 forces plain output. + */ +export function richOutput(isTTY: boolean, env: NodeJS.ProcessEnv = process.env): boolean { + return isTTY && !agentDriving(env) && !env.DATACORE_PLAIN && !env.CI +} diff --git a/src/lib/selfupdate.ts b/src/lib/selfupdate.ts index ab7a01c..6a8e3a7 100644 --- a/src/lib/selfupdate.ts +++ b/src/lib/selfupdate.ts @@ -15,6 +15,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { join } from 'path' import { execFileSync, homeDir, runShell } from './exec' +import { richOutput } from './output' // ─── Versions ──────────────────────────────────────────────────────────────── @@ -63,7 +64,7 @@ function checkStatePath(): string { * the 3 s fetch timeout (and only on the day it checks). */ export async function maybeNotifyUpdate(current: string): Promise { - if (!process.stdout.isTTY || process.env.CI || process.env.DATACORE_NO_UPDATE_CHECK) return + if (!richOutput(!!process.stdout.isTTY) || process.env.DATACORE_NO_UPDATE_CHECK) return try { const path = checkStatePath() let state: { checkedAt?: string; latest?: string } = {} diff --git a/src/lib/upgrade.ts b/src/lib/upgrade.ts index 800d258..5df0363 100644 --- a/src/lib/upgrade.ts +++ b/src/lib/upgrade.ts @@ -13,6 +13,7 @@ import { join } from 'path' import { execFileSync, homeDir, npmBinCandidates, runShell, which } from './exec' import { harnessConfigs, windowsMcpEntry, windowsScriptFor, wireHarnesses, type McpEntry } from './harness' import { mergeUpstream, upgradeMcpPackages } from './selfupdate' +import { richOutput } from './output' import { createInterface } from 'readline' import { detectPlatform, getInstallCommand, type Platform } from './platform' import { updateModules, listModules } from './module' @@ -704,7 +705,7 @@ function upgradeSnapshot( export async function updateDatacore(options: UpdateOptions = {}): Promise { const { stream = false, skipModules = false, skipDeps = false, yes = false } = options - const isTTY = stream && !!process.stdout.isTTY + const isTTY = stream && richOutput(!!process.stdout.isTTY) const interactive = isTTY && !yes const platform = detectPlatform() diff --git a/tests/output.test.ts b/tests/output.test.ts new file mode 100644 index 0000000..716c2f0 --- /dev/null +++ b/tests/output.test.ts @@ -0,0 +1,67 @@ +/** + * Plain output when an agent is driving the terminal. + * + * Cursor's agent runs commands in a real pseudo-terminal, so isTTY is true and + * the CLI animated for it: a spinner redrawing 12 times a second, streamed + * winget and npm progress bars, a ticker every 30 s. The agent keeps what it + * reads in its chat history, which Cursor stores in state.vscdb — the file that + * grew too large to open on a user's machine (docs/incidents/2026-09-27-...). + * An agent also cannot answer the wizard's prompts, so init must send it to the + * --answers path instead. + */ +import { describe, it, expect } from 'bun:test' +import { spawnSync } from 'child_process' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { agentDriving, richOutput } from '../src/lib/output' + +describe('agentDriving', () => { + it('recognises Cursor, Claude Code, Codex and Gemini CLI', () => { + expect(agentDriving({ CURSOR_AGENT: '1' })).toBe('Cursor') + expect(agentDriving({ CLAUDECODE: '1' })).toBe('Claude Code') + expect(agentDriving({ CODEX_SANDBOX: 'seatbelt' })).toBe('Codex') + expect(agentDriving({ GEMINI_CLI: '1' })).toBe('Gemini CLI') + }) + it('is null for a person at a terminal', () => { + expect(agentDriving({ TERM_PROGRAM: 'vscode' })).toBeNull() + expect(agentDriving({})).toBeNull() + }) +}) + +describe('richOutput', () => { + it('is on only for a real terminal with nobody else driving it', () => { + expect(richOutput(true, {})).toBe(true) + expect(richOutput(false, {})).toBe(false) + expect(richOutput(true, { CURSOR_AGENT: '1' })).toBe(false) + expect(richOutput(true, { DATACORE_PLAIN: '1' })).toBe(false) + expect(richOutput(true, { CI: 'true' })).toBe(false) + }) +}) + +// A real pseudo-terminal, as Cursor gives its agent: isTTY is true here. +const scriptArgs = (cmd: string[]) => + process.platform === 'darwin' ? ['-q', '/dev/null', ...cmd] + : process.platform === 'linux' ? ['-qec', cmd.join(' '), '/dev/null'] + : null + +describe('init under an agent, in a real pseudo-terminal', () => { + const args = scriptArgs(['bun', 'run', join(import.meta.dir, '..', 'src', 'index.ts'), 'init']) + it.skipIf(!args)('refuses the wizard, names the agent path, and does not animate', () => { + const root = mkdtempSync(join(tmpdir(), 'dc-cli-agent-')) + try { + const r = spawnSync('script', args!, { + encoding: 'utf-8', timeout: 30000, + env: { ...process.env, CURSOR_AGENT: '1', DATACORE_ROOT: join(root, 'Data'), DATACORE_NO_UPDATE_CHECK: '1' }, + }) + const out = `${r.stdout}${r.stderr}` + expect(out).toContain('--answers') + expect(out).toContain('Cursor') + expect(out).not.toContain('\x1b[?25l') // spinner hides the cursor + expect(out).not.toContain('\x1b[2J') // console.clear + expect(out.length).toBeLessThan(3000) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, 40000) +})