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
//! The command surface (SPEC §8). Definitions only: every command's behaviour lives in
//! `commands/`, and the composition of the six crates lives in `app.rs`.
//!
//! Exit codes are the spec's: 0 success, 1 user error, 2 internal error, 3 policy refusal.
//! `cyberbrain-core`'s `Error::exit_code()` is the single mapping, shared with the HTTP API
//! (§8.1) so that one failure taxonomy serves both front ends.
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(
name = "cyberbrain",
version,
about = "Cited, trust-tiered, local-first memory for AI coding agents",
long_about = None,
)]
pub struct Cli {
/// Store to operate on. Defaults to `.cyberbrain` found from the working directory
/// upwards, so a command works anywhere inside a project.
#[arg(long, global = true, env = "CYBERBRAIN_STORE")]
pub store: Option<PathBuf>,
/// Machine-readable output. Every command that prints anything supports it.
#[arg(long, global = true)]
pub json: bool,
/// Print less. Errors still go to stderr.
#[arg(long, short, global = true)]
pub quiet: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Create a store and write a commented configuration file.
Init {
/// Where to create it. Defaults to `.cyberbrain` in the working directory.
#[arg(long)]
path: Option<PathBuf>,
},
/// Rebuild the index from the notes tree.
Scan {
/// Discard the index and rebuild everything. The audit log is untouched: it lives
/// in its own file precisely so that this is safe.
#[arg(long)]
full: bool,
#[arg(long)]
dry_run: bool,
},
/// Search the store. Hybrid by default; there is no lexical-only mode, because a
/// retrieval tool whose best mode is opt-in gets used in its worst mode.
Recall {
/// The query. Omit when using --id.
query: Option<String>,
/// Expand a citation to its full note instead of searching.
#[arg(long, value_name = "CITATION", conflicts_with = "query")]
id: Option<String>,
/// How many hits to return.
#[arg(long, short, default_value_t = 8)]
n: usize,
/// Restrict to exactly this ring.
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=4))]
ring: Option<u8>,
},
/// Exact line ranges for a symbol, so the agent reads a slice and not a file.
Find {
/// The symbol to locate: a function, type, class, constant or table name.
symbol: String,
/// Most hits to return before the result is reported as truncated.
#[arg(long, short, default_value_t = 20)]
limit: usize,
},
/// Write a note.
///
/// The body comes from stdin unless --body is given. Only the first paragraph is
/// shared with the MCP tool description (SPEC §9.2); anything after it is CLI-only,
/// which is what lets this sentence mention stdin without leaking into a protocol
/// where stdin means something else entirely.
Write {
/// Trust tier, 0 to 4. Lower is more trusted and wins a contradiction.
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=4))]
ring: u8,
/// What the note records. Drives filtering and the write template, not retrieval.
#[arg(long)]
kind: NoteKindArg,
/// kebab-case slug, unique in the store.
#[arg(long)]
name: String,
/// The note body as Markdown. Omit to read it from stdin.
#[arg(long)]
body: Option<String>,
/// A tag. Repeat the flag for several.
#[arg(long)]
tags: Vec<String>,
/// ISO-8601 duration, e.g. P2Y. Absent means keep indefinitely.
#[arg(long)]
retention: Option<String>,
/// Write despite PII findings, recording them as flagged rather than reviewed.
#[arg(long)]
force: bool,
/// Run the real path with no-op writers and report what would have happened.
#[arg(long)]
dry_run: bool,
},
/// Erase a note and everything derived from it (GDPR Art. 17, SPEC §12.2).
Forget {
/// Note name or id.
target: String,
#[arg(long)]
dry_run: bool,
},
/// Report dangling links, ring-cap pressure, a stale index and orphaned vectors.
Doctor,
/// Store health, embedding profile, inference endpoint, compliance profile.
Status,
/// Print a note.
Export {
target: String,
#[arg(long, value_enum, default_value_t = ExportFormat::Md)]
format: ExportFormat,
},
/// Bring an existing tree of Markdown notes into the store, driven by a mapping file.
///
/// Deliberately generic. It handles a tree because the tree is Markdown, not because it
/// knows what wrote it: every source-specific detail lives in the mapping file and none
/// of it in the code.
Import {
/// TOML mapping file: what to take, what to skip, how to split, which ring.
#[arg(long)]
plan: PathBuf,
/// Accept every PII finding in bulk. Holding several hundred imports one at a time
/// is unusable, and an unusable gate gets bypassed rather than obeyed.
#[arg(long)]
accept_pii: bool,
#[arg(long)]
dry_run: bool,
},
/// Serve the web UI and the HTTP API on loopback (SPEC §8.1, §13).
Serve {
#[arg(long, default_value_t = 7777)]
port: u16,
/// Do not open a browser.
#[arg(long)]
no_open: bool,
},
/// Agent harness integration. Reads the payload on stdin, answers on stdout.
///
/// A hook never fails the harness: any internal error is recorded and the process
/// still exits 0 with empty output (SPEC §9.1).
Hook {
#[arg(value_enum)]
event: HookEvent,
},
/// Serve the same operations over MCP on stdio.
Mcp,
/// Compliance operations (SPEC §12).
Policy {
#[command(subcommand)]
command: PolicyCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum PolicyCommand {
/// Every path by which bytes may leave this machine, and whether it is enabled.
Egress,
/// What the active profile claims about the law, with the basis and the confidence.
Obligations,
/// The audit log.
Audit {
#[arg(long, default_value_t = 50)]
limit: usize,
#[arg(long)]
action: Option<String>,
#[arg(long)]
subject: Option<String>,
/// Verify the hash chain and name the first altered row.
#[arg(long)]
verify: bool,
},
/// Everything stored about an identifier (GDPR Art. 15).
Subject { identifier: String },
/// Notes whose retention has expired.
Retention {
/// Erase what is due, through the same path as `forget`.
#[arg(long)]
apply: bool,
#[arg(long)]
dry_run: bool,
},
/// Identity, source, licence and hash of every model artefact in use.
ModelCard,
/// Record consent for the one model download, or withdraw it.
Consent {
#[arg(long)]
withdraw: bool,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum HookEvent {
SessionStart,
UserPromptSubmit,
PreToolUse,
PostToolUse,
Stop,
PreCompact,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ExportFormat {
Md,
Json,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum NoteKindArg {
Knowledge,
Bug,
Lesson,
Decision,
Reference,
Session,
}
impl From<NoteKindArg> for cyberbrain_core::NoteKind {
fn from(k: NoteKindArg) -> Self {
use cyberbrain_core::NoteKind as N;
match k {
NoteKindArg::Knowledge => N::Knowledge,
NoteKindArg::Bug => N::Bug,
NoteKindArg::Lesson => N::Lesson,
NoteKindArg::Decision => N::Decision,
NoteKindArg::Reference => N::Reference,
NoteKindArg::Session => N::Session,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn the_command_surface_is_well_formed() {
Cli::command().debug_assert();
}
/// SPEC §8 lists the commands; this pins the list so one cannot quietly disappear.
#[test]
fn every_command_in_the_spec_is_reachable() {
let cmd = Cli::command();
let names: Vec<&str> = cmd.get_subcommands().map(|s| s.get_name()).collect();
for expected in [
"init", "scan", "recall", "find", "write", "forget", "doctor", "status", "export",
"import", "serve", "hook", "mcp", "policy",
] {
assert!(
names.contains(&expected),
"{expected} is missing from the CLI"
);
}
}
/// The compliance surface is only worth something if it can be printed. The obligation
/// catalogue existed from the first profile and was reachable from nowhere for months.
#[test]
fn the_policy_surface_can_print_what_it_claims() {
let cmd = Cli::command();
let policy = cmd.find_subcommand("policy").unwrap();
let subs: Vec<&str> = policy.get_subcommands().map(|s| s.get_name()).collect();
for expected in [
"egress",
"obligations",
"audit",
"subject",
"retention",
"model-card",
] {
assert!(subs.contains(&expected), "policy {expected} is missing");
}
}
/// Every state-changing command takes --dry-run (SPEC §8).
#[test]
fn state_changing_commands_offer_dry_run() {
let cmd = Cli::command();
for name in ["scan", "write", "forget", "import"] {
let sub = cmd.find_subcommand(name).unwrap();
assert!(
sub.get_arguments().any(|a| a.get_long() == Some("dry-run")),
"{name} must offer --dry-run"
);
}
let policy = cmd.find_subcommand("policy").unwrap();
let retention = policy.find_subcommand("retention").unwrap();
assert!(
retention
.get_arguments()
.any(|a| a.get_long() == Some("dry-run"))
);
}
#[test]
fn all_six_hook_events_exist() {
assert_eq!(HookEvent::value_variants().len(), 6);
}
}