feat(cli): complete task surface — human dates, recurrence, full API
Some checks failed
Build / validate (pull_request) Failing after 1m52s

Make heph a real task driver and the complete daemon-API surface (the
three-surface model's capture/scripting role). Structured fields are flags.

- datespec: human date parsing (today/tomorrow/+3d/fri/ISO, injectable today
  for deterministic tests) + compact display; recurrence presets + the common
  Todoist-style natural-language forms ("every 3 days", "every fri", "every
  April 15") + raw RRULE passthrough. Table-driven unit tests.
- main: new commands covering every RPC — list, done/drop/skip, attention,
  edit (reschedule via task.set_schedule), promote, show, log (append/tail),
  health, node update/rm, resolve, links/backlinks, link add,
  project add [--parent], sync [--status], conflicts [resolve]. task/next/list
  show human dates; projects referenced by name (resolved, errors if absent).
- tests/cli.rs: real-socket process tests for the new verbs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-02 19:36:50 -07:00
commit 07e4d786b3
6 changed files with 1025 additions and 25 deletions

1
Cargo.lock generated
View file

@ -1135,6 +1135,7 @@ name = "heph"
version = "0.0.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"heph-core",
"hephd",

View file

@ -18,6 +18,7 @@ hephd = { path = "../hephd" }
clap.workspace = true
serde_json.workspace = true
anyhow.workspace = true
chrono.workspace = true
[dev-dependencies]
tempfile = "3"

372
crates/heph/src/datespec.rs Normal file
View file

@ -0,0 +1,372 @@
//! Human-friendly date and recurrence parsing for the CLI (tech-spec §1, §8).
//!
//! `heph-core` is clock-pure (no ambient wall-clock reads); the CLI is a client,
//! so it may read the local clock. Date parsing is split so the logic is
//! deterministically testable: the pure functions take `today` / a year, and the
//! thin wrappers supply `Local::now()`.
//!
//! Recurrence input mirrors how the owner thinks in Todoist (see [[design]]
//! §6.2.1): presets, the common natural-language forms, or a raw RRULE.
use anyhow::{bail, Context, Result};
use chrono::{Datelike, Days, Local, Months, NaiveDate, TimeZone, Weekday};
// ---------------------------------------------------------------------------
// Dates
// ---------------------------------------------------------------------------
/// Parse a human date spec **relative to `today`** into a `NaiveDate`. Accepts:
/// `today`, `tomorrow`/`tom`, `yesterday`; `+Nd`/`+Nw`/`+Nm` (and bare `+N` =
/// days); weekday names (`mon`..`sun`, the soonest such day on/after today);
/// ISO `YYYY-MM-DD`.
pub fn parse_date(input: &str, today: NaiveDate) -> Result<NaiveDate> {
let s = input.trim().to_lowercase();
if s.is_empty() {
bail!("empty date");
}
match s.as_str() {
"today" | "now" => return Ok(today),
"tomorrow" | "tom" => return Ok(today + Days::new(1)),
"yesterday" => return Ok(today - Days::new(1)),
_ => {}
}
if let Some(wd) = parse_weekday(&s) {
return Ok(soonest_weekday(today, wd));
}
if let Some(rest) = s.strip_prefix('+') {
return parse_offset(rest, today);
}
NaiveDate::parse_from_str(&s, "%Y-%m-%d").with_context(|| {
format!("unrecognized date: {input:?} (try today, tomorrow, +3d, fri, or YYYY-MM-DD)")
})
}
/// `parse_date` against the local `today`.
pub fn parse_date_today(input: &str) -> Result<NaiveDate> {
parse_date(input, Local::now().date_naive())
}
/// Parse a human date spec to epoch milliseconds at **local midnight** — the
/// form `do_date`/`late_on` are stored in (a date-grained candidacy gate, §7).
pub fn parse_date_ms(input: &str) -> Result<i64> {
Ok(to_epoch_ms(parse_date_today(input)?))
}
/// Local-midnight epoch ms for a date. Falls back gracefully across a DST gap.
pub fn to_epoch_ms(date: NaiveDate) -> i64 {
let midnight = date.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid");
match Local.from_local_datetime(&midnight).earliest() {
Some(dt) => dt.timestamp_millis(),
None => midnight.and_utc().timestamp_millis(),
}
}
/// Compact display of an epoch-ms date: `MM-DD` within the current year,
/// `YYYY-MM-DD` otherwise.
pub fn fmt_date(ms: i64) -> String {
let date = match Local.timestamp_millis_opt(ms).earliest() {
Some(dt) => dt.date_naive(),
None => return ms.to_string(),
};
fmt_naive(date, Local::now().date_naive().year())
}
fn fmt_naive(date: NaiveDate, this_year: i32) -> String {
if date.year() == this_year {
format!("{:02}-{:02}", date.month(), date.day())
} else {
date.format("%Y-%m-%d").to_string()
}
}
fn parse_offset(rest: &str, today: NaiveDate) -> Result<NaiveDate> {
let rest = rest.trim();
let (num, unit) = rest.split_at(
rest.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len()),
);
let n: u64 = num
.parse()
.with_context(|| format!("not a relative date offset: +{rest}"))?;
match unit.trim() {
"" | "d" | "day" | "days" => Ok(today + Days::new(n)),
"w" | "wk" | "week" | "weeks" => Ok(today + Days::new(n * 7)),
"m" | "mo" | "month" | "months" => Ok(today + Months::new(n as u32)),
other => bail!("unknown offset unit {other:?} (use d, w, or m)"),
}
}
/// Map a weekday name (full or common abbreviation) to a `Weekday`. Matches
/// only recognized weekday tokens — not arbitrary prefixes (so "months" is not
/// "mon").
fn parse_weekday(s: &str) -> Option<Weekday> {
match s {
"mon" | "monday" => Some(Weekday::Mon),
"tue" | "tues" | "tuesday" => Some(Weekday::Tue),
"wed" | "weds" | "wednesday" => Some(Weekday::Wed),
"thu" | "thur" | "thurs" | "thursday" => Some(Weekday::Thu),
"fri" | "friday" => Some(Weekday::Fri),
"sat" | "saturday" => Some(Weekday::Sat),
"sun" | "sunday" => Some(Weekday::Sun),
_ => None,
}
}
/// The soonest date on/after `today` whose weekday is `wd`.
fn soonest_weekday(today: NaiveDate, wd: Weekday) -> NaiveDate {
let mut d = today;
for _ in 0..7 {
if d.weekday() == wd {
return d;
}
d = d + Days::new(1);
}
today // unreachable: a weekday occurs within 7 days
}
// ---------------------------------------------------------------------------
// Recurrence
// ---------------------------------------------------------------------------
/// Parse a recurrence spec into an RFC-5545 RRULE. Accepts a raw RRULE
/// (anything containing `FREQ=`), presets (`daily|weekly|monthly|yearly|
/// weekdays`), and the common natural-language forms the owner uses in Todoist
/// (§6.2.1): `every N (day|week|month|year)s`, `every <weekday>`, `every other
/// <weekday|unit>`, `every workday`, `every <Month> <day>`. A trailing
/// `at <time>` is ignored (heph's do-date is date-grained).
pub fn parse_recurrence(spec: &str) -> Result<String> {
let raw = spec.trim();
if raw.to_uppercase().contains("FREQ=") {
return Ok(raw.to_string());
}
// Normalize: lowercase, drop a trailing "at <time>" clause.
let mut s = raw.to_lowercase();
if let Some(at) = s.find(" at ") {
s.truncate(at);
}
let s = s.trim();
// Presets.
match s {
"daily" | "day" => return Ok("FREQ=DAILY".into()),
"weekly" | "week" => return Ok("FREQ=WEEKLY".into()),
"monthly" | "month" => return Ok("FREQ=MONTHLY".into()),
"yearly" | "annually" | "year" => return Ok("FREQ=YEARLY".into()),
"weekdays" | "workdays" | "workday" | "weekday" => {
return Ok("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR".into())
}
_ => {}
}
let body = s.strip_prefix("every ").unwrap_or(s).trim();
// "every other <...>" → INTERVAL=2.
if let Some(rest) = body.strip_prefix("other ") {
return interval_form(2, rest.trim());
}
// "every workday/weekday".
if body == "workday" || body == "weekday" || body == "workdays" || body == "weekdays" {
return Ok("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR".into());
}
// "every <weekday>".
if let Some(wd) = parse_weekday(body) {
return Ok(format!("FREQ=WEEKLY;BYDAY={}", byday(wd)));
}
// "every <Month> <day>".
if let Some((m, d)) = parse_month_day(body) {
return Ok(format!("FREQ=YEARLY;BYMONTH={m};BYMONTHDAY={d}"));
}
// "every <unit>" / "every N <unit>s".
let mut it = body.split_whitespace();
let first = it.next().unwrap_or("");
if let Ok(n) = first.parse::<u32>() {
return interval_form(n, it.next().unwrap_or(""));
}
interval_form(1, first)
}
/// `FREQ=...[;INTERVAL=n]` for a unit word, or a weekday with an interval.
fn interval_form(n: u32, unit: &str) -> Result<String> {
if let Some(wd) = parse_weekday(unit) {
let mut r = format!("FREQ=WEEKLY;BYDAY={}", byday(wd));
if n != 1 {
r = format!("FREQ=WEEKLY;INTERVAL={n};BYDAY={}", byday(wd));
}
return Ok(r);
}
let freq = match unit {
"day" | "days" => "DAILY",
"week" | "weeks" => "WEEKLY",
"month" | "months" => "MONTHLY",
"year" | "years" => "YEARLY",
other => bail!(
"unrecognized recurrence {other:?} (try daily/weekly/monthly/yearly, \
'every 3 days', 'every fri', or a raw RRULE)"
),
};
Ok(if n == 1 {
format!("FREQ={freq}")
} else {
format!("FREQ={freq};INTERVAL={n}")
})
}
fn byday(wd: Weekday) -> &'static str {
match wd {
Weekday::Mon => "MO",
Weekday::Tue => "TU",
Weekday::Wed => "WE",
Weekday::Thu => "TH",
Weekday::Fri => "FR",
Weekday::Sat => "SA",
Weekday::Sun => "SU",
}
}
/// Parse "april 15" / "apr 15" (and the reversed "15 april") → (month, day).
fn parse_month_day(s: &str) -> Option<(u32, u32)> {
let toks: Vec<&str> = s.split_whitespace().collect();
if toks.len() != 2 {
return None;
}
let month = |t: &str| -> Option<u32> {
match &t[..t.len().min(3)] {
"jan" => Some(1),
"feb" => Some(2),
"mar" => Some(3),
"apr" => Some(4),
"may" => Some(5),
"jun" => Some(6),
"jul" => Some(7),
"aug" => Some(8),
"sep" => Some(9),
"oct" => Some(10),
"nov" => Some(11),
"dec" => Some(12),
_ => None,
}
};
let day = |t: &str| {
t.trim_end_matches(|c: char| !c.is_ascii_digit())
.parse::<u32>()
.ok()
};
if let (Some(m), Some(d)) = (month(toks[0]), day(toks[1])) {
return Some((m, d));
}
if let (Some(d), Some(m)) = (day(toks[0]), month(toks[1])) {
return Some((m, d));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
// 2026-06-02 is a Tuesday.
fn today() -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 6, 2).unwrap()
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn parse_date_keywords_and_offsets() {
let t = today();
assert_eq!(parse_date("today", t).unwrap(), d(2026, 6, 2));
assert_eq!(parse_date("tomorrow", t).unwrap(), d(2026, 6, 3));
assert_eq!(parse_date("yesterday", t).unwrap(), d(2026, 6, 1));
assert_eq!(parse_date("+3d", t).unwrap(), d(2026, 6, 5));
assert_eq!(parse_date("+2w", t).unwrap(), d(2026, 6, 16));
assert_eq!(parse_date("+1m", t).unwrap(), d(2026, 7, 2));
assert_eq!(parse_date("+5", t).unwrap(), d(2026, 6, 7));
}
#[test]
fn parse_date_weekdays_are_soonest_on_or_after_today() {
let t = today(); // Tuesday
assert_eq!(
parse_date("tue", t).unwrap(),
d(2026, 6, 2),
"today if it matches"
);
assert_eq!(parse_date("fri", t).unwrap(), d(2026, 6, 5));
assert_eq!(
parse_date("mon", t).unwrap(),
d(2026, 6, 8),
"wraps to next week"
);
}
#[test]
fn parse_date_iso_and_errors() {
assert_eq!(parse_date("2026-12-25", today()).unwrap(), d(2026, 12, 25));
assert!(parse_date("someday", today()).is_err());
assert!(parse_date("", today()).is_err());
}
#[test]
fn fmt_naive_elides_the_current_year() {
assert_eq!(fmt_naive(d(2026, 6, 5), 2026), "06-05");
assert_eq!(fmt_naive(d(2027, 1, 9), 2026), "2027-01-09");
}
#[test]
fn recurrence_presets_and_raw() {
assert_eq!(parse_recurrence("daily").unwrap(), "FREQ=DAILY");
assert_eq!(parse_recurrence("weekly").unwrap(), "FREQ=WEEKLY");
assert_eq!(parse_recurrence("monthly").unwrap(), "FREQ=MONTHLY");
assert_eq!(parse_recurrence("yearly").unwrap(), "FREQ=YEARLY");
assert_eq!(
parse_recurrence("weekdays").unwrap(),
"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
);
// Raw RRULE passes through untouched.
assert_eq!(
parse_recurrence("FREQ=DAILY;INTERVAL=2").unwrap(),
"FREQ=DAILY;INTERVAL=2"
);
}
#[test]
fn recurrence_natural_language() {
assert_eq!(parse_recurrence("every day").unwrap(), "FREQ=DAILY");
assert_eq!(
parse_recurrence("every 3 days").unwrap(),
"FREQ=DAILY;INTERVAL=3"
);
assert_eq!(
parse_recurrence("every 2 weeks").unwrap(),
"FREQ=WEEKLY;INTERVAL=2"
);
assert_eq!(
parse_recurrence("every 6 months").unwrap(),
"FREQ=MONTHLY;INTERVAL=6"
);
assert_eq!(
parse_recurrence("every fri").unwrap(),
"FREQ=WEEKLY;BYDAY=FR"
);
assert_eq!(
parse_recurrence("every other wed").unwrap(),
"FREQ=WEEKLY;INTERVAL=2;BYDAY=WE"
);
assert_eq!(
parse_recurrence("every other day").unwrap(),
"FREQ=DAILY;INTERVAL=2"
);
assert_eq!(
parse_recurrence("every workday at 08:00").unwrap(),
"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
);
assert_eq!(
parse_recurrence("every April 15").unwrap(),
"FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15"
);
assert!(parse_recurrence("every blue moon").is_err());
}
}

