Skip to main content

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}