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
//! Command-line interface (clap derive).
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Parser)]
#[command(
name = "opys",
version,
about = "File-based inventory of typed markdown documents"
)]
pub struct Cli {
/// Where to start searching upward for `opys.toml` (the project root).
/// Defaults to the current directory.
#[arg(long, default_value = ".", global = true)]
pub root: String,
/// Skip the automatic sync (reconcile/linkify/relocate) after mutating commands.
#[arg(long, global = true)]
pub no_sync: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Clone, Copy, ValueEnum)]
pub enum ListFormat {
Table,
Ids,
Paths,
}
/// A rules-based editor that reads an always-on instruction file.
#[derive(Clone, Copy, ValueEnum)]
pub enum AgentTool {
Cursor,
Windsurf,
Cline,
Copilot,
Kiro,
/// Generate the rule file for every supported editor.
All,
}
#[derive(Subcommand)]
pub enum Command {
/// Bootstrap the inventory directory and config; print the CLAUDE.md snippet.
Init,
/// Create a document of a configured type with the next ID.
New {
/// Document type (configured in opys.toml; default `feature`).
#[arg(long = "type", default_value = "feature")]
type_name: String,
#[arg(long)]
title: String,
/// Comma-separated, kebab-case (required when the type requires tags).
#[arg(long, default_value = "")]
tags: String,
/// Defaults to the type's `default_status`.
#[arg(long, default_value = "")]
status: String,
/// Comma-separated IDs this document references (e.g. linked features).
#[arg(long, default_value = "")]
features: String,
/// Sets `<status>_reason` (e.g. wontfix/blocked/archived).
#[arg(long)]
reason: Option<String>,
/// Custom field key=value (repeatable).
#[arg(long = "field")]
field: Vec<String>,
},
/// Bulk-create documents of one type from a JSONL file (one JSON object per
/// line), allocating sequential IDs and syncing once. Run `verify` after.
Import {
/// Document type to create (configured in opys.toml; default `feature`).
#[arg(long = "type", default_value = "feature")]
type_name: String,
/// Path to a `.jsonl` file. Each line is an object with `title` and
/// `tags` (required), optional `status`/custom fields, and an optional
/// `body` (markdown placed under the title heading).
file: String,
},
/// Print a document.
Show {
id: String,
/// After the document, list textual references to its id found in code
/// (scans `[file_refs].roots`, matching the configured id formats).
#[arg(long)]
refs: bool,
},
/// Filtered listing.
List {
/// Restrict to one document type.
#[arg(long = "type")]
type_name: Option<String>,
/// Filter by tag. Matches an exact tag, or any tag with this key
/// (the head before `:` or `=`, e.g. `--tag area` matches
/// `area:parsing` and `area=high`).
#[arg(long)]
tag: Option<String>,
#[arg(long)]
status: Option<String>,
/// Filter by custom field: key=value (repeatable). Matches when the
/// field equals the value (or, for list fields, contains it).
#[arg(long = "field")]
field: Vec<String>,
#[arg(long, value_enum, default_value_t = ListFormat::Table)]
format: ListFormat,
},
/// List the distinct tags in the inventory, sorted, one per line.
Tags {
/// List distinct tag keys (the head before `:` / `=`) instead of full
/// tags — pairs with `list --tag <key>`.
#[arg(long)]
keys: bool,
},
/// Guarded status transition. The id may be a comma-separated list to move
/// several documents at once (same status/reason applied to each).
SetStatus {
/// One id, a comma-separated list (e.g. `FEAT-1,FEAT-2`), or `-` to read
/// the list from stdin (comma/space/newline-separated).
ids: String,
status: String,
/// Required when moving to wontfix.
#[arg(long)]
reason: Option<String>,
},
/// Add/remove tags on one or more documents.
Tag {
/// One id, a comma-separated list (e.g. `FEAT-1,FEAT-2`), or `-` to read
/// the list from stdin (comma/space/newline-separated).
ids: String,
#[arg(long)]
add: Option<String>,
#[arg(long)]
remove: Option<String>,
},
/// Delete one or more documents; each ID is logged and never reused.
Retire {
/// One id, a comma-separated list (e.g. `FEAT-1,FEAT-2`), or `-` to read
/// the list from stdin (comma/space/newline-separated).
ids: String,
#[arg(long)]
reason: String,
},
/// Mark one or more documents as blocked by another, linking both
/// directions. Each blocked document is auto-set to `blocked` if its type
/// has that status.
Block {
/// The blocked document's id, a comma-separated list, or `-` for stdin.
ids: String,
/// The blocking document's ID.
#[arg(long = "by")]
by: String,
},
/// Remove a blocker link added by `block` from one or more documents.
Unblock {
/// One id, a comma-separated list (e.g. `FEAT-1,FEAT-2`), or `-` to read
/// the list from stdin (comma/space/newline-separated).
ids: String,
#[arg(long = "by")]
by: String,
},
/// Integrity check (CI gate).
Verify,
/// Reconcile references, linkify prose, and relocate docs to their layout path (after hand edits).
Sync,
/// Render the configured `[[stats]]` sections. Styled for a terminal;
/// plain markdown when piped (or with `--plain` / `NO_COLOR`).
Stats {
/// Emit raw markdown instead of styled terminal output.
#[arg(long)]
plain: bool,
},
/// Run a SQL query over the inventory and print the result table. Read-only
/// (SELECT) by default; `--write` allows INSERT/UPDATE/DELETE. Styled for a
/// terminal; plain markdown when piped (or with `--plain` / `NO_COLOR`).
///
/// Tables: docs(id, num, type, status, title, created, updated, body,
/// path), tags(doc_id, seq, tag, key, value), relations(doc_id, field,
/// seq, ref_id, ref_num, raw_value, title, struck), fields(doc_id, key,
/// value), sections(doc_id, heading, kind, items, unchecked),
/// blocks(doc_id, seq, heading, text),
/// retired(id, num, title).
Query {
/// The SQL to run (`-` reads it from stdin). A SELECT by default;
/// INSERT/UPDATE/DELETE require `--write`.
sql: String,
/// Emit raw markdown instead of styled terminal output.
#[arg(long)]
plain: bool,
/// Bind a value read from stdin to `$1` in the SQL — for large or
/// multi-line values (e.g. a section body) without SQL-escaping;
/// trailing whitespace is trimmed. Incompatible with `sql = -`.
#[arg(long)]
stdin: bool,
/// Allow edit statements (INSERT/UPDATE/DELETE). Applied only if the
/// edit introduces no new `verify` problem — otherwise nothing is
/// written. Write the authoritative columns (`docs.status`/`body`/
/// `created`/`updated`, `tags.tag`, `relations.raw_value`,
/// `fm_fields.value_yaml`); derived ones are recomputed. Setting `blocks.text` splices a `##` section back into the body.
#[arg(long)]
write: bool,
},
/// Reconstruct a document's lifecycle from git history: the status timeline
/// across commits, following the file across relocations (e.g. into
/// `_archived/`). Reads the repository in-process and decodes each revision
/// through the document parser — no subprocess, no string scraping.
/// Requires the optional `history` build feature.
#[cfg(feature = "history")]
History { id: String },
/// Finish a document of a type with a terminal status: delete the file and
/// strike its title in every referencing doc (the struck reference reserves
/// the ID forever).
Close {
/// One id, a comma-separated list (e.g. `FEAT-1,FEAT-2`), or `-` to read
/// the list from stdin (comma/space/newline-separated).
ids: String,
/// Close even if a required checklist section has unchecked items.
#[arg(long)]
force: bool,
},
/// Resolve ID conflicts between branches: keep IDs that existed at the git
/// merge-base with main/master and renumber the ones added on this branch.
Renumber {
/// The git ref to treat as the base (defaults to merge-base with main/master).
#[arg(long)]
base: Option<String>,
},
/// Strip struck-through (closed) references from every document.
Cleanup,
/// Project configuration (generate/inspect the universal opys.toml).
#[command(subcommand)]
Config(ConfigCommand),
/// Launch the interactive terminal UI: a live board over the inventory that
/// updates as documents change on disk. (Requires a `--features tui` build.)
#[cfg(feature = "tui")]
Tui {
/// Project directory to open (where to search upward for opys.toml).
/// Overrides `--root`; defaults to the current directory.
dir: Option<String>,
},
/// Generate the always-on agent rule file for a rules-based editor
/// (Cursor/Windsurf/Cline/Copilot/Kiro) from the canonical rule.
AgentRules {
#[arg(long, value_enum)]
tool: AgentTool,
/// Print to stdout instead of writing the file(s).
#[arg(long)]
stdout: bool,
},
}
#[derive(Subcommand)]
pub enum ConfigCommand {
/// Generate the opinionated default opys.toml (never overwrites an existing one).
Init,
/// Parse opys.toml and check it is well-formed (exit 1 on problems).
Validate,
}