data-olympus 0.3.0__py3-none-any.whl

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.
Files changed (73) hide show
  1. data_olympus/__init__.py +14 -0
  2. data_olympus/_bin/_kb_detect_workspace.sh +57 -0
  3. data_olympus/_bin/_kb_enforce.py +619 -0
  4. data_olympus/_bin/_kb_fallback.py +361 -0
  5. data_olympus/_bin/kb +957 -0
  6. data_olympus/_bin/kb-enforce-hook +337 -0
  7. data_olympus/_bin/opencode/data-olympus-gate.ts +102 -0
  8. data_olympus/audit_log.py +275 -0
  9. data_olympus/audit_trailers.py +42 -0
  10. data_olympus/auth.py +139 -0
  11. data_olympus/cli/__init__.py +1 -0
  12. data_olympus/cli/import_cmd.py +115 -0
  13. data_olympus/cli/indexgen.py +60 -0
  14. data_olympus/cli/main.py +151 -0
  15. data_olympus/cli/report_cmd.py +181 -0
  16. data_olympus/config.py +261 -0
  17. data_olympus/cooccurrence.py +393 -0
  18. data_olympus/dedup.py +57 -0
  19. data_olympus/durable.py +51 -0
  20. data_olympus/embeddings.py +317 -0
  21. data_olympus/enforce_policy.py +297 -0
  22. data_olympus/format/__init__.py +16 -0
  23. data_olympus/format/document.py +39 -0
  24. data_olympus/format/frontmatter.py +35 -0
  25. data_olympus/format/lint.py +92 -0
  26. data_olympus/format/validate.py +71 -0
  27. data_olympus/git_ops.py +397 -0
  28. data_olympus/health.py +114 -0
  29. data_olympus/importer/__init__.py +13 -0
  30. data_olympus/importer/adr.py +286 -0
  31. data_olympus/importer/flat.py +170 -0
  32. data_olympus/importer/model.py +106 -0
  33. data_olympus/importer/okf.py +192 -0
  34. data_olympus/importer/run.py +416 -0
  35. data_olympus/importer/stamp.py +227 -0
  36. data_olympus/index.py +1745 -0
  37. data_olympus/markdown_parse.py +103 -0
  38. data_olympus/models.py +480 -0
  39. data_olympus/onboarding.py +131 -0
  40. data_olympus/onboarding_inflight.py +137 -0
  41. data_olympus/onboarding_playbook.py +99 -0
  42. data_olympus/pending.py +533 -0
  43. data_olympus/principals.py +168 -0
  44. data_olympus/prompts.py +35 -0
  45. data_olympus/push_queue.py +261 -0
  46. data_olympus/query_expansion.py +200 -0
  47. data_olympus/rate_limit.py +81 -0
  48. data_olympus/refresh.py +329 -0
  49. data_olympus/report.py +133 -0
  50. data_olympus/rest_api.py +845 -0
  51. data_olympus/safe_id.py +36 -0
  52. data_olympus/search_gate.py +64 -0
  53. data_olympus/search_shortcut.py +146 -0
  54. data_olympus/server.py +1115 -0
  55. data_olympus/session_metrics.py +303 -0
  56. data_olympus/setup_wizard.py +751 -0
  57. data_olympus/thin_pointer.py +20 -0
  58. data_olympus/tools_audit.py +41 -0
  59. data_olympus/tools_enforce.py +198 -0
  60. data_olympus/tools_onboarding.py +585 -0
  61. data_olympus/tools_read.py +230 -0
  62. data_olympus/tools_write.py +878 -0
  63. data_olympus/trigram.py +126 -0
  64. data_olympus/viewer/__init__.py +1 -0
  65. data_olympus/viewer/generator.py +375 -0
  66. data_olympus/worktrees.py +147 -0
  67. data_olympus/write_gate.py +382 -0
  68. data_olympus-0.3.0.dist-info/METADATA +97 -0
  69. data_olympus-0.3.0.dist-info/RECORD +73 -0
  70. data_olympus-0.3.0.dist-info/WHEEL +4 -0
  71. data_olympus-0.3.0.dist-info/entry_points.txt +3 -0
  72. data_olympus-0.3.0.dist-info/licenses/LICENSE +202 -0
  73. data_olympus-0.3.0.dist-info/licenses/NOTICE +8 -0
