@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,697 @@
|
|
|
1
|
+
# clast-subcommands/entries.bash — `clast entries` list/read/write.
|
|
2
|
+
#
|
|
3
|
+
# Curated journal entries live at
|
|
4
|
+
# $(clast_journal_dir)/entries/YYYY-MM-DD-HHMM-<project-slug>-<session-slug>.md
|
|
5
|
+
# as Markdown files with a YAML frontmatter block. See
|
|
6
|
+
# docs/cli-contract.md#clast-entries and docs/cli-contract.md#entry-frontmatter.
|
|
7
|
+
# shellcheck shell=bash
|
|
8
|
+
# shellcheck source=lib/clast/clast-lib.bash
|
|
9
|
+
# shellcheck source=lib/clast/clast-manifest-lib.bash
|
|
10
|
+
# shellcheck source=lib/clast/clast-registry-lib.bash
|
|
11
|
+
# shellcheck source=lib/clast/clast-decode-lib.bash
|
|
12
|
+
|
|
13
|
+
_clast_entries_usage() {
|
|
14
|
+
cat <<'EOF'
|
|
15
|
+
Usage:
|
|
16
|
+
clast entries [--day DATE] [--since DATE] [--until DATE]
|
|
17
|
+
[--project SLUG] [--tag TAG]... [--limit N]
|
|
18
|
+
clast entries read <entry-path-or-basename>
|
|
19
|
+
clast entries write --session SESSION_ID --slug SESSION_SLUG
|
|
20
|
+
[--tags TAG,TAG,...] [--title TITLE]
|
|
21
|
+
(--body-from FILE | --body-stdin)
|
|
22
|
+
|
|
23
|
+
DATE accepts ISO, today, yesterday, last-week, -Nd, -Nw.
|
|
24
|
+
Tags are AND-intersection when --tag is repeated.
|
|
25
|
+
EOF
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_clast_entries_err() {
|
|
29
|
+
local msg="$1" code="${2:-2}"
|
|
30
|
+
if [[ -n "${CLAST_JSON:-}" ]]; then
|
|
31
|
+
jq -cn --arg m "$msg" --argjson c "$code" '{error:$m, code:$c}'
|
|
32
|
+
else
|
|
33
|
+
clast_log_error "entries: $msg"
|
|
34
|
+
fi
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
clast_cmd_entries() {
|
|
38
|
+
local first="${1:-}"
|
|
39
|
+
case "$first" in
|
|
40
|
+
-h|--help)
|
|
41
|
+
_clast_entries_usage
|
|
42
|
+
return 0
|
|
43
|
+
;;
|
|
44
|
+
read)
|
|
45
|
+
shift
|
|
46
|
+
_clast_entries_read "$@"
|
|
47
|
+
;;
|
|
48
|
+
write)
|
|
49
|
+
shift
|
|
50
|
+
_clast_entries_write "$@"
|
|
51
|
+
;;
|
|
52
|
+
list)
|
|
53
|
+
shift
|
|
54
|
+
_clast_entries_list "$@"
|
|
55
|
+
;;
|
|
56
|
+
""|-*)
|
|
57
|
+
_clast_entries_list "$@"
|
|
58
|
+
;;
|
|
59
|
+
*)
|
|
60
|
+
_clast_entries_err "unknown subcommand '$first'"
|
|
61
|
+
return 2
|
|
62
|
+
;;
|
|
63
|
+
esac
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Frontmatter helpers
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
# _clast_entries_read_frontmatter <path>
|
|
71
|
+
# Emit raw frontmatter lines (between the first two `---` fences) to stdout.
|
|
72
|
+
_clast_entries_read_frontmatter() {
|
|
73
|
+
local path="$1"
|
|
74
|
+
awk '
|
|
75
|
+
BEGIN { in_fm = 0; seen = 0 }
|
|
76
|
+
/^---[[:space:]]*$/ {
|
|
77
|
+
if (!seen) { in_fm = 1; seen = 1; next }
|
|
78
|
+
if (in_fm) { exit }
|
|
79
|
+
}
|
|
80
|
+
in_fm { print }
|
|
81
|
+
' "$path"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# _clast_entries_extract_title <path>
|
|
85
|
+
# Look for `# Session: <title>` as the first non-blank body line.
|
|
86
|
+
_clast_entries_extract_title() {
|
|
87
|
+
awk '
|
|
88
|
+
BEGIN { in_fm = 0; seen = 0; past = 0 }
|
|
89
|
+
/^---[[:space:]]*$/ {
|
|
90
|
+
if (!seen) { in_fm = 1; seen = 1; next }
|
|
91
|
+
if (in_fm) { in_fm = 0; past = 1; next }
|
|
92
|
+
}
|
|
93
|
+
in_fm { next }
|
|
94
|
+
past {
|
|
95
|
+
if ($0 ~ /^[[:space:]]*$/) next
|
|
96
|
+
if (sub(/^# Session: /, "")) { print; exit }
|
|
97
|
+
exit
|
|
98
|
+
}
|
|
99
|
+
' "$1"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# _clast_entries_unquote <string>
|
|
103
|
+
# Strip surrounding double quotes and unescape \", \\, \n.
|
|
104
|
+
_clast_entries_unquote() {
|
|
105
|
+
local v="$1"
|
|
106
|
+
if [[ "${v:0:1}" == '"' && "${v: -1}" == '"' && ${#v} -ge 2 ]]; then
|
|
107
|
+
v="${v:1:${#v}-2}"
|
|
108
|
+
# Process escapes in order: \\ → placeholder, \" → ", \n → LF, placeholder → \
|
|
109
|
+
v="${v//\\\\/$'\x01'}"
|
|
110
|
+
v="${v//\\\"/\"}"
|
|
111
|
+
v="${v//\\n/$'\n'}"
|
|
112
|
+
v="${v//$'\x01'/\\}"
|
|
113
|
+
fi
|
|
114
|
+
printf '%s' "$v"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# YAML emit helpers
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# _clast_entries_yaml_string <value>
|
|
122
|
+
# Emit a YAML scalar. Bare when safe; double-quoted otherwise.
|
|
123
|
+
_clast_entries_yaml_string() {
|
|
124
|
+
local v="$1"
|
|
125
|
+
if [[ -z "$v" ]]; then
|
|
126
|
+
printf '""'
|
|
127
|
+
return 0
|
|
128
|
+
fi
|
|
129
|
+
if [[ "$v" =~ ^[A-Za-z0-9._/@+-][A-Za-z0-9._/@+-]*$ ]]; then
|
|
130
|
+
printf '%s' "$v"
|
|
131
|
+
return 0
|
|
132
|
+
fi
|
|
133
|
+
local esc="${v//\\/\\\\}"
|
|
134
|
+
esc="${esc//\"/\\\"}"
|
|
135
|
+
esc="${esc//$'\n'/\\n}"
|
|
136
|
+
printf '"%s"' "$esc"
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Time helpers
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
_clast_entries_now_hhmm() {
|
|
144
|
+
local epoch
|
|
145
|
+
epoch="$(_clast_now_epoch)"
|
|
146
|
+
date -d "@$epoch" +%H:%M
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_clast_entries_now_hhmm_compact() {
|
|
150
|
+
local epoch
|
|
151
|
+
epoch="$(_clast_now_epoch)"
|
|
152
|
+
date -d "@$epoch" +%H%M
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# list
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
_clast_entries_list() {
|
|
160
|
+
local day_filter="" since_date="" until_date=""
|
|
161
|
+
local project_filter="" limit=""
|
|
162
|
+
local -a tag_filters=()
|
|
163
|
+
|
|
164
|
+
while [[ $# -gt 0 ]]; do
|
|
165
|
+
case "$1" in
|
|
166
|
+
--day)
|
|
167
|
+
if [[ $# -lt 2 ]]; then _clast_entries_err "--day requires a value"; return 2; fi
|
|
168
|
+
if ! day_filter="$(clast_parse_date "$2" 2>/dev/null)"; then
|
|
169
|
+
_clast_entries_err "invalid date '$2'"; return 2
|
|
170
|
+
fi
|
|
171
|
+
shift 2 ;;
|
|
172
|
+
--day=*)
|
|
173
|
+
if ! day_filter="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
|
|
174
|
+
_clast_entries_err "invalid date '${1#*=}'"; return 2
|
|
175
|
+
fi
|
|
176
|
+
shift ;;
|
|
177
|
+
--since)
|
|
178
|
+
if [[ $# -lt 2 ]]; then _clast_entries_err "--since requires a value"; return 2; fi
|
|
179
|
+
if ! since_date="$(clast_parse_date "$2" 2>/dev/null)"; then
|
|
180
|
+
_clast_entries_err "invalid date '$2'"; return 2
|
|
181
|
+
fi
|
|
182
|
+
shift 2 ;;
|
|
183
|
+
--since=*)
|
|
184
|
+
if ! since_date="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
|
|
185
|
+
_clast_entries_err "invalid date '${1#*=}'"; return 2
|
|
186
|
+
fi
|
|
187
|
+
shift ;;
|
|
188
|
+
--until)
|
|
189
|
+
if [[ $# -lt 2 ]]; then _clast_entries_err "--until requires a value"; return 2; fi
|
|
190
|
+
if ! until_date="$(clast_parse_date "$2" 2>/dev/null)"; then
|
|
191
|
+
_clast_entries_err "invalid date '$2'"; return 2
|
|
192
|
+
fi
|
|
193
|
+
shift 2 ;;
|
|
194
|
+
--until=*)
|
|
195
|
+
if ! until_date="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
|
|
196
|
+
_clast_entries_err "invalid date '${1#*=}'"; return 2
|
|
197
|
+
fi
|
|
198
|
+
shift ;;
|
|
199
|
+
--project)
|
|
200
|
+
if [[ $# -lt 2 ]]; then _clast_entries_err "--project requires a value"; return 2; fi
|
|
201
|
+
project_filter="$2"; shift 2 ;;
|
|
202
|
+
--project=*)
|
|
203
|
+
project_filter="${1#*=}"; shift ;;
|
|
204
|
+
--tag)
|
|
205
|
+
if [[ $# -lt 2 ]]; then _clast_entries_err "--tag requires a value"; return 2; fi
|
|
206
|
+
tag_filters+=("$2"); shift 2 ;;
|
|
207
|
+
--tag=*)
|
|
208
|
+
tag_filters+=("${1#*=}"); shift ;;
|
|
209
|
+
--limit)
|
|
210
|
+
if [[ $# -lt 2 ]]; then _clast_entries_err "--limit requires a value"; return 2; fi
|
|
211
|
+
if ! [[ "$2" =~ ^[1-9][0-9]*$ ]]; then
|
|
212
|
+
_clast_entries_err "--limit must be a positive integer"; return 2
|
|
213
|
+
fi
|
|
214
|
+
limit="$2"; shift 2 ;;
|
|
215
|
+
--limit=*)
|
|
216
|
+
local v="${1#*=}"
|
|
217
|
+
if ! [[ "$v" =~ ^[1-9][0-9]*$ ]]; then
|
|
218
|
+
_clast_entries_err "--limit must be a positive integer"; return 2
|
|
219
|
+
fi
|
|
220
|
+
limit="$v"; shift ;;
|
|
221
|
+
-h|--help) _clast_entries_usage; return 0 ;;
|
|
222
|
+
--) shift; break ;;
|
|
223
|
+
-*) _clast_entries_err "unknown flag '$1'"; return 2 ;;
|
|
224
|
+
*) _clast_entries_err "unexpected positional '$1'"; return 2 ;;
|
|
225
|
+
esac
|
|
226
|
+
done
|
|
227
|
+
|
|
228
|
+
if [[ -n "$day_filter" && ( -n "$since_date" || -n "$until_date" ) ]]; then
|
|
229
|
+
_clast_entries_err "--day is mutually exclusive with --since/--until"
|
|
230
|
+
return 2
|
|
231
|
+
fi
|
|
232
|
+
|
|
233
|
+
local journal_dir entries_dir
|
|
234
|
+
journal_dir="$(clast_journal_dir)"
|
|
235
|
+
entries_dir="$journal_dir/entries"
|
|
236
|
+
|
|
237
|
+
local -a rows=()
|
|
238
|
+
if [[ -d "$entries_dir" ]]; then
|
|
239
|
+
local f
|
|
240
|
+
while IFS= read -r f; do
|
|
241
|
+
[[ -z "$f" ]] && continue
|
|
242
|
+
_clast_entries_list_consider "$f" "$day_filter" "$since_date" "$until_date" \
|
|
243
|
+
"$project_filter" "${#tag_filters[@]}" "${tag_filters[@]+"${tag_filters[@]}"}" \
|
|
244
|
+
&& rows+=("$_CLAST_ENTRY_ROW_JSON")
|
|
245
|
+
done < <(find "$entries_dir" -mindepth 1 -maxdepth 1 -type f -name '*.md' 2>/dev/null | sort)
|
|
246
|
+
fi
|
|
247
|
+
|
|
248
|
+
local rows_json='[]'
|
|
249
|
+
if (( ${#rows[@]} > 0 )); then
|
|
250
|
+
rows_json="$(printf '%s\n' "${rows[@]}" | jq -cs 'sort_by(.date + "T" + .time) | reverse')"
|
|
251
|
+
fi
|
|
252
|
+
if [[ -n "$limit" ]]; then
|
|
253
|
+
rows_json="$(jq -c --argjson n "$limit" '.[0:$n]' <<<"$rows_json")"
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
if [[ -n "${CLAST_JSON:-}" ]]; then
|
|
257
|
+
printf '%s\n' "$rows_json"
|
|
258
|
+
return 0
|
|
259
|
+
fi
|
|
260
|
+
|
|
261
|
+
if [[ -n "${CLAST_QUIET:-}" ]]; then
|
|
262
|
+
return 0
|
|
263
|
+
fi
|
|
264
|
+
|
|
265
|
+
printf '%-11s %-6s %-17s %-29s %s\n' \
|
|
266
|
+
"date" "time" "project" "slug" "tags"
|
|
267
|
+
|
|
268
|
+
local n i row r_date r_time r_project r_slug r_tags tags_disp
|
|
269
|
+
n="$(jq 'length' <<<"$rows_json")"
|
|
270
|
+
for (( i = 0; i < n; i++ )); do
|
|
271
|
+
row="$(jq -c ".[$i]" <<<"$rows_json")"
|
|
272
|
+
r_date="$(jq -r '.date // ""' <<<"$row")"
|
|
273
|
+
r_time="$(jq -r '.time // ""' <<<"$row")"
|
|
274
|
+
r_project="$(jq -r '.project // ""' <<<"$row")"
|
|
275
|
+
r_slug="$(jq -r '.session_slug // ""' <<<"$row")"
|
|
276
|
+
r_tags="$(jq -r '.tags // [] | join(",")' <<<"$row")"
|
|
277
|
+
tags_disp="$r_tags"
|
|
278
|
+
if (( ${#tags_disp} > 30 )); then
|
|
279
|
+
tags_disp="${tags_disp:0:29}…"
|
|
280
|
+
fi
|
|
281
|
+
printf '%-11s %-6s %-17s %-29s %s\n' \
|
|
282
|
+
"$r_date" "$r_time" "$r_project" "$r_slug" "$tags_disp"
|
|
283
|
+
done
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
# _clast_entries_list_consider <file> <day> <since> <until> <project> <tag_count> <tags...>
|
|
287
|
+
# On match, set _CLAST_ENTRY_ROW_JSON globally and return 0. On miss, return 1.
|
|
288
|
+
_clast_entries_list_consider() {
|
|
289
|
+
local file="$1" day="$2" since="$3" until="$4" project="$5" tag_count="$6"
|
|
290
|
+
shift 6
|
|
291
|
+
local -a wanted_tags=()
|
|
292
|
+
if (( tag_count > 0 )); then
|
|
293
|
+
wanted_tags=("$@")
|
|
294
|
+
fi
|
|
295
|
+
|
|
296
|
+
# Parse frontmatter.
|
|
297
|
+
local fm_date="" fm_time="" fm_day_bucket="" fm_project=""
|
|
298
|
+
local fm_session_id="" fm_session_slug="" fm_branch=""
|
|
299
|
+
local fm_tags_raw=""
|
|
300
|
+
local line key val
|
|
301
|
+
while IFS= read -r line; do
|
|
302
|
+
[[ -z "$line" ]] && continue
|
|
303
|
+
key="${line%%:*}"
|
|
304
|
+
val="${line#*:}"
|
|
305
|
+
# Trim leading whitespace from val.
|
|
306
|
+
val="${val#"${val%%[![:space:]]*}"}"
|
|
307
|
+
# Trim trailing whitespace.
|
|
308
|
+
val="${val%"${val##*[![:space:]]}"}"
|
|
309
|
+
case "$key" in
|
|
310
|
+
date) fm_date="$(_clast_entries_unquote "$val")" ;;
|
|
311
|
+
time) fm_time="$(_clast_entries_unquote "$val")" ;;
|
|
312
|
+
day_bucket) fm_day_bucket="$(_clast_entries_unquote "$val")" ;;
|
|
313
|
+
project) fm_project="$(_clast_entries_unquote "$val")" ;;
|
|
314
|
+
session_id) fm_session_id="$(_clast_entries_unquote "$val")" ;;
|
|
315
|
+
session_slug) fm_session_slug="$(_clast_entries_unquote "$val")" ;;
|
|
316
|
+
branch)
|
|
317
|
+
if [[ "$val" != "null" ]]; then
|
|
318
|
+
fm_branch="$(_clast_entries_unquote "$val")"
|
|
319
|
+
fi
|
|
320
|
+
;;
|
|
321
|
+
tags) fm_tags_raw="$val" ;;
|
|
322
|
+
esac
|
|
323
|
+
done < <(_clast_entries_read_frontmatter "$file")
|
|
324
|
+
|
|
325
|
+
# Window filter on day_bucket (fall back to date if day_bucket missing).
|
|
326
|
+
local bucket="${fm_day_bucket:-$fm_date}"
|
|
327
|
+
if [[ -n "$day" && "$bucket" != "$day" ]]; then return 1; fi
|
|
328
|
+
if [[ -n "$since" && "$bucket" < "$since" ]]; then return 1; fi
|
|
329
|
+
if [[ -n "$until" && "$bucket" > "$until" ]]; then return 1; fi
|
|
330
|
+
|
|
331
|
+
if [[ -n "$project" && "$project" != "$fm_project" ]]; then return 1; fi
|
|
332
|
+
|
|
333
|
+
# Parse tags array.
|
|
334
|
+
local -a tags_arr=()
|
|
335
|
+
if [[ "$fm_tags_raw" == "["*"]" ]]; then
|
|
336
|
+
local inner="${fm_tags_raw#[}"
|
|
337
|
+
inner="${inner%]}"
|
|
338
|
+
if [[ -n "$inner" ]]; then
|
|
339
|
+
local IFS=','
|
|
340
|
+
read -r -a tags_arr <<<"$inner"
|
|
341
|
+
unset IFS
|
|
342
|
+
local k t
|
|
343
|
+
for k in "${!tags_arr[@]}"; do
|
|
344
|
+
t="${tags_arr[$k]}"
|
|
345
|
+
t="${t#"${t%%[![:space:]]*}"}"
|
|
346
|
+
t="${t%"${t##*[![:space:]]}"}"
|
|
347
|
+
tags_arr[k]="$t"
|
|
348
|
+
done
|
|
349
|
+
fi
|
|
350
|
+
fi
|
|
351
|
+
|
|
352
|
+
# AND-intersection of tag filters.
|
|
353
|
+
if (( ${#wanted_tags[@]} > 0 )); then
|
|
354
|
+
local want have ok
|
|
355
|
+
for want in "${wanted_tags[@]}"; do
|
|
356
|
+
ok=0
|
|
357
|
+
for have in "${tags_arr[@]+"${tags_arr[@]}"}"; do
|
|
358
|
+
if [[ "$have" == "$want" ]]; then ok=1; break; fi
|
|
359
|
+
done
|
|
360
|
+
if (( ok == 0 )); then return 1; fi
|
|
361
|
+
done
|
|
362
|
+
fi
|
|
363
|
+
|
|
364
|
+
local title
|
|
365
|
+
title="$(_clast_entries_extract_title "$file")"
|
|
366
|
+
|
|
367
|
+
# Build the JSON row.
|
|
368
|
+
local tags_json='[]'
|
|
369
|
+
if (( ${#tags_arr[@]} > 0 )); then
|
|
370
|
+
tags_json="$(printf '%s\n' "${tags_arr[@]}" | jq -R . | jq -cs .)"
|
|
371
|
+
fi
|
|
372
|
+
|
|
373
|
+
_CLAST_ENTRY_ROW_JSON="$(jq -cn \
|
|
374
|
+
--arg path "$file" \
|
|
375
|
+
--arg date "$fm_date" \
|
|
376
|
+
--arg time "$fm_time" \
|
|
377
|
+
--arg day_bucket "$bucket" \
|
|
378
|
+
--arg project "$fm_project" \
|
|
379
|
+
--arg session_id "$fm_session_id" \
|
|
380
|
+
--arg session_slug "$fm_session_slug" \
|
|
381
|
+
--arg branch "$fm_branch" \
|
|
382
|
+
--argjson tags "$tags_json" \
|
|
383
|
+
--arg title "$title" \
|
|
384
|
+
'{
|
|
385
|
+
path: $path,
|
|
386
|
+
date: $date,
|
|
387
|
+
time: $time,
|
|
388
|
+
day_bucket: $day_bucket,
|
|
389
|
+
project: $project,
|
|
390
|
+
session_id: $session_id,
|
|
391
|
+
session_slug: $session_slug,
|
|
392
|
+
branch: (if $branch == "" then null else $branch end),
|
|
393
|
+
tags: $tags,
|
|
394
|
+
title: (if $title == "" then null else $title end)
|
|
395
|
+
}')"
|
|
396
|
+
return 0
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
# read
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
_clast_entries_read() {
|
|
404
|
+
local arg=""
|
|
405
|
+
while [[ $# -gt 0 ]]; do
|
|
406
|
+
case "$1" in
|
|
407
|
+
-h|--help) _clast_entries_usage; return 0 ;;
|
|
408
|
+
--) shift; break ;;
|
|
409
|
+
-*) _clast_entries_err "read: unknown flag '$1'"; return 2 ;;
|
|
410
|
+
*)
|
|
411
|
+
if [[ -n "$arg" ]]; then
|
|
412
|
+
_clast_entries_err "read: unexpected positional '$1'"; return 2
|
|
413
|
+
fi
|
|
414
|
+
arg="$1"; shift ;;
|
|
415
|
+
esac
|
|
416
|
+
done
|
|
417
|
+
|
|
418
|
+
if [[ -z "$arg" ]]; then
|
|
419
|
+
_clast_entries_err "read: missing <entry-path>"
|
|
420
|
+
return 2
|
|
421
|
+
fi
|
|
422
|
+
|
|
423
|
+
local resolved
|
|
424
|
+
if [[ "$arg" == /* && -f "$arg" ]]; then
|
|
425
|
+
resolved="$arg"
|
|
426
|
+
else
|
|
427
|
+
resolved="$(clast_journal_dir)/entries/$arg"
|
|
428
|
+
fi
|
|
429
|
+
|
|
430
|
+
if [[ ! -f "$resolved" ]]; then
|
|
431
|
+
_clast_entries_err "read: not found '$arg'" 1
|
|
432
|
+
return 1
|
|
433
|
+
fi
|
|
434
|
+
|
|
435
|
+
if [[ -n "${CLAST_JSON:-}" ]]; then
|
|
436
|
+
local content
|
|
437
|
+
content="$(cat -- "$resolved")"
|
|
438
|
+
jq -cn --arg path "$resolved" --arg content "$content" \
|
|
439
|
+
'{path:$path, content:$content}'
|
|
440
|
+
return 0
|
|
441
|
+
fi
|
|
442
|
+
|
|
443
|
+
cat -- "$resolved"
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
# write
|
|
448
|
+
# ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
_clast_entries_write() {
|
|
451
|
+
local session_id="" slug="" tags_csv="" title=""
|
|
452
|
+
local body_from="" body_stdin=0
|
|
453
|
+
local tags_explicit=0
|
|
454
|
+
|
|
455
|
+
while [[ $# -gt 0 ]]; do
|
|
456
|
+
case "$1" in
|
|
457
|
+
--session)
|
|
458
|
+
if [[ $# -lt 2 ]]; then _clast_entries_err "write: --session requires a value"; return 2; fi
|
|
459
|
+
session_id="$2"; shift 2 ;;
|
|
460
|
+
--session=*) session_id="${1#*=}"; shift ;;
|
|
461
|
+
--slug)
|
|
462
|
+
if [[ $# -lt 2 ]]; then _clast_entries_err "write: --slug requires a value"; return 2; fi
|
|
463
|
+
slug="$2"; shift 2 ;;
|
|
464
|
+
--slug=*) slug="${1#*=}"; shift ;;
|
|
465
|
+
--tags)
|
|
466
|
+
if [[ $# -lt 2 ]]; then _clast_entries_err "write: --tags requires a value"; return 2; fi
|
|
467
|
+
tags_csv="$2"; tags_explicit=1; shift 2 ;;
|
|
468
|
+
--tags=*) tags_csv="${1#*=}"; tags_explicit=1; shift ;;
|
|
469
|
+
--title)
|
|
470
|
+
if [[ $# -lt 2 ]]; then _clast_entries_err "write: --title requires a value"; return 2; fi
|
|
471
|
+
title="$2"; shift 2 ;;
|
|
472
|
+
--title=*) title="${1#*=}"; shift ;;
|
|
473
|
+
--body-from)
|
|
474
|
+
if [[ $# -lt 2 ]]; then _clast_entries_err "write: --body-from requires a value"; return 2; fi
|
|
475
|
+
body_from="$2"; shift 2 ;;
|
|
476
|
+
--body-from=*) body_from="${1#*=}"; shift ;;
|
|
477
|
+
--body-stdin) body_stdin=1; shift ;;
|
|
478
|
+
-h|--help) _clast_entries_usage; return 0 ;;
|
|
479
|
+
--) shift; break ;;
|
|
480
|
+
-*) _clast_entries_err "write: unknown flag '$1'"; return 2 ;;
|
|
481
|
+
*) _clast_entries_err "write: unexpected positional '$1'"; return 2 ;;
|
|
482
|
+
esac
|
|
483
|
+
done
|
|
484
|
+
|
|
485
|
+
if [[ -z "$session_id" ]]; then
|
|
486
|
+
_clast_entries_err "write: missing required flag '--session'"; return 2
|
|
487
|
+
fi
|
|
488
|
+
if ! [[ "$session_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
|
|
489
|
+
_clast_entries_err "write: '$session_id' is not a valid UUID"; return 2
|
|
490
|
+
fi
|
|
491
|
+
if [[ -z "$slug" ]]; then
|
|
492
|
+
_clast_entries_err "write: missing required flag '--slug'"; return 2
|
|
493
|
+
fi
|
|
494
|
+
if ! [[ "$slug" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]]; then
|
|
495
|
+
_clast_entries_err "write: invalid --slug '$slug'"; return 2
|
|
496
|
+
fi
|
|
497
|
+
if [[ -n "$body_from" && $body_stdin -eq 1 ]]; then
|
|
498
|
+
_clast_entries_err "write: --body-from and --body-stdin are mutually exclusive"; return 2
|
|
499
|
+
fi
|
|
500
|
+
if [[ -z "$body_from" && $body_stdin -eq 0 ]]; then
|
|
501
|
+
_clast_entries_err "write: missing required flag '--body-from' or '--body-stdin'"; return 2
|
|
502
|
+
fi
|
|
503
|
+
if [[ -n "$title" && "$title" == *$'\n'* ]]; then
|
|
504
|
+
_clast_entries_err "write: --title must not contain newlines"; return 2
|
|
505
|
+
fi
|
|
506
|
+
|
|
507
|
+
# Parse + validate tags.
|
|
508
|
+
local -a tags=()
|
|
509
|
+
if (( tags_explicit == 1 )) && [[ -n "$tags_csv" ]]; then
|
|
510
|
+
local IFS=','
|
|
511
|
+
read -r -a _raw_tags <<<"$tags_csv"
|
|
512
|
+
unset IFS
|
|
513
|
+
local rt trimmed
|
|
514
|
+
for rt in "${_raw_tags[@]}"; do
|
|
515
|
+
trimmed="${rt#"${rt%%[![:space:]]*}"}"
|
|
516
|
+
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
|
|
517
|
+
[[ -z "$trimmed" ]] && continue
|
|
518
|
+
if ! [[ "$trimmed" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then
|
|
519
|
+
_clast_entries_err "write: invalid tag '$trimmed'"; return 2
|
|
520
|
+
fi
|
|
521
|
+
tags+=("$trimmed")
|
|
522
|
+
done
|
|
523
|
+
fi
|
|
524
|
+
|
|
525
|
+
# Look up manifest entry.
|
|
526
|
+
local manifest_line
|
|
527
|
+
if ! manifest_line="$(clast_manifest_lookup "$session_id" 2>/dev/null)" || [[ -z "$manifest_line" ]]; then
|
|
528
|
+
_clast_entries_err "write: session '$session_id' not found in manifest" 1
|
|
529
|
+
return 1
|
|
530
|
+
fi
|
|
531
|
+
|
|
532
|
+
local snapshot_rel mtime
|
|
533
|
+
snapshot_rel="$(jq -r '.snapshot' <<<"$manifest_line")"
|
|
534
|
+
mtime="$(jq -r '.source_mtime' <<<"$manifest_line")"
|
|
535
|
+
: "$mtime" # unused for now; reserved for future best-effort fields
|
|
536
|
+
|
|
537
|
+
local seg
|
|
538
|
+
seg="$(awk -F/ 'NR==1{print $3}' <<<"$snapshot_rel")"
|
|
539
|
+
|
|
540
|
+
# Resolve project from registry.
|
|
541
|
+
local project_slug="" project_path="" project_remote=""
|
|
542
|
+
local resolved_slug=""
|
|
543
|
+
if resolved_slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$resolved_slug" ]]; then
|
|
544
|
+
local reg_json reg_match
|
|
545
|
+
reg_json="$(clast_registry_list_json)"
|
|
546
|
+
reg_match="$(jq -c --arg s "$resolved_slug" 'map(select(.slug == $s)) | .[0] // empty' <<<"$reg_json")"
|
|
547
|
+
if [[ -n "$reg_match" ]]; then
|
|
548
|
+
project_slug="$resolved_slug"
|
|
549
|
+
project_path="$(jq -r '.path // empty' <<<"$reg_match")"
|
|
550
|
+
project_remote="$(jq -r '.remote // empty' <<<"$reg_match")"
|
|
551
|
+
else
|
|
552
|
+
project_slug="$resolved_slug"
|
|
553
|
+
fi
|
|
554
|
+
else
|
|
555
|
+
project_slug="$seg"
|
|
556
|
+
local -a decoded=()
|
|
557
|
+
mapfile -t decoded < <(clast_decode_candidates "$seg" 2>/dev/null)
|
|
558
|
+
local d existing=()
|
|
559
|
+
for d in "${decoded[@]+"${decoded[@]}"}"; do
|
|
560
|
+
if [[ -d "$d" ]]; then existing+=("$d"); fi
|
|
561
|
+
done
|
|
562
|
+
if (( ${#existing[@]} == 1 )); then
|
|
563
|
+
project_path="${existing[0]}"
|
|
564
|
+
fi
|
|
565
|
+
clast_log_warn "entries: write: segment '$seg' is not registered; using slug '$project_slug'"
|
|
566
|
+
fi
|
|
567
|
+
|
|
568
|
+
# Best-effort branch from snapshot.
|
|
569
|
+
local journal_dir snapshot_abs branch=""
|
|
570
|
+
journal_dir="$(clast_journal_dir)"
|
|
571
|
+
snapshot_abs="$journal_dir/$snapshot_rel"
|
|
572
|
+
if [[ -r "$snapshot_abs" ]]; then
|
|
573
|
+
branch="$(head -n1 "$snapshot_abs" 2>/dev/null | jq -r '.cwd // .git_branch // empty' 2>/dev/null || true)"
|
|
574
|
+
# `.cwd` isn't a branch, but we keep parity with step 07's TODO: leave best-effort placeholder.
|
|
575
|
+
# If the value looks like a path, drop it.
|
|
576
|
+
case "$branch" in
|
|
577
|
+
/*|"") branch="" ;;
|
|
578
|
+
esac
|
|
579
|
+
fi
|
|
580
|
+
|
|
581
|
+
local author machine
|
|
582
|
+
author="${CLAST_AUTHOR:-${USER:-unknown}}"
|
|
583
|
+
machine="${CLAST_MACHINE:-$(hostname)}"
|
|
584
|
+
|
|
585
|
+
local today hhmm hhmm_compact
|
|
586
|
+
today="$(clast_today)"
|
|
587
|
+
hhmm="$(_clast_entries_now_hhmm)"
|
|
588
|
+
hhmm_compact="$(_clast_entries_now_hhmm_compact)"
|
|
589
|
+
|
|
590
|
+
# Body acquisition.
|
|
591
|
+
local body=""
|
|
592
|
+
if [[ -n "$body_from" ]]; then
|
|
593
|
+
if [[ ! -r "$body_from" ]]; then
|
|
594
|
+
_clast_entries_err "write: --body-from: cannot read '$body_from'" 1
|
|
595
|
+
return 1
|
|
596
|
+
fi
|
|
597
|
+
body="$(cat -- "$body_from")"
|
|
598
|
+
else
|
|
599
|
+
body="$(cat)"
|
|
600
|
+
fi
|
|
601
|
+
# Empty / whitespace-only check.
|
|
602
|
+
local body_stripped="${body//[[:space:]]/}"
|
|
603
|
+
if [[ -z "$body_stripped" ]]; then
|
|
604
|
+
_clast_entries_err "write: body is empty" 1
|
|
605
|
+
return 1
|
|
606
|
+
fi
|
|
607
|
+
# Trim a single trailing newline at most; ensure exactly one trailing newline.
|
|
608
|
+
if [[ "${body: -1}" == $'\n' ]]; then
|
|
609
|
+
body="${body%$'\n'}"
|
|
610
|
+
fi
|
|
611
|
+
body+=$'\n'
|
|
612
|
+
|
|
613
|
+
if [[ -n "$title" ]]; then
|
|
614
|
+
body="# Session: $title"$'\n\n'"$body"
|
|
615
|
+
fi
|
|
616
|
+
|
|
617
|
+
# Compose frontmatter.
|
|
618
|
+
local fm=""
|
|
619
|
+
fm+="date: $(_clast_entries_yaml_string "$today")"$'\n'
|
|
620
|
+
fm+="time: $(_clast_entries_yaml_string "$hhmm")"$'\n'
|
|
621
|
+
fm+="day_bucket: $(_clast_entries_yaml_string "$today")"$'\n'
|
|
622
|
+
fm+="project: $(_clast_entries_yaml_string "$project_slug")"$'\n'
|
|
623
|
+
if [[ -n "$project_path" ]]; then
|
|
624
|
+
fm+="project_path: $(_clast_entries_yaml_string "$project_path")"$'\n'
|
|
625
|
+
else
|
|
626
|
+
fm+="project_path: null"$'\n'
|
|
627
|
+
fi
|
|
628
|
+
if [[ -n "$project_remote" ]]; then
|
|
629
|
+
fm+="project_remote: $(_clast_entries_yaml_string "$project_remote")"$'\n'
|
|
630
|
+
else
|
|
631
|
+
fm+="project_remote: null"$'\n'
|
|
632
|
+
fi
|
|
633
|
+
if [[ -n "$branch" ]]; then
|
|
634
|
+
fm+="branch: $(_clast_entries_yaml_string "$branch")"$'\n'
|
|
635
|
+
else
|
|
636
|
+
fm+="branch: null"$'\n'
|
|
637
|
+
fi
|
|
638
|
+
fm+="author: $(_clast_entries_yaml_string "$author")"$'\n'
|
|
639
|
+
if (( ${#tags[@]} == 0 )); then
|
|
640
|
+
fm+="tags: []"$'\n'
|
|
641
|
+
else
|
|
642
|
+
local joined="" t
|
|
643
|
+
for t in "${tags[@]}"; do
|
|
644
|
+
if [[ -n "$joined" ]]; then joined+=", "; fi
|
|
645
|
+
joined+="$t"
|
|
646
|
+
done
|
|
647
|
+
fm+="tags: [$joined]"$'\n'
|
|
648
|
+
fi
|
|
649
|
+
fm+="session_id: $(_clast_entries_yaml_string "$session_id")"$'\n'
|
|
650
|
+
fm+="session_slug: $(_clast_entries_yaml_string "$slug")"$'\n'
|
|
651
|
+
fm+="snapshot_path: $(_clast_entries_yaml_string "$snapshot_rel")"$'\n'
|
|
652
|
+
fm+="machine: $(_clast_entries_yaml_string "$machine")"$'\n'
|
|
653
|
+
|
|
654
|
+
local composed="---"$'\n'"$fm""---"$'\n\n'"$body"
|
|
655
|
+
|
|
656
|
+
# Resolve target filename with collision suffixing.
|
|
657
|
+
local entries_dir="$journal_dir/entries"
|
|
658
|
+
if ! mkdir -p "$entries_dir"; then
|
|
659
|
+
_clast_entries_err "write: failed to create '$entries_dir'" 1
|
|
660
|
+
return 1
|
|
661
|
+
fi
|
|
662
|
+
local base="${today}-${hhmm_compact}-${project_slug}-${slug}"
|
|
663
|
+
# Collapse leading dashes from segment-derived slug (but not internal `--`).
|
|
664
|
+
base="${base/-\///}" # no-op safety
|
|
665
|
+
local target="$entries_dir/${base}.md"
|
|
666
|
+
if [[ -e "$target" ]]; then
|
|
667
|
+
local i found=""
|
|
668
|
+
for (( i = 2; i <= 99; i++ )); do
|
|
669
|
+
if [[ ! -e "$entries_dir/${base}-${i}.md" ]]; then
|
|
670
|
+
target="$entries_dir/${base}-${i}.md"
|
|
671
|
+
found=1
|
|
672
|
+
break
|
|
673
|
+
fi
|
|
674
|
+
done
|
|
675
|
+
if [[ -z "$found" ]]; then
|
|
676
|
+
_clast_entries_err "write: too many collisions for ${base}.md" 1
|
|
677
|
+
return 1
|
|
678
|
+
fi
|
|
679
|
+
fi
|
|
680
|
+
|
|
681
|
+
if ! clast_atomic_write "$target" "$composed"; then
|
|
682
|
+
_clast_entries_err "write: failed to write '$target'" 1
|
|
683
|
+
return 1
|
|
684
|
+
fi
|
|
685
|
+
|
|
686
|
+
local basename_only
|
|
687
|
+
basename_only="$(basename "$target")"
|
|
688
|
+
|
|
689
|
+
if [[ -n "${CLAST_JSON:-}" ]]; then
|
|
690
|
+
jq -cn --arg path "$target" '{path:$path}'
|
|
691
|
+
return 0
|
|
692
|
+
fi
|
|
693
|
+
if [[ -n "${CLAST_QUIET:-}" ]]; then
|
|
694
|
+
return 0
|
|
695
|
+
fi
|
|
696
|
+
printf 'Wrote entries/%s\n' "$basename_only"
|
|
697
|
+
}
|