diff --git a/samples/README.md b/samples/README.md index 7dfc65c..3e7326d 100644 --- a/samples/README.md +++ b/samples/README.md @@ -15,6 +15,13 @@ The first is the declared case, and it is the minority. A survey of ~20 real repositories found **zero** agent manifests, which is why the folder is the input and every declaration is a bonus. +Set everything up with one command (installs dependencies, creates `.env` files, generates the incident-scribe token, installs embeddings, and checks the model key): + +```bash +./samples/setup.sh # macOS / Linux / WSL +powershell -ExecutionPolicy Bypass -File samples\setup.ps1 # Windows +``` + ```bash cd samples/refund-desk && rook # command-line agent cd samples/triage-service && npm start # then, in another terminal: diff --git a/samples/incident-scribe/.env.example b/samples/incident-scribe/.env.example index da41021..19e22c3 100644 --- a/samples/incident-scribe/.env.example +++ b/samples/incident-scribe/.env.example @@ -3,11 +3,20 @@ # Any secret string you choose; every CLI command must pass it as --token. SCRIBE_API_TOKEN= -# One model provider. GLM through Z.AI's Anthropic-compatible endpoint, for example: -LLM_PROVIDER=glm -ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic -GLM_API_KEY= -GLM_MODEL= +# One model provider. The LambdaTest gateway (OpenAI-compatible chat completions, +# HTTP Basic auth). GATEWAY_URL is the base host only: the desk calls +# /v1/chat/completions and checks /healthz. +LLM_PROVIDER=lambdatest +GATEWAY_URL=https://rook-samples.lambdatest.com +LT_USERNAME= +LT_ACCESS_KEY= +LT_MODEL=auto + +# Or GLM through Z.AI's Anthropic-compatible endpoint (comment out the block above first): +# LLM_PROVIDER=glm +# ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic +# GLM_API_KEY= +# GLM_MODEL= # Or Gemini: # LLM_PROVIDER=gemini diff --git a/samples/incident-scribe/README.md b/samples/incident-scribe/README.md index 45561e8..5f70d27 100644 --- a/samples/incident-scribe/README.md +++ b/samples/incident-scribe/README.md @@ -11,6 +11,7 @@ An incident-intake agent to test with `rook`. ```bash cp .env.example .env # add a token and one model provider's key +python3 scripts/check_llm.py # keys present? endpoint up? credentials accepted? (Windows: python) ./scripts/preflight.sh # Windows: powershell -File scripts\preflight.ps1 rook # explore the agent and generate a suite ``` @@ -230,7 +231,16 @@ export SCRIBE_API_TOKEN=choose-any-secret-string # macOS / Linux / WSL $env:SCRIBE_API_TOKEN = "choose-any-secret-string" # Windows PowerShell ``` -Use the same value in both places. +Use the same value in both places. Or load all of `.env` into the shell in one +step (source it, do not run it): + +```bash +. scripts/env.sh # macOS / Linux / WSL +``` + +```powershell +. .\scripts\env.ps1 # Windows PowerShell +``` ### Running it @@ -260,7 +270,7 @@ variables win over `.env`. | variable | role | | --- | --- | | `SCRIBE_API_TOKEN` | **required**: the credential checked on every command | -| `LLM_PROVIDER` | `anthropic`/`glm` · `gemini` · `responses` · `openai`/`chat`. If unset, auto-detected in that order from the variables present | +| `LLM_PROVIDER` | `anthropic`/`glm` · `gemini` · `responses` · `lambdatest`/`lt` · `openai`/`chat`. If unset, auto-detected in that order from the variables present | | `SCRIBE_STATE_DIR` | session storage root (default `.scribe-sessions/`) | | `OPS_LEARNED_FILE` | learned-incidents file (default `services/incidents.learned.json`) | | `MCP_TIMEOUT` | per-call timeout to ops-directory, seconds (default 30) | @@ -273,6 +283,7 @@ Per provider: | `anthropic` / `glm` | `ANTHROPIC_BASE_URL` (default `https://api.z.ai/api/anthropic`); key from `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_API_KEY`, `GLM_API_KEY` or `ZAI_API_KEY`; model from `ANTHROPIC_MODEL`, `GLM_MODEL` or `ANTHROPIC_DEFAULT_{SONNET,OPUS,HAIKU}_MODEL` (a `[1m]` suffix is stripped) | | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL`; optional `GEMINI_ENDPOINT` | | `responses` | `OPENAI_ENDPOINT`, `OPENAI_INDIA_API_KEY`, `OPENAI_MODEL` | +| `lambdatest` / `lt` | `GATEWAY_URL`, `LT_USERNAME`, `LT_ACCESS_KEY` (sent as HTTP Basic auth to `/v1/chat/completions`); optional `LT_MODEL` (default `auto`) | | `openai` / `chat` | `OPENAI_API_KEY` (or `OPENAI_INDIA_API_KEY`), `OPENAI_CHAT_MODEL` (or `OPENAI_MODEL`); optional `OPENAI_CHAT_ENDPOINT` / `OPENAI_BASE_URL` | ### Project layout @@ -291,6 +302,10 @@ scripts/ preflight.sh pre-run checks (macOS / Linux / WSL) preflight.ps1 pre-run checks (Windows PowerShell) build-index.mjs enables semantic search (rebuilds the index) + check_llm.py keys present, endpoint up, credentials accepted + ensure_token.py fills SCRIBE_API_TOKEN in .env when it is empty + prompt_credentials.py asks for LT_USERNAME and LT_ACCESS_KEY when .env has none + env.sh / env.ps1 source them to load .env into your shell docs/ invoking.md the CLI contract incident-policy.pdf the incident policy (authoritative) diff --git a/samples/incident-scribe/docs/invoking.md b/samples/incident-scribe/docs/invoking.md index 3b542bd..45bef2b 100644 --- a/samples/incident-scribe/docs/invoking.md +++ b/samples/incident-scribe/docs/invoking.md @@ -41,6 +41,19 @@ python -m incident_scribe logs --token $env:SCRIBE_API_TOKEN --session-id S-1 `.env` expands to an empty `--token`, which is refused as `unauthorized`. For a multi-line report, pipe a file: `Get-Content report.txt -Raw | python -m …`. +**Load `.env` into the shell first.** `./setup.sh` (or `setup.ps1`) creates +`.env` with a generated token, but a script cannot set variables in your shell, +so source the loader once per terminal. Variables already set in the shell win +over the file. + +```bash +. scripts/env.sh # macOS / Linux / WSL +``` + +```powershell +. .\scripts\env.ps1 # Windows PowerShell (note the leading dot) +``` + **There is no login step.** The token is issued out of band and lives in `SCRIBE_API_TOKEN`. Every one of the four commands takes `--token`, and it is checked before anything else happens — `logs` and `close` exactly as much as @@ -62,7 +75,8 @@ continues the same session. | --- | --- | | `SCRIBE_API_TOKEN` | the session credential, presented on every command | -The default provider remains the existing Responses API. To use GLM through +Pick a provider with `LLM_PROVIDER`; the `.env.example` template uses the +LambdaTest gateway. To use GLM through Z.AI's Anthropic-compatible endpoint, set `LLM_PROVIDER=glm`, `ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic`, `GLM_API_KEY`, and `GLM_MODEL` (for example `glm-5.3`). The adapter also accepts the names from @@ -79,10 +93,17 @@ Other supported providers use the same multi-turn agent loop: | `glm` / `anthropic` | `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL` | | `openai` / `chat` | `OPENAI_API_KEY`, `OPENAI_CHAT_MODEL`; optional `OPENAI_CHAT_ENDPOINT` | | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL`; optional `GEMINI_ENDPOINT` | +| `lambdatest` / `lt` | `GATEWAY_URL`, `LT_USERNAME`, `LT_ACCESS_KEY`; optional `LT_MODEL` (default `auto`) | + +`GATEWAY_URL` must start with `https://`, because the access key is sent with +every call. `./setup.sh` asks +for `LT_USERNAME` and `LT_ACCESS_KEY` when they are empty, with the key typed +hidden, and writes them to `.env`. If `LLM_PROVIDER` is omitted, the adapter selects Anthropic/GLM, Gemini, -Responses, or Chat Completions from the variables that are present, in that -order. +Responses, LambdaTest (when `GATEWAY_URL`, `LT_USERNAME` and `LT_ACCESS_KEY` are +all set and no `OPENAI_API_KEY` is), or Chat Completions from the variables that +are present, in that order. **That is the whole list.** The desk's model endpoint, key and deployment are its OWN configuration — it reads them from its `.env` and they are nobody diff --git a/samples/incident-scribe/incident_scribe/llm.py b/samples/incident-scribe/incident_scribe/llm.py index a8c9a81..3f35ea9 100644 --- a/samples/incident-scribe/incident_scribe/llm.py +++ b/samples/incident-scribe/incident_scribe/llm.py @@ -2,22 +2,29 @@ from __future__ import annotations +import base64 import json import os import time import urllib.error +import urllib.parse import urllib.request from typing import Any class ModelError(RuntimeError): - """The model could not be reached, or refused.""" + """The model could not be reached, or refused. `status` is the HTTP status when an endpoint answered.""" + + def __init__(self, message: str, status: int | None = None) -> None: + super().__init__(message) + self.status = status ANTHROPIC = {"anthropic", "glm", "zai"} RESPONSES = {"responses", "azure", "azure_responses", "openai_responses"} CHAT = {"chat", "chat_completions", "openai", "openai_chat"} GEMINI = {"gemini", "google", "google_gemini"} +LAMBDATEST = {"lambdatest", "lt", "gateway"} def _config() -> tuple[str, str, str, str]: @@ -29,6 +36,10 @@ def _config() -> tuple[str, str, str, str]: requested = "gemini" elif os.environ.get("OPENAI_ENDPOINT", "").strip(): requested = "responses" + elif all(os.environ.get(name, "").strip() for name in ("GATEWAY_URL", "LT_USERNAME", "LT_ACCESS_KEY")) and not any( + os.environ.get(name, "").strip() for name in ("OPENAI_API_KEY", "OPENAI_INDIA_API_KEY") + ): + requested = "lambdatest" else: requested = "chat" @@ -54,6 +65,21 @@ def _config() -> tuple[str, str, str, str]: raise ModelError("the Gemini model needs GEMINI_API_KEY and GEMINI_MODEL") return "gemini", endpoint.rstrip("/"), key, model + if requested in LAMBDATEST: + endpoint = os.environ.get("GATEWAY_URL", "").strip() + username = os.environ.get("LT_USERNAME", "").strip() + access_key = os.environ.get("LT_ACCESS_KEY", "").strip() + model = os.environ.get("LT_MODEL", "").strip() or "auto" + missing = [name for name, value in ( + ("GATEWAY_URL", endpoint), ("LT_USERNAME", username), ("LT_ACCESS_KEY", access_key) + ) if not value] + if missing: + raise ModelError("the LambdaTest gateway is not configured: " + ", ".join(missing)) + parts = urllib.parse.urlsplit(endpoint) + if parts.scheme != "https" and not (parts.scheme == "http" and parts.hostname in ("localhost", "127.0.0.1", "::1")): + raise ModelError("GATEWAY_URL must start with https:// (the account access key is sent with every call)") + return "lambdatest", _chat_url(endpoint), base64.b64encode(f"{username}:{access_key}".encode()).decode(), model + if requested in CHAT: endpoint = os.environ.get("OPENAI_CHAT_ENDPOINT", "").strip() if not endpoint: @@ -242,11 +268,11 @@ def _request(url: str, body: dict[str, Any], headers: dict[str, str]) -> dict[st with urllib.request.urlopen(req, timeout=_timeout_seconds()) as response: data = json.load(response) if data.get("error"): - raise ModelError(f"model returned an error: {data['error']}") + raise ModelError(f"model returned an error: {data['error']}", status=response.status) return data except urllib.error.HTTPError as error: detail = error.read()[:400].decode("utf-8", "replace") - raise ModelError(f"model answered {error.code}: {detail}") from None + raise ModelError(f"model answered {error.code}: {detail}", status=error.code) from None except (urllib.error.URLError, TimeoutError, OSError) as error: if attempt >= retries: raise ModelError(f"could not reach the model: {error}") from None @@ -271,11 +297,11 @@ def _call_anthropic(items: list[dict[str, Any]], tools: list[dict[str, Any]] | N return {"text": "\n".join(block.get("text", "") for block in blocks if block.get("type") == "text").strip(), "tool_calls": calls, "raw_output": [{"role": "assistant", "content": blocks}], "stop_reason": data.get("stop_reason"), "usage": {"input_tokens": usage.get("input_tokens", 0), "output_tokens": usage.get("output_tokens", 0)}} -def _call_chat(items: list[dict[str, Any]], tools: list[dict[str, Any]] | None, endpoint: str, key: str, model: str, max_tokens: int) -> dict[str, Any]: +def _call_chat(items: list[dict[str, Any]], tools: list[dict[str, Any]] | None, endpoint: str, key: str, model: str, max_tokens: int, scheme: str = "Bearer") -> dict[str, Any]: body: dict[str, Any] = {"model": model, "messages": _chat_items(items), "max_tokens": max_tokens} if tools: body["tools"] = _chat_tools(tools) - data = _request(endpoint, body, {"content-type": "application/json", "Authorization": f"Bearer {key}"}) + data = _request(endpoint, body, {"content-type": "application/json", "Authorization": f"{scheme} {key}"}) message = (data.get("choices") or [{}])[0].get("message") or {} calls = [] for call in message.get("tool_calls") or []: @@ -314,6 +340,8 @@ def call(items: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, return _call_anthropic(items, tools, endpoint, key, model, max_output_tokens) if provider == "chat": return _call_chat(items, tools, endpoint, key, model, max_output_tokens) + if provider == "lambdatest": + return _call_chat(items, tools, endpoint, key, model, max_output_tokens, scheme="Basic") if provider == "gemini": return _call_gemini(items, tools, endpoint, key, model, max_output_tokens) diff --git a/samples/incident-scribe/scripts/check_llm.py b/samples/incident-scribe/scripts/check_llm.py new file mode 100644 index 0000000..6222753 --- /dev/null +++ b/samples/incident-scribe/scripts/check_llm.py @@ -0,0 +1,124 @@ +"""Prove the model provider in `.env` works: keys present, endpoint up, auth accepted. + + python scripts/check_llm.py # all three checks + python scripts/check_llm.py --config-only # keys only, no network call + +Exit codes: 0 all good · 1 not configured · 2 endpoint up but credentials +refused · 3 endpoint unreachable or the request was rejected. + +Secrets are never printed: only the NAMES of the variables that are set. +""" + +from __future__ import annotations + +import os +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from incident_scribe.__main__ import _load_dotenv # noqa: E402 +from incident_scribe import llm # noqa: E402 + +_load_dotenv() + +# Variable names each provider reads, so the report can say which are set. +VARIABLES = { + "anthropic": ("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "GLM_API_KEY", "ZAI_API_KEY", "ANTHROPIC_MODEL", "GLM_MODEL"), + "gemini": ("GEMINI_API_KEY", "GEMINI_MODEL", "GEMINI_ENDPOINT"), + "chat": ("OPENAI_API_KEY", "OPENAI_INDIA_API_KEY", "OPENAI_CHAT_MODEL", "OPENAI_MODEL", "OPENAI_CHAT_ENDPOINT", "OPENAI_BASE_URL"), + "responses": ("OPENAI_ENDPOINT", "OPENAI_INDIA_API_KEY", "OPENAI_MODEL"), + "lambdatest": ("GATEWAY_URL", "LT_USERNAME", "LT_ACCESS_KEY", "LT_MODEL"), +} + + +def _paint(code: str, text: str) -> str: + return f"\033[{code}m{text}\033[0m" if sys.stdout.isatty() and "NO_COLOR" not in os.environ else text + + +def ok(message: str) -> None: + print(f" {_paint('32', 'ok')} {message}") + + +def bad(message: str) -> None: + print(f" {_paint('31', '!!')} {message}") + + +def info(message: str) -> None: + print(f" {message}") + + +def gateway_health() -> bool: + """GET /healthz — unauthenticated, so it isolates "is it up" from "are my keys good". + + A gateway with no such route (404) is not a failure; the credential check below still decides. + """ + url = os.environ["GATEWAY_URL"].strip().rstrip("/") + "/healthz" + try: + with urllib.request.urlopen(url, timeout=15) as response: + ok(f"health: {url} answered {response.status}") + return True + except urllib.error.HTTPError as error: + if error.code == 404: + info(f"health: {url} has no health route, relying on the credential check") + return True + bad(f"health: {url} answered {error.code}") + except (urllib.error.URLError, TimeoutError, OSError) as error: + bad(f"health: {url} unreachable — {error}") + return False + + +def main() -> int: + config_only = "--config-only" in sys.argv[1:] + + # 1. keys present + try: + provider, endpoint, _key, model = llm._config() + except llm.ModelError as error: + bad(f"no usable model configuration: {error}") + return 1 + present = [name for name in VARIABLES.get(provider, ()) if os.environ.get(name, "").strip()] + ok(f"provider '{provider}', model '{model}', endpoint {endpoint}") + ok("credentials present: " + ", ".join(present)) + if config_only: + return 0 + + # 2. health. The gateway has a dedicated probe; other providers are judged + # by the request below, since a 401 from them already proves they are up. + probed = provider == "lambdatest" + if probed and not gateway_health(): + return 3 + + # 3. one tiny real request. Any HTTP answer, even a refusal, proves the + # endpoint is up; only a 2xx proves the credentials are accepted. + os.environ.setdefault("LLM_RETRIES", "0") + started = time.monotonic() + try: + llm.call([llm.user("Reply with the single word: ok")], max_output_tokens=64) + except llm.ModelError as error: + message = str(error) + if error.status is None: + bad(f"health: endpoint unreachable — {message[:300]}") + return 3 + if not probed: + ok(f"health: endpoint is up ({error.status} answered)") + if error.status in (401, 403): + bad(f"auth: credentials were refused — check the key/username in .env ({message[:200]})") + return 2 + bad(f"auth: could not be confirmed, the request was rejected — {message[:300]}") + return 3 + except Exception as error: # an unexpected answer must never read as "not configured" (exit 1) + bad(f"the endpoint answered something unexpected — {type(error).__name__}: {str(error)[:300]}") + return 3 + seconds = time.monotonic() - started + if not probed: + ok(f"health: endpoint is up ({seconds:.1f}s round trip)") + ok(f"auth: credentials accepted, the model answered ({seconds:.1f}s)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/samples/incident-scribe/scripts/ensure_token.py b/samples/incident-scribe/scripts/ensure_token.py new file mode 100644 index 0000000..bfdd5da --- /dev/null +++ b/samples/incident-scribe/scripts/ensure_token.py @@ -0,0 +1,51 @@ +"""Make sure .env holds a usable SCRIBE_API_TOKEN, without touching anything else. + + python scripts/ensure_token.py [path-to-.env] # default: ./.env + +Fills the first active SCRIBE_API_TOKEN line when it is empty, and appends one when +there is none (a missing line, or only a commented-out one). A token that is set is +left alone. Prints "generated" or "kept". Hex, because a token that starts with "-" +is parsed as an option by `--token "$SCRIBE_API_TOKEN"`. +""" + +from __future__ import annotations + +import secrets +import sys +from pathlib import Path + + +def ensure(text: str) -> tuple[str, bool]: + """Same reading as the desk's .env loader: first assignment wins, quotes are stripped.""" + lines = text.splitlines(keepends=True) + for index, raw in enumerate(lines): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + if key.strip() != "SCRIBE_API_TOKEN": + continue + if value.strip().strip('"').strip("'"): + return text, False + ending = raw[len(raw.rstrip("\r\n")):] + lines[index] = f"SCRIBE_API_TOKEN={secrets.token_hex(24)}{ending}" + return "".join(lines), True + newline = "\r\n" if "\r\n" in text else "\n" + separator = "" if not text or text.endswith("\n") else newline + return f"{text}{separator}SCRIBE_API_TOKEN={secrets.token_hex(24)}{newline}", True + + +def main() -> int: + path = Path(sys.argv[1] if len(sys.argv) > 1 else ".env") + with path.open(encoding="utf-8", newline="") as handle: + text = handle.read() + new, generated = ensure(text) + if generated: + with path.open("w", encoding="utf-8", newline="") as handle: + handle.write(new) + print("generated" if generated else "kept") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/samples/incident-scribe/scripts/env.ps1 b/samples/incident-scribe/scripts/env.ps1 new file mode 100644 index 0000000..f2bfae7 --- /dev/null +++ b/samples/incident-scribe/scripts/env.ps1 @@ -0,0 +1,20 @@ +# Load .env into the current PowerShell session. Dot-source it, do not run it: +# +# . .\scripts\env.ps1 [path-to-.env] # default: .\.env +# +# Follows the desk's loader: the first assignment of a name wins, and a variable +# already set in the session wins over the file. + +param([string]$Path = '.env') + +if (-not (Test-Path $Path)) { Write-Error "env.ps1: no such file: $Path"; return } + +foreach ($line in [System.IO.File]::ReadAllLines((Resolve-Path $Path), [System.Text.UTF8Encoding]::new($false))) { + $line = $line.Trim() + if (-not $line -or $line.StartsWith('#') -or -not $line.Contains('=')) { continue } + $key, $value = $line -split '=', 2 + $key = ($key -replace '^export\s+', '').Trim() + if ($key -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { continue } + if (Test-Path "env:$key") { continue } + Set-Item -Path "env:$key" -Value $value.Trim().Trim('"').Trim("'") +} diff --git a/samples/incident-scribe/scripts/env.sh b/samples/incident-scribe/scripts/env.sh new file mode 100644 index 0000000..6dee8e3 --- /dev/null +++ b/samples/incident-scribe/scripts/env.sh @@ -0,0 +1,29 @@ +# Load .env into the current shell. Source it, do not run it: +# +# . scripts/env.sh [path-to-.env] # default: ./.env +# +# Follows the desk's loader: the first assignment of a name wins, and a variable +# already set in the shell wins over the file. + +_env_file="${1:-.env}" +if [ ! -f "$_env_file" ]; then + echo "env.sh: no such file: $_env_file" >&2 + unset _env_file + return 1 2>/dev/null || exit 1 +fi + +while IFS= read -r _line || [ -n "$_line" ]; do + _line="${_line%$'\r'}" + case "$_line" in ''|'#'*) continue ;; esac + case "$_line" in *=*) ;; *) continue ;; esac + _key="${_line%%=*}" + _val="${_line#*=}" + _key="${_key#export }" + _key="${_key// /}" + case "$_key" in ''|[0-9]*|*[!A-Za-z0-9_]*) continue ;; esac + _val="${_val#\"}"; _val="${_val%\"}"; _val="${_val#\'}"; _val="${_val%\'}" + printenv "$_key" >/dev/null 2>&1 && continue + export "$_key=$_val" +done < "$_env_file" + +unset _env_file _line _key _val diff --git a/samples/incident-scribe/scripts/preflight.sh b/samples/incident-scribe/scripts/preflight.sh index ccfcd5f..a4e7031 100644 --- a/samples/incident-scribe/scripts/preflight.sh +++ b/samples/incident-scribe/scripts/preflight.sh @@ -14,6 +14,7 @@ set -euo pipefail cd "$(dirname "$0")/.." +PY="${PY:-python3}" green() { printf ' \033[32m%s\033[0m %s\n' "ok" "$1"; } red() { printf ' \033[31m%s\033[0m %s\n' "!!" "$1"; } @@ -31,7 +32,7 @@ fi green "the token is configured" # --- 2. the model configuration ------------------------------------------ -if python3 -c "from incident_scribe.__main__ import _load_dotenv; _load_dotenv(); from incident_scribe.llm import _config; _config()" 2>/dev/null; then +if "$PY" -c "from incident_scribe.__main__ import _load_dotenv; _load_dotenv(); from incident_scribe.llm import _config; _config()" 2>/dev/null; then green "the model provider is configured" else red "the model provider is not configured" @@ -42,7 +43,7 @@ fi # # Checked rather than assumed: the token gate is the one behaviour a demo # cannot show going wrong on purpose, so it is worth knowing it is on. -REFUSED=$(python3 -m incident_scribe logs --session-id _preflight --token wrong-token 2>/dev/null || true) +REFUSED=$("$PY" -m incident_scribe logs --session-id _preflight --token wrong-token 2>/dev/null || true) if printf '%s' "$REFUSED" | grep -q '"unauthorized"'; then green "the token gate is on (a wrong token is refused)" else @@ -78,7 +79,7 @@ else fi # --- 6. the desk can actually reach it ------------------------------------ -if python3 -c " +if "$PY" -c " from incident_scribe.mcp import directory r = directory().call('lookup_service', {'name': 'checkout-api'}) raise SystemExit(0 if r.get('owning_team') == 'payments' else 1) diff --git a/samples/incident-scribe/scripts/prompt_credentials.py b/samples/incident-scribe/scripts/prompt_credentials.py new file mode 100644 index 0000000..6ec5a5c --- /dev/null +++ b/samples/incident-scribe/scripts/prompt_credentials.py @@ -0,0 +1,81 @@ +"""Ask for the LambdaTest username and access key when .env has none, and save them. + + python scripts/prompt_credentials.py [path-to-.env] # default: ./.env + +Asks only on a terminal, only for a LambdaTest setup, and only for a value that is +neither set in the shell nor filled in .env. The access key is typed hidden. An +empty answer skips that value. Nothing it reads is ever printed. +""" + +from __future__ import annotations + +import getpass +import os +import sys +from pathlib import Path + +PROMPTS = (("LT_USERNAME", False), ("LT_ACCESS_KEY", True)) + + +def first_assignment(text: str, key: str) -> tuple[int | None, str]: + """Same reading as the desk's .env loader: first assignment wins, quotes are stripped.""" + for index, raw in enumerate(text.splitlines()): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + name, _, value = line.partition("=") + if name.strip() == key: + return index, value.strip().strip('"').strip("'") + return None, "" + + +def set_value(text: str, key: str, value: str) -> str: + """Fill the first active line for `key`, or append one.""" + index, _ = first_assignment(text, key) + lines = text.splitlines(keepends=True) + if index is not None: + raw = lines[index] + lines[index] = f"{key}={value}{raw[len(raw.rstrip(chr(13) + chr(10))):]}" + return "".join(lines) + newline = "\r\n" if "\r\n" in text else "\n" + separator = "" if not text or text.endswith("\n") else newline + return f"{text}{separator}{key}={value}{newline}" + + +def wants_lambdatest(text: str) -> bool: + provider = (os.environ.get("LLM_PROVIDER") or first_assignment(text, "LLM_PROVIDER")[1]).strip().lower() + return provider in ("lambdatest", "lt") + + +def ask(key: str, hidden: bool) -> str: + if hidden: + return getpass.getpass(f" {key} (hidden): ").strip() + sys.stdout.write(f" {key}: ") + sys.stdout.flush() + return sys.stdin.readline().strip() + + +def main() -> int: + path = Path(sys.argv[1] if len(sys.argv) > 1 else ".env") + with path.open(encoding="utf-8", newline="") as handle: + text = handle.read() + if not wants_lambdatest(text) or not sys.stdin.isatty(): + return 0 + + saved = [] + for key, hidden in PROMPTS: + if os.environ.get(key, "").strip() or first_assignment(text, key)[1]: + continue + answer = ask(key, hidden) + if answer and "\n" not in answer and "\r" not in answer: + text = set_value(text, key, answer) + saved.append(key) + if saved: + with path.open("w", encoding="utf-8", newline="") as handle: + handle.write(text) + print(f" ok saved {', '.join(saved)} to {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/samples/incident-scribe/test_incident_scribe.py b/samples/incident-scribe/test_incident_scribe.py index 94b15f9..b0c5d6d 100644 --- a/samples/incident-scribe/test_incident_scribe.py +++ b/samples/incident-scribe/test_incident_scribe.py @@ -263,3 +263,220 @@ def test_chat_and_gemini_tool_shapes(monkeypatch): result = llm.call([llm.user("find")], tools=[{"name": "lookup", "parameters": {"type": "object"}}]) _, contents = _gemini_items(result["raw_output"] + [tool_result(result["tool_calls"][0]["call_id"], {"ok": True})]) assert contents[-1]["parts"][0]["functionResponse"]["name"] == "lookup" + + +def test_lambdatest_gateway_uses_basic_auth(monkeypatch): + import base64 + from incident_scribe import llm + + for name in ("LLM_TIMEOUT", "LT_MODEL"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("LLM_PROVIDER", "lambdatest") + monkeypatch.setenv("GATEWAY_URL", "https://gateway.example.com") + monkeypatch.setenv("LT_USERNAME", "alice") + monkeypatch.setenv("LT_ACCESS_KEY", "s3cret") + provider, endpoint, key, model = _config() + assert (provider, endpoint, model) == ("lambdatest", "https://gateway.example.com/v1/chat/completions", "auto") + assert base64.b64decode(key).decode() == "alice:s3cret" + + seen = {} + + def fake_request(url, body, headers): + seen.update(url=url, body=body, headers=headers) + return {"choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}], "usage": {}} + + monkeypatch.setattr(llm, "_request", fake_request) + assert llm.call([llm.user("hi")])["text"] == "ok" + assert seen["headers"]["Authorization"] == f"Basic {key}" + assert seen["body"]["model"] == "auto" and "stream" not in seen["body"] + + +def test_lambdatest_gateway_needs_all_three_settings(monkeypatch): + import pytest + from incident_scribe.llm import ModelError + + monkeypatch.setenv("LLM_PROVIDER", "lt") + monkeypatch.setenv("GATEWAY_URL", "https://gateway.example.com") + monkeypatch.setenv("LT_USERNAME", "alice") + monkeypatch.delenv("LT_ACCESS_KEY", raising=False) + with pytest.raises(ModelError, match="LT_ACCESS_KEY"): + _config() + + +_PROVIDER_VARS = ( + "LLM_PROVIDER", "ANTHROPIC_BASE_URL", "GLM_API_KEY", "ZAI_API_KEY", "GEMINI_API_KEY", "OPENAI_ENDPOINT", + "OPENAI_API_KEY", "OPENAI_INDIA_API_KEY", "OPENAI_CHAT_MODEL", "OPENAI_MODEL", "OPENAI_CHAT_ENDPOINT", + "OPENAI_BASE_URL", "GATEWAY_URL", "LT_USERNAME", "LT_ACCESS_KEY", "LT_MODEL", +) + + +def _clean_provider_env(monkeypatch): + for name in _PROVIDER_VARS: + monkeypatch.delenv(name, raising=False) + + +def test_lambdatest_is_autodetected_after_the_chat_keys(monkeypatch): + _clean_provider_env(monkeypatch) + monkeypatch.setenv("GATEWAY_URL", "https://gateway.example.com") + monkeypatch.setenv("LT_USERNAME", "alice") + monkeypatch.setenv("LT_ACCESS_KEY", "s3cret") + assert _config()[0] == "lambdatest" + + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + monkeypatch.setenv("OPENAI_CHAT_MODEL", "gpt-x") + assert _config()[0] == "chat" + + +def test_lambdatest_sends_credentials_only_over_https_or_loopback(monkeypatch): + import pytest + from incident_scribe.llm import ModelError + + _clean_provider_env(monkeypatch) + monkeypatch.setenv("LLM_PROVIDER", "lambdatest") + monkeypatch.setenv("LT_USERNAME", "alice") + monkeypatch.setenv("LT_ACCESS_KEY", "s3cret") + for url in ("http://gateway.example.com", "gateway.example.com"): + monkeypatch.setenv("GATEWAY_URL", url) + with pytest.raises(ModelError, match="https"): + _config() + for url in ("http://127.0.0.1:8080", "http://localhost:8080", "https://gateway.example.com"): + monkeypatch.setenv("GATEWAY_URL", url) + assert _config()[0] == "lambdatest" + + +def test_model_error_carries_the_http_status(monkeypatch): + import io + import urllib.error + from incident_scribe import llm + + monkeypatch.setenv("LLM_RETRIES", "0") + + def refused(req, timeout): + raise urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, io.BytesIO(b"no")) + + monkeypatch.setattr(llm.urllib.request, "urlopen", refused) + try: + llm._request("https://gateway.example.com", {}, {}) + except llm.ModelError as error: + assert error.status == 401 + else: + raise AssertionError("expected ModelError") + + class Answer(io.BytesIO): + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + monkeypatch.setattr(llm.urllib.request, "urlopen", lambda req, timeout: Answer(b'{"error": "bad key"}')) + try: + llm._request("https://gateway.example.com", {}, {}) + except llm.ModelError as error: + assert error.status == 200 + else: + raise AssertionError("expected ModelError") + + +def _script(name): + import importlib.util + from pathlib import Path + + spec = importlib.util.spec_from_file_location(name, Path(__file__).parent / "scripts" / f"{name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_ensure_token_fills_every_way_a_token_can_be_missing(): + ensure = _script("ensure_token").ensure + for text in ("SCRIBE_API_TOKEN=\n", 'SCRIBE_API_TOKEN=""\n', "# SCRIBE_API_TOKEN=\nLLM_PROVIDER=x\n", "LLM_PROVIDER=x\n", ""): + new, generated = ensure(text) + assert generated + value = [line for line in new.splitlines() if line.startswith("SCRIBE_API_TOKEN=")][0].split("=", 1)[1] + assert len(value) == 48 and not value.startswith("-") + + kept, generated = ensure("A=1\nSCRIBE_API_TOKEN=abc\n") + assert (kept, generated) == ("A=1\nSCRIBE_API_TOKEN=abc\n", False) + + crlf, _ = ensure("SCRIBE_API_TOKEN=\r\nA=1\r\n") + assert "\n" not in crlf.replace("\r\n", "") and crlf.endswith("A=1\r\n") + + +def test_check_llm_never_reports_a_crash_as_not_configured(monkeypatch): + import json + check = _script("check_llm") + monkeypatch.setattr(check.llm, "_config", lambda: ("lambdatest", "https://gateway.example.com/v1/chat/completions", "k", "auto")) + monkeypatch.setattr(check, "gateway_health", lambda: True) + + def html_answer(*args, **kwargs): + raise json.JSONDecodeError("Expecting value", "", 0) + + monkeypatch.setattr(check.llm, "call", html_answer) + assert check.main() == 3 + + +def test_check_llm_classifies_by_status_not_by_message(monkeypatch): + check = _script("check_llm") + monkeypatch.setattr(check.llm, "_config", lambda: ("lambdatest", "https://gateway.example.com/v1/chat/completions", "k", "auto")) + monkeypatch.setattr(check, "gateway_health", lambda: True) + + def answering(status): + def call(*args, **kwargs): + error = check.llm.ModelError("model returned an error: bad key") + error.status = status + raise error + return call + + monkeypatch.setattr(check.llm, "call", answering(401)) + assert check.main() == 2 + monkeypatch.setattr(check.llm, "call", answering(200)) + assert check.main() == 3 + + +def test_gateway_health_probes_under_the_gateway_path_and_tolerates_a_missing_probe(monkeypatch): + import io + import urllib.error + check = _script("check_llm") + seen = [] + + def missing(url, timeout): + seen.append(url) + raise urllib.error.HTTPError(url, 404, "Not Found", {}, io.BytesIO(b"")) + + monkeypatch.setenv("GATEWAY_URL", "https://proxy.example.com/llm-gateway/") + monkeypatch.setattr(check.urllib.request, "urlopen", missing) + assert check.gateway_health() is True + assert seen == ["https://proxy.example.com/llm-gateway/healthz"] + + +def test_prompt_credentials_fills_only_what_is_missing(tmp_path, monkeypatch): + prompt = _script("prompt_credentials") + template = "LLM_PROVIDER=lambdatest\nLT_USERNAME=\nLT_ACCESS_KEY=\nLT_MODEL=auto\n" + + filled = prompt.set_value(prompt.set_value(template, "LT_USERNAME", "me"), "LT_ACCESS_KEY", "s3cret") + assert filled == "LLM_PROVIDER=lambdatest\nLT_USERNAME=me\nLT_ACCESS_KEY=s3cret\nLT_MODEL=auto\n" + assert prompt.set_value("A=1", "LT_USERNAME", "me") == "A=1\nLT_USERNAME=me\n" + assert prompt.set_value("LT_USERNAME=\r\nA=1\r\n", "LT_USERNAME", "me") == "LT_USERNAME=me\r\nA=1\r\n" + + env_file = tmp_path / ".env" + env_file.write_text(template) + monkeypatch.delenv("LLM_PROVIDER", raising=False) + monkeypatch.setattr(prompt.sys.stdin, "isatty", lambda: False) + monkeypatch.setattr(prompt.sys, "argv", ["prompt_credentials.py", str(env_file)]) + assert prompt.main() == 0 + assert env_file.read_text() == template + + monkeypatch.setattr(prompt.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(prompt, "ask", lambda key, hidden: {"LT_USERNAME": "me", "LT_ACCESS_KEY": "s3cret"}[key]) + monkeypatch.setenv("LT_USERNAME", "from-the-shell") + assert prompt.main() == 0 + assert env_file.read_text() == "LLM_PROVIDER=lambdatest\nLT_USERNAME=\nLT_ACCESS_KEY=s3cret\nLT_MODEL=auto\n" + + glm = tmp_path / "glm.env" + glm.write_text("LLM_PROVIDER=glm\n") + monkeypatch.setattr(prompt.sys, "argv", ["prompt_credentials.py", str(glm)]) + assert prompt.main() == 0 + assert glm.read_text() == "LLM_PROVIDER=glm\n" diff --git a/samples/setup.ps1 b/samples/setup.ps1 new file mode 100644 index 0000000..8393eca --- /dev/null +++ b/samples/setup.ps1 @@ -0,0 +1,143 @@ +# +# One-command setup for the sample agents on Windows - the twin of setup.sh. +# Runs on Windows PowerShell 5.1 and PowerShell 7. +# +# powershell -ExecutionPolicy Bypass -File setup.ps1 set up EVERYTHING (the one command; run it from anywhere) +# powershell -ExecutionPolicy Bypass -File setup.ps1 incident-scribe or just the samples you name +# powershell -ExecutionPolicy Bypass -File setup.ps1 -NoSemantic skip the embeddings download +# +# Safe to re-run: an existing .env is kept, and a token is only generated where +# the file has none. +# +# What each sample needs: +# incident-scribe Python 3.9+, Node, a .env with a token and a model provider +# industry-agents Node 22+, npm install, one .env per demo +# refund-desk, triage-service, knowledge-vault, trip-weather-station +# Node only - nothing to install + +param( + [switch]$NoSemantic, + [Parameter(ValueFromRemainingArguments = $true)][string[]]$Samples +) + +Set-Location $PSScriptRoot + +$All = 'incident-scribe', 'industry-agents', 'refund-desk', 'triage-service', 'knowledge-vault', 'trip-weather-station' + +function Ok($msg) { Write-Host " " -NoNewline; Write-Host "ok" -ForegroundColor Green -NoNewline; Write-Host " $msg" } +function Bad($msg) { Write-Host " " -NoNewline; Write-Host "!!" -ForegroundColor Red -NoNewline; Write-Host " $msg" } +function Info($msg) { Write-Host " $msg" } +function Head($msg) { Write-Host ""; Write-Host $msg -ForegroundColor White } + +if (-not $Samples) { $Samples = $All } +foreach ($s in $Samples) { + if ($All -notcontains $s) { Bad "unknown sample: $s (choose from: $($All -join ', '))"; exit 2 } +} + +$failed = @() + +# `python3` on Windows is often the Microsoft Store stub; `python` is the real one. +$python = $null +foreach ($candidate in 'python', 'python3') { + $cmd = Get-Command $candidate -ErrorAction SilentlyContinue + if ($cmd) { + & $cmd.Source -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)" 2>$null + if ($LASTEXITCODE -eq 0) { $python = $cmd.Source; break } + } +} + +Head "sample agents - setup" + +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { Bad "node is not on PATH (https://nodejs.org)"; exit 1 } +$nodeMajor = [int](node -p "process.versions.node.split('.')[0]") +$nodeMinor = [int](node -p "process.versions.node.split('.')[1]") +Ok "node $(node --version)" + +function Setup-IncidentScribe { + Head "incident-scribe" + if (-not $python) { Bad "Python 3.9+ is not on PATH (python / python3)"; $script:failed += 'incident-scribe'; return } + Ok (& $python --version) + + Push-Location incident-scribe + try { + if (-not (Test-Path .env)) { + Copy-Item .env.example .env + Ok "created .env from .env.example" + } else { + Ok "kept existing .env" + } + + if ((& $python scripts\ensure_token.py .env) -eq 'generated') { + Ok "generated SCRIBE_API_TOKEN in .env" + } else { + Ok "SCRIBE_API_TOKEN already set" + } + + & $python scripts\prompt_credentials.py .env + + if (-not $NoSemantic) { + Info "installing embeddings (downloads a model, a few minutes)..." + npm install --no-audit --no-fund *> $null + $installed = ($LASTEXITCODE -eq 0) + if ($installed) { node scripts/build-index.mjs *> $null; $installed = ($LASTEXITCODE -eq 0) } + if ($installed) { Ok "semantic search armed" } else { Bad "embedding install failed - search_incidents will fall back to lexical mode" } + } else { + Info "search_incidents will run in lexical mode (-NoSemantic was given; re-run without it for embeddings)" + } + + # keys present -> endpoint up -> credentials accepted (exit 1 / 3 / 2 when not) + & $python scripts\check_llm.py + switch ($LASTEXITCODE) { + 0 { + & powershell -NoProfile -ExecutionPolicy Bypass -File scripts\preflight.ps1 + if ($LASTEXITCODE -ne 0) { $script:failed += 'incident-scribe' } + else { Info "next: cd incident-scribe; . .\scripts\env.ps1 (loads .env, including SCRIBE_API_TOKEN, into your session)" } + } + 1 { + Info "next: complete the provider settings named above in incident-scribe\.env, then re-run setup.ps1 incident-scribe" + Info " (the LambdaTest template needs LT_USERNAME and LT_ACCESS_KEY; an older .env keeps its own LLM_PROVIDER)." + } + default { Bad "the model provider is configured but did not pass - fix .env and re-run"; $script:failed += 'incident-scribe' } + } + } finally { + Pop-Location + } +} + +function Setup-IndustryAgents { + Head "industry-agents" + if ($nodeMajor -lt 22) { Bad "needs Node 22+, found $(node --version)"; $script:failed += 'industry-agents'; return } + Push-Location industry-agents + try { + npm install --no-audit --no-fund *> $null + if ($LASTEXITCODE -ne 0) { Bad "npm install failed"; $script:failed += 'industry-agents'; return } + Ok "dependencies installed" + npm run setup --silent | ForEach-Object { Info $_ } + if ($LASTEXITCODE -ne 0) { Bad "npm run setup failed"; $script:failed += 'industry-agents'; return } + Info "next: add MODEL_API_KEY to the demo .env you want to run (demos\*\.env)" + } finally { + Pop-Location + } +} + +foreach ($sample in $Samples) { + switch ($sample) { + 'incident-scribe' { Setup-IncidentScribe } + 'industry-agents' { Setup-IndustryAgents } + default { + Head $sample + # knowledge-vault and trip-weather-station import node:sqlite, unflagged from Node 22.13 + $needsSqlite = $sample -in 'knowledge-vault', 'trip-weather-station' + if ($needsSqlite -and ($nodeMajor -lt 22 -or ($nodeMajor -eq 22 -and $nodeMinor -lt 13))) { + Bad "needs Node 22.13+ for node:sqlite, found $(node --version)"; $failed += $sample + } else { + Ok "nothing to install" + } + } + } +} + +Write-Host "" +if ($failed.Count -gt 0) { Bad "needs attention: $($failed -join ', ')"; exit 1 } +Info "done." +Write-Host "" diff --git a/samples/setup.sh b/samples/setup.sh new file mode 100755 index 0000000..8b5e336 --- /dev/null +++ b/samples/setup.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# +# One-command setup for the sample agents (macOS, Linux, WSL). The Windows twin +# is setup.ps1. +# +# ./setup.sh set up EVERYTHING (the one command; run it from anywhere) +# ./setup.sh incident-scribe or just the samples you name +# ./setup.sh --no-semantic skip the embeddings download for incident-scribe +# ./setup.sh --help +# +# Safe to re-run: an existing .env is kept, and a token is only generated where +# the file has none. +# +# What each sample needs: +# incident-scribe Python 3.9+, Node, a .env with a token and a model provider +# industry-agents Node 22+, npm install, one .env per demo +# refund-desk, triage-service, knowledge-vault, trip-weather-station +# Node only — nothing to install + +set -euo pipefail +cd "$(dirname "$0")" + +ALL="incident-scribe industry-agents refund-desk triage-service knowledge-vault trip-weather-station" +SEMANTIC=1 +SELECTED="" + +green() { printf ' \033[32m%s\033[0m %s\n' "ok" "$1"; } +red() { printf ' \033[31m%s\033[0m %s\n' "!!" "$1"; } +info() { printf ' %s\n' "$1"; } +head_() { printf '\n\033[1m%s\033[0m\n' "$1"; } + +for arg in "$@"; do + case "$arg" in + --semantic) SEMANTIC=1 ;; + --no-semantic) SEMANTIC=0 ;; + -h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) red "unknown option: $arg"; exit 2 ;; + *) + case " $ALL " in + *" $arg "*) SELECTED="$SELECTED $arg" ;; + *) red "unknown sample: $arg (choose from: $ALL)"; exit 2 ;; + esac ;; + esac +done +SELECTED="${SELECTED:-$ALL}" + +wants() { case " $SELECTED " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } + +# --- prerequisites --------------------------------------------------------- +FAILED="" + +PY="" +for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1 \ + && "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)' 2>/dev/null; then + PY="$candidate"; break + fi +done + +NODE_MAJOR="" +NODE_MINOR="" +if command -v node >/dev/null 2>&1; then + NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" + NODE_MINOR="$(node -p 'process.versions.node.split(".")[1]')" +fi + +head_ "sample agents — setup" + +if [ -n "$NODE_MAJOR" ]; then green "node $(node --version)"; else red "node is not on PATH (https://nodejs.org)"; exit 1; fi + +# --- incident-scribe ------------------------------------------------------- +setup_incident_scribe() { + head_ "incident-scribe" + if [ -z "$PY" ]; then red "Python 3.9+ is not on PATH (python3 / python)"; FAILED="$FAILED incident-scribe"; return; fi + green "$($PY --version)" + + cd incident-scribe + if [ ! -f .env ]; then + (umask 077; cp .env.example .env) + green "created .env from .env.example" + else + green "kept existing .env" + fi + + if [ "$("$PY" scripts/ensure_token.py .env)" = generated ]; then + green "generated SCRIBE_API_TOKEN in .env" + else + green "SCRIBE_API_TOKEN already set" + fi + + "$PY" scripts/prompt_credentials.py .env + + if [ "$SEMANTIC" = 1 ]; then + info "installing embeddings (downloads a model, a few minutes)..." + if npm install --no-audit --no-fund >/dev/null && node scripts/build-index.mjs >/dev/null; then + green "semantic search armed" + else + red "embedding install failed — search_incidents will fall back to lexical mode" + fi + else + info "search_incidents will run in lexical mode (--no-semantic was given; re-run without it for embeddings)" + fi + + # keys present -> endpoint up -> credentials accepted (exit 1 / 3 / 2 when not) + local status=0 + "$PY" scripts/check_llm.py || status=$? + case "$status" in + 0) + if PY="$PY" bash scripts/preflight.sh; then + info "next: cd incident-scribe && . scripts/env.sh (loads .env, including SCRIBE_API_TOKEN, into your shell)" + else + FAILED="$FAILED incident-scribe" + fi ;; + 1) + info "next: complete the provider settings named above in incident-scribe/.env, then re-run ./setup.sh incident-scribe" + info " (the LambdaTest template needs LT_USERNAME and LT_ACCESS_KEY; an older .env keeps its own LLM_PROVIDER)." ;; + *) red "the model provider is configured but did not pass — fix .env and re-run"; FAILED="$FAILED incident-scribe" ;; + esac + cd .. +} + +# --- industry-agents ------------------------------------------------------- +setup_industry_agents() { + head_ "industry-agents" + if [ "$NODE_MAJOR" -lt 22 ]; then red "needs Node 22+, found $(node --version)"; FAILED="$FAILED industry-agents"; return; fi + cd industry-agents + if npm install --no-audit --no-fund >/dev/null; then + green "dependencies installed" + else + red "npm install failed"; FAILED="$FAILED industry-agents"; cd ..; return + fi + if npm run setup --silent | while IFS= read -r line; do info "$line"; done; then + info "next: add MODEL_API_KEY to the demo .env you want to run (demos/*/.env)" + else + red "npm run setup failed"; FAILED="$FAILED industry-agents" + fi + cd .. +} + +# --- node-only samples ----------------------------------------------------- +# knowledge-vault and trip-weather-station import node:sqlite, unflagged from Node 22.13. +setup_node_only() { + head_ "$1" + case "$1" in + knowledge-vault|trip-weather-station) + if [ "$NODE_MAJOR" -lt 22 ] || { [ "$NODE_MAJOR" -eq 22 ] && [ "$NODE_MINOR" -lt 13 ]; }; then + red "needs Node 22.13+ for node:sqlite, found $(node --version)"; FAILED="$FAILED $1"; return + fi ;; + esac + green "nothing to install" +} + +for sample in $SELECTED; do + case "$sample" in + incident-scribe) setup_incident_scribe ;; + industry-agents) setup_industry_agents ;; + *) setup_node_only "$sample" ;; + esac +done + +echo +if [ -n "$FAILED" ]; then + red "needs attention:$FAILED" + exit 1 +fi +info "done." +echo