data_olympus/_bin/kb ADDED
@@ -0,0 +1,957 @@
1
+ #!/usr/bin/env bash
2
+ # kb - data-olympus-mcp client CLI
3
+ #
4
+ # Usage:
5
+ # kb search QUERY [-o md|json|plain] [--tier T] [--category C] [--limit N] [--no-stale]
6
+ # kb get ID [-o md|json|plain] [--no-stale]
7
+ # kb list TIER [CATEGORY] [-o md|json|plain] [--no-stale]
8
+ # kb outline [-o md|json|plain] [--no-stale]
9
+ # kb health [-o json|plain]
10
+ # kb propose memory TEXT [--tags t1,t2] [--confidence 0.9] [--non-interactive]
11
+ # kb propose edit TARGET_PATH --postimage-file FILE [--reason R] [--confidence 0.9] [--non-interactive]
12
+ # kb resolve PENDING_ID --decision approve|reject [--edit-text TEXT]
13
+ # kb pending [-o json|plain]
14
+ # kb audit [--since EPOCH] [--agent A] [--status S] [--limit N] [-o json|plain]
15
+ # kb audit --verify (recompute the tamper-evident hash chain)
16
+ # kb onboarding-check [--workspace W] [--component C] [--workspace-remote-url U] [--component-remote-url U]
17
+ # kb onboard status <workspace> [<component>]
18
+ # kb onboard project <workspace>
19
+ # kb onboard component <workspace> <component>
20
+ # kb onboard rename <from> <to>
21
+ # kb onboard playbook [--kind dispatch|project|component] [--workspace W] [--component C]
22
+ # kb enforce install|uninstall|status|doctor [--agent NAME | --all] [--settings PATH]
23
+ # kb enforce report [--workspace W] [--range A..B | --since S] [--json] [--fail-on-unverified]
24
+ #
25
+ # Environment:
26
+ # KB_ENDPOINT REST endpoint URL (default: http://localhost:8080)
27
+ # KB_LOCAL_PATH Local KB checkout for fallback (default: $PWD)
28
+ # KB_NO_STALE Set to 1 to refuse fallback and degraded responses (equivalent to --no-stale)
29
+ # EDITOR/VISUAL Editor for interactive proposal editing (fallback: vi)
30
+ #
31
+ # Exit codes:
32
+ # 0 success (including default-mode fallback with stderr warning)
33
+ # 1 --no-stale set AND endpoint unreachable; OR REST call failed for a write subcommand
34
+ # 2 --no-stale set AND REST returned degraded:true (HTTP 200 or 503); OR proposal rejected
35
+ # 3 unexpected REST response for a write subcommand
36
+ # 64 usage error (unknown subcommand or bad flags)
37
+
38
+ set -euo pipefail
39
+
40
+ KB_ENDPOINT="${KB_ENDPOINT:-http://localhost:8080}"
41
+ KB_LOCAL_PATH="${KB_LOCAL_PATH:-$PWD}"
42
+ KB_NO_STALE="${KB_NO_STALE:-0}"
43
+ KB_AUTH_TOKEN="${KB_AUTH_TOKEN:-}"
44
+
45
+ usage() {
46
+ sed -n '/^# Usage:/,/^$/p' "$0" | sed 's/^# \{0,1\}//'
47
+ exit 64
48
+ }
49
+
50
+ if [[ $# -eq 0 ]]; then usage; fi
51
+
52
+ SUBCMD="$1"; shift
53
+
54
+ # Default output format per subcommand
55
+ case "$SUBCMD" in
56
+ search|list|outline|health|pending|audit) FORMAT="json" ;;
57
+ get) FORMAT="json" ;;
58
+ *) FORMAT="json" ;;
59
+ esac
60
+
61
+ NO_STALE=0
62
+ TIER=""
63
+ CATEGORY=""
64
+ LIMIT=20
65
+ POSITIONAL=()
66
+
67
+ # Honor env var
68
+ if [[ "$KB_NO_STALE" == "1" ]]; then NO_STALE=1; fi
69
+
70
+ need_value() {
71
+ local flag="$1" value="${2:-}"
72
+ if [[ -z "$value" || "${value:0:1}" == "-" ]]; then
73
+ echo "kb: $flag requires a value" >&2
74
+ exit 64
75
+ fi
76
+ }
77
+
78
+ # Helper: URL-encode a value via Python (more reliable than bash for arbitrary chars).
79
+ # Defined here (before subcommand functions) so the write-side cmd_* functions
80
+ # below can use it without relying on bash late-binding for later definitions.
81
+ url_encode() {
82
+ python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1"
83
+ }
84
+
85
+ # Format JSON output (use jq if available). Same rationale as url_encode for
86
+ # the early definition.
87
+ format_json() {
88
+ if command -v jq >/dev/null 2>&1; then
89
+ echo "$REST_BODY" | jq .
90
+ else
91
+ echo "$REST_BODY"
92
+ fi
93
+ }
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Write-side helpers + subcommands. These post to the REST
97
+ # write endpoints exposed by data-olympus-mcp. They do NOT use the local
98
+ # fallback: write paths must be intentional and synchronous.
99
+ # ---------------------------------------------------------------------------
100
+
101
+ # Helper: POST JSON body to a URL. Stores body in $REST_BODY, status in
102
+ # $REST_STATUS. Returns 0 on a successful connection (200/202/4xx all return
103
+ # 0); 1 on connect failure (curl exits non-zero only when the connection
104
+ # itself fails, not on HTTP error).
105
+ do_curl_post() {
106
+ local url="$1"
107
+ local body="$2"
108
+ local tmpfile
109
+ tmpfile=$(mktemp)
110
+ # Build auth argument: when KB_AUTH_TOKEN is set, pass the Authorization
111
+ # header via a curl config on a process-substitution FD so the token is
112
+ # NOT exposed in process arguments (visible via `ps`).
113
+ local auth_k_arg=()
114
+ if [[ -n "$KB_AUTH_TOKEN" ]]; then
115
+ auth_k_arg=(-K <(printf 'header = "Authorization: Bearer %s"\n' "$KB_AUTH_TOKEN"))
116
+ fi
117
+ # NOTE: expand auth_k_arg with the ${arr[@]+"${arr[@]}"} idiom, not a bare
118
+ # "${arr[@]}". Under `set -u`, expanding an EMPTY array as "${arr[@]}" aborts
119
+ # with "unbound variable" on bash < 4.4 (incl. macOS's system bash 3.2). The
120
+ # idiom expands to nothing when the array is empty and is safe on all bashes.
121
+ if REST_STATUS=$(curl --silent --max-time 10 \
122
+ -H "Content-Type: application/json" \
123
+ ${auth_k_arg[@]+"${auth_k_arg[@]}"} \
124
+ --data "$body" \
125
+ --output "$tmpfile" --write-out "%{http_code}" \
126
+ -X POST "$url" 2>/dev/null); then
127
+ REST_BODY=$(cat "$tmpfile")
128
+ rm -f "$tmpfile"
129
+ return 0
130
+ fi
131
+ rm -f "$tmpfile"
132
+ return 1
133
+ }
134
+
135
+ # Helper: fetch the KB's current commit from /api/v1/health, to use as the
136
+ # base_commit of an edit proposal. Echoes the sha on success, empty on failure.
137
+ # Writes never fall back to another endpoint, so this targets the primary
138
+ # $KB_ENDPOINT only (consistent with the rest of the write path). Defined here,
139
+ # before the write-subcommand dispatch, because the GET-side do_curl helper is
140
+ # defined later in the file and is not yet available when cmd_propose_edit runs.
141
+ kb_fetch_base_commit() {
142
+ local tmpfile sha=""
143
+ tmpfile=$(mktemp)
144
+ if curl --silent --max-time 10 --output "$tmpfile" \
145
+ "$KB_ENDPOINT/api/v1/health" 2>/dev/null; then
146
+ sha=$(jq -r '.kb_commit // ""' < "$tmpfile" 2>/dev/null) || sha=""
147
+ fi
148
+ rm -f "$tmpfile"
149
+ printf '%s' "$sha"
150
+ }
151
+
152
+ # Helper: interactive resolve flow. Prompts the operator for
153
+ # accept/edit/reject and posts the appropriate /api/v1/resolve/<id> call.
154
+ # Reads /dev/tty so bats and other non-tty harnesses can bypass via
155
+ # --non-interactive. Honors $EDITOR -> $VISUAL -> vi fallback chain.
156
+ interactive_resolve() {
157
+ local pid="$1"
158
+ local proposal_text="${2:-}"
159
+ echo "--- proposal (pending_id=$pid) ---"
160
+ printf "%s\n" "$proposal_text" | head -30
161
+ echo "--- end ---"
162
+ printf "[a]ccept / [e]dit / [r]eject (default: a)? "
163
+ local answer="a"
164
+ # If /dev/tty is unavailable (CI, bats), treat as accept.
165
+ if [[ -r /dev/tty ]]; then
166
+ read -r answer < /dev/tty || answer="a"
167
+ fi
168
+ case "${answer:-a}" in
169
+ e|E|edit)
170
+ local tmp editor
171
+ tmp=$(mktemp -t kb-edit.XXXXXX)
172
+ printf "%s\n" "$proposal_text" > "$tmp"
173
+ editor="${EDITOR:-${VISUAL:-vi}}"
174
+ # shellcheck disable=SC2086 # EDITOR may be a command with args.
175
+ $editor "$tmp" < /dev/tty || true
176
+ local edited
177
+ edited=$(cat "$tmp")
178
+ rm -f "$tmp"
179
+ local body
180
+ body=$(jq -n --arg t "$edited" '{decision: "approve", edited_text: $t}')
181
+ if do_curl_post "$KB_ENDPOINT/api/v1/resolve/$pid" "$body"; then
182
+ format_json
183
+ else
184
+ echo "kb: resolve POST failed" >&2; exit 1
185
+ fi
186
+ ;;
187
+ r|R|reject)
188
+ if do_curl_post "$KB_ENDPOINT/api/v1/resolve/$pid" '{"decision": "reject"}'; then
189
+ format_json
190
+ else
191
+ echo "kb: resolve POST failed" >&2; exit 1
192
+ fi
193
+ ;;
194
+ *)
195
+ if do_curl_post "$KB_ENDPOINT/api/v1/resolve/$pid" '{"decision": "approve"}'; then
196
+ format_json
197
+ else
198
+ echo "kb: resolve POST failed" >&2; exit 1
199
+ fi
200
+ ;;
201
+ esac
202
+ }
203
+
204
+ # Helper: render a propose-response. Used by both propose memory and
205
+ # propose edit since the response schema is identical.
206
+ handle_propose_response() {
207
+ local non_interactive="$1"
208
+ # Guard: the response must be a JSON OBJECT. A plain-text 5xx ("Internal
209
+ # Server Error"), an empty body, or any non-object JSON would otherwise be
210
+ # piped to jq below and abort the script with an opaque "jq: parse error".
211
+ # Surface a clean, actionable diagnostic with the HTTP status and raw body
212
+ # instead. (Valid object bodies, including 4xx rejected_* responses, pass.)
213
+ if ! echo "$REST_BODY" | jq -e 'type == "object"' >/dev/null 2>&1; then
214
+ echo "kb: server error ${REST_STATUS:-?}: ${REST_BODY:-<empty response>}" >&2
215
+ exit 1
216
+ fi
217
+ local status pid proposal
218
+ status=$(echo "$REST_BODY" | jq -r '.status // ""')
219
+ case "$status" in
220
+ committed)
221
+ local sha push
222
+ sha=$(echo "$REST_BODY" | jq -r '.commit_sha // ""')
223
+ push=$(echo "$REST_BODY" | jq -r '.push_state // ""')
224
+ echo "Committed $sha; push $push."
225
+ ;;
226
+ pending_confirmation)
227
+ pid=$(echo "$REST_BODY" | jq -r '.pending_id // ""')
228
+ proposal=$(echo "$REST_BODY" | jq -r '.proposal_text // ""')
229
+ if [[ "$non_interactive" == "1" ]]; then
230
+ echo "Pending $pid; run \`kb resolve $pid --decision approve\` to act."
231
+ else
232
+ interactive_resolve "$pid" "$proposal"
233
+ fi
234
+ ;;
235
+ rejected_*)
236
+ local reason
237
+ reason=$(echo "$REST_BODY" | jq -r '.reason // ""')
238
+ echo "Rejected: $status${reason:+ ($reason)}" >&2
239
+ exit 2
240
+ ;;
241
+ *)
242
+ echo "kb: unexpected response: $REST_BODY" >&2
243
+ exit 3
244
+ ;;
245
+ esac
246
+ }
247
+
248
+ # Subcommand: kb propose memory TEXT [--tags ...] [--confidence ...] [--non-interactive]
249
+ cmd_propose_memory() {
250
+ local text="" tags="" confidence="0.9" non_interactive=0
251
+ while [[ $# -gt 0 ]]; do
252
+ case "$1" in
253
+ --tags) need_value "$1" "${2:-}"; tags="$2"; shift 2 ;;
254
+ --confidence) need_value "$1" "${2:-}"; confidence="$2"; shift 2 ;;
255
+ --non-interactive) non_interactive=1; shift ;;
256
+ -h|--help) usage ;;
257
+ -*) echo "kb: unknown flag $1" >&2; exit 64 ;;
258
+ *) [[ -z "$text" ]] && text="$1" || { echo "kb: too many positional args" >&2; exit 64; }; shift ;;
259
+ esac
260
+ done
261
+ [[ -z "$text" ]] && { echo "kb: propose memory requires TEXT" >&2; exit 64; }
262
+ local tags_json
263
+ tags_json=$(printf "%s" "$tags" | tr ',' '\n' | jq -R . | jq -s 'map(select(. != ""))')
264
+ local body
265
+ body=$(jq -n \
266
+ --arg t "$text" \
267
+ --argjson tags "$tags_json" \
268
+ --arg session "${USER:-cli}" \
269
+ --arg agent "operator-cli" \
270
+ --arg conf "$confidence" \
271
+ '{text: $t, tags: $tags, source_session: $session, agent_identity: $agent, confidence: ($conf | tonumber)}')
272
+ if ! do_curl_post "$KB_ENDPOINT/api/v1/propose/memory" "$body"; then
273
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT" >&2; exit 1
274
+ fi
275
+ handle_propose_response "$non_interactive"
276
+ }
277
+
278
+ # Subcommand: kb propose edit TARGET --postimage-file FILE [--reason ...] [--confidence ...] [--non-interactive]
279
+ cmd_propose_edit() {
280
+ local target="" postfile="" reason="" confidence="0.9" non_interactive=0
281
+ while [[ $# -gt 0 ]]; do
282
+ case "$1" in
283
+ --postimage-file) need_value "$1" "${2:-}"; postfile="$2"; shift 2 ;;
284
+ --reason) need_value "$1" "${2:-}"; reason="$2"; shift 2 ;;
285
+ --confidence) need_value "$1" "${2:-}"; confidence="$2"; shift 2 ;;
286
+ --non-interactive) non_interactive=1; shift ;;
287
+ -h|--help) usage ;;
288
+ -*) echo "kb: unknown flag $1" >&2; exit 64 ;;
289
+ *) [[ -z "$target" ]] && target="$1" || { echo "kb: too many positional args" >&2; exit 64; }; shift ;;
290
+ esac
291
+ done
292
+ [[ -z "$target" ]] && { echo "kb: propose edit requires TARGET_PATH" >&2; exit 64; }
293
+ [[ -z "$postfile" ]] && { echo "kb: propose edit requires --postimage-file" >&2; exit 64; }
294
+ [[ -r "$postfile" ]] || { echo "kb: cannot read postimage file: $postfile" >&2; exit 64; }
295
+ local postimage
296
+ postimage=$(cat "$postfile")
297
+ # The server REQUIRES base_commit on /api/v1/propose/edit. We send the KB's
298
+ # current commit (from /api/v1/health). NOTE: this is the server's HEAD at
299
+ # submit time, not necessarily the commit the postimage was diffed against;
300
+ # the server stores it but does not yet enforce a stale-edit (CAS) check on
301
+ # resolve, so this satisfies the contract without guaranteeing conflict
302
+ # detection. base_blob_sha / target_file_hash are optional server-side (treated
303
+ # as None when absent), so they are intentionally omitted.
304
+ local base_commit
305
+ base_commit=$(kb_fetch_base_commit)
306
+ if [[ -z "$base_commit" ]]; then
307
+ echo "kb: could not determine base_commit (GET $KB_ENDPOINT/api/v1/health failed or returned no kb_commit)" >&2
308
+ exit 1
309
+ fi
310
+ local body
311
+ body=$(jq -n \
312
+ --arg target "$target" \
313
+ --arg post "$postimage" \
314
+ --arg base "$base_commit" \
315
+ --arg reason "$reason" \
316
+ --arg session "${USER:-cli}" \
317
+ --arg agent "operator-cli" \
318
+ --arg conf "$confidence" \
319
+ '{target_path: $target, postimage: $post, base_commit: $base, reason: $reason, source_session: $session, agent_identity: $agent, confidence: ($conf | tonumber)}')
320
+ if ! do_curl_post "$KB_ENDPOINT/api/v1/propose/edit" "$body"; then
321
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT" >&2; exit 1
322
+ fi
323
+ handle_propose_response "$non_interactive"
324
+ }
325
+
326
+ # Subcommand: kb resolve PENDING_ID --decision approve|reject [--edit-text TEXT]
327
+ cmd_resolve() {
328
+ local pid="" decision="" edit_text="" edit_text_set=0
329
+ while [[ $# -gt 0 ]]; do
330
+ case "$1" in
331
+ --decision) need_value "$1" "${2:-}"; decision="$2"; shift 2 ;;
332
+ --edit-text) need_value "$1" "${2:-}"; edit_text="$2"; edit_text_set=1; shift 2 ;;
333
+ -h|--help) usage ;;
334
+ -*) echo "kb: unknown flag $1" >&2; exit 64 ;;
335
+ *) [[ -z "$pid" ]] && pid="$1" || { echo "kb: too many positional args" >&2; exit 64; }; shift ;;
336
+ esac
337
+ done
338
+ [[ -z "$pid" ]] && { echo "kb: resolve requires PENDING_ID" >&2; exit 64; }
339
+ case "$decision" in
340
+ approve|reject) ;;
341
+ *) echo "kb: --decision must be approve or reject" >&2; exit 64 ;;
342
+ esac
343
+ local body
344
+ if [[ "$decision" == "approve" && "$edit_text_set" == "1" ]]; then
345
+ body=$(jq -n --arg t "$edit_text" '{decision: "approve", edited_text: $t}')
346
+ elif [[ "$decision" == "approve" ]]; then
347
+ body='{"decision": "approve"}'
348
+ else
349
+ body='{"decision": "reject"}'
350
+ fi
351
+ if ! do_curl_post "$KB_ENDPOINT/api/v1/resolve/$pid" "$body"; then
352
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT" >&2; exit 1
353
+ fi
354
+ format_json
355
+ }
356
+
357
+ # Subcommand: kb pending [-o json|plain]
358
+ cmd_pending() {
359
+ local fmt="json"
360
+ while [[ $# -gt 0 ]]; do
361
+ case "$1" in
362
+ -o|--output) need_value "$1" "${2:-}"; fmt="$2"; shift 2 ;;
363
+ -h|--help) usage ;;
364
+ -*) echo "kb: unknown flag $1" >&2; exit 64 ;;
365
+ *) echo "kb: pending takes no positional args" >&2; exit 64 ;;
366
+ esac
367
+ done
368
+ local tmpfile
369
+ tmpfile=$(mktemp)
370
+ # When KB_AUTH_TOKEN is set, /api/v1/pending requires authentication; pass the
371
+ # bearer header via a curl config FD so the token is never visible in argv.
372
+ local auth_k_arg=()
373
+ if [[ -n "$KB_AUTH_TOKEN" ]]; then
374
+ auth_k_arg=(-K <(printf 'header = "Authorization: Bearer %s"\n' "$KB_AUTH_TOKEN"))
375
+ fi
376
+ if ! curl --silent --max-time 10 --output "$tmpfile" \
377
+ ${auth_k_arg[@]+"${auth_k_arg[@]}"} \
378
+ "$KB_ENDPOINT/api/v1/pending" 2>/dev/null; then
379
+ rm -f "$tmpfile"
380
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT" >&2; exit 1
381
+ fi
382
+ REST_BODY=$(cat "$tmpfile"); rm -f "$tmpfile"
383
+ if [[ "$fmt" == "plain" ]] && command -v jq >/dev/null 2>&1; then
384
+ echo "$REST_BODY" | jq -r '.pending[] | "\(.pending_id)\t\(.proposal_type)\t\(.target_path // "-")\t\(.confidence // "-")\t\(.agent_identity // "-")"'
385
+ else
386
+ format_json
387
+ fi
388
+ }
389
+
390
+ # Subcommand: kb onboarding-check [--workspace W] [--component C] [--workspace-remote-url U] [--component-remote-url U]
391
+ # Lightweight: reads workspace + component from flags (or from CWD via the
392
+ # detect helper when flags are absent), queries the MCP, emits a one-liner
393
+ # reminder when state != onboarded. Designed for SessionStart hooks.
394
+ cmd_onboarding_check() {
395
+ local workspace="" component="" ws_url="" comp_url=""
396
+ while [[ $# -gt 0 ]]; do
397
+ case "$1" in
398
+ --workspace) need_value "$1" "${2:-}"; workspace="$2"; shift 2 ;;
399
+ --component) shift; component="${1:-}"; shift ;;
400
+ --workspace-remote-url) shift; ws_url="${1:-}"; shift ;;
401
+ --component-remote-url) shift; comp_url="${1:-}"; shift ;;
402
+ -h|--help) usage ;;
403
+ *) echo "kb: unknown arg $1" >&2; exit 64 ;;
404
+ esac
405
+ done
406
+ if [[ -z "$workspace" ]]; then
407
+ # shellcheck disable=SC1091
408
+ . "$(dirname "$0")/_kb_detect_workspace.sh"
409
+ detect_workspace_and_component "$PWD" || exit 0
410
+ workspace="$WORKSPACE"
411
+ component="$COMPONENT"
412
+ ws_url="$WORKSPACE_REMOTE_URL"
413
+ comp_url="$COMPONENT_REMOTE_URL"
414
+ fi
415
+ local q="workspace=$(url_encode "$workspace")"
416
+ [[ -n "$component" ]] && q="$q&component=$(url_encode "$component")"
417
+ [[ -n "$ws_url" ]] && q="$q&workspace_remote_url=$(url_encode "$ws_url")"
418
+ [[ -n "$comp_url" ]] && q="$q&component_remote_url=$(url_encode "$comp_url")"
419
+ local resp
420
+ resp=$(curl --silent --max-time 5 "$KB_ENDPOINT/api/v1/onboarding/status?$q" 2>/dev/null) || exit 0
421
+ [[ -z "$resp" ]] && exit 0
422
+ local state
423
+ if command -v jq >/dev/null 2>&1; then
424
+ state=$(echo "$resp" | jq -r '.state // ""' 2>/dev/null) || exit 0
425
+ else
426
+ state=$(python3 -c 'import json,sys
427
+ try:
428
+ d = json.loads(sys.stdin.read())
429
+ print(d.get("state", ""))
430
+ except Exception:
431
+ print("")' <<< "$resp") || exit 0
432
+ fi
433
+ case "$state" in
434
+ onboarded) : ;;
435
+ absent)
436
+ if [[ -n "$component" ]]; then
437
+ echo "[KB] component '$component' in workspace '$workspace' is not onboarded. Run: kb onboard component $workspace $component"
438
+ else
439
+ echo "[KB] workspace '$workspace' is not onboarded. Run: kb onboard project $workspace"
440
+ fi
441
+ ;;
442
+ partial)
443
+ local missing
444
+ if command -v jq >/dev/null 2>&1; then
445
+ missing=$(echo "$resp" | jq -r '.missing_files | join(", ")' 2>/dev/null)
446
+ else
447
+ missing="(unknown)"
448
+ fi
449
+ echo "[KB] workspace '$workspace' onboarded but missing files: $missing"
450
+ ;;
451
+ rename_candidate)
452
+ local cand
453
+ if command -v jq >/dev/null 2>&1; then
454
+ cand=$(echo "$resp" | jq -r '.rename_candidates[0].target_workspace // ""' 2>/dev/null)
455
+ else
456
+ cand=""
457
+ fi
458
+ if [[ -n "$cand" ]]; then
459
+ echo "[KB] workspace '$workspace' may be a rename of existing '$cand'. Run: kb onboard rename $cand $workspace"
460
+ else
461
+ echo "[KB] workspace '$workspace' may be a rename of a known workspace."
462
+ fi
463
+ ;;
464
+ *) : ;;
465
+ esac
466
+ exit 0
467
+ }
468
+
469
+ # Subcommand: kb onboard status <workspace> [<component>]
470
+ # Operator-facing: print full status JSON for the given workspace/component.
471
+ cmd_onboard_status() {
472
+ local workspace="${1:-}" component="${2:-}"
473
+ if [[ -z "$workspace" ]]; then
474
+ echo "kb: usage: kb onboard status <workspace> [<component>]" >&2
475
+ exit 64
476
+ fi
477
+ local q="workspace=$(url_encode "$workspace")"
478
+ [[ -n "$component" ]] && q="$q&component=$(url_encode "$component")"
479
+ local tmpfile
480
+ tmpfile=$(mktemp)
481
+ if ! curl --silent --max-time 10 --output "$tmpfile" \
482
+ "$KB_ENDPOINT/api/v1/onboarding/status?$q" 2>/dev/null; then
483
+ rm -f "$tmpfile"
484
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT" >&2
485
+ exit 1
486
+ fi
487
+ REST_BODY=$(cat "$tmpfile"); rm -f "$tmpfile"
488
+ format_json
489
+ }
490
+
491
+ # Subcommand: kb onboard project <workspace>
492
+ # v1 stub: the interactive bootstrap flow (read local docs, propose initial
493
+ # T3 entries via POST /api/v1/onboarding/bootstrap) is deferred. Operators
494
+ # can drive POST /api/v1/onboarding/bootstrap directly until the CLI flow
495
+ # lands in a follow-up.
496
+ cmd_onboard_project() {
497
+ local workspace="${1:-}"
498
+ if [[ -z "$workspace" ]]; then
499
+ echo "kb: usage: kb onboard project <workspace>" >&2
500
+ exit 64
501
+ fi
502
+ cat <<EOF
503
+ Interactive onboarding for workspace '$workspace' is not yet implemented as a
504
+ guided flow. You can drive it directly via the REST API:
505
+
506
+ Pseudocode:
507
+ 1. Gather local documentation for workspace '$workspace' (README, agent
508
+ config files, coding standards, .rules/ directory if present).
509
+ 2. Build a files=[] list of {target_path, postimage, reason} entries
510
+ targeting the projects/$workspace/ prefix in your knowledge-base bundle.
511
+ 3. POST /api/v1/onboarding/bootstrap with:
512
+ workspace=$workspace
513
+ workspace_remote_url=<git remote origin of the workspace repo>
514
+ files=<that list>
515
+ source_session=<your session id>
516
+ agent_identity=<your agent identity>
517
+ confidence=<0.0 to 1.0>
518
+
519
+ For now:
520
+ - Use 'kb onboard status $workspace' to see current state and missing files.
521
+ - The bootstrap endpoint is fully implemented; the interactive CLI flow is
522
+ a stub and will be completed in a future release.
523
+ EOF
524
+ }
525
+
526
+ # Subcommand: kb onboard component <workspace> <component>
527
+ # v1 stub: same shape as onboard project; deferred to follow-up.
528
+ cmd_onboard_component() {
529
+ local workspace="${1:-}" component="${2:-}"
530
+ if [[ -z "$workspace" || -z "$component" ]]; then
531
+ echo "kb: usage: kb onboard component <workspace> <component>" >&2
532
+ exit 64
533
+ fi
534
+ cat <<EOF
535
+ Interactive onboarding for component '$component' in workspace '$workspace'
536
+ is operator-driven in v1.
537
+
538
+ Pseudocode:
539
+ 1. Read local docs at the component checkout (AGENTS.md, .rules/).
540
+ 2. Build a files=[] list under projects/$workspace/components/$component/.
541
+ 3. POST /api/v1/onboarding/bootstrap with workspace=$workspace,
542
+ component=$component, component_remote_url=<git remote origin>,
543
+ files=<that list>, ...
544
+
545
+ For now:
546
+ - Use 'kb onboard status $workspace $component' to see state + missing files.
547
+ - See audits/2026-06-01-phase-2d-onboarding-spec.md section 3.1.
548
+ EOF
549
+ }
550
+
551
+ # Subcommand: kb onboard rename <from> <to>
552
+ # v1 stub: prints the operator-driven git mv hint; not automated in v1.
553
+ cmd_onboard_rename() {
554
+ local from="${1:-}" to="${2:-}"
555
+ if [[ -z "$from" || -z "$to" ]]; then
556
+ echo "kb: usage: kb onboard rename <from> <to>" >&2
557
+ exit 64
558
+ fi
559
+ cat <<EOF
560
+ Rename workspace '$from' -> '$to' is operator-driven in v1.
561
+
562
+ Pseudocode:
563
+ 1. cd <your-kb-repo>
564
+ 2. git mv projects/$from projects/$to
565
+ 3. Rewrite cross-references in markdown bodies (grep + sed).
566
+ 4. Commit + push; the MCP picks up the rename via git_remote_url match.
567
+
568
+ Not automated in this slice. The rename_candidate detection in
569
+ 'kb onboard status' will surface the match once the rename lands in the KB.
570
+ EOF
571
+ }
572
+
573
+ # Subcommand: kb onboard playbook [--kind dispatch|project|component]
574
+ # [--workspace W] [--component C]
575
+ # Cross-agent fallback: prints the same guided-onboarding script the MCP
576
+ # prompts surface, for agents that cannot use native MCP prompts.
577
+ cmd_onboard_playbook() {
578
+ local kind="dispatch" workspace="" component=""
579
+ while [[ $# -gt 0 ]]; do
580
+ case "$1" in
581
+ --kind) need_value "$1" "${2:-}"; kind="$2"; shift 2 ;;
582
+ --workspace) need_value "$1" "${2:-}"; workspace="$2"; shift 2 ;;
583
+ --component) need_value "$1" "${2:-}"; component="$2"; shift 2 ;;
584
+ *) shift ;;
585
+ esac
586
+ done
587
+ local q="kind=$(url_encode "$kind")"
588
+ [[ -n "$workspace" ]] && q="$q&workspace=$(url_encode "$workspace")"
589
+ [[ -n "$component" ]] && q="$q&component=$(url_encode "$component")"
590
+ local tmpfile REST_STATUS
591
+ tmpfile=$(mktemp)
592
+ if ! REST_STATUS=$(curl --silent --max-time 10 --output "$tmpfile" \
593
+ --write-out "%{http_code}" \
594
+ "$KB_ENDPOINT/api/v1/onboarding/playbook?$q" 2>/dev/null); then
595
+ rm -f "$tmpfile"
596
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT" >&2
597
+ exit 1
598
+ fi
599
+ REST_BODY=$(cat "$tmpfile"); rm -f "$tmpfile"
600
+ if [[ "$REST_STATUS" == 2* ]]; then
601
+ python3 -c 'import sys,json; print(json.load(sys.stdin)["text"])' <<<"$REST_BODY"
602
+ return 0
603
+ fi
604
+ local err
605
+ err=$(python3 -c 'import sys,json
606
+ try:
607
+ print(json.load(sys.stdin).get("error", ""))
608
+ except Exception:
609
+ print("")' <<<"$REST_BODY" 2>/dev/null) || err=""
610
+ if [[ -n "$err" ]]; then
611
+ echo "kb: $err" >&2
612
+ else
613
+ echo "kb: server returned HTTP $REST_STATUS" >&2
614
+ fi
615
+ exit 1
616
+ }
617
+
618
+ # Subcommand: kb audit [--since EPOCH] [--agent A] [--status S] [--limit N] [-o json|plain]
619
+ cmd_audit() {
620
+ local fmt="json" since="" agent="" status_f="" limit="100" verify=""
621
+ while [[ $# -gt 0 ]]; do
622
+ case "$1" in
623
+ -o|--output) need_value "$1" "${2:-}"; fmt="$2"; shift 2 ;;
624
+ --since) need_value "$1" "${2:-}"; since="$2"; shift 2 ;;
625
+ --agent) need_value "$1" "${2:-}"; agent="$2"; shift 2 ;;
626
+ --status) need_value "$1" "${2:-}"; status_f="$2"; shift 2 ;;
627
+ --limit) need_value "$1" "${2:-}"; limit="$2"; shift 2 ;;
628
+ --verify) verify="1"; shift ;;
629
+ -h|--help) usage ;;
630
+ -*) echo "kb: unknown flag $1" >&2; exit 64 ;;
631
+ *) echo "kb: audit takes no positional args" >&2; exit 64 ;;
632
+ esac
633
+ done
634
+ # When KB_AUTH_TOKEN is set, audit routes require authentication; pass the
635
+ # bearer header via a curl config FD so the token is never visible in argv.
636
+ local auth_k_arg=()
637
+ if [[ -n "$KB_AUTH_TOKEN" ]]; then
638
+ auth_k_arg=(-K <(printf 'header = "Authorization: Bearer %s"\n' "$KB_AUTH_TOKEN"))
639
+ fi
640
+ if [[ -n "$verify" ]]; then
641
+ # Verify the tamper-evident hash chain server-side.
642
+ local vtmp
643
+ vtmp=$(mktemp)
644
+ if ! curl --silent --max-time 10 --output "$vtmp" \
645
+ ${auth_k_arg[@]+"${auth_k_arg[@]}"} \
646
+ "$KB_ENDPOINT/api/v1/audit/verify" 2>/dev/null; then
647
+ rm -f "$vtmp"
648
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT" >&2; exit 1
649
+ fi
650
+ REST_BODY=$(cat "$vtmp"); rm -f "$vtmp"
651
+ if command -v jq >/dev/null 2>&1; then
652
+ local ok
653
+ ok=$(echo "$REST_BODY" | jq -r '.ok')
654
+ if [[ "$ok" == "true" ]]; then
655
+ echo "audit chain OK"
656
+ else
657
+ echo "audit chain BROKEN at line index $(echo "$REST_BODY" | jq -r '.first_broken_index')" >&2
658
+ exit 1
659
+ fi
660
+ else
661
+ format_json
662
+ fi
663
+ return 0
664
+ fi
665
+ local url="$KB_ENDPOINT/api/v1/audit?limit=$(url_encode "$limit")"
666
+ [[ -n "$since" ]] && url="$url&since=$(url_encode "$since")"
667
+ [[ -n "$agent" ]] && url="$url&agent=$(url_encode "$agent")"
668
+ [[ -n "$status_f" ]] && url="$url&status=$(url_encode "$status_f")"
669
+ local tmpfile
670
+ tmpfile=$(mktemp)
671
+ if ! curl --silent --max-time 10 --output "$tmpfile" \
672
+ ${auth_k_arg[@]+"${auth_k_arg[@]}"} "$url" 2>/dev/null; then
673
+ rm -f "$tmpfile"
674
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT" >&2; exit 1
675
+ fi
676
+ REST_BODY=$(cat "$tmpfile"); rm -f "$tmpfile"
677
+ if [[ "$fmt" == "plain" ]] && command -v jq >/dev/null 2>&1; then
678
+ echo "$REST_BODY" | jq -r '.events[] | "\(.ts)\t\(.event_type)\t\(.status)\t\(.agent_identity // "-")\t\(.target_path // "-")\t\(.commit_sha // "-")"'
679
+ else
680
+ format_json
681
+ fi
682
+ }
683
+
684
+ # Early dispatch for write-side subcommands. These have their own argument
685
+ # parsing (different flags than the read-side commands) so route them BEFORE
686
+ # the generic flag loop below. format_json + url_encode are defined later;
687
+ # we forward-reference them from cmd_* by relying on bash's late binding.
688
+ case "$SUBCMD" in
689
+ propose)
690
+ [[ $# -ge 1 ]] || { echo "kb: propose requires subcommand (memory|edit)" >&2; exit 64; }
691
+ SUB2="$1"; shift
692
+ case "$SUB2" in
693
+ memory) cmd_propose_memory "$@"; exit $? ;;
694
+ edit) cmd_propose_edit "$@"; exit $? ;;
695
+ *) echo "kb: unknown propose subcommand: $SUB2" >&2; exit 64 ;;
696
+ esac
697
+ ;;
698
+ resolve) cmd_resolve "$@"; exit $? ;;
699
+ pending) cmd_pending "$@"; exit $? ;;
700
+ audit) cmd_audit "$@"; exit $? ;;
701
+ enforce)
702
+ [[ $# -ge 1 ]] || { echo "kb: enforce requires subcommand (install|uninstall|status|doctor|report)" >&2; exit 64; }
703
+ if [[ "$1" == "report" ]]; then
704
+ shift
705
+ if ! command -v data-olympus >/dev/null 2>&1; then
706
+ echo "kb: data-olympus not found on PATH; install the package or activate its venv" >&2
707
+ exit 127
708
+ fi
709
+ exec data-olympus report "$@"
710
+ fi
711
+ exec python3 "$(dirname "$0")/_kb_enforce.py" "$@"
712
+ ;;
713
+ onboarding-check) cmd_onboarding_check "$@"; exit $? ;;
714
+ onboard)
715
+ [[ $# -ge 1 ]] || { echo "kb: onboard requires subcommand (status|project|component|rename|playbook)" >&2; exit 64; }
716
+ SUB2="$1"; shift
717
+ case "$SUB2" in
718
+ status) cmd_onboard_status "$@"; exit $? ;;
719
+ project) cmd_onboard_project "$@"; exit $? ;;
720
+ component) cmd_onboard_component "$@"; exit $? ;;
721
+ rename) cmd_onboard_rename "$@"; exit $? ;;
722
+ playbook) cmd_onboard_playbook "$@"; exit $? ;;
723
+ *) echo "kb: unknown onboard subcommand: $SUB2" >&2; exit 64 ;;
724
+ esac
725
+ ;;
726
+ esac
727
+
728
+ while [[ $# -gt 0 ]]; do
729
+ case "$1" in
730
+ -o|--output)
731
+ need_value "$1" "${2:-}"; FORMAT="$2"; shift 2 ;;
732
+ --tier)
733
+ need_value "$1" "${2:-}"; TIER="$2"; shift 2 ;;
734
+ --category)
735
+ need_value "$1" "${2:-}"; CATEGORY="$2"; shift 2 ;;
736
+ --limit)
737
+ need_value "$1" "${2:-}"; LIMIT="$2"; shift 2 ;;
738
+ --no-stale)
739
+ NO_STALE=1; shift ;;
740
+ -h|--help)
741
+ usage ;;
742
+ --)
743
+ shift; POSITIONAL+=("$@"); break ;;
744
+ -*)
745
+ echo "kb: unknown flag $1" >&2; exit 64 ;;
746
+ *)
747
+ POSITIONAL+=("$1"); shift ;;
748
+ esac
749
+ done
750
+
751
+ # Validate FORMAT for the current subcommand
752
+ case "$SUBCMD:$FORMAT" in
753
+ search:json|search:plain) ;;
754
+ get:json|get:md|get:plain) ;;
755
+ list:json|list:plain) ;;
756
+ outline:json|outline:plain) ;;
757
+ health:json|health:plain) ;;
758
+ *) echo "kb: invalid -o $FORMAT for $SUBCMD" >&2; exit 64 ;;
759
+ esac
760
+
761
+ # Helper: run curl. Stores body in $REST_BODY and status in $REST_STATUS. Returns 1 on connect failure.
762
+ # Uses a temp file for body so multi-line bodies containing newlines don't break the split.
763
+ do_curl() {
764
+ local url="$1"
765
+ local tmpfile
766
+ tmpfile=$(mktemp)
767
+ if REST_STATUS=$(curl --silent --max-time 5 --output "$tmpfile" --write-out "%{http_code}" "$url" 2>/dev/null); then
768
+ REST_BODY=$(cat "$tmpfile")
769
+ rm -f "$tmpfile"
770
+ return 0
771
+ fi
772
+ rm -f "$tmpfile"
773
+ return 1
774
+ }
775
+
776
+ # Helper: emit a plain-text human-readable summary of a health JSON body.
777
+ format_plain_health() {
778
+ if command -v jq >/dev/null 2>&1; then
779
+ echo "$REST_BODY" | jq -r '
780
+ "commit: \(.kb_commit)
781
+ total_rules: \(.total_rules)
782
+ degraded: \(.degraded)
783
+ last_pull_at: \(.last_git_pull_at // "never")
784
+ staleness_seconds: \(.staleness_seconds // "n/a")
785
+ index_build_status: \(.last_index_build_status)
786
+ pending: \(.pending_count) | push_queue: \(.push_queue_size)"
787
+ '
788
+ else
789
+ echo "$REST_BODY"
790
+ fi
791
+ }
792
+
793
+ # Helper: emit a plain-text outline (one line per category).
794
+ format_plain_outline() {
795
+ if command -v jq >/dev/null 2>&1; then
796
+ echo "$REST_BODY" | jq -r '.tiers[] | .name as $t | .categories[] | "\($t)\t\(.name)\t\(.count)"'
797
+ else
798
+ echo "$REST_BODY"
799
+ fi
800
+ }
801
+
802
+ # Helper: detect degraded:true at the TOP-LEVEL of the JSON body. Returns 0 if degraded, 1 otherwise.
803
+ # Uses jq when available for proper JSON-awareness (avoids false positives from nested
804
+ # fields named "degraded"); falls back to a Python check if jq is missing.
805
+ is_degraded() {
806
+ if command -v jq >/dev/null 2>&1; then
807
+ echo "$REST_BODY" | jq -e '.degraded == true' >/dev/null 2>&1
808
+ else
809
+ python3 -c 'import json,sys
810
+ try:
811
+ d = json.loads(sys.stdin.read())
812
+ sys.exit(0 if d.get("degraded") is True else 1)
813
+ except Exception:
814
+ sys.exit(1)' <<< "$REST_BODY"
815
+ fi
816
+ }
817
+
818
+ # Subcommand dispatch
819
+ case "$SUBCMD" in
820
+ health)
821
+ # The CLI is an operator-facing tool: it requests verbose=true so the
822
+ # human-readable plain renderers below (which read fields the compact default
823
+ # trims, e.g. per-hit path and the full health envelope) keep working. The
824
+ # compact default serves LLM/MCP callers; see issue #65.
825
+ if do_curl "$KB_ENDPOINT/api/v1/health?verbose=true"; then
826
+ # 503 with degraded:true body is the canonical degraded-health response
827
+ # A degraded 503 is a valid response. The CLI must NOT treat it as endpoint-unreachable.
828
+ if is_degraded && [[ "$NO_STALE" -eq 1 ]]; then
829
+ echo "kb: REST response degraded:true and --no-stale set" >&2
830
+ exit 2
831
+ fi
832
+ if [[ "$FORMAT" == "plain" ]]; then
833
+ format_plain_health
834
+ else
835
+ format_json
836
+ fi
837
+ else
838
+ if [[ "$NO_STALE" -eq 1 ]]; then
839
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT and --no-stale set" >&2
840
+ exit 1
841
+ fi
842
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT; using local fallback" >&2
843
+ KB_LOCAL_PATH="$KB_LOCAL_PATH" KB_ENDPOINT="$KB_ENDPOINT" \
844
+ "$(dirname "$0")/_kb_fallback.py" health | (command -v jq >/dev/null && jq . || cat)
845
+ fi
846
+ ;;
847
+ outline)
848
+ if do_curl "$KB_ENDPOINT/api/v1/outline?verbose=true"; then
849
+ if is_degraded && [[ "$NO_STALE" -eq 1 ]]; then
850
+ echo "kb: REST response degraded:true and --no-stale set" >&2
851
+ exit 2
852
+ fi
853
+ if [[ "$FORMAT" == "plain" ]]; then
854
+ format_plain_outline
855
+ else
856
+ format_json
857
+ fi
858
+ else
859
+ if [[ "$NO_STALE" -eq 1 ]]; then
860
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT and --no-stale set" >&2
861
+ exit 1
862
+ fi
863
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT; using local fallback" >&2
864
+ KB_LOCAL_PATH="$KB_LOCAL_PATH" KB_ENDPOINT="$KB_ENDPOINT" \
865
+ "$(dirname "$0")/_kb_fallback.py" outline | (command -v jq >/dev/null && jq . || cat)
866
+ fi
867
+ ;;
868
+ search)
869
+ [[ ${#POSITIONAL[@]} -ge 1 ]] || { echo "kb: search requires QUERY" >&2; exit 64; }
870
+ Q="${POSITIONAL[0]}"
871
+ # Robust URL encoding for arbitrary characters in query and filters.
872
+ URL="$KB_ENDPOINT/api/v1/search?q=$(url_encode "$Q")&limit=$LIMIT&verbose=true"
873
+ [[ -n "$TIER" ]] && URL="$URL&tier=$(url_encode "$TIER")"
874
+ [[ -n "$CATEGORY" ]] && URL="$URL&category=$(url_encode "$CATEGORY")"
875
+ if do_curl "$URL"; then
876
+ if is_degraded && [[ "$NO_STALE" -eq 1 ]]; then
877
+ echo "kb: REST response degraded:true and --no-stale set" >&2
878
+ exit 2
879
+ fi
880
+ if [[ "$FORMAT" == "plain" ]] && command -v jq >/dev/null 2>&1; then
881
+ echo "$REST_BODY" | jq -r '.hits[] | "\(.id)\t\(.path)\t\(.title)"'
882
+ else
883
+ format_json
884
+ fi
885
+ else
886
+ if [[ "$NO_STALE" -eq 1 ]]; then
887
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT and --no-stale set" >&2
888
+ exit 1
889
+ fi
890
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT; using local fallback" >&2
891
+ ARGS=("search" "$Q" "--limit" "$LIMIT")
892
+ [[ -n "$TIER" ]] && ARGS+=("--tier" "$TIER")
893
+ [[ -n "$CATEGORY" ]] && ARGS+=("--category" "$CATEGORY")
894
+ KB_LOCAL_PATH="$KB_LOCAL_PATH" KB_ENDPOINT="$KB_ENDPOINT" \
895
+ "$(dirname "$0")/_kb_fallback.py" "${ARGS[@]}" | (command -v jq >/dev/null && jq . || cat)
896
+ fi
897
+ ;;
898
+ get)
899
+ [[ ${#POSITIONAL[@]} -ge 1 ]] || { echo "kb: get requires ID" >&2; exit 64; }
900
+ ID="${POSITIONAL[0]}"
901
+ URL="$KB_ENDPOINT/api/v1/get/$(url_encode "$ID")?verbose=true"
902
+ if do_curl "$URL"; then
903
+ if [[ "$REST_STATUS" == "404" ]]; then
904
+ echo "$REST_BODY" >&2; exit 1
905
+ fi
906
+ if is_degraded && [[ "$NO_STALE" -eq 1 ]]; then
907
+ echo "kb: REST response degraded:true and --no-stale set" >&2
908
+ exit 2
909
+ fi
910
+ if [[ "$FORMAT" == "md" ]] && command -v jq >/dev/null 2>&1; then
911
+ echo "$REST_BODY" | jq -r .content_markdown
912
+ elif [[ "$FORMAT" == "plain" ]] && command -v jq >/dev/null 2>&1; then
913
+ echo "$REST_BODY" | jq -r '"\(.id)\t\(.path)\t\(.title)"'
914
+ else
915
+ format_json
916
+ fi
917
+ else
918
+ if [[ "$NO_STALE" -eq 1 ]]; then
919
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT and --no-stale set" >&2
920
+ exit 1
921
+ fi
922
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT; using local fallback" >&2
923
+ KB_LOCAL_PATH="$KB_LOCAL_PATH" KB_ENDPOINT="$KB_ENDPOINT" \
924
+ "$(dirname "$0")/_kb_fallback.py" get "$ID" | (command -v jq >/dev/null && jq . || cat)
925
+ fi
926
+ ;;
927
+ list)
928
+ [[ ${#POSITIONAL[@]} -ge 1 ]] || { echo "kb: list requires TIER" >&2; exit 64; }
929
+ LTIER="${POSITIONAL[0]}"
930
+ LCAT="${POSITIONAL[1]:-}"
931
+ URL="$KB_ENDPOINT/api/v1/list?tier=$(url_encode "$LTIER")&verbose=true"
932
+ [[ -n "$LCAT" ]] && URL="$URL&category=$(url_encode "$LCAT")"
933
+ if do_curl "$URL"; then
934
+ if is_degraded && [[ "$NO_STALE" -eq 1 ]]; then
935
+ echo "kb: REST response degraded:true and --no-stale set" >&2
936
+ exit 2
937
+ fi
938
+ if [[ "$FORMAT" == "plain" ]] && command -v jq >/dev/null 2>&1; then
939
+ echo "$REST_BODY" | jq -r '.entries[] | "\(.id)\t\(.path)\t\(.title)"'
940
+ else
941
+ format_json
942
+ fi
943
+ else
944
+ if [[ "$NO_STALE" -eq 1 ]]; then
945
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT and --no-stale set" >&2
946
+ exit 1
947
+ fi
948
+ echo "kb: data-olympus-mcp unreachable at $KB_ENDPOINT; using local fallback" >&2
949
+ ARGS=("list" "$LTIER")
950
+ [[ -n "$LCAT" ]] && ARGS+=("$LCAT")
951
+ KB_LOCAL_PATH="$KB_LOCAL_PATH" KB_ENDPOINT="$KB_ENDPOINT" \
952
+ "$(dirname "$0")/_kb_fallback.py" "${ARGS[@]}" | (command -v jq >/dev/null && jq . || cat)
953
+ fi
954
+ ;;
955
+ *)
956
+ usage ;;
957
+ esac