@procrastivity/clast 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ # clast-registry-lib.bash — projects.json read/write/resolve
2
+ #
3
+ # Sourced after clast-lib.bash and clast-decode-lib.bash. The registry is
4
+ # an append-only JSONL log at $(clast_journal_dir)/projects.json (the
5
+ # ".json" name is historical — semantically it is JSONL). See
6
+ # docs/cli-contract.md#registry-line-in-projectsjson for the on-disk
7
+ # schema and docs/cli-contract.md#clast-registry for the resolution rules.
8
+ # shellcheck shell=bash
9
+ # shellcheck source=lib/clast/clast-lib.bash
10
+ # shellcheck source=lib/clast/clast-decode-lib.bash
11
+
12
+ if [[ -n "${_CLAST_REGISTRY_LIB_SOURCED:-}" ]]; then
13
+ return 0
14
+ fi
15
+ _CLAST_REGISTRY_LIB_SOURCED=1
16
+
17
+ # clast_registry_path — single chokepoint so CLAST_JOURNAL_DIR override
18
+ # redirects every read/write.
19
+ clast_registry_path() {
20
+ printf '%s\n' "$(clast_journal_dir)/projects.json"
21
+ }
22
+
23
+ # clast_registry_list_json — print the registry as a compact JSON array.
24
+ # Missing file → []. Malformed lines are silently dropped (fromjson?).
25
+ clast_registry_list_json() {
26
+ local path
27
+ path="$(clast_registry_path)"
28
+ if [[ ! -f "$path" ]]; then
29
+ printf '[]\n'
30
+ return 0
31
+ fi
32
+ jq -cR 'fromjson?' "$path" \
33
+ | jq -cs 'map(select(type == "object" and .path != null))'
34
+ }
35
+
36
+ # clast_registry_match_remote <remote>
37
+ # Print slug of first line whose .remote == <remote>; return 1 if none.
38
+ # Empty <remote> → return 1 (does not match unset remotes).
39
+ clast_registry_match_remote() {
40
+ local remote="${1:-}"
41
+ if [[ -z "$remote" ]]; then
42
+ return 1
43
+ fi
44
+ local arr slug
45
+ arr="$(clast_registry_list_json)"
46
+ slug="$(jq -r --arg r "$remote" 'map(select(.remote == $r)) | .[0].slug // empty' <<<"$arr")"
47
+ if [[ -z "$slug" ]]; then
48
+ return 1
49
+ fi
50
+ printf '%s\n' "$slug"
51
+ }
52
+
53
+ # clast_registry_resolve <path-or-segment>
54
+ # Print slug on hit, return 1 silently on miss.
55
+ clast_registry_resolve() {
56
+ local input="${1:-}"
57
+ if [[ -z "$input" ]]; then
58
+ return 1
59
+ fi
60
+
61
+ local arr
62
+ arr="$(clast_registry_list_json)"
63
+
64
+ # Segment input: starts with `-`. Decode (possibly ambiguous) then
65
+ # resolve as a path.
66
+ if [[ "$input" == -* ]]; then
67
+ local -a candidates=()
68
+ mapfile -t candidates < <(clast_decode_candidates "$input")
69
+ local c slug
70
+ # Prefer registry-matching candidate over filesystem-resolving one.
71
+ for c in "${candidates[@]}"; do
72
+ slug="$(_clast_registry_lookup_path "$c" "$arr")" || continue
73
+ if [[ -n "$slug" ]]; then
74
+ printf '%s\n' "$slug"
75
+ return 0
76
+ fi
77
+ done
78
+ return 1
79
+ fi
80
+
81
+ # Path input: canonicalize. `realpath -m` resolves non-existent paths
82
+ # (whereami inside a non-git tmpdir). BSD realpath lacks -m; the nix
83
+ # dev shell pulls in GNU coreutils.
84
+ local canon
85
+ canon="$(realpath -m "$input" 2>/dev/null || printf '%s' "$input")"
86
+ local slug
87
+ slug="$(_clast_registry_lookup_path "$canon" "$arr")" || return 1
88
+ if [[ -z "$slug" ]]; then
89
+ return 1
90
+ fi
91
+ printf '%s\n' "$slug"
92
+ }
93
+
94
+ # _clast_registry_lookup_path <path> <registry-json-array>
95
+ # First match wins: scan .path, then .aliases[]. Print slug or empty.
96
+ _clast_registry_lookup_path() {
97
+ local p="$1" arr="$2"
98
+ jq -r --arg p "$p" '
99
+ (map(select(.path == $p)) | .[0].slug)
100
+ // (map(select(.aliases? // [] | index($p) != null)) | .[0].slug)
101
+ // empty
102
+ ' <<<"$arr"
103
+ }
104
+
105
+ # clast_registry_add <path> [--slug NAME] [--remote URL]
106
+ # Append a single JSONL line. Default slug = basename(path). Default
107
+ # remote = `git -C <path> remote get-url origin` (or absent). If the
108
+ # resolved remote matches an existing entry's remote, append a new line
109
+ # carrying that entry's slug and rolling its known paths into aliases.
110
+ # Print the appended JSON on stdout. Exit 2 on bad args, 1 on write fail.
111
+ clast_registry_add() {
112
+ local path="" slug="" remote="" remote_explicit=0
113
+ while [[ $# -gt 0 ]]; do
114
+ case "$1" in
115
+ --slug)
116
+ if [[ $# -lt 2 ]]; then
117
+ clast_log_error "clast_registry_add: --slug requires a value"
118
+ return 2
119
+ fi
120
+ slug="$2"; shift 2 ;;
121
+ --slug=*) slug="${1#*=}"; shift ;;
122
+ --remote)
123
+ if [[ $# -lt 2 ]]; then
124
+ clast_log_error "clast_registry_add: --remote requires a value"
125
+ return 2
126
+ fi
127
+ remote="$2"; remote_explicit=1; shift 2 ;;
128
+ --remote=*) remote="${1#*=}"; remote_explicit=1; shift ;;
129
+ -*)
130
+ clast_log_error "clast_registry_add: unknown flag '$1'"
131
+ return 2
132
+ ;;
133
+ *)
134
+ if [[ -n "$path" ]]; then
135
+ clast_log_error "clast_registry_add: unexpected positional '$1'"
136
+ return 2
137
+ fi
138
+ path="$1"; shift
139
+ ;;
140
+ esac
141
+ done
142
+
143
+ # Reject empty / whitespace-only paths.
144
+ local trimmed="${path//[[:space:]]/}"
145
+ if [[ -z "$trimmed" ]]; then
146
+ clast_log_error "clast_registry_add: <path> is required"
147
+ return 2
148
+ fi
149
+
150
+ # Canonicalize. -m tolerates non-existent paths.
151
+ local canon
152
+ canon="$(realpath -m "$path" 2>/dev/null || printf '%s' "$path")"
153
+
154
+ # Resolve default remote if not given. Tolerate missing git / non-repo.
155
+ if [[ $remote_explicit -eq 0 ]]; then
156
+ if command -v git >/dev/null 2>&1 && [[ -d "$canon" ]]; then
157
+ remote="$(git -C "$canon" remote get-url origin 2>/dev/null || true)"
158
+ fi
159
+ fi
160
+
161
+ # Default slug from basename.
162
+ if [[ -z "$slug" ]]; then
163
+ slug="$(basename "$canon")"
164
+ fi
165
+
166
+ # Aliases roll-up only when remote matched an existing entry.
167
+ local arr matched_slug
168
+ arr="$(clast_registry_list_json)"
169
+ local aliases_json='[]'
170
+ if [[ -n "$remote" ]]; then
171
+ matched_slug="$(jq -r --arg r "$remote" 'map(select(.remote == $r)) | .[0].slug // empty' <<<"$arr")"
172
+ if [[ -n "$matched_slug" ]]; then
173
+ slug="$matched_slug"
174
+ # Collect every known path for this slug (excluding the new one),
175
+ # plus their existing aliases.
176
+ aliases_json="$(jq -c --arg s "$slug" --arg p "$canon" '
177
+ [ .[] | select(.slug == $s) | [.path] + (.aliases // []) ]
178
+ | add // []
179
+ | map(select(. != $p))
180
+ | unique
181
+ ' <<<"$arr")"
182
+ fi
183
+ fi
184
+
185
+ local journal_dir
186
+ journal_dir="$(clast_journal_dir)"
187
+ if ! mkdir -p "$journal_dir"; then
188
+ clast_log_error "clast_registry_add: failed to create '$journal_dir'"
189
+ return 1
190
+ fi
191
+
192
+ local first_seen
193
+ first_seen="$(clast_today)"
194
+
195
+ local line
196
+ line="$(jq -c -n \
197
+ --arg path "$canon" \
198
+ --arg slug "$slug" \
199
+ --arg remote "$remote" \
200
+ --arg first_seen "$first_seen" \
201
+ --argjson aliases "$aliases_json" \
202
+ '{path: $path, slug: $slug, remote: $remote, first_seen: $first_seen}
203
+ | with_entries(select(.value != null and .value != ""))
204
+ | . + {aliases: $aliases}')" || {
205
+ clast_log_error "clast_registry_add: jq failed to build registry line"
206
+ return 1
207
+ }
208
+
209
+ local registry_path
210
+ registry_path="$(clast_registry_path)"
211
+ if ! printf '%s\n' "$line" >>"$registry_path" 2>/dev/null; then
212
+ clast_log_error "clast_registry_add: failed to append to '$registry_path'"
213
+ return 1
214
+ fi
215
+
216
+ printf '%s\n' "$line"
217
+ }
218
+
219
+ # clast_registry_remove <slug>
220
+ # Whole-file rewrite via clast_atomic_write. Return 0 only when ≥ 1
221
+ # line was removed; 1 otherwise. Never touches files other than
222
+ # projects.json.
223
+ clast_registry_remove() {
224
+ local slug="${1:-}"
225
+ if [[ -z "$slug" ]]; then
226
+ clast_log_error "clast_registry_remove: <slug> is required"
227
+ return 2
228
+ fi
229
+ local registry_path
230
+ registry_path="$(clast_registry_path)"
231
+ if [[ ! -f "$registry_path" ]]; then
232
+ return 1
233
+ fi
234
+
235
+ local before after kept
236
+ before="$(jq -cR 'fromjson? | select(.path != null)' "$registry_path" | grep -c . || true)"
237
+ kept="$(jq -cR --arg s "$slug" 'fromjson? | select(.path != null and .slug != $s)' "$registry_path")"
238
+ after="$(printf '%s\n' "$kept" | grep -c . || true)"
239
+ if (( before == after )); then
240
+ return 1
241
+ fi
242
+
243
+ # Trailing newline only when content is non-empty.
244
+ local content=""
245
+ if [[ -n "$kept" ]]; then
246
+ content="$kept"$'\n'
247
+ fi
248
+ if ! clast_atomic_write "$registry_path" "$content"; then
249
+ return 1
250
+ fi
251
+ return 0
252
+ }
File without changes
@@ -0,0 +1,454 @@
1
+ # clast-subcommands/breadcrumb.bash — `clast breadcrumb` write/read/list.
2
+ #
3
+ # Breadcrumbs are one-line in-flight hints stored under
4
+ # $(clast_journal_dir)/breadcrumbs/YYYY-MM-DD-<project>.md
5
+ # with tiny YAML frontmatter and append-only Markdown body lines.
6
+ # shellcheck shell=bash
7
+ # shellcheck source=lib/clast/clast-lib.bash
8
+ # shellcheck source=lib/clast/clast-registry-lib.bash
9
+ # shellcheck source=lib/clast/clast-decode-lib.bash
10
+
11
+ clast_cmd_breadcrumb() {
12
+ local arg saw_read=0 saw_list=0 before_double_dash=1
13
+
14
+ _clast_breadcrumb_strip_json "$@"
15
+ set -- "${_CLAST_BREADCRUMB_JSON_STRIPPED[@]}"
16
+
17
+ for arg in "$@"; do
18
+ case "$arg" in
19
+ -h|--help)
20
+ _clast_breadcrumb_usage
21
+ return 0
22
+ ;;
23
+ esac
24
+ done
25
+
26
+ for arg in "$@"; do
27
+ if (( before_double_dash )) && [[ "$arg" == "--" ]]; then
28
+ before_double_dash=0
29
+ continue
30
+ fi
31
+ if (( ! before_double_dash )); then
32
+ continue
33
+ fi
34
+ case "$arg" in
35
+ --read) saw_read=1 ;;
36
+ --list) saw_list=1 ;;
37
+ esac
38
+ done
39
+
40
+ if (( saw_read && saw_list )); then
41
+ _clast_breadcrumb_err "--read and --list are mutually exclusive" 2
42
+ return 2
43
+ fi
44
+
45
+ if (( saw_list )); then
46
+ _clast_breadcrumb_strip_mode --list "$@"
47
+ _clast_breadcrumb_list "${_CLAST_BREADCRUMB_STRIPPED[@]}"
48
+ elif (( saw_read )); then
49
+ _clast_breadcrumb_strip_mode --read "$@"
50
+ _clast_breadcrumb_read "${_CLAST_BREADCRUMB_STRIPPED[@]}"
51
+ else
52
+ _clast_breadcrumb_write "$@"
53
+ fi
54
+ }
55
+
56
+ _clast_breadcrumb_strip_json() {
57
+ local arg before_double_dash=1
58
+ _CLAST_BREADCRUMB_JSON_STRIPPED=()
59
+ for arg in "$@"; do
60
+ if (( before_double_dash )) && [[ "$arg" == "--" ]]; then
61
+ before_double_dash=0
62
+ _CLAST_BREADCRUMB_JSON_STRIPPED+=("$arg")
63
+ continue
64
+ fi
65
+ if (( before_double_dash )) && [[ "$arg" == "--json" ]]; then
66
+ export CLAST_JSON=1
67
+ continue
68
+ fi
69
+ _CLAST_BREADCRUMB_JSON_STRIPPED+=("$arg")
70
+ done
71
+ }
72
+
73
+ _clast_breadcrumb_usage() {
74
+ cat <<'EOF'
75
+ Usage:
76
+ clast breadcrumb [--project SLUG | --global] [--date DATE] <TEXT>
77
+ clast breadcrumb --read [--project SLUG | --global] [--day DATE]
78
+ clast breadcrumb --list [--day DATE]
79
+
80
+ DATE accepts ISO, today, yesterday, last-week, -Nd, -Nw.
81
+ EOF
82
+ }
83
+
84
+ _clast_breadcrumb_err() {
85
+ local msg="$1" code="${2:-2}"
86
+ if [[ -n "${CLAST_JSON:-}" ]]; then
87
+ jq -cn --arg m "$msg" --argjson c "$code" '{error:$m, code:$c}'
88
+ else
89
+ printf 'clast: breadcrumb: %s\n' "$msg" >&2
90
+ fi
91
+ }
92
+
93
+ _clast_breadcrumb_strip_mode() {
94
+ local strip="$1" arg stripped=0 before_double_dash=1
95
+ shift
96
+ _CLAST_BREADCRUMB_STRIPPED=()
97
+ for arg in "$@"; do
98
+ if (( before_double_dash )) && [[ "$arg" == "--" ]]; then
99
+ before_double_dash=0
100
+ _CLAST_BREADCRUMB_STRIPPED+=("$arg")
101
+ continue
102
+ fi
103
+ if (( before_double_dash )) && (( ! stripped )) && [[ "$arg" == "$strip" ]]; then
104
+ stripped=1
105
+ continue
106
+ fi
107
+ _CLAST_BREADCRUMB_STRIPPED+=("$arg")
108
+ done
109
+ }
110
+
111
+ _clast_breadcrumb_resolve_date() {
112
+ local flag="$1" input="$2" resolved
113
+ if ! resolved="$(clast_parse_date "$input" 2>/dev/null)"; then
114
+ return 2
115
+ fi
116
+ if ! [[ "$resolved" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
117
+ return 2
118
+ fi
119
+ printf '%s\n' "$resolved"
120
+ }
121
+
122
+ _clast_breadcrumb_resolve_scope() {
123
+ local project_filter="$1" scope_global="$2" out_name="$3" resolved_slug reg_json
124
+ if [[ -n "$project_filter" ]]; then
125
+ resolved_slug="$project_filter"
126
+ reg_json="$(clast_registry_list_json)"
127
+ if ! jq -e --arg s "$resolved_slug" 'any(.slug == $s)' <<<"$reg_json" >/dev/null; then
128
+ clast_log_warn "slug '$resolved_slug' not in registry"
129
+ fi
130
+ printf -v "$out_name" '%s' "$resolved_slug"
131
+ return 0
132
+ fi
133
+ if [[ "$scope_global" == "1" ]]; then
134
+ printf -v "$out_name" '%s' "_global"
135
+ return 0
136
+ fi
137
+ if resolved_slug="$(clast_registry_resolve "$PWD" 2>/dev/null)" && [[ -n "$resolved_slug" ]]; then
138
+ printf -v "$out_name" '%s' "$resolved_slug"
139
+ return 0
140
+ fi
141
+
142
+ return 1
143
+ }
144
+
145
+ _clast_breadcrumb_path() {
146
+ local day="$1" slug="$2" journal_dir
147
+ journal_dir="$(realpath -m "$(clast_journal_dir)")"
148
+ printf '%s\n' "$journal_dir/breadcrumbs/$day-$slug.md"
149
+ }
150
+
151
+ _clast_breadcrumb_validate_slug() {
152
+ local slug="$1"
153
+ if [[ -z "$slug" || "$slug" == "." || "$slug" == ".." || "$slug" == *"/"* || "$slug" == *$'\n'* || "$slug" == *$'\r'* ]]; then
154
+ _clast_breadcrumb_err "invalid project slug '$slug'" 2
155
+ return 2
156
+ fi
157
+ }
158
+
159
+ _clast_breadcrumb_line_count() {
160
+ local path="$1" count
161
+ count="$(grep -c '^- ' "$path" 2>/dev/null || true)"
162
+ printf '%s\n' "${count:-0}"
163
+ }
164
+
165
+ _clast_breadcrumb_parse_scope_flag() {
166
+ local flag="$1" project_filter_name="$2" scope_global_name="$3"
167
+ local -n project_filter_ref="$project_filter_name"
168
+ local -n scope_global_ref="$scope_global_name"
169
+ case "$flag" in
170
+ project)
171
+ if [[ "$scope_global_ref" == "1" ]]; then
172
+ _clast_breadcrumb_err "--project and --global are mutually exclusive" 2
173
+ return 2
174
+ fi
175
+ ;;
176
+ global)
177
+ if [[ -n "$project_filter_ref" ]]; then
178
+ _clast_breadcrumb_err "--project and --global are mutually exclusive" 2
179
+ return 2
180
+ fi
181
+ scope_global_ref=1
182
+ ;;
183
+ esac
184
+ }
185
+
186
+ _clast_breadcrumb_write() {
187
+ local project_filter="" scope_global=0 date_input="" resolved_date="" slug="" text="" path line epoch hhmm content line_count rel
188
+ local -a words=()
189
+
190
+ while [[ $# -gt 0 ]]; do
191
+ case "$1" in
192
+ -h|--help)
193
+ _clast_breadcrumb_usage
194
+ return 0
195
+ ;;
196
+ --project)
197
+ if [[ $# -lt 2 ]]; then _clast_breadcrumb_err "--project requires a value" 2; return 2; fi
198
+ _clast_breadcrumb_parse_scope_flag project project_filter scope_global || return $?
199
+ project_filter="$2"
200
+ shift 2
201
+ ;;
202
+ --project=*)
203
+ _clast_breadcrumb_parse_scope_flag project project_filter scope_global || return $?
204
+ project_filter="${1#*=}"
205
+ shift
206
+ ;;
207
+ --global)
208
+ _clast_breadcrumb_parse_scope_flag global project_filter scope_global || return $?
209
+ shift
210
+ ;;
211
+ --date)
212
+ if [[ $# -lt 2 ]]; then _clast_breadcrumb_err "--date requires a value" 2; return 2; fi
213
+ date_input="$2"
214
+ shift 2
215
+ ;;
216
+ --date=*)
217
+ date_input="${1#*=}"
218
+ shift
219
+ ;;
220
+ --)
221
+ shift
222
+ while [[ $# -gt 0 ]]; do words+=("$1"); shift; done
223
+ ;;
224
+ -*)
225
+ _clast_breadcrumb_err "unknown flag '$1'" 2
226
+ return 2
227
+ ;;
228
+ *)
229
+ words+=("$1")
230
+ shift
231
+ ;;
232
+ esac
233
+ done
234
+
235
+ local word
236
+ for word in "${words[@]}"; do
237
+ if [[ "$word" == *$'\n'* || "$word" == *$'\r'* ]]; then
238
+ _clast_breadcrumb_err "text must be a single line" 2
239
+ return 2
240
+ fi
241
+ done
242
+ text="${words[*]}"
243
+ if ! [[ "$text" =~ [^[:space:]] ]]; then
244
+ _clast_breadcrumb_err "missing required argument <TEXT>" 2
245
+ return 2
246
+ fi
247
+
248
+ if [[ -n "$date_input" ]]; then
249
+ if ! resolved_date="$(_clast_breadcrumb_resolve_date --date "$date_input")"; then
250
+ _clast_breadcrumb_err "invalid --date '$date_input'" 2
251
+ return 2
252
+ fi
253
+ else
254
+ resolved_date="$(clast_today)"
255
+ fi
256
+
257
+ if ! _clast_breadcrumb_resolve_scope "$project_filter" "$scope_global" slug; then
258
+ _clast_breadcrumb_err "pwd does not resolve to a registered project (pass --project SLUG or --global)" 1
259
+ return 1
260
+ fi
261
+ _clast_breadcrumb_validate_slug "$slug" || return $?
262
+ path="$(_clast_breadcrumb_path "$resolved_date" "$slug")"
263
+ mkdir -p "$(dirname "$path")" || { _clast_breadcrumb_err "failed to create breadcrumbs directory" 1; return 1; }
264
+
265
+ epoch="${CLAST_NOW_EPOCH:-$(date +%s)}"
266
+ if ! hhmm="$(date -d "@$epoch" +%H:%M 2>/dev/null)"; then
267
+ _clast_breadcrumb_err "failed to format timestamp" 1
268
+ return 1
269
+ fi
270
+ line="- $hhmm — $text"
271
+
272
+ if [[ ! -e "$path" ]]; then
273
+ content=$'---\n'
274
+ content+="date: $resolved_date"$'\n'
275
+ content+="project: $slug"$'\n'
276
+ content+=$'---\n\n'
277
+ content+="$line"$'\n'
278
+ if ! clast_atomic_write "$path" "$content"; then
279
+ _clast_breadcrumb_err "failed to write '$path'" 1
280
+ return 1
281
+ fi
282
+ else
283
+ if [[ -s "$path" ]] && [[ "$(tail -c 1 "$path" | wc -l | tr -d ' ')" == "0" ]]; then
284
+ printf '\n%s\n' "$line" >>"$path" || { _clast_breadcrumb_err "failed to append '$path'" 1; return 1; }
285
+ else
286
+ printf '%s\n' "$line" >>"$path" || { _clast_breadcrumb_err "failed to append '$path'" 1; return 1; }
287
+ fi
288
+ fi
289
+
290
+ line_count="$(_clast_breadcrumb_line_count "$path")"
291
+ if [[ -n "${CLAST_JSON:-}" ]]; then
292
+ jq -cn --arg path "$path" --arg slug "$slug" --arg date "$resolved_date" --argjson n "$line_count" \
293
+ '{path:$path, slug:$slug, date:$date, line_count:$n}'
294
+ elif [[ -n "${CLAST_VERBOSE:-}" ]]; then
295
+ rel="breadcrumbs/$resolved_date-$slug.md"
296
+ printf 'clast: breadcrumb: wrote %s (%s lines)\n' "$rel" "$line_count" >&2
297
+ fi
298
+ }
299
+
300
+ _clast_breadcrumb_read() {
301
+ local project_filter="" scope_global=0 day_input="" resolved_day="" slug="" path
302
+ while [[ $# -gt 0 ]]; do
303
+ case "$1" in
304
+ -h|--help)
305
+ _clast_breadcrumb_usage
306
+ return 0
307
+ ;;
308
+ --project)
309
+ if [[ $# -lt 2 ]]; then _clast_breadcrumb_err "--project requires a value" 2; return 2; fi
310
+ _clast_breadcrumb_parse_scope_flag project project_filter scope_global || return $?
311
+ project_filter="$2"
312
+ shift 2
313
+ ;;
314
+ --project=*)
315
+ _clast_breadcrumb_parse_scope_flag project project_filter scope_global || return $?
316
+ project_filter="${1#*=}"
317
+ shift
318
+ ;;
319
+ --global)
320
+ _clast_breadcrumb_parse_scope_flag global project_filter scope_global || return $?
321
+ shift
322
+ ;;
323
+ --day)
324
+ if [[ $# -lt 2 ]]; then _clast_breadcrumb_err "--day requires a value" 2; return 2; fi
325
+ day_input="$2"
326
+ shift 2
327
+ ;;
328
+ --day=*)
329
+ day_input="${1#*=}"
330
+ shift
331
+ ;;
332
+ --)
333
+ shift
334
+ if [[ $# -gt 0 ]]; then _clast_breadcrumb_err "unexpected arg '$1'" 2; return 2; fi
335
+ ;;
336
+ -*)
337
+ _clast_breadcrumb_err "unknown flag '$1'" 2
338
+ return 2
339
+ ;;
340
+ *)
341
+ _clast_breadcrumb_err "unexpected arg '$1'" 2
342
+ return 2
343
+ ;;
344
+ esac
345
+ done
346
+
347
+ if [[ -n "$day_input" ]]; then
348
+ if ! resolved_day="$(_clast_breadcrumb_resolve_date --day "$day_input")"; then
349
+ _clast_breadcrumb_err "invalid --day '$day_input'" 2
350
+ return 2
351
+ fi
352
+ else
353
+ resolved_day="$(clast_today)"
354
+ fi
355
+ if ! _clast_breadcrumb_resolve_scope "$project_filter" "$scope_global" slug; then
356
+ _clast_breadcrumb_err "pwd does not resolve to a registered project (pass --project SLUG or --global)" 1
357
+ return 1
358
+ fi
359
+ _clast_breadcrumb_validate_slug "$slug" || return $?
360
+ path="$(_clast_breadcrumb_path "$resolved_day" "$slug")"
361
+
362
+ if [[ -f "$path" ]]; then
363
+ if [[ -n "${CLAST_JSON:-}" ]]; then
364
+ jq -n --arg path "$path" --rawfile content "$path" '{path:$path, exists:true, content:$content}'
365
+ elif [[ -z "${CLAST_QUIET:-}" ]]; then
366
+ cat -- "$path"
367
+ fi
368
+ else
369
+ if [[ -n "${CLAST_JSON:-}" ]]; then
370
+ jq -cn --arg path "$path" '{path:$path, exists:false, content:""}'
371
+ fi
372
+ fi
373
+ }
374
+
375
+ _clast_breadcrumb_list() {
376
+ local day_input="" resolved_day="" dir rows_json
377
+ while [[ $# -gt 0 ]]; do
378
+ case "$1" in
379
+ -h|--help)
380
+ _clast_breadcrumb_usage
381
+ return 0
382
+ ;;
383
+ --day)
384
+ if [[ $# -lt 2 ]]; then _clast_breadcrumb_err "--day requires a value" 2; return 2; fi
385
+ day_input="$2"
386
+ shift 2
387
+ ;;
388
+ --day=*)
389
+ day_input="${1#*=}"
390
+ shift
391
+ ;;
392
+ --)
393
+ shift
394
+ if [[ $# -gt 0 ]]; then _clast_breadcrumb_err "unexpected arg '$1'" 2; return 2; fi
395
+ ;;
396
+ -*)
397
+ _clast_breadcrumb_err "unknown flag '$1'" 2
398
+ return 2
399
+ ;;
400
+ *)
401
+ _clast_breadcrumb_err "unexpected arg '$1'" 2
402
+ return 2
403
+ ;;
404
+ esac
405
+ done
406
+
407
+ if [[ -n "$day_input" ]]; then
408
+ if ! resolved_day="$(_clast_breadcrumb_resolve_date --day "$day_input")"; then
409
+ _clast_breadcrumb_err "invalid --day '$day_input'" 2
410
+ return 2
411
+ fi
412
+ else
413
+ resolved_day="$(clast_today)"
414
+ fi
415
+
416
+ dir="$(realpath -m "$(clast_journal_dir)")/breadcrumbs"
417
+ rows_json="$(_clast_breadcrumb_list_rows "$dir" "$resolved_day")"
418
+ if [[ -n "${CLAST_JSON:-}" ]]; then
419
+ jq -n --argjson rows "$rows_json" '$rows'
420
+ return 0
421
+ fi
422
+ if [[ -n "${CLAST_QUIET:-}" ]]; then
423
+ return 0
424
+ fi
425
+
426
+ printf '%-17s %-50s %11s\n' "project" "path" "breadcrumbs"
427
+ jq -r '.[] | [.project, .path, (.line_count|tostring)] | @tsv' <<<"$rows_json" \
428
+ | while IFS=$'\t' read -r project path count; do
429
+ if [[ "$project" == "_global" ]]; then
430
+ project="(global)"
431
+ fi
432
+ printf '%-17s %-50s %11s\n' "$project" "$path" "$count"
433
+ done
434
+ }
435
+
436
+ _clast_breadcrumb_list_rows() {
437
+ local dir="$1" day="$2" file base project count abs
438
+ if [[ ! -d "$dir" ]]; then
439
+ printf '[]\n'
440
+ return 0
441
+ fi
442
+
443
+ find "$dir" -maxdepth 1 -type f -name "$day-*.md" -print0 \
444
+ | while IFS= read -r -d '' file; do
445
+ base="$(basename "$file")"
446
+ project="${base#"$day-"}"
447
+ project="${project%.md}"
448
+ abs="$(realpath -m "$file")"
449
+ count="$(_clast_breadcrumb_line_count "$file")"
450
+ jq -cn --arg project "$project" --arg path "$abs" --argjson line_count "$count" \
451
+ '{project:$project, path:$path, line_count:$line_count}'
452
+ done \
453
+ | jq -cs 'sort_by(.project)'
454
+ }