Skip to content

Commit 29626b5

Browse files
authored
Merge pull request #39 from sakamotopaya/story-14-non-interactive-mode
feat: implement non-interactive mode for CLI (story #14)
2 parents 905e80b + 8f09bf7 commit 29626b5

17 files changed

+3747
-6
lines changed

docs/product-stories/cli-utility/dev-prompt.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
we are ready to work on issue #13 (docs/product-stories/cli-utility/story-13-session-persistence.md) in repo https://github.com/sakamotopaya/code-agent.
2-
follow the normal git flow. create a new local branch for the story, code the tasks and unit tests that
3-
prove the task are complete.
1+
we are ready to work on issue #14 (docs/product-stories/cli-utility/story-14-non-interactive-mode.md) in repo https://github.com/sakamotopaya/code-agent.
2+
follow the normal git flow. create a new local branch for the story.
3+
code the tasks and unit tests that prove the task are complete.
44
if you need information about prior stories, you can find them locally here docs/product-stories/cli-utility
55
we often get rejected trying to push our changes. make sure and run a build and lint prior to trying to push
66
when you are finished with the code and tests, update the issue with a new comment describing your work and then

src/cli/index.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ interface CliOptions {
2828
batch?: string
2929
interactive: boolean
3030
generateConfig?: string
31+
// Non-interactive mode options
32+
stdin?: boolean
33+
yes?: boolean
34+
no?: boolean
35+
timeout?: number
36+
parallel?: boolean
37+
continueOnError?: boolean
38+
dryRun?: boolean
39+
quiet?: boolean
3140
// Browser options
3241
headless: boolean
3342
browserViewport?: string
@@ -107,6 +116,14 @@ program
107116
)
108117
.option("-b, --batch <task>", "Run in non-interactive mode with specified task")
109118
.option("-i, --interactive", "Run in interactive mode (default)", true)
119+
.option("--stdin", "Read commands from stdin (non-interactive mode)")
120+
.option("--yes", "Assume yes for all prompts (non-interactive mode)")
121+
.option("--no", "Assume no for all prompts (non-interactive mode)")
122+
.option("--timeout <ms>", "Global timeout in milliseconds", validateTimeout)
123+
.option("--parallel", "Execute commands in parallel (batch mode)")
124+
.option("--continue-on-error", "Continue execution on command failure")
125+
.option("--dry-run", "Show what would be executed without running commands")
126+
.option("--quiet", "Suppress non-essential output")
110127
.option("--generate-config <path>", "Generate default configuration file at specified path", validatePath)
111128
.option("--headless", "Run browser in headless mode (default: true)", true)
112129
.option("--no-headless", "Run browser in headed mode")
@@ -202,9 +219,48 @@ program
202219
}
203220

