@onlooker-community/ecosystem 0.26.1 → 0.27.0

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,108 @@
1
+ #!/usr/bin/env bash
2
+ # Config resolution for bursar.
3
+ #
4
+ # Reads three layers, latest wins:
5
+ # 1. plugins/bursar/config.json (defaults shipped with the plugin)
6
+ # 2. ~/.claude/settings.json
7
+ # 3. <repo>/.claude/settings.json
8
+ #
9
+ # Exposes:
10
+ # bursar_config_load <repo_root> # populates _BURSAR_CONFIG (JSON)
11
+ # bursar_config_get <jq-path> # echoes string value (empty if unset)
12
+ # bursar_config_get_json <jq-path> # echoes JSON value (null if unset)
13
+ # bursar_config_enabled # 0 if bursar.enabled is true
14
+ # bursar_config_window # echoes "rolling_7d" or "calendar_week"
15
+ # bursar_config_week_start # echoes "monday" or "sunday"
16
+
17
+ _BURSAR_CONFIG="{}"
18
+
19
+ bursar_config_load() {
20
+ local repo_root="${1:-}"
21
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
22
+ local home_dir="${HOME:-}"
23
+
24
+ local merged="{}"
25
+ local file
26
+
27
+ file="${plugin_root}/config.json"
28
+ if [[ -f "$file" ]]; then
29
+ local defaults
30
+ defaults=$(jq '.' "$file" 2>/dev/null) || defaults="{}"
31
+ merged=$(jq -n --argjson a "$merged" --argjson b "$defaults" '$a * $b' 2>/dev/null) \
32
+ || merged="$defaults"
33
+ fi
34
+
35
+ local repo_settings=""
36
+ [[ -n "$repo_root" ]] && repo_settings="${repo_root}/.claude/settings.json"
37
+
38
+ for file in "${home_dir}/.claude/settings.json" "$repo_settings"; do
39
+ [[ -n "$file" && -f "$file" ]] || continue
40
+ local overlay
41
+ overlay=$(jq '{ bursar: (.bursar // {}) }' "$file" 2>/dev/null) || continue
42
+ [[ -z "$overlay" ]] && continue
43
+ local attempt
44
+ if attempt=$(jq -n --argjson a "$merged" --argjson b "$overlay" '
45
+ def deepmerge($a; $b):
46
+ if ($a|type) == "object" and ($b|type) == "object" then
47
+ reduce (($a|keys) + ($b|keys) | unique)[] as $k
48
+ ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
49
+ elif $b == null then $a
50
+ else $b end;
51
+ deepmerge($a; $b)
52
+ ' 2>/dev/null) && [[ -n "$attempt" ]]; then
53
+ merged="$attempt"
54
+ fi
55
+ done
56
+
57
+ _BURSAR_CONFIG="$merged"
58
+ }
59
+
60
+ bursar_config_get() {
61
+ local path="$1"
62
+ printf '%s' "$_BURSAR_CONFIG" | jq -r "${path} // empty" 2>/dev/null
63
+ }
64
+
65
+ bursar_config_get_json() {
66
+ local path="$1"
67
+ printf '%s' "$_BURSAR_CONFIG" | jq -c "${path}" 2>/dev/null
68
+ }
69
+
70
+ bursar_config_enabled() {
71
+ local v
72
+ v=$(bursar_config_get '.bursar.enabled')
73
+ [[ "$v" == "true" ]]
74
+ }
75
+
76
+ bursar_config_surface_enabled() {
77
+ # Use the JSON getter, not bursar_config_get: jq's `//` treats a literal
78
+ # `false` as empty, which would mask an explicit opt-out. The JSON getter
79
+ # returns the raw `false`/`true`/`null` so the default-on behavior holds.
80
+ local v
81
+ v=$(bursar_config_get_json '.bursar.surface_at_session_start')
82
+ # Default to surfacing unless explicitly set to false.
83
+ [[ "$v" != "false" ]]
84
+ }
85
+
86
+ bursar_config_window() {
87
+ local v
88
+ v=$(bursar_config_get '.bursar.window')
89
+ case "$v" in
90
+ calendar_week) printf 'calendar_week' ;;
91
+ *) printf 'rolling_7d' ;;
92
+ esac
93
+ }
94
+
95
+ bursar_config_week_start() {
96
+ local v
97
+ v=$(bursar_config_get '.bursar.week_start')
98
+ case "$v" in
99
+ sunday) printf 'sunday' ;;
100
+ *) printf 'monday' ;;
101
+ esac
102
+ }
103
+
104
+ bursar_config_min_cost() {
105
+ local v
106
+ v=$(bursar_config_get '.bursar.min_cost_to_surface_usd')
107
+ printf '%s' "${v:-0}"
108
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # Canonical bursar.* event emission.
3
+ #
4
+ # Thin wrapper around the ecosystem plugin's onlooker-event.mjs `emit` mode.
5
+ # Every emission is validated against @onlooker-community/schema (>= 2.7.0,
6
+ # which registers the bursar.* event types) before being appended to
7
+ # ~/.onlooker/logs/onlooker-events.jsonl.
8
+ #
9
+ # Usage:
10
+ # bursar_emit_event "bursar.session.recorded" '{"project_key":"...",...}' "$SESSION_ID"
11
+
12
+ _BURSAR_PLUGIN_NAME="bursar"
13
+
14
+ _bursar_event_js_path() {
15
+ if [[ -n "${_ONLOOKER_EVENT_JS:-}" && -f "$_ONLOOKER_EVENT_JS" ]]; then
16
+ printf '%s' "$_ONLOOKER_EVENT_JS"
17
+ return 0
18
+ fi
19
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
20
+ local candidates=(
21
+ "${plugin_root}/scripts/lib/onlooker-event.mjs"
22
+ "${plugin_root}/../../scripts/lib/onlooker-event.mjs"
23
+ )
24
+ local c
25
+ for c in "${candidates[@]}"; do
26
+ [[ -f "$c" ]] && { printf '%s' "$c"; return 0; }
27
+ done
28
+ return 1
29
+ }
30
+
31
+ _bursar_session_id() {
32
+ if [[ -n "${_HOOK_SESSION_ID:-}" ]]; then
33
+ printf '%s' "$_HOOK_SESSION_ID"
34
+ return 0
35
+ fi
36
+ if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then
37
+ printf '%s' "$CLAUDE_SESSION_ID"
38
+ return 0
39
+ fi
40
+ printf 'unknown'
41
+ }
42
+
43
+ # Emit a single bursar.* event. Returns 0 on success, non-zero on failure.
44
+ # Usage: bursar_emit_event <event_type> <payload_json> [session_id]
45
+ bursar_emit_event() {
46
+ local event_type="${1:-}"
47
+ local payload="${2:-}"
48
+ local session_id="${3:-}"
49
+
50
+ [[ -z "$event_type" || -z "$payload" ]] && return 1
51
+ [[ -z "$session_id" ]] && session_id=$(_bursar_session_id)
52
+
53
+ local event_js
54
+ event_js=$(_bursar_event_js_path) || return 1
55
+
56
+ local params
57
+ params=$(jq -n \
58
+ --arg plugin "$_BURSAR_PLUGIN_NAME" \
59
+ --arg sid "$session_id" \
60
+ --arg type "$event_type" \
61
+ --argjson payload "$payload" \
62
+ '{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}' \
63
+ 2>/dev/null) || return 1
64
+
65
+ local event
66
+ local stderr_file
67
+ stderr_file=$(mktemp -t bursar-event-err.XXXXXX 2>/dev/null) || stderr_file="/tmp/bursar-event-err.$$"
68
+ event=$(printf '%s' "$params" \
69
+ | ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}" \
70
+ ONLOOKER_PLUGIN_NAME="$_BURSAR_PLUGIN_NAME" \
71
+ node "$event_js" emit 2>"$stderr_file") || {
72
+ printf 'bursar_emit_event: schema validation failed for %s\n' "$event_type" >&2
73
+ [[ -s "$stderr_file" ]] && cat "$stderr_file" >&2
74
+ rm -f "$stderr_file"
75
+ return 1
76
+ }
77
+ rm -f "$stderr_file"
78
+
79
+ local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
80
+ mkdir -p "$(dirname "$log_path")" 2>/dev/null || return 1
81
+ printf '%s\n' "$event" >> "$log_path"
82
+ }
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env bash
2
+ # Per-project, multi-session rollup ledger for bursar.
3
+ #
4
+ # Where governor keeps a per-session ledger under
5
+ # ~/.onlooker/governance/ledgers/<session-id>.jsonl, bursar keeps one ledger
6
+ # per project under:
7
+ #
8
+ # $ONLOOKER_DIR/bursar/projects/<project_key>/sessions.jsonl
9
+ #
10
+ # Each line is one session's spend, recorded once on SessionEnd:
11
+ #
12
+ # { ts, ts_epoch, session_id, project_key,
13
+ # cost_usd?, tokens?, api_calls?, governor_present, model? }
14
+ #
15
+ # cost/tokens/api_calls are omitted when governor was not running for the
16
+ # session (governor_present:false) — bursar degrades to a session count.
17
+ #
18
+ # Records are keyed by session_id: re-recording a session replaces its line
19
+ # rather than appending, so a SessionEnd that fires more than once is
20
+ # idempotent.
21
+ #
22
+ # ts_epoch (seconds) is stored alongside the RFC3339 ts so window filtering is
23
+ # a portable numeric jq compare with no date parsing.
24
+ #
25
+ # Requires portable-lock.sh to be sourced beforehand.
26
+
27
+ BURSAR_LEDGER_LOCK_TIMEOUT=5
28
+
29
+ bursar_ledger_dir() {
30
+ local project_key="${1:-unknown}"
31
+ local safe_key
32
+ safe_key=$(printf '%s' "$project_key" | tr -c 'a-zA-Z0-9-' '_')
33
+ printf '%s/bursar/projects/%s' "${ONLOOKER_DIR:-${HOME}/.onlooker}" "$safe_key"
34
+ }
35
+
36
+ bursar_ledger_path() {
37
+ printf '%s/sessions.jsonl' "$(bursar_ledger_dir "$1")"
38
+ }
39
+
40
+ # Current wall-clock helpers (portable across macOS/Linux).
41
+ bursar_now_epoch() { date +%s 2>/dev/null || printf '0'; }
42
+ bursar_now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || printf ''; }
43
+
44
+ bursar_epoch_to_iso() {
45
+ local e="${1:-0}"
46
+ date -u -r "$e" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
47
+ || date -u -d "@$e" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
48
+ || printf ''
49
+ }
50
+
51
+ # Upsert a session record into the project ledger, keyed by session_id.
52
+ # Usage: bursar_ledger_record "$project_key" "$record_json"
53
+ bursar_ledger_record() {
54
+ local project_key="${1:-}"
55
+ local record="${2:-}"
56
+ [[ -z "$project_key" || -z "$record" ]] && return 1
57
+
58
+ local sid
59
+ sid=$(printf '%s' "$record" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
60
+ [[ -z "$sid" ]] && return 1
61
+
62
+ local record_compact
63
+ record_compact=$(printf '%s' "$record" | jq -c . 2>/dev/null) || return 1
64
+
65
+ local dir ledger_path lock_path
66
+ dir=$(bursar_ledger_dir "$project_key")
67
+ ledger_path="${dir}/sessions.jsonl"
68
+ lock_path="${ledger_path}.lock"
69
+ mkdir -p "$dir" 2>/dev/null || return 1
70
+
71
+ if ! lock_acquire "$lock_path" "$BURSAR_LEDGER_LOCK_TIMEOUT"; then
72
+ return 1
73
+ fi
74
+
75
+ local tmp
76
+ tmp=$(mktemp "${dir}/.sessions.XXXXXX" 2>/dev/null) || { lock_release "$lock_path"; return 1; }
77
+
78
+ if [[ -f "$ledger_path" ]]; then
79
+ # Keep every line whose session_id differs from the one being recorded.
80
+ jq -c --arg sid "$sid" 'select(.session_id != $sid)' "$ledger_path" 2>/dev/null >>"$tmp"
81
+ fi
82
+ printf '%s\n' "$record_compact" >>"$tmp"
83
+
84
+ mv "$tmp" "$ledger_path" 2>/dev/null || { rm -f "$tmp"; lock_release "$lock_path"; return 1; }
85
+ lock_release "$lock_path"
86
+ return 0
87
+ }
88
+
89
+ # Compute the inclusive lower-bound epoch for the active window.
90
+ # Usage: bursar_window_cutoff_epoch <rolling_7d|calendar_week> <monday|sunday>
91
+ bursar_window_cutoff_epoch() {
92
+ local window="${1:-rolling_7d}"
93
+ local week_start="${2:-monday}"
94
+ local now
95
+ now=$(bursar_now_epoch)
96
+
97
+ if [[ "$window" == "calendar_week" ]]; then
98
+ # Local midnight today, computed portably from H/M/S (no GNU/BSD-only flags).
99
+ local h m s secs_today today_midnight dow days_since
100
+ h=$(date +%H 2>/dev/null) || h=0
101
+ m=$(date +%M 2>/dev/null) || m=0
102
+ s=$(date +%S 2>/dev/null) || s=0
103
+ secs_today=$(( 10#$h * 3600 + 10#$m * 60 + 10#$s ))
104
+ today_midnight=$(( now - secs_today ))
105
+
106
+ dow=$(date +%u 2>/dev/null) || dow=1 # 1=Mon .. 7=Sun
107
+ if [[ "$week_start" == "sunday" ]]; then
108
+ days_since=$(( dow % 7 )) # Sun(7)->0, Mon(1)->1 .. Sat(6)->6
109
+ else
110
+ days_since=$(( dow - 1 )) # Mon(1)->0 .. Sun(7)->6
111
+ fi
112
+ printf '%d' "$(( today_midnight - days_since * 86400 ))"
113
+ else
114
+ printf '%d' "$(( now - 604800 ))" # rolling trailing 7 days
115
+ fi
116
+ }
117
+
118
+ # Aggregate spend over the window. Prints a JSON object:
119
+ # {total_cost_usd, total_tokens, session_count, sessions_with_cost}
120
+ # Usage: bursar_window_totals "$project_key" "$cutoff_epoch"
121
+ bursar_window_totals() {
122
+ local project_key="${1:-}"
123
+ local cutoff="${2:-0}"
124
+ local ledger_path
125
+ ledger_path=$(bursar_ledger_path "$project_key")
126
+
127
+ if [[ ! -f "$ledger_path" ]]; then
128
+ printf '{"total_cost_usd":0,"total_tokens":0,"session_count":0,"sessions_with_cost":0}'
129
+ return 0
130
+ fi
131
+
132
+ jq -s --argjson cutoff "$cutoff" '
133
+ [ .[] | select((.ts_epoch // 0) >= $cutoff) ] as $w
134
+ | {
135
+ total_cost_usd: ([ $w[] | (.cost_usd // 0) ] | add // 0),
136
+ total_tokens: ([ $w[] | (.tokens // 0) ] | add // 0),
137
+ session_count: ($w | length),
138
+ sessions_with_cost: ([ $w[] | select(.cost_usd != null) ] | length)
139
+ }
140
+ ' "$ledger_path" 2>/dev/null \
141
+ || printf '{"total_cost_usd":0,"total_tokens":0,"session_count":0,"sessions_with_cost":0}'
142
+ }
143
+
144
+ # Human-friendly token count: 1234567 -> "1.2M", 42000 -> "42k", 800 -> "800".
145
+ bursar_fmt_tokens() {
146
+ local n="${1:-0}"
147
+ awk -v n="$n" 'BEGIN {
148
+ if (n >= 1000000) printf "%.1fM", n/1000000;
149
+ else if (n >= 1000) printf "%.0fk", n/1000;
150
+ else printf "%d", n;
151
+ }'
152
+ }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ # Project key derivation for bursar.
3
+ #
4
+ # Mirrors the archivist/tribunal project-key scheme so the plugins partition
5
+ # storage identically. A project key is a stable 12-char hex identifier that
6
+ # survives:
7
+ # - local rename of the repo directory
8
+ # - cloning the same repo to a different path on the same machine
9
+ # - moving the repo between machines (as long as the git remote is preserved)
10
+ # - worktrees (a worktree shares its parent repo's key)
11
+ #
12
+ # Resolution order:
13
+ # 1. SHA256(`git remote get-url origin`) — preferred, machine-portable
14
+ # 2. SHA256(realpath of `git rev-parse --show-toplevel`) — fallback for repos
15
+ # without an origin remote (greenfield local-only work)
16
+ #
17
+ # Returns the first 12 hex chars. Returns empty string if neither resolution
18
+ # path works.
19
+
20
+ _bursar_sha256_first12() {
21
+ local input="$1"
22
+ if command -v shasum >/dev/null 2>&1; then
23
+ printf '%s' "$input" | shasum -a 256 2>/dev/null | cut -c1-12
24
+ elif command -v sha256sum >/dev/null 2>&1; then
25
+ printf '%s' "$input" | sha256sum 2>/dev/null | cut -c1-12
26
+ else
27
+ return 1
28
+ fi
29
+ }
30
+
31
+ bursar_project_remote_url() {
32
+ local cwd="${1:-}"
33
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
34
+ git -C "$cwd" remote get-url origin 2>/dev/null || true
35
+ }
36
+
37
+ # Worktree-aware: uses common-dir so worktrees share a key with the main repo.
38
+ bursar_project_repo_root() {
39
+ local cwd="${1:-}"
40
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
41
+
42
+ if ! git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
43
+ return 0
44
+ fi
45
+
46
+ local common_dir toplevel
47
+ common_dir=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || return 0
48
+
49
+ if [[ -n "$common_dir" && "$common_dir" != /* ]]; then
50
+ common_dir="$(cd "$cwd" && cd "$common_dir" 2>/dev/null && pwd -P)" || common_dir=""
51
+ fi
52
+
53
+ if [[ -n "$common_dir" && -d "$common_dir" ]]; then
54
+ toplevel="$(cd "$common_dir/.." 2>/dev/null && pwd -P)" || toplevel=""
55
+ fi
56
+
57
+ if [[ -z "$toplevel" ]]; then
58
+ toplevel=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
59
+ [[ -n "$toplevel" ]] && toplevel="$(cd "$toplevel" 2>/dev/null && pwd -P)"
60
+ fi
61
+
62
+ printf '%s' "$toplevel"
63
+ }
64
+
65
+ # Compute the project key for the given cwd. Prints the key or empty string.
66
+ bursar_project_key() {
67
+ local cwd="${1:-}"
68
+ [[ -z "$cwd" ]] && cwd="$(pwd)"
69
+
70
+ local remote
71
+ remote=$(bursar_project_remote_url "$cwd")
72
+ if [[ -n "$remote" ]]; then
73
+ _bursar_sha256_first12 "remote:$remote"
74
+ return 0
75
+ fi
76
+
77
+ local root
78
+ root=$(bursar_project_repo_root "$cwd")
79
+ if [[ -n "$root" ]]; then
80
+ _bursar_sha256_first12 "root:$root"
81
+ return 0
82
+ fi
83
+
84
+ return 0
85
+ }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # Minimal ULID generator for bursar record ids.
3
+ #
4
+ # Spec: https://github.com/ulid/spec
5
+ # - 48-bit timestamp (ms since epoch) → 10 chars Crockford Base32
6
+ # - 80-bit randomness → 16 chars Crockford Base32
7
+ # - lexicographically sortable, time-ordered
8
+ #
9
+ # Copied from plugins/tribunal/scripts/lib/tribunal-ulid.sh and renamed; the
10
+ # ecosystem ships one *_ulid helper per plugin rather than a shared one.
11
+
12
+ _BURSAR_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
13
+
14
+ _bursar_ulid_encode() {
15
+ local n="$1"
16
+ local len="$2"
17
+ local out=""
18
+ local i
19
+ for ((i = 0; i < len; i++)); do
20
+ out="${_BURSAR_ULID_ALPHABET:$((n % 32)):1}${out}"
21
+ n=$((n / 32))
22
+ done
23
+ printf '%s' "$out"
24
+ }
25
+
26
+ bursar_ulid() {
27
+ local now_ms
28
+ if [[ "$(uname)" == "Darwin" ]]; then
29
+ now_ms=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
30
+ || now_ms=$(($(date +%s) * 1000))
31
+ else
32
+ now_ms=$(date +%s%3N 2>/dev/null) || now_ms=$(($(date +%s) * 1000))
33
+ fi
34
+
35
+ local rand_hex rand_hi rand_lo
36
+ rand_hex=$(openssl rand -hex 10 2>/dev/null)
37
+ if [[ -n "$rand_hex" && ${#rand_hex} -eq 20 ]]; then
38
+ rand_hi=$((16#${rand_hex:0:10}))
39
+ rand_lo=$((16#${rand_hex:10:10}))
40
+ else
41
+ rand_hi=$((RANDOM * 32768 + RANDOM))
42
+ rand_lo=$((RANDOM * 32768 + RANDOM))
43
+ rand_hi=$(((rand_hi * 256 + RANDOM % 256) & ((1 << 40) - 1)))
44
+ rand_lo=$(((rand_lo * 256 + RANDOM % 256) & ((1 << 40) - 1)))
45
+ fi
46
+
47
+ local ts_part hi_part lo_part
48
+ ts_part=$(_bursar_ulid_encode "$now_ms" 10)
49
+ hi_part=$(_bursar_ulid_encode "$rand_hi" 8)
50
+ lo_part=$(_bursar_ulid_encode "$rand_lo" 8)
51
+
52
+ printf '%s%s%s' "$ts_part" "$hi_part" "$lo_part"
53
+ }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ # portable-lock.sh — vendored copy of the ecosystem substrate's portable lock.
3
+ #
4
+ # Vendored into the bursar plugin so the per-project ledger's atomic upserts
5
+ # keep working when bursar is installed standalone from the marketplace: the
6
+ # cache layout (~/.claude/plugins/cache/<owner>/bursar/<version>/) does not
7
+ # include the ecosystem repo's top-level scripts/lib/. Without a local copy,
8
+ # lock_acquire would be undefined and the SessionEnd upsert could clobber a
9
+ # concurrent writer. This mirrors the per-plugin vendoring of bursar-ulid.sh
10
+ # and friends.
11
+ # Keep in sync with scripts/lib/portable-lock.sh at the repo root.
12
+ #
13
+ # Portable advisory file locking via mkdir() atomicity.
14
+ #
15
+ # Replaces flock(1), which ships with util-linux on Linux but is not present
16
+ # in stock macOS. This matters because the Onlooker hooks run on user
17
+ # machines, not just in CI: a macOS user without util-linux would otherwise
18
+ # see concurrent writes to $ONLOOKER_DIR silently clobber each other.
19
+ #
20
+ # mkdir() is atomic on POSIX local filesystems, which is the only place
21
+ # $ONLOOKER_DIR ever lives. Network filesystems (NFS) do not guarantee
22
+ # atomicity, but Claude Code state is local-only.
23
+ #
24
+ # Usage:
25
+ # lock_acquire "/path/to/file.lock" [timeout_seconds=5]
26
+ # # ... critical section ...
27
+ # lock_release "/path/to/file.lock"
28
+ #
29
+ # Avoid associative arrays so bash 3.2 (macOS default) keeps working.
30
+
31
+ # Acquire an exclusive lock at LOCKPATH. Returns 0 on success, 1 on timeout.
32
+ lock_acquire() {
33
+ local lockpath="${1:-}"
34
+ local timeout="${2:-5}"
35
+ [[ -z "$lockpath" ]] && return 1
36
+
37
+ local lockdir="${lockpath}.d"
38
+ local waited=0
39
+ # Poll at 10 Hz so a 5s timeout = 50 attempts.
40
+ local max_iter=$((timeout * 10))
41
+ while ! mkdir "$lockdir" 2>/dev/null; do
42
+ if ((waited >= max_iter)); then
43
+ return 1
44
+ fi
45
+ # `sleep 0.1` works on Linux + macOS; the `|| sleep 1` is a paranoid
46
+ # fallback for embedded shells that only accept integer seconds.
47
+ sleep 0.1 2>/dev/null || sleep 1
48
+ waited=$((waited + 1))
49
+ done
50
+ return 0
51
+ }
52
+
53
+ # Release the lock previously acquired for LOCKPATH. Safe to call when the
54
+ # lock is not held (no-op in that case).
55
+ lock_release() {
56
+ local lockpath="${1:-}"
57
+ [[ -z "$lockpath" ]] && return 0
58
+ rmdir "${lockpath}.d" 2>/dev/null || true
59
+ }
@@ -222,6 +222,22 @@
222
222
  "jsonpath": "$.version"
223
223
  }
224
224
  ]
225
+ },
226
+ "plugins/bursar": {
227
+ "changelog-path": "CHANGELOG.md",
228
+ "release-type": "simple",
229
+ "bump-minor-pre-major": true,
230
+ "bump-patch-for-minor-pre-major": false,
231
+ "component": "bursar",
232
+ "draft": false,
233
+ "prerelease": false,
234
+ "extra-files": [
235
+ {
236
+ "type": "json",
237
+ "path": ".claude-plugin/plugin.json",
238
+ "jsonpath": "$.version"
239
+ }
240
+ ]
225
241
  }
226
242
  },
227
243
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/bursar"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/bursar-config.sh"
11
+ }
12
+
13
+ @test "bursar is disabled by default" {
14
+ bursar_config_load ""
15
+ run bursar_config_enabled
16
+ [ "$status" -ne 0 ]
17
+ }
18
+
19
+ @test "user-level settings.json can enable bursar" {
20
+ mkdir -p "${HOME}/.claude"
21
+ printf '%s\n' '{"bursar":{"enabled":true}}' > "${HOME}/.claude/settings.json"
22
+ bursar_config_load ""
23
+ run bursar_config_enabled
24
+ [ "$status" -eq 0 ]
25
+ }
26
+
27
+ @test "repo-level settings.json overrides user-level" {
28
+ mkdir -p "${HOME}/.claude"
29
+ printf '%s\n' '{"bursar":{"enabled":true}}' > "${HOME}/.claude/settings.json"
30
+ local repo="${BATS_TEST_TMPDIR}/repo"
31
+ mkdir -p "${repo}/.claude"
32
+ printf '%s\n' '{"bursar":{"enabled":false}}' > "${repo}/.claude/settings.json"
33
+ bursar_config_load "$repo"
34
+ run bursar_config_enabled
35
+ [ "$status" -ne 0 ]
36
+ }
37
+
38
+ @test "default window is rolling_7d" {
39
+ bursar_config_load ""
40
+ [ "$(bursar_config_window)" = "rolling_7d" ]
41
+ }
42
+
43
+ @test "window can be set to calendar_week" {
44
+ mkdir -p "${HOME}/.claude"
45
+ printf '%s\n' '{"bursar":{"window":"calendar_week"}}' > "${HOME}/.claude/settings.json"
46
+ bursar_config_load ""
47
+ [ "$(bursar_config_window)" = "calendar_week" ]
48
+ }
49
+
50
+ @test "an invalid window falls back to rolling_7d" {
51
+ mkdir -p "${HOME}/.claude"
52
+ printf '%s\n' '{"bursar":{"window":"yearly"}}' > "${HOME}/.claude/settings.json"
53
+ bursar_config_load ""
54
+ [ "$(bursar_config_window)" = "rolling_7d" ]
55
+ }
56
+
57
+ @test "default week_start is monday" {
58
+ bursar_config_load ""
59
+ [ "$(bursar_config_week_start)" = "monday" ]
60
+ }
61
+
62
+ @test "week_start can be set to sunday" {
63
+ mkdir -p "${HOME}/.claude"
64
+ printf '%s\n' '{"bursar":{"week_start":"sunday"}}' > "${HOME}/.claude/settings.json"
65
+ bursar_config_load ""
66
+ [ "$(bursar_config_week_start)" = "sunday" ]
67
+ }
68
+
69
+ @test "surfacing is on by default and can be disabled" {
70
+ bursar_config_load ""
71
+ run bursar_config_surface_enabled
72
+ [ "$status" -eq 0 ]
73
+
74
+ mkdir -p "${HOME}/.claude"
75
+ printf '%s\n' '{"bursar":{"surface_at_session_start":false}}' > "${HOME}/.claude/settings.json"
76
+ bursar_config_load ""
77
+ run bursar_config_surface_enabled
78
+ [ "$status" -ne 0 ]
79
+ }