Skip to content

Commit 636fab9

Browse files
cailmdaleyclaude
andcommitted
feat: unify ls and find, drop --all, add tag prefix matching
- `-s all` now includes statusless fibers (was silently dropping them) - `ls` accepts optional query arg with -e (exact) and -r (regex) - `-t tag:` prefix matching (trailing colon = prefix mode) - `find` deprecated as alias for `ls -s all <query>` - Drop `--all` flag (redundant with `-s all`) Fixes portolan dashboard showing 2/29 rule-tagged fibers because statusless fibers were excluded by the HasStatus gate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 22f1e65 commit 636fab9

File tree

9 files changed

+146
-155
lines changed

9 files changed

+146
-155
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Fibers are minimal by default. All fields except title are optional.
3939
go build . # build
4040
go test ./... # test
4141
./felt ls # run locally
42-
./felt ls --all # include untracked fibers
42+
./felt ls -s all # include untracked fibers
4343
```
4444

4545
## Releasing

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ The `-o` flag captures *what was learned, decided, or produced*. Closed fibers b
5656

5757
```bash
5858
felt ls -s closed # what's been done
59-
felt find "JWT" # search all fibers
59+
felt ls -s all "JWT" # search all fibers
6060
felt show <id> -d compact # see outcome without full body
6161
```
6262

@@ -136,14 +136,16 @@ felt rm <id> # delete (fails if dependents exist)
136136

137137
```bash
138138
felt ls # tracked fibers (open/active)
139-
felt ls --all # all fibers including untracked
139+
felt ls -s all # all fibers including untracked
140140
felt ls -s closed # by status
141141
felt ls -t backend -t urgent # by tags (AND)
142+
felt ls -s all -t rule: # tag prefix matching
143+
felt ls -s all "query" # search title, body, outcome
144+
felt ls -s all -r "pattern" # regex search
142145
felt ready # open with all deps closed
143146
felt show <id> # full details
144147
felt show <id> -d compact # structured overview
145148
felt tree # dependency tree
146-
felt find <query> # search title, body, outcome
147149
```
148150

149151
### Editing

cmd/edit.go

Lines changed: 9 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,12 @@ import (
44
"fmt"
55
"os"
66
"os/exec"
7-
"regexp"
8-
"strings"
97
"time"
108

119
"github.com/cailmdaley/felt/internal/felt"
1210
"github.com/spf13/cobra"
1311
)
1412

15-
var (
16-
findExact bool
17-
findRegex bool
18-
findStatus string
19-
findTags []string
20-
)
21-
2213
// Edit command flags
2314
var (
2415
editTitle string
@@ -296,111 +287,17 @@ var unlinkCmd = &cobra.Command{
296287
},
297288
}
298289

290+
// find is an alias for "ls -s all <query>" — searches all fibers by default.
299291
var findCmd = &cobra.Command{
300-
Use: "find <query>",
301-
Short: "Search felts",
302-
Long: `Searches felts by title, body, and outcome.
303-
304-
Flags:
305-
--exact/-e Only match felts where title equals query exactly
306-
--regex/-r Treat query as a regular expression
307-
308-
Results are sorted with exact title matches first.`,
309-
Args: cobra.ExactArgs(1),
292+
Use: "find <query>",
293+
Short: "Search felts (alias for ls -s all <query>)",
294+
Long: `Alias for "felt ls -s all <query>". Searches all fibers regardless of status.`,
295+
Args: cobra.ExactArgs(1),
296+
Deprecated: "use 'felt ls -s all <query>' instead",
310297
RunE: func(cmd *cobra.Command, args []string) error {
311-
root, err := felt.FindProjectRoot()
312-
if err != nil {
313-
return fmt.Errorf("not in a felt repository")
314-
}
315-
316-
storage := felt.NewStorage(root)
317-
felts, err := storage.List()
318-
if err != nil {
319-
return err
320-
}
321-
322-
query := args[0]
323-
queryLower := strings.ToLower(query)
324-
325-
// Compile regex if needed
326-
var re *regexp.Regexp
327-
if findRegex {
328-
var err error
329-
re, err = regexp.Compile("(?i)" + query) // case-insensitive
330-
if err != nil {
331-
return fmt.Errorf("invalid regex: %w", err)
332-
}
333-
}
334-
335-
var exactMatches []*felt.Felt
336-
var partialMatches []*felt.Felt
337-
338-
for _, f := range felts {
339-
// Status filter
340-
if findStatus != "" && f.Status != findStatus {
341-
continue
342-
}
343-
// Tag filter (AND logic)
344-
if len(findTags) > 0 {
345-
hasAll := true
346-
for _, tag := range findTags {
347-
if !f.HasTag(tag) {
348-
hasAll = false
349-
break
350-
}
351-
}
352-
if !hasAll {
353-
continue
354-
}
355-
}
356-
357-
titleLower := strings.ToLower(f.Title)
358-
359-
// Check for exact title match (not applicable in regex mode)
360-
if !findRegex && titleLower == queryLower {
361-
exactMatches = append(exactMatches, f)
362-
continue
363-
}
364-
365-
// If --exact flag, skip partial matches
366-
if findExact {
367-
continue
368-
}
369-
370-
// Check for matches (regex or substring)
371-
var matches bool
372-
if findRegex {
373-
matches = re.MatchString(f.Title) ||
374-
re.MatchString(f.Body) ||
375-
re.MatchString(f.Outcome)
376-
} else {
377-
matches = strings.Contains(titleLower, queryLower) ||
378-
strings.Contains(strings.ToLower(f.Body), queryLower) ||
379-
strings.Contains(strings.ToLower(f.Outcome), queryLower)
380-
}
381-
382-
if matches {
383-
partialMatches = append(partialMatches, f)
384-
}
385-
}
386-
387-
// Combine: exact matches first, then partial
388-
allMatches := append(exactMatches, partialMatches...)
389-
390-
if jsonOutput {
391-
return outputJSON(allMatches)
392-
}
393-
394-
if len(allMatches) == 0 {
395-
fmt.Printf("No felts matching %q\n", query)
396-
return nil
397-
}
398-
399-
for _, f := range allMatches {
400-
printFeltTwoLine(f)
401-
}
402-
403-
return nil
298+
// Set ls flags to match find's "search everything" behavior
299+
lsStatus = "all"
300+
return lsCmd.RunE(lsCmd, args)
404301
},
405302
}
406303

@@ -421,10 +318,4 @@ func init() {
421318

422319
// Link command flags
423320
linkCmd.Flags().StringVarP(&linkLabel, "label", "l", "", "Label explaining the dependency")
424-
425-
// Find command flags
426-
findCmd.Flags().BoolVarP(&findExact, "exact", "e", false, "Exact title match only")
427-
findCmd.Flags().BoolVarP(&findRegex, "regex", "r", false, "Treat query as regular expression")
428-
findCmd.Flags().StringVarP(&findStatus, "status", "s", "", "Filter by status (open, active, closed)")
429-
findCmd.Flags().StringArrayVarP(&findTags, "tag", "t", nil, "Filter by tag (repeatable, AND logic)")
430321
}

cmd/hook.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,8 @@ felt edit <id> -s active # enter tracking / mark active
215215
felt edit <id> -s closed -o "outcome" # close with outcome
216216
felt comment <id> "note" # add comment
217217
felt show <id> # full details (-d: title, compact, summary)
218-
felt ls # tracked fibers (open/active)
219-
felt ls --all # all fibers including untracked
220-
felt find "query" # search title/body/outcome
218+
felt ls # open/active; -s all|closed, -t tag:, -n N
219+
felt ls -s all "query" -t tag: # flags compose; -e exact, -r regex
221220
Also: link, unlink, tag, untag, upstream, downstream, tree, ready, rm
222221
` + "```" + `
223222
Statuses: · untracked, ○ open, ◐ active, ● closed

cmd/ls.go

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"regexp"
56
"sort"
67
"strings"
78

@@ -14,18 +15,28 @@ var (
1415
lsTags []string
1516
lsRecent int
1617
lsBody bool
17-
lsAll bool
18+
lsExact bool
19+
lsRegex bool
1820
readyTags []string
1921
)
2022

2123
var lsCmd = &cobra.Command{
22-
Use: "ls",
23-
Short: "List felts",
24-
Long: `Lists tracked felts (those with a status), showing open and active by default.
25-
26-
Use --all to include fibers without status.
27-
Use -s to filter: open, active, closed, or all.`,
28-
Args: cobra.NoArgs,
24+
Use: "ls [query]",
25+
Short: "List and search felts",
26+
Long: `Lists felts, showing open and active by default.
27+
28+
Use -s to filter by status: open, active, closed, or all.
29+
-s all includes fibers without status (everything).
30+
31+
Use -t to filter by tag (AND logic, prefix matching with trailing colon):
32+
-t rule: matches any rule:* tag
33+
-t rule:cosebis_data_vector exact tag match
34+
35+
Optional query searches title, body, and outcome:
36+
felt ls cosebis substring search
37+
felt ls -r "rule:.*data" regex search
38+
felt ls -e "exact title" exact title match`,
39+
Args: cobra.MaximumNArgs(1),
2940
RunE: func(cmd *cobra.Command, args []string) error {
3041
root, err := felt.FindProjectRoot()
3142
if err != nil {
@@ -38,24 +49,45 @@ Use -s to filter: open, active, closed, or all.`,
3849
return err
3950
}
4051

