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
mod cmd;
mod git;
mod graph;
mod merge;
mod model;
mod prime;
mod store;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use crate::model::{Kind, Status};
/// An issue tracker and agent memory that lives on a git ref
#[derive(Parser)]
#[command(name = "foam", version)]
struct Cli {
/// Run as if started in this directory
#[arg(short = 'C', long, global = true, default_value = ".")]
directory: PathBuf,
/// Print JSON instead of text
#[arg(long, global = true)]
json: bool,
/// Who is acting; defaults to $FOAM_ACTOR, then git user.name, then $USER
#[arg(long, global = true)]
actor: Option<String>,
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Create the data ref in this repository
Init {
/// Issue id prefix; defaults to the directory name
#[arg(long)]
prefix: Option<String>,
},
/// Create an issue
Create {
title: String,
/// Kind of issue
#[arg(long = "type", value_enum, default_value_t = Kind::Task)]
kind: Kind,
/// 0 is highest, 4 lowest
#[arg(long, short, default_value_t = 2, value_parser = clap::value_parser!(u8).range(0..=4))]
priority: u8,
/// Add a label; repeatable
#[arg(long = "label")]
labels: Vec<String>,
/// Parent epic
#[arg(long)]
parent: Option<String>,
/// Longer description
#[arg(long, default_value = "")]
body: String,
/// Issue that must close first; repeatable
#[arg(long = "blocked-by")]
blocked_by: Vec<String>,
},
/// Show one issue in full
Show { id: String },
/// List issues; open and in progress unless filtered
List {
#[arg(long, value_enum)]
status: Option<Status>,
#[arg(long = "type", value_enum)]
kind: Option<Kind>,
#[arg(long)]
label: Option<String>,
#[arg(long)]
assignee: Option<String>,
/// Include closed and deferred issues
#[arg(long)]
all: bool,
},
/// Issues that can be worked now: open, past any deferral, nothing blocking
Ready {
/// Show at most this many
#[arg(long)]
limit: Option<usize>,
},
/// Open issues that something still blocks
Blocked,
/// Change fields of an issue
Update {
id: String,
#[arg(long)]
title: Option<String>,
#[arg(long)]
body: Option<String>,
#[arg(long = "type", value_enum)]
kind: Option<Kind>,
#[arg(long, short, value_parser = clap::value_parser!(u8).range(0..=4))]
priority: Option<u8>,
/// open clears any deferral; closed is the same as `close`
#[arg(long, value_enum)]
status: Option<Status>,
/// Empty string clears
#[arg(long)]
assignee: Option<String>,
#[arg(long = "add-label")]
add_label: Vec<String>,
#[arg(long = "rm-label")]
rm_label: Vec<String>,
/// Empty string clears
#[arg(long)]
parent: Option<String>,
/// Defer until an RFC 3339 timestamp or a YYYY-MM-DD date (UTC)
#[arg(long = "defer-until")]
defer_until: Option<String>,
},
/// Close one or more issues
Close {
#[arg(required = true)]
ids: Vec<String>,
#[arg(long)]
reason: Option<String>,
},
/// Reopen one or more closed issues
Reopen {
#[arg(required = true)]
ids: Vec<String>,
},
/// Take an issue: mark it in progress under your name with a 15 minute lease
Claim {
id: String,
/// Take it even if someone else holds an unexpired lease
#[arg(long)]
force: bool,
},
/// Give an issue back: open again, unassigned
Unclaim {
id: String,
/// Release it even if someone else holds it
#[arg(long)]
force: bool,
},
/// Extend the lease on an issue you hold
Heartbeat { id: String },
/// Reopen every in-progress issue whose lease has expired
Reclaim,
/// Append a note to an issue
Note { id: String, text: String },
/// Search titles, bodies and notes, case-insensitively; closed issues included
Search { query: String },
/// Store a memory under a slug, replacing any with the same slug
Remember { slug: String, text: String },
/// List every memory
Memories,
/// Print one memory
Recall { slug: String },
/// Delete a memory
Forget { slug: String },
/// Print the context an agent needs at session start
Prime {
/// Wrap the output as a Claude Code SessionStart hook payload
#[arg(long = "hook-json")]
hook_json: bool,
/// Ready issues to list
#[arg(long, default_value_t = 10)]
limit: usize,
},
/// Install or remove an editor integration
Setup {
#[command(subcommand)]
command: SetupCmd,
},
/// Fetch the remote's data ref, merge it, and push the result
Sync {
/// Which remote
#[arg(long, default_value = "origin")]
remote: String,
/// Also add the fetch refspec and the pre-push hook to this clone
#[arg(long)]
setup: bool,
},
/// Check the data and this clone's setup for problems
Doctor,
/// The history of the data ref, newest first
Log {
/// Only entries that touched this issue or memory
id: Option<String>,
/// Show at most this many
#[arg(long, default_value_t = 20)]
limit: usize,
},
/// Manage what an issue waits on
Dep {
#[command(subcommand)]
command: DepCmd,
},
}
#[derive(Subcommand)]
enum SetupCmd {
/// A SessionStart hook in .claude/settings.json that runs `foam prime --hook-json`
Claude {
/// Take the hook out again
#[arg(long)]
remove: bool,
},
}
#[derive(Subcommand)]
enum DepCmd {
/// Make `id` wait on `blocker`
Add { id: String, blocker: String },
/// Stop `id` waiting on `blocker`
Rm { id: String, blocker: String },
/// Show `id` and everything it waits on
Tree { id: String },
}
fn main() -> ExitCode {
let cli = Cli::parse();
match cmd::run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("foam: {err:#}");
if err.to_string().contains("not initialized") {
ExitCode::from(3)
} else {
ExitCode::from(1)
}
}
}
}