diff --git a/.gitignore b/.gitignore index f7107a8..7964f68 100755 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ adminBenches/cloudAdmin/ devBenches/flutterBench/ devBenches/frappeBench/ +devBenches/phpBench/ devBenches/pythonBench/ # Installation tracking (local only) @@ -14,11 +15,23 @@ config/version-manifest.json # Common ignores .DS_Store +__pycache__/ +*.py[cod] *.log *.tmp node_modules/ + +# Secret and machine-local environment files. Keep template examples tracked. .env +.env.* +*.env +!.env.example +!.env.sample +!.env.template +*.backup *.bak +*.bak.* +secrets/ *.swp *.swo @@ -32,8 +45,13 @@ Thumbs.db # Bio bench repositories bioBenches/gentecBench/ +bioBenches/simBench/ logs/ reports/cache-pilot/ .codex .codex/ +.claude/dashboard.md +.claude/speckit-history.md +.claude/settings.local.json sysBenches/opsBench/ +.worktrees/ diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..5447ac5 --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,154 @@ +# Asynchronous Clarify Protocol + +A custom clarify step for [GitHub Spec Kit](https://github.com/github/spec-kit) + +Claude Code. It fans question generation out across reviewer angles, logs the questions +to a file, and finishes clarify **automatically** once they are answered — no manual +re-running. + +## Why + +Spec Kit's stock `/speckit.clarify` is synchronous: it asks, you answer, all in one +sitting. Real clarification has human latency — a domain expert may take a day to answer +a compliance question. This protocol decouples **question generation** (cheap, parallel, +done by AI) from **answering** (slow, human) from **application** (one edit to `spec.md`), +and uses a polling loop so the loop, not you, watches for completion. + +## The four files + +| File | Role | +|------|------| +| `.claude/commands/openClarify.md` | Orchestrator. Resolves the feature dir, initializes the log, triggers the generation fan-out, registers the poll loop. Never answers questions. | +| `.claude/commands/openClarify-resume.md` | Poll tick. Reads the log and branches cheaply; only the all-answered tick edits `spec.md`. Enforces the critical-class human gate. | +| `templates/clarify-log.template.md` | The log schema: two top-of-file sentinels + per-question blocks. | +| `PROTOCOL.md` | This document. | + +## Data flow + +``` +/openClarify [feature-dir] + ├─ verify spec.md exists + ├─ init clarify-log.md from template (GENERATION: PENDING, CLARIFY: IN_PROGRESS) + ├─ workflow: fan out reviewer angles ──┐ + │ data-model ┐ │ each appends OPEN question blocks + │ edge-cases ┤ │ merge + dedupe, cap 25 + │ security-compliance ┤ │ then flip GENERATION: COMPLETE + │ testability ┤ │ + │ integration ┘ │ + └─ register /loop 10m /openClarify-resume + + ... humans answer blocks in clarify-log.md over time ... + +/openClarify-resume (every 10 min) + ├─ log missing? → no-op + ├─ CLARIFY: COMPLETE? → no-op, stop loop + ├─ GENERATION != COMPLETE → no-op (sentinel guard: no early firing) + ├─ any status: OPEN? → no-op (still waiting) + ├─ critical answered by architect-ai? → no-op (human escalation) + └─ all answered, criticals human-signed → edit spec.md, CLARIFY: COMPLETE, stop loop +``` + +## The log schema + +Two **sentinels** at the top of `clarify-log.md` are the entire coordination contract: + +- `GENERATION: PENDING | COMPLETE` — set `COMPLETE` only when the fan-out has written + every question. Until then the poller refuses to act. +- `CLARIFY: IN_PROGRESS | COMPLETE` — set `COMPLETE` only after answers are applied to + `spec.md`. + +Each question is a block: + +``` +## Q3 +- id: q3 +- status: OPEN # OPEN | ANSWERED +- class: normal # normal | critical +- agent: security-compliance # which reviewer angle raised it +- question: How long is PHI retained after account deletion? +- answer: +- answered_by: # human | architect-ai +- ts: +``` + +## Three design guarantees + +1. **Sentinel guard against early firing.** The poller treats `GENERATION: COMPLETE` as a + precondition. A log that is mid-generation can momentarily show "zero OPEN questions" + simply because no questions have been written yet — the guard stops the poller from + misreading that as "all answered" and prematurely editing `spec.md`. + +2. **Critical-class human escalation.** A `class: critical` question (clinical / regulated / + safety-impacting) answered by `architect-ai` does **not** clear. Completion blocks until + a human re-answers or confirms it. The AI can draft; only a human signature releases the + gate. + +3. **Cheap read-and-branch ticks.** Nearly every poll tick just greps the sentinels and a + handful of `status:` lines, then exits. Exactly one tick — the one that sees everything + answered and all criticals human-signed — does the expensive `spec.md` edit. Polling + every 10 minutes is therefore nearly free. + +## Relationship to stock `/speckit.clarify` — audit pass + +This protocol **does the clarification work**, then stock `/speckit.clarify` runs **after** +as an **audit**, not as the primary clarifier. + +Because `/openClarify-resume` writes answers in stock's canonical shape — a +`## Clarifications` section with `### Session YYYY-MM-DD` and `- Q: … → A: …` bullets, plus +the answer folded into the relevant spec section — a subsequent stock run sees those points +as already resolved. Stock decides what to ask by scanning **spec sections** against its +coverage taxonomy (Clear / Partial / Missing), so the folding in §3b is what actually makes +the audit quiet, not the log bullets. + +**How to use the audit:** after our protocol marks `CLARIFY: COMPLETE`, run stock +`/speckit.clarify` (the `speckit-clarify` skill) a few times. + +- **No new questions** → our five reviewer angles covered the spec to stock's standard. Proceed to `/speckit.plan`. +- **New questions** → a real coverage gap. Most will land in taxonomy categories our angles + don't target: **functional scope & behavior, interaction/UX flow, non-functional + (performance, scalability, reliability, observability), constraints & tradeoffs, and + terminology consistency**. Our angles cover data-model, edge-cases, security/compliance, + testability, and integration — so those five categories are the expected blind spots. + +Treat stock's output as a **regression check on our generation coverage**. If a category +keeps surfacing, add a reviewer angle for it to the fan-out in `/openClarify`. + +> Note: in this environment stock clarify is overridden (see `~/.claude` global config) to +> ask up to **25** questions in **block form**, written to `/clarify-questions.md` +> — so the audit produces a diffable file rather than a one-at-a-time interactive loop. + +``` +/openClarify → (async answers) → resume applies + canonical format → CLARIFY: COMPLETE + │ + /speckit.clarify ×N (audit) + │ + new questions? → new clarify cycle ; else → /speckit.plan +``` + +## Known gaps / operational notes + +- **Generation script is authored on first run.** The `workflow` keyword lets the Claude + Code runtime author the fan-out's internal script the first time `/openClarify` + runs. **Save that run as `/openClarify-generate`** so subsequent features reuse it + instead of re-authoring the fan-out each time. + +- **`/loop` is session-scoped with a 3-day cap.** It only survives while the session is + alive and stops after ~3 days. For human turnaround longer than that, swap the in-session + loop for an external scheduler: + + ``` + cron + claude -p /openClarify-resume + ``` + + e.g. a crontab entry running `claude -p "/openClarify-resume specs/my-feature/"` every + 15 minutes, which survives restarts and arbitrary human latency. + +## Usage + +``` +# 1. start (defaults to specs//) +/openClarify + +# 2. humans edit clarify-log.md, filling answer / answered_by / status: ANSWERED + +# 3. nothing else to do — the loop applies answers to spec.md and stops itself +``` diff --git a/README.md b/README.md index 60bcba0..c056903 100755 --- a/README.md +++ b/README.md @@ -11,10 +11,11 @@ A layered Docker-based development environment system. Each "bench" is a self-co This single command: 1. Configures your shell (zsh + Oh My Zsh + Powerlevel10k) 2. Checks workstation VPN clients and patches 0dcloud TUN MTU for large Git/Docker transfers -3. Ensures Docker is running and Layer 0 base image exists -4. Opens an interactive TUI to select benches, AI tools, and workstation tools -5. Builds Docker images for selected benches -6. Installs AI coding CLIs and workstation tools (Claude, Copilot, Codex, Pi, etc.) +3. Installs or updates Wave Terminal widgets for workBenches +4. Ensures Docker is running and Layer 0 base image exists +5. Opens an interactive TUI to select benches, AI tools, and workstation tools +6. Builds Docker images for selected benches +7. Installs AI coding CLIs and workstation tools (Claude, Copilot, Codex, Pi, etc.) After setup, open any bench in VS Code → "Reopen in Container" to start developing. @@ -25,13 +26,14 @@ Safe to run repeatedly. Installed benches show `✓ up to date` and are skipped. ## Docker Image Layers ``` -Layer 0: workbench-base:latest — Ubuntu 24.04 + git, zsh, curl, AI CLIs, bun - ├─ Layer 1a: dev-bench-base:latest — Python, Node.js LTS, npm, dev tools, testing tools, Playwright Chromium +Layer 0: workbench-base:latest — Ubuntu 24.04 + git, zsh, curl, shared AI CLIs, bun + ├─ Layer 1a: dev-bench-base:latest — Python, Node.js LTS, npm, dev tools, OpenSpec, spec-kit, testing tools, Playwright Chromium │ ├─ Layer 2: cpp-bench:latest — GCC, CMake, vcpkg │ ├─ Layer 2: dotnet-bench:latest — .NET SDK 8/9 │ ├─ Layer 2: flutter-bench:latest — Flutter SDK, Dart, Android tools │ ├─ Layer 2: frappe-bench:latest — MariaDB client, Redis, Nginx, bench CLI (Node.js 20) - │ ├─ Layer 2: java-bench:latest — OpenJDK 21, Maven, Gradle, Spring CLI + │ ├─ Layer 2: java-bench:latest — OpenJDK 25, Maven, Gradle, Spring CLI + │ ├─ Layer 2: php-bench:latest — PHP 8.3, Composer, PHPUnit, Xdebug │ ├─ Layer 2: py-bench:latest — Python dev tools (thin layer on 1a) │ └─ Layer 2: go-bench:latest — Go toolchain ├─ Layer 1b: sys-bench-base:latest — Kubernetes, Terraform, cloud CLIs @@ -67,6 +69,7 @@ bash scripts/ensure-layer3.sh --base java-bench:latest setup.sh ├── Shell setup (zsh + Oh My Zsh + Powerlevel10k) ├── VPN setup (AmneziaVPN + 0dcloud checks, 0dcloud MTU patch) + ├── Wave Terminal widgets (terminal, projects, and workBench containers) ├── Docker check (is daemon running?) ├── Layer 0 check (build workbench-base:latest if missing) ├── Interactive TUI (scripts/interactive-setup.sh) @@ -79,6 +82,39 @@ setup.sh └── Summary + log file path ``` +## Wave Terminal Widgets + +`setup.sh` runs the Wave Terminal installer as a best-effort host setup step so +all workBenches checkouts get the same desktop shortcuts. The installer comes +from `opensoft/Install-Wave-Terminal`; setup prefers a sibling +`../Install-Wave-Terminal` checkout when present, then falls back to cloning it +into `~/.cache/workbenches/Install-Wave-Terminal`. + +Installed widgets include: + +| Widget | Behavior | +|--------|----------| +| `terminal` | Overrides Wave's built-in terminal to open `wsl://Ubuntu-24.04` instead of PowerShell | +| `projects` | Opens the Wave files view at `$HOME/projects` on the WSL connection | +| `pyBench` | Starts or repairs `py-bench`, then opens an interactive shell | +| `flutterBench` | Starts or repairs `flutter-bench`, then opens an interactive shell | +| `C++Bench` | Starts or repairs `cpp-bench`, then opens an interactive shell | +| `cloudBench` | Starts or repairs `cloud-bench`, then opens an interactive shell | + +First-run setup offers consent-based work and personal AI profile onboarding, +including GitHub credential-registry discovery and a local manual fallback. +See [Shared AI provider profiles](docs/multi-provider-profiles.md). +Multi-account Claude details remain in +[Claude multi-account profiles](docs/claude-multi-account-profiles.md). +The cross-provider ownership and composition model is documented in +[AI credential ownership and profile composition](docs/ai-credential-ownership.md). + +The WSL connection defaults to `wsl://Ubuntu-24.04` and the projects widget +defaults to `$HOME/projects`. Override them with `WAVE_WSL_CONNECTION` and +`WAVE_PROJECTS_ROOT`. Widget font size defaults to `16`; override it with +`WAVE_WIDGET_FONT_SIZE`. Set `WORKBENCHES_SKIP_WAVE_WIDGETS=1` to skip this +step. + ### Bench Processing Logic | State | Action | @@ -112,6 +148,7 @@ workBenches/ │ ├── frappeBench/ ← Frappe/ERPNext bench (opensoft/frappeBench) │ ├── goBench/ ← Go bench (opensoft/goBench) │ ├── javaBench/ ← Java bench (opensoft/javaBench) +│ ├── phpBench/ ← PHP bench (opensoft/phpBench) │ └── pyBench/ ← Python bench (opensoft/pyBench) ├── sysBenches/ │ ├── base-image/ ← Layer 1b: sys-bench-base Dockerfile @@ -180,9 +217,10 @@ Installed and updated via the setup TUI: |------|---------------|------| | Claude Code CLI | Native installer | `claude login` | | GitHub Copilot CLI | npm | `copilot auth login` | -| OpenAI Codex CLI | npm | `OPENAI_API_KEY` or `codex login` | -| Google Gemini CLI | npm | Google login (free tier: 60 req/min) | -| OpenCode CLI | Manual | Additional setup required | +| OpenAI Codex CLI | npm | `OPENAI_API_KEY`, `codex login`, or isolated `pcodex PROFILE` logins | +| Google Gemini CLI | npm | Google login or isolated `pgemini PROFILE` login | +| Grok Build | Native installer | Isolated `pgrok PROFILE` login | +| OpenCode CLI with Z.AI GLM | Manual | Isolated `pglm PROFILE` Z.AI Coding Plan key | | spec-kit | uv (pip) | None | | OpenSpec | npm | None | @@ -256,6 +294,7 @@ sysBenches/cloudBench/docs/amnezia-server-runbook.md | frappeBench | [opensoft/frappeBench](https://github.com/opensoft/frappeBench) | | goBench | [opensoft/goBench](https://github.com/opensoft/goBench) | | javaBench | [opensoft/javaBench](https://github.com/opensoft/javaBench) | +| phpBench | [opensoft/phpBench](https://github.com/opensoft/phpBench) | | pyBench | [opensoft/pyBench](https://github.com/opensoft/pyBench) | | gentecBench | [opensoft/gentecBench](https://github.com/opensoft/gentecBench) | | simBench | [opensoft/simBench](https://github.com/opensoft/simBench) | diff --git a/apps/credential-manager/README.md b/apps/credential-manager/README.md new file mode 100644 index 0000000..5c492c7 --- /dev/null +++ b/apps/credential-manager/README.md @@ -0,0 +1,58 @@ +# Multiple AI Harness Account Manager + +This local-only dashboard manages account inventory and supported login flows +for Claude Code, ChatGPT/Codex CLI, Grok Build, Google Antigravity, and Abacus +AI. It is generic public tooling: real account names and email addresses belong +in a separate private source-of-truth repository. + +Create a private manifest from the example: + +```bash +mkdir -p ~/account-registry/config +cp config/ai-harness-accounts.example.json \ + ~/account-registry/config/ai-harness-accounts.json +$EDITOR ~/account-registry/config/ai-harness-accounts.json +``` + +Run the dashboard: + +```bash +python3 apps/credential-manager/credential_manager.py \ + --source-repo ~/account-registry +``` + +Profiles created by `setup.sh` need no separate registry argument: + +```bash +./scripts/check-ai-credentials.sh +``` + +This reads supported manifests in `~/.config/workbenches` by default, including +the Claude, OpenAI, Gemini, Grok, and GLM profile manifests. + +Open `http://127.0.0.1:8765`. The server binds only to loopback. It reads +non-secret account metadata, derives the source clone's Git remote, verifies +supported profiles, and starts supported vendor login commands. + +Automated adapters: + +- Claude Code: `CLAUDE_CONFIG_DIR`, `claude auth login`, and + `claude auth status`. +- ChatGPT/Codex CLI: `CODEX_HOME`, `codex login`, and + `codex login status`. +- Grok Build: `GROK_HOME`, `grok login`, and `grok models`. + +Inventory/manual adapters: + +- Google Antigravity: the vendor stores sessions in the operating-system + keyring and does not document a profile-home override. +- Abacus AI: the vendor documents `/login [email]` and `ABACUS_API_KEY`, but + not a profile-home override. + +The dashboard never reads, returns, copies, or stores credential contents. +Authentication logs are local, mode `0600`, and may contain short-lived login +URLs, so they must not be committed. + +See [Multiple AI harness account management](../../docs/ai-harness-account-management.md) +for the manifest schema, provider commands, security model, and official +documentation links. diff --git a/apps/credential-manager/credential_manager.py b/apps/credential-manager/credential_manager.py new file mode 100755 index 0000000..38a88fb --- /dev/null +++ b/apps/credential-manager/credential_manager.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""Local-only dashboard for multiple AI harness accounts.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import threading +import webbrowser +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlparse + + +HOME = Path.home() +STATE_DIR = HOME / ".local/state/workbenches/credential-manager" +STATE_DIR.mkdir(parents=True, exist_ok=True) + + +@dataclass(frozen=True) +class Account: + provider: str + name: str + email: str + family: str = "" + plan: str = "" + workspace: str = "" + status: str = "active" + auth_mode: str = "browser" + secret_env: str = "" + + +PROVIDER_ALIASES = { + "codex": "chatgpt", + "openai": "chatgpt", + "zai": "glm", + "z.ai": "glm", +} + +PROVIDER_CLIS = { + "claude": "claude", + "chatgpt": "codex", + "grok": "grok", + "gemini": "gemini", + "glm": "opencode", + "antigravity": "agy", + "abacus": "abacusai", +} + + +def canonical_provider(provider: str) -> str: + provider = provider.strip().lower() + return PROVIDER_ALIASES.get(provider, provider) + + +def git_repo_url(repo: Path) -> str: + try: + remote = subprocess.check_output( + ["git", "-C", str(repo), "remote", "get-url", "origin"], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except (OSError, subprocess.CalledProcessError): + return "" + if remote.startswith("git@github.com:"): + remote = "https://github.com/" + remote.removeprefix("git@github.com:") + if remote.endswith(".git"): + remote = remote[:-4] + return remote + + +def load_accounts(repo: Path) -> list[Account]: + accounts: list[Account] = [] + seen: set[tuple[str, str]] = set() + config = repo / "config" if (repo / "config").is_dir() else repo + + unified = config / "ai-harness-accounts.json" + if unified.exists(): + data = json.loads(unified.read_text()) + for item in data.get("accounts", []): + provider = canonical_provider(item["provider"]) + account = Account( + provider=provider, + name=item["name"], + email=item.get("email", ""), + family=item.get("family", ""), + plan=item.get("plan", ""), + workspace=item.get("workspace", ""), + status=item.get("status", "active"), + auth_mode=item.get("authMode", "browser"), + secret_env=item.get("secretEnv", ""), + ) + accounts.append(account) + seen.add((account.provider, account.name)) + + legacy_specs = ( + ("claude", config / "claude-profiles.json", "profiles"), + ("chatgpt", config / "openai-profiles.json", "profiles"), + ("grok", config / "grok-profiles.json", "profiles"), + ("gemini", config / "gemini-profiles.json", "profiles"), + ("glm", config / "glm-profiles.json", "profiles"), + ("antigravity", config / "antigravity-accounts.json", "accounts"), + ("abacus", config / "abacus-accounts.json", "accounts"), + ) + for provider, path, key in legacy_specs: + if not path.exists(): + continue + data = json.loads(path.read_text()) + for item in data.get(key, []): + identity = (provider, item["name"]) + if identity in seen: + continue + account = Account( + provider=provider, + name=item["name"], + email=item.get("email", ""), + family=item.get("family", ""), + plan=item.get("plan", ""), + workspace=item.get("workspace", ""), + status=item.get("status", "active"), + auth_mode=item.get("authMode", "browser"), + secret_env=item.get("secretEnv", ""), + ) + accounts.append(account) + seen.add(identity) + return accounts + + +def profile_home(account: Account) -> Path | None: + if account.provider == "claude": + return HOME / ".claude-profiles/profiles" / account.name + if account.provider == "chatgpt": + current = HOME / ".chatgpt-profiles/profiles" / account.name + legacy = HOME / ".openai-profiles/profiles" / account.name + return legacy if legacy.exists() and not current.exists() else current + if account.provider == "grok": + return HOME / ".grok-profiles/profiles" / account.name + if account.provider == "gemini": + return HOME / ".gemini-profiles/profiles" / account.name + if account.provider == "glm": + return HOME / ".glm-profiles/profiles" / account.name + return None + + +def auth_command(account: Account) -> tuple[list[str], dict[str, str]] | None: + env = os.environ.copy() + directory = profile_home(account) + if account.provider == "claude" and directory: + directory.mkdir(parents=True, exist_ok=True) + metadata = directory / ".claude.json" + if not metadata.exists(): + metadata.write_text('{"hasCompletedOnboarding": true}\n') + metadata.chmod(0o600) + env["CLAUDE_CONFIG_DIR"] = str(directory) + return ["claude", "auth", "login", "--claudeai", "--email", account.email], env + if account.provider == "chatgpt" and directory: + directory.mkdir(parents=True, exist_ok=True) + env["CODEX_HOME"] = str(directory) + config = directory / "config.toml" + if not config.exists(): + config.write_text('forced_login_method = "chatgpt"\ncli_auth_credentials_store = "file"\n') + config.chmod(0o600) + command = ["codex", "login"] + if account.auth_mode == "device": + command.append("--device-auth") + return command, env + if account.provider == "grok" and directory: + directory.mkdir(parents=True, exist_ok=True) + env["GROK_HOME"] = str(directory) + command = ["grok", "login"] + if account.auth_mode == "device": + command.append("--device-auth") + return command, env + return None + + +def verify(account: Account) -> dict: + directory = profile_home(account) + cli = PROVIDER_CLIS.get(account.provider) + identity_valid = ( + ("@" in account.email or account.auth_mode == "api-key") + and not account.email.startswith("REPLACE_") + ) + cli_available = bool(cli and shutil.which(cli)) + auth_supported = account.provider in {"claude", "chatgpt", "grok"} + result = { + **account.__dict__, + "profileDirectory": str(directory) if directory else "", + "installed": bool(directory and directory.exists()) if directory else cli_available, + "authenticated": False, + "identityValid": identity_valid, + "cliAvailable": cli_available, + "detail": "Authentication adapter not available", + "canAuth": auth_supported and account.status != "planned" and identity_valid and cli_available, + } + if account.status == "planned": + result["detail"] = "Planned account" + return result + if not identity_valid: + result["detail"] = "Login email is still a manifest placeholder" + return result + if account.provider == "claude" and directory: + command = ["claude", "auth", "status", "--text"] + env_key = "CLAUDE_CONFIG_DIR" + elif account.provider == "chatgpt" and directory: + command = ["codex", "login", "status"] + env_key = "CODEX_HOME" + elif account.provider == "grok" and directory: + command = ["grok", "models"] + env_key = "GROK_HOME" + elif account.provider == "gemini" and directory: + credential = directory / ".gemini/oauth_creds.json" + result["authenticated"] = credential.is_file() and credential.stat().st_size > 0 + result["detail"] = ( + "Credential cache present; launch pgemini and run /about to verify" + if result["authenticated"] + else "Launch pgemini login PROFILE to sign in" + ) + return result + elif account.provider == "glm" and directory: + result["detail"] = "Use pglm login PROFILE and select Z.AI Coding Plan" + return result + elif account.provider == "antigravity": + result["detail"] = "Session is managed by the operating-system keyring; launch agy to verify or switch accounts" + return result + elif account.provider == "abacus": + if account.secret_env and os.environ.get(account.secret_env): + result["detail"] = f"{account.secret_env} is present but has not been sent to Abacus for validation" + else: + result["detail"] = "Launch abacusai and use /login [email], or provide a per-account ABACUS_API_KEY" + return result + else: + return result + env = os.environ.copy() + env[env_key] = str(directory) + try: + check = subprocess.run(command, env=env, text=True, capture_output=True, timeout=15) + output = (check.stdout or check.stderr).strip() + result["authenticated"] = check.returncode == 0 + result["detail"] = output[-500:] or ("Authenticated" if check.returncode == 0 else "Not authenticated") + except FileNotFoundError: + result["detail"] = f"{command[0]} CLI is not installed" + except subprocess.TimeoutExpired: + result["detail"] = "Credential check timed out" + return result + + +HTML = r''' +AI Harness Account Manager
+

