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.
- data_olympus/__init__.py +14 -0
- data_olympus/_bin/_kb_detect_workspace.sh +57 -0
- data_olympus/_bin/_kb_enforce.py +619 -0
- data_olympus/_bin/_kb_fallback.py +361 -0
- data_olympus/_bin/kb +957 -0
- data_olympus/_bin/kb-enforce-hook +337 -0
- data_olympus/_bin/opencode/data-olympus-gate.ts +102 -0
- data_olympus/audit_log.py +275 -0
- data_olympus/audit_trailers.py +42 -0
- data_olympus/auth.py +139 -0
- data_olympus/cli/__init__.py +1 -0
- data_olympus/cli/import_cmd.py +115 -0
- data_olympus/cli/indexgen.py +60 -0
- data_olympus/cli/main.py +151 -0
- data_olympus/cli/report_cmd.py +181 -0
- data_olympus/config.py +261 -0
- data_olympus/cooccurrence.py +393 -0
- data_olympus/dedup.py +57 -0
- data_olympus/durable.py +51 -0
- data_olympus/embeddings.py +317 -0
- data_olympus/enforce_policy.py +297 -0
- data_olympus/format/__init__.py +16 -0
- data_olympus/format/document.py +39 -0
- data_olympus/format/frontmatter.py +35 -0
- data_olympus/format/lint.py +92 -0
- data_olympus/format/validate.py +71 -0
- data_olympus/git_ops.py +397 -0
- data_olympus/health.py +114 -0
- data_olympus/importer/__init__.py +13 -0
- data_olympus/importer/adr.py +286 -0
- data_olympus/importer/flat.py +170 -0
- data_olympus/importer/model.py +106 -0
- data_olympus/importer/okf.py +192 -0
- data_olympus/importer/run.py +416 -0
- data_olympus/importer/stamp.py +227 -0
- data_olympus/index.py +1745 -0
- data_olympus/markdown_parse.py +103 -0
- data_olympus/models.py +480 -0
- data_olympus/onboarding.py +131 -0
- data_olympus/onboarding_inflight.py +137 -0
- data_olympus/onboarding_playbook.py +99 -0
- data_olympus/pending.py +533 -0
- data_olympus/principals.py +168 -0
- data_olympus/prompts.py +35 -0
- data_olympus/push_queue.py +261 -0
- data_olympus/query_expansion.py +200 -0
- data_olympus/rate_limit.py +81 -0
- data_olympus/refresh.py +329 -0
- data_olympus/report.py +133 -0
- data_olympus/rest_api.py +845 -0
- data_olympus/safe_id.py +36 -0
- data_olympus/search_gate.py +64 -0
- data_olympus/search_shortcut.py +146 -0
- data_olympus/server.py +1115 -0
- data_olympus/session_metrics.py +303 -0
- data_olympus/setup_wizard.py +751 -0
- data_olympus/thin_pointer.py +20 -0
- data_olympus/tools_audit.py +41 -0
- data_olympus/tools_enforce.py +198 -0
- data_olympus/tools_onboarding.py +585 -0
- data_olympus/tools_read.py +230 -0
- data_olympus/tools_write.py +878 -0
- data_olympus/trigram.py +126 -0
- data_olympus/viewer/__init__.py +1 -0
- data_olympus/viewer/generator.py +375 -0
- data_olympus/worktrees.py +147 -0
- data_olympus/write_gate.py +382 -0
- data_olympus-0.3.0.dist-info/METADATA +97 -0
- data_olympus-0.3.0.dist-info/RECORD +73 -0
- data_olympus-0.3.0.dist-info/WHEEL +4 -0
- data_olympus-0.3.0.dist-info/entry_points.txt +3 -0
- data_olympus-0.3.0.dist-info/licenses/LICENSE +202 -0
- 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
|