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, Arg, ArgAction, 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().propagate_version(true).disable_version_flag(true).arg(
33        Arg::new("version")
34            .short('v')
35            .long("version")
36            .help("Print version")
37            .action(ArgAction::Version)
38            .global(true),
39    );
40    let styles = cmd.get_styles();
41    let header = styles.get_header();
42    let literal = styles.get_literal();
43    let root_help = StyledStr::from(format!(
44        "{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\" --priority 1 --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\" --priority 2{literal:#}\n  {literal}sq list --ready{literal:#}"
45    ));
46    cmd = cmd.after_help(root_help);
47
48    cmd.mut_subcommand("collect", |subcmd| {
49        let styles = subcmd.get_styles();
50        let header = styles.get_header();
51        let literal = styles.get_literal();
52        let help = StyledStr::from(format!(
53            "{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:#}"
54        ));
55
56        subcmd.after_help(help)
57    })
58}
59
60#[derive(Subcommand)]
61pub enum Commands {
62    /// Add a new task
63    Add(AddArgs),
64    /// Collect tasks from stdin
65    Collect(CollectArgs),
66    /// List tasks
67    List(ListArgs),
68    /// Show task details
69    Show(ShowArgs),
70    /// Edit an existing task
71    Edit(EditArgs),
72    /// Mark a task as closed
73    Close(StatusArgs),
74    /// Remove a task
75    Rm(RmArgs),
76    /// Output task workflow context for AI agents
77    Prime(PrimeArgs),
78}
79
80#[derive(Parser)]
81pub struct AddArgs {
82    /// Title for the item
83    #[arg(long = "title", value_name = "TITLE", display_order = 1)]
84    pub title: Option<String>,
85
86    /// Description for the item
87    #[arg(long = "description", value_name = "TEXT", display_order = 2)]
88    pub description: Option<String>,
89
90    /// Priority (0-4, 0=highest)
91    #[arg(long = "priority", value_name = "PRIORITY", display_order = 3)]
92    pub priority: Option<String>,
93
94    /// Add diff source (repeatable)
95    #[arg(long = "diff", value_name = "PATH", display_order = 10)]
96    pub diff: Vec<String>,
97
98    /// Add file source (repeatable)
99    #[arg(long = "file", value_name = "PATH", display_order = 11)]
100    pub file: Vec<String>,
101
102    /// Add text source (repeatable)
103    #[arg(long = "text", value_name = "STRING", display_order = 12)]
104    pub text: Vec<String>,
105
106    /// Add directory source (repeatable)
107    #[arg(long = "directory", value_name = "PATH", display_order = 13)]
108    pub directory: Vec<String>,
109
110    /// Read source content from stdin (diff|file|text|directory)
111    #[arg(long = "stdin", value_name = "TYPE", display_order = 14)]
112    pub stdin: Option<String>,
113
114    /// Attach metadata as JSON
115    #[arg(long = "metadata", value_name = "JSON", display_order = 15)]
116    pub metadata: Option<String>,
117
118    /// Comma-separated blocker IDs
119    #[arg(long = "blocked-by", value_name = "IDS", display_order = 16)]
120    pub blocked_by: Option<String>,
121
122    /// Output as JSON
123    #[arg(long = "json", display_order = 17)]
124    pub json: bool,
125}
126
127#[derive(Args)]
128#[command(about = "Collect tasks from stdin")]
129pub struct CollectArgs {
130    /// Title for every created item
131    #[arg(long = "title", value_name = "TITLE", display_order = 1)]
132    pub title: Option<String>,
133
134    /// Description for every created item
135    #[arg(long = "description", value_name = "TEXT", display_order = 2)]
136    pub description: Option<String>,
137
138    /// Priority (0-4, 0=highest)
139    #[arg(long = "priority", value_name = "PRIORITY", display_order = 3)]
140    pub priority: Option<String>,
141
142    /// Split stdin into one item per file
143    #[arg(long = "by-file", display_order = 10)]
144    pub by_file: bool,
145
146    /// Input format: currently only rg-json is supported
147    #[arg(long = "stdin-format", value_name = "FORMAT", display_order = 11)]
148    pub stdin_format: Option<String>,
149
150    /// Template for each created item title
151    #[arg(long = "title-template", value_name = "TEMPLATE", display_order = 12)]
152    pub title_template: Option<String>,
153
154    /// Attach metadata as JSON
155    #[arg(long = "metadata", value_name = "JSON", display_order = 13)]
156    pub metadata: Option<String>,
157
158    /// Comma-separated blocker IDs
159    #[arg(long = "blocked-by", value_name = "IDS", display_order = 14)]
160    pub blocked_by: Option<String>,
161
162    /// Output as JSON
163    #[arg(long = "json", display_order = 15)]
164    pub json: bool,
165}
166
167#[derive(Parser)]
168pub struct ListArgs {
169    /// Filter by status (pending|in_progress|closed)
170    #[arg(long = "status", value_name = "STATUS")]
171    pub status: Option<String>,
172
173    /// Include closed items when status is not explicitly filtered
174    #[arg(long = "all")]
175    pub all: bool,
176
177    /// Output as JSON
178    #[arg(long = "json")]
179    pub json: bool,
180
181    /// jq select expression
182    #[arg(long = "filter", value_name = "EXPR")]
183    pub filter: Option<String>,
184
185    /// jq path expression to sort by
186    #[arg(long = "sort", value_name = "PATH")]
187    pub sort: Option<String>,
188
189    /// Reverse sort order
190    #[arg(long = "reverse")]
191    pub reverse: bool,
192
193    /// Show only ready items (pending and unblocked)
194    #[arg(long = "ready")]
195    pub ready: bool,
196}
197
198#[derive(Parser)]
199pub struct ShowArgs {
200    /// Item ID
201    pub id: Option<String>,
202
203    /// Output as JSON
204    #[arg(long = "json")]
205    pub json: bool,
206}
207
208#[derive(Parser)]
209pub struct EditArgs {
210    /// Item ID
211    pub id: Option<String>,
212
213    /// Set title for the item
214    #[arg(long = "set-title", value_name = "TITLE", display_order = 1)]
215    pub set_title: Option<String>,
216
217    /// Set description for the item
218    #[arg(long = "set-description", value_name = "TEXT", display_order = 2)]
219    pub set_description: Option<String>,
220
221    /// Change status (pending|in_progress|closed)
222    #[arg(long = "set-status", value_name = "STATUS", display_order = 3)]
223    pub set_status: Option<String>,
224
225    /// Set priority (0-4, 0=highest)
226    #[arg(long = "set-priority", value_name = "PRIORITY", display_order = 4)]
227    pub set_priority: Option<String>,
228
229    /// Clear priority
230    #[arg(long = "clear-priority", display_order = 5)]
231    pub clear_priority: bool,
232
233    /// Add diff source
234    #[arg(long = "add-diff", value_name = "PATH", display_order = 10)]
235    pub add_diff: Vec<String>,
236
237    /// Add file source
238    #[arg(long = "add-file", value_name = "PATH", display_order = 11)]
239    pub add_file: Vec<String>,
240
241    /// Add text source
242    #[arg(long = "add-text", value_name = "STRING", display_order = 12)]
243    pub add_text: Vec<String>,
244
245    /// Add directory source
246    #[arg(long = "add-directory", value_name = "PATH", display_order = 13)]
247    pub add_directory: Vec<String>,
248
249    /// Add transcript source
250    #[arg(long = "add-transcript", value_name = "PATH", display_order = 14)]
251    pub add_transcript: Vec<String>,
252
253    /// Remove source by index (0-based, repeatable)
254    #[arg(long = "rm-source", value_name = "INDEX", display_order = 15)]
255    pub rm_source: Vec<usize>,
256
257    /// Set metadata as JSON (replaces full metadata object)
258    #[arg(long = "set-metadata", value_name = "JSON", display_order = 16)]
259    pub set_metadata: Option<String>,
260
261    /// Merge metadata object as JSON (deep object merge)
262    #[arg(long = "merge-metadata", value_name = "JSON", display_order = 17)]
263    pub merge_metadata: Option<String>,
264
265    /// Set blocker IDs (comma-separated, empty to clear)
266    #[arg(long = "set-blocked-by", value_name = "IDS", display_order = 18)]
267    pub set_blocked_by: Option<String>,
268
269    /// Output as JSON
270    #[arg(long = "json", display_order = 19)]
271    pub json: bool,
272}
273
274#[derive(Parser)]
275pub struct StatusArgs {
276    /// Item ID
277    pub id: Option<String>,
278
279    /// Output as JSON
280    #[arg(long = "json")]
281    pub json: bool,
282}
283
284#[derive(Parser)]
285pub struct RmArgs {
286    /// Item ID
287    pub id: Option<String>,
288
289    /// Output as JSON
290    #[arg(long = "json")]
291    pub json: bool,
292}
293
294#[derive(Parser)]
295pub struct PrimeArgs {
296    /// Force full CLI output
297    #[arg(long = "full")]
298    pub full: bool,
299}