AI Harness Account Manager

Source of truth:
+
Accounts
Authenticated
Need login
Providers
+
Configured accountsCredentials remain in provider-owned profiles or keyrings.
HarnessProfileEmailPlan / workspaceLocalCredentialDetail
+''' + + +class App: + def __init__(self, repo: Path): + self.repo = repo.resolve() + self.accounts = load_accounts(self.repo) + self.repo_url = git_repo_url(self.repo) + self.processes: dict[str, subprocess.Popen] = {} + + def summary(self) -> dict: + return {"repositoryPath": str(self.repo), "repositoryUrl": self.repo_url, "accounts": [verify(a) for a in self.accounts]} + + def authenticate(self, provider: str, name: str) -> str: + account = next((a for a in self.accounts if a.provider == provider and a.name == name), None) + if not account: + raise ValueError("Unknown account") + spec = auth_command(account) + if not spec: + raise ValueError(f"Interactive authentication is not supported for {provider}") + command, env = spec + if not shutil.which(command[0]): + raise ValueError(f"{command[0]} CLI is not installed") + key = f"{provider}-{name}" + log = STATE_DIR / f"{key}.log" + stream = log.open("w") + log.chmod(0o600) + self.processes[key] = subprocess.Popen(command, env=env, stdin=subprocess.DEVNULL, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True) + return f"Authentication started for {name}. Complete the provider browser flow, then refresh. Log: {log}" + + +def handler_for(app: App): + class Handler(BaseHTTPRequestHandler): + def reply(self, status: int, body, content_type="application/json"): + payload = body.encode() if isinstance(body, str) else json.dumps(body).encode() + self.send_response(status); self.send_header("content-type", content_type); self.send_header("content-length", str(len(payload))); self.end_headers(); self.wfile.write(payload) + def do_GET(self): + path = urlparse(self.path).path + if path == "/": self.reply(200, HTML, "text/html; charset=utf-8") + elif path == "/api/summary": self.reply(200, app.summary()) + else: self.reply(404, {"error": "Not found"}) + def do_POST(self): + if urlparse(self.path).path != "/api/auth": return self.reply(404, {"error": "Not found"}) + try: + length = int(self.headers.get("content-length", "0")); data = json.loads(self.rfile.read(length)); message = app.authenticate(data["provider"], data["name"]); self.reply(202, {"message": message}) + except (ValueError, KeyError, json.JSONDecodeError) as exc: self.reply(400, {"error": str(exc)}) + def log_message(self, fmt, *args): pass + return Handler + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--source-repo", + type=Path, + default=Path(os.environ.get("AI_HARNESS_ACCOUNT_REPO", Path.home() / ".config/workbenches")), + help="Local clone containing config/ai-harness-accounts.json or a legacy split manifest", + ) + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--no-browser", action="store_true") + args = parser.parse_args() + app = App(args.source_repo) + if not app.accounts: raise SystemExit("No supported account manifests found") + server = ThreadingHTTPServer(("127.0.0.1", args.port), handler_for(app)) + url = f"http://127.0.0.1:{args.port}" + print(f"AI Harness Account Manager: {url}") + if not args.no_browser: threading.Timer(0.4, lambda: webbrowser.open(url)).start() + server.serve_forever() + + +if __name__ == "__main__": main() diff --git a/apps/credential-manager/test_generic_manager.py b/apps/credential-manager/test_generic_manager.py new file mode 100644 index 0000000..a759d80 --- /dev/null +++ b/apps/credential-manager/test_generic_manager.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Regression checks for the public multi-harness account manager.""" + +from __future__ import annotations + +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +APP_DIR = Path(__file__).resolve().parent +REPO = APP_DIR.parents[1] +MODULE_PATH = APP_DIR / "credential_manager.py" +SPEC = importlib.util.spec_from_file_location("credential_manager_under_test", MODULE_PATH) +assert SPEC and SPEC.loader +MANAGER = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MANAGER +SPEC.loader.exec_module(MANAGER) + + +class GenericManagerTest(unittest.TestCase): + def test_example_loads_all_supported_harnesses(self): + example = REPO / "config/ai-harness-accounts.example.json" + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / "config" + config.mkdir() + (config / "ai-harness-accounts.json").write_text(example.read_text()) + accounts = MANAGER.load_accounts(Path(directory)) + + self.assertEqual( + sorted(account.provider for account in accounts), + ["abacus", "antigravity", "chatgpt", "claude", "grok"], + ) + + def test_legacy_provider_names_are_normalized(self): + self.assertEqual(MANAGER.canonical_provider("openai"), "chatgpt") + self.assertEqual(MANAGER.canonical_provider("codex"), "chatgpt") + self.assertEqual(MANAGER.canonical_provider("gemini"), "gemini") + self.assertEqual(MANAGER.canonical_provider("zai"), "glm") + + def test_direct_workstation_config_directory_is_supported(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) + (config / "claude-profiles.json").write_text( + json.dumps({ + "version": 1, + "profiles": [{ + "name": "work-example", + "family": "work-example", + "email": "user@example.org", + }], + }) + ) + accounts = MANAGER.load_accounts(config) + + self.assertEqual( + [(account.provider, account.name) for account in accounts], + [("claude", "work-example")], + ) + + def test_example_contains_no_secret_values(self): + data = json.loads( + (REPO / "config/ai-harness-accounts.example.json").read_text() + ) + forbidden_fields = { + "apikey", + "api_key", + "accesstoken", + "access_token", + "refreshtoken", + "refresh_token", + "sessionkey", + } + for account in data["accounts"]: + self.assertTrue(forbidden_fields.isdisjoint(key.lower() for key in account)) + + def test_claude_example_provides_work_and_personal_profiles(self): + data = json.loads( + (REPO / "config/claude-profiles.example.json").read_text() + ) + profiles = {(profile["name"], profile["family"]) for profile in data["profiles"]} + self.assertIn(("work", "work"), profiles) + self.assertIn(("personal", "personal"), profiles) + + def test_codex_example_provides_work_and_personal_profiles(self): + data = json.loads( + (REPO / "config/openai-profiles.example.json").read_text() + ) + profiles = {(profile["name"], profile["family"]) for profile in data["profiles"]} + self.assertIn(("work-chatgpt-1", "work"), profiles) + self.assertIn(("personal-chatgpt-1", "personal"), profiles) + + def test_codex_profile_setup_and_alias_isolate_codex_home(self): + with tempfile.TemporaryDirectory(dir="/tmp") as directory: + root = Path(directory) + home = root / "home" + codex_home = home / ".codex" + config = root / "config" + capture = root / "capture" + codex_home.mkdir(parents=True) + config.mkdir() + capture.mkdir() + (codex_home / "config.toml").write_text( + 'model = "gpt-5.5"\n\n[tui]\nstatus_line = ["model-name"]\n' + ) + manifest = config / "openai-profiles.json" + manifest.write_text(json.dumps({ + "version": 1, + "profiles": [{ + "name": "work-chatgpt-1", + "family": "work", + "email": "user@company.example", + "aliases": ["work1"], + }], + })) + fake_codex = root / "codex" + fake_codex.write_text( + '#!/bin/sh\n' + 'printf "%s\\n" "$CODEX_HOME" > "$CAPTURE_DIR/home"\n' + 'printf "%s\\n" "$@" > "$CAPTURE_DIR/args"\n' + ) + fake_codex.chmod(0o755) + env = { + **os.environ, + "HOME": str(home), + "XDG_CONFIG_HOME": str(root / "xdg"), + "CODEX_BIN": str(fake_codex), + "CODEX_PROFILES_MANIFEST": str(manifest), + "CODEX_PROFILES_HOME": str(home / ".chatgpt-profiles"), + "CAPTURE_DIR": str(capture), + } + subprocess.run( + [str(REPO / "scripts/setup-codex-profiles.sh")], + env=env, + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + [str(REPO / "scripts/codex-profile"), "status", "work1"], + env=env, + check=True, + capture_output=True, + text=True, + ) + + profile = home / ".chatgpt-profiles/profiles/work-chatgpt-1" + profile_config = (profile / "config.toml").read_text() + self.assertEqual((capture / "home").read_text().strip(), str(profile)) + self.assertIn('forced_login_method = "chatgpt"', profile_config) + self.assertIn('cli_auth_credentials_store = "file"', profile_config) + self.assertEqual( + (capture / "args").read_text().splitlines()[-2:], + ["login", "status"], + ) + self.assertEqual(profile.stat().st_mode & 0o777, 0o700) + self.assertEqual((profile / ".profile.json").stat().st_mode & 0o777, 0o600) + + def test_public_account_surface_has_no_private_identifiers(self): + targets = [ + REPO / "apps/credential-manager", + REPO / "config/ai-harness-accounts.example.json", + REPO / "config/claude-profiles.example.json", + REPO / "config/openai-profiles.example.json", + REPO / "docs/ai-harness-account-management.md", + REPO / "docs/ai-credentials-management.md", + REPO / "docs/claude-multi-account-profiles.md", + REPO / "docs/codex-multi-account-profiles.md", + REPO / "scripts/claude-profile", + REPO / "scripts/codex-profile", + REPO / "scripts/setup-claude-profiles.sh", + REPO / "scripts/setup-codex-profiles.sh", + REPO / "sysBenches/cloudBench/devcontainer.example", + ] + forbidden = ( + "br" + "ett", + "open" + "soft", + "far" + "heap", + "med" + "x", + ) + violations: list[str] = [] + for target in targets: + files = [target] if target.is_file() else target.rglob("*") + for path in files: + if not path.is_file() or "__pycache__" in path.parts: + continue + text = path.read_text(errors="ignore").lower() + for identifier in forbidden: + if identifier in text: + violations.append(f"{path.relative_to(REPO)}: {identifier}") + self.assertEqual(violations, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/credential-manager/test_profile_composer.py b/apps/credential-manager/test_profile_composer.py new file mode 100644 index 0000000..2e96252 --- /dev/null +++ b/apps/credential-manager/test_profile_composer.py @@ -0,0 +1,157 @@ +import importlib.util +import json +import pathlib +import tempfile +import unittest + + +SCRIPT = pathlib.Path(__file__).parents[2] / "scripts" / "compose-ai-profiles.py" +SPEC = importlib.util.spec_from_file_location("compose_ai_profiles", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + + +class ProfileComposerTest(unittest.TestCase): + def write_source(self, root, owner_type, owner_id, profiles): + source = root / owner_id / "ai" + source.mkdir(parents=True) + (source / "source.json").write_text( + json.dumps( + { + "version": 1, + "kind": "workbenches-ai-profile-source", + "owner": {"type": owner_type, "id": owner_id}, + "profiles": profiles, + } + ), + encoding="utf-8", + ) + return source + + def test_tenant_grant_and_personal_source_are_composed(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + tenant = self.write_source( + root, + "tenant", + "example-tenant", + { + "claude": [ + {"name": "team-001", "email": "team-001@example.com", "family": "company"}, + {"name": "hidden-001", "email": "hidden@example.com", "family": "company"}, + ], + "openai": [], + }, + ) + grant = tenant / "grants" / "users" + grant.mkdir(parents=True) + (grant / "engineer.json").write_text( + json.dumps( + { + "version": 1, + "user": "engineer", + "profiles": {"claude": ["team-*"], "openai": []}, + } + ), + encoding="utf-8", + ) + personal = self.write_source( + root, + "user", + "engineer", + { + "claude": [ + {"name": "personal-1", "email": "engineer@example.com", "family": "personal"} + ], + "openai": [], + }, + ) + + result = MODULE.compose([str(tenant), str(personal)], "engineer") + + self.assertEqual([profile["name"] for profile in result["claude"]], ["team-001", "personal-1"]) + + def test_duplicate_alias_is_rejected(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + personal = self.write_source( + root, + "user", + "engineer", + { + "claude": [ + {"name": "one", "email": "one@example.com", "family": "personal", "aliases": ["shared"]}, + {"name": "two", "email": "two@example.com", "family": "personal", "aliases": ["shared"]}, + ], + "openai": [], + }, + ) + + with self.assertRaises(MODULE.ProfileError): + MODULE.compose([str(personal)], "engineer") + + def test_five_provider_parity_is_enforced(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + profile = { + "name": "team-001", + "email": "team-001@example.com", + "family": "company", + "aliases": ["team001"], + } + profiles = {provider: [dict(profile)] for provider in MODULE.PROVIDERS} + source = self.write_source(root, "user", "engineer", profiles) + source_json = source / "source.json" + payload = json.loads(source_json.read_text()) + payload["providerParity"] = [ + {"providers": list(MODULE.PROVIDERS), "profiles": ["team-*"]} + ] + source_json.write_text(json.dumps(payload), encoding="utf-8") + + result = MODULE.compose([str(source)], "engineer") + self.assertTrue(all(len(result[provider]) == 1 for provider in MODULE.PROVIDERS)) + + payload["profiles"]["glm"][0]["email"] = "wrong@example.com" + source_json.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaises(MODULE.ProfileError): + MODULE.compose([str(source)], "engineer") + + def test_versioned_credential_contract_requires_account_and_secret_reference(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + personal = self.write_source( + root, + "user", + "engineer", + { + "claude": [ + { + "name": "personal-1", + "email": "engineer@example.com", + "family": "personal", + "accountId": "engineer.claude.personal-1.account", + "credentialId": "engineer.claude.personal-1.credential", + "authentication": { + "type": "subscription_oauth", + "credentialRef": "ai/secrets/claude/personal-1.credentials.sops.yaml", + "escrowStatus": "not-escrowed", + }, + } + ], + "openai": [], + }, + ) + payload = json.loads((personal / "source.json").read_text()) + payload["credentialContractVersion"] = 1 + (personal / "source.json").write_text(json.dumps(payload)) + self.assertEqual(MODULE.compose([str(personal)], "engineer")["claude"][0]["name"], "personal-1") + + del payload["profiles"]["claude"][0]["authentication"] + (personal / "source.json").write_text(json.dumps(payload)) + with self.assertRaises(MODULE.ProfileError): + MODULE.compose([str(personal)], "engineer") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/credential-manager/test_profile_onboarding.py b/apps/credential-manager/test_profile_onboarding.py new file mode 100644 index 0000000..9802db4 --- /dev/null +++ b/apps/credential-manager/test_profile_onboarding.py @@ -0,0 +1,151 @@ +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +REPO = pathlib.Path(__file__).parents[2] +SCRIPT = REPO / "scripts/onboard-ai-profiles.py" + + +class ProfileOnboardingTest(unittest.TestCase): + def run_onboarding(self, home: pathlib.Path, answers: dict): + answer_file = home / "answers.json" + answer_file.write_text(json.dumps(answers)) + output = home / ".config/workbenches" + result = subprocess.run( + [str(SCRIPT), "--answers", str(answer_file), "--output-dir", str(output)], + env={**os.environ, "HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config")}, + text=True, + capture_output=True, + ) + return result, output + + def test_manual_company_and_personal_profiles_cover_selected_providers(self): + with tempfile.TemporaryDirectory(dir="/tmp") as temporary: + home = pathlib.Path(temporary) + answers = { + "consent": True, + "githubUser": "engineer", + "companies": [ + { + "name": "Example Company", + "email": "engineer@example.com", + "githubOrg": "example-company", + "providers": ["all"], + "registry": "manual", + } + ], + "personal": { + "githubOrg": "engineer", + "registry": "manual", + "accounts": [ + { + "email": "person@example.net", + "providers": ["claude", "gpt"], + } + ], + }, + } + + result, output = self.run_onboarding(home, answers) + + self.assertEqual(result.returncode, 0, result.stderr) + expected = {"claude": 2, "openai": 2, "gemini": 1, "grok": 1, "glm": 1} + for provider, count in expected.items(): + data = json.loads((output / f"{provider}-profiles.json").read_text()) + self.assertEqual(len(data["profiles"]), count) + state = output / "ai-profile-onboarding.json" + self.assertEqual(state.stat().st_mode & 0o777, 0o600) + self.assertIn("Existing standard provider credential homes were preserved", result.stdout) + + def test_registry_sources_are_composed_with_user_grants(self): + with tempfile.TemporaryDirectory(dir="/tmp") as temporary: + home = pathlib.Path(temporary) + tenant = home / "company-registry/ai" + personal = home / "personal-registry/ai" + (tenant / "grants/users").mkdir(parents=True) + personal.mkdir(parents=True) + (tenant / "source.json").write_text( + json.dumps( + { + "version": 1, + "kind": "workbenches-ai-profile-source", + "owner": {"type": "tenant", "id": "example-company"}, + "profiles": { + "claude": [ + { + "name": "team-001", + "email": "team-001@example.com", + "family": "company", + } + ] + }, + } + ) + ) + (tenant / "grants/users/engineer.json").write_text( + json.dumps( + { + "version": 1, + "user": "engineer", + "profiles": {"claude": ["team-*"]}, + } + ) + ) + (personal / "source.json").write_text( + json.dumps( + { + "version": 1, + "kind": "workbenches-ai-profile-source", + "owner": {"type": "user", "id": "engineer"}, + "profiles": { + "openai": [ + { + "name": "personal-chatgpt", + "email": "person@example.net", + "family": "personal", + } + ] + }, + } + ) + ) + answers = { + "consent": True, + "githubUser": "engineer", + "companies": [ + { + "name": "Example Company", + "email": "engineer@example.com", + "githubOrg": "example-company", + "registry": str(tenant.parent), + } + ], + "personal": { + "githubOrg": "engineer", + "registry": str(personal.parent), + "accounts": [{"email": "person@example.net", "providers": ["openai"]}], + }, + } + + result, output = self.run_onboarding(home, answers) + + self.assertEqual(result.returncode, 0, result.stderr) + claude = json.loads((output / "claude-profiles.json").read_text()) + openai = json.loads((output / "openai-profiles.json").read_text()) + self.assertEqual([item["name"] for item in claude["profiles"]], ["team-001"]) + self.assertEqual([item["name"] for item in openai["profiles"]], ["personal-chatgpt"]) + + def test_declined_consent_writes_nothing(self): + with tempfile.TemporaryDirectory(dir="/tmp") as temporary: + home = pathlib.Path(temporary) + result, output = self.run_onboarding(home, {"consent": False}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/credential-manager/test_provider_account_normalizer.py b/apps/credential-manager/test_provider_account_normalizer.py new file mode 100644 index 0000000..7975aa2 --- /dev/null +++ b/apps/credential-manager/test_provider_account_normalizer.py @@ -0,0 +1,60 @@ +import importlib.util +import json +import pathlib +import sys +import tempfile +import unittest + + +SCRIPT = pathlib.Path(__file__).parents[2] / "scripts" / "normalize-ai-provider-accounts.py" +SPEC = importlib.util.spec_from_file_location("normalize_ai_provider_accounts", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class ProviderAccountNormalizerTest(unittest.TestCase): + def test_normalizes_account_and_truthful_escrow_state(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + ai = root / "ai" + ai.mkdir() + source = ai / "source.json" + source.write_text( + json.dumps( + { + "version": 1, + "kind": "workbenches-ai-profile-source", + "owner": {"type": "tenant", "id": "example"}, + "profiles": { + "claude": [ + { + "name": "team-001", + "email": "team-001@example.com", + "family": "team", + } + ] + }, + } + ) + ) + + normalized = MODULE.normalize(source) + profile = normalized["profiles"]["claude"][0] + self.assertEqual(normalized["credentialContractVersion"], 1) + self.assertEqual(profile["accountId"], "example.claude.team-001.account") + self.assertEqual(profile["authentication"]["type"], "subscription_oauth") + self.assertEqual(profile["authentication"]["escrowStatus"], "not-escrowed") + + secret = root / profile["authentication"]["credentialRef"] + secret.parent.mkdir(parents=True) + secret.write_text("ciphertext") + self.assertEqual( + MODULE.normalize(source)["profiles"]["claude"][0]["authentication"]["escrowStatus"], + "available", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/credential-manager/test_provider_profiles.py b/apps/credential-manager/test_provider_profiles.py new file mode 100644 index 0000000..6ddfd71 --- /dev/null +++ b/apps/credential-manager/test_provider_profiles.py @@ -0,0 +1,87 @@ +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +REPO = pathlib.Path(__file__).parents[2] + + +class ProviderProfilesTest(unittest.TestCase): + def test_provider_launchers_isolate_state_and_resolve_aliases(self): + cases = { + "gemini": ("pgemini", "GEMINI_BIN", "GEMINI_CLI_HOME"), + "grok": ("pgrok", "GROK_BIN", "GROK_HOME"), + "glm": ("pglm", "OPENCODE_BIN", "XDG_DATA_HOME"), + } + with tempfile.TemporaryDirectory(dir="/tmp") as temporary: + root = pathlib.Path(temporary) + home = root / "home" + config = home / ".config/workbenches" + config.mkdir(parents=True) + fake = root / "fake-cli" + fake.write_text( + "#!/bin/sh\n" + 'printf "%s\\n" "${GEMINI_CLI_HOME:-${GROK_HOME:-${XDG_DATA_HOME:-}}}" > "$CAPTURE"\n' + ) + fake.chmod(0o755) + + for provider, (launcher, binary_env, expected_env) in cases.items(): + manifest = config / f"{provider}-profiles.json" + manifest.write_text( + json.dumps( + { + "version": 1, + "profiles": [ + { + "name": "team-001", + "email": "team-001@example.com", + "family": "company", + "aliases": ["team001"], + } + ], + } + ) + ) + env = { + **os.environ, + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + binary_env: str(fake), + "CAPTURE": str(root / f"{provider}.capture"), + } + subprocess.run( + [ + str(REPO / "scripts/setup-provider-profiles.sh"), + "--provider", + provider, + "--manifest", + str(manifest), + ], + env=env, + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + [str(home / ".local/bin" / launcher), "team001"], + env=env, + check=True, + capture_output=True, + text=True, + ) + profile = home / f".{provider}-profiles/profiles/team-001" + expected = profile if expected_env != "XDG_DATA_HOME" else profile / "xdg/data" + self.assertEqual((root / f"{provider}.capture").read_text().strip(), str(expected)) + self.assertEqual(profile.stat().st_mode & 0o777, 0o700, provider) + self.assertEqual( + (profile / ".profile.json").stat().st_mode & 0o777, + 0o600, + provider, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/base-image/Dockerfile b/base-image/Dockerfile index 0a4b607..344df0c 100644 --- a/base-image/Dockerfile +++ b/base-image/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1.7 # Layer 0: System Base Image # Ubuntu 24.04 with system tools and AI coding CLIs # Used by ALL benches (Frappe, Flutter, .NET, Bio, Admin, etc.) @@ -9,7 +10,7 @@ FROM ubuntu:24.04 # Container version labels LABEL layer="0" LABEL layer.name="workbench-base" -LABEL layer.version="2.0.0" +LABEL layer.version="2.0.3" LABEL layer.description="System base with AI CLIs for all workBenches (user-agnostic)" # Everything runs as root — no user in this layer @@ -65,23 +66,28 @@ RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd # GITHUB CLI # ======================================== -RUN for attempt in 1 2 3; do \ - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /tmp/githubcli-archive-keyring.gpg && break; \ - if [ "$attempt" = "3" ]; then exit 1; fi; \ - sleep $((attempt * 5)); \ - done \ - && gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg /tmp/githubcli-archive-keyring.gpg \ - && rm -f /tmp/githubcli-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages focal main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ - && for attempt in 1 2 3; do \ - apt-get update \ - && apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=60 -o Acquire::https::Timeout=60 -y install gh \ - && break; \ - if [ "$attempt" = "3" ]; then exit 1; fi; \ - sleep $((attempt * 10)); \ - done \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +ARG GH_VERSION=2.96.0 +RUN set -eux; \ + gh_version="${GH_VERSION}"; \ + if [ "$gh_version" = "latest" ]; then \ + gh_version="$(curl --http1.1 -fsSL --retry 3 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 60 https://api.github.com/repos/cli/cli/releases/latest | jq -r '.tag_name // empty' | sed 's/^v//')"; \ + fi; \ + if [ -z "$gh_version" ] || [ "$gh_version" = "null" ]; then \ + gh_version="2.96.0"; \ + fi; \ + case "$(dpkg --print-architecture)" in \ + amd64) gh_arch="amd64" ;; \ + arm64) gh_arch="arm64" ;; \ + armhf) gh_arch="armv6" ;; \ + 386) gh_arch="386" ;; \ + *) echo "Unsupported architecture for GitHub CLI: $(dpkg --print-architecture)" >&2; exit 1 ;; \ + esac; \ + gh_url="https://github.com/cli/cli/releases/download/v${gh_version}/gh_${gh_version}_linux_${gh_arch}.tar.gz"; \ + timeout 900s wget -q --https-only --timeout=30 --tries=3 --waitretry=2 -O /tmp/gh.tar.gz "$gh_url" \ + || curl --http1.1 -fL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 90 -o /tmp/gh.tar.gz "$gh_url"; \ + tar -xzf /tmp/gh.tar.gz -C /tmp; \ + install -m 0755 "/tmp/gh_${gh_version}_linux_${gh_arch}/bin/gh" /usr/local/bin/gh; \ + rm -rf /tmp/gh.tar.gz "/tmp/gh_${gh_version}_linux_${gh_arch}" # ======================================== # DOCKER CLI & COMPOSE @@ -112,18 +118,18 @@ RUN YQ_VERSION=$(curl --retry 5 --retry-all-errors --connect-timeout 30 -fsSL ht # Install zoxide (smarter cd) - install to /usr/local/bin RUN curl --retry 5 --retry-all-errors --connect-timeout 30 -fsSL https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | bash -s -- --bin-dir /usr/local/bin -# Install Node.js 22 for modern AI CLI builds and npm-based tools. +# Install Node.js 24 LTS for modern AI CLI builds and npm-based tools. RUN apt-get update \ && export DEBIAN_FRONTEND=noninteractive \ - && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ && apt-get install -y nodejs \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* RUN corepack enable || true -# Install tldr (simplified man pages) - using npm version -RUN npm install -g tldr \ +# Install npm itself and tldr (simplified man pages) from npm's latest dist-tags. +RUN npm install -g npm@latest tldr \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -134,15 +140,20 @@ RUN npm install -g tldr \ # Install uv to /usr/local/bin (system-wide) RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh -# Install spec-kit system-wide -RUN uv tool install specify-cli --from git+https://github.com/github/spec-kit.git --python-preference system \ - || echo "spec-kit installation skipped (non-fatal)" +# Ensure spec-driven CLIs are not inherited from cached Layer 0 state. +RUN npm uninstall -g @fission-ai/openspec || true \ + && rm -f /usr/bin/openspec /usr/local/bin/openspec \ + && rm -rf /usr/lib/node_modules/@fission-ai/openspec /usr/local/lib/node_modules/@fission-ai/openspec \ + && uv tool uninstall specify-cli || true \ + && env UV_TOOL_BIN_DIR=/usr/local/bin UV_TOOL_DIR=/opt/uv/tools uv tool uninstall specify-cli || true \ + && rm -f /root/.local/bin/specify /usr/local/bin/specify \ + && rm -rf /root/.local/share/uv/tools/specify-cli /opt/uv/tools/specify-cli # ======================================== # BUN RUNTIME (system-wide at /opt/bun) # ======================================== -ARG BUN_VERSION=1.3.5 +ARG BUN_VERSION=1.3.14 ENV BUN_INSTALL=/opt/bun RUN curl -fsSL https://bun.sh/install | BUN_VERSION=${BUN_VERSION} bash \ && chmod -R a+rx /opt/bun @@ -154,30 +165,79 @@ ENV PATH="/opt/bun/bin:${PATH}" # Copy and run AI CLI installation script COPY install-ai-clis.sh /tmp/ -ARG AI_CLI_REFRESH=2026-06-18 +ARG AI_CLI_REFRESH=2026-07-12 RUN echo "AI CLI refresh marker: ${AI_CLI_REFRESH}" -RUN bash /tmp/install-ai-clis.sh && rm /tmp/install-ai-clis.sh +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=cache,target=/root/.npm \ + bash /tmp/install-ai-clis.sh && rm /tmp/install-ai-clis.sh + +# Provider-specific multi-account launchers. Credential profiles are host state +# mounted under provider-specific profile roots; only launchers belong in the +# image. +COPY files/claude-profile /usr/local/bin/claude-profile +COPY files/codex-profile /usr/local/bin/codex-profile +COPY files/provider-profile /usr/local/bin/provider-profile +COPY files/claude-statusline-command.sh /usr/local/share/workbenches/claude/statusline-command.sh +RUN chmod 0755 /usr/local/bin/claude-profile \ + /usr/local/bin/codex-profile \ + /usr/local/bin/provider-profile \ + /usr/local/share/workbenches/claude/statusline-command.sh \ + && ln -sfn claude-profile /usr/local/bin/pclaude \ + && ln -sfn codex-profile /usr/local/bin/pcodex \ + && ln -sfn provider-profile /usr/local/bin/gemini-profile \ + && ln -sfn provider-profile /usr/local/bin/pgemini \ + && ln -sfn provider-profile /usr/local/bin/grok-profile \ + && ln -sfn provider-profile /usr/local/bin/pgrok \ + && ln -sfn provider-profile /usr/local/bin/glm-profile \ + && ln -sfn provider-profile /usr/local/bin/zai-profile \ + && ln -sfn provider-profile /usr/local/bin/pglm \ + && ln -sfn provider-profile /usr/local/bin/pzai \ + && mkdir -p /etc/skel/.local/bin \ + && ln -sfn /usr/local/bin/claude /etc/skel/.local/bin/claude # ======================================== # ZSH CONFIGURATION (into /etc/skel for future users) # ======================================== -# Install Oh-My-Zsh into /etc/skel. Use direct clones because the installer -# wraps git clone and gives us no retry control in flaky networks. -RUN for attempt in 1 2 3; do \ - git clone --depth=1 https://github.com/ohmyzsh/ohmyzsh.git /etc/skel/.oh-my-zsh && break; \ - rm -rf /etc/skel/.oh-my-zsh; \ - if [ "$attempt" = "3" ]; then exit 1; fi; \ - sleep $((attempt * 5)); \ - done - -# Install Powerlevel10k theme -RUN for attempt in 1 2 3; do \ - git clone --depth=1 https://github.com/romkatv/powerlevel10k.git /etc/skel/.oh-my-zsh/custom/themes/powerlevel10k && break; \ - rm -rf /etc/skel/.oh-my-zsh/custom/themes/powerlevel10k; \ - if [ "$attempt" = "3" ]; then exit 1; fi; \ - sleep $((attempt * 5)); \ - done +# Install Oh-My-Zsh into /etc/skel from a bounded tarball download. +RUN mkdir -p /etc/skel/.oh-my-zsh \ + && timeout 1800s curl --http1.1 -fL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 1200 \ + -o /tmp/ohmyzsh.tar.gz https://github.com/ohmyzsh/ohmyzsh/archive/refs/heads/master.tar.gz \ + && tar -xzf /tmp/ohmyzsh.tar.gz -C /etc/skel/.oh-my-zsh --strip-components=1 \ + && rm -f /tmp/ohmyzsh.tar.gz /root/.zshrc + +# Install Powerlevel10k theme from a bounded tarball download. +RUN mkdir -p /etc/skel/.oh-my-zsh/custom/themes/powerlevel10k \ + && timeout 1800s curl --http1.1 -fL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 1200 \ + -o /tmp/powerlevel10k.tar.gz https://github.com/romkatv/powerlevel10k/archive/refs/heads/master.tar.gz \ + && tar -xzf /tmp/powerlevel10k.tar.gz -C /etc/skel/.oh-my-zsh/custom/themes/powerlevel10k --strip-components=1 \ + && rm -f /tmp/powerlevel10k.tar.gz + +# Overlay latest upstream binaries for CLI tools where Ubuntu apt lags. +RUN set -eux; \ + case "$(dpkg --print-architecture)" in \ + amd64) nvim_arch="x86_64"; fzf_arch="amd64"; jq_arch="amd64" ;; \ + arm64) nvim_arch="arm64"; fzf_arch="arm64"; jq_arch="arm64" ;; \ + *) echo "Unsupported architecture: $(dpkg --print-architecture)" >&2; exit 1 ;; \ + esac; \ + nvim_tag="$(curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --max-time 120 https://api.github.com/repos/neovim/neovim/releases/latest | jq -r '.tag_name')"; \ + timeout 3600s curl --http1.1 -fL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 3600 \ + -o /tmp/nvim.tar.gz "https://github.com/neovim/neovim/releases/download/${nvim_tag}/nvim-linux-${nvim_arch}.tar.gz"; \ + rm -rf /opt/nvim; \ + tar -xzf /tmp/nvim.tar.gz -C /opt; \ + mv "/opt/nvim-linux-${nvim_arch}" /opt/nvim; \ + ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim; \ + fzf_tag="$(curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --max-time 120 https://api.github.com/repos/junegunn/fzf/releases/latest | jq -r '.tag_name')"; \ + fzf_version="${fzf_tag#v}"; \ + timeout 3600s curl --http1.1 -fL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 3600 \ + -o /tmp/fzf.tar.gz "https://github.com/junegunn/fzf/releases/download/${fzf_tag}/fzf-${fzf_version}-linux_${fzf_arch}.tar.gz"; \ + tar -xzf /tmp/fzf.tar.gz -C /tmp; \ + install -m 0755 /tmp/fzf /usr/local/bin/fzf; \ + jq_tag="$(curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --max-time 120 https://api.github.com/repos/jqlang/jq/releases/latest | jq -r '.tag_name')"; \ + timeout 3600s curl --http1.1 -fL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 3600 \ + -o /usr/local/bin/jq "https://github.com/jqlang/jq/releases/download/${jq_tag}/jq-linux-${jq_arch}"; \ + chmod 0755 /usr/local/bin/jq; \ + rm -f /tmp/nvim.tar.gz /tmp/fzf.tar.gz /tmp/fzf # Create /etc/skel/.zshrc template RUN echo 'export ZSH="$HOME/.oh-my-zsh"' > /etc/skel/.zshrc && \ @@ -201,14 +261,139 @@ RUN echo 'eval "$(zoxide init bash)"' >> /etc/skel/.bashrc && \ echo 'export PATH="/opt/grok/bin:$HOME/.local/bin:/opt/bun/bin:$PATH"' >> /etc/skel/.bashrc && \ echo '[[ -r /opt/grok/completions/bash/grok.bash ]] && source /opt/grok/completions/bash/grok.bash' >> /etc/skel/.bashrc -# Provide AI helper aliases system-wide so all benches inherit them even when +# Provide AI helpers system-wide so all benches inherit them even when # a workspace does not mount a host shell profile. -RUN echo '' >> /etc/zsh/zshrc && \ - echo '# WorkBench AI helpers' >> /etc/zsh/zshrc && \ - echo 'alias yolo="claude --dangerously-skip-permissions --teammate-mode tmux"' >> /etc/zsh/zshrc && \ - echo '' >> /etc/bash.bashrc && \ - echo '# WorkBench AI helpers' >> /etc/bash.bashrc && \ - echo 'alias yolo="claude --dangerously-skip-permissions --teammate-mode tmux"' >> /etc/bash.bashrc +RUN cat <<'EOF' >> /etc/zsh/zshrc + +# WorkBench AI helpers +_yolo_shell_quote() { + local quoted="" arg + + for arg in "$@"; do + quoted="${quoted} $(printf '%q' "$arg")" + done + + printf '%s\n' "${quoted# }" +} + +unalias yolo 2>/dev/null || true +yolo() { + local session_name command_string prompt_file + local -a prompt_args + + if ! command -v claude >/dev/null 2>&1; then + echo "yolo: Claude CLI not found on PATH" >&2 + return 1 + fi + + if ! command -v tmux >/dev/null 2>&1; then + echo "yolo: tmux not found on PATH" >&2 + return 1 + fi + + prompt_file="" + for candidate_prompt_file in \ + "$HOME/.claude/prompts/speckit-dashboard-full.md" \ + "/usr/local/share/ct/claude/prompts/speckit-dashboard-full.md" \ + "$HOME/.claude/prompts/speckit-dashboard-bootstrap.md"; do + if [ -r "$candidate_prompt_file" ]; then + prompt_file="$candidate_prompt_file" + break + fi + done + prompt_args=() + if [ -n "$prompt_file" ]; then + prompt_args=(--append-system-prompt-file "$prompt_file") + fi + + if [ -n "${TMUX:-}" ]; then + claude --dangerously-skip-permissions --teammate-mode tmux "${prompt_args[@]}" "$@" + return $? + fi + + session_name="yolo-$(date +%Y%m%d%H%M%S)-$$" + command_string=$(_yolo_shell_quote \ + claude \ + --dangerously-skip-permissions \ + --teammate-mode tmux \ + "${prompt_args[@]}" \ + "$@") || return 1 + + tmux new-session -d -s "$session_name" -c "$PWD" "exec $command_string" || { + echo "yolo: failed to start tmux session" >&2 + return 1 + } + + tmux set-option -t "$session_name" mouse on >/dev/null 2>&1 || true + tmux attach-session -t "$session_name" +} +EOF + +RUN cat <<'EOF' >> /etc/bash.bashrc + +# WorkBench AI helpers +_yolo_shell_quote() { + local quoted="" arg + + for arg in "$@"; do + quoted="${quoted} $(printf '%q' "$arg")" + done + + printf '%s\n' "${quoted# }" +} + +unalias yolo 2>/dev/null || true +yolo() { + local session_name command_string prompt_file + local -a prompt_args + + if ! command -v claude >/dev/null 2>&1; then + echo "yolo: Claude CLI not found on PATH" >&2 + return 1 + fi + + if ! command -v tmux >/dev/null 2>&1; then + echo "yolo: tmux not found on PATH" >&2 + return 1 + fi + + prompt_file="" + for candidate_prompt_file in \ + "$HOME/.claude/prompts/speckit-dashboard-full.md" \ + "/usr/local/share/ct/claude/prompts/speckit-dashboard-full.md" \ + "$HOME/.claude/prompts/speckit-dashboard-bootstrap.md"; do + if [ -r "$candidate_prompt_file" ]; then + prompt_file="$candidate_prompt_file" + break + fi + done + prompt_args=() + if [ -n "$prompt_file" ]; then + prompt_args=(--append-system-prompt-file "$prompt_file") + fi + + if [ -n "${TMUX:-}" ]; then + claude --dangerously-skip-permissions --teammate-mode tmux "${prompt_args[@]}" "$@" + return $? + fi + + session_name="yolo-$(date +%Y%m%d%H%M%S)-$$" + command_string=$(_yolo_shell_quote \ + claude \ + --dangerously-skip-permissions \ + --teammate-mode tmux \ + "${prompt_args[@]}" \ + "$@") || return 1 + + tmux new-session -d -s "$session_name" -c "$PWD" "exec $command_string" || { + echo "yolo: failed to start tmux session" >&2 + return 1 + } + + tmux set-option -t "$session_name" mouse on >/dev/null 2>&1 || true + tmux attach-session -t "$session_name" +} +EOF # ======================================== # OPENCODE CONFIGURATION (into /etc/skel) @@ -221,22 +406,11 @@ COPY files/opencode/oh-my-opencode.json /etc/skel/.config/opencode/ COPY files/opencode/agent/ /etc/skel/.config/opencode/agent/ COPY files/opencode/context/ /etc/skel/.config/opencode/context/ -# ======================================== -# OPSX COMMANDS & SKILLS (Claude Code, into /etc/skel) -# ======================================== -# Upgraded OpenSpec workflows with agent team orchestration. -# Uses opsx-* / opsx: prefix — openspec init/update won't overwrite these. - -RUN mkdir -p /etc/skel/.claude/commands/opsx \ - && mkdir -p /etc/skel/.claude/skills/opsx-clarify \ - && mkdir -p /etc/skel/.claude/skills/opsx-analyze -COPY files/claude/commands/opsx/ /etc/skel/.claude/commands/opsx/ -COPY files/claude/skills/opsx-clarify/ /etc/skel/.claude/skills/opsx-clarify/ -COPY files/claude/skills/opsx-analyze/ /etc/skel/.claude/skills/opsx-analyze/ - # ======================================== # SPECKIT SKILLS (Claude Code, into /etc/skel) # ======================================== +# OpenSpec/opsx commands are installed with the devBench OpenSpec layer, where +# the openspec CLI is present. Layer 0 keeps only non-OpenSpec Claude defaults. # Agent-team-enhanced Speckit workflows. Each skill upgrades the corresponding # project-level /speckit.* command with parallel specialist agents. # Uses speckit-* prefix — speckit init/update won't overwrite these. diff --git a/base-image/build.sh b/base-image/build.sh index a5e96da..cb23a74 100755 --- a/base-image/build.sh +++ b/base-image/build.sh @@ -13,20 +13,24 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" # Parse arguments (--user is accepted but ignored for backward compat) +NO_CACHE="${NO_CACHE:-false}" while [[ $# -gt 0 ]]; do case $1 in --user) shift 2 ;; + --no-cache) NO_CACHE=true; shift ;; *) shift ;; esac done echo "Configuration:" echo " Tag: workbench-base:latest (user-agnostic)" +echo " No cache: $NO_CACHE" echo "" # Build the image echo "Building workbench-base:latest..." docker build \ + $([ "$NO_CACHE" = true ] && printf '%s\n' "--no-cache") \ -t "workbench-base:latest" \ . diff --git a/base-image/files/claude-profile b/base-image/files/claude-profile new file mode 100755 index 0000000..889ccba --- /dev/null +++ b/base-image/files/claude-profile @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +manifest="${CLAUDE_PROFILES_MANIFEST:-${XDG_CONFIG_HOME:-$HOME/.config}/workbenches/claude-profiles.json}" +base="${CLAUDE_PROFILES_HOME:-$HOME/.claude-profiles}" +command -v jq >/dev/null 2>&1 || { echo "jq is required." >&2; exit 1; } + +configure_profile_runtime() { + local config_dir="$1" statusline_source statusline_link settings settings_tmp statusline_command default_model + + statusline_source="$base/shared/statusline-command.sh" + if [[ ! -f "$statusline_source" && -f /usr/local/share/workbenches/claude/statusline-command.sh ]]; then + statusline_source=/usr/local/share/workbenches/claude/statusline-command.sh + fi + + if [[ -f "$statusline_source" ]]; then + statusline_link="$config_dir/statusline-command.sh" + if [[ -L "$statusline_link" || ! -e "$statusline_link" ]]; then + ln -sfn "$statusline_source" "$statusline_link" + fi + fi + + settings="$config_dir/settings.json" + if [[ -e "$settings" ]] && ! jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then + echo "Claude profile settings are not valid JSON: $settings" >&2 + return 1 + fi + + statusline_command='bash "${CLAUDE_CONFIG_DIR}/statusline-command.sh"' + default_model='claude-fable-5' + settings_tmp="$(mktemp "$config_dir/.settings.XXXXXX.tmp")" + if [[ -f "$settings" ]]; then + jq --arg command "$statusline_command" --arg model "$default_model" ' + .statusLine = {type: "command", command: $command, refreshInterval: 10} + | .permissions = (.permissions // {}) + | .permissions.defaultMode = "bypassPermissions" + | .effortLevel = (.effortLevel // "xhigh") + | .model = $model + ' "$settings" > "$settings_tmp" + else + jq -n --arg command "$statusline_command" --arg model "$default_model" '{ + statusLine: {type: "command", command: $command, refreshInterval: 10}, + permissions: {defaultMode: "bypassPermissions"}, + effortLevel: "xhigh", + model: $model + }' > "$settings_tmp" + fi + chmod 600 "$settings_tmp" + mv -f "$settings_tmp" "$settings" +} + +claude_bin="${CLAUDE_BIN:-$(command -v claude || true)}" +if [[ -z "$claude_bin" && -x "$HOME/.local/bin/claude" ]]; then + claude_bin="$HOME/.local/bin/claude" +fi + +# Native Claude records ~/.local/bin/claude as its expected installation path. +# Bench images install the immutable CLI in /usr/local/bin, so provide a +# per-user compatibility link to keep Claude's installer health check accurate. +if [[ "$claude_bin" == "/usr/local/bin/claude" ]]; then + if mkdir -p "$HOME/.local/bin" 2>/dev/null; then + if [[ ! -e "$HOME/.local/bin/claude" && ! -L "$HOME/.local/bin/claude" ]]; then + ln -s /usr/local/bin/claude "$HOME/.local/bin/claude" + elif [[ -L "$HOME/.local/bin/claude" && ! -e "$HOME/.local/bin/claude" ]]; then + ln -sfn /usr/local/bin/claude "$HOME/.local/bin/claude" + fi + fi +fi + +profile_metadata() { + local requested="$1" normalized row metadata candidate + normalized="$(printf '%s' "$requested" | tr '[:upper:]' '[:lower:]')" + if [[ -f "$manifest" ]]; then + row="$(jq -r --arg name "$normalized" \ + '.profiles[] + | select( + ((.name | ascii_downcase) == $name) + or any(.aliases[]?; (ascii_downcase == $name)) + ) + | [.name,.email,.family] | @tsv' \ + "$manifest" | head -n 1)" + if [[ -n "$row" ]]; then + printf '%s\n' "$row" + return 0 + fi + fi + + for metadata in "$base"/profiles/*/.profile.json; do + [[ -f "$metadata" ]] || continue + candidate="$(jq -r --arg name "$normalized" ' + if (((.name // "") | ascii_downcase) == $name) + or any(.aliases[]?; (ascii_downcase == $name)) + then "match" else empty end + ' "$metadata")" + if [[ "$candidate" == "match" ]]; then + jq -r '[.name,.email,.family] | @tsv' "$metadata" + return 0 + fi + done + return 1 +} + +action="${1:-list}" +if [[ "$action" == "list" || "$action" == "login" || "$action" == "status" || "$action" == "run" ]]; then + [[ $# -eq 0 ]] || shift +else + action="run" +fi + +case "$action" in + list) + { + if [[ -f "$manifest" ]]; then + jq -r '.profiles[] | [.name, .family, .email, ((.aliases // []) | join(","))] | @tsv' "$manifest" + fi + for metadata in "$base"/profiles/*/.profile.json; do + [[ -f "$metadata" ]] || continue + jq -r '[.name,.family,.email,((.aliases // []) | join(","))] | @tsv' "$metadata" + done + } | awk -F '\t' '!seen[tolower($1)]++' + ;; + login|status|run) + profile="${1:?Usage: claude-profile $action PROFILE [arguments]}"; shift + row="$(profile_metadata "$profile" || true)" + [[ -n "$row" ]] || { echo "Unknown Claude profile: $profile" >&2; exit 2; } + IFS=$'\t' read -r profile email family <<<"$row" + config_dir="$base/profiles/$profile" + [[ -n "$claude_bin" ]] || { echo "Claude CLI not found." >&2; exit 1; } + configure_profile_runtime "$config_dir" + case "$action" in + login) CLAUDE_CONFIG_DIR="$config_dir" "$claude_bin" auth login --claudeai --email "$email" "$@" ;; + status) CLAUDE_CONFIG_DIR="$config_dir" "$claude_bin" auth status "${@---text}" ;; + run) CLAUDE_CONFIG_DIR="$config_dir" exec "$claude_bin" \ + --allow-dangerously-skip-permissions \ + --dangerously-skip-permissions \ + --permission-mode bypassPermissions \ + "$@" ;; + esac + ;; + *) echo "Usage: claude-profile {list|login|status|run} [PROFILE]" >&2; exit 2 ;; +esac diff --git a/base-image/files/claude-statusline-command.sh b/base-image/files/claude-statusline-command.sh new file mode 100644 index 0000000..fcf70d6 --- /dev/null +++ b/base-image/files/claude-statusline-command.sh @@ -0,0 +1,359 @@ +#!/usr/bin/env bash +# Two-line Claude Code status line. Claude supplies session data as JSON on stdin. + +input=$(cat) +if ! jq -e . >/dev/null 2>&1 <<<"$input"; then + exit 0 +fi + +# Parse the input once. Missing fields intentionally become empty strings. +mapfile -t data < <(jq -r '[ + (.workspace.current_dir // .cwd // ""), + (.workspace.repo.name // ""), + (.workspace.git_worktree // .worktree.name // ""), + (.worktree.branch // ""), + (.model.display_name // .model.id // ""), + (.context_window.used_percentage // 0), + (.effort.level // ""), + (.thinking.enabled // ""), + (.rate_limits.five_hour.used_percentage // ""), + (.rate_limits.five_hour.resets_at // ""), + (.rate_limits.seven_day.used_percentage // ""), + (.rate_limits.seven_day.resets_at // ""), + (.session_name // ""), + (.agent.name // ""), + (.pr.number // ""), + (.pr.review_state // ""), + (.permission_mode // .permissions.mode // "") +] | .[]' <<<"$input") + +cwd=${data[0]:-} +repo_name=${data[1]:-} +worktree_name=${data[2]:-} +worktree_branch=${data[3]:-} +model=${data[4]:-} +used_pct=${data[5]:-} +effort=${data[6]:-} +thinking=${data[7]:-} +five_hour_pct=${data[8]:-} +five_hour_reset=${data[9]:-} +seven_day_pct=${data[10]:-} +seven_day_reset=${data[11]:-} +session_name=${data[12]:-} +agent_name=${data[13]:-} +pr_number=${data[14]:-} +pr_state=${data[15]:-} +permission_mode=${data[16]:-} + +columns=${COLUMNS:-120} +[[ $columns =~ ^[0-9]+$ ]] || columns=120 + +reset=$'\033[0m' +dim=$'\033[2m' +cyan=$'\033[36m' +yellow=$'\033[33m' +magenta=$'\033[35m' +blue=$'\033[34m' +green=$'\033[32m' +red=$'\033[31m' +sep="${dim}|${reset}" + +join_parts() { + local out="" part + for part in "$@"; do + [[ -z $part ]] && continue + if [[ -z $out ]]; then + out=$part + else + out+=" $sep $part" + fi + done + printf '%s' "$out" +} + +truncate_middle() { + local value=$1 max=$2 side + if (( ${#value} <= max )); then + printf '%s' "$value" + return + fi + side=$(( (max - 3) / 2 )) + printf '%s...%s' "${value:0:side}" "${value: -side}" +} + +pct_integer() { + local value=$1 rounded + rounded=$(printf '%.0f' "$value" 2>/dev/null) || rounded=0 + (( rounded < 0 )) && rounded=0 + (( rounded > 100 )) && rounded=100 + printf '%s' "$rounded" +} + +pct_color() { + local value=$1 + if (( value >= 80 )); then + printf '%s' "$red" + elif (( value >= 60 )); then + printf '%s' "$yellow" + else + printf '%s' "$green" + fi +} + +context_segment() { + local pct color filled empty bar_width bar empty_bar + pct=$(pct_integer "$used_pct") + color=$(pct_color "$pct") + bar_width=$(( columns >= 100 ? 12 : 8 )) + filled=$(( pct * bar_width / 100 )) + empty=$(( bar_width - filled )) + printf -v bar '%*s' "$filled" '' + bar=${bar// /=} + printf -v empty_bar '%*s' "$empty" '' + empty_bar=${empty_bar// /-} + printf '%s[%s%s] %s%%%s' "$color" "$bar" "$empty_bar" "$pct" "$reset" +} + +rate_segment() { + local label=$1 raw_pct=$2 reset_at=$3 pct color reset_text="" bar_width filled empty bar empty_bar + bar_width=$(( columns >= 100 ? 10 : 8 )) + if [[ -z $raw_pct ]]; then + printf -v empty_bar '%*s' "$bar_width" '' + empty_bar=${empty_bar// /-} + printf '%s%s [%s] --%s' "$dim" "$label" "$empty_bar" "$reset" + return + fi + pct=$(pct_integer "$raw_pct") + color=$(pct_color "$pct") + filled=$(( pct * bar_width / 100 )) + empty=$(( bar_width - filled )) + printf -v bar '%*s' "$filled" '' + bar=${bar// /=} + printf -v empty_bar '%*s' "$empty" '' + empty_bar=${empty_bar// /-} + if (( pct >= 80 )) && [[ $reset_at =~ ^[0-9]+$ ]]; then + reset_text=$(date -d "@$reset_at" '+ %H:%M' 2>/dev/null || true) + fi + printf '%s%s [%s%s] %s%%%s%s' "$color" "$label" "$bar" "$empty_bar" "$pct" "$reset" "$reset_text" +} + +repeat_char() { + local char=$1 count=$2 value + (( count <= 0 )) && return + printf -v value '%*s' "$count" '' + printf '%s' "${value// /$char}" +} + +countdown_segment() { + local label=$1 reset_at=$2 window_seconds=$3 unit=$4 + local now remaining elapsed bar_width marker_pos bar color amount reset_text + + bar_width=$(( columns >= 100 ? 8 : 6 )) + if [[ ! $reset_at =~ ^[0-9]+$ ]]; then + bar=$(repeat_char '-' "$bar_width") + printf '%s%s [%s] waiting%s' "$dim" "$label" "$bar" "$reset" + return + fi + + now=$(date +%s) + remaining=$(( reset_at - now )) + (( remaining < 0 )) && remaining=0 + elapsed=$(( window_seconds - remaining )) + (( elapsed < 0 )) && elapsed=0 + (( elapsed > window_seconds )) && elapsed=$window_seconds + marker_pos=$(( elapsed * (bar_width - 1) / window_seconds )) + bar="$(repeat_char '=' "$marker_pos")>$(repeat_char '-' "$((bar_width - marker_pos - 1))")" + + if (( remaining * 100 <= window_seconds * 15 )); then + color=$red + elif (( remaining * 100 <= window_seconds * 40 )); then + color=$yellow + else + color=$green + fi + + if [[ $unit == minutes ]]; then + amount="$(( (remaining + 59) / 60 ))m" + reset_text=$(TZ="${STATUSLINE_TZ:-America/Los_Angeles}" date -d "@$reset_at" '+%-I:%M%p %Z' 2>/dev/null || true) + else + if (( remaining >= 86400 )); then + amount="$(( remaining / 86400 ))d" + else + amount="$(( (remaining + 3599) / 3600 ))h" + fi + if (( columns >= 100 )); then + reset_text=$(TZ="${STATUSLINE_TZ:-America/Los_Angeles}" date -d "@$reset_at" '+%a %b %d %-I:%M%p %Z' 2>/dev/null || true) + else + reset_text=$(TZ="${STATUSLINE_TZ:-America/Los_Angeles}" date -d "@$reset_at" '+%b %d %-I:%M%p' 2>/dev/null || true) + fi + fi + + printf '%s%s [%s] %s left%s %s@ %s%s' "$color" "$label" "$bar" "$amount" "$reset" "$dim" "$reset_text" "$reset" +} + +fable_weekly_limit() { + local config_dir credentials cache now modified token tmp response_pct response_reset + + # The status-line payload currently exposes only the aggregate seven-day + # limit. Claude's own read-only usage endpoint also returns scoped weekly + # limits, including Fable. Cache it because this script refreshes often. + config_dir=${CLAUDE_CONFIG_DIR:-$HOME/.claude} + credentials="$config_dir/.credentials.json" + cache="$config_dir/cache/statusline-usage.json" + now=$(date +%s) + modified=0 + [[ -f $cache ]] && modified=$(stat -c %Y "$cache" 2>/dev/null || printf '0') + + if (( now - modified >= 60 )) && [[ -r $credentials ]] && command -v curl >/dev/null 2>&1; then + token=$(jq -r '.claudeAiOauth.accessToken // empty' "$credentials" 2>/dev/null) + if [[ -n $token ]]; then + mkdir -p "${cache%/*}" + tmp="$cache.$$" + if curl -fsS --connect-timeout 1 --max-time 3 \ + 'https://api.anthropic.com/api/oauth/usage' \ + -H "Authorization: Bearer $token" \ + -H 'anthropic-version: 2023-06-01' \ + -H 'anthropic-beta: oauth-2025-04-20' \ + -o "$tmp" \ + && jq -e '.limits | type == "array"' "$tmp" >/dev/null 2>&1; then + chmod 600 "$tmp" + mv -f "$tmp" "$cache" + else + rm -f "$tmp" + fi + fi + fi + + [[ -r $cache ]] || return 0 + response_pct=$(jq -r ' + [.limits[]? + | select(.kind == "weekly_scoped") + | select((.scope.model.display_name // "" | ascii_downcase) == "fable") + | .percent] | first // empty + ' "$cache" 2>/dev/null) + response_reset=$(jq -r ' + [.limits[]? + | select(.kind == "weekly_scoped") + | select((.scope.model.display_name // "" | ascii_downcase) == "fable") + | .resets_at] | first // empty + ' "$cache" 2>/dev/null) + if [[ -n $response_reset ]]; then + response_reset=$(date -d "$response_reset" +%s 2>/dev/null || printf '') + fi + [[ -n $response_pct ]] && printf '%s\t%s\n' "$response_pct" "$response_reset" +} + +# Build a compact path. Narrow terminals favor repository and leaf names. +short_cwd=${cwd/#$HOME/\~} +if (( columns < 100 )); then + leaf=${cwd##*/} + if [[ -n $repo_name && $repo_name != "$leaf" ]]; then + short_cwd="$repo_name/$leaf" + elif [[ -n $repo_name ]]; then + short_cwd=$repo_name + else + short_cwd=$leaf + fi +fi +path_max=$(( columns >= 120 ? 52 : (columns >= 80 ? 34 : 24) )) +short_cwd=$(truncate_middle "$short_cwd" "$path_max") + +# One bounded Git command supplies branch, dirty counts, and ahead/behind state. +git_branch=$worktree_branch +git_status_text="" +if [[ -n $cwd ]]; then + if command -v timeout >/dev/null 2>&1; then + git_output=$(GIT_OPTIONAL_LOCKS=0 timeout 1s git -C "$cwd" status --porcelain=v1 --branch --untracked-files=normal 2>/dev/null) + else + git_output=$(GIT_OPTIONAL_LOCKS=0 git -C "$cwd" status --porcelain=v1 --branch --untracked-files=normal 2>/dev/null) + fi + if [[ -n $git_output ]]; then + staged=0 + modified=0 + untracked=0 + ahead=0 + behind=0 + while IFS= read -r status_line; do + if [[ $status_line == '## '* ]]; then + branch_header=${status_line#'## '} + [[ -z $git_branch ]] && git_branch=${branch_header%%...*} + [[ $branch_header =~ ahead[[:space:]]+([0-9]+) ]] && ahead=${BASH_REMATCH[1]} + [[ $branch_header =~ behind[[:space:]]+([0-9]+) ]] && behind=${BASH_REMATCH[1]} + elif [[ ${status_line:0:2} == '??' ]]; then + ((untracked++)) + else + [[ ${status_line:0:1} != ' ' ]] && ((staged++)) + [[ ${status_line:1:1} != ' ' ]] && ((modified++)) + fi + done <<<"$git_output" + (( staged > 0 )) && git_status_text+=" +$staged" + (( modified > 0 )) && git_status_text+=" ~$modified" + (( untracked > 0 )) && git_status_text+=" ?$untracked" + (( ahead > 0 )) && git_status_text+=" a$ahead" + (( behind > 0 )) && git_status_text+=" b$behind" + fi +fi + +line1_parts=("${dim}[WORK]${reset}" "${cyan}${short_cwd}${reset}") +if [[ -n $git_branch ]]; then + branch_max=$(( columns >= 100 ? 30 : 18 )) + git_branch=$(truncate_middle "$git_branch" "$branch_max") + line1_parts+=("${yellow}${git_branch}${reset}${git_status_text}") +fi +if [[ -n $pr_number && $columns -ge 80 ]]; then + pr_text="PR #$pr_number" + [[ -n $pr_state ]] && pr_text+=" $pr_state" + line1_parts+=("${blue}${pr_text}${reset}") +fi + +line2_parts=("${dim}[MODEL]${reset}") +model_text=$model +[[ -n $effort ]] && model_text+=" $effort" +[[ $thinking == true && $columns -ge 100 ]] && model_text+=" think" +[[ -n $model_text ]] && line2_parts+=("${magenta}${model_text}${reset}") +line2_parts+=("CTX $(context_segment)") + +fable_weekly_pct=$(jq -r '.rate_limits.seven_day_fable.used_percentage // empty' <<<"$input") +fable_weekly_reset=$(jq -r '.rate_limits.seven_day_fable.resets_at // empty' <<<"$input") +if [[ -z $fable_weekly_pct ]]; then + IFS=$'\t' read -r fable_weekly_pct fable_weekly_reset < <(fable_weekly_limit) +fi + +line3_parts=( + "${dim}[LIMIT]${reset}" + "$(rate_segment '5h' "$five_hour_pct" "$five_hour_reset")" + "$(rate_segment '7d All' "$seven_day_pct" "$seven_day_reset")" +) +[[ -n $fable_weekly_pct ]] && line3_parts+=("$(rate_segment '7d Fable' "$fable_weekly_pct" "$fable_weekly_reset")") + +identity="" +if [[ -n $agent_name ]]; then + identity="agent:$agent_name" +elif [[ -n $session_name ]]; then + identity="session:$session_name" +elif [[ -n $worktree_name ]]; then + identity="wt:$worktree_name" +fi +runtime_target="" +# AgentTower needs the exact tmux session name for `tmux attach -t`. +if [[ -n ${TMUX:-} ]] && command -v tmux >/dev/null 2>&1; then + tmux_session=$(tmux display-message -p '#S' 2>/dev/null || true) + if [[ -n $tmux_session ]]; then + runtime_target="tmux:${tmux_session}" + [[ -n ${TMUX_PANE:-} ]] && runtime_target+="/${TMUX_PANE}" + fi +fi +[[ -z $runtime_target && -n $identity ]] && runtime_target=$identity +[[ -n $runtime_target ]] && line1_parts+=("${blue}${runtime_target}${reset}") + +weekly_reset=${fable_weekly_reset:-$seven_day_reset} +line4_parts=( + "${dim}[RESET]${reset}" + "$(countdown_segment '5h' "$five_hour_reset" 18000 minutes)" + "$(countdown_segment '7d' "$weekly_reset" 604800 days)" +) + +printf '%s\n' "$(join_parts "${line1_parts[@]}")" +printf '%s\n' "$(join_parts "${line2_parts[@]}")" +printf '%s\n' "$(join_parts "${line3_parts[@]}")" +printf '%s\n' "$(join_parts "${line4_parts[@]}")" diff --git a/base-image/files/codex-profile b/base-image/files/codex-profile new file mode 100755 index 0000000..0a346f6 --- /dev/null +++ b/base-image/files/codex-profile @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +manifest="${CODEX_PROFILES_MANIFEST:-${CHATGPT_PROFILES_MANIFEST:-${XDG_CONFIG_HOME:-$HOME/.config}/workbenches/openai-profiles.json}}" +base="${CODEX_PROFILES_HOME:-${CHATGPT_PROFILES_HOME:-$HOME/.chatgpt-profiles}}" +command -v jq >/dev/null 2>&1 || { echo "jq is required." >&2; exit 1; } + +codex_bin="${CODEX_BIN:-$(command -v codex || true)}" +if [[ -z "$codex_bin" && -x "$HOME/.local/bin/codex" ]]; then + codex_bin="$HOME/.local/bin/codex" +fi + +profile_metadata() { + local requested="$1" normalized row metadata candidate + normalized="$(printf '%s' "$requested" | tr '[:upper:]' '[:lower:]')" + if [[ -f "$manifest" ]]; then + row="$(jq -r --arg name "$normalized" \ + '.profiles[] + | select( + ((.name | ascii_downcase) == $name) + or any(.aliases[]?; (ascii_downcase == $name)) + ) + | [.name,.email,.family] | @tsv' \ + "$manifest" | head -n 1)" + if [[ -n "$row" ]]; then + printf '%s\n' "$row" + return 0 + fi + fi + + for metadata in "$base"/profiles/*/.profile.json; do + [[ -f "$metadata" ]] || continue + candidate="$(jq -r --arg name "$normalized" ' + if (((.name // "") | ascii_downcase) == $name) + or any(.aliases[]?; (ascii_downcase == $name)) + then "match" else empty end + ' "$metadata")" + if [[ "$candidate" == "match" ]]; then + jq -r '[.name,.email,.family] | @tsv' "$metadata" + return 0 + fi + done + return 1 +} + +action="${1:-list}" +if [[ "$action" == "list" || "$action" == "login" || "$action" == "status" || "$action" == "logout" || "$action" == "run" ]]; then + [[ $# -eq 0 ]] || shift +else + action="run" +fi + +case "$action" in + list) + { + if [[ -f "$manifest" ]]; then + jq -r '.profiles[] | [.name, .family, .email, ((.aliases // []) | join(","))] | @tsv' "$manifest" + fi + for metadata in "$base"/profiles/*/.profile.json; do + [[ -f "$metadata" ]] || continue + jq -r '[.name,.family,.email,((.aliases // []) | join(","))] | @tsv' "$metadata" + done + } | awk -F '\t' '!seen[tolower($1)]++' + ;; + login|status|logout|run) + profile="${1:?Usage: codex-profile $action PROFILE [arguments]}"; shift + row="$(profile_metadata "$profile" || true)" + [[ -n "$row" ]] || { echo "Unknown Codex profile: $profile" >&2; exit 2; } + IFS=$'\t' read -r profile email family <<<"$row" + config_dir="$base/profiles/$profile" + [[ -d "$config_dir" ]] || { echo "Codex profile is not configured: $config_dir" >&2; exit 2; } + [[ -n "$codex_bin" ]] || { echo "Codex CLI not found." >&2; exit 1; } + + export CODEX_PROFILE_NAME="$profile" + export CODEX_PROFILE_EMAIL="$email" + export CODEX_PROFILE_FAMILY="$family" + codex_auth_config=( + -c 'forced_login_method="chatgpt"' + -c 'cli_auth_credentials_store="file"' + ) + + case "$action" in + login) + printf 'Starting ChatGPT login for %s (%s).\n' "$profile" "$email" >&2 + CODEX_HOME="$config_dir" "$codex_bin" "${codex_auth_config[@]}" login "$@" + ;; + status) + CODEX_HOME="$config_dir" "$codex_bin" "${codex_auth_config[@]}" login status "$@" + ;; + logout) + CODEX_HOME="$config_dir" "$codex_bin" "${codex_auth_config[@]}" logout "$@" + ;; + run) + CODEX_HOME="$config_dir" exec "$codex_bin" "${codex_auth_config[@]}" "$@" + ;; + esac + ;; + *) echo "Usage: codex-profile {list|login|status|logout|run} [PROFILE]" >&2; exit 2 ;; +esac diff --git a/base-image/files/opencode/opencode.json b/base-image/files/opencode/opencode.json index 4ff68d8..eef5102 100644 --- a/base-image/files/opencode/opencode.json +++ b/base-image/files/opencode/opencode.json @@ -3,8 +3,8 @@ "default_agent": "openagent", "plugin": [ "oh-my-opencode", - "opencode-gemini-auth@1.3.6", - "opencode-openai-codex-auth@4.2.0" + "opencode-gemini-auth@1.4.15", + "opencode-openai-codex-auth@4.4.0" ], "provider": { "anthropic": { diff --git a/base-image/files/provider-profile b/base-image/files/provider-profile new file mode 100755 index 0000000..f0ad474 --- /dev/null +++ b/base-image/files/provider-profile @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +invoked_as="$(basename "$0")" +case "$invoked_as" in + pgemini|gemini-profile) provider=gemini ;; + pgrok|grok-profile) provider=grok ;; + pglm|pzai|glm-profile|zai-profile) provider=glm ;; + *) provider="${AI_PROFILE_PROVIDER:-}" ;; +esac + +case "$provider" in + gemini) + manifest="${GEMINI_PROFILES_MANIFEST:-${XDG_CONFIG_HOME:-$HOME/.config}/workbenches/gemini-profiles.json}" + base="${GEMINI_PROFILES_HOME:-$HOME/.gemini-profiles}" + cli="${GEMINI_BIN:-$(command -v gemini || true)}" + label="Gemini" + ;; + grok) + manifest="${GROK_PROFILES_MANIFEST:-${XDG_CONFIG_HOME:-$HOME/.config}/workbenches/grok-profiles.json}" + base="${GROK_PROFILES_HOME:-$HOME/.grok-profiles}" + if [[ -z "${GROK_PROFILES_HOME:-}" && -e "$base" && ! -w "$base" ]]; then + base="${XDG_DATA_HOME:-$HOME/.local/share}/workbenches/grok-profiles" + fi + cli="${GROK_BIN:-$(command -v grok || true)}" + label="Grok" + ;; + glm) + manifest="${GLM_PROFILES_MANIFEST:-${XDG_CONFIG_HOME:-$HOME/.config}/workbenches/glm-profiles.json}" + base="${GLM_PROFILES_HOME:-$HOME/.glm-profiles}" + cli="${OPENCODE_BIN:-$(command -v opencode || true)}" + label="Z.AI GLM" + ;; + *) + echo "Invoke as pgemini, pgrok, or pglm." >&2 + exit 2 + ;; +esac + +command -v jq >/dev/null 2>&1 || { echo "jq is required." >&2; exit 1; } + +profile_metadata() { + local requested="$1" normalized + normalized="$(printf '%s' "$requested" | tr '[:upper:]' '[:lower:]')" + jq -r --arg name "$normalized" ' + .profiles[] + | select( + ((.name | ascii_downcase) == $name) + or any(.aliases[]?; (ascii_downcase == $name)) + ) + | [.name,.email,.family] | @tsv + ' "$manifest" | head -n 1 +} + +configure_environment() { + local profile_dir="$1" + export AI_PROFILE_NAME="$profile" + export AI_PROFILE_EMAIL="$email" + export AI_PROFILE_FAMILY="$family" + case "$provider" in + gemini) export GEMINI_CLI_HOME="$profile_dir" ;; + grok) export GROK_HOME="$profile_dir" ;; + glm) + export XDG_CONFIG_HOME="$profile_dir/xdg/config" + export XDG_DATA_HOME="$profile_dir/xdg/data" + export XDG_CACHE_HOME="$profile_dir/xdg/cache" + export XDG_STATE_HOME="$profile_dir/xdg/state" + ;; + esac +} + +action="${1:-list}" +if [[ "$action" == "list" || "$action" == "login" || "$action" == "status" || "$action" == "logout" || "$action" == "run" || "$action" == "path" ]]; then + [[ $# -eq 0 ]] || shift +else + action=run +fi + +case "$action" in + list) + jq -r '.profiles[] | [.name,.family,.email,((.aliases // []) | join(",")),(.status // "active")] | @tsv' "$manifest" + ;; + login|status|logout|run|path) + profile_requested="${1:?Usage: $invoked_as $action PROFILE [arguments]}"; shift + row="$(profile_metadata "$profile_requested")" + [[ -n "$row" ]] || { echo "Unknown $label profile: $profile_requested" >&2; exit 2; } + IFS=$'\t' read -r profile email family <<<"$row" + profile_dir="$base/profiles/$profile" + [[ -d "$profile_dir" ]] || { echo "$label profile is not configured: $profile_dir" >&2; exit 2; } + [[ "$action" == path ]] && { printf '%s\n' "$profile_dir"; exit 0; } + [[ -n "$cli" ]] || { echo "$label CLI not found." >&2; exit 1; } + configure_environment "$profile_dir" + + case "$provider:$action" in + gemini:login) + printf 'Starting Gemini sign-in for %s (%s). Complete the Google flow in Gemini.\n' "$profile" "$email" >&2 + exec "$cli" "$@" + ;; + gemini:status) + if [[ -s "$profile_dir/.gemini/oauth_creds.json" ]]; then + echo "Gemini credential cache is present for $profile. Launch pgemini $profile and run /about to verify the account." + else + echo "Gemini credentials are not present for $profile." + exit 1 + fi + ;; + gemini:logout) exec "$cli" auth logout "$@" ;; + gemini:run) exec "$cli" "$@" ;; + grok:login) exec "$cli" login "$@" ;; + grok:status) exec "$cli" models "$@" ;; + grok:logout) exec "$cli" logout "$@" ;; + grok:run) exec "$cli" "$@" ;; + glm:login) + echo "Select Z.AI Coding Plan when OpenCode asks for the provider." >&2 + exec "$cli" auth login "$@" + ;; + glm:status) exec "$cli" auth list "$@" ;; + glm:logout) exec "$cli" auth logout "$@" ;; + glm:run) exec "$cli" "$@" ;; + esac + ;; + *) echo "Usage: $invoked_as {list|login|status|logout|run|path} [PROFILE]" >&2; exit 2 ;; +esac diff --git a/base-image/install-ai-clis.sh b/base-image/install-ai-clis.sh index 2e78a31..396b677 100755 --- a/base-image/install-ai-clis.sh +++ b/base-image/install-ai-clis.sh @@ -1,6 +1,6 @@ #!/bin/bash # Shared AI CLI Installation Script -# Version: 2.0.2 +# Version: 2.0.5 # # USER-AGNOSTIC: Runs as root, installs to system-wide paths. # All npm globals go to /usr/local (default root prefix). @@ -9,11 +9,12 @@ # Claude Code goes to /usr/local/bin. # # Installs: -# - OpenCode (from Opensoft/opencode fork) -# - oh-my-opencode plugin (from git: darrenhinde/oh-my-opencode) +# - OpenCode (installed from the official npm platform package) +# - oh-my-opencode plugin (installed from the published npm package) # Includes built-in agents: Sisyphus, oracle, librarian, explore, frontend, etc. # - Auth plugins (opencode-gemini-auth, opencode-openai-codex-auth) -# - Other AI CLIs (Codex, Antigravity, Copilot, etc.) +# - Other AI CLIs (Codex, Gemini, Copilot, etc.) +# - Google Antigravity CLI (agy), checksum-gated opt-in # - Claude Code (via native installer, not npm) # # Note: OpenAgents agent files (openagent.md, opencoder.md) are copied via @@ -29,8 +30,15 @@ set -e # DEBUG AND TIMEOUT CONFIGURATION # ======================================== DEBUG="${DEBUG:-1}" -COMMAND_TIMEOUT="${COMMAND_TIMEOUT:-300}" # 5 minutes per command -BUN_OPERATIONS_TIMEOUT="${BUN_OPERATIONS_TIMEOUT:-180}" # 3 minutes for bun ops +COMMAND_TIMEOUT="${COMMAND_TIMEOUT:-300}" # 5 minutes per general command +NPM_INSTALL_TIMEOUT="${NPM_INSTALL_TIMEOUT:-600}" # 10 minutes for npm package installs +GIT_CLONE_TIMEOUT="${GIT_CLONE_TIMEOUT:-900}" # 15 minutes for slow GitHub clones +RELEASE_DOWNLOAD_TIMEOUT="${RELEASE_DOWNLOAD_TIMEOUT:-3600}" # 60 minutes for slow GitHub release assets +BUN_OPERATIONS_TIMEOUT="${BUN_OPERATIONS_TIMEOUT:-900}" # 15 minutes for bun ops +UV_TOOL_INSTALL_TIMEOUT="${UV_TOOL_INSTALL_TIMEOUT:-3600}" # 60 minutes for Python tool installs +INSTALL_ANTIGRAVITY_CLI="${INSTALL_ANTIGRAVITY_CLI:-0}" +ANTIGRAVITY_INSTALL_URL="${ANTIGRAVITY_INSTALL_URL:-https://antigravity.google/cli/install.sh}" +ANTIGRAVITY_INSTALL_SHA256="${ANTIGRAVITY_INSTALL_SHA256:-}" log_debug() { if [ "$DEBUG" = "1" ]; then @@ -69,6 +77,22 @@ run_with_timeout() { fi } +ensure_system_uv_tool_paths() { + mkdir -p "$SYSTEM_UV_TOOL_DIR" "$SYSTEM_UV_TOOL_BIN_DIR" /root/.local/share/uv + ln -sfn "$SYSTEM_UV_TOOL_DIR" /root/.local/share/uv/tools +} + +run_system_uv_tool_install() { + local description="$1" + shift + + ensure_system_uv_tool_paths + run_with_timeout "$UV_TOOL_INSTALL_TIMEOUT" "$description" env \ + UV_TOOL_BIN_DIR="$SYSTEM_UV_TOOL_BIN_DIR" \ + UV_TOOL_DIR="$SYSTEM_UV_TOOL_DIR" \ + uv tool install "$@" --python-preference system +} + check_system_resources() { log_debug "Checking system resources..." log_debug "Memory: $(free -h | head -2)" @@ -77,6 +101,51 @@ check_system_resources() { log_debug "Available disk in /tmp: $(df -h /tmp | tail -1)" } +resolve_claude_js_fallback_version() { + if [ -n "${CLAUDE_CODE_JS_FALLBACK_VERSION:-}" ]; then + printf '%s\n' "$CLAUDE_CODE_JS_FALLBACK_VERSION" + return 0 + fi + + curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --max-time 120 \ + https://registry.npmjs.org/@anthropic-ai%2fclaude-code \ + | jq -r '.versions | to_entries[] | select(.value.bin.claude == "cli.js") | .key' \ + | sort -V \ + | tail -1 +} + +install_claude_js_fallback() { + local fallback_version + + fallback_version="$(resolve_claude_js_fallback_version)" + if [ -z "$fallback_version" ]; then + log_error "Could not resolve a JS-based Claude Code fallback version" + return 1 + fi + + log_info "Installing Claude Code JS fallback ${fallback_version}..." + rm -f /usr/local/bin/claude /usr/bin/claude + hash -r || true + run_with_timeout "$NPM_INSTALL_TIMEOUT" "Claude Code JS fallback npm install" \ + npm install -g "@anthropic-ai/claude-code@${fallback_version}" +} + +ensure_claude_runnable() { + if command -v claude >/dev/null 2>&1 && claude --version >/dev/null 2>&1; then + log_info "Claude Code runnable check passed: $(claude --version)" + return 0 + fi + + log_error "Claude Code native binary failed runnable check; falling back to JS package" + if install_claude_js_fallback && command -v claude >/dev/null 2>&1 && claude --version >/dev/null 2>&1; then + log_info "Claude Code fallback runnable check passed: $(claude --version)" + return 0 + fi + + log_error "Claude Code fallback did not produce a runnable claude CLI" + return 1 +} + log_info "==========================================" log_info "Installing AI CLI Tools" log_info "==========================================" @@ -91,39 +160,9 @@ check_system_resources # Bun is pre-installed at /opt/bun by Dockerfile export BUN_INSTALL="${BUN_INSTALL:-/opt/bun}" -export PATH="/opt/bun/bin:/usr/local/bin:$HOME/.local/bin:$PATH" - -ensure_cli_on_path() { - local cli="$1" - shift - - if command -v "$cli" >/dev/null 2>&1; then - return 0 - fi - - local candidate - for candidate in "$@"; do - if [ -n "$candidate" ] && [ -x "$candidate" ]; then - ln -sf "$candidate" "/usr/local/bin/$cli" - log_info "Linked $cli to /usr/local/bin/$cli" - return 0 - fi - done - - return 1 -} - -install_opencode_npm_fallback() { - log_info "Falling back to npm package opencode-ai..." - if run_with_timeout "$COMMAND_TIMEOUT" "OpenCode npm fallback install" npm install -g opencode-ai; then - ensure_cli_on_path opencode \ - "$(npm config get prefix 2>/dev/null)/bin/opencode" \ - "$HOME/.npm-global/bin/opencode" \ - "$HOME/.local/bin/opencode" || log_error "OpenCode npm fallback installed but opencode is not on PATH" - else - log_error "OpenCode npm fallback failed (continuing)" - fi -} +export PATH="/opt/bun/bin:$PATH" +SYSTEM_UV_TOOL_DIR="${SYSTEM_UV_TOOL_DIR:-/opt/uv/tools}" +SYSTEM_UV_TOOL_BIN_DIR="${SYSTEM_UV_TOOL_BIN_DIR:-/usr/local/bin}" log_debug "Verifying Bun installation" if which bun >/dev/null 2>&1; then @@ -133,11 +172,6 @@ else log_error "Bun not found in PATH (expected at /opt/bun/bin)" fi -log_info "Installing OpenSpec..." -if ! run_with_timeout "$COMMAND_TIMEOUT" "OpenSpec npm install" npm install -g @fission-ai/openspec@latest; then - log_error "OpenSpec installation failed (continuing)" -fi - log_info "Installing Claude Code CLI (native installer)..." # Native installer downloads to $HOME/.claude/downloads/ then runs 'claude install' # which places a launcher in ~/.local/bin/. Since we run as root, we need to @@ -164,62 +198,132 @@ else log_error "Claude Code binary not found after installation (continuing)" fi +ensure_claude_runnable || true + log_info "Installing OpenAI Codex CLI..." -if ! run_with_timeout "$COMMAND_TIMEOUT" "Codex npm install" npm install -g @openai/codex; then +if ! run_with_timeout "$NPM_INSTALL_TIMEOUT" "Codex npm install" npm install -g @openai/codex@latest; then log_error "Codex installation failed (continuing)" fi -ensure_cli_on_path codex \ - "$(npm config get prefix 2>/dev/null)/bin/codex" \ - "$HOME/.npm-global/bin/codex" \ - "$HOME/.local/bin/codex" || true - -log_info "Installing Antigravity CLI..." -if ! run_with_timeout "$COMMAND_TIMEOUT" "Antigravity CLI install" bash -c 'curl -fsSL https://antigravity.google/cli/install.sh | bash -s -- --dir /usr/local/bin'; then - log_error "Antigravity CLI installation failed (continuing)" + +log_info "Installing Google Gemini CLI..." +if ! run_with_timeout "$NPM_INSTALL_TIMEOUT" "Gemini npm install" npm install -g @google/gemini-cli; then + log_error "Gemini CLI installation failed (continuing)" +fi + +if [ "$INSTALL_ANTIGRAVITY_CLI" = "1" ] || [ "$INSTALL_ANTIGRAVITY_CLI" = "true" ]; then + log_info "Installing Google Antigravity CLI..." + if [ -z "$ANTIGRAVITY_INSTALL_SHA256" ]; then + log_error "Antigravity install requested but ANTIGRAVITY_INSTALL_SHA256 is not set (skipping)" + else + antigravity_installer="$(mktemp)" + if run_with_timeout "120" "Antigravity installer download" \ + curl -fsSL "$ANTIGRAVITY_INSTALL_URL" -o "$antigravity_installer" && + printf '%s %s\n' "$ANTIGRAVITY_INSTALL_SHA256" "$antigravity_installer" | sha256sum -c - >/dev/null 2>&1 && + run_with_timeout "300" "Antigravity CLI install" bash "$antigravity_installer" --skip-aliases --skip-path; then + if [ -x "$HOME/.local/bin/agy" ] && [ ! -x /usr/local/bin/agy ]; then + cp "$HOME/.local/bin/agy" /usr/local/bin/agy + chmod +x /usr/local/bin/agy + fi + log_info "Antigravity CLI installed to $(command -v agy || printf '/usr/local/bin/agy')" + else + log_error "Antigravity CLI installation failed or checksum verification failed (continuing)" + fi + rm -f "$antigravity_installer" + fi +else + log_info "Skipping Google Antigravity CLI install; set INSTALL_ANTIGRAVITY_CLI=1 and ANTIGRAVITY_INSTALL_SHA256 to enable" fi -ensure_cli_on_path agy \ - "/usr/local/bin/agy" \ - "$HOME/.local/bin/agy" || true log_info "Installing GitHub Copilot CLI..." -if ! run_with_timeout "$COMMAND_TIMEOUT" "GitHub Copilot npm install" npm install -g @githubnext/github-copilot-cli; then +if ! run_with_timeout "$NPM_INSTALL_TIMEOUT" "GitHub Copilot npm install" npm install -g @github/copilot; then log_error "GitHub Copilot installation failed (continuing)" fi log_info "Installing Grok CLI (xAI)..." mkdir -p /opt/grok/bin -if run_with_timeout "$COMMAND_TIMEOUT" "Grok native install" bash -o pipefail -c 'curl -fsSL https://x.ai/cli/install.sh | GROK_BIN_DIR=/opt/grok/bin bash'; then +if run_with_timeout "$COMMAND_TIMEOUT" "Grok native install" bash -o pipefail -c \ + 'curl -fsSL https://x.ai/cli/install.sh | GROK_BIN_DIR=/opt/grok/bin bash'; then mkdir -p /opt/grok/completions if [ -d "$HOME/.grok/completions" ]; then cp -a "$HOME/.grok/completions/." /opt/grok/completions/ fi chmod -R a+rX /opt/grok - ensure_cli_on_path grok \ - "/opt/grok/bin/grok" \ - "/usr/local/bin/grok" \ - "$HOME/.grok/bin/grok" || log_error "Grok installed but grok is not on PATH" - ensure_cli_on_path agent \ - "/opt/grok/bin/agent" \ - "/usr/local/bin/agent" \ - "$HOME/.grok/bin/agent" || true + ln -sfn /opt/grok/bin/grok /usr/local/bin/grok + if [ -x /opt/grok/bin/agent ]; then + ln -sfn /opt/grok/bin/agent /usr/local/bin/agent + fi else log_error "Grok CLI native installation failed (continuing)" fi -log_info "Installing OpenCode AI (from Opensoft fork)..." -# OpenCode: open source AI coding agent (https://github.com/Opensoft/opencode) -# Install from Opensoft fork instead of npm (sst version) -log_debug "Cloning OpenCode repository from Opensoft..." -if ! run_with_timeout "$COMMAND_TIMEOUT" "OpenCode git clone" git clone --depth 1 https://github.com/Opensoft/opencode.git /tmp/opencode; then - log_error "Failed to clone OpenCode repository (skipping OpenCode installation)" - install_opencode_npm_fallback -else +install_opencode_release() { + local opencode_version="${OPENCODE_VERSION:-latest}" + local opencode_arch + local opencode_url + local opencode_dir="/tmp/opencode-release" + local opencode_bin + + case "$(uname -m)" in + x86_64|amd64) + opencode_arch="x64" + ;; + aarch64|arm64) + opencode_arch="arm64" + ;; + *) + log_error "Unsupported OpenCode release architecture: $(uname -m)" + return 1 + ;; + esac + + if [ "$opencode_version" = "latest" ]; then + opencode_version=$(curl --http1.1 -fsSL --retry 3 --connect-timeout 10 --speed-time 20 --speed-limit 1024 --max-time 60 \ + https://registry.npmjs.org/opencode-ai/latest | jq -r '.version // empty') + fi + + if [ -z "$opencode_version" ] || [ "$opencode_version" = "null" ]; then + log_error "Could not resolve latest OpenCode release version" + return 1 + fi + + opencode_url="https://registry.npmjs.org/opencode-linux-${opencode_arch}/-/opencode-linux-${opencode_arch}-${opencode_version}.tgz" + rm -rf "$opencode_dir" + mkdir -p "$opencode_dir" + + if ! run_with_timeout "$RELEASE_DOWNLOAD_TIMEOUT" "OpenCode release download" \ + curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 \ + -o "$opencode_dir/opencode.tgz" "$opencode_url"; then + return 1 + fi + + if ! tar -xzf "$opencode_dir/opencode.tgz" -C "$opencode_dir"; then + log_error "Failed to extract OpenCode npm platform archive" + return 1 + fi + + opencode_bin=$(find "$opencode_dir" -type f -name opencode -perm /111 2>/dev/null | head -1) + if [ -z "$opencode_bin" ] || [ ! -x "$opencode_bin" ]; then + log_error "OpenCode binary not found in release archive" + return 1 + fi + + install -m 0755 "$opencode_bin" /usr/local/bin/opencode + log_info "OpenCode ${opencode_version} installed to /usr/local/bin/opencode from npm platform package" +} + +build_opencode_from_source() { + log_debug "Cloning OpenCode repository from upstream..." + if ! run_with_timeout "$GIT_CLONE_TIMEOUT" "OpenCode git clone" git -c http.version=HTTP/1.1 clone --depth 1 --single-branch --no-tags --filter=blob:none https://github.com/anomalyco/opencode.git /tmp/opencode; then + log_error "Failed to clone OpenCode repository" + return 1 + fi + cd /tmp/opencode log_debug "Current directory: $(pwd)" log_debug "Directory contents: $(ls -la | head -20)" log_info "Running bun install for OpenCode..." - OPENCODE_BUILD_TIMEOUT="${OPENCODE_BUILD_TIMEOUT:-300}" # fall back to npm if the source build drags + OPENCODE_BUILD_TIMEOUT=900 # 15 minutes for single-target compile if command -v bun >/dev/null 2>&1; then log_debug "Bun is available, using it for installation" case "$(uname -m)" in @@ -262,11 +366,18 @@ else chmod +x /usr/local/bin/opencode log_info "OpenCode installed to /usr/local/bin/opencode" else - log_error "OpenCode binary not found after build" - install_opencode_npm_fallback + log_error "OpenCode binary not found after build (continuing)" fi cd - +} + +log_info "Installing OpenCode AI..." +# OpenCode: open source AI coding agent (https://github.com/anomalyco/opencode). +# Prefer official release binaries; fall back to source build only if needed. +if ! install_opencode_release; then + log_error "OpenCode release install failed, falling back to source build" + build_opencode_from_source || log_error "OpenCode source build failed" fi # ======================================== @@ -274,62 +385,125 @@ fi # ======================================== # Note: OpenAgents agent files (openagent.md, opencoder.md) are provided # via Dockerfile COPY step, not installed here -log_info "Installing oh-my-opencode plugin from git..." -# oh-my-opencode: OpenCode plugin from darrenhinde's fork (not published to npm) -# Using darrenhinde's fork which may include customizations for OpenAgents integration -# Install from GitHub repository -log_debug "Cloning oh-my-opencode..." -if ! run_with_timeout "$COMMAND_TIMEOUT" "oh-my-opencode git clone" git clone --depth 1 https://github.com/darrenhinde/oh-my-opencode.git /tmp/oh-my-opencode; then - log_error "Failed to clone oh-my-opencode (skipping oh-my-opencode installation)" -else - cd /tmp/oh-my-opencode - log_debug "Current directory: $(pwd)" - log_debug "Directory contents: $(ls -la | head -20)" - - log_info "Building oh-my-opencode..." - if command -v bun >/dev/null 2>&1; then - log_debug "Using Bun for oh-my-opencode installation" - if run_with_timeout "$BUN_OPERATIONS_TIMEOUT" "oh-my-opencode bun install" bun install; then - if ! run_with_timeout "$BUN_OPERATIONS_TIMEOUT" "oh-my-opencode bun build" bun run build; then - log_error "oh-my-opencode build failed" - fi - else - log_error "oh-my-opencode bun install timeout, trying npm fallback" - if run_with_timeout "$COMMAND_TIMEOUT" "oh-my-opencode npm install" npm install; then - run_with_timeout "$COMMAND_TIMEOUT" "oh-my-opencode npm build" npm run build || log_error "npm build failed" - else - log_error "oh-my-opencode npm install failed" - fi - fi - else - log_debug "Using npm for oh-my-opencode installation" - if run_with_timeout "$COMMAND_TIMEOUT" "oh-my-opencode npm install" npm install; then - run_with_timeout "$COMMAND_TIMEOUT" "oh-my-opencode npm build" npm run build || log_error "npm build failed" - else - log_error "oh-my-opencode npm install failed" - fi +install_oh_my_opencode_package() { + local tmp_dir metadata version tarball_url omo_arch platform_package platform_metadata platform_tarball_url plugin_src shared_skills_src wrapper_src bin_name + + tmp_dir="$(mktemp -d)" + log_debug "Resolving latest oh-my-opencode package metadata..." + if ! metadata="$(curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --max-time 60 https://registry.npmjs.org/oh-my-opencode/latest)"; then + log_error "Failed to resolve oh-my-opencode npm metadata" + rm -rf "$tmp_dir" + return 1 fi - - log_debug "Installing oh-my-opencode plugin..." - mkdir -p /opt/opencode/plugin - cp -r /tmp/oh-my-opencode /opt/opencode/plugin/oh-my-opencode - cd /opt/opencode/plugin/oh-my-opencode - - log_debug "Running bun install in plugin directory..." - if command -v bun >/dev/null 2>&1; then - run_with_timeout "$BUN_OPERATIONS_TIMEOUT" "oh-my-opencode plugin bun install" bun install || log_error "Plugin bun install failed" - else - run_with_timeout "$COMMAND_TIMEOUT" "oh-my-opencode plugin npm install" npm install || log_error "Plugin npm install failed" + + version="$(printf '%s' "$metadata" | jq -r '.version')" + tarball_url="$(printf '%s' "$metadata" | jq -r '.dist.tarball')" + if [ -z "$version" ] || [ "$version" = "null" ] || [ -z "$tarball_url" ] || [ "$tarball_url" = "null" ]; then + log_error "Invalid oh-my-opencode npm metadata" + rm -rf "$tmp_dir" + return 1 fi - cd - - + + case "$(uname -m)" in + x86_64|amd64) + omo_arch="x64" + ;; + aarch64|arm64) + omo_arch="arm64" + ;; + *) + log_error "Unsupported oh-my-opencode architecture: $(uname -m)" + rm -rf "$tmp_dir" + return 1 + ;; + esac + + platform_package="oh-my-opencode-linux-${omo_arch}" + if ! platform_metadata="$(curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --max-time 60 "https://registry.npmjs.org/${platform_package}/${version}")"; then + log_error "Failed to resolve ${platform_package} ${version} npm metadata" + rm -rf "$tmp_dir" + return 1 + fi + + platform_tarball_url="$(printf '%s' "$platform_metadata" | jq -r '.dist.tarball')" + if [ -z "$platform_tarball_url" ] || [ "$platform_tarball_url" = "null" ]; then + log_error "Invalid ${platform_package} npm metadata" + rm -rf "$tmp_dir" + return 1 + fi + + log_info "Installing oh-my-opencode ${version} from npm tarballs..." + mkdir -p "$tmp_dir/main" "$tmp_dir/platform" + + if ! run_with_timeout "$RELEASE_DOWNLOAD_TIMEOUT" "oh-my-opencode package download" curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 -o "$tmp_dir/oh-my-opencode.tgz" "$tarball_url"; then + log_error "Failed to download oh-my-opencode package tarball" + rm -rf "$tmp_dir" + return 1 + fi + + if ! run_with_timeout "$RELEASE_DOWNLOAD_TIMEOUT" "oh-my-opencode platform package download" curl --http1.1 -fsSL --retry 2 --connect-timeout 10 --speed-time 20 --speed-limit 1024 -o "$tmp_dir/oh-my-opencode-platform.tgz" "$platform_tarball_url"; then + log_error "Failed to download ${platform_package} package tarball" + rm -rf "$tmp_dir" + return 1 + fi + + if ! tar -xzf "$tmp_dir/oh-my-opencode.tgz" -C "$tmp_dir/main"; then + log_error "Failed to extract oh-my-opencode package tarball" + rm -rf "$tmp_dir" + return 1 + fi + + if ! tar -xzf "$tmp_dir/oh-my-opencode-platform.tgz" -C "$tmp_dir/platform"; then + log_error "Failed to extract ${platform_package} package tarball" + rm -rf "$tmp_dir" + return 1 + fi + + plugin_src="$tmp_dir/main/package/packages/omo-codex/plugin" + shared_skills_src="$tmp_dir/main/package/packages/shared-skills" + wrapper_src="$tmp_dir/platform/package/bin/oh-my-opencode.js" + if [ ! -d "$plugin_src" ] || [ ! -d "$shared_skills_src" ] || [ ! -f "$wrapper_src" ]; then + log_error "oh-my-opencode package is missing expected plugin payload" + rm -rf "$tmp_dir" + return 1 + fi + + rm -rf /opt/oh-my-opencode + mkdir -p /opt/oh-my-opencode + cp -a "$tmp_dir/main/package"/. /opt/oh-my-opencode/ + install -m 0755 "$wrapper_src" /opt/oh-my-opencode/oh-my-opencode-wrapper.js + + for bin_name in oh-my-opencode oh-my-openagent omo lazycodex lazycodex-ai; do + cat > "/usr/local/bin/$bin_name" <<'EOF' +#!/bin/sh +export OMO_WRAPPER_PACKAGE_ROOT="${OMO_WRAPPER_PACKAGE_ROOT:-/opt/oh-my-opencode}" +export OMO_INVOCATION_NAME="${0##*/}" +exec node /opt/oh-my-opencode/oh-my-opencode-wrapper.js "$@" +EOF + chmod 0755 "/usr/local/bin/$bin_name" + done + + log_debug "Installing oh-my-opencode plugin payload..." + rm -rf /opt/opencode/plugin/oh-my-opencode /opt/opencode/shared-skills + mkdir -p /opt/opencode/plugin/oh-my-opencode /opt/opencode/shared-skills /opt/opencode/plugin/oh-my-opencode/node_modules/@oh-my-opencode + cp -a "$plugin_src"/. /opt/opencode/plugin/oh-my-opencode/ + cp -a "$shared_skills_src"/. /opt/opencode/shared-skills/ + ln -sfn /opt/opencode/shared-skills /opt/opencode/plugin/oh-my-opencode/node_modules/@oh-my-opencode/shared-skills + + rm -rf "$tmp_dir" +} + +log_info "Installing oh-my-opencode package and plugin..." +if ! install_oh_my_opencode_package; then + log_error "Failed to install oh-my-opencode" +else log_info "Installing auth plugins..." cd /opt/opencode/plugin if command -v bun >/dev/null 2>&1; then log_debug "Installing Gemini auth plugin via bun..." - run_with_timeout "$COMMAND_TIMEOUT" "Gemini auth plugin" bun add opencode-gemini-auth@1.3.6 || log_error "Gemini auth plugin install failed" + run_with_timeout "$COMMAND_TIMEOUT" "Gemini auth plugin" bun add opencode-gemini-auth@1.4.15 || log_error "Gemini auth plugin install failed" log_debug "Installing Codex auth plugin via bun..." - run_with_timeout "$COMMAND_TIMEOUT" "Codex auth plugin" bun add opencode-openai-codex-auth@4.2.0 || log_error "Codex auth plugin install failed" + run_with_timeout "$COMMAND_TIMEOUT" "Codex auth plugin" bun add opencode-openai-codex-auth@4.4.0 || log_error "Codex auth plugin install failed" else log_debug "Bun not available for auth plugins, skipping" fi @@ -342,15 +516,14 @@ fi log_info "Installing Letta Code..." # Letta Code: memory-first coding agent (https://github.com/letta-ai/letta-code) -if ! run_with_timeout "$COMMAND_TIMEOUT" "Letta Code npm install" npm install -g @letta-ai/letta-code; then +if ! run_with_timeout "$NPM_INSTALL_TIMEOUT" "Letta Code npm install" npm install -g @letta-ai/letta-code@latest; then log_error "Letta Code installation failed (continuing)" fi log_info "Installing NotebookLM tools..." # notebooklm-py: Python CLI + API for NotebookLM (notebooklm command) # Base install only — no browser deps needed in container; auth mounted from host -if run_with_timeout "$COMMAND_TIMEOUT" "notebooklm-py install" uv tool install notebooklm-py --python-preference system; then - [ -f "$HOME/.local/bin/notebooklm" ] && ln -sf "$HOME/.local/bin/notebooklm" /usr/local/bin/notebooklm +if run_system_uv_tool_install "notebooklm-py install" notebooklm-py; then log_info "notebooklm-py CLI installed (notebooklm)" else log_error "notebooklm-py installation failed (continuing)" @@ -358,11 +531,9 @@ fi # notebooklm-mcp-cli: MCP server + nlm CLI for AI agent integration # Auth is done on the host (requires browser); tokens mounted into container -# uv tool install puts binaries in ~/.local/bin (root), so symlink to /usr/local/bin -if run_with_timeout "$COMMAND_TIMEOUT" "NotebookLM MCP CLI install" uv tool install notebooklm-mcp-cli --python-preference system; then - for bin in nlm notebooklm-mcp; do - [ -f "$HOME/.local/bin/$bin" ] && ln -sf "$HOME/.local/bin/$bin" "/usr/local/bin/$bin" - done +# Install into a shared uv tools directory instead of root's home so bench +# users can execute the launchers from /usr/local/bin. +if run_system_uv_tool_install "NotebookLM MCP CLI install" notebooklm-mcp-cli; then log_info "NotebookLM MCP CLI installed (nlm, notebooklm-mcp)" else log_error "NotebookLM MCP CLI installation failed (continuing)" @@ -373,32 +544,40 @@ log_info "AI CLI Tools Installation Complete!" log_info "==========================================" log_info "" -required_clis=(claude codex agy opencode) +required_clis=(claude codex gemini copilot opencode omo letta notebooklm nlm) missing_clis=() for cli in "${required_clis[@]}"; do - ensure_cli_on_path "$cli" \ - "$(npm config get prefix 2>/dev/null)/bin/$cli" \ - "$HOME/.npm-global/bin/$cli" \ - "$HOME/.local/bin/$cli" || true if ! command -v "$cli" >/dev/null 2>&1; then missing_clis+=("$cli") fi done +if command -v claude >/dev/null 2>&1 && ! claude --version >/dev/null 2>&1; then + missing_clis+=("claude(runnable)") +fi + if [ "${#missing_clis[@]}" -gt 0 ]; then log_error "Missing required AI CLIs after installation: ${missing_clis[*]}" exit 1 fi log_info "Installed tools:" -log_info " - OpenSpec" log_info " - Claude Code (claude) [native installer]" log_info " - OpenAI Codex (codex)" -log_info " - Antigravity CLI (agy)" -log_info " - GitHub Copilot (copilot)" -log_info " - Grok (grok)" +log_info " - Google Gemini (gemini)" +if command -v agy >/dev/null 2>&1; then + log_info " - Google Antigravity CLI (agy)" +else + log_info " - Google Antigravity CLI (agy) [checksum-gated opt-in, skipped]" +fi +log_info " - GitHub Copilot CLI (copilot)" +if command -v grok >/dev/null 2>&1; then + log_info " - Grok CLI (grok)" +else + log_info " - Grok CLI (grok) [install skipped or failed]" +fi log_info " - OpenCode (opencode)" -log_info " - oh-my-opencode (darrenhinde fork with built-in agents)" +log_info " - oh-my-opencode (omo)" log_info " - Letta Code (letta)" log_info " - NotebookLM CLI (notebooklm) [auth via host browser]" log_info " - NotebookLM MCP CLI (nlm) [auth via host browser]" diff --git a/bench-config.json.backup b/bench-config.json.backup deleted file mode 100755 index ba19bf6..0000000 --- a/bench-config.json.backup +++ /dev/null @@ -1,55 +0,0 @@ -{ - "infrastructure": { - "specKit": { - "install": "uv tool install specify-cli --from git+https://github.com/github/spec-kit.git", - "run": "uvx --from git+https://github.com/github/spec-kit.git specify init --here", - "description": "GitHub Spec Kit - installed via uvx (always fetches latest)" - } - }, - "benches": { - "cloudBench": { - "url": "git@github.com:opensoft/cloudBench.git", - "path": "sysBenches/cloudBench", - "description": "Cloud infrastructure and operations tools" - }, - "pythonBench": { - "url": "git@github.com:opensoft/pythonBench.git", - "path": "devBench/pythonBench", - "description": "Python development environment and tools" - }, - "javaBench": { - "url": "git@github.com:opensoft/javaBench.git", - "path": "devBench/javaBench", - "description": "Java development environment and tools" - }, - "dotNetBench": { - "url": "git@github.com:opensoft/dotNetBench.git", - "path": "devBench/dotNetBench", - "description": ".NET development environment and tools" - }, - "flutterBench": { - "url": "git@github.com:opensoft/flutterBench.git", - "path": "devBench/flutterBench", - "description": "Flutter/Dart development environment and tools", - "project_scripts": [ - { - "name": "flutter", - "script": "scripts/new-flutter-project.sh", - "description": "Create a new Flutter project with DevContainer setup", - "includes_speckit": true - }, - { - "name": "dartwing", - "script": "scripts/new-dartwing-project.sh", - "description": "Create a new DartWing project with specialized configuration", - "includes_speckit": true - } - ] - }, - "cppBench": { - "url": "git@github.com:opensoft/cppBench.git", - "path": "devBench/cppBench", - "description": "C++ development environment and tools" - } - } -} diff --git a/bioBenches/base-image/Dockerfile b/bioBenches/base-image/Dockerfile index 6809a2e..e5b8559 100644 --- a/bioBenches/base-image/Dockerfile +++ b/bioBenches/base-image/Dockerfile @@ -8,7 +8,7 @@ FROM workbench-base:latest # Container version labels LABEL layer="1c" LABEL layer.name="bio-bench-base" -LABEL layer.version="2.0.0" +LABEL layer.version="2.0.2" LABEL layer.description="Bio base with Miniconda, bioconda and conda-forge channels (user-agnostic)" # Everything runs as root @@ -62,7 +62,10 @@ RUN conda tos accept --override-channels --channel https://repo.anaconda.com/pkg && conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r \ && conda config --system --prepend channels conda-forge \ && conda config --system --prepend channels bioconda \ - && conda config --system --set channel_priority strict + && conda config --system --set channel_priority strict \ + && CONDA_VERSION="$(curl -fsSL https://api.github.com/repos/conda/conda/releases/latest | jq -r '.tag_name')" \ + && conda install -n base -y --override-channels -c conda-forge "conda=${CONDA_VERSION}" \ + && conda clean -afy # ======================================== # SHELL CONFIGURATION (into /etc/skel) diff --git a/bioBenches/base-image/build.sh b/bioBenches/base-image/build.sh index 704ec0c..2edf744 100755 --- a/bioBenches/base-image/build.sh +++ b/bioBenches/base-image/build.sh @@ -18,9 +18,11 @@ LEGACY_IMAGE="$(legacy_family_base_image bio)" cd "$SCRIPT_DIR" # Parse arguments (--user is accepted but ignored for backward compat) +NO_CACHE="${NO_CACHE:-false}" while [[ $# -gt 0 ]]; do case $1 in --user) shift 2 ;; + --no-cache) NO_CACHE=true; shift ;; *) shift ;; esac done @@ -28,6 +30,7 @@ done echo "Configuration:" echo " Tag: $CANONICAL_IMAGE (user-agnostic)" echo " Legacy alias: $LEGACY_IMAGE" +echo " No cache: $NO_CACHE" echo "" # Check if Layer 0 exists @@ -43,6 +46,7 @@ fi # Build the image echo "Building $CANONICAL_IMAGE..." docker build \ + $([ "$NO_CACHE" = true ] && printf '%s\n' "--no-cache") \ -t "$CANONICAL_IMAGE" \ . tag_family_base_legacy_alias bio diff --git a/config/ai-harness-accounts.example.json b/config/ai-harness-accounts.example.json new file mode 100644 index 0000000..4b3ef13 --- /dev/null +++ b/config/ai-harness-accounts.example.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "accounts": [ + { + "provider": "claude", + "name": "personal-claude-1", + "family": "personal", + "email": "user@example.com", + "plan": "max", + "workspace": "personal", + "authMode": "browser", + "status": "active" + }, + { + "provider": "chatgpt", + "name": "company-chatgpt-1", + "family": "company", + "email": "developer@company.example", + "plan": "business", + "workspace": "company", + "authMode": "browser", + "status": "active" + }, + { + "provider": "grok", + "name": "personal-grok-1", + "family": "personal", + "email": "user@example.com", + "plan": "supergrok", + "workspace": "personal", + "authMode": "browser", + "status": "active" + }, + { + "provider": "antigravity", + "name": "personal-antigravity-1", + "family": "personal", + "email": "user@gmail.com", + "plan": "individual", + "workspace": "personal", + "authMode": "keyring", + "status": "active" + }, + { + "provider": "abacus", + "name": "company-abacus-1", + "family": "company", + "email": "developer@company.example", + "plan": "team", + "workspace": "company", + "authMode": "api-key", + "secretEnv": "ABACUS_API_KEY_COMPANY_1", + "status": "active" + } + ] +} diff --git a/config/ai-profile-source.example.json b/config/ai-profile-source.example.json new file mode 100644 index 0000000..e438e92 --- /dev/null +++ b/config/ai-profile-source.example.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "kind": "workbenches-ai-profile-source", + "owner": { + "type": "user", + "id": "example-engineer" + }, + "credentialContractVersion": 1, + "profiles": { + "claude": [ + { + "name": "personal-claude-1", + "email": "engineer@example.com", + "family": "personal", + "aliases": ["personal-claude"], + "accountId": "example-engineer.claude.personal-claude-1.account", + "credentialId": "example-engineer.claude.personal-claude-1.credential", + "authentication": { + "type": "subscription_oauth", + "credentialRef": "ai/secrets/claude/personal-claude-1.credentials.sops.yaml", + "escrowStatus": "not-escrowed" + } + } + ], + "openai": [ + { + "name": "personal-chatgpt-1", + "email": "engineer@example.com", + "family": "personal", + "aliases": ["personal-chatgpt"], + "plan": "plus", + "workspace": "personal", + "status": "active", + "accountId": "example-engineer.openai.personal-chatgpt-1.account", + "credentialId": "example-engineer.openai.personal-chatgpt-1.credential", + "authentication": { + "type": "workspace_access_token", + "credentialRef": "ai/secrets/openai/personal-chatgpt-1.credentials.sops.yaml", + "escrowStatus": "not-escrowed" + } + } + ], + "gemini": [], + "grok": [], + "glm": [] + }, + "providerParity": [] +} diff --git a/config/bench-config.json b/config/bench-config.json index 5f7acee..f852930 100755 --- a/config/bench-config.json +++ b/config/bench-config.json @@ -14,7 +14,7 @@ "ai_keywords": ["cloud", "infrastructure", "devops", "kubernetes", "docker", "deployment", "admin", "monitoring", "logging"] }, "pyBench": { - "url": "git@github.com:opensoft/pyBench.git", + "url": "git@github.com:opensoft/pyBench.git", "path": "devBenches/pyBench", "description": "Python development environment and tools", "ai_keywords": ["python", "django", "flask", "fastapi", "pandas", "numpy", "machine learning", "ml", "data science", "AI", "artificial intelligence", "web scraping"], diff --git a/config/claude-profiles.example.json b/config/claude-profiles.example.json new file mode 100644 index 0000000..876c66d --- /dev/null +++ b/config/claude-profiles.example.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "profiles": [ + { "name": "work", "family": "work", "email": "user@company.example" }, + { "name": "personal", "family": "personal", "email": "user@example.com" } + ] +} diff --git a/config/claude/workflows/deep-swarm-code-review.js b/config/claude/workflows/deep-swarm-code-review.js new file mode 100644 index 0000000..b91cbb2 --- /dev/null +++ b/config/claude/workflows/deep-swarm-code-review.js @@ -0,0 +1,438 @@ +export const meta = { + name: 'deep-swarm-code-review', + description: 'Swarm of expert subagents deep-reviews a PR / branch / uncommitted diff, adversarially verifies each finding, and (in PR mode) automatically posts all confirmed findings as inline comments on the PR', + whenToUse: 'Deep multi-agent code review. Auto-targets: open PR for the current branch, else committed branch-vs-main, else uncommitted working-tree changes. In PR mode it ALWAYS posts all confirmed findings to the PR automatically (set args.post=false to suppress, args.dedupeAgainstExisting=true to skip findings already commented on the PR). Override with args {mode:"pr"|"branch"|"uncommitted", prNumber, base}.', + phases: [ + { title: 'Scope', detail: 'detect review target (PR / branch / uncommitted) + partition the diff' }, + { title: 'Review', detail: 'multi-pass swarm — each pass adds expert lenses, finer file units, deeper digging' }, + { title: 'Verify', detail: 'independent skeptic verifies each finding + validates the diff line' }, + { title: 'Post', detail: 'auto-publish one consolidated GitHub review of all confirmed findings (PR mode)' }, + ], +} + +// ============================================================================ +// args (all optional — workflow auto-detects when omitted): +// mode: 'pr' | 'branch' | 'uncommitted' +// prNumber: number (pr mode) +// base: base ref for committed diffs (default auto: origin/main || main) +// post: boolean — post results to GitHub (default true in pr mode) +// repoRoot: absolute path (default: current working dir of agents) +// ============================================================================ +const cfg = args || {} +const REPO_ROOT = cfg.repoRoot || '.' +const POST = cfg.post !== false // PR mode auto-posts unless explicitly disabled +const DEDUPE = cfg.dedupeAgainstExisting === true // skip findings already commented on the PR + +// ---- Phase 0: detect the review target ------------------------------------- +phase('Scope') + +const SCOPE_SCHEMA = { + type: 'object', + required: ['mode', 'base', 'summary'], + properties: { + mode: { type: 'string', enum: ['pr', 'branch', 'uncommitted'] }, + prNumber: { type: 'integer' }, + base: { type: 'string' }, + branch: { type: 'string' }, + summary: { type: 'string' }, + }, +} + +let scope +if (cfg.mode) { + scope = { mode: cfg.mode, prNumber: cfg.prNumber, base: cfg.base || 'origin/main', summary: 'from args' } +} else { + scope = await agent( + `Determine what this code review should target. Repo root: ${REPO_ROOT}. Use Bash (git, gh). + +Decide ONE mode, in this priority order: +1. 'pr' — if 'gh pr view --json number,baseRefName,headRefName' shows an OPEN PR for the CURRENT branch. Capture prNumber and base (the PR's baseRefName, e.g. origin/main or main). +2. 'uncommitted' — else if 'git status --porcelain' shows tracked changes (the working tree is dirty). base = HEAD. +3. 'branch' — else review committed work on this branch vs its base. base = whichever of 'origin/main' or 'main' exists (prefer origin/main). If the current branch IS main/master with no PR and a clean tree, still pick 'branch' with base = the previous commit's parent (HEAD~1) and note it in summary. + +Return the chosen mode, base ref string (usable in 'git diff ...HEAD' for pr/branch, or literally 'HEAD' for uncommitted), prNumber if pr, branch name, and a one-line summary of what will be reviewed.`, + { label: 'scope:detect', phase: 'Scope', schema: SCOPE_SCHEMA }, + ) +} + +const MODE = scope.mode +const BASE = scope.base || 'origin/main' +const PRNUM = scope.prNumber || cfg.prNumber +log(`Target: ${MODE}${PRNUM ? ' #' + PRNUM : ''} (base=${BASE}) — ${scope.summary}`) + +// How each reviewer obtains its slice of the diff, by mode. +function diffSpec(files) { + const fileArgs = files.map(f => `'${f}'`).join(' ') + if (MODE === 'uncommitted') { + return `Review UNCOMMITTED changes only:\n git -C ${REPO_ROOT} diff HEAD -- ${fileArgs}\n (also 'git -C ${REPO_ROOT} status --porcelain -- ${fileArgs}' for new untracked files).` + } + return `BASE detection (run first):\n BASE=$(git -C ${REPO_ROOT} merge-base HEAD ${BASE} 2>/dev/null || git -C ${REPO_ROOT} merge-base HEAD main || echo ${BASE})\nThen review the committed diff:\n git -C ${REPO_ROOT} diff "$BASE"...HEAD -- ${fileArgs}` +} + +// ---- Phase 1 setup: discover changed files and group them ------------------ +// A reviewer agent reads the changed-file list and partitions it into coherent +// subsystem groups, so the workflow adapts to whatever diff it is pointed at. +const GROUPS_SCHEMA = { + type: 'object', + required: ['groups'], + properties: { + groups: { + type: 'array', + items: { + type: 'object', + required: ['name', 'persona', 'files'], + properties: { + name: { type: 'string' }, + persona: { type: 'string' }, + files: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, +} + +const listCmd = MODE === 'uncommitted' + ? `git -C ${REPO_ROOT} diff --name-only HEAD; git -C ${REPO_ROOT} ls-files --others --exclude-standard` + : `BASE=$(git -C ${REPO_ROOT} merge-base HEAD ${BASE} 2>/dev/null || git -C ${REPO_ROOT} merge-base HEAD main || echo ${BASE}); git -C ${REPO_ROOT} diff --name-only "$BASE"...HEAD` + +const partition = await agent( + `List the changed files for this review and partition them into coherent review groups. + +Run: + ${listCmd} + +Then group the changed files into 8–24 subsystem groups so each group is a coherent unit one expert can review well (group by directory / language / feature; keep related scripts together; isolate large/high-risk files into their own group). For each group give: a short kebab 'name', a 'persona' (the kind of expert best suited — e.g. "a defensive Bash engineer", "a Docker layered-build expert", "a senior Python engineer", "a PowerShell automation expert", "a config/JSON correctness reviewer", "a refactor-safety auditor for renamed/removed paths"), and the exact repo-relative 'files' (every changed file must appear in exactly one group). Aim to cover EVERY changed file.`, + { label: 'scope:partition', phase: 'Scope', schema: GROUPS_SCHEMA }, +) + +const GROUPS = (partition.groups || []).filter(g => g.files && g.files.length) +if (!GROUPS.length) { + log('No changed files found — nothing to review.') + return { mode: MODE, base: BASE, confirmedCount: 0, confirmed: [] } +} +log(`Swarm: ${GROUPS.length} expert reviewers over ${GROUPS.reduce((n, g) => n + g.files.length, 0)} changed files`) + +// ---- shared reviewer guidance ---------------------------------------------- +const SHARED_RULES = ` +You are reviewing a real change. Work from the actual diff and full file context — do NOT speculate. + +SCOPE: Report only problems introduced or touched by THIS diff. Ignore pre-existing issues in unchanged lines. + +LOOK FOR (weight by real impact): +- Correctness / logic bugs, wrong conditionals, off-by-one, bad expansion, unset-var use. +- Shell robustness: unquoted expansions, word-splitting, missing 'set -euo pipefail' where it matters, ignored exit codes, fragile parsing, non-portable bashisms in /bin/sh, eval misuse, unguarded cd, unsafe rm globs. +- Security: command injection, curl|bash of untrusted input, secret/token leakage, unsafe temp files, world-readable creds, permissions. +- Cross-platform / cross-shell parity (bash vs zsh vs PowerShell; macOS vs Linux: sed -i, mktemp, readlink). +- Dockerfile: cache busting, missing cleanup/--no-install-recommends, root vs user, version pinning where it matters, COPY/chmod correctness. +- Config/JSON/YAML: invalid syntax, wrong keys, broken references to renamed/removed paths. +- Dead code, broken cross-file refs, renames the diff didn't propagate everywhere. + +PRECISION (critical for the next stage): +- "file" MUST be the repo-relative path exactly as in the diff. +- "line" MUST be a line number in the NEW (post-change) file — a line on the RIGHT side of the diff (an added '+' line, or a context line inside a changed hunk). Read the file to get the exact number. Prefer an added '+' line. +- "body" is GitHub-Markdown: state the concrete problem, why it matters, and a specific fix (a short \`\`\`suggestion\`\`\` block is ideal). +- Quality over quantity. Skip pure style nits with no functional impact. A finding you are not fairly confident is real does more harm than good downstream. + +Return findings via the structured tool. An empty list is a valid answer.` + +function reviewerPrompt(u) { + const known = (u.known && u.known.length) + ? `\nALREADY-REPORTED in this area by earlier reviewers — do NOT repeat these. Find DIFFERENT, deeper, or adjacent problems the others missed:\n${u.known.map(k => ` - ${k.file}:${k.line} — ${k.title}`).join('\n')}\n` + : '' + const deep = u.depth + ? 'This is a DEEP pass: read each touched file in full, trace control/data flow into the sibling files it sources or calls (and that call it), and reason about non-obvious failure modes, edge cases, and cross-file interactions — not just surface-level bugs.\n' + : '' + return `You are ${u.persona}. Repo root: ${REPO_ROOT}. +Review lens for this pass: ${u.lensDesc} + +Review these changed files: +${u.files.map(f => ' - ' + f).join('\n')} + +${diffSpec(u.files)} + +Use Bash (git diff / grep) and Read freely to get the diff and full surrounding context before judging. +${deep}${known}${SHARED_RULES}` +} + +const FINDINGS_SCHEMA = { + type: 'object', + required: ['findings'], + properties: { + findings: { + type: 'array', + items: { + type: 'object', + required: ['file', 'line', 'severity', 'category', 'title', 'body', 'confidence'], + properties: { + file: { type: 'string' }, + line: { type: 'integer' }, + severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, + category: { type: 'string' }, + title: { type: 'string' }, + body: { type: 'string' }, + confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, + }, + }, + }, + }, +} + +const VERDICT_SCHEMA = { + type: 'object', + required: ['keep', 'reason'], + properties: { + keep: { type: 'boolean' }, + reason: { type: 'string' }, + adjustedLine: { type: 'integer' }, + adjustedSeverity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, + refinedBody: { type: 'string' }, + inDiff: { type: 'boolean' }, + }, +} + +function verifyPrompt(f) { + return `You are an independent, skeptical senior reviewer. A prior reviewer raised the finding below. REFUTE it unless it clearly holds up. Default keep=false when uncertain, when it is style-only, or when it concerns unchanged/pre-existing code. + +Repo root: ${REPO_ROOT} +Finding file: ${f.file} +Claimed NEW-file line: ${f.line} +Severity: ${f.severity} | Category: ${f.category} +Title: ${f.title} +Body: +${f.body} + +Steps: +1. ${diffSpec([f.file]).split('\n').join('\n ')} + Confirm the cited line is part of this diff (an added '+' line or context line inside a changed hunk). Set inDiff. If the issue is real but the line is slightly off, put the correct NEW-file line (one that IS in the diff) in adjustedLine. +2. Read surrounding code to confirm the problem is REAL with practical impact, not a misreading. +3. Decide keep (true only if real, impactful, tied to changed lines). One-sentence reason. Optionally set adjustedSeverity and improve refinedBody (GitHub-Markdown). + +Return via the structured tool.` +} + +// ---- Multi-pass swarm — wider lenses + finer granularity + deeper digging each pass +// Each pass adds more expert lenses, splits the diff into finer units, and tells +// every reviewer what earlier passes already found so it hunts for NEW, deeper issues. +const PASSES = Math.max(1, cfg.passes || 3) +const MAX_UNITS = cfg.maxReviewersPerPass || 120 // per-pass reviewer cap (cost guard) + +const LENS_SETS = [ + // Pass 1 — broad sweep, one generalist per subsystem + [{ key: 'core', desc: 'overall correctness, logic bugs, and the highest-impact robustness problems' }], + // Pass 2 — specialist quartet, applied per subsystem + [ + { key: 'security', desc: 'security: command/regex injection, secret & token handling, file permissions, unsafe temp files, curl|bash of untrusted input' }, + { key: 'robustness', desc: 'shell robustness & portability: quoting/word-splitting, set -euo pipefail interactions, ignored exit codes, GNU-vs-BSD/macOS, bash-vs-zsh-vs-POSIX' }, + { key: 'consistency', desc: 'cross-file consistency: renamed/removed paths, parser parity between sibling scripts, docs/READMEs that contradict behavior, broken references' }, + { key: 'control-flow', desc: 'control-flow & tool/API semantics: wrong conditionals, early/no-op exits, broken orchestration, misused CLIs/builtins, idempotency on re-run' }, + ], + // Pass 3+ — full battery, per-file granularity, deep flow tracing + [ + { key: 'concurrency', desc: 'concurrency, races, locking, and idempotency under parallel or repeated invocation' }, + { key: 'error-handling', desc: 'error handling & failure modes: partial failures, missing guards, silent skips, cleanup/trap correctness' }, + { key: 'edge-cases', desc: 'edge cases & input validation: empty/whitespace/unicode/missing inputs, unusual paths, boundary conditions' }, + { key: 'perf-resource', desc: 'performance & resource use: redundant work, repeated network/subprocess calls, unbounded loops, leaks' }, + { key: 'docs-ux', desc: 'documentation/UX accuracy: help text, READMEs, comments, and error messages vs actual behavior' }, + ], +] + +function chunk(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out } +const sevRank = { critical: 0, high: 1, medium: 2, low: 3 } + +const confirmedAll = [] +const seenByFile = {} +function titleKey(t) { return (t || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').split(/\s+/).filter(Boolean).slice(0, 6).join(' ') } +// Dup only if same file AND within ±3 lines AND (identical line OR similar title). +// Different-angle findings (e.g. a security vs a perf issue) on nearby lines survive. +function isNew(f) { + const tk = titleKey(f.title) + for (const e of (seenByFile[f.file] || [])) { + if (Math.abs(e.line - f.line) <= 3 && (e.line === f.line || e.tkey === tk)) return false + } + return true +} +function remember(f) { (seenByFile[f.file] = seenByFile[f.file] || []).push({ line: f.line, tkey: titleKey(f.title) }) } +function knownFor(files) { const s = new Set(files); return confirmedAll.filter(f => s.has(f.file)).map(f => ({ file: f.file, line: f.line, title: f.title })) } +function normalize(x) { + return { + file: x.file, + line: (Number.isInteger(x.verdict.adjustedLine) ? x.verdict.adjustedLine : x.line), + severity: x.verdict.adjustedSeverity || x.severity, + category: x.category, title: x.title, + body: x.verdict.refinedBody || x.body, + confidence: x.confidence, group: x.group, + inDiff: x.verdict.inDiff !== false, verifyReason: x.verdict.reason, + } +} + +let rawTotal = 0 +for (let p = 1; p <= PASSES; p++) { + // widen across passes: each pass applies a DISTINCT tier of lenses (it does NOT + // re-run earlier tiers — re-running 'core' every pass just rediscovers pass-1 + // findings and wastes the budget). Reviewers still get earlier findings as context. + const tier = Math.min(p - 1, LENS_SETS.length - 1) + const lenses = LENS_SETS[tier] + // deepen: finer file chunks + full-file deep reads on later passes + const chunkSize = p <= 1 ? 999 : (p === 2 ? 3 : 2) + const deep = p >= 3 + + // Build per-lens unit lists, then interleave round-robin so that if the per-pass + // cap trims, it trims EVENLY across lenses and subsystems (never starves a lens). + const perLens = lenses.map(lens => { + const arr = [] + for (const g of GROUPS) for (const fc of chunk(g.files, chunkSize)) { + arr.push({ + name: `${g.name}/${lens.key}`, + persona: lens.key === 'core' ? g.persona : `${g.persona}, reviewing specifically through a ${lens.key} lens`, + lensDesc: lens.desc, files: fc, depth: deep, known: knownFor(fc), + }) + } + return arr + }) + const totalUnits = perLens.reduce((n, a) => n + a.length, 0) + let units = [] + for (let i = 0; units.length < totalUnits; i++) { + for (const arr of perLens) if (i < arr.length) units.push(arr[i]) + } + if (units.length > MAX_UNITS) { log(`Pass ${p}: ${totalUnits} reviewer units → capped to ${MAX_UNITS} (interleaved across lenses; raise args.maxReviewersPerPass for fuller coverage)`); units = units.slice(0, MAX_UNITS) } + + phase(`Pass ${p} · Review`) + log(`Pass ${p}/${PASSES}: ${units.length} expert reviewers — lenses [${lenses.map(l => l.key).join(', ')}], chunk=${chunkSize}${deep ? ', deep' : ''}`) + + const reviewed = await pipeline( + units, + u => agent(reviewerPrompt(u), { label: `p${p}:${u.name}`, phase: `Pass ${p} · Review`, schema: FINDINGS_SCHEMA }) + .then(r => ({ u, findings: (r && r.findings) || [] })) + .catch(() => ({ u, findings: [] })), + (res) => parallel((res.findings).map(f => () => + agent(verifyPrompt(f), { label: `p${p}:verify:${(f.file || '').split('/').pop()}:${f.line}`, phase: `Pass ${p} · Verify`, schema: VERDICT_SCHEMA }) + .then(v => ({ ...f, group: res.u.name, verdict: v })) + .catch(() => null) + )), + ) + + const passRaw = reviewed.flat().filter(Boolean) + rawTotal += passRaw.length + const passConfirmed = passRaw.filter(x => x.verdict && x.verdict.keep).map(normalize) + let added = 0 + for (const f of passConfirmed) { if (isNew(f)) { confirmedAll.push(f); remember(f); added++ } } + log(`Pass ${p}: +${added} net-new confirmed (running total ${confirmedAll.length})`) + + if (budget.total && budget.remaining() < 80000) { log(`Budget low (${Math.round(budget.remaining() / 1000)}k left) — stopping after pass ${p}.`); break } +} + +confirmedAll.sort((a, b) => (sevRank[a.severity] - sevRank[b.severity]) || a.file.localeCompare(b.file) || a.line - b.line) +const counts = confirmedAll.reduce((m, f) => (m[f.severity] = (m[f.severity] || 0) + 1, m), {}) +log(`Total confirmed (deduped) across passes: ${confirmedAll.length} from ${rawTotal} raw — ${JSON.stringify(counts)}`) + +// ---- Phase 3: auto-post ONE consolidated GitHub review (PR mode) ----------- +// In PR mode the workflow ALWAYS posts every confirmed finding (unless post=false). +// The posting agent follows an exact, deterministic procedure so it is reliable +// unattended: it parses the diff with the embedded python script (no guessing +// about which lines are commentable) and submits one COMMENT review. +let posted = { attempted: false } +if (POST && MODE === 'pr' && PRNUM && confirmedAll.length) { + phase('Post') + const payload = JSON.stringify({ prNumber: PRNUM, counts, dedupe: DEDUPE, findings: confirmedAll }) + const postResult = await agent( + `Publish the verified findings below as ONE consolidated GitHub pull-request review on PR #${PRNUM}, with each finding as an inline comment. Repo root: ${REPO_ROOT}. This DOES publish to GitHub — that is the intended behavior, post everything that maps. Use Bash (gh, git, python3). + +FINDINGS JSON (write it to a temp file, e.g. /tmp/swarm_findings.json): +${payload} + +Run EXACTLY this procedure (do not improvise the diff parsing): + +STEP 1 — fetch the diff and owner/repo: + gh pr diff ${PRNUM} > /tmp/pr_${PRNUM}.diff + OWNER_REPO=$(gh repo view --json owner,name --jq '.owner.login + "/" + .name') + +STEP 2 — run this python3 script verbatim (it parses commentable RIGHT-side lines, snaps each finding to a valid line within ±3, optionally dedupes against existing PR comments, and writes the review payload): + + cat > /tmp/build_review.py <<'PY' + import json, re, subprocess, sys + PR = "${PRNUM}" + data = json.load(open('/tmp/swarm_findings.json')) + findings = data['findings']; counts = data['counts']; dedupe = data.get('dedupe', False) + # 1. valid RIGHT-side (new-file) line numbers per path + valid = {}; cur=None; new_ln=None + for line in open('/tmp/pr_%s.diff' % PR): + if line.startswith('diff --git '): cur=None; new_ln=None; continue + if line.startswith('+++ '): + p=line[4:].strip(); cur=None if p=='/dev/null' else (p[2:] if p.startswith('b/') else p) + if cur: valid.setdefault(cur,set()) + continue + if line.startswith('@@'): + m=re.search(r'\\+(\\d+)(?:,(\\d+))?',line); new_ln=int(m.group(1)) if m else None; continue + if cur is None or new_ln is None: continue + if line.startswith('+') and not line.startswith('+++'): valid[cur].add(new_ln); new_ln+=1 + elif line.startswith('-') and not line.startswith('---'): pass + elif line.startswith('\\\\'): pass + else: valid[cur].add(new_ln); new_ln+=1 + # 2. optional dedupe vs existing PR comments (existing comments file passed via EXISTING_JSON env) + import os + posted={} + if dedupe and os.environ.get('EXISTING_JSON'): + for c in json.load(open(os.environ['EXISTING_JSON'])): + ln=c.get('line') or c.get('original_line') + if c.get('path') and ln: posted.setdefault(c['path'],[]).append(ln) + # 3. map findings + comments=[]; unmapped=[] + for f in findings: + if dedupe and any(abs(l-f['line'])<=6 for l in posted.get(f['file'],[])): + continue + vs=valid.get(f['file']); ln=f['line']; chosen=None + if vs: + if ln in vs: chosen=ln + else: + cands=[l for l in vs if abs(l-ln)<=3] + if cands: chosen=min(cands,key=lambda l:(abs(l-ln),l)) + if chosen is not None: + comments.append({"path":f['file'],"line":chosen,"side":"RIGHT","body":"**[%s]** %s"%(f['severity'],f['body'])}) + else: + unmapped.append(f) + # 4. summary body + hi=[f for f in findings if f['severity']=='high' or f['severity']=='critical'] + lines=["## 🤖 AI Swarm Code Review",""] + lines.append("Deep multi-agent review: expert subagents partitioned the diff by subsystem; every finding was adversarially verified by an independent skeptic before posting.") + lines.append("") + lines.append("**Confirmed findings: %d** — %s. %d posted as inline comments below." % (len(findings), json.dumps(counts), len(comments))) + if hi: + lines.append(""); lines.append("Highlights (high severity):") + for f in hi[:6]: lines.append("- **%s** — %s" % (f['file'].split('/')[-1], f['title'])) + if unmapped: + lines.append(""); lines.append("Findings that could not be mapped to a diff line (shown here instead):") + for f in unmapped: lines.append("- **[%s] %s:%s** — %s" % (f['severity'], f['file'], f['line'], f['title'])) + lines.append(""); lines.append("_Advisory; severities are the swarm's estimate. Generated with Claude Code._") + payload={"event":"COMMENT","body":"\\n".join(lines),"comments":comments} + json.dump(payload, open('/tmp/review_payload.json','w')) + print("MAPPED",len(comments),"UNMAPPED",len(unmapped)) + PY + ${DEDUPE ? `gh api --paginate repos/$OWNER_REPO/pulls/${PRNUM}/comments > /tmp/existing_comments.json; EXISTING_JSON=/tmp/existing_comments.json python3 /tmp/build_review.py` : `python3 /tmp/build_review.py`} + +STEP 3 — submit ONE review: + gh api --method POST repos/$OWNER_REPO/pulls/${PRNUM}/reviews --input /tmp/review_payload.json --jq '{id, state, html_url}' + +STEP 4 — if the API returns 422 mentioning a specific line/path, remove that one comment from /tmp/review_payload.json (python or jq) and resubmit so the rest still post. Repeat at most 3 times. + +STEP 5 — verify and report: count how many comments belong to the new review id and return a concise report: number of inline comments posted, number unmapped, and the review html_url.`, + { label: 'post:github-review', phase: 'Post' }, + ) + posted = { attempted: true, report: postResult } + log('Auto-posted GitHub review.') +} else if (POST && MODE === 'pr' && !confirmedAll.length) { + log('No confirmed findings — nothing to post.') +} else if (!POST && MODE === 'pr') { + log('post=false — skipping GitHub posting (findings returned in result).') +} + +return { + mode: MODE, + base: BASE, + prNumber: PRNUM, + passes: PASSES, + rawFindings: rawTotal, + confirmedCount: confirmedAll.length, + counts, + confirmed: confirmedAll, + posted, +} diff --git a/config/openai-profiles.example.json b/config/openai-profiles.example.json new file mode 100644 index 0000000..abbc3d2 --- /dev/null +++ b/config/openai-profiles.example.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "profiles": [ + { + "name": "work-chatgpt-1", + "family": "work", + "email": "user@company.example", + "aliases": ["work1"] + }, + { + "name": "personal-chatgpt-1", + "family": "personal", + "email": "user@example.com", + "aliases": ["personal1"] + } + ] +} diff --git a/config/shell/zshrc b/config/shell/zshrc index be3e5aa..083d5ad 100755 --- a/config/shell/zshrc +++ b/config/shell/zshrc @@ -578,7 +578,7 @@ fi export KUBECONFIG=/home/brett/projects/adminbench/brett-heap.kubeconfig export PATH="/usr/bin:$PATH" export NODE_PATH=/usr/bin/node -export NODE_VERSION=22.20.0 +export NODE_VERSION=24.18.0 export NODEJS_HOME=/usr/bin # Claude Code Node.js configuration @@ -609,8 +609,69 @@ alias devbench-status='docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Por alias devbench-stop='docker stop java_bench dot_net_bench flutter_bench 2>/dev/null || true' # End DevBench Aliases -# Claude alias -alias yolo="claude --dangerously-skip-permissions --teammate-mode tmux" +# Claude helper. Runs the main Claude session inside tmux so another operator +# can capture and drive it, while still letting Claude use tmux for teammates. +_yolo_shell_quote() { + local quoted="" arg + + for arg in "$@"; do + quoted="${quoted} $(printf '%q' "$arg")" + done + + printf '%s\n' "${quoted# }" +} + +unalias yolo 2>/dev/null || true +yolo() { + local session_name command_string prompt_file + local -a prompt_args + + if ! command -v claude >/dev/null 2>&1; then + echo "yolo: Claude CLI not found on PATH" >&2 + return 1 + fi + + if ! command -v tmux >/dev/null 2>&1; then + echo "yolo: tmux not found on PATH" >&2 + return 1 + fi + + prompt_file="" + for candidate_prompt_file in \ + "$HOME/.claude/prompts/speckit-dashboard-full.md" \ + "/usr/local/share/ct/claude/prompts/speckit-dashboard-full.md" \ + "$HOME/.claude/prompts/speckit-dashboard-bootstrap.md"; do + if [ -r "$candidate_prompt_file" ]; then + prompt_file="$candidate_prompt_file" + break + fi + done + prompt_args=() + if [ -n "$prompt_file" ]; then + prompt_args=(--append-system-prompt-file "$prompt_file") + fi + + if [ -n "${TMUX:-}" ]; then + claude --dangerously-skip-permissions --teammate-mode tmux "${prompt_args[@]}" "$@" + return $? + fi + + session_name="yolo-$(date +%Y%m%d%H%M%S)-$$" + command_string=$(_yolo_shell_quote \ + claude \ + --dangerously-skip-permissions \ + --teammate-mode tmux \ + "${prompt_args[@]}" \ + "$@") || return 1 + + tmux new-session -d -s "$session_name" -c "$PWD" "exec $command_string" || { + echo "yolo: failed to start tmux session" >&2 + return 1 + } + + tmux set-option -t "$session_name" mouse on >/dev/null 2>&1 || true + tmux attach-session -t "$session_name" +} export PATH="$HOME/.npm-global/bin:$PATH" export PATH="/workspace/.venv/bin:$PATH" diff --git a/devBenches/.devcontainer/devcontainer.json b/devBenches/.devcontainer/devcontainer.json index 4bfe9d3..4581d8a 100644 --- a/devBenches/.devcontainer/devcontainer.json +++ b/devBenches/.devcontainer/devcontainer.json @@ -3,6 +3,7 @@ "dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"], "service": "devbench", "workspaceFolder": "/workspace", + "initializeCommand": "bash ${localWorkspaceFolder}/scripts/ensure-sonarqube-mcp.sh", "shutdownAction": "stopCompose", "forwardPorts": [1455], diff --git a/devBenches/README.md b/devBenches/README.md index 18ae108..8c8a71a 100755 --- a/devBenches/README.md +++ b/devBenches/README.md @@ -10,6 +10,7 @@ Each subfolder is a separate git repository containing a complete development en - **`dotNetBench/`** - .NET development environment with DevContainer - **`flutterBench/`** - Flutter/Dart development environment with DevContainer - **`javaBench/`** - Java development environment with DevContainer +- **`phpBench/`** - PHP development environment with DevContainer - **`pyBench/`** - Python development environment with DevContainer ## Layered Containers (Current Standard) @@ -20,6 +21,17 @@ All benches are moving to the layered image model described in `workBenches/docs - **Layer 2**: `-bench:latest` - **Layer 3**: `-bench:{user}` (user personalization) +Layer 1a carries the shared developer tooling used by all devBenches, including: +- `sonar-scanner` - SonarScanner CLI for project analysis uploads to SonarQube Server or SonarQube Cloud +- `sonar` - SonarQube CLI for issue/project workflows, secrets scanning, and agent integrations +- `sonar-env` - container-safe Sonar environment loader that reads `~/.config/sonarqube/sonar.env` +- `gt` - Graphite CLI for stacked pull request workflows + +Devcontainers should mount `~/.config/sonarqube` read-only or mount the full host +home directory. The shared `sonar-env` helper then loads tokens at runtime and +sets `SONARQUBE_CLI_KEYCHAIN_FILE` to a writable file-backed keychain so `sonar` +does not depend on a desktop keychain service inside containers. + ## Legacy Monolithic DevContainers (Deprecated) Some benches still include a `.devcontainer/` directory with a monolithic Dockerfile. These are **legacy** and should not be used as the source of truth. Use the layered images and bench-level build scripts instead; treat monolithic Dockerfiles as deprecated artifacts until removed. diff --git a/devBenches/base-image/Dockerfile b/devBenches/base-image/Dockerfile index 80b57ad..e28c4b1 100644 --- a/devBenches/base-image/Dockerfile +++ b/devBenches/base-image/Dockerfile @@ -1,6 +1,7 @@ +# syntax=docker/dockerfile:1.7 # Layer 1a: Developer Base Image -# Extends Layer 0 with Python, Node.js LTS, and dev tools -# AI CLIs are inherited from Layer 0 (workbench-base) +# Extends Layer 0 with Python, Node.js LTS, dev tools, and spec CLIs +# Most AI CLIs are inherited from Layer 0 (workbench-base) # Used by ALL developer benches (Frappe, Flutter, .NET, etc.) # USER-AGNOSTIC: No user creation — Layer 3 handles user setup @@ -9,7 +10,7 @@ FROM workbench-base:latest # Container version labels LABEL layer="1" LABEL layer.name="dev-bench-base" -LABEL layer.version="2.3.1" +LABEL layer.version="2.3.2" LABEL layer.description="Developer base with Python, Node.js LTS, dev tools, Playwright browsers, generic testing tools, and the Omnigent meta-harness (user-agnostic)" # Everything runs as root @@ -50,11 +51,12 @@ RUN pip install --break-system-packages \ pytest \ ipython -# Install Node.js development tools (husky, commitlint) +# Install Node.js development tools and cross-repo workflow CLIs. RUN npm install -g \ husky \ @commitlint/cli \ - @commitlint/config-conventional + @commitlint/config-conventional \ + @withgraphite/graphite-cli@stable # ======================================== # DEVELOPER TOOLS SETUP @@ -63,7 +65,74 @@ RUN npm install -g \ # Verify Python and pip RUN python3 --version && pip --version -# uv, AI CLIs are inherited from Layer 0 (workbench-base) +# uv and most AI CLIs are inherited from Layer 0 (workbench-base) + +# ======================================== +# SPEC-DRIVEN DEVELOPMENT TOOLS +# ======================================== + +# Remove any inherited copies so Layer 1a is the clear owner of these tools. +RUN npm uninstall -g @fission-ai/openspec || true \ + && rm -f /usr/bin/openspec /usr/local/bin/openspec \ + && rm -rf /usr/lib/node_modules/@fission-ai/openspec /usr/local/lib/node_modules/@fission-ai/openspec \ + && uv tool uninstall specify-cli || true \ + && env UV_TOOL_BIN_DIR=/usr/local/bin UV_TOOL_DIR=/opt/uv/tools uv tool uninstall specify-cli || true \ + && rm -f /root/.local/bin/specify /usr/local/bin/specify \ + && rm -rf /root/.local/share/uv/tools/specify-cli /opt/uv/tools/specify-cli + +# Install spec-driven CLIs only in developer benches, not every bench family. +# Clone explicitly so slow GitHub transfers are bounded and visible. +RUN mkdir -p /opt/uv/tools /root/.local/share/uv \ + && timeout 1800s git -c http.version=HTTP/1.1 clone --depth 1 --filter=blob:none https://github.com/github/spec-kit.git /tmp/spec-kit \ + && UV_TOOL_BIN_DIR=/usr/local/bin UV_TOOL_DIR=/opt/uv/tools \ + uv tool install specify-cli --from /tmp/spec-kit --python-preference system \ + && ln -sfn /opt/uv/tools /root/.local/share/uv/tools \ + && rm -rf /tmp/spec-kit \ + || echo "spec-kit installation skipped (non-fatal)" + +RUN npm install -g @fission-ai/openspec@latest \ + || echo "OpenSpec installation skipped (non-fatal)" + +# Shared OpenSpec/Speckit project bootstrapper. Keep it with the spec-driven +# CLIs so every developer bench can initialize the same agent context files. +COPY files/openspeckit/setup-openspeckit /usr/local/bin/setup-openspeckit +RUN chmod 0755 /usr/local/bin/setup-openspeckit \ + && ln -sfn setup-openspeckit /usr/local/bin/setup-openspec-speckit-project + +# OpenSpec Claude commands and skills live with the devBench OpenSpec CLI. +RUN mkdir -p /etc/skel/.claude/commands/opsx \ + && mkdir -p /etc/skel/.claude/skills/opsx-clarify \ + && mkdir -p /etc/skel/.claude/skills/opsx-analyze +COPY files/claude/commands/opsx/ /etc/skel/.claude/commands/opsx/ +COPY files/claude/skills/opsx-clarify/ /etc/skel/.claude/skills/opsx-clarify/ +COPY files/claude/skills/opsx-analyze/ /etc/skel/.claude/skills/opsx-analyze/ + +# Shared, project-agnostic Speckit worktree helpers for all developer benches. +COPY files/ct/ct-functions.zsh /usr/local/share/ct/ct-functions.zsh +COPY files/ct/claude/ /usr/local/share/ct/claude/ +RUN chmod 0644 /usr/local/share/ct/ct-functions.zsh \ + && chmod 0755 /usr/local/share/ct/claude/speckit-dashboard.sh \ + /usr/local/share/ct/claude/speckit-dashboard-sync.sh \ + /usr/local/share/ct/claude/speckit-dash-toggle.sh \ + && chmod 0644 /usr/local/share/ct/claude/prompts/speckit-dashboard-full.md \ + && mkdir -p /etc/skel/.claude/prompts \ + && cp /usr/local/share/ct/claude/speckit-dashboard.sh /etc/skel/.claude/speckit-dashboard.sh \ + && cp /usr/local/share/ct/claude/speckit-dashboard-sync.sh /etc/skel/.claude/speckit-dashboard-sync.sh \ + && cp /usr/local/share/ct/claude/speckit-dash-toggle.sh /etc/skel/.claude/speckit-dash-toggle.sh \ + && cp /usr/local/share/ct/claude/prompts/speckit-dashboard-full.md /etc/skel/.claude/prompts/speckit-dashboard-full.md \ + && chmod 0755 /etc/skel/.claude/speckit-dashboard.sh \ + /etc/skel/.claude/speckit-dashboard-sync.sh \ + /etc/skel/.claude/speckit-dash-toggle.sh \ + && chmod 0644 /etc/skel/.claude/prompts/speckit-dashboard-full.md + +# Speckit worktree bootstrap. This installs a stable command outside Speckit +# itself so generated Speckit files can be refreshed and the worktree workflow +# can be reapplied afterwards. +COPY files/speckit-worktree/templates /usr/local/share/speckit-worktree/templates +COPY files/speckit-worktree/speckit-worktree-enable /usr/local/bin/speckit-worktree-enable +RUN chmod 0755 /usr/local/bin/speckit-worktree-enable && \ + find /usr/local/share/speckit-worktree/templates -type f \( -name '*.sh' -o -name '*.zsh' -o -name '*.ps1' \) -exec chmod 0755 {} + && \ + find /usr/local/share/speckit-worktree/templates -type f ! \( -name '*.sh' -o -name '*.zsh' -o -name '*.ps1' \) -exec chmod 0644 {} + # ======================================== # AI META-HARNESS (OMNIGENT) @@ -105,7 +174,18 @@ RUN mkdir -p /opt/corepack && \ # load testing, security scanning, accessibility, code quality, utilities) # and preload Chromium into the shared Playwright cache for all dev benches. COPY install-testing-tools.sh /tmp/ -RUN bash /tmp/install-testing-tools.sh && rm -f /tmp/install-testing-tools.sh +RUN --mount=type=cache,target=/root/.cache/pip \ + --mount=type=cache,target=/root/.cache/uv \ + --mount=type=cache,target=/root/.npm \ + bash /tmp/install-testing-tools.sh && rm -f /tmp/install-testing-tools.sh + +# Container-safe SonarQube/SonarCloud environment. These helpers make auth +# usable without libsecret. +COPY files/sonarqube/sonarqube-cli-env.sh /usr/local/share/sonarqube/sonarqube-cli-env.sh +COPY files/sonarqube/sonar-env /usr/local/bin/sonar-env +RUN chmod 0644 /usr/local/share/sonarqube/sonarqube-cli-env.sh \ + && chmod 0755 /usr/local/bin/sonar-env \ + && ln -sfn /usr/local/share/sonarqube/sonarqube-cli-env.sh /etc/profile.d/sonarqube-cli.sh # ======================================== # ZSH PLUGINS (into /etc/skel) @@ -140,6 +220,15 @@ RUN if [ ! -f /etc/skel/.zshrc ]; then \ # Update /etc/skel/.zshrc to include plugins RUN sed -i 's/plugins=(git)/plugins=(git zsh-autosuggestions zsh-syntax-highlighting)/' /etc/skel/.zshrc +# Source Speckit worktree helpers from global shell startup so they remain +# available even when benches mount a host ~/.zshrc over the generated one. +RUN printf '\n# DevBench Speckit worktree helpers\n[[ -f /usr/local/share/ct/ct-functions.zsh ]] && source /usr/local/share/ct/ct-functions.zsh\n' >> /etc/zsh/zshrc && \ + printf '\n# DevBench Speckit worktree helpers\n[ -f /usr/local/share/ct/ct-functions.zsh ] && . /usr/local/share/ct/ct-functions.zsh\n' >> /etc/bash.bashrc + +# Source SonarQube CLI env defaults from global interactive shell startup. +RUN printf '\n# DevBench SonarQube CLI environment\n[ -f /usr/local/share/sonarqube/sonarqube-cli-env.sh ] && . /usr/local/share/sonarqube/sonarqube-cli-env.sh\n' >> /etc/zsh/zshrc && \ + printf '\n# DevBench SonarQube CLI environment\n[ -f /usr/local/share/sonarqube/sonarqube-cli-env.sh ] && . /usr/local/share/sonarqube/sonarqube-cli-env.sh\n' >> /etc/bash.bashrc + # Make Grok available in zsh and bash shells created from /etc/skel. RUN if ! grep -q '/opt/grok/bin' /etc/skel/.zshrc; then \ echo '' >> /etc/skel/.zshrc && \ diff --git a/devBenches/base-image/build.sh b/devBenches/base-image/build.sh index 6e7b6a3..ab2e564 100755 --- a/devBenches/base-image/build.sh +++ b/devBenches/base-image/build.sh @@ -18,9 +18,11 @@ LEGACY_IMAGE="$(legacy_family_base_image dev)" cd "$SCRIPT_DIR" # Parse arguments (--user is accepted but ignored for backward compat) +NO_CACHE="${NO_CACHE:-false}" while [[ $# -gt 0 ]]; do case $1 in --user) shift 2 ;; + --no-cache) NO_CACHE=true; shift ;; *) shift ;; esac done @@ -28,6 +30,7 @@ done echo "Configuration:" echo " Tag: $CANONICAL_IMAGE (user-agnostic)" echo " Legacy alias: $LEGACY_IMAGE" +echo " No cache: $NO_CACHE" echo "" # Check if Layer 0 exists @@ -43,6 +46,7 @@ fi # Build the image echo "Building $CANONICAL_IMAGE..." docker build \ + $([ "$NO_CACHE" = true ] && printf '%s\n' "--no-cache") \ -t "$CANONICAL_IMAGE" \ . tag_family_base_legacy_alias dev diff --git a/devBenches/base-image/files/claude/commands/opsx/apply.md b/devBenches/base-image/files/claude/commands/opsx/apply.md new file mode 100644 index 0000000..3b9d1f9 --- /dev/null +++ b/devBenches/base-image/files/claude/commands/opsx/apply.md @@ -0,0 +1,226 @@ +--- +name: "OPSX: Apply" +description: Implement tasks from an OpenSpec change using agent teams for parallel execution +category: Workflow +tags: [workflow, artifacts, experimental, teams] +--- + +Implement tasks from an OpenSpec change. Uses agent teams to parallelize independent tasks across non-overlapping file groups. + +**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +--- + +## Phase 1: Select & Load Context + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` and use **AskUserQuestion** to let the user select + + Always announce: "Using change: " + +2. **Check status** + ```bash + openspec status --change "" --json + ``` + Parse `schemaName`, artifact status, and which artifact contains tasks. + +3. **Get apply instructions** + ```bash + openspec instructions apply --change "" --json + ``` + - If `state: "blocked"` (missing artifacts): show message, suggest `/opsx:propose` + - If `state: "all_done"`: congratulate, suggest `/opsx:archive` + - Otherwise: proceed + +4. **Read context files** + + Read ALL files from `contextFiles` in the apply instructions output (proposal, design, specs, tasks, clarifications if present). + +5. **Show progress** + - Schema being used + - "N/M tasks complete" + - Remaining tasks overview + +--- + +## Phase 2: Task Analysis & Grouping + +Before implementing, analyze the pending tasks to determine the execution strategy. + +### Step 1: Build the task dependency graph + +For each pending task, determine: +- **Which files it will create or modify** (infer from the task description and design.md) +- **Which tasks it depends on** (does it reference output from another task?) +- **Which tasks are independent** (no shared files, no dependency) + +### Step 2: Choose execution strategy + +**Sequential (no team)** — Use when: +- 5 or fewer pending tasks +- Most tasks depend on each other linearly +- Tasks touch overlapping files +- The user asks to go one-by-one + +**Parallel (agent team)** — Use when: +- 6+ pending tasks remaining +- Tasks can be grouped into 2+ independent clusters +- Clusters touch non-overlapping files + +If parallel, proceed to Phase 3. If sequential, skip to Phase 4. + +### Step 3: Group tasks into work packages + +Group independent tasks into **work packages**, where each package: +- Contains tasks that share related files (same service, same model, same test file) +- Has **zero file overlap** with other packages +- Has its internal tasks ordered by dependency + +Example grouping: +``` +Package A (buffer-service): Tasks 1.1, 1.2, 1.3, 2.1, 2.2 → touches buffer_service.dart, buffer_manifest.dart +Package B (packaging-service): Tasks 6.1, 6.2, 7.1, 7.2 → touches packaging_service.dart, package_metadata.dart +Package C (ui-widgets): Tasks 5.2, 5.3, 12.3 → touches orphan_dialog.dart, incomplete_dialog.dart +Package D (integration-tests): Tasks 13.1, 13.2 → touches test/ files +``` + +**Dependencies between packages**: If Package B depends on Package A completing first, mark it. Only packages with no blockers get spawned in the first wave. + +--- + +## Phase 3: Team Execution (Parallel) + +### Create the team +Use **TeamCreate** to create a team (e.g., `apply-`). + +### Create task items +Use **TaskCreate** for each work package. Include: +- All tasks in the package with their descriptions +- The files to create/modify +- Context: which design.md sections and spec scenarios are relevant +- Dependencies on other packages (use `addBlockedBy` if needed) + +### Spawn teammates +Use the team subagent mechanism to spawn one `general-purpose` teammate per +work package, in parallel. Each teammate must be launched into the team created +above, for example: + +```text +Task({ + team_name: "apply-", + name: "", + subagent_type: "general-purpose", + run_in_background: true +}) +``` + +Do not use plain one-shot Agent subagents for this phase. They cannot claim +team tasks, receive inbox messages, or participate in shutdown. Each teammate +prompt must include: + +1. The team name +2. The task ID to claim +3. Full file paths for all context files (proposal, design, specs, clarifications) +4. The specific tasks to implement, in order +5. The files they own (create/modify only these) +6. Instruction to mark tasks complete with `- [ ]` → `- [x]` in tasks.md — **but only their assigned tasks** +7. Instruction to report back when done or blocked + +**CRITICAL file ownership rules:** +- Each agent ONLY modifies files in its assigned package +- `tasks.md` checkbox updates: each agent updates ONLY its own task checkboxes +- If an agent discovers it needs to modify a file owned by another agent, it reports the dependency instead of making the change + +### Monitor & coordinate +- Wait for agents to complete or report blockers +- If an agent is blocked on another package, check if the blocking package is done +- When a wave completes, check for newly-unblocked packages and spawn the next wave +- Handle conflicts: if two agents report needing the same file, reassign one + +### Shutdown +After all packages complete: +- Send **shutdown_request** to all teammates +- **TeamDelete** to clean up + +--- + +## Phase 4: Sequential Execution (Fallback) + +For each pending task: +- Show which task is being worked on +- Make the code changes required +- Keep changes minimal and focused +- Mark task complete: `- [ ]` → `- [x]` +- Continue to next task + +**Pause if:** +- Task is unclear → ask for clarification +- Implementation reveals a design issue → suggest updating artifacts +- Error or blocker encountered → report and wait for guidance +- User interrupts + +--- + +## Phase 5: Completion + +### Show final status + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Strategy:** [Sequential | Parallel — N agents, M waves] +**Progress:** N/N tasks complete + +### Completed This Session +- [x] Task 1.1 — description +- [x] Task 1.2 — description +... + +All tasks complete! Run `/opsx:archive` to archive this change. +``` + +### On pause (issue encountered) + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** N/M tasks complete + +### Issue Encountered + + +**Options:** +1.