204221
// Pass configuration to processors
205-
if (options.batch) {
206-
const batchProcessor = new BatchProcessor(options, configManager)
207-
await batchProcessor.run(options.batch)
222+
if (options.batch || options.stdin || !options.interactive) {
223+
// Use NonInteractiveModeService for non-interactive operations
224+
const { NonInteractiveModeService } = await import("./services/NonInteractiveModeService")
225+
const nonInteractiveService = new NonInteractiveModeService({
226+
batch: options.batch,
227+
stdin: options.stdin,
228+
yes: options.yes,
229+
no: options.no,
230+
timeout: options.timeout,
231+
parallel: options.parallel,
232+
continueOnError: options.continueOnError,
233+
dryRun: options.dryRun,
234+
quiet: options.quiet,
235+
verbose: options.verbose,
236+
})
237+
238+
try {
239+
if (options.stdin) {
240+
await nonInteractiveService.executeFromStdin()
241+
} else if (options.batch) {
242+
// Check if batch is a file path or a direct command
243+
if (
244+
options.batch.includes(".") ||
245+
options.batch.startsWith("/") ||
246+
options.batch.startsWith("./")
247+
) {
248+
await nonInteractiveService.executeFromFile(options.batch)
249+
} else {
250+
// Treat as direct command - use existing BatchProcessor
251+
const batchProcessor = new BatchProcessor(options, configManager)
252+
await batchProcessor.run(options.batch)
253+
}
254+
}
255+
} catch (error) {
256+
const message = error instanceof Error ? error.message : String(error)
257+
if (options.color) {
258+
console.error(chalk.red("❌ Non-interactive execution failed:"), message)
259+
} else {
260+
console.error("Non-interactive execution failed:", message)
261+
}
262+
process.exit(1)
263+
}
208264
} else {
209265
const repl = new CliRepl(options, configManager)
210266
await repl.start()
@@ -346,6 +402,11 @@ program.on("--help", () => {
346402
console.log(" $ roo-cli # Start interactive mode")
347403
console.log(" $ roo-cli --cwd /path/to/project # Start in specific directory")
348404
console.log(' $ roo-cli --batch "Create a hello function" # Run single task')
405+
console.log(" $ roo-cli --batch commands.json # Run batch file")
406+
console.log(" $ roo-cli --stdin --yes # Read from stdin, auto-confirm")
407+
console.log(" $ echo 'npm test' | roo-cli --stdin # Pipe commands")
408+
console.log(" $ roo-cli --batch script.yaml --parallel # Run batch in parallel")
409+
console.log(" $ roo-cli --batch tasks.txt --dry-run # Preview batch execution")
349410
console.log(" $ roo-cli --model gpt-4 # Use specific model")
350411
console.log(" $ roo-cli --mode debug # Start in debug mode")
351412
console.log(" $ roo-cli --format json # Output as JSON")
@@ -370,6 +431,17 @@ program.on("--help", () => {
370431
console.log(" --output <file> Write output to file (format auto-detected)")
371432
console.log(" ROO_OUTPUT_FORMAT Environment variable for default format")
372433
console.log()
434+
console.log("Non-Interactive Mode Options:")
435+
console.log(" --batch <file|task> Run batch file or single task")
436+
console.log(" --stdin Read commands from stdin")
437+
console.log(" --yes Assume yes for all prompts")
438+
console.log(" --no Assume no for all prompts")
439+
console.log(" --timeout <ms> Global timeout for operations")
440+
console.log(" --parallel Execute batch commands in parallel")
441+
console.log(" --continue-on-error Continue execution on command failure")
442+
console.log(" --dry-run Show what would be executed")
443+
console.log(" --quiet Suppress non-essential output")
444+
console.log()
373445
console.log("Browser Options:")
374446
console.log(" --headless/--no-headless Run browser in headless or headed mode")
375447
console.log(" --browser-viewport <size> Set browser viewport (e.g., 1920x1080)")

src/cli/parsers/BatchFileParser.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import {
2+
BatchConfig,
3+
BatchCommand,
4+
BatchSettings,
5+
NonInteractiveDefaults,
6+
ErrorHandlingStrategy,
7+
OutputFormat,
8+
JSONBatchFile,
9+
YAMLBatchFile,
10+
} from "../types/batch-types"
11+
import { JSONBatchParser } from "./JSONBatchParser"
12+
import { YAMLBatchParser } from "./YAMLBatchParser"
13+
import { TextBatchParser } from "./TextBatchParser"
14+
import * as fs from "fs/promises"
15+
import * as path from "path"
16+
17+
export class BatchFileParser {
18+
private jsonParser: JSONBatchParser
19+
private yamlParser: YAMLBatchParser
20+
private textParser: TextBatchParser
21+
22+
constructor() {
23+
this.jsonParser = new JSONBatchParser()
24+
this.yamlParser = new YAMLBatchParser()
25+
this.textParser = new TextBatchParser()
26+
}
27+
28+
async parseFile(filePath: string): Promise<BatchConfig> {
29+
const content = await fs.readFile(filePath, "utf-8")
30+
const extension = path.extname(filePath).toLowerCase()
31+
32+
switch (extension) {
33+
case ".json":
34+
return this.parseJSON(JSON.parse(content))
35+
case ".yaml":
36+
case ".yml":
37+
return this.parseYAML(content)
38+
case ".txt":
39+
default:
40+
return this.parseText(content)
41+
}
42+
}
43+
44+
parseJSON(data: any): BatchConfig {
45+
return this.jsonParser.parse(data)
46+
}
47+
48+
parseYAML(content: string): BatchConfig {
49+
return this.yamlParser.parse(content)
50+
}
51+
52+
parseText(content: string): BatchConfig {
53+
return this.textParser.parse(content)
54+
}
55+
56+
async validateBatchFile(filePath: string): Promise<{
57+
valid: boolean
58+
errors: string[]
59+
warnings: string[]
60+
}> {
61+
try {
62+
const config = await this.parseFile(filePath)
63+
return this.validateBatchConfig(config)
64+
} catch (error) {
65+
return {
66+
valid: false,
67+
errors: [error instanceof Error ? error.message : String(error)],
68+
warnings: [],
69+
}
70+
}
71+
}
72+
73+
private validateBatchConfig(config: BatchConfig): {
74+
valid: boolean
75+
errors: string[]
76+
warnings: string[]
77+
} {
78+
const errors: string[] = []
79+
const warnings: string[] = []
80+
81+
// Validate commands
82+
if (!config.commands || config.commands.length === 0) {
83+
errors.push("At least one command is required")
84+
}
85+
86+
config.commands.forEach((cmd, index) => {
87+
if (!cmd.id) {
88+
errors.push(`Command at index ${index} is missing required 'id' field`)
89+
}
90+
if (!cmd.command) {
91+
errors.push(`Command '${cmd.id}' is missing required 'command' field`)
92+
}
93+
94+
// Validate dependencies
95+
if (cmd.dependsOn) {
96+
const invalidDeps = cmd.dependsOn.filter((dep) => !config.commands.some((c) => c.id === dep))
97+
if (invalidDeps.length > 0) {
98+
errors.push(`Command '${cmd.id}' has invalid dependencies: ${invalidDeps.join(", ")}`)
99+
}
100+
101+
// Check for circular dependencies
102+
if (this.hasCircularDependency(config.commands, cmd.id)) {
103+
errors.push(`Circular dependency detected for command '${cmd.id}'`)
104+
}
105+
}
106+
107+
// Validate timeout
108+
if (cmd.timeout && cmd.timeout <= 0) {
109+
warnings.push(`Command '${cmd.id}' has invalid timeout value: ${cmd.timeout}`)
110+
}
111+
112+
// Validate retries
113+
if (cmd.retries && cmd.retries < 0) {
114+
warnings.push(`Command '${cmd.id}' has invalid retries value: ${cmd.retries}`)
115+
}
116+
})
117+
118+
// Validate settings
119+
if (config.settings.maxConcurrency && config.settings.maxConcurrency <= 0) {
120+
errors.push("maxConcurrency must be greater than 0")
121+
}
122+
123+
return {
124+
valid: errors.length === 0,
125+
errors,
126+
warnings,
127+
}
128+
}
129+
130+
private hasCircularDependency(
131+
commands: BatchCommand[],
132+
commandId: string,
133+
visited: Set<string> = new Set(),
134+
): boolean {
135+
if (visited.has(commandId)) {
136+
return true
137+
}
138+
139+
const command = commands.find((c) => c.id === commandId)
140+
if (!command || !command.dependsOn) {
141+
return false
142+
}
143+
144+
visited.add(commandId)
145+
146+
for (const depId of command.dependsOn) {
147+
if (this.hasCircularDependency(commands, depId, new Set(visited))) {
148+
return true
149+
}
150+
}
151+
152+
return false
153+
}
154+
155+
getDefaultBatchConfig(): BatchConfig {
156+
return {
157+
commands: [],
158+
settings: {
159+
parallel: false,
160+
maxConcurrency: 1,
161+
continueOnError: false,
162+
verbose: false,
163+
dryRun: false,
164+
outputFormat: OutputFormat.TEXT,
165+
},
166+
defaults: {
167+
confirmations: false,
168+
fileOverwrite: false,
169+
createDirectories: true,
170+
timeout: 300000, // 5 minutes
171+
retryCount: 3,
172+
},
173+
errorHandling: ErrorHandlingStrategy.FAIL_FAST,
174+
}
175+
}
176+
177+
async generateSampleBatchFile(filePath: string, format: "json" | "yaml" | "text" = "json"): Promise<void> {
178+
const sampleConfig = this.createSampleConfig()
179+
180+
let content: string
181+
182+
const batchConfig: BatchConfig = {
183+
commands: sampleConfig.commands,
184+
settings: sampleConfig.settings,
185+
defaults: sampleConfig.defaults,
186+
errorHandling: ErrorHandlingStrategy.FAIL_FAST,
187+
}
188+
189+
switch (format) {
190+
case "json":
191+
content = JSON.stringify(sampleConfig, null, 2)
192+
break
193+
case "yaml":
194+
content = this.yamlParser.stringify(batchConfig)
195+
break
196+
case "text":
197+
content = this.textParser.stringify(batchConfig)
198+
break
199+
default:
200+
throw new Error(`Unsupported format: ${format}`)
201+
}
202+
203+
// Ensure directory exists
204+
const dir = path.dirname(filePath)
205+
await fs.mkdir(dir, { recursive: true })
206+
207+
// Write sample file
208+
await fs.writeFile(filePath, content, "utf-8")
209+
}
210+
211+
private createSampleConfig(): JSONBatchFile {
212+
return {
213+
version: "1.0",
214+
settings: {
215+
parallel: false,
216+
maxConcurrency: 3,
217+
continueOnError: false,
218+
verbose: true,
219+
dryRun: false,
220+
outputFormat: OutputFormat.JSON,
221+
},
222+
defaults: {
223+
confirmations: false,
224+
fileOverwrite: false,
225+
createDirectories: true,
226+
timeout: 300000,
227+
retryCount: 3,
228+
},
229+
commands: [
230+
{
231+
id: "setup",
232+
command: "echo",
233+
args: ["Setting up environment"],
234+
environment: {
235+
NODE_ENV: "development",
236+
},
237+
timeout: 30000,
238+
},
239+
{
240+
id: "install",
241+
command: "npm",
242+
args: ["install"],
243+
dependsOn: ["setup"],
244+
retries: 2,
245+
},
246+
{
247+
id: "test",
248+
command: "npm",
249+
args: ["test"],
250+
dependsOn: ["install"],
251+
condition: {
252+
type: "file_exists",
253+
value: "package.json",
254+
},
255+
},
256+
],
257+
}
258+
}
259+
}

0 commit comments

Comments
 (0)