Skip to main content

sift_queue/
lib.rs

1pub mod cli;
2pub mod collect;
3pub mod queue;
4pub mod queue_path;
5
6use clap::{builder::StyledStr, Args, Command, CommandFactory, Parser, Subcommand};
7use std::path::PathBuf;
8
9#[derive(Parser)]
10#[command(
11    name = "sq",
12    version,
13    about = "Lightweight task-list CLI with structured sources",
14    long_about = "sq is a lightweight task-list CLI with structured sources.\n\nIt manages tasks in a JSONL file. You can use it directly from the shell or instruct agents to manage them for you."
15)]
16pub struct Cli {
17    /// Path to task file
18    #[arg(
19        short = 'q',
20        long = "queue",
21        value_name = "PATH",
22        global = true,
23        display_order = 900
24    )]
25    pub queue: Option<PathBuf>,
26
27    #[command(subcommand)]
28    pub command: Commands,
29}
30
31pub fn build_cli() -> Command {
32    let mut cmd = Cli::command();
33    let styles = cmd.get_styles();
34    let header = styles.get_header();
35    let literal = styles.get_literal();
36    let root_help = StyledStr::from(format!(
37        "{header}Task file:{header:#}\n  By default, {literal}sq{literal:#} uses {literal}.sift/issues.jsonl{literal:#}\n  Override with {literal}-q, --queue <PATH>{literal:#} or {literal}SQ_QUEUE_PATH=<PATH>{literal:#}\n\n{header}Examples:{header:#}\n  {literal}sq add --title \"Investigate checkout exception\" --description \"Review the pasted error report and identify the failing code path\" --text \"Sentry alert: NoMethodError in Checkout::ApplyDiscount at app/services/checkout/apply_discount.rb:42\"{literal:#}\n  {literal}rg --json -n -C2 'OldApi.call' | sq collect --by-file --title-template \"migrate: {{{{filepath}}}}\" --description \"Migrate OldApi.call to NewApi.call\"{literal:#}\n  {literal}sq list --ready{literal:#}"
38    ));
39    cmd = cmd.after_help(root_help);
40
41    cmd.mut_subcommand("collect", |subcmd| {
42        let styles = subcmd.get_styles();
43        let header = styles.get_header();
44        let literal = styles.get_literal();
45        let help = StyledStr::from(format!(
46            "{header}Examples:{header:#}\n  {literal}rg --json PATTERN | sq collect --by-file --title-template \"review: {{{{filepath}}}}\" --description \"Review ripgrep matches\"{literal:#}\n  {literal}rg --json -n -C2 PATTERN | sq collect --by-file --title-template \"migrate: {{{{filepath}}}}\" --description \"Migrate OldApi.call to NewApi.call\"{literal:#}\n\nPlain-text {literal}rg{literal:#} output is not supported. Pass ripgrep context flags like {literal}-n{literal:#}, {literal}-C2{literal:#}, {literal}-A2{literal:#}, or {literal}-B2{literal:#} to include line numbers and surrounding context in each created text source.\n\n{header}Templates:{header:#}\n  {literal}{{{{filepath}}}}{literal:#}     Full file path for the grouped result\n  {literal}{{{{filename}}}}{literal:#}     Basename of {literal}{{{{filepath}}}}{literal:#}\n  {literal}{{{{match_count}}}}{literal:#}  Number of rg match events collected for the file\n\n  Default title template: {literal}{{{{match_count}}}}:{{{{filepath}}}}{literal:#}"
47        ));
48
49        subcmd.after_help(help)
50    })
51}
52
53#[derive(Subcommand)]
54pub enum Commands {
55    /// Add a new task
56    Add(AddArgs),
57    /// Collect tasks from stdin
58    Collect(CollectArgs),
59    /// List tasks
60    List(ListArgs),
61    /// Show task details
62    Show(ShowArgs),
63    /// Edit an existing task
64    Edit(EditArgs),
65    /// Mark a task as closed
66    Close(StatusArgs),
67    /// Remove a task
68    Rm(RmArgs),
69    /// Output task workflow context for AI agents
70    Prime(PrimeArgs),
71}
72
73#[derive(Parser)]
74pub struct AddArgs {
75    /// Title for the item
76    #[arg(long = "title", value_name = "TITLE", display_order = 1)]
77    pub title: Option<String>,
78
79    /// Description for the item
80    #[arg(long = "description", value_name = "TEXT", display_order = 2)]
81    pub description: Option<String>,
82
83    /// Add diff source (repeatable)
84    #[arg(long = "diff", value_name = "PATH", display_order = 10)]
85    pub diff: Vec<String>,
86
87    /// Add file source (repeatable)
88    #[arg(long = "file", value_name = "PATH", display_order = 11)]
89    pub file: Vec<String>,
90
91    /// Add text source (repeatable)
92    #[arg(long = "text", value_name = "STRING", display_order = 12)]
93    pub text: Vec<String>,
94
95    /// Add directory source (repeatable)
96    #[arg(long = "directory", value_name = "PATH", display_order = 13)]
97    pub directory: Vec<String>,
98
99    /// Read source content from stdin (diff|file|text|directory)
100    #[arg(long = "stdin", value_name = "TYPE", display_order = 14)]
101    pub stdin: Option<String>,
102
103    /// Attach metadata as JSON
104    #[arg(long = "metadata", value_name = "JSON", display_order = 15)]
105    pub metadata: Option<String>,
106
107    /// Comma-separated blocker IDs
108    #[arg(long = "blocked-by", value_name = "IDS", display_order = 16)]
109    pub blocked_by: Option<String>,
110
111    /// Output as JSON
112    #[arg(long = "json", display_order = 17)]
113    pub json: bool,
114}
115
116#[derive(Args)]
117#[command(about = "Collect tasks from stdin")]
118pub struct CollectArgs {
119    /// Title for every created item
120    #[arg(long = "title", value_name = "TITLE", display_order = 1)]
121    pub title: Option<String>,
122
123    /// Description for every created item
124    #[arg(long = "description", value_name = "TEXT", display_order = 2)]
125    pub description: Option<String>,
126
127    /// Split stdin into one item per file
128    #[arg(long = "by-file", display_order = 10)]
129    pub by_file: bool,
130
131    /// Input format: currently only rg-json is supported
132    #[arg(long = "stdin-format", value_name = "FORMAT", display_order = 11)]
133    pub stdin_format: Option<String>,
134
135    /// Template for each created item title
136    #[arg(long = "title-template", value_name = "TEMPLATE", display_order = 12)]
137    pub title_template: Option<String>,
138
139    /// Attach metadata as JSON
140    #[arg(long = "metadata", value_name = "JSON", display_order = 13)]
141    pub metadata: Option<String>,
142
143    /// Comma-separated blocker IDs
144    #[arg(long = "blocked-by", value_name = "IDS", display_order = 14)]
145    pub blocked_by: Option<String>,
146
147    /// Output as JSON
148    #[arg(long = "json", display_order = 15)]
149    pub json: bool,
150}
151
152#[derive(Parser)]
153pub struct ListArgs {
154    /// Filter by status (pending|in_progress|closed)
155    #[arg(long = "status", value_name = "STATUS")]
156    pub status: Option<String>,
157
158    /// Include closed items when status is not explicitly filtered
159    #[arg(long = "all")]
160    pub all: bool,
161
162    /// Output as JSON
163    #[arg(long = "json")]
164    pub json: bool,
165
166    /// jq select expression
167    #[arg(long = "filter", value_name = "EXPR")]
168    pub filter: Option<String>,
169
170    /// jq path expression to sort by
171    #[arg(long = "sort", value_name = "PATH")]
172    pub sort: Option<String>,
173
174    /// Reverse sort order
175    #[arg(long = "reverse")]
176    pub reverse: bool,
177
178    /// Show only ready items (pending and unblocked)
179    #[arg(long = "ready")]
180    pub ready: bool,
181}
182
183#[derive(Parser)]
184pub struct ShowArgs {
185    /// Item ID
186    pub id: Option<String>,
187
188    /// Output as JSON
189    #[arg(long = "json")]
190    pub json: bool,
191}
192
193#[derive(Parser)]
194pub struct EditArgs {
195    /// Item ID
196    pub id: Option<String>,
197
198    /// Set title for the item
199    #[arg(long = "set-title", value_name = "TITLE", display_order = 1)]
200    pub set_title: Option<String>,
201
202    /// Set description for the item
203    #[arg(long = "set-description", value_name = "TEXT", display_order = 2)]
204    pub set_description: Option<String>,
205
206    /// Change status (pending|in_progress|closed)
207    #[arg(long = "set-status", value_name = "STATUS", display_order = 3)]
208    pub set_status: Option<String>,
209
210    /// Add diff source
211    #[arg(long = "add-diff", value_name = "PATH", display_order = 10)]
212    pub add_diff: Vec<String>,
213
214    /// Add file source
215    #[arg(long = "add-file", value_name = "PATH", display_order = 11)]
216    pub add_file: Vec<String>,
217
218    /// Add text source
219    #[arg(long = "add-text", value_name = "STRING", display_order = 12)]
220    pub add_text: Vec<String>,
221
222    /// Add directory source
223    #[arg(long = "add-directory", value_name = "PATH", display_order = 13)]
224    pub add_directory: Vec<String>,
225
226    /// Add transcript source
227    #[arg(long = "add-transcript", value_name = "PATH", display_order = 14)]
228    pub add_transcript: Vec<String>,
229
230    /// Remove source by index (0-based, repeatable)
231    #[arg(long = "rm-source", value_name = "INDEX", display_order = 15)]
232    pub rm_source: Vec<usize>,
233
234    /// Set metadata as JSON (replaces full metadata object)
235    #[arg(long = "set-metadata", value_name = "JSON", display_order = 16)]
236    pub set_metadata: Option<String>,
237
238    /// Merge metadata object as JSON (deep object merge)
239    #[arg(long = "merge-metadata", value_name = "JSON", display_order = 17)]
240    pub merge_metadata: Option<String>,
241
242    /// Set blocker IDs (comma-separated, empty to clear)
243    #[arg(long = "set-blocked-by", value_name = "IDS", display_order = 18)]
244    pub set_blocked_by: Option<String>,
245
246    /// Output as JSON
247    #[arg(long = "json", display_order = 19)]
248    pub json: bool,
249}
250
251#[derive(Parser)]
252pub struct StatusArgs {
253    /// Item ID
254    pub id: Option<String>,
255
256    /// Output as JSON
257    #[arg(long = "json")]
258    pub json: bool,
259}
260
261#[derive(Parser)]
262pub struct RmArgs {
263    /// Item ID
264    pub id: Option<String>,
265
266    /// Output as JSON
267    #[arg(long = "json")]
268    pub json: bool,
269}
270
271#[derive(Parser)]
272pub struct PrimeArgs {
273    /// Force full CLI output
274    #[arg(long = "full")]
275    pub full: bool,
276}