52+
query := ""
53+
if len(args) == 1 {
54+
query = args[0]
55+
}
56+
57+
// Compile regex if needed
58+
var re *regexp.Regexp
59+
if lsRegex && query != "" {
60+
re, err = regexp.Compile("(?i)" + query)
61+
if err != nil {
62+
return fmt.Errorf("invalid regex: %w", err)
63+
}
64+
}
65+
66+
queryLower := strings.ToLower(query)
67+
4168
// Filter
69+
var exactMatches []*felt.Felt
4270
var filtered []*felt.Felt
4371
for _, f := range felts {
44-
// Unless --all, only show fibers with a status
45-
if !lsAll && !f.HasStatus() {
46-
continue
47-
}
48-
49-
// Status filter: default to open+active, "all" shows everything
50-
if lsStatus == "" && !lsAll {
51-
// Default: show open and active only
72+
// Status gate
73+
if lsStatus == "all" {
74+
// -s all: no filtering, include everything
75+
} else if lsStatus != "" {
76+
// Specific status: must match
77+
if f.Status != lsStatus {
78+
continue
79+
}
80+
} else {
81+
// Default: open+active, must have status
82+
if !f.HasStatus() {
83+
continue
84+
}
5285
if f.Status != felt.StatusOpen && f.Status != felt.StatusActive {
5386
continue
5487
}
55-
} else if lsStatus != "" && lsStatus != "all" && f.Status != lsStatus {
56-
continue
5788
}
58-
// Tag filter: must have ALL specified tags (AND logic)
89+
90+
// Tag filter: must have ALL specified tags (AND logic, prefix supported)
5991
if len(lsTags) > 0 {
6092
hasAll := true
6193
for _, tag := range lsTags {
@@ -68,9 +100,45 @@ Use -s to filter: open, active, closed, or all.`,
68100
continue
69101
}
70102
}
103+
104+
// Text search (if query provided)
105+
if query != "" {
106+
titleLower := strings.ToLower(f.Title)
107+
108+
// Exact title match (sorted first)
109+
if !lsRegex && titleLower == queryLower {
110+
exactMatches = append(exactMatches, f)
111+
continue
112+
}
113+
114+
// If --exact, skip partial matches
115+
if lsExact {
116+
continue
117+
}
118+
119+
// Regex or substring match
120+
var matches bool
121+
if lsRegex {
122+
matches = re.MatchString(f.Title) ||
123+
re.MatchString(f.Body) ||
124+
re.MatchString(f.Outcome)
125+
} else {
126+
matches = strings.Contains(titleLower, queryLower) ||
127+
strings.Contains(strings.ToLower(f.Body), queryLower) ||
128+
strings.Contains(strings.ToLower(f.Outcome), queryLower)
129+
}
130+
131+
if !matches {
132+
continue
133+
}
134+
}
135+
71136
filtered = append(filtered, f)
72137
}
73138

139+
// Exact title matches first, then the rest
140+
filtered = append(exactMatches, filtered...)
141+
74142
// Sort: --recent sorts by recency, otherwise by priority then creation
75143
if lsRecent > 0 {
76144
// Sort by most recent activity (closed-at for closed, created-at otherwise)
@@ -89,8 +157,8 @@ Use -s to filter: open, active, closed, or all.`,
89157
if len(filtered) > lsRecent {
90158
filtered = filtered[:lsRecent]
91159
}
92-
} else {
93-
// Default: sort by creation time
160+
} else if query == "" {
161+
// Default: sort by creation time (skip for search results to preserve relevance)
94162
sort.Slice(filtered, func(i, j int) bool {
95163
return filtered[i].CreatedAt.Before(filtered[j].CreatedAt)
96164
})
@@ -108,7 +176,11 @@ Use -s to filter: open, active, closed, or all.`,
108176
}
109177

110178
if len(filtered) == 0 {
111-
fmt.Println("No felts found")
179+
if query != "" {
180+
fmt.Printf("No felts matching %q\n", query)
181+
} else {
182+
fmt.Println("No felts found")
183+
}
112184
return nil
113185
}
114186

@@ -172,10 +244,11 @@ func formatFeltTwoLine(f *felt.Felt) string {
172244
func init() {
173245
rootCmd.AddCommand(lsCmd)
174246
lsCmd.Flags().StringVarP(&lsStatus, "status", "s", "", "Filter by status (open, active, closed, all)")
175-
lsCmd.Flags().BoolVar(&lsAll, "all", false, "Include fibers without status")
176-
lsCmd.Flags().StringArrayVarP(&lsTags, "tag", "t", nil, "Filter by tag (repeatable, AND logic)")
177-
lsCmd.Flags().IntVarP(&lsRecent, "recent", "r", 0, "Show N most recent (by closed-at or created-at)")
247+
lsCmd.Flags().StringArrayVarP(&lsTags, "tag", "t", nil, "Filter by tag (repeatable, AND logic; trailing colon for prefix match)")
248+
lsCmd.Flags().IntVarP(&lsRecent, "recent", "n", 0, "Show N most recent (by closed-at or created-at)")
178249
lsCmd.Flags().BoolVar(&lsBody, "body", false, "Include body field in JSON output")
250+
lsCmd.Flags().BoolVarP(&lsExact, "exact", "e", false, "Exact title match only (with query)")
251+
lsCmd.Flags().BoolVarP(&lsRegex, "regex", "r", false, "Treat query as regular expression")
179252
}
180253

181254
// ready command - open felts with all deps closed

0 commit comments

Comments
 (0)