loki-mode 7.28.2 → 7.30.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,282 @@
1
+ #!/usr/bin/env bash
2
+ # mcp-launch.sh -- launch the Loki Mode MCP server, bootstrapping its Python
3
+ # dependencies on first run (task 562).
4
+ #
5
+ # Why this exists: a fresh `npm install -g loki-mode` ships mcp/server.py and
6
+ # mcp/requirements.txt but installs NO Python packages and exposes only the
7
+ # `loki` bin. So `python3 -m mcp.server` exits because the MCP SDK is absent.
8
+ # `loki mcp` closes that gap: it checks for python3 + the MCP SDK, and when the
9
+ # SDK is missing it offers a consent-gated bootstrap into a project-local
10
+ # virtualenv (.loki/mcp-venv), then execs the server over stdio using THAT
11
+ # venv's python so the SDK is actually importable.
12
+ #
13
+ # Design (least-invasive, honest):
14
+ # * Venv location: <user-cwd>/.loki/mcp-venv (the project Loki is invoked in,
15
+ # NOT the install root). Project-local, no global site-packages pollution,
16
+ # no sudo, no curl-pipe-bash, no root-owned writes under a global install.
17
+ # Removing the project's .loki fully uninstalls. Override with
18
+ # LOKI_MCP_VENV=/abs/path. Honors LOKI_DIR (defaults to .loki).
19
+ # * The server is launched with PYTHONPATH set to the install root (NOT by
20
+ # cd-ing into it) so the user's cwd is preserved: mcp/server.py resolves the
21
+ # project .loki from os.getcwd(). Without PYTHONPATH, `import mcp` from an
22
+ # arbitrary cwd resolves to the pip MCP SDK's own `mcp` package (zero Loki
23
+ # tools); PYTHONPATH puts the install root first so the LOCAL mcp/server.py
24
+ # wins, while server.py's namespace juggle still hands the real SDK its own
25
+ # `mcp.*` subtree.
26
+ # * The ONLY command run on the user's behalf is, after explicit consent:
27
+ # <venv>/bin/pip install -r mcp/requirements.txt
28
+ # The exact command is printed before it runs.
29
+ # * Non-interactive / CI: NEVER install. Print the manual command to stderr
30
+ # and exit 2 (mirrors autonomy/provider-offer.sh gate semantics).
31
+ # * Opt-out: LOKI_NO_INSTALL_OFFER=1 -> never prompt, print manual command,
32
+ # exit 2. --yes / LOKI_ASSUME_YES / LOKI_AUTO_CONFIRM=true -> auto-accept.
33
+ #
34
+ # Self-containment: depends only on bash builtins + python3 on PATH. Defines
35
+ # its own colors so it behaves identically whether sourced by autonomy/loki or
36
+ # run standalone.
37
+
38
+ # Guard against double-source.
39
+ if [ -n "${_LOKI_MCP_LAUNCH_SOURCED:-}" ]; then
40
+ return 0 2>/dev/null || true
41
+ fi
42
+ _LOKI_MCP_LAUNCH_SOURCED=1
43
+
44
+ # --- Self-contained colors (honor NO_COLOR) --------------------------------
45
+ if [ -n "${NO_COLOR:-}" ] || [ ! -t 1 ]; then
46
+ _ML_RED=''; _ML_YELLOW=''; _ML_BOLD=''; _ML_NC=''
47
+ else
48
+ _ML_RED=$'\033[0;31m'
49
+ _ML_YELLOW=$'\033[1;33m'
50
+ _ML_BOLD=$'\033[1m'
51
+ _ML_NC=$'\033[0m'
52
+ fi
53
+
54
+ # Repo root = parent of the directory holding this script (autonomy/..).
55
+ _ml_repo_root() {
56
+ local self_dir
57
+ self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
58
+ (cd "$self_dir/.." && pwd)
59
+ }
60
+
61
+ # _ml_assume_yes: true when the user opted into unattended confirmation.
62
+ _ml_assume_yes() {
63
+ [ "${LOKI_ASSUME_YES:-}" = "1" ] && return 0
64
+ [ "${LOKI_AUTO_CONFIRM:-}" = "true" ] && return 0
65
+ return 1
66
+ }
67
+
68
+ # _ml_non_interactive: true when we must NEVER prompt (non-TTY or CI).
69
+ _ml_non_interactive() {
70
+ [ ! -t 0 ] && return 0
71
+ [ ! -t 1 ] && return 0
72
+ [ -n "${CI:-}" ] && return 0
73
+ return 1
74
+ }
75
+
76
+ # _ml_python: echo the best base python3 for creating the venv, or empty.
77
+ _ml_python() {
78
+ local p
79
+ for p in python3 python3.12 python3.11; do
80
+ if command -v "$p" >/dev/null 2>&1; then
81
+ printf '%s' "$p"
82
+ return 0
83
+ fi
84
+ done
85
+ return 1
86
+ }
87
+
88
+ # _ml_sdk_importable <python> <root>: true (0) only if the real MCP SDK's
89
+ # FastMCP can actually be CONSTRUCTED -- not merely that the SDK files exist on
90
+ # disk. A file-exists check is a false positive: under the local-vs-SDK `mcp`
91
+ # namespace collision the package-dir FastMCP can be present yet fail to import
92
+ # (`No module named 'mcp.types'`). We delegate to mcp/server.py's own
93
+ # `--check-sdk` probe, which runs the exact loader the server uses and exits 0
94
+ # only when FastMCP loaded.
95
+ #
96
+ # Critical: we set PYTHONPATH to the install root and DO NOT cd into it, so the
97
+ # probe exercises the SAME module resolution as the real launch (which preserves
98
+ # the user's cwd). The redirect of stdin from /dev/null is insurance: if the
99
+ # pip SDK's own `mcp.server` were ever reached, its stub starts a stdio receive
100
+ # loop; the EOF makes it exit instead of hanging.
101
+ _ml_sdk_importable() {
102
+ local py="$1" root="$2"
103
+ PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" \
104
+ "$py" -m mcp.server --check-sdk </dev/null >/dev/null 2>&1
105
+ }
106
+
107
+ # _ml_print_manual <root> <venv>: print the honest manual install commands.
108
+ # The venv lives in the user's project (.loki/mcp-venv by default), while
109
+ # requirements.txt is shipped under the install root.
110
+ _ml_print_manual() {
111
+ local root="$1" venv="$2"
112
+ printf 'Install the MCP server dependencies manually:\n' >&2
113
+ printf ' python3 -m venv %s\n' "$venv" >&2
114
+ printf ' %s/bin/pip install -r %s/mcp/requirements.txt\n' "$venv" "$root" >&2
115
+ printf ' PYTHONPATH=%s %s/bin/python -m mcp.server\n' "$root" "$venv" >&2
116
+ }
117
+
118
+ _ml_help() {
119
+ cat <<'EOF'
120
+ Loki Mode -- launch the MCP (Model Context Protocol) server
121
+
122
+ Usage: loki mcp [--transport stdio|http] [--port N] [--help]
123
+
124
+ Starts the Loki Mode MCP server so MCP-aware clients (Claude Code, IDEs)
125
+ can call Loki's tools (memory, task queue, code search, build management).
126
+
127
+ On first run, if the Python MCP SDK is not installed, Loki offers to create
128
+ a project-local virtualenv at .loki/mcp-venv and install mcp/requirements.txt
129
+ into it (with your consent). The exact pip command is printed before it runs.
130
+ It then launches the server using that venv's python.
131
+
132
+ Options:
133
+ --transport stdio|http Transport to use (default: stdio).
134
+ --port N Port for http transport (default: 8421).
135
+ --help, -h Show this help and exit.
136
+
137
+ Environment:
138
+ LOKI_MCP_VENV=/abs/path Use a custom venv location instead of .loki/mcp-venv.
139
+ LOKI_NO_INSTALL_OFFER=1 Never prompt to install; print the manual command.
140
+ --yes / LOKI_ASSUME_YES=1 Auto-accept the dependency install.
141
+
142
+ Behavior in non-interactive / CI shells: never installs. Prints the manual
143
+ install command to stderr and exits 2.
144
+ EOF
145
+ }
146
+
147
+ # mcp_launch_main: dispatcher invoked by cmd_mcp() (autonomy/loki) or directly.
148
+ mcp_launch_main() {
149
+ # Parse only flags we own; everything else is forwarded to the server.
150
+ local arg
151
+ for arg in "$@"; do
152
+ case "$arg" in
153
+ --help|-h|help)
154
+ _ml_help
155
+ return 0
156
+ ;;
157
+ esac
158
+ done
159
+
160
+ local root
161
+ root="$(_ml_repo_root)"
162
+
163
+ # 1. python3 presence.
164
+ local base_py
165
+ if ! base_py="$(_ml_python)"; then
166
+ printf '%sNo python3 found on PATH.%s The MCP server needs Python 3.\n' "$_ML_RED" "$_ML_NC" >&2
167
+ printf 'Install Python 3 (https://www.python.org/downloads), then re-run: loki mcp\n' >&2
168
+ return 2
169
+ fi
170
+
171
+ # 2. venv location. Lives in the USER'S project (their cwd), NOT the install
172
+ # root: $root may be a root-owned global npm prefix where we must never
173
+ # write. LOKI_DIR (default .loki) keeps this consistent with every other
174
+ # .loki artifact; LOKI_MCP_VENV overrides outright.
175
+ local venv="${LOKI_MCP_VENV:-$PWD/${LOKI_DIR:-.loki}/mcp-venv}"
176
+ local venv_py="$venv/bin/python"
177
+
178
+ # 3. If the venv already has the SDK, use it directly. The server is launched
179
+ # with PYTHONPATH=$root (NOT by cd-ing) so the user's cwd is preserved for
180
+ # .loki resolution; see _ml_sdk_importable for why.
181
+ # Known narrow residual: if the user's cwd itself contains a Python
182
+ # package literally named mcp/ with a server submodule, python -m puts
183
+ # the cwd ahead of PYTHONPATH and that package wins. Essentially never
184
+ # true for real projects; documented rather than fought.
185
+ if [ -x "$venv_py" ] && _ml_sdk_importable "$venv_py" "$root"; then
186
+ exec env PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" "$venv_py" -m mcp.server "$@"
187
+ fi
188
+
189
+ # 4. If the BASE python already has the SDK (e.g. user pip-installed it),
190
+ # use it -- no venv needed.
191
+ if _ml_sdk_importable "$base_py" "$root"; then
192
+ exec env PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" "$base_py" -m mcp.server "$@"
193
+ fi
194
+
195
+ # 5. SDK missing. Decide whether we may bootstrap.
196
+ if [ "${LOKI_NO_INSTALL_OFFER:-}" = "1" ]; then
197
+ printf '%sMCP SDK not installed.%s\n' "$_ML_YELLOW" "$_ML_NC" >&2
198
+ _ml_print_manual "$root" "$venv"
199
+ return 2
200
+ fi
201
+
202
+ if _ml_non_interactive; then
203
+ printf '%sMCP SDK not installed%s and this is a non-interactive shell, so Loki will not install it automatically.\n' "$_ML_YELLOW" "$_ML_NC" >&2
204
+ _ml_print_manual "$root" "$venv"
205
+ return 2
206
+ fi
207
+
208
+ # 6. Interactive TTY: offer the consent-gated bootstrap.
209
+ local answer=""
210
+ if _ml_assume_yes; then
211
+ answer="y"
212
+ else
213
+ printf '\n'
214
+ printf 'The MCP server needs Python dependencies that are not installed.\n'
215
+ printf 'Loki can create a project-local virtualenv and install them:\n'
216
+ printf ' python3 -m venv %s\n' "$venv"
217
+ printf ' %s/bin/pip install -r %s/mcp/requirements.txt\n' "$venv" "$root"
218
+ printf '\n'
219
+ printf 'Nothing is installed globally and no sudo is used. Proceed? [Y/n] '
220
+ read -r answer || answer="n"
221
+ fi
222
+
223
+ case "$answer" in
224
+ ""|y|Y|yes|YES) ;;
225
+ *)
226
+ printf 'Skipped.\n'
227
+ _ml_print_manual "$root" "$venv"
228
+ return 2
229
+ ;;
230
+ esac
231
+
232
+ # 7. Create the venv if needed. Ensure the parent .loki exists in the user's
233
+ # project first (never write under the install root).
234
+ if [ ! -x "$venv_py" ]; then
235
+ local venv_parent
236
+ venv_parent="$(dirname "$venv")"
237
+ if [ ! -d "$venv_parent" ] && ! mkdir -p "$venv_parent"; then
238
+ printf '%sCannot create %s (no write access).%s\n' "$_ML_RED" "$venv_parent" "$_ML_NC" >&2
239
+ _ml_print_manual "$root" "$venv"
240
+ return 2
241
+ fi
242
+ printf 'Creating virtualenv (%s) ...\n' "$venv"
243
+ if ! "$base_py" -m venv "$venv"; then
244
+ printf '%sFailed to create virtualenv at %s.%s\n' "$_ML_RED" "$venv" "$_ML_NC" >&2
245
+ _ml_print_manual "$root" "$venv"
246
+ return 2
247
+ fi
248
+ fi
249
+
250
+ # 8. Install requirements into the venv.
251
+ local req="$root/mcp/requirements.txt"
252
+ if [ ! -f "$req" ]; then
253
+ printf '%smcp/requirements.txt not found at %s.%s\n' "$_ML_RED" "$req" "$_ML_NC" >&2
254
+ return 2
255
+ fi
256
+ printf 'Installing MCP dependencies (%s/bin/pip install -r %s) ...\n' "$venv" "$req"
257
+ local code=0
258
+ "$venv/bin/pip" install -r "$req" || code=$?
259
+ if [ "$code" -ne 0 ]; then
260
+ printf '%sInstall failed (pip exited %s).%s You can retry manually:\n' "$_ML_RED" "$code" "$_ML_NC" >&2
261
+ _ml_print_manual "$root" "$venv"
262
+ return 2
263
+ fi
264
+
265
+ # 9. Verify, then exec the server using the venv python (critical: the
266
+ # site-packages walk in server.py only finds the SDK if we run the
267
+ # venv's interpreter, not the ambient python3). PYTHONPATH=$root keeps the
268
+ # user's cwd intact for .loki resolution; see _ml_sdk_importable.
269
+ if ! _ml_sdk_importable "$venv_py" "$root"; then
270
+ printf '%sDependencies installed but the MCP SDK still is not importable.%s\n' "$_ML_RED" "$_ML_NC" >&2
271
+ _ml_print_manual "$root" "$venv"
272
+ return 2
273
+ fi
274
+ printf "%sMCP dependencies ready. Launching server over stdio ...%s\n" "$_ML_BOLD" "$_ML_NC" >&2
275
+ exec env PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" "$venv_py" -m mcp.server "$@"
276
+ }
277
+
278
+ # Executed directly (tests, manual): run the dispatcher.
279
+ # When sourced by autonomy/loki, this block does not run.
280
+ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
281
+ mcp_launch_main "$@"
282
+ fi
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env bash
2
+ # provider-offer.sh -- shared, self-contained provider install offer (v7.29.0).
3
+ #
4
+ # Single source of truth for "no AI provider CLI found" handling, used by BOTH
5
+ # the bash CLI (autonomy/loki, sourced) and the Bun-routed doctor
6
+ # (loki-ts/src/commands/doctor.ts, via child_process). Parity is by
7
+ # construction: there is exactly one prompt + npm install + login handoff
8
+ # implementation, and both routes call it.
9
+ #
10
+ # Self-containment contract (load-bearing for parity): this file depends ONLY
11
+ # on bash builtins + npm/claude on PATH. It defines its own colors and never
12
+ # reads $RED/$NC or any other variable owned by autonomy/loki, because when
13
+ # doctor.ts spawns it standalone those variables are unset. If this file ever
14
+ # starts depending on loki's environment, the bash-route and Bun-route bytes
15
+ # diverge and the bun-parity matrix breaks.
16
+ #
17
+ # Security posture (design 1.7): the ONLY command ever executed on the user's
18
+ # behalf is `npm install -g @anthropic-ai/claude-code`, only after explicit
19
+ # consent, with the exact command printed first. No sudo. No curl-pipe-bash.
20
+ # Non-interactive / CI contexts never run an install.
21
+
22
+ # Guard against double-source (loki may source this more than once via reloads).
23
+ if [ -n "${_LOKI_PROVIDER_OFFER_SOURCED:-}" ]; then
24
+ return 0 2>/dev/null || true
25
+ fi
26
+ _LOKI_PROVIDER_OFFER_SOURCED=1
27
+
28
+ # --- Self-contained colors (honor NO_COLOR; no dependency on loki) ----------
29
+ if [ -n "${NO_COLOR:-}" ] || [ ! -t 1 ]; then
30
+ _PO_RED=''; _PO_YELLOW=''; _PO_BOLD=''; _PO_DIM=''; _PO_NC=''
31
+ else
32
+ _PO_RED=$'\033[0;31m'
33
+ _PO_YELLOW=$'\033[1;33m'
34
+ _PO_BOLD=$'\033[1m'
35
+ _PO_NC=$'\033[0m'
36
+ fi
37
+
38
+ # The one canonical install command. Quoted everywhere; never re-derived.
39
+ _PO_INSTALL_CMD="npm install -g @anthropic-ai/claude-code"
40
+
41
+ # detect_any_provider: true (0) if any supported provider CLI is on PATH.
42
+ # Extracted verbatim from the loki doctor detection loop (design 1.2).
43
+ detect_any_provider() {
44
+ local _dp
45
+ for _dp in claude codex cline aider; do
46
+ command -v "$_dp" >/dev/null 2>&1 && return 0
47
+ done
48
+ return 1
49
+ }
50
+
51
+ # _po_assume_yes: true when the user has opted into unattended confirmation.
52
+ # Honors --yes (LOKI_AUTO_CONFIRM, set by loki:1013) and LOKI_ASSUME_YES.
53
+ _po_assume_yes() {
54
+ [ "${LOKI_ASSUME_YES:-}" = "1" ] && return 0
55
+ [ "${LOKI_AUTO_CONFIRM:-}" = "true" ] && return 0
56
+ return 1
57
+ }
58
+
59
+ # _po_non_interactive: true when we must NEVER prompt (non-TTY or CI).
60
+ # Mirrors cmd_welcome_maybe_firstrun (loki:4286) and maybe_show_auto_plan.
61
+ _po_non_interactive() {
62
+ [ ! -t 1 ] && return 0
63
+ [ ! -t 0 ] && return 0
64
+ [ -n "${CI:-}" ] && return 0
65
+ return 1
66
+ }
67
+
68
+ # _po_run_login: offer (or auto-accept) the claude auth login handoff after a
69
+ # successful install. Inherited stdio; Loki never handles credentials.
70
+ _po_run_login() {
71
+ # claude must actually be on PATH for login to make sense.
72
+ if ! command -v claude >/dev/null 2>&1; then
73
+ printf "%sInstalled, but 'claude' is not on your PATH yet. You may need to restart your shell or add npm's global bin to PATH (npm config get prefix). Run 'loki doctor' to recheck.%s\n" "$_PO_YELLOW" "$_PO_NC"
74
+ return 0
75
+ fi
76
+
77
+ local do_login=""
78
+ if _po_assume_yes; then
79
+ do_login="y"
80
+ else
81
+ printf 'Claude Code installed.\n'
82
+ printf '\n'
83
+ printf 'You still need to authenticate. Run the login flow now? [Y/n] '
84
+ read -r do_login || do_login="n"
85
+ fi
86
+ case "$do_login" in
87
+ ""|y|Y|yes|YES)
88
+ if claude auth login; then
89
+ # Do not trust the exit code alone: verify the session is
90
+ # actually authenticated before claiming readiness (council
91
+ # HIGH: the old path could falsely report success).
92
+ if claude auth status 2>/dev/null | grep -q '"loggedIn"[[:space:]]*:[[:space:]]*true'; then
93
+ printf "%sProvider ready. Run 'loki doctor' to confirm, or 'loki quickstart' to build.%s\n" "$_PO_BOLD" "$_PO_NC"
94
+ return 0
95
+ fi
96
+ printf "Login finished but authentication could not be confirmed. Run 'claude auth status' to check, then 'loki doctor'.\n"
97
+ return 0
98
+ fi
99
+ printf "Login not completed. Run 'claude auth login' when ready, then 'loki doctor'.\n"
100
+ return 0
101
+ ;;
102
+ *)
103
+ printf "Login not completed. Run 'claude auth login' when ready, then 'loki doctor'.\n"
104
+ return 0
105
+ ;;
106
+ esac
107
+ }
108
+
109
+ # _po_do_install: run the one consented command, print it first, handle result.
110
+ # Returns 0 on success, non-zero on failure (caller decides exit behavior).
111
+ _po_do_install() {
112
+ printf 'Installing Claude Code (%s) ...\n' "$_PO_INSTALL_CMD"
113
+ # The exact, fixed argv. No interpolation, no extra flags. (design 1.7)
114
+ # Capture npm's exit code directly (not via `if`, whose statement status is
115
+ # 0 when the condition is false with no else, masking the real npm code).
116
+ local code=0
117
+ npm install -g @anthropic-ai/claude-code || code=$?
118
+ if [ "$code" -eq 0 ]; then
119
+ printf '\n'
120
+ _po_run_login
121
+ return 0
122
+ fi
123
+ printf '%sInstall failed (npm exited %s). You can retry manually:%s\n' "$_PO_RED" "$code" "$_PO_NC"
124
+ printf ' %s\n' "$_PO_INSTALL_CMD"
125
+ printf 'If this is a permissions error, see https://docs.npmjs.com/resolving-eacces-permissions-errors\n'
126
+ return "$code"
127
+ }
128
+
129
+ # offer_provider_install <mode>
130
+ # mode = "report" -> doctor: append the offer on a TTY; on non-TTY/CI do
131
+ # NOTHING (doctor already printed the FAIL + install line,
132
+ # and we must keep non-TTY/json bytes identical for parity).
133
+ # Never exits the process.
134
+ # mode = "gate" -> start/demo/quick pre-flight: on non-TTY/CI print the
135
+ # honest one-liner to stderr and return 2. On a TTY, prompt;
136
+ # on decline return 2. On accept install + login.
137
+ #
138
+ # Honors:
139
+ # LOKI_NO_INSTALL_OFFER=1 -> never prompt; print manual command (1.4)
140
+ # --yes / LOKI_ASSUME_YES -> auto-accept install + login (1.4)
141
+ offer_provider_install() {
142
+ local mode="${1:-gate}"
143
+
144
+ # Opt-out: never offer, just surface the manual command.
145
+ if [ "${LOKI_NO_INSTALL_OFFER:-}" = "1" ]; then
146
+ if [ "$mode" = "gate" ]; then
147
+ printf 'No AI provider CLI found. Install one when ready:\n' >&2
148
+ printf ' %s (then: claude auth login)\n' "$_PO_INSTALL_CMD" >&2
149
+ return 2
150
+ fi
151
+ printf '\n'
152
+ printf 'Install a provider when ready:\n'
153
+ printf ' %s (then: claude auth login)\n' "$_PO_INSTALL_CMD"
154
+ printf ' Other supported providers: codex, cline, aider.\n'
155
+ return 0
156
+ fi
157
+
158
+ # Non-interactive / CI: NEVER prompt, NEVER install.
159
+ #
160
+ # gate (start/demo/quick): print the honest one-liner to stderr and return 2
161
+ # so the caller exits with an actionable message before any spend.
162
+ # report (doctor): stay SILENT. doctor has already printed the FAIL line and
163
+ # the install command on stdout, so no information is lost. Silence here is
164
+ # load-bearing for parity: doctor.ts gates its child_process bridge on
165
+ # process.stdout.isTTY, so on a non-TTY/CI run the Bun route emits nothing
166
+ # extra. If report-mode printed a stderr line, the bash route would diverge
167
+ # from Bun in exactly the no-provider/non-TTY case the bun-parity matrix
168
+ # captures (2>&1) on CI runners, which have no provider installed.
169
+ if _po_non_interactive; then
170
+ if [ "$mode" = "gate" ]; then
171
+ printf 'No AI provider CLI found; cannot prompt to install in a non-interactive shell. Run: %s\n' "$_PO_INSTALL_CMD" >&2
172
+ return 2
173
+ fi
174
+ return 0
175
+ fi
176
+
177
+ # npm missing: degraded path, never attempt a non-npm install.
178
+ if ! command -v npm >/dev/null 2>&1; then
179
+ printf '\n'
180
+ printf '%sNo AI provider CLI was found, and npm is not installed either, so Loki%s\n' "$_PO_BOLD" "$_PO_NC"
181
+ printf 'cannot install one for you.\n'
182
+ printf '\n'
183
+ printf 'Install Node.js + npm first (https://nodejs.org), then run:\n'
184
+ printf ' %s\n' "$_PO_INSTALL_CMD"
185
+ printf ' claude auth login\n'
186
+ printf '\n'
187
+ printf "Already have a provider via another method? Make sure 'claude' (or codex,\n"
188
+ printf "cline, aider) is on your PATH, then run 'loki doctor'.\n"
189
+ [ "$mode" = "gate" ] && return 2
190
+ return 0
191
+ fi
192
+
193
+ # TTY, npm present: the interactive offer.
194
+ printf '\n'
195
+ printf 'No AI provider CLI was found. Loki needs one agent CLI to run a build.\n'
196
+ printf '\n'
197
+ printf 'Claude Code is the recommended provider (full feature support).\n'
198
+ printf ' Install: %s\n' "$_PO_INSTALL_CMD"
199
+ printf ' Then: claude auth login\n'
200
+ printf '\n'
201
+
202
+ local answer=""
203
+ if _po_assume_yes; then
204
+ answer="y"
205
+ else
206
+ printf 'Install Claude Code now? [Y/n] '
207
+ read -r answer || answer="n"
208
+ fi
209
+
210
+ case "$answer" in
211
+ ""|y|Y|yes|YES)
212
+ if _po_do_install; then
213
+ return 0
214
+ fi
215
+ # Install failed: honest failure already printed by _po_do_install.
216
+ [ "$mode" = "gate" ] && return 2
217
+ return 1
218
+ ;;
219
+ *)
220
+ printf 'Skipped. Install a provider when ready:\n'
221
+ printf ' %s (then: claude auth login)\n' "$_PO_INSTALL_CMD"
222
+ printf 'Other supported providers: codex, cline, aider.\n'
223
+ [ "$mode" = "gate" ] && return 2
224
+ return 0
225
+ ;;
226
+ esac
227
+ }
228
+
229
+ # provider_offer_gate: convenience wrapper for the start/demo/quick pre-flight.
230
+ # Returns 0 if a provider is present (or one was just installed); returns 2 to
231
+ # signal the caller should `exit 2` (no provider, declined or non-interactive).
232
+ provider_offer_gate() {
233
+ detect_any_provider && return 0
234
+ offer_provider_install gate || return 2
235
+ # After an accepted install, re-detect; if still absent, fail the gate.
236
+ detect_any_provider && return 0
237
+ return 2
238
+ }
239
+
240
+ # Executed directly (doctor.ts child_process bridge, or manual): run the offer.
241
+ # When sourced by autonomy/loki, this block does not run.
242
+ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
243
+ case "${1:-report}" in
244
+ offer|report) offer_provider_install report ;;
245
+ gate) offer_provider_install gate ;;
246
+ detect) detect_any_provider ;;
247
+ *) offer_provider_install report ;;
248
+ esac
249
+ fi