//! `heph` — the CLI surface (tech-spec §1). A thin client of the local //! `hephd`: it never touches SQLite, only the daemon socket. //! //! Per the three-surface model ([[design]] §4), the CLI is the **task //! capture/scripting** surface and exposes the **complete daemon API** — every //! RPC method has a command. Structured task fields are flags, with human dates //! (`--do tomorrow`) and recurrence (`--recur weekly`) parsed in [`datespec`]. use std::path::PathBuf; use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use heph_core::{Node, RankedTask, Task}; use hephd::{datespec, default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore}; mod service; #[derive(Parser, Debug)] #[command(name = "heph", version = heph_core::VERSION, about)] struct Cli { /// Path to the hephd unix socket (defaults to the standard runtime path). #[arg(long, global = true)] socket: Option, #[command(subcommand)] command: Command, } #[derive(Subcommand, Debug)] enum Command { /// Show the Tactical "what is next?" ranking. Next { /// Restrict to a project node id. #[arg(long)] scope: Option, /// Maximum rows (red items always show). #[arg(long, default_value_t = 5)] limit: usize, }, /// Create a committed task (auto-creates its canonical context doc). Task { /// The task title. title: String, /// Attention-state: white|orange|red|blue. #[arg(short = 'a', long)] attention: Option, /// Do-date (earliest-actionable): today|tomorrow|+3d|fri|YYYY-MM-DD. #[arg(long)] do_date: Option, /// Late-on (the sole urgency marker): same date forms as --do-date. #[arg(long)] late_on: Option, /// Project name to file it under (must already exist). #[arg(long)] project: Option, /// Recurrence: a preset (daily|weekly|monthly|yearly|weekdays) or NL /// ("every 3 days", "every fri"). #[arg(long)] recur: Option, /// A raw RFC-5545 RRULE (overrides --recur). #[arg(long)] rrule: Option, }, /// Enumerate the outstanding set (the Organizational survey). List { /// Restrict to a project node id. #[arg(long)] scope: Option, /// Restrict to a project by NAME (subtree-expanded). e.g. --project Hephaestus. #[arg(long)] project: Option, /// Only this attention-state: white|orange|red|blue. #[arg(short = 'a', long)] attention: Option, /// Hide on-deck (blue) items. #[arg(long)] no_blue: bool, /// Print raw JSON rows (node id, canonical-context id, scalars) for scripting/agents. #[arg(long)] json: bool, }, /// Run a built-in filter view (tech-spec §8.2); omit the name to list views. View { /// View name: tom|ondeck|chores|work|tasks. Omit to list all views. name: Option, }, /// Mark a task done (recurring tasks roll forward). Done { /// Task node id. id: String, }, /// Mark a task dropped. Drop { /// Task node id. id: String, }, /// Skip a recurring task's current occurrence (advance without logging). Skip { /// Task node id. id: String, }, /// Set a task's attention-state. Attention { /// Task node id. id: String, /// white|orange|red|blue. attention: String, }, /// Reschedule a task: change do-date / late-on / recurrence (use `none` to /// clear a field; omit to leave it unchanged). Also re-attentions / re-files. Edit { /// Task node id. id: String, /// Do-date or `none`. #[arg(long)] do_date: Option, /// Late-on or `none`. #[arg(long)] late_on: Option, /// Recurrence (preset/NL) or `none`. #[arg(long)] recur: Option, /// A raw RRULE or `none`. #[arg(long)] rrule: Option, /// Set attention: white|orange|red|blue. #[arg(short = 'a', long)] attention: Option, /// Re-file under a project (by name); `none` unfiles the task. #[arg(long)] project: Option, }, /// Promote a context-item line into a committed task. Promote { /// The container node id (the doc holding the `- [ ]` line). container_id: String, /// 1-based index of the context item to promote (document order). item_ref: usize, /// Attention for the new task: white|orange|red|blue. #[arg(short = 'a', long)] attention: Option, /// Project name to file the new task under. #[arg(long)] project: Option, }, /// Show a node (and, if it is a task, its scalars). Show { /// Node id. id: String, }, /// Append to a task's log (with text) or print its latest entries (without). Log { /// Task node id. id: String, /// Text to append; if omitted, the latest entries are printed. text: Vec, /// How many entries to print (read mode). #[arg(short = 'n', long, default_value_t = 10)] n: usize, }, /// Read or edit a task's canonical-context doc body **by task id** — no /// manual `canonical_context_id` lookup. With neither flag, prints the body. Context { /// Task node id. id: String, /// Replace the body with this text (`-` reads from stdin). #[arg(long)] body: Option, /// Append this text to the body (separated by a blank line). #[arg(long)] append: Option, }, /// Working-set health — the §6.2 tensions (orange vs 6, active vs ~30, …). Health, /// Create a document node. Doc { /// The document title. title: String, /// Markdown body. #[arg(long)] body: Option, }, /// Node operations (update, tombstone). Node { #[command(subcommand)] action: NodeAction, }, /// Fetch a node by id and print it as JSON. Get { /// Node id. id: String, }, /// Resolve a wiki-link title to a node (exact, alias-then-title). Resolve { /// The title or alias. title: String, }, /// Full-text search over titles and bodies. Search { /// FTS5 query. query: String, }, /// Open (or create) the journal for an ISO date (YYYY-MM-DD). Journal { /// The ISO date. date: String, }, /// List a node's outgoing links. Links { /// Node id. id: String, }, /// List a node's backlinks. Backlinks { /// Node id. id: String, }, /// Link operations (add). Link { #[command(subcommand)] action: LinkAction, }, /// Project operations (add). Project { #[command(subcommand)] action: ProjectAction, }, /// Tag operations: add/remove a tag on a node, or list tags. Tag { #[command(subcommand)] action: TagAction, }, /// Force a sync cycle (or show sync status with --status). Sync { /// Show status instead of syncing. #[arg(long)] status: bool, }, /// List merge conflicts, or resolve one. Conflicts { #[command(subcommand)] action: Option, }, /// Export the store to a directory tree of .md files. Export { /// Destination directory (created if needed). dir: PathBuf, }, /// One-time: rewrite legacy `[[Name]]` body links to the canonical /// `[[node-id]]` form (tech-spec §8.4). Idempotent. MigrateLinks, /// Manage the hephd daemon as an OS service (launchd / systemd). Daemon { #[command(subcommand)] action: service::DaemonAction, }, /// Authenticate this device with a sync hub (OAuth 2.0 device-code flow). Auth { #[command(subcommand)] action: AuthAction, }, } #[derive(Subcommand, Debug)] enum NodeAction { /// Update a node's title and/or body. Update { /// Node id. id: String, /// New title. #[arg(long)] title: Option, /// New markdown body (or `-` to read the body from stdin). #[arg(long)] body: Option, }, /// Tombstone (soft-delete) a node. Rm { /// Node id. id: String, }, } #[derive(Subcommand, Debug)] enum LinkAction { /// Add a typed link between two nodes. Add { /// Source node id. src: String, /// Destination node id. dst: String, /// Link type: blocks|parent|tagged|in-project|context-of|… link_type: String, }, } #[derive(Subcommand, Debug)] enum ProjectAction { /// Create a project node (optionally under a parent project). Add { /// Project name. name: String, /// Parent project name (creates a `parent` link). #[arg(long)] parent: Option, }, /// List all projects. List, } #[derive(Subcommand, Debug)] enum TagAction { /// Tag a node. Add { /// Node id to tag. node: String, /// Tag name. tag: String, }, /// Remove a tag from a node. Rm { /// Node id. node: String, /// Tag name. tag: String, }, /// List a node's tags, or every tag in the store (with no node). List { /// Node id (omit to list all tags). node: Option, }, } #[derive(Subcommand, Debug)] enum ConflictAction { /// Resolve a conflict by choosing the local or remote value. Resolve { /// Conflict id. id: String, /// `local` or `remote`. choice: String, }, } #[derive(Subcommand, Debug)] enum AuthAction { /// Log in via the device-code flow; caches the bearer token for hub sync. Login { /// Hub/server URL this token is for (keys the credential store entry). #[arg(long)] hub_url: String, /// OIDC issuer, e.g. https://authentik.ops.eblu.me/application/o/heph/. #[arg(long)] issuer: String, /// OIDC client id this device authenticates as. #[arg(long)] client_id: String, /// Scopes to request (`offline_access` yields a refresh token). #[arg(long, default_value = "openid offline_access")] scope: String, }, /// Forget the cached token for a hub. Logout { /// Hub/server URL whose cached token to remove. #[arg(long)] hub_url: String, }, } /// Run the device-code flow (or clear a token) — no daemon needed. fn run_auth(action: AuthAction) -> Result<()> { match action { AuthAction::Login { hub_url, issuer, client_id, scope, } => { let flow = DeviceFlow::discover(&issuer, &client_id)?; let auth = flow.start(&scope)?; let uri = auth .verification_uri_complete .as_deref() .unwrap_or(&auth.verification_uri); println!( "To authorize hephaestus, visit:\n {uri}\nand enter code: {}\n\nWaiting…", auth.user_code ); let token = flow.poll(&auth, std::thread::sleep)?; KeyringTokenStore::new(hub_url.as_str()).save(&token)?; println!("Logged in. Token cached for {hub_url}."); } AuthAction::Logout { hub_url } => { KeyringTokenStore::new(hub_url.as_str()).clear()?; println!("Logged out of {hub_url}."); } } Ok(()) } fn main() -> Result<()> { let cli = Cli::parse(); // `daemon` manages the OS service; it must not connect to a daemon. if let Command::Daemon { action } = &cli.command { return service::run(action); } // `auth` runs locally (device-code flow + keyring); it needs no daemon. if let Command::Auth { action } = cli.command { return run_auth(action); } let socket = cli.socket.unwrap_or_else(default_socket_path); let mut client = Client::connect(&socket)?; match cli.command { Command::Next { scope, limit } => { let result = client.call("next", json!({ "scope": scope, "limit": limit }))?; print_rows(result)?; } Command::Task { title, attention, do_date, late_on, project, recur, rrule, } => { let recurrence = recurrence_value(recur.as_deref(), rrule.as_deref())?; let project_id = resolve_project(&mut client, project.as_deref())?; let result = client.call( "task.create", json!({ "title": title, "attention": attention, "do_date": opt_date_ms(do_date.as_deref())?, "late_on": opt_date_ms(late_on.as_deref())?, "project_id": project_id, "recurrence": recurrence, }), )?; let task: Task = serde_json::from_value(result)?; println!("Created task {} \"{title}\"", task.node_id); } Command::List { scope, project, attention, no_blue, json, } => { // `list` takes a ListFilter (tech-spec §8.2). Map the flags: a single // `--scope` id or `--project` NAME (resolved + subtree-expanded by the // daemon), a single `--attention` whitelist, and `--no-blue`. let mut filter = json!({}); if let Some(s) = scope { filter["scope"] = json!([s]); } if let Some(p) = project { filter["project"] = json!(p); } if let Some(a) = attention { filter["attention_in"] = json!([a]); } if no_blue { filter["attention_not"] = json!(["blue"]); } let result = client.call("list", filter)?; if json { println!("{}", serde_json::to_string_pretty(&result)?); } else { print_rows(result)?; } } Command::View { name } => match name { Some(name) => { let result = client.call("view", json!({ "name": name }))?; print_rows(result)?; } None => { println!("Available views (heph view ):"); for v in heph_core::BUILTIN_VIEWS { println!(" {:<8} {}", v.name, v.title); } } }, Command::Done { id } => { set_state(&mut client, &id, "done")?; } Command::Drop { id } => { set_state(&mut client, &id, "dropped")?; } Command::Skip { id } => { client.call("task.skip", json!({ "id": id }))?; println!("Skipped occurrence of {id}"); } Command::Attention { id, attention } => { client.call( "task.set_attention", json!({ "id": id, "attention": attention }), )?; println!("{id} attention → {attention}"); } Command::Edit { id, do_date, late_on, recur, rrule, attention, project, } => { let mut patch = serde_json::Map::new(); patch.insert("id".into(), json!(id)); if let Some(v) = sched_date(do_date.as_deref())? { patch.insert("do_date".into(), v); } if let Some(v) = sched_date(late_on.as_deref())? { patch.insert("late_on".into(), v); } let recur_spec = recur.or(rrule); if let Some(v) = sched_recurrence(recur_spec.as_deref())? { patch.insert("recurrence".into(), v); } if patch.len() > 1 { client.call("task.set_schedule", Value::Object(patch))?; } if let Some(a) = attention { client.call("task.set_attention", json!({ "id": id, "attention": a }))?; } if let Some(spec) = project.as_deref() { // Re-file (or unfile with `none`) via the move-to-project path, // which tombstones the old `in-project` link rather than piling // a second one on top of it. let project_id = match spec { "none" | "clear" => None, name => resolve_project(&mut client, Some(name))?, }; client.call( "task.set_project", json!({ "id": id, "project_id": project_id }), )?; } println!("Edited task {id}"); } Command::Promote { container_id, item_ref, attention, project, } => { let project_id = resolve_project(&mut client, project.as_deref())?; let result = client.call( "task.promote", json!({ "container_id": container_id, "item_ref": item_ref, "attention": attention, "project": project_id, }), )?; let task: Task = serde_json::from_value(result)?; println!("Promoted item {item_ref} → task {}", task.node_id); } Command::Show { id } => { let node = client.call("node.get", json!({ "id": id }))?; println!("{}", serde_json::to_string_pretty(&node)?); if node.get("kind").and_then(Value::as_str) == Some("task") { let task = client.call("task.get", json!({ "id": id }))?; println!("task: {}", serde_json::to_string_pretty(&task)?); } } Command::Log { id, text, n } => { if text.is_empty() { let entries = client.call("log.tail", json!({ "task_id": id, "n": n }))?; let entries: Vec = serde_json::from_value(entries)?; if entries.is_empty() { println!("(no log entries)"); } for e in &entries { println!("{e}"); } } else { let line = text.join(" "); client.call("log.append", json!({ "task_id": id, "text": line }))?; println!("Logged to {id}"); } } Command::Context { id, body, append } => { let doc_id = canonical_context_id(&mut client, &id)?; match (body, append) { (Some(_), Some(_)) => bail!("pass only one of --body / --append"), (Some(body), None) => { let body = read_body_arg(Some(body))?; client.call("node.update", json!({ "id": doc_id, "body": body }))?; println!("Set context of {id}"); } (None, Some(text)) => { let current = context_body(&mut client, &doc_id)?; let combined = if current.trim().is_empty() { text } else { format!("{}\n\n{text}", current.trim_end()) }; client.call("node.update", json!({ "id": doc_id, "body": combined }))?; println!("Appended to context of {id}"); } (None, None) => { let current = context_body(&mut client, &doc_id)?; if current.trim().is_empty() { println!("(empty context)"); } else { println!("{}", current.trim_end()); } } } } Command::Health => { let h = client.call("health", json!({}))?; println!( "orange {} active {} on-deck {} conflicts {} sync {}", num(&h, "orange_count"), num(&h, "active_count"), num(&h, "on_deck_count"), num(&h, "conflict_count"), h.get("sync_status").and_then(Value::as_str).unwrap_or("?"), ); } Command::Doc { title, body } => { let result = client.call( "node.create", json!({ "kind": "doc", "title": title, "body": body }), )?; let node: Node = serde_json::from_value(result)?; println!("Created doc {} \"{}\"", node.id, node.title); } Command::Node { action } => match action { NodeAction::Update { id, title, body } => { let body = read_body_arg(body)?; let result = client.call( "node.update", json!({ "id": id, "title": title, "body": body }), )?; let node: Node = serde_json::from_value(result)?; println!("Updated {} \"{}\"", node.id, node.title); } NodeAction::Rm { id } => { client.call("node.tombstone", json!({ "id": id }))?; println!("Tombstoned {id}"); } }, Command::Get { id } => { let result = client.call("node.get", json!({ "id": id }))?; println!("{}", serde_json::to_string_pretty(&result)?); } Command::Resolve { title } => { let result = client.call("node.resolve", json!({ "title": title }))?; if result.is_null() { println!("(no match)"); } else { let node: Node = serde_json::from_value(result)?; println!("{} [{}] {}", node.id, node.kind.as_str(), node.title); } } Command::Search { query } => { let result = client.call("search", json!({ "query": query }))?; let nodes: Vec = serde_json::from_value(result)?; if nodes.is_empty() { println!("No matches."); } for n in &nodes { println!("{} [{}] {}", n.id, n.kind.as_str(), n.title); } } Command::Journal { date } => { let result = client.call("journal.open_or_create", json!({ "date": date }))?; let node: Node = serde_json::from_value(result)?; println!("Journal {} ({})", node.title, node.id); } Command::Links { id } => { print_links(client.call("links.outgoing", json!({ "id": id }))?); } Command::Backlinks { id } => { print_links(client.call("links.backlinks", json!({ "id": id }))?); } Command::Link { action } => match action { LinkAction::Add { src, dst, link_type, } => { client.call( "links.add", json!({ "src": src, "dst": dst, "link_type": link_type }), )?; println!("Linked {src} -[{link_type}]-> {dst}"); } }, Command::Project { action } => match action { ProjectAction::Add { name, parent } => { let result = client.call("node.create", json!({ "kind": "project", "title": name }))?; let node: Node = serde_json::from_value(result)?; if let Some(parent) = parent { let pid = resolve_project(&mut client, Some(&parent))? .context("parent project not found")?; client.call( "links.add", json!({ "src": node.id, "dst": pid, "link_type": "parent" }), )?; } println!("Created project {} \"{}\"", node.id, node.title); } ProjectAction::List => { let result = client.call("node.list", json!({ "kind": "project" }))?; let nodes: Vec = serde_json::from_value(result)?; if nodes.is_empty() { println!("No projects."); } for n in &nodes { println!("{} {}", n.id, n.title); } } }, Command::Tag { action } => match action { TagAction::Add { node, tag } => { let result = client.call("tag.add", json!({ "node_id": node, "tag": tag }))?; let t: Node = serde_json::from_value(result)?; println!("{node} tagged #{}", t.title); } TagAction::Rm { node, tag } => { client.call("tag.remove", json!({ "node_id": node, "tag": tag }))?; println!("{node} untagged #{tag}"); } TagAction::List { node } => { let tags: Vec = match node { Some(node) => { let result = client.call("tag.list", json!({ "node_id": node }))?; serde_json::from_value(result)? } None => { let result = client.call("node.list", json!({ "kind": "tag" }))?; let nodes: Vec = serde_json::from_value(result)?; nodes.into_iter().map(|n| n.title).collect() } }; if tags.is_empty() { println!("(no tags)"); } for t in &tags { println!("#{t}"); } } }, Command::Sync { status } => { let method = if status { "sync.status" } else { "sync.now" }; let result = client.call(method, json!({}))?; println!("{}", serde_json::to_string_pretty(&result)?); } Command::Conflicts { action } => match action { None => { let result = client.call("conflicts.list", json!({}))?; let arr = result.as_array().cloned().unwrap_or_default(); if arr.is_empty() { println!("No conflicts."); } for c in &arr { println!("{}", serde_json::to_string(c)?); } } Some(ConflictAction::Resolve { id, choice }) => { client.call("conflicts.resolve", json!({ "id": id, "choice": choice }))?; println!("Resolved {id} → {choice}"); } }, Command::Export { dir } => { let path = dir .to_str() .context("export path is not valid UTF-8")? .to_string(); let result = client.call("export", json!({ "path": path }))?; let count = result.get("count").and_then(Value::as_u64).unwrap_or(0); println!("Exported {count} nodes to {}", dir.display()); } Command::MigrateLinks => { let result = client.call("migrate.wikilinks", json!({}))?; let n = result.as_u64().unwrap_or(0); println!("Rewrote legacy [[Name]] links to [[id]] in {n} node(s)."); } Command::Auth { .. } => unreachable!("auth is handled before connecting"), Command::Daemon { .. } => unreachable!("daemon is handled before connecting"), } Ok(()) } /// Parse an optional human date into epoch-ms JSON (for `task.create`). fn opt_date_ms(spec: Option<&str>) -> Result> { spec.map(datespec::parse_date_ms).transpose() } /// A `task.set_schedule` date field value: `Some(null)` to clear (`"none"`), /// `Some()` to set, `None` to omit (leave unchanged). fn sched_date(spec: Option<&str>) -> Result> { match spec { None => Ok(None), Some("none") => Ok(Some(Value::Null)), Some(s) => Ok(Some(json!(datespec::parse_date_ms(s)?))), } } /// A `task.set_schedule` recurrence value, same tri-state as [`sched_date`]. fn sched_recurrence(spec: Option<&str>) -> Result> { match spec { None => Ok(None), Some("none") => Ok(Some(Value::Null)), Some(s) => Ok(Some(json!(datespec::parse_recurrence(s)?))), } } /// Recurrence for `task.create`: a raw `--rrule` wins, else parse `--recur`. fn recurrence_value(recur: Option<&str>, rrule: Option<&str>) -> Result> { if let Some(raw) = rrule { return Ok(Some(raw.to_string())); } recur.map(datespec::parse_recurrence).transpose() } /// Resolve a project **name** to its node id, erroring if no project matches. /// Matching is case-insensitive and prefix-fuzzy when unambiguous (server-side, /// shared with `heph list --project`); an exact title always wins outright. fn resolve_project(client: &mut Client, name: Option<&str>) -> Result> { let Some(name) = name else { return Ok(None) }; let result = client.call("project.resolve", json!({ "name": name }))?; if result.is_null() { bail!("no project named {name:?} (create it with: heph project add {name:?})"); } let node: Node = serde_json::from_value(result)?; Ok(Some(node.id)) } /// Resolve a task's canonical-context doc id by walking its outgoing links. /// Errors if the node has no such doc (e.g. it isn't a task). fn canonical_context_id(client: &mut Client, task_id: &str) -> Result { let links = client.call("links.outgoing", json!({ "id": task_id }))?; let dst = links .as_array() .into_iter() .flatten() .find(|l| l.get("link_type").and_then(Value::as_str) == Some("canonical-context")) .and_then(|l| l.get("dst_id").and_then(Value::as_str)); match dst { Some(id) => Ok(id.to_string()), None => bail!("{task_id} has no canonical-context doc (is it a task?)"), } } /// Fetch a context doc's current body (empty string when unset). fn context_body(client: &mut Client, doc_id: &str) -> Result { let node = client.call("node.get", json!({ "id": doc_id }))?; Ok(node .get("body") .and_then(Value::as_str) .unwrap_or("") .to_string()) } /// `--body -` reads the body from stdin; otherwise pass it through. fn read_body_arg(body: Option) -> Result> { match body.as_deref() { Some("-") => { use std::io::Read; let mut s = String::new(); std::io::stdin().read_to_string(&mut s)?; Ok(Some(s)) } _ => Ok(body), } } fn set_state(client: &mut Client, id: &str, state: &str) -> Result<()> { client.call("task.set_state", json!({ "id": id, "state": state }))?; println!("{id} → {state}"); Ok(()) } fn num(v: &Value, key: &str) -> u64 { v.get(key).and_then(Value::as_u64).unwrap_or(0) } /// Print a list of `RankedTask` rows (shared by `next` and `list`). fn print_rows(result: Value) -> Result<()> { let tasks: Vec = serde_json::from_value(result)?; if tasks.is_empty() { println!("Nothing actionable right now."); } for t in &tasks { println!("{}", format_row(t)); } Ok(()) } fn print_links(result: Value) { let arr = result.as_array().cloned().unwrap_or_default(); if arr.is_empty() { println!("(no links)"); } for l in &arr { let ty = l.get("link_type").and_then(Value::as_str).unwrap_or("?"); let src = l.get("src_id").and_then(Value::as_str).unwrap_or("?"); let dst = l.get("dst_id").and_then(Value::as_str).unwrap_or("?"); println!("{src} -[{ty}]-> {dst}"); } } /// One concise Tactical row: attention tag, title, and human do/late context. fn format_row(t: &RankedTask) -> String { let tag = t .attention .map(|a| format!("[{}]", serde_json::to_value(a).unwrap().as_str().unwrap())) .unwrap_or_else(|| "[ ]".to_string()); let mut extra = Vec::new(); if let Some(d) = t.do_date { extra.push(format!("do:{}", datespec::fmt_date(d))); } if let Some(l) = t.late_on { extra.push(format!("late:{}", datespec::fmt_date(l))); } let suffix = if extra.is_empty() { String::new() } else { format!(" ({})", extra.join(", ")) }; format!("{tag} {}{suffix} {}", t.title, t.node_id) }