@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.
- package/.claude-plugin/plugin.json +11 -0
- package/.claude-plugin/skills/day-wakeup/SKILL.md +194 -0
- package/.claude-plugin/skills/wakeup/SKILL.md +115 -0
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/bin/.gitkeep +0 -0
- package/bin/clast +172 -0
- package/examples/config/config.toml.sample +32 -0
- package/examples/cron/clast-snapshot.service +10 -0
- package/examples/cron/clast-snapshot.timer +15 -0
- package/examples/cron/crontab.sample +16 -0
- package/examples/workflows/morning-briefing.md +137 -0
- package/hooks/hooks.json +8 -0
- package/hooks/snapshot.sh +15 -0
- package/lib/clast/.gitkeep +0 -0
- package/lib/clast/clast-decode-lib.bash +184 -0
- package/lib/clast/clast-lib.bash +233 -0
- package/lib/clast/clast-manifest-lib.bash +232 -0
- package/lib/clast/clast-registry-lib.bash +252 -0
- package/lib/clast/clast-subcommands/.gitkeep +0 -0
- package/lib/clast/clast-subcommands/breadcrumb.bash +454 -0
- package/lib/clast/clast-subcommands/doctor.bash +607 -0
- package/lib/clast/clast-subcommands/entries.bash +697 -0
- package/lib/clast/clast-subcommands/projects.bash +309 -0
- package/lib/clast/clast-subcommands/registry.bash +207 -0
- package/lib/clast/clast-subcommands/sessions.bash +275 -0
- package/lib/clast/clast-subcommands/show.bash +283 -0
- package/lib/clast/clast-subcommands/snapshot.bash +294 -0
- package/lib/clast/clast-subcommands/stats.bash +354 -0
- package/lib/clast/clast-subcommands/whereami.bash +108 -0
- package/package.json +39 -0
|
@@ -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
|
+
}
|