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
mod cmd;
mod git;
mod graph;
mod merge;
mod model;
mod prime;
mod render;
mod store;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand, ValueEnum};
use crate::model::{Kind, Resolution, 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,
/// Print the plain text a pipe gets: no color, full timestamps, no wrapping
#[arg(long, global = true)]
plain: bool,
/// Who is acting; defaults to $FOAM_ACTOR, then git user.name or $USER with a Claude Code session suffix
#[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 blocks all other work, 1 this session, 2 soon, 3 when convenient, 4 someday
#[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 milestone
#[arg(long)]
parent: Option<String>,
/// Longer description
#[arg(long, default_value = "", allow_hyphen_values = true)]
body: String,
/// Issue that must close first; repeatable
#[arg(long = "blocked-by")]
blocked_by: Vec<String>,
},
/// Show one issue in full; with no id, pick one with fzf
Show { id: Option<String> },
/// Pick an issue with fzf and print its id, for `foam claim $(foam pick)`
Pick,
/// 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,
/// One screen: milestones, in progress, ready, blocked and the recent log
Board,
/// Change fields of one or more issues
Update {
#[arg(required = true)]
ids: Vec<String>,
#[arg(long, allow_hyphen_values = true)]
title: Option<String>,
#[arg(long, allow_hyphen_values = true)]
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; use `close` to 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>,
/// Correct a closed issue's resolution
#[arg(long, value_enum)]
resolution: Option<Resolution>,
/// Correct a closed issue's reason
#[arg(long, allow_hyphen_values = true)]
reason: Option<String>,
},
/// Close one or more issues
Close {
#[arg(required = true)]
ids: Vec<String>,
/// What was done, or why it is being dropped
#[arg(long, allow_hyphen_values = true)]
reason: String,
/// Given up on rather than done
#[arg(long)]
dropped: bool,
},
/// 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 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,
#[arg(allow_hyphen_values = true)]
text: String,
},
/// Search issue titles, bodies and notes, and memories, case-insensitively; closed issues included
Search { query: String },
/// Store a memory under a slug, replacing any with the same slug
Remember {
slug: String,
#[arg(allow_hyphen_values = true)]
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,
},
/// Release every claim this session holds; the SessionEnd hook runs it
#[command(name = "session-end")]
SessionEnd,
/// 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,
},
/// Show or set a setting shared by every clone of this repository
Config {
key: Option<ConfigKey>,
/// The new value; omit it to print the current one
value: Option<u32>,
},
}
#[derive(Clone, Copy, ValueEnum)]
#[clap(rename_all = "kebab-case")]
pub enum ConfigKey {
/// How many minutes a claim holds before reclaim may take it back
LeaseMinutes,
/// How many commits behind HEAD a memory may be before prime marks it old
StaleAfter,
}
#[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,
},
/// Print bash completion that searches issues with fzf; for .bashrc: eval "$(foam setup bash)"
Bash,
}
#[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 {
// a reader that stops early, `foam list | head`, closes the
// pipe; with SIGPIPE ignored, the next println! panics
// instead of ending the process the way head expects
#[cfg(unix)]
unsafe {
// SAFETY: signal(2) with SIG_DFL only changes the
// disposition of one signal in this process, before
// any other thread exists
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
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)
}
}
}
}