View file

@ -1,16 +1,22 @@
//! `heph` — the CLI surface (tech-spec §1). A thin client of the local
//! `hephd`: it never touches SQLite, only the daemon socket. Secondary to
//! `heph.nvim`; for scripting, admin, smoke tests, and `export`.
//! `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::{Context, Result};
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use heph_core::{Node, RankedTask, Task};
use hephd::{default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore};
mod datespec;
#[derive(Parser, Debug)]
#[command(name = "heph", version, about)]
struct Cli {
@ -38,21 +44,113 @@ enum Command {
/// The task title.
title: String,
/// Attention-state: white|orange|red|blue.
#[arg(long)]
#[arg(short = 'a', long)]
attention: Option<String>,
/// Earliest-actionable date, epoch ms.
/// Do-date (earliest-actionable): today|tomorrow|+3d|fri|YYYY-MM-DD.
#[arg(long)]
do_date: Option<i64>,
/// Lateness-problem marker, epoch ms.
do_date: Option<String>,
/// Late-on (the sole urgency marker): same date forms as --do-date.
#[arg(long)]
late_on: Option<i64>,
/// Project node id to file it under.
late_on: Option<String>,
/// Project name to file it under (must already exist).
#[arg(long)]
project: Option<String>,
/// RFC-5545 RRULE for a recurring task.
/// Recurrence: a preset (daily|weekly|monthly|yearly|weekdays) or NL
/// ("every 3 days", "every fri").
#[arg(long)]
recurrence: Option<String>,
recur: Option<String>,
/// A raw RFC-5545 RRULE (overrides --recur).
#[arg(long)]
rrule: Option<String>,
},
/// Enumerate the outstanding set (the Organizational survey).
List {
/// Restrict to a project node id.
#[arg(long)]
scope: Option<String>,
/// Only this attention-state: white|orange|red|blue.
#[arg(short = 'a', long)]
attention: Option<String>,
/// Hide on-deck (blue) items.
#[arg(long)]
no_blue: bool,
},
/// 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<String>,
/// Late-on or `none`.
#[arg(long)]
late_on: Option<String>,
/// Recurrence (preset/NL) or `none`.
#[arg(long)]
recur: Option<String>,
/// A raw RRULE or `none`.
#[arg(long)]
rrule: Option<String>,
/// Set attention: white|orange|red|blue.
#[arg(short = 'a', long)]
attention: Option<String>,
/// File under a project (by name; adds an in-project link).
#[arg(long)]
project: Option<String>,
},
/// 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<String>,
/// Project name to file the new task under.
#[arg(long)]
project: Option<String>,
},
/// 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<String>,
/// How many entries to print (read mode).
#[arg(short = 'n', long, default_value_t = 10)]
n: usize,
},
/// Working-set health — the §6.2 tensions (orange vs 6, active vs ~30, …).
Health,
/// Create a document node.
Doc {
/// The document title.
@ -61,11 +159,21 @@ enum Command {
#[arg(long)]
body: Option<String>,
},
/// 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.
@ -76,6 +184,37 @@ enum Command {
/// 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,
},
/// 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<ConflictAction>,
},
/// Export the store to a directory tree of .md files.
Export {
/// Destination directory (created if needed).
@ -88,6 +227,62 @@ enum Command {
},
}
#[derive(Subcommand, Debug)]
enum NodeAction {
/// Update a node's title and/or body.
Update {
/// Node id.
id: String,
/// New title.
#[arg(long)]
title: Option<String>,
/// New markdown body (or `-` to read the body from stdin).
#[arg(long)]
body: Option<String>,
},
/// 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<String>,
},
}
#[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.
@ -158,13 +353,7 @@ fn main() -> Result<()> {
match cli.command {
Command::Next { scope, limit } => {
let result = client.call("next", json!({ "scope": scope, "limit": limit }))?;
let tasks: Vec<RankedTask> = serde_json::from_value(result)?;
if tasks.is_empty() {
println!("Nothing actionable right now.");
}
for t in &tasks {
println!("{}", format_row(t));
}
print_rows(result)?;
}
Command::Task {
title,
@ -172,22 +361,142 @@ fn main() -> Result<()> {
do_date,
late_on,
project,
recurrence,
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": do_date,
"late_on": late_on,
"project_id": project,
"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,
attention,
no_blue,
} => {
let result = client.call(
"list",
json!({ "scope": scope, "attention": attention, "include_blue": !no_blue }),
)?;
print_rows(result)?;
}
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(pid) = resolve_project(&mut client, project.as_deref())? {
client.call(
"links.add",
json!({ "src": id, "dst": pid, "link_type": "in-project" }),
)?;
}
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<String> = 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::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",
@ -196,10 +505,34 @@ fn main() -> Result<()> {
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<Node> = serde_json::from_value(result)?;
@ -215,6 +548,62 @@ fn main() -> Result<()> {
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);
}
},
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()
@ -229,7 +618,104 @@ fn main() -> Result<()> {
Ok(())
}
/// One concise Tactical row: attention tag, title, and do/late context.
/// Parse an optional human date into epoch-ms JSON (for `task.create`).
fn opt_date_ms(spec: Option<&str>) -> Result<Option<i64>> {
spec.map(datespec::parse_date_ms).transpose()
}
/// A `task.set_schedule` date field value: `Some(null)` to clear (`"none"`),
/// `Some(<ms>)` to set, `None` to omit (leave unchanged).
fn sched_date(spec: Option<&str>) -> Result<Option<Value>> {
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<Option<Value>> {
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<Option<String>> {
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 it isn't a project.
fn resolve_project(client: &mut Client, name: Option<&str>) -> Result<Option<String>> {
let Some(name) = name else { return Ok(None) };
let result = client.call("node.resolve", json!({ "title": 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)?;
if node.kind.as_str() != "project" {
bail!(
"{name:?} resolves to a {} node, not a project",
node.kind.as_str()
);
}
Ok(Some(node.id))
}
/// `--body -` reads the body from stdin; otherwise pass it through.
fn read_body_arg(body: Option<String>) -> Result<Option<String>> {
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<RankedTask> = 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
@ -237,15 +723,15 @@ fn format_row(t: &RankedTask) -> String {
.unwrap_or_else(|| "[ ]".to_string());
let mut extra = Vec::new();
if let Some(d) = t.do_date {
extra.push(format!("do:{d}"));
extra.push(format!("do:{}", datespec::fmt_date(d)));
}
if let Some(l) = t.late_on {
extra.push(format!("late:{l}"));
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)
format!("{tag} {}{suffix} {}", t.title, t.node_id)
}

View file

@ -76,6 +76,145 @@ fn next_on_empty_store_is_friendly() {
assert!(out.contains("Nothing actionable"), "{out}");
}
/// The first whitespace token of a "Created task <id> …" line.
fn created_id(out: &str) -> String {
out.split_whitespace()
.nth(2)
.expect("id in output")
.to_string()
}
#[test]
fn task_with_iso_do_date_shows_formatted_date_in_next() {
let (socket, _dir) = spawn_daemon();
// A do-date in the past (relative to the daemon's fixed 2024-01-01 clock)
// so it is actionable and appears in `next`.
let (out, ok) = heph(
&socket,
&[
"task",
"Renew passport",
"-a",
"orange",
"--do-date",
"2023-12-25",
],
);
assert!(ok, "{out}");
let (out, ok) = heph(&socket, &["next"]);
assert!(ok);
assert!(out.contains("Renew passport"), "{out}");
// Within 2023 vs the 2024 clock → full YYYY-MM-DD form.
assert!(
out.contains("do:2023-12-25"),
"expected formatted do-date: {out}"
);
}
#[test]
fn edit_reschedules_and_clears_fields() {
let (socket, _dir) = spawn_daemon();
let (out, _) = heph(
&socket,
&[
"task",
"Taxes",
"--do-date",
"2023-01-01",
"--recur",
"yearly",
],
);
let id = created_id(&out);
// Reschedule do-date and clear recurrence.
let (out, ok) = heph(
&socket,
&["edit", &id, "--do-date", "2023-06-01", "--recur", "none"],
);
assert!(ok, "{out}");
let (out, ok) = heph(&socket, &["show", &id]);
assert!(ok, "{out}");
assert!(
out.contains("\"recurrence\": null"),
"recurrence cleared: {out}"
);
assert!(out.contains("\"do_date\""), "{out}");
}
#[test]
fn list_and_done_and_health() {
let (socket, _dir) = spawn_daemon();
let (out, _) = heph(&socket, &["task", "Mow lawn", "-a", "red"]);
let id = created_id(&out);
let (out, ok) = heph(&socket, &["list"]);
assert!(ok, "{out}");
assert!(out.contains("Mow lawn"), "{out}");
let (out, ok) = heph(&socket, &["health"]);
assert!(ok, "{out}");
assert!(out.contains("active"), "{out}");
let (out, ok) = heph(&socket, &["done", &id]);
assert!(ok, "{out}");
assert!(out.contains("done"), "{out}");
// Done tasks drop out of `next`.
let (out, ok) = heph(&socket, &["next"]);
assert!(ok);
assert!(
!out.contains("Mow lawn"),
"completed task should be gone: {out}"
);
}
#[test]
fn recur_preset_makes_a_recurring_task() {
let (socket, _dir) = spawn_daemon();
let (out, _) = heph(&socket, &["task", "Standup", "--recur", "weekdays"]);
let id = created_id(&out);
let (out, ok) = heph(&socket, &["show", &id]);
assert!(ok, "{out}");
assert!(out.contains("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"), "{out}");
}
#[test]
fn project_add_then_file_a_task_under_it() {
let (socket, _dir) = spawn_daemon();
let (out, ok) = heph(&socket, &["project", "add", "Maintenance"]);
assert!(ok, "{out}");
// A task can be filed under the project by name.
let (out, ok) = heph(
&socket,
&["task", "Replace filter", "--project", "Maintenance"],
);
assert!(ok, "{out}");
// An unknown project name is a clear error, not a silent miss.
let (out, ok) = heph(&socket, &["task", "Whatever", "--project", "Nonexistent"]);
assert!(!ok, "expected failure for unknown project: {out}");
}
#[test]
fn log_append_then_tail() {
let (socket, _dir) = spawn_daemon();
let (out, _) = heph(&socket, &["task", "Investigate bug"]);
let id = created_id(&out);
let (_, ok) = heph(
&socket,
&["log", &id, "looked", "at", "the", "stack", "trace"],
);
assert!(ok);
let (out, ok) = heph(&socket, &["log", &id]);
assert!(ok, "{out}");
assert!(out.contains("looked at the stack trace"), "{out}");
}
#[test]
fn export_writes_markdown_files() {
let (socket, dir) = spawn_daemon();

View file

@ -20,3 +20,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
- `heph.nvim` managed daemon — plug-and-play by default: `require("heph").setup({})` spawns and supervises a local `hephd` against the default paths when none is running, kills only the daemon it spawned on exit, and self-heals (respawns + reconnects if the daemon dies mid-session). A daemon you started yourself (a `server`/`client` architecture, or a service) is always respected — the plugin only spawns when nothing is serving the socket; with `autostart = false` it connects only and warns if unreachable. `$HEPH_SOCKET` / `$HEPH_DB` isolate a development Neovim onto a separate daemon + DB.
- `heph.nvim` follow-or-create: pressing `<CR>` on a `[[wiki-link]]` whose target doesn't exist yet now **creates** a doc with that title and opens it (the zettelkasten gesture), materializing the source's backlink — so you can link a journal entry to a brand-new note in one keystroke. Plus `:Heph doc <title>` to create a standalone wiki entry, and `:Heph home` — a single designated landing/index page (open-or-create by title, configurable via `opts.home`) to grow a map of content around. `:Heph journals` opens a recent-days picker (preview existing days, `@create` for new ones; count via `opts.journal_days`, default 7) — the dailies workflow. Pickers (Telescope) now support a preview pane. The `:Heph next`/`list` views are interactive: `<CR>` opens a task's context, `a` adds a task (prompt title + attention), `d` marks the task under the cursor done, `r` refreshes — with a dimmed key hint shown above the list.
- Dev/installed isolation tooling: a `mise run dev` task runs the working-tree `hephd` on isolated `.dev/` paths, and a how-to ([[install-heph]]) covers installing `heph`/`hephd` from the forge (build-from-source), the lazy.nvim plugin setup, and pointing a dev Neovim at the dev daemon via `$HEPH_SOCKET`/`$HEPH_DB` so it never touches the installed store.
- CLI as a complete task surface (§1, §6.2.1): `heph` now implements the entire daemon API and is the task capture/scripting surface. Structured fields are flags with **human dates** (`--do-date tomorrow|+3d|fri|YYYY-MM-DD`, shown back compactly in `next`/`list`) and **recurrence** (`--recur` presets/natural-language like "every 3 days", or a raw `--rrule`). New verbs: `list`, `done`/`drop`/`skip`, `attention`, `edit` (reschedule do-date/late-on/recurrence, re-attention, re-file — backed by the new `task.set_schedule` RPC), `promote`, `show`, `log` (append or tail), `health`, `node update`/`rm`, `resolve`, `links`/`backlinks`, `link add`, `project add [--parent]`, `sync [--status]`, `conflicts [resolve]`. Projects are referenced by name. Date/recurrence parsing is unit-tested; the new verbs have real-socket process tests.