opys_engine/cli.rs
1//! Command-line interface (clap derive).
2
3use clap::{Parser, Subcommand, ValueEnum};
4
5#[derive(Parser)]
6#[command(
7 name = "opys",
8 version,
9 about = "File-based inventory of typed markdown documents"
10)]
11pub struct Cli {
12 /// Where to start searching upward for `opys.toml` (the project root).
13 /// Defaults to the current directory.
14 #[arg(long, default_value = ".", global = true)]
15 pub root: String,
16
17 /// Skip the automatic sync (reconcile/linkify/relocate) after mutating commands.
18 #[arg(long, global = true)]
19 pub no_sync: bool,
20
21 #[command(subcommand)]
22 pub command: Command,
23}
24
25#[derive(Clone, Copy, ValueEnum)]
26pub enum ListFormat {
27 Table,
28 Ids,
29 Paths,
30}
31
32/// A rules-based editor that reads an always-on instruction file.
33#[derive(Clone, Copy, ValueEnum)]
34pub enum AgentTool {
35 Cursor,
36 Windsurf,
37 Cline,
38 Copilot,
39 Kiro,
40 /// Generate the rule file for every supported editor.
41 All,
42}
43
44#[derive(Subcommand)]
45pub enum Command {
46 /// Bootstrap the inventory directory and config; print the CLAUDE.md snippet.
47 Init,
48
49 /// Create a document of a configured type with the next ID.
50 New {
51 /// Document type (configured in opys.toml; default `feature`).
52 #[arg(long = "type", default_value = "feature")]
53 type_name: String,
54 #[arg(long)]
55 title: String,
56 /// Comma-separated, kebab-case (required when the type requires tags).
57 #[arg(long, default_value = "")]
58 tags: String,
59 /// Defaults to the type's `default_status`.
60 #[arg(long, default_value = "")]
61 status: String,
62 /// Comma-separated IDs this document references (e.g. linked features).
63 #[arg(long, default_value = "")]
64 features: String,
65 /// Sets `<status>_reason` (e.g. wontfix/blocked/archived).
66 #[arg(long)]
67 reason: Option<String>,
68 /// Custom field key=value (repeatable).
69 #[arg(long = "field")]
70 field: Vec<String>,
71 },
72
73 /// Bulk-create documents of one type from a JSONL file (one JSON object per
74 /// line), allocating sequential IDs and syncing once. Run `verify` after.
75 Import {
76 /// Document type to create (configured in opys.toml; default `feature`).
77 #[arg(long = "type", default_value = "feature")]
78 type_name: String,
79 /// Path to a `.jsonl` file. Each line is an object with `title` and
80 /// `tags` (required), optional `status`/custom fields, and an optional
81 /// `body` (markdown placed under the title heading).
82 file: String,
83 },
84
85 /// Print a document.
86 Show {
87 id: String,
88 /// After the document, list textual references to its id found in code
89 /// (scans `[file_refs].roots`, matching the configured id formats).
90 #[arg(long)]
91 refs: bool,
92 },
93
94 /// Filtered listing.
95 List {
96 /// Restrict to one document type.
97 #[arg(long = "type")]
98 type_name: Option<String>,
99 /// Filter by tag. Matches an exact tag, or any tag with this key
100 /// (the head before `:` or `=`, e.g. `--tag area` matches
101 /// `area:parsing` and `area=high`).
102 #[arg(long)]
103 tag: Option<String>,
104 #[arg(long)]
105 status: Option<String>,
106 /// Filter by custom field: key=value (repeatable). Matches when the
107 /// field equals the value (or, for list fields, contains it).
108 #[arg(long = "field")]
109 field: Vec<String>,
110 #[arg(long, value_enum, default_value_t = ListFormat::Table)]
111 format: ListFormat,
112 },
113
114 /// List the distinct tags in the inventory, sorted, one per line.
115 Tags {
116 /// List distinct tag keys (the head before `:` / `=`) instead of full
117 /// tags — pairs with `list --tag <key>`.
118 #[arg(long)]
119 keys: bool,
120 },
121
122 /// Guarded status transition. The id may be a comma-separated list to move
123 /// several documents at once (same status/reason applied to each).
124 SetStatus {
125 /// One id, a comma-separated list (e.g. `FEAT-1,FEAT-2`), or `-` to read
126 /// the list from stdin (comma/space/newline-separated).
127 ids: String,
128 status: String,
129 /// Required when moving to wontfix.
130 #[arg(long)]
131 reason: Option<String>,
132 },
133
134 /// Add/remove tags on one or more documents.
135 Tag {
136 /// One id, a comma-separated list (e.g. `FEAT-1,FEAT-2`), or `-` to read
137 /// the list from stdin (comma/space/newline-separated).
138 ids: String,
139 #[arg(long)]
140 add: Option<String>,
141 #[arg(long)]
142 remove: Option<String>,
143 },
144
145 /// Delete one or more documents; each ID is logged and never reused.
146 Retire {
147 /// One id, a comma-separated list (e.g. `FEAT-1,FEAT-2`), or `-` to read
148 /// the list from stdin (comma/space/newline-separated).
149 ids: String,
150 #[arg(long)]
151 reason: String,
152 },
153
154 /// Mark one or more documents as blocked by another, linking both
155 /// directions. Each blocked document is auto-set to `blocked` if its type
156 /// has that status.
157 Block {
158 /// The blocked document's id, a comma-separated list, or `-` for stdin.
159 ids: String,
160 /// The blocking document's ID.
161 #[arg(long = "by")]
162 by: String,
163 },
164
165 /// Remove a blocker link added by `block` from one or more documents.
166 Unblock {
167 /// One id, a comma-separated list (e.g. `FEAT-1,FEAT-2`), or `-` to read
168 /// the list from stdin (comma/space/newline-separated).
169 ids: String,
170 #[arg(long = "by")]
171 by: String,
172 },
173
174 /// Integrity check (CI gate).
175 Verify,
176
177 /// Reconcile references, linkify prose, and relocate docs to their layout path (after hand edits).
178 Sync,
179
180 /// Render the configured `[[stats]]` sections. Styled for a terminal;
181 /// plain markdown when piped (or with `--plain` / `NO_COLOR`).
182 Stats {
183 /// Emit raw markdown instead of styled terminal output.
184 #[arg(long)]
185 plain: bool,
186 },
187
188 /// Run a SQL query over the inventory and print the result table. Read-only
189 /// (SELECT) by default; `--write` allows INSERT/UPDATE/DELETE. Styled for a
190 /// terminal; plain markdown when piped (or with `--plain` / `NO_COLOR`).
191 ///
192 /// Tables: docs(id, num, type, status, title, created, updated, body,
193 /// path), tags(doc_id, seq, tag, key, value), relations(doc_id, field,
194 /// seq, ref_id, ref_num, raw_value, title, struck), fields(doc_id, key,
195 /// value), sections(doc_id, heading, kind, items, unchecked),
196 /// blocks(doc_id, seq, heading, text),
197 /// retired(id, num, title).
198 Query {
199 /// The SQL to run (`-` reads it from stdin). A SELECT by default;
200 /// INSERT/UPDATE/DELETE require `--write`.
201 sql: String,
202 /// Emit raw markdown instead of styled terminal output.
203 #[arg(long)]
204 plain: bool,
205 /// Bind a value read from stdin to `$1` in the SQL — for large or
206 /// multi-line values (e.g. a section body) without SQL-escaping;
207 /// trailing whitespace is trimmed. Incompatible with `sql = -`.
208 #[arg(long)]
209 stdin: bool,
210 /// Allow edit statements (INSERT/UPDATE/DELETE). Applied only if the
211 /// edit introduces no new `verify` problem — otherwise nothing is
212 /// written. Write the authoritative columns (`docs.status`/`body`/
213 /// `created`/`updated`, `tags.tag`, `relations.raw_value`,
214 /// `fm_fields.value_yaml`); derived ones are recomputed. Setting `blocks.text` splices a `##` section back into the body.
215 #[arg(long)]
216 write: bool,
217 },
218
219 /// Reconstruct a document's lifecycle from git history: the status timeline
220 /// across commits, following the file across relocations (e.g. into
221 /// `_archived/`). Reads the repository in-process and decodes each revision
222 /// through the document parser — no subprocess, no string scraping.
223 /// Requires the optional `history` build feature.
224 #[cfg(feature = "history")]
225 History { id: String },
226
227 /// Finish a document of a type with a terminal status: delete the file and
228 /// strike its title in every referencing doc (the struck reference reserves
229 /// the ID forever).
230 Close {
231 /// One id, a comma-separated list (e.g. `FEAT-1,FEAT-2`), or `-` to read
232 /// the list from stdin (comma/space/newline-separated).
233 ids: String,
234 /// Close even if a required checklist section has unchecked items.
235 #[arg(long)]
236 force: bool,
237 },
238
239 /// Resolve ID conflicts between branches: keep IDs that existed at the git
240 /// merge-base with main/master and renumber the ones added on this branch.
241 Renumber {
242 /// The git ref to treat as the base (defaults to merge-base with main/master).
243 #[arg(long)]
244 base: Option<String>,
245 },
246
247 /// Strip struck-through (closed) references from every document.
248 Cleanup,
249
250 /// Project configuration (generate/inspect the universal opys.toml).
251 #[command(subcommand)]
252 Config(ConfigCommand),
253
254 /// Launch the interactive terminal UI: a live board over the inventory that
255 /// updates as documents change on disk. (Requires a `--features tui` build.)
256 #[cfg(feature = "tui")]
257 Tui {
258 /// Project directory to open (where to search upward for opys.toml).
259 /// Overrides `--root`; defaults to the current directory.
260 dir: Option<String>,
261 },
262
263 /// Generate the always-on agent rule file for a rules-based editor
264 /// (Cursor/Windsurf/Cline/Copilot/Kiro) from the canonical rule.
265 AgentRules {
266 #[arg(long, value_enum)]
267 tool: AgentTool,
268 /// Print to stdout instead of writing the file(s).
269 #[arg(long)]
270 stdout: bool,
271 },
272}
273
274#[derive(Subcommand)]
275pub enum ConfigCommand {
276 /// Generate the opinionated default opys.toml (never overwrites an existing one).
277 Init,
278 /// Parse opys.toml and check it is well-formed (exit 1 on problems).
279 Validate,
280}