1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
// SPDX-License-Identifier: MIT OR Apache-2.0
//! CLI argument parser and subcommand dispatch definitions.
use std::path::PathBuf;
use anyhow::Result;
use clap::{Parser, Subcommand, ValueHint};
pub use crate::cli_args::*;
/// Build-time version line for clap (`env!` / `option_env!` — compile-time macros).
///
/// `ATOMWRITE_GIT_SHA` and `TARGET` are injected by `build.rs` via
/// `cargo:rustc-env`. Outside a git repository the source is `"no-git"`.
fn version_string() -> String {
format!(
"{} ({}) {}",
env!("CARGO_PKG_VERSION"),
env!("ATOMWRITE_GIT_SHA"),
env!("TARGET"),
)
}
#[derive(Parser, Debug)]
#[command(
name = "atomwrite",
version = version_string(),
author = env!("CARGO_PKG_AUTHORS"),
about = "Atomic file operations CLI for LLM agents",
long_about = "A single, self-contained Rust CLI that gives LLM agents superpowers \
for file operations. Every write is atomic (tempfile → fsync → rename), \
every output is NDJSON, every search is parallel.",
// Derived from the error enum, so `--help`, `agent-surface` and the README
// table list the same statuses.
after_help = crate::commands::agent_surface::exit_codes_help(),
propagate_version = true
)]
/// Top-level CLI definition parsed by clap.
///
/// # Examples
///
/// ```
/// use atomwrite::cli::{Cli, Commands};
/// use clap::Parser;
///
/// let cli = Cli::try_parse_from(["atomwrite", "--workspace", ".", "read", "--head", "5", "src/main.rs"])?;
/// assert!(matches!(cli.command, Commands::Read(_)));
/// assert_eq!(cli.global.timeout_secs, 120);
/// # Ok::<(), clap::Error>(())
/// ```
pub struct Cli {
/// Global flags shared across all subcommands.
#[command(flatten)]
pub global: GlobalArgs,
/// Subcommand to execute.
#[command(subcommand)]
pub command: Commands,
}
include!("cli_global.inc.rs");
include!("cli_examples.inc.rs");
impl Cli {
/// Parser with the parts that cannot be written as an attribute applied.
///
/// `config --help` publishes the key table, so it is generated from the
/// same `KEYS` the verb reads. The derive attribute can only hold a
/// `'static` string, and a hand-written copy of the table is what drifted
/// before (22 rows against 35). `main` builds the parser here instead of
/// calling `try_parse`, so the page printed from argv carries the table the
/// verb itself reads; `agent-surface` and `commands` publish the summary
/// only, and completions carry no prose at all.
#[must_use]
pub fn parser() -> clap::Command {
use clap::CommandFactory;
let keys = crate::commands::config_cmd::keys_help_block();
Cli::command().mut_subcommand("config", |sub| {
// The generated table plus the scopes block, in one blank line.
sub.after_long_help(format!("{keys}\n{}", EX_CONFIG_SCOPES))
})
}
}
impl GlobalArgs {
/// Return the workspace root as an absolute path, defaulting to the current directory.
///
/// # Examples
///
/// ```
/// use atomwrite::cli::Cli;
/// use clap::Parser;
///
/// let cli = Cli::try_parse_from(["atomwrite", "--workspace", "/srv/project", "doctor"])?;
/// assert_eq!(cli.global.resolve_workspace()?, std::path::PathBuf::from("/srv/project"));
/// # Ok::<(), anyhow::Error>(())
/// ```
///
/// # Errors
///
/// Returns an error if the current directory cannot be determined.
pub fn resolve_workspace(&self) -> Result<PathBuf> {
let base = match &self.workspace {
Some(p) => p.clone(),
// Classified as a typed error so an unreadable current directory
// keeps its semantic exit (13 or 74) instead of falling to 255.
None => std::env::current_dir().map_err(|e| {
crate::error::AtomwriteError::from_io_at(std::path::Path::new("."), e)
})?,
};
if base.is_relative() {
let cwd = std::env::current_dir().map_err(|e| {
crate::error::AtomwriteError::from_io_at(std::path::Path::new("."), e)
})?;
Ok(cwd.join(base))
} else {
Ok(base)
}
}
/// Return the maximum allowed file size, defaulting to 1 GiB.
pub fn effective_max_filesize(&self) -> u64 {
self.max_filesize
.unwrap_or(crate::constants::DEFAULT_MAX_FILESIZE)
}
/// Resolve stderr/tracing ANSI policy (G-007: CLI only, no process env).
pub fn color_mode(&self) -> crate::runtime::ColorMode {
if self.no_color {
return crate::runtime::ColorMode::Never;
}
match self.color {
ColorChoice::Auto => crate::runtime::ColorMode::Auto,
ColorChoice::Always => crate::runtime::ColorMode::Always,
ColorChoice::Never => crate::runtime::ColorMode::Never,
}
}
}
/// Terminal color output preference.
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum ColorChoice {
/// Detect color support automatically.
Auto,
/// Always emit colored output.
Always,
/// Never emit colored output.
Never,
}
/// Side-effect acknowledgement policy resolved from the global flags.
///
/// Commands that mutate declare which policy they require; a policy the
/// caller did not grant fails closed before any filesystem work.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SideEffectMode {
/// Command has no side effect gated by acknowledgement.
Unspecified,
/// Command mutates and requires `--confirm-side-effect`.
Acknowledged,
}
impl GlobalArgs {
/// Resolve the acknowledgement policy of this invocation.
///
/// The flag is only meaningful on a verb that changes bytes on disk. On any
/// other verb it is not a harmless no-op: it is a caller telling the CLI
/// they believe the run writes, and the CLI reporting success would confirm
/// a belief it never checked. The dispatch refuses it there instead.
pub fn side_effect_mode(&self) -> SideEffectMode {
if self.confirm_side_effect {
SideEffectMode::Acknowledged
} else {
SideEffectMode::Unspecified
}
}
/// Resolve whether the process is agent-hosted and must never prompt.
///
/// True when `--non-interactive` is set or stdin is not a terminal. A
/// one-shot CLI that can block on a terminal prompt is the failure this
/// covers, so a pipe or `/dev/null` counts as non-interactive.
pub fn non_interactive(&self, stdin_is_tty: bool) -> bool {
self.non_interactive || !stdin_is_tty
}
}
/// Available subcommands for the CLI.
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Read files with metadata, checksum, and optional content
#[command(after_long_help = EX_READ)]
Read(ReadArgs),
/// Create or overwrite files atomically via stdin
#[command(after_long_help = EX_WRITE)]
Write(WriteArgs),
/// Surgically edit files by line number, text marker, or exact match
#[command(after_long_help = EX_EDIT)]
Edit(EditArgs),
/// Search file contents in parallel (ripgrep engine)
#[command(after_long_help = EX_SEARCH)]
Search(SearchArgs),
/// Replace text across files in parallel with atomic writes
#[command(after_long_help = EX_REPLACE)]
Replace(ReplaceArgs),
/// Calculate BLAKE3 checksums for files, or for stdin when --stdin is given
#[command(after_long_help = EX_HASH)]
Hash(HashArgs),
/// Delete files with optional backup
#[command(after_long_help = EX_DELETE)]
Delete(DeleteArgs),
/// Count lines, matches, or files by extension
#[command(after_long_help = EX_COUNT)]
Count(CountArgs),
/// Compare two files or file vs stdin (unified diff)
#[command(after_long_help = EX_DIFF)]
Diff(DiffArgs),
/// Move or rename files/dirs atomically (same-FS dir rename; cross-device dirs: copy -r + delete)
#[command(after_long_help = EX_MOVE)]
Move(MoveArgs),
/// Copy files with checksum verification and atomic destination
#[command(after_long_help = EX_COPY)]
Copy(CopyArgs),
/// List project file structure with metadata (NDJSON per entry)
#[command(after_long_help = EX_LIST)]
List(ListArgs),
/// Extract fields from NDJSON stdin or text columns
#[command(after_long_help = EX_EXTRACT)]
Extract(ExtractArgs),
/// Evaluate math expressions and unit conversions (fend engine)
#[command(after_long_help = EX_CALC)]
Calc(CalcArgs),
/// Generate regex from examples (grex engine)
#[command(after_long_help = EX_REGEX)]
Regex(RegexArgs),
/// Structural code search and rewrite via AST patterns (ast-grep engine)
#[command(after_long_help = EX_TRANSFORM)]
Transform(TransformArgs),
/// Grammatical scoping: select AST categories and apply actions (delete, upper, lower, etc.)
#[command(after_long_help = EX_SCOPE)]
Scope(crate::commands::scope::ScopeArgs),
/// Execute multiple operations from an NDJSON manifest (batch mode)
#[command(after_long_help = EX_BATCH)]
Batch(BatchArgs),
/// Create timestamped backups of files with BLAKE3 checksums
#[command(after_long_help = EX_BACKUP)]
Backup(BackupArgs),
/// Restore a file from a previous backup
#[command(after_long_help = EX_ROLLBACK)]
Rollback(RollbackArgs),
/// Apply a patch (unified diff, SEARCH/REPLACE, or full file) from stdin
#[command(after_long_help = EX_APPLY)]
Apply(ApplyArgs),
/// Set a value in a TOML or JSON file by dotted key path
#[command(after_long_help = EX_SET)]
Set(crate::cli_args::SetArgs),
/// Read a value from a TOML or JSON file by dotted key path
#[command(after_long_help = EX_GET)]
Get(crate::cli_args::GetArgs),
/// Delete a key from a TOML or JSON file by dotted key path
#[command(after_long_help = EX_DEL)]
Del(crate::cli_args::DelArgs),
/// Convert identifier case in source files
#[command(after_long_help = EX_CASE)]
Case(crate::cli_args::CaseArgs),
/// Run a tree-sitter S-expression query against a file
#[command(after_long_help = EX_QUERY)]
Query(crate::cli_args::QueryArgs),
/// Extract the high-level structure (functions, classes, structs, enums) from a source file
#[command(after_long_help = EX_OUTLINE)]
Outline(crate::cli_args::OutlineArgs),
/// Count journal sidecars by terminal state, size, age and directory
#[command(after_long_help = EX_WAL_STATS)]
WalStats(crate::cli_args::WalStatsArgs),
/// Remove terminal journals older than the threshold
#[command(after_long_help = EX_WAL_HEAL)]
WalHeal(crate::cli_args::WalHealArgs),
/// Three-way line-based merge for multi-agent writes (not AST based)
#[command(after_long_help = EX_SEMANTIC_MERGE)]
SemanticMerge(crate::commands::semantic_merge::SemanticMergeArgs),
/// List, read and outline files under a hard file budget
#[command(after_long_help = EX_SPARSE)]
Sparse(crate::commands::sparse::SparseArgs),
/// List and run named multi-step recipes
#[command(after_long_help = EX_RECIPE)]
Recipe(crate::commands::recipe::RecipeArgs),
/// Metadata only, alias of `read --stat`
#[command(after_long_help = EX_STAT)]
Stat(crate::cli_args::ReadArgs),
/// Print the agent tool manifest as NDJSON
#[command(after_long_help = EX_AGENT_SURFACE)]
AgentSurface(crate::commands::agent_surface::AgentSurfaceArgs),
/// Watch filesystem events (needs a build with the `watch` feature)
#[command(after_long_help = EX_WATCH)]
Watch(crate::commands::watch::WatchArgs),
/// Extract a bounded signal from PDF, OOXML and image headers
///
/// Reads PDF, OOXML (document / spreadsheet / presentation) and image
/// headers, emitting a bounded signal and never raw artifact bytes.
#[command(after_long_help = EX_EXTRACT_ARTIFACT)]
ExtractArtifact(crate::commands::extract_artifact::ExtractArtifactArgs),
/// Run a multi-rule AST codemod campaign from a YAML rules file
#[command(after_long_help = EX_CODEMOD)]
Codemod(crate::commands::codemod::CodemodArgs),
/// Offline token-overlap (Jaccard) search
#[command(after_long_help = EX_SEMANTIC_SEARCH)]
SemanticSearch(crate::commands::semantic_search::SemanticSearchArgs),
/// Diagnose environment and dependencies (agent host readiness)
#[command(after_long_help = EX_DOCTOR)]
Doctor(crate::commands::doctor::DoctorArgs),
/// Create agent staging dir under workspace (never OS /tmp)
#[command(after_long_help = EX_SCRATCH)]
Scratch(crate::commands::scratch::ScratchArgs),
/// Show resolved UI locale / persist preference (XDG)
#[command(after_long_help = EX_LOCALE)]
Locale(crate::commands::locale_cmd::LocaleArgs),
/// Read or write persistent tuning keys (`config.toml`, `.atomwrite.toml`) without env vars
// `after_long_help` is filled by `Cli::parser` from the live key table:
// the attribute can only hold a `'static` string, and the copy in it had
// drifted from `KEYS` (22 rows against 35).
Config(crate::commands::config_cmd::ConfigArgs),
/// Emit full command tree as JSON for agent discovery
#[command(name = "commands", after_long_help = EX_COMMANDS)]
CommandsTree(crate::commands::command_tree::CommandsTreeArgs),
/// Generate shell completions for bash, zsh, fish, elvish, or powershell
#[command(after_long_help = EX_COMPLETIONS)]
Completions(CompletionsArgs),
/// Prune `.bak.YYYYMMDD_HHMMSS` backups by age or count
#[command(after_long_help = EX_PRUNE_BACKUPS)]
PruneBackups(PruneBackupsArgs),
/// Apply N `old`/`new` pairs from stdin (JSON array or NDJSON) in one write
#[command(after_long_help = EX_EDIT_LOOP)]
EditLoop(EditLoopArgs),
/// Verify file integrity by comparing BLAKE3 checksum
#[command(after_long_help = EX_VERIFY)]
Verify(crate::cli_args::VerifyArgs),
}