Skip to content
Draft
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
60 changes: 60 additions & 0 deletions packages/cli/src/commands/__tests__/inspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
IterationManager,
} from 'rover-core';
import { inspectCommand } from '../inspect.js';
import { isStdoutTTY } from '../../utils/stdout.js';

// Store testDir for context mock
let testDir: string;
Expand Down Expand Up @@ -49,6 +50,11 @@ vi.mock('../../utils/display.js', () => ({
showTips: vi.fn(),
}));

// Mock stdout TTY detection so we can test piped vs interactive behavior
vi.mock('../../utils/stdout.js', () => ({
isStdoutTTY: vi.fn(() => true),
}));

describe('inspect command', () => {
let originalCwd: string;
// biome-ignore lint/suspicious/noExplicitAny: process.exit mock type requires flexible typing
Expand Down Expand Up @@ -351,4 +357,58 @@ describe('inspect command', () => {
expect(output).toContain('Details');
});
});

describe('Piped stdout (content-only output)', () => {
it('should output only raw file content when stdout is piped', async () => {
createTestTask(1, 'Piped Task');
vi.mocked(isStdoutTTY).mockReturnValue(false);

await inspectCommand('1');

const output = capturedOutput.join('\n');
// Should contain the raw file content (summary.md from createTestTask)
expect(output).toContain('# Test Summary');
// Should not contain decorated output
expect(output).not.toContain('Details');
expect(output).not.toContain('Workspace');
expect(output).not.toContain('Workflow Output');
// Should not contain box-drawing characters from showFile
expect(output).not.toMatch(/┌|└|│/);

vi.mocked(isStdoutTTY).mockReturnValue(true);
});

it('should output nothing when stdout is piped and task has no iteration files', async () => {
const taskPath = join(testDir, '.rover', 'tasks', '2');
const task = TaskDescriptionManager.create(taskPath, {
id: 2,
title: 'Empty Task',
description: 'No iterations',
inputs: new Map(),
workflowName: 'swe',
});
const worktreePath = join('.rover', 'tasks', '2', 'workspace');
launchSync('git', [
'worktree',
'add',
worktreePath,
'-b',
'rover-task-2',
]);
task.setWorkspace(join(testDir, worktreePath), 'rover-task-2');
task.markInProgress();
task.markCompleted();
// No iteration directory with markdown files

vi.mocked(isStdoutTTY).mockReturnValue(false);

await inspectCommand('2');

const output = capturedOutput.join('\n');
expect(output).not.toContain('Details');
expect(output).not.toContain('Workspace');

vi.mocked(isStdoutTTY).mockReturnValue(true);
});
});
});
233 changes: 124 additions & 109 deletions packages/cli/src/commands/inspect.ts
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another interesting command to adapt would be diff.ts.

The idea is that if I do rover diff <taskId>, I get a decorated output, but if I do rover diff <taskId> | cat I get only the patch itself.

Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
requireProjectContext,
} from '../lib/context.js';
import { exitWithError, exitWithSuccess } from '../utils/exit.js';
import { isStdoutTTY } from '../utils/stdout.js';
import type {
FileChangeStat,
RawFileOutput,
Expand Down Expand Up @@ -269,133 +270,147 @@ const inspectCommand = async (
await exitWithSuccess(null, jsonOutput, { telemetry });
return;
} else {
// Format status with user-friendly names
const formattedStatus = formatTaskStatus(task.status);

// Status color
const statusColorFunc = statusColor(task.status);

showTitle('Details');

const properties: Record<string, string> = {
ID: `${task.id.toString()} (${colors.gray(task.uuid)})`,
Title: task.title,
Status: statusColorFunc(formattedStatus),
Agent: task.agent
? formatAgentWithModel(task.agent as any, task.agentModel)
: '-',
Workflow: task.workflowName,
'Created At': new Date(task.createdAt).toLocaleString(),
};
const discoveredFiles = iteration.listMarkdownFiles();

// Show task source if available (e.g., GitHub issue, etc.)
if (task.source?.url) {
const sourceLabel =
task.source.type === 'github' ? 'GitHub Issue' : 'Source';
properties[sourceLabel] = colors.cyan(task.source.url);
}
if (!isStdoutTTY()) {
// Piped stdout: output only raw file content, no decorations
if (discoveredFiles.length > 0) {
const hasSummary = discoveredFiles.includes(DEFAULT_FILE_CONTENTS);
const fileFilter = options.file || [
hasSummary
? DEFAULT_FILE_CONTENTS
: discoveredFiles[discoveredFiles.length - 1],
];
const iterationFileContents = iteration.getMarkdownFiles(fileFilter);
iterationFileContents.forEach(contents => {
console.log(contents.trim());
});
}
} else {
// Interactive TTY: Format status with user-friendly names
const formattedStatus = formatTaskStatus(task.status);
// Status color
const statusColorFunc = statusColor(task.status);

showTitle('Details');

const properties: Record<string, string> = {
ID: `${task.id.toString()} (${colors.gray(task.uuid)})`,
Title: task.title,
Status: statusColorFunc(formattedStatus),
Agent: task.agent
? formatAgentWithModel(task.agent as any, task.agentModel)
: '-',
Workflow: task.workflowName,
'Created At': new Date(task.createdAt).toLocaleString(),
};

if (task.completedAt) {
properties['Completed At'] = new Date(
task.completedAt
).toLocaleString();
} else if (task.failedAt) {
properties['Failed At'] = new Date(task.failedAt).toLocaleString();
}
// Show task source if available (e.g., GitHub issue, etc.)
if (task.source?.url) {
const sourceLabel =
task.source.type === 'github' ? 'GitHub Issue' : 'Source';
properties[sourceLabel] = colors.cyan(task.source.url);
}

// Show error if failed
if (task.error) {
properties['Error'] = colors.red(task.error);
}
if (task.completedAt) {
properties['Completed At'] = new Date(
task.completedAt
).toLocaleString();
} else if (task.failedAt) {
properties['Failed At'] = new Date(task.failedAt).toLocaleString();
}

showProperties(properties);
// Show error if failed
if (task.error) {
properties['Error'] = colors.red(task.error);
}

// Workspace information
showTitle('Workspace');
showProperties(properties);

const workspaceProps: Record<string, string> = {
'Branch Name': task.branchName,
'Git Workspace path': task.worktreePath,
};
// Workspace information
showTitle('Workspace');

showProperties(workspaceProps);
const workspaceProps: Record<string, string> = {
'Branch Name': task.branchName,
'Git Workspace path': task.worktreePath,
};

// Workflow files
const discoveredFiles = iteration.listMarkdownFiles();
showProperties(workspaceProps);

if (discoveredFiles.length > 0) {
showTitle(
`Workflow Output ${colors.gray(`| Iteration ${iterationNumber}/${task.iterations}`)}`
);
showList(discoveredFiles);

// Show the summary file by default only when it's available
const hasSummary = discoveredFiles.includes(DEFAULT_FILE_CONTENTS);
const fileFilter = options.file || [
hasSummary
? DEFAULT_FILE_CONTENTS
: discoveredFiles[discoveredFiles.length - 1],
];

const iterationFileContents = iteration.getMarkdownFiles(fileFilter);
if (iterationFileContents.size === 0) {
console.log(
colors.gray(
`\nNo content for the ${fileFilter.join(', ')} files found for iteration ${iterationNumber}.`
)
if (discoveredFiles.length > 0) {
showTitle(
`Workflow Output ${colors.gray(`| Iteration ${iterationNumber}/${task.iterations}`)}`
);
} else {
console.log();
iterationFileContents.forEach((contents, file) => {
showFile(file, contents.trim());
});
showList(discoveredFiles);

// Show the summary file by default only when it's available
const hasSummary = discoveredFiles.includes(DEFAULT_FILE_CONTENTS);
const fileFilter = options.file || [
hasSummary
? DEFAULT_FILE_CONTENTS
: discoveredFiles[discoveredFiles.length - 1],
];

const iterationFileContents = iteration.getMarkdownFiles(fileFilter);
if (iterationFileContents.size === 0) {
console.log(
colors.gray(
`\nNo content for the ${fileFilter.join(', ')} files found for iteration ${iterationNumber}.`
)
);
} else {
console.log();
iterationFileContents.forEach((contents, file) => {
showFile(file, contents.trim());
});
}
}
}

// Show file changes only if task is not in an active state
if (!task.isActive()) {
const git = new Git({ cwd: project.path });
const stats = await git.diffStats({
worktreePath: task.worktreePath,
includeUntracked: true,
});
// Show file changes only if task is not in an active state
if (!task.isActive()) {
const git = new Git({ cwd: project.path });
const stats = await git.diffStats({
worktreePath: task.worktreePath,
includeUntracked: true,
});

const statFiles = stats.files.map(fileStat => {
const insertions =
fileStat.insertions > 0
? colors.green(`+${fileStat.insertions}`)
: '';
const deletions =
fileStat.deletions > 0 ? colors.red(`-${fileStat.deletions}`) : '';
return `${insertions} ${deletions} ${colors.cyan(fileStat.path)}`;
});
const statFiles = stats.files.map(fileStat => {
const insertions =
fileStat.insertions > 0
? colors.green(`+${fileStat.insertions}`)
: '';
const deletions =
fileStat.deletions > 0 ? colors.red(`-${fileStat.deletions}`) : '';
return `${insertions} ${deletions} ${colors.cyan(fileStat.path)}`;
});

showTitle('File Changes');
showList(statFiles);
}
showTitle('File Changes');
showList(statFiles);
}

const tips = [];

const tips = [];
if (task.status === 'NEW' || task.status === 'FAILED') {
tips.push(
'Use ' + colors.cyan(`rover restart ${taskId}`) + ' to retry it'
);
} else if (options.file == null && discoveredFiles.length > 0) {
tips.push(
'Use ' +
colors.cyan(
`rover inspect ${taskId} --file ${discoveredFiles[0]}`
) +
' to read its content'
);
}

if (task.status === 'NEW' || task.status === 'FAILED') {
tips.push(
'Use ' + colors.cyan(`rover restart ${taskId}`) + ' to retry it'
);
} else if (options.file == null && discoveredFiles.length > 0) {
tips.push(
showTips([
...tips,
'Use ' +
colors.cyan(
`rover inspect ${taskId} --file ${discoveredFiles[0]}`
) +
' to read its content'
);
colors.cyan(`rover iterate ${taskId}`) +
' to start a new agent iteration on this task',
]);
}

showTips([
...tips,
'Use ' +
colors.cyan(`rover iterate ${taskId}`) +
' to start a new agent iteration on this task',
]);
}

await exitWithSuccess(null, { success: true }, { telemetry });
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
} from './lib/context.js';
import { showRoverHeader } from 'rover-core/src/display/header.js';
import { getUserAIAgent } from './lib/agents/index.js';
import { isStdoutTTY } from './utils/stdout.js';
import type { CommandDefinition } from './types.js';

// Registry of all commands for metadata lookup
Expand Down Expand Up @@ -207,8 +208,8 @@ export function createProgram(
agentName = agent.toString();
}

if (isJsonMode() || commandName === 'mcp') {
// Do not print anything for JSON or MCP mode
if (isJsonMode() || commandName === 'mcp' || !isStdoutTTY()) {
// Do not print anything for JSON, MCP, or when stdout is piped
return;
}

Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/utils/stdout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Utility functions for stdout / TTY detection
*/

/**
* Check if stdout is a TTY (interactive terminal).
* When false, stdout is piped or redirected (e.g. to another process or file).
*/
export const isStdoutTTY = (): boolean => {
return process.stdout.isTTY === true;
};

/**
* Check if stdout is being piped or redirected.
* Use this when output should be content-only (no banners, boxes, or decorations).
*/
export const isStdoutPiped = (): boolean => {
return !isStdoutTTY();
};
Loading