Make ork a pure 9P client with friendly commands

ork now operates solely as a 9P client against ork-server; all org
intelligence lives in the server. Remove the local file-parsing command
layer (commands.rs) and its org-ast/org-parser/orkmode-core/serde deps.

Base verbs speak raw 9P: ls, read, write, rdwr. Address resolves from
--addr, then $ORK_ADDR, then tcp://localhost:5640.

Add friendly commands, all thin compositions of the base 9P operations:
docs, sections, todos, agenda, get, set, add (--parent), mkdoc, reload.

Fix the directory-listing parser in client.rs: the stat-entry name offset
double-counted the size field (pos + 2 + 41 instead of pos + 41), so ls
returned nothing. Handle a downstream reader closing early (e.g. | head)
by treating BrokenPipe as a clean exit instead of panicking.
This commit is contained in:
Levi Neely 2026-09-30 21:29:09 +02:00
parent 623aef4f42
commit 32e4fd0007
5 changed files with 332 additions and 1422 deletions

6
Cargo.lock generated
View File

@ -550,12 +550,6 @@ name = "ork-cli"
version = "0.1.0"
dependencies = [
"clap",
"org-ast",
"org-parser",
"orkmode-core",
"serde",
"serde_json",
"thiserror",
]
[[package]]

View File

@ -11,10 +11,4 @@ name = "ork"
path = "src/main.rs"
[dependencies]
org-ast = { path = "../org-ast" }
org-parser = { path = "../org-parser" }
orkmode-core = { path = "../orkmode-core" }
clap = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@ -233,12 +233,6 @@ impl Client {
Ok(result)
}
/// Read file as string.
pub fn read_string(&mut self, path: &str) -> io::Result<String> {
let data = self.read(path)?;
Ok(String::from_utf8_lossy(&data).trim().to_string())
}
/// Write to a file.
pub fn write(&mut self, path: &str, data: &[u8]) -> io::Result<()> {
let fid = self.walk(path)?;
@ -370,8 +364,9 @@ impl Client {
break;
}
// Skip to name field: size(2) + type(2) + dev(4) + qid(13) + mode(4) + atime(4) + mtime(4) + length(8) = 41
let name_offset = pos + 2 + 41;
// Stat layout from entry start: size(2) + type(2) + dev(4) + qid(13)
// + mode(4) + atime(4) + mtime(4) + length(8) = 41 bytes, then name[s].
let name_offset = pos + 41;
if name_offset + 2 > data.len() {
break;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,353 +1,363 @@
//! ork - CLI for org-mode file manipulation
//! ork - 9P client for ork-server.
//!
//! Operates on org directories or individual files.
//! Provides commands for:
//! - Querying org files (list todos, sections, tags)
//! - Outputting as JSON for programmatic use
//! - Modifying org files (add/update sections, toggle todos)
//! `ork` is only a 9P client: it connects to a running `ork-server` and
//! operates on its namespace. All org-mode intelligence lives in the server.
//! The base verbs (ls/read/write/rdwr) speak raw 9P; the friendly verbs
//! (docs/sections/todos/agenda/get/set/add/mkdoc/reload) are thin compositions
//! of those same 9P operations against the exposed namespace.
//!
//! Address resolution: `--addr` flag, then `$ORK_ADDR`, then
//! `tcp://localhost:5640`. Accepts `tcp://host:port` or `unix:///path/to/sock`.
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use std::io::{self, Read, Write};
use std::process;
mod commands;
mod client;
use client::Client;
#[derive(Parser)]
#[command(name = "ork")]
#[command(author, version, about = "CLI for org-mode file manipulation")]
#[command(author, version, about = "9P client for ork-server")]
#[command(propagate_version = true)]
#[command(after_help = "PATH can be a directory (operates on all .org files) or a single .org file.")]
#[command(after_help = "\
PATH is a path in the ork-server 9P namespace (e.g. /idx, /test/task/keyword).
Address resolution order: --addr, then $ORK_ADDR, then tcp://localhost:5640.
Accepts tcp://host:port or unix:///path/to/socket.")]
struct Cli {
/// Server address (default: $ORK_ADDR or tcp://localhost:5640)
#[arg(long, short, global = true)]
addr: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Parse and display document information
Info {
/// Path to org directory or file
#[arg(default_value = ".")]
path: PathBuf,
/// Output as JSON
#[arg(long, short)]
json: bool,
/// Recurse into subdirectories
#[arg(long, short = 'R')]
recursive: bool,
/// List a directory in the namespace
Ls {
/// Namespace path (default: /)
#[arg(default_value = "/")]
path: String,
},
/// List sections/headlines
/// Read a file from the namespace to stdout
Read {
/// Namespace path
path: String,
},
/// Write to a file (DATA argument, or stdin if omitted)
Write {
/// Namespace path
path: String,
/// Data to write; if omitted, read from stdin
data: Option<String>,
},
/// Write then read a request/response file (e.g. ctl, new)
Rdwr {
/// Namespace path
path: String,
/// Data to write; if omitted, read from stdin
data: Option<String>,
},
/// List documents on the server
Docs,
/// List top-level sections of a document
Sections {
/// Path to org directory or file
#[arg(default_value = ".")]
path: PathBuf,
/// Output as JSON
#[arg(long, short)]
json: bool,
/// Filter by tag
#[arg(long, short)]
tag: Option<String>,
/// Filter by TODO state (TODO, DONE, or custom)
#[arg(long)]
todo: Option<String>,
/// Maximum depth to display (default: all)
#[arg(long, short)]
depth: Option<u8>,
/// Only show TODO items
#[arg(long)]
todos_only: bool,
/// Recurse into subdirectories
#[arg(long, short = 'R')]
recursive: bool,
/// Document name (as shown by `docs`)
doc: String,
},
/// List all tags across files
Tags {
/// Path to org directory or file
#[arg(default_value = ".")]
path: PathBuf,
/// Output as JSON
#[arg(long, short)]
json: bool,
/// Recurse into subdirectories
#[arg(long, short = 'R')]
recursive: bool,
/// List TODO items across all documents
Todos {
/// Only show TODOs from this document
doc: Option<String>,
},
/// Query sections by various criteria
Query {
/// Query expression (tag:work, todo:TODO, priority:A, file:pattern, etc.)
query: String,
/// Path to org directory or file
#[arg(default_value = ".")]
path: PathBuf,
/// Output as JSON
#[arg(long, short)]
json: bool,
/// Recurse into subdirectories
#[arg(long, short = 'R')]
recursive: bool,
},
/// Get a specific section by ID
Get {
/// Section identifier (CUSTOM_ID, ID, or headline text)
id: String,
/// Path to org directory or file
#[arg(default_value = ".")]
path: PathBuf,
/// Output as JSON
#[arg(long, short)]
json: bool,
/// Include body content
#[arg(long, short)]
body: bool,
/// Recurse into subdirectories
#[arg(long, short = 'R')]
recursive: bool,
},
/// Export as JSON
Export {
/// Path to org directory or file
#[arg(default_value = ".")]
path: PathBuf,
/// Section ID to export (default: all)
#[arg(long)]
section: Option<String>,
/// Pretty print JSON
#[arg(long, short)]
pretty: bool,
/// Recurse into subdirectories
#[arg(long, short = 'R')]
recursive: bool,
},
/// Toggle TODO state of a section
Toggle {
/// Section identifier (searches all files)
id: String,
/// Path to org directory or file
#[arg(default_value = ".")]
path: PathBuf,
/// Dry run - show what would change
#[arg(long, short = 'n')]
dry_run: bool,
/// Recurse into subdirectories
#[arg(long, short = 'R')]
recursive: bool,
},
/// Add a new section
Add {
/// Headline title
title: String,
/// Target file (required for directory paths)
#[arg(long, short)]
file: Option<PathBuf>,
/// Path to org directory or file
#[arg(default_value = ".")]
path: PathBuf,
/// Headline level (1-6)
#[arg(long, short, default_value = "1")]
level: u8,
/// TODO keyword
#[arg(long)]
todo: Option<String>,
/// Priority (A, B, C)
#[arg(long)]
priority: Option<char>,
/// Tags (comma-separated)
#[arg(long, short)]
tags: Option<String>,
/// Body content
#[arg(long, short)]
body: Option<String>,
/// Insert after section with this ID
#[arg(long)]
after: Option<String>,
/// Insert as child of section with this ID
#[arg(long)]
parent: Option<String>,
/// Dry run - show what would be added
#[arg(long, short = 'n')]
dry_run: bool,
},
/// Set or update a property
Property {
/// Section identifier
id: String,
/// Property name
name: String,
/// Property value (omit to delete)
value: Option<String>,
/// Path to org directory or file
#[arg(default_value = ".")]
path: PathBuf,
/// Dry run
#[arg(long, short = 'n')]
dry_run: bool,
/// Recurse into subdirectories
#[arg(long, short = 'R')]
recursive: bool,
},
/// Set or add a tag
Tag {
/// Section identifier
id: String,
/// Tag to add/remove
tag: String,
/// Path to org directory or file
#[arg(default_value = ".")]
path: PathBuf,
/// Remove the tag instead of adding
#[arg(long, short)]
remove: bool,
/// Dry run
#[arg(long, short = 'n')]
dry_run: bool,
/// Recurse into subdirectories
#[arg(long, short = 'R')]
recursive: bool,
},
/// Validate org file syntax
Check {
/// Path to org directory or file
#[arg(default_value = ".")]
path: PathBuf,
/// Output as JSON
#[arg(long, short)]
json: bool,
/// Recurse into subdirectories
#[arg(long, short = 'R')]
recursive: bool,
},
/// Output agenda-relevant items
/// Show an agenda view
Agenda {
/// Path to org directory or file
#[arg(default_value = ".")]
path: PathBuf,
/// Number of days to show (default: 7)
#[arg(long, short, default_value = "7")]
days: u32,
/// Output as JSON
#[arg(long, short)]
json: bool,
/// Include done items
#[arg(long)]
include_done: bool,
/// Recurse into subdirectories
#[arg(long, short = 'R')]
recursive: bool,
/// View: today, week, or todos (default: todos)
#[arg(default_value = "todos")]
view: String,
},
/// List org files in directory
Files {
/// Path to org directory
#[arg(default_value = ".")]
path: PathBuf,
/// Output as JSON
#[arg(long, short)]
json: bool,
/// Recurse into subdirectories
#[arg(long, short = 'R')]
recursive: bool,
/// Show a section's fields
Get {
/// Document name
doc: String,
/// Section id (slug or UUID, as shown by `sections`)
id: String,
},
/// Set a field on a section
Set {
/// Document name
doc: String,
/// Section id
id: String,
/// Field: title, keyword, priority, tags, scheduled, deadline, closed, properties, body
field: String,
/// New value
value: String,
},
/// Add a new section to a document
Add {
/// Document name
doc: String,
/// Headline text, e.g. "* TODO Write docs :work:"
headline: String,
/// Add as a child of this section id instead of top-level
#[arg(long, short)]
parent: Option<String>,
},
/// Create a new document
Mkdoc {
/// Document name (without .org)
name: String,
},
/// Ask the server to rescan org files from disk
Reload,
}
/// Section fields that may be written via `set`.
const SETTABLE_FIELDS: &[&str] = &[
"title", "keyword", "priority", "tags", "scheduled", "deadline", "closed",
"properties", "body",
];
/// Fields shown by `get`, in display order (read-only "headline" first).
const GET_FIELDS: &[&str] = &[
"headline", "keyword", "priority", "tags", "scheduled", "deadline", "closed",
"properties", "body",
];
/// Entries under a document dir that are not sections.
const DOC_FILES: &[&str] = &["raw", "body", "meta", "new"];
/// Resolve the server address: explicit --addr, then $ORK_ADDR, then default.
fn resolve_addr(addr: Option<&str>) -> String {
addr.map(str::to_string)
.or_else(|| std::env::var("ORK_ADDR").ok())
.unwrap_or_else(|| "tcp://localhost:5640".to_string())
}
/// Read stdin to end (used when a write/rdwr DATA argument is omitted).
fn read_stdin() -> io::Result<Vec<u8>> {
let mut buf = Vec::new();
io::stdin().read_to_end(&mut buf)?;
Ok(buf)
}
/// Read a namespace file and return its contents as a trimmed string.
fn read_str(client: &mut Client, path: &str) -> io::Result<String> {
let data = client.read(path)?;
Ok(String::from_utf8_lossy(&data).trim_end().to_string())
}
// ── Friendly commands (pure 9P compositions) ───────────────────────────────
/// `docs` — list documents from /idx as "name title".
fn cmd_docs(client: &mut Client) -> io::Result<()> {
let idx = read_str(client, "/idx")?;
let mut out = io::stdout().lock();
for line in idx.lines().filter(|l| !l.is_empty()) {
// idx format: name\tpath\ttitle
let mut cols = line.splitn(3, '\t');
let name = cols.next().unwrap_or("");
let _path = cols.next().unwrap_or("");
let title = cols.next().unwrap_or("");
writeln!(out, "{:<20} {}", name, title)?;
}
Ok(())
}
/// `sections <doc>` — list top-level sections with their headlines.
fn cmd_sections(client: &mut Client, doc: &str) -> io::Result<()> {
let entries = client.list(&format!("/{}", doc))?;
let ids: Vec<String> = entries
.into_iter()
.filter(|e| !DOC_FILES.contains(&e.as_str()))
.collect();
let mut out = io::stdout().lock();
if ids.is_empty() {
writeln!(out, "(no sections)")?;
return Ok(());
}
for id in ids {
let headline = read_str(client, &format!("/{}/{}/headline", doc, id))
.unwrap_or_default();
writeln!(out, "{:<28} {}", id, headline)?;
}
Ok(())
}
/// `todos [doc]` — read /agenda/todos and optionally filter by document.
fn cmd_todos(client: &mut Client, doc: Option<&str>) -> io::Result<()> {
let todos = read_str(client, "/agenda/todos")?;
let mut out = io::stdout().lock();
for line in todos.lines().filter(|l| !l.is_empty()) {
// agenda format: id\tdoc\ttitle\tkeyword
let cols: Vec<&str> = line.splitn(4, '\t').collect();
if cols.len() < 4 {
continue;
}
let (id, ldoc, title, kw) = (cols[0], cols[1], cols[2], cols[3]);
if let Some(want) = doc {
if ldoc != want {
continue;
}
}
writeln!(out, "{:<10} {:<12} {:<28} {}", kw, ldoc, id, title)?;
}
Ok(())
}
/// `agenda [view]` — read /agenda/<view> verbatim.
fn cmd_agenda(client: &mut Client, view: &str) -> io::Result<()> {
match view {
"today" | "week" | "todos" => {}
other => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown agenda view '{}' (use today, week, or todos)", other),
));
}
}
let data = read_str(client, &format!("/agenda/{}", view))?;
let mut out = io::stdout().lock();
if data.is_empty() {
writeln!(out, "(empty)")?;
} else {
writeln!(out, "{}", data)?;
}
Ok(())
}
/// `get <doc> <id>` — print a section's non-empty fields.
fn cmd_get(client: &mut Client, doc: &str, id: &str) -> io::Result<()> {
// Confirm the section exists by walking its headline first.
let base = format!("/{}/{}", doc, id);
let headline = read_str(client, &format!("{}/headline", base))
.map_err(|_| io::Error::new(io::ErrorKind::NotFound,
format!("section '{}' not found in '{}'", id, doc)))?;
let mut out = io::stdout().lock();
writeln!(out, "{}", headline)?;
for field in GET_FIELDS {
if *field == "headline" {
continue;
}
let val = read_str(client, &format!("{}/{}", base, field)).unwrap_or_default();
if !val.is_empty() {
writeln!(out, " {:<11} {}", format!("{}:", field), val)?;
}
}
Ok(())
}
/// `set <doc> <id> <field> <value>` — write one section field.
fn cmd_set(client: &mut Client, doc: &str, id: &str, field: &str, value: &str) -> io::Result<()> {
if !SETTABLE_FIELDS.contains(&field) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("cannot set '{}'; settable fields: {}", field, SETTABLE_FIELDS.join(", ")),
));
}
client.write(&format!("/{}/{}/{}", doc, id, field), value.as_bytes())?;
Ok(())
}
/// `add <doc> <headline> [--parent id]` — create a section, print its UUID.
fn cmd_add(client: &mut Client, doc: &str, headline: &str, parent: Option<&str>) -> io::Result<()> {
let path = match parent {
Some(pid) => format!("/{}/{}/new", doc, pid),
None => format!("/{}/new", doc),
};
let resp = client.rdwr(&path, headline.as_bytes())?;
io::stdout().write_all(&resp)?;
Ok(())
}
/// `mkdoc <name>` — create a new document, print its filename.
fn cmd_mkdoc(client: &mut Client, name: &str) -> io::Result<()> {
let resp = client.rdwr("/new", name.as_bytes())?;
io::stdout().write_all(&resp)?;
Ok(())
}
/// `reload` — ask the server to rescan.
fn cmd_reload(client: &mut Client) -> io::Result<()> {
let resp = client.rdwr("/ctl", b"reload")?;
io::stdout().write_all(&resp)?;
Ok(())
}
fn run(cli: Cli) -> io::Result<()> {
let addr = resolve_addr(cli.addr.as_deref());
let mut client = Client::connect(&addr)
.map_err(|e| io::Error::new(e.kind(), format!("connect {}: {}", addr, e)))?;
match cli.command {
Commands::Ls { path } => {
let mut out = io::stdout().lock();
for name in client.list(&path)? {
writeln!(out, "{}", name)?;
}
}
Commands::Read { path } => {
let data = client.read(&path)?;
io::stdout().write_all(&data)?;
}
Commands::Write { path, data } => {
let bytes = match data {
Some(s) => s.into_bytes(),
None => read_stdin()?,
};
client.write(&path, &bytes)?;
}
Commands::Rdwr { path, data } => {
let bytes = match data {
Some(s) => s.into_bytes(),
None => read_stdin()?,
};
let resp = client.rdwr(&path, &bytes)?;
io::stdout().write_all(&resp)?;
}
Commands::Docs => cmd_docs(&mut client)?,
Commands::Sections { doc } => cmd_sections(&mut client, &doc)?,
Commands::Todos { doc } => cmd_todos(&mut client, doc.as_deref())?,
Commands::Agenda { view } => cmd_agenda(&mut client, &view)?,
Commands::Get { doc, id } => cmd_get(&mut client, &doc, &id)?,
Commands::Set { doc, id, field, value } => {
cmd_set(&mut client, &doc, &id, &field, &value)?
}
Commands::Add { doc, headline, parent } => {
cmd_add(&mut client, &doc, &headline, parent.as_deref())?
}
Commands::Mkdoc { name } => cmd_mkdoc(&mut client, &name)?,
Commands::Reload => cmd_reload(&mut client)?,
}
Ok(())
}
fn main() {
let cli = Cli::parse();
let result = match cli.command {
Commands::Info { path, json, recursive } => {
commands::info(&path, json, recursive)
if let Err(e) = run(cli) {
// A downstream reader closing early (e.g. `| head`) is not an error.
if e.kind() == io::ErrorKind::BrokenPipe {
process::exit(0);
}
Commands::Sections {
path,
json,
tag,
todo,
depth,
todos_only,
recursive,
} => commands::sections(&path, json, tag, todo, depth, todos_only, recursive),
Commands::Tags { path, json, recursive } => {
commands::tags(&path, json, recursive)
}
Commands::Query { query, path, json, recursive } => {
commands::query(&path, &query, json, recursive)
}
Commands::Get { id, path, json, body, recursive } => {
commands::get(&path, &id, json, body, recursive)
}
Commands::Export { path, section, pretty, recursive } => {
commands::export(&path, section.as_deref(), pretty, recursive)
}
Commands::Toggle { id, path, dry_run, recursive } => {
commands::toggle(&path, &id, dry_run, recursive)
}
Commands::Add {
title,
file,
path,
level,
todo,
priority,
tags,
body,
after,
parent,
dry_run,
} => commands::add(
&path,
file.as_deref(),
level,
&title,
todo.as_deref(),
priority,
tags.as_deref(),
body.as_deref(),
after.as_deref(),
parent.as_deref(),
dry_run,
),
Commands::Property {
id,
name,
value,
path,
dry_run,
recursive,
} => commands::property(&path, &id, &name, value.as_deref(), dry_run, recursive),
Commands::Tag {
id,
tag,
path,
remove,
dry_run,
recursive,
} => commands::tag(&path, &id, &tag, remove, dry_run, recursive),
Commands::Check { path, json, recursive } => {
commands::check(&path, json, recursive)
}
Commands::Agenda {
path,
days,
json,
include_done,
recursive,
} => commands::agenda(&path, days, json, include_done, recursive),
Commands::Files { path, json, recursive } => {
commands::files(&path, json, recursive)
}
};
if let Err(e) = result {
eprintln!("error: {}", e);
std::process::exit(1);
process::exit(1);
}
}