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
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.6.0",
"version": "2.6.1",
"description": "CLI for setting up and managing Datacore installations",
"type": "module",
"main": "dist/index.js",
Expand Down
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions src/lib/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* Terminal animations for that hacker aesthetic.
*/

import { richOutput } from './output'

const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const MATRIX_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789'

Expand Down Expand Up @@ -236,9 +238,8 @@ export async function completionSequence(
opts: { enabled?: boolean } = {},
): Promise<void> {
const enabled = opts.enabled ?? (
!!process.stdout.isTTY
richOutput(!!process.stdout.isTTY)
&& !process.env.DATACORE_NO_ANIMATION
&& !process.env.CI
)

if (!enabled) {
Expand Down
18 changes: 17 additions & 1 deletion src/lib/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1107,7 +1108,8 @@ export function isInitialized(): boolean {
export async function initDatacore(options: InitOptions = {}): Promise<InitResult> {
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
Expand All @@ -1130,6 +1132,20 @@ export async function initDatacore(options: InitOptions = {}): Promise<InitResul
// precisely what an agent does when it pipes output, so the quiet path was
// the likeliest one in practice. Taking the defaults is fine; doing it
// without anyone choosing to is not, so it now has to be asked for.
const agent = agentDriving()
if (!isTTY && !options.nonInteractive && !answers && agent) {
// The wizard's prompts cannot be answered by an agent, and its output is
// what the agent would store. Send it to the path built for it.
result.errors.push(
`${agent}'s agent is running this command, and it cannot answer the setup ` +
'wizard. Ask the user the questions yourself: run `datacore init ' +
'--print-questions`, ask them in conversation, write their answers to ' +
'answers.json, then run `datacore init --answers answers.json`. Do not use ' +
'--yes unless the user asked for every default. ' +
'(https://datacore.one/install.txt)',
)
return result
}
if (!isTTY && !options.nonInteractive && !answers) {
result.errors.push(
'Not a terminal, so every prompt would be skipped and all defaults taken ' +
Expand Down
37 changes: 37 additions & 0 deletions src/lib/output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Who is reading the output: a person at a terminal, or an agent.
*
* isTTY cannot tell them apart. Cursor's agent runs commands in a real
* pseudo-terminal, so the CLI animated for it — a spinner redrawing 12 times a
* second, streamed winget and npm progress, a 30-second ticker — and the agent
* kept all of it in its chat history, which Cursor stores in state.vscdb. On a
* user's machine that file grew too large for Cursor to open
* (docs/incidents/2026-09-27-windows-cursor-crash.md).
*
* Harnesses mark their agent terminals with an environment variable; that is
* the signal used here. A person typing into the same editor's terminal does
* not have it set, so they keep the full wizard.
*/

const AGENT_MARKERS: Array<[string, string]> = [
['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
}
3 changes: 2 additions & 1 deletion src/lib/selfupdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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<void> {
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 } = {}
Expand Down
3 changes: 2 additions & 1 deletion src/lib/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -704,7 +705,7 @@ function upgradeSnapshot(

export async function updateDatacore(options: UpdateOptions = {}): Promise<UpdateResult> {
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()

Expand Down
67 changes: 67 additions & 0 deletions tests/output.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading