@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,309 @@
1
+ # clast-subcommands/projects.bash — `clast projects`.
2
+ #
3
+ # Read-only view over .manifest.jsonl: list projects (segments) with
4
+ # session activity in a date window. See docs/cli-contract.md#clast-projects.
5
+ # shellcheck shell=bash
6
+ # shellcheck source=lib/clast/clast-lib.bash
7
+ # shellcheck source=lib/clast/clast-manifest-lib.bash
8
+ # shellcheck source=lib/clast/clast-registry-lib.bash
9
+ # shellcheck source=lib/clast/clast-decode-lib.bash
10
+
11
+ _clast_projects_usage() {
12
+ cat <<'EOF'
13
+ Usage: clast projects [--day DATE] [--since DATE] [--until DATE] [--unregistered]
14
+
15
+ List projects with activity in a date window.
16
+
17
+ Flags:
18
+ --day DATE Single-day window (default: today). Mutually exclusive
19
+ with --since/--until.
20
+ --since DATE Start of range (inclusive).
21
+ --until DATE End of range (inclusive).
22
+ --unregistered Show only projects whose segment does not resolve via
23
+ the registry.
24
+ -h, --help Print this usage and exit.
25
+
26
+ DATE accepts ISO (YYYY-MM-DD), `today`, `yesterday`, `last-week`,
27
+ `-Nd`, or `-Nw`. See docs/cli-contract.md#date-parsing.
28
+ EOF
29
+ }
30
+
31
+ _clast_projects_err() {
32
+ local msg="$1"
33
+ if [[ -n "${CLAST_JSON:-}" ]]; then
34
+ jq -cn --arg m "$msg" '{error:$m, code:2}'
35
+ else
36
+ clast_log_error "projects: $msg"
37
+ fi
38
+ }
39
+
40
+ clast_cmd_projects() {
41
+ local day_filter="" since_date="" until_date=""
42
+ local unregistered_only=0
43
+
44
+ while [[ $# -gt 0 ]]; do
45
+ case "$1" in
46
+ --day)
47
+ if [[ $# -lt 2 ]]; then _clast_projects_err "--day requires a value"; return 2; fi
48
+ if ! day_filter="$(clast_parse_date "$2" 2>/dev/null)"; then
49
+ _clast_projects_err "invalid date '$2'"; return 2
50
+ fi
51
+ shift 2 ;;
52
+ --day=*)
53
+ if ! day_filter="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
54
+ _clast_projects_err "invalid date '${1#*=}'"; return 2
55
+ fi
56
+ shift ;;
57
+ --since)
58
+ if [[ $# -lt 2 ]]; then _clast_projects_err "--since requires a value"; return 2; fi
59
+ if ! since_date="$(clast_parse_date "$2" 2>/dev/null)"; then
60
+ _clast_projects_err "invalid date '$2'"; return 2
61
+ fi
62
+ shift 2 ;;
63
+ --since=*)
64
+ if ! since_date="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
65
+ _clast_projects_err "invalid date '${1#*=}'"; return 2
66
+ fi
67
+ shift ;;
68
+ --until)
69
+ if [[ $# -lt 2 ]]; then _clast_projects_err "--until requires a value"; return 2; fi
70
+ if ! until_date="$(clast_parse_date "$2" 2>/dev/null)"; then
71
+ _clast_projects_err "invalid date '$2'"; return 2
72
+ fi
73
+ shift 2 ;;
74
+ --until=*)
75
+ if ! until_date="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
76
+ _clast_projects_err "invalid date '${1#*=}'"; return 2
77
+ fi
78
+ shift ;;
79
+ --unregistered)
80
+ unregistered_only=1; shift ;;
81
+ -h|--help)
82
+ _clast_projects_usage; return 0 ;;
83
+ --)
84
+ shift; break ;;
85
+ -*)
86
+ _clast_projects_err "unknown flag '$1'"; return 2 ;;
87
+ *)
88
+ _clast_projects_err "unexpected positional '$1'"; return 2 ;;
89
+ esac
90
+ done
91
+
92
+ if [[ -n "$day_filter" && ( -n "$since_date" || -n "$until_date" ) ]]; then
93
+ _clast_projects_err "--day is mutually exclusive with --since/--until"
94
+ return 2
95
+ fi
96
+
97
+ if [[ -z "$day_filter" && -z "$since_date" && -z "$until_date" ]]; then
98
+ day_filter="$(clast_today)"
99
+ fi
100
+
101
+ # `--until` defaults to `today` per docs/cli-contract.md#clast-projects when
102
+ # `--since` is supplied without an explicit upper bound.
103
+ if [[ -n "$since_date" && -z "$until_date" ]]; then
104
+ until_date="$(clast_today)"
105
+ fi
106
+
107
+ # Single-day window when --day is set, or --since == --until.
108
+ local single_day=""
109
+ if [[ -n "$day_filter" ]]; then
110
+ single_day="$day_filter"
111
+ elif [[ -n "$since_date" && "$since_date" == "$until_date" ]]; then
112
+ single_day="$since_date"
113
+ fi
114
+
115
+ local filter
116
+ filter="$(_clast_projects_window_filter "$day_filter" "$since_date" "$until_date")"
117
+
118
+ local journal_dir
119
+ journal_dir="$(clast_journal_dir)"
120
+
121
+ # Two-pass over the manifest stream: first pass finds the most-recent
122
+ # manifest line per session_id within the window (manifest is append-
123
+ # ordered, so the last occurrence wins). Second pass aggregates per
124
+ # segment.
125
+ declare -A latest_line=()
126
+ local line sid
127
+ while IFS= read -r line; do
128
+ [[ -z "$line" ]] && continue
129
+ sid="$(jq -r '.session_id' <<<"$line")"
130
+ [[ -z "$sid" || "$sid" == "null" ]] && continue
131
+ latest_line["$sid"]="$line"
132
+ done < <(clast_manifest_iterate "$filter")
133
+
134
+ declare -A seg_session_count=()
135
+ declare -A seg_msg_count=()
136
+ declare -A seg_last_active=()
137
+ local snapshot mtime seg msgs abs_path
138
+
139
+ for sid in "${!latest_line[@]}"; do
140
+ line="${latest_line[$sid]}"
141
+ snapshot="$(jq -r '.snapshot' <<<"$line")"
142
+ mtime="$(jq -r '.source_mtime' <<<"$line")"
143
+ seg="$(awk -F/ 'NR==1{print $3}' <<<"$snapshot")"
144
+ [[ -z "$seg" ]] && continue
145
+
146
+ seg_session_count["$seg"]=$(( ${seg_session_count["$seg"]:-0} + 1 ))
147
+
148
+ abs_path="$journal_dir/$snapshot"
149
+ if [[ -r "$abs_path" ]]; then
150
+ msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
151
+ [[ -z "$msgs" ]] && msgs=0
152
+ else
153
+ if [[ -n "${CLAST_VERBOSE:-}" ]]; then
154
+ clast_log_warn "projects: snapshot missing or unreadable: $abs_path"
155
+ fi
156
+ msgs=0
157
+ fi
158
+ seg_msg_count["$seg"]=$(( ${seg_msg_count["$seg"]:-0} + msgs ))
159
+
160
+ if [[ -z "${seg_last_active["$seg"]:-}" || "$mtime" > "${seg_last_active["$seg"]}" ]]; then
161
+ seg_last_active["$seg"]="$mtime"
162
+ fi
163
+ done
164
+
165
+ # Build per-segment rows as JSON, applying registry + --unregistered.
166
+ local -a rows=()
167
+ local slug path remote registered row decode_rc decoded
168
+ for seg in "${!seg_session_count[@]}"; do
169
+ if slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$slug" ]]; then
170
+ registered=true
171
+ path="$(_clast_projects_path_for_slug "$slug")"
172
+ remote="$(_clast_projects_remote_for_slug "$slug")"
173
+ else
174
+ registered=false
175
+ slug=""
176
+ remote=""
177
+ decode_rc=0
178
+ decoded="$(clast_decode_segment "$seg" 2>/dev/null)" || decode_rc=$?
179
+ if (( decode_rc == 0 )); then
180
+ path="$decoded"
181
+ else
182
+ path=""
183
+ fi
184
+ fi
185
+
186
+ if (( unregistered_only == 1 )) && [[ "$registered" == "true" ]]; then
187
+ continue
188
+ fi
189
+
190
+ row="$(jq -cn \
191
+ --arg slug "$slug" \
192
+ --arg path "$path" \
193
+ --arg segment "$seg" \
194
+ --arg remote "$remote" \
195
+ --argjson session_count "${seg_session_count[$seg]}" \
196
+ --argjson msg_count_approx "${seg_msg_count[$seg]}" \
197
+ --arg last_active "${seg_last_active[$seg]}" \
198
+ --argjson registered "$registered" \
199
+ '{
200
+ slug: (if $slug == "" then null else $slug end),
201
+ path: (if $path == "" then null else $path end),
202
+ segment: $segment,
203
+ remote: (if $remote == "" then null else $remote end),
204
+ session_count: $session_count,
205
+ msg_count_approx: $msg_count_approx,
206
+ last_active: $last_active,
207
+ registered: $registered
208
+ }')"
209
+ rows+=("$row")
210
+ done
211
+
212
+ # Sort by (-session_count, slug-or-segment).
213
+ local rows_json='[]'
214
+ if (( ${#rows[@]} > 0 )); then
215
+ rows_json="$(printf '%s\n' "${rows[@]}" \
216
+ | jq -cs 'sort_by([-.session_count, (.slug // .segment)])')"
217
+ fi
218
+
219
+ if [[ -n "${CLAST_JSON:-}" ]]; then
220
+ printf '%s\n' "$rows_json"
221
+ return 0
222
+ fi
223
+
224
+ if [[ -n "${CLAST_QUIET:-}" ]]; then
225
+ return 0
226
+ fi
227
+
228
+ # Default human output: header + one row per project.
229
+ printf '%-17s %-33s %9s %5s %s\n' \
230
+ "slug" "path" "sessions" "msgs" "last_active"
231
+
232
+ local n
233
+ n="$(jq 'length' <<<"$rows_json")"
234
+ local i sj
235
+ for (( i = 0; i < n; i++ )); do
236
+ sj="$(jq -c ".[$i]" <<<"$rows_json")"
237
+ local r_slug r_path r_seg r_sessions r_msgs r_last_active disp_slug disp_path
238
+ r_slug="$(jq -r '.slug // ""' <<<"$sj")"
239
+ r_path="$(jq -r '.path // ""' <<<"$sj")"
240
+ r_seg="$(jq -r '.segment' <<<"$sj")"
241
+ r_sessions="$(jq -r '.session_count' <<<"$sj")"
242
+ r_msgs="$(jq -r '.msg_count_approx' <<<"$sj")"
243
+ r_last_active="$(jq -r '.last_active' <<<"$sj")"
244
+
245
+ if [[ -n "$r_slug" ]]; then
246
+ disp_slug="$r_slug"
247
+ else
248
+ disp_slug="(unregistered)"
249
+ fi
250
+ if [[ -n "$r_path" ]]; then
251
+ disp_path="$r_path"
252
+ else
253
+ disp_path="$r_seg"
254
+ fi
255
+
256
+ local disp_last
257
+ if [[ -n "$single_day" ]]; then
258
+ disp_last="${r_last_active:11:5}"
259
+ else
260
+ disp_last="${r_last_active:0:10} ${r_last_active:11:5}"
261
+ fi
262
+
263
+ printf '%-17s %-33s %9s %5s %s\n' \
264
+ "$disp_slug" "$disp_path" "$r_sessions" "$r_msgs" "$disp_last"
265
+ done
266
+ }
267
+
268
+ # _clast_projects_window_filter <day_filter> <since> <until>
269
+ # Build a jq select-body string matching .day_bucket against the window.
270
+ _clast_projects_window_filter() {
271
+ local day="$1" since="$2" until="$3"
272
+ if [[ -n "$day" ]]; then
273
+ printf '.day_bucket == "%s"' "$day"
274
+ return 0
275
+ fi
276
+ local parts=()
277
+ if [[ -n "$since" ]]; then
278
+ parts+=(".day_bucket >= \"$since\"")
279
+ fi
280
+ if [[ -n "$until" ]]; then
281
+ parts+=(".day_bucket <= \"$until\"")
282
+ fi
283
+ if (( ${#parts[@]} == 0 )); then
284
+ printf 'true'
285
+ return 0
286
+ fi
287
+ local joined="${parts[0]}"
288
+ local i
289
+ for (( i = 1; i < ${#parts[@]}; i++ )); do
290
+ joined+=" and ${parts[i]}"
291
+ done
292
+ printf '%s' "$joined"
293
+ }
294
+
295
+ # _clast_projects_path_for_slug <slug>
296
+ # First registry path for <slug>, or empty.
297
+ _clast_projects_path_for_slug() {
298
+ local slug="$1"
299
+ clast_registry_list_json \
300
+ | jq -r --arg s "$slug" 'map(select(.slug == $s)) | .[0].path // empty'
301
+ }
302
+
303
+ # _clast_projects_remote_for_slug <slug>
304
+ # First non-empty registry remote for <slug>, or empty.
305
+ _clast_projects_remote_for_slug() {
306
+ local slug="$1"
307
+ clast_registry_list_json \
308
+ | jq -r --arg s "$slug" 'map(select(.slug == $s and (.remote // "") != "")) | .[0].remote // empty'
309
+ }
@@ -0,0 +1,207 @@
1
+ # clast-subcommands/registry.bash — `clast registry list|add|resolve|remove`.
2
+ # shellcheck shell=bash
3
+ # shellcheck source=lib/clast/clast-lib.bash
4
+ # shellcheck source=lib/clast/clast-registry-lib.bash
5
+
6
+ _clast_registry_usage() {
7
+ cat <<'EOF'
8
+ Usage:
9
+ clast registry list [--json]
10
+ clast registry add <path> [--slug NAME] [--remote URL] [--json]
11
+ clast registry resolve <path-or-segment> [--json]
12
+ clast registry remove <slug> [--json]
13
+ EOF
14
+ }
15
+
16
+ clast_cmd_registry() {
17
+ if [[ $# -eq 0 ]]; then
18
+ _clast_registry_usage >&2
19
+ return 2
20
+ fi
21
+
22
+ local op="$1"; shift
23
+ case "$op" in
24
+ list) _clast_registry_op_list "$@" ;;
25
+ add) _clast_registry_op_add "$@" ;;
26
+ resolve) _clast_registry_op_resolve "$@" ;;
27
+ remove) _clast_registry_op_remove "$@" ;;
28
+ -h|--help)
29
+ _clast_registry_usage
30
+ return 0
31
+ ;;
32
+ *)
33
+ clast_log_error "registry: unknown op '$op'"
34
+ _clast_registry_usage >&2
35
+ return 2
36
+ ;;
37
+ esac
38
+ }
39
+
40
+ _clast_registry_op_list() {
41
+ local json="${CLAST_JSON:-}"
42
+ while [[ $# -gt 0 ]]; do
43
+ case "$1" in
44
+ --json) json=1; shift ;;
45
+ -h|--help)
46
+ cat <<'EOF'
47
+ Usage: clast registry list [--json]
48
+ EOF
49
+ return 0
50
+ ;;
51
+ *)
52
+ clast_log_error "registry list: unexpected arg '$1'"
53
+ return 2
54
+ ;;
55
+ esac
56
+ done
57
+
58
+ local arr
59
+ arr="$(clast_registry_list_json)"
60
+
61
+ if [[ -n "$json" ]]; then
62
+ printf '%s\n' "$arr"
63
+ return 0
64
+ fi
65
+
66
+ printf '%-17s %-33s %-43s %s\n' slug path remote aliases
67
+ local n i slug path remote aliases
68
+ n="$(jq 'length' <<<"$arr")"
69
+ for (( i = 0; i < n; i++ )); do
70
+ slug="$(jq -r ".[$i].slug // \"\"" <<<"$arr")"
71
+ path="$(jq -r ".[$i].path // \"\"" <<<"$arr")"
72
+ remote="$(jq -r ".[$i].remote // \"\"" <<<"$arr")"
73
+ aliases="$(jq -r ".[$i].aliases // [] | if length == 0 then \"(none)\" else join(\",\") end" <<<"$arr")"
74
+ printf '%-17s %-33s %-43s %s\n' "$slug" "$path" "$remote" "$aliases"
75
+ done
76
+ }
77
+
78
+ _clast_registry_op_add() {
79
+ local json="${CLAST_JSON:-}"
80
+ local -a passthrough=()
81
+ while [[ $# -gt 0 ]]; do
82
+ case "$1" in
83
+ --json) json=1; shift ;;
84
+ -h|--help)
85
+ cat <<'EOF'
86
+ Usage: clast registry add <path> [--slug NAME] [--remote URL] [--json]
87
+ EOF
88
+ return 0
89
+ ;;
90
+ # TODO(v1.1): interactive --slug prompt when stdin is a TTY.
91
+ --slug|--remote)
92
+ if [[ $# -lt 2 ]]; then
93
+ clast_log_error "registry add: $1 requires a value"
94
+ return 2
95
+ fi
96
+ passthrough+=("$1" "$2"); shift 2 ;;
97
+ --slug=*|--remote=*)
98
+ passthrough+=("$1"); shift ;;
99
+ *)
100
+ passthrough+=("$1"); shift ;;
101
+ esac
102
+ done
103
+
104
+ local line rc=0
105
+ line="$(clast_registry_add "${passthrough[@]}")" || rc=$?
106
+ if (( rc != 0 )); then
107
+ return "$rc"
108
+ fi
109
+
110
+ if [[ -n "$json" ]]; then
111
+ printf '%s\n' "$line"
112
+ else
113
+ local slug path
114
+ slug="$(jq -r '.slug' <<<"$line")"
115
+ path="$(jq -r '.path' <<<"$line")"
116
+ printf 'registered %s → %s\n' "$slug" "$path"
117
+ fi
118
+ }
119
+
120
+ _clast_registry_op_resolve() {
121
+ local json="${CLAST_JSON:-}"
122
+ local input=""
123
+ while [[ $# -gt 0 ]]; do
124
+ case "$1" in
125
+ --json) json=1; shift ;;
126
+ -h|--help)
127
+ cat <<'EOF'
128
+ Usage: clast registry resolve <path-or-segment> [--json]
129
+ EOF
130
+ return 0
131
+ ;;
132
+ *)
133
+ if [[ -n "$input" ]]; then
134
+ clast_log_error "registry resolve: unexpected arg '$1'"
135
+ return 2
136
+ fi
137
+ input="$1"; shift
138
+ ;;
139
+ esac
140
+ done
141
+
142
+ if [[ -z "$input" ]]; then
143
+ clast_log_error "registry resolve: <path-or-segment> is required"
144
+ return 2
145
+ fi
146
+
147
+ local slug
148
+ if slug="$(clast_registry_resolve "$input")" && [[ -n "$slug" ]]; then
149
+ if [[ -n "$json" ]]; then
150
+ jq -cn --arg slug "$slug" '{slug: $slug}'
151
+ else
152
+ printf '%s\n' "$slug"
153
+ fi
154
+ return 0
155
+ fi
156
+
157
+ if [[ -n "$json" ]]; then
158
+ printf '%s\n' '{"error":"not registered"}'
159
+ else
160
+ clast_log_error "not registered"
161
+ fi
162
+ return 1
163
+ }
164
+
165
+ _clast_registry_op_remove() {
166
+ local json="${CLAST_JSON:-}"
167
+ local slug=""
168
+ while [[ $# -gt 0 ]]; do
169
+ case "$1" in
170
+ --json) json=1; shift ;;
171
+ -h|--help)
172
+ cat <<'EOF'
173
+ Usage: clast registry remove <slug> [--json]
174
+ EOF
175
+ return 0
176
+ ;;
177
+ *)
178
+ if [[ -n "$slug" ]]; then
179
+ clast_log_error "registry remove: unexpected arg '$1'"
180
+ return 2
181
+ fi
182
+ slug="$1"; shift
183
+ ;;
184
+ esac
185
+ done
186
+
187
+ if [[ -z "$slug" ]]; then
188
+ clast_log_error "registry remove: <slug> is required"
189
+ return 2
190
+ fi
191
+
192
+ if clast_registry_remove "$slug"; then
193
+ if [[ -n "$json" ]]; then
194
+ jq -cn --arg slug "$slug" '{removed: $slug}'
195
+ else
196
+ printf 'unregistered %s\n' "$slug"
197
+ fi
198
+ return 0
199
+ fi
200
+
201
+ if [[ -n "$json" ]]; then
202
+ printf '%s\n' '{"error":"not registered"}'
203
+ else
204
+ clast_log_error "not registered"
205
+ fi
206
+ return 1
207
+ }