diff --git a/CHANGELOG.md b/CHANGELOG.md index ccf9bd1..ec61cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Optional `local-image-gen` sidecar discovery: `dyro doctor` reports PATH + presence only, and `dyro image doctor` / `dyro image install` stay off the + coding-tool catalog and Skill seats. Dyro still does not generate images. + ## 0.7.4 - 2026-08-18 - First-party Skill seats now include `dyro-executor` and `dyro-board` beside diff --git a/README.md b/README.md index 0182753..ce2b696 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,8 @@ Install recipes are built into Dyro as shell-free argv. Project files cannot supply install commands. Remote-script installers are never executed by Dyro; after confirmation it opens the official page instead. See [coding-tool catalog and guided installation](docs/tool-catalog.md). +The optional [image sidecar](docs/image-sidecar.md) is a separate CLI +(`local-image-gen`), not a home tool and not a managed seat. An OpenClaw workspace is a default working directory, not an operating-system sandbox; the onboarding prompt calls out that boundary before launch. @@ -612,10 +614,15 @@ dyro --dry-run task run API-101 | `blueprint validate` / `join` | Validate a team-owned generic blueprint and create a resumable isolated multi-repository workspace. | | `setup` / `init --discover` / `init --wizard` / `repo add/list` / `bootstrap` / `start` | Onboard a teammate without TOML edits, manage anchors, and choose a line and agent. | | `doctor` / `status` / `status --all` | Validate and display one or every registered workspace. | +| `next` | Print the one safe follow-up. A live Objective may emit `tick` or `attention`; `next.commands` stays empty. | +| `image doctor` / `image install` | Discover or guide-install the optional `local-image-gen` sidecar. Does not generate images. | | `line create/list` | Create, register, and inspect feature development lines. | | `hotfix create` | Create a hotfix line from an explicit production base. | | `changeset create/list/verify` | Pin and verify the exact clean Git heads that make up a multi-repository delivery. | +| `objective list/status/explain/tick/attention/plan` | Inspect accepted Objectives and the switch-tool briefing. Mutations stay on `objective apply`. | +| `proof list/show/verify/export/verify-bundle` | Rebind delivery Proof. `verify` rebinds the workspace; `verify-bundle` checks portable integrity only. `live` is not merge. | | `config get/set` / `agent list/add/test/discover` / `tool list/install/default/pin` / `open` | Safely manage policy, adapters, tool discovery and personal launch preferences, or open an agent in the correct line. | +| `integration status/install/sync/uninstall` | Manage first-party Skill seats (`dyro-control-plane`, `dyro-executor`, `dyro-board`, `dyro-dispatch`). Loading a seat is not consent to mutate. | | `task create/open/list/board/status/next/graph/explain/attempts/binding` | Create or enter tasks, manage state, validate the task graph, explain scheduling, inspect provenance, and output review bindings. | | `task run/answer/gates/review/signoff` | Run tasks, resolve questions, execute gates, request independent review, and record external sign-off when a Profile requires it. | | `task claim --output` / `task evidence build/execution/review` | One-time claim with a create-only runner handoff file, portable execution-evidence build/import, and receipt-bound review import. | @@ -623,9 +630,13 @@ dyro --dry-run task run API-101 | `task loop/daemon/stats/decisions` | Run controlled batches, scheduling, ledger reporting, and decision gates. | | `dispatch` | Optional local multi-agent dispatch (L0–L4); advisory only — not a substitute for gates/merge. | +First-party seats are installed with `dyro integration`. Proof is rebound with +`dyro proof verify`; it does not replay gates. + See the [architecture and Profile contract](docs/architecture.md), the [workspace blueprint contract](docs/workspace-blueprints.md), the [existing control-plane migration guide](docs/migrating-existing-control-planes.md), +the [optional image sidecar](docs/image-sidecar.md), and the [PyPI publishing runbook](docs/publishing.md) (maintainers) for implementation detail. ## Languages and documentation @@ -639,3 +650,7 @@ DyroEngineeringFlow provides a complete local workflow loop and policy controls ### Graph Engineering (optional reading) Some discussions call multi-node agent/work topologies **Graph Engineering** (as opposed to a single-agent loop). Dyro’s delivery topology is close in substance—TaskGraph, state machine, gates, review, merge, plus the optional `dispatch` subgraph—but the product identity remains a **delivery control plane**, not an agent-orchestration framework or a knowledge-graph/RAG stack. Dispatch output is advisory. See [architecture](docs/architecture.md#与-graph-engineering-的关系可选读). + +## Related sibling + +[`local-image-gen`](https://github.com/DandreYang/local-image-gen) is an optional first-party image CLI. Same house, not the same product: it is not a Dyro coding tool and not a managed Skill seat. Installing Dyro does not install it. See the [image sidecar](docs/image-sidecar.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 20f2493..146fa60 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -415,6 +415,8 @@ dyro tool install openclaw 安装配方是 Dyro 内置的无 shell argv,项目文件不能提供安装命令。对于需要执行 远程脚本的官方安装方式,Dyro 不会代为执行,只会在确认后打开官方页面。详见 [编码工具目录与安装引导](docs/tool-catalog.md)。 +可选的[生图 sidecar](docs/image-sidecar.md) 是独立 CLI(`local-image-gen`), +不是首页编码工具,也不是托管座位。 OpenClaw 工作区是默认工作目录,并不是操作系统级沙箱;初始化前会明确提示这一边界。 也可以显式登记、切换和查看所有项目;这些命令只管理全局入口,不会移动或删除项目: @@ -550,10 +552,15 @@ dyro --dry-run task run API-101 | `blueprint validate` / `join` | 验证团队自有的通用蓝图,并创建可续跑的隔离多仓工作区。 | | `setup` / `init --discover` / `init --wizard` / `repo add/list` / `bootstrap` / `start` | 无需手改 TOML 地完成新人引导、仓库管理与开发线、Agent 选择。 | | `doctor` / `status` / `status --all` | 验证并显示当前或全部已登记工作区状态。 | +| `next` | 打印当前工作区唯一安全的下一步。活着的 Objective 可能直接给出 `tick` 或 `attention`;`next.commands` 保持为空。 | +| `image doctor` / `image install` | 发现或引导安装可选的 `local-image-gen` sidecar。不代跑出图。 | | `line create/list` | 创建、登记和查看功能开发线。 | | `hotfix create` | 从显式生产基线创建 Hotfix 开发线。 | | `changeset create/list/verify` | 固化并核验一次多仓交付所包含的干净、精确 Git 提交组合。 | +| `objective list/status/explain/tick/attention/plan` | 查看已接受的 Objective 与换工具开场白。写操作在 `objective apply`。 | +| `proof list/show/verify/export/verify-bundle` | 重绑交付 Proof。`verify` 重绑当前工作区;`verify-bundle` 只核便携完整性。`live` 不是 merge。 | | `config get/set` / `agent list/add/test/discover` / `tool list/install/default/pin` / `open` | 安全管理策略、adapter、工具发现与个人启动偏好,或在正确开发线启动 Agent。 | +| `integration status/install/sync/uninstall` | 管理第一方座位 Skill(`dyro-control-plane`、`dyro-executor`、`dyro-board`、`dyro-dispatch`)。加载座位不是同意改世界。 | | `task create/open/list/board/status/next/graph/explain/attempts/binding` | 创建或进入任务、管理状态,编译/校验任务图,解释调度,查看 provenance,输出精确复核绑定。 | | `task run/answer/gates/review/signoff` | 执行任务、回答追问、运行门禁、申请独立复核;需要时记录外部签收。 | | `task claim --output` / `task evidence build/execution/review` | 一次性领取任务并以“仅创建”文件交给隔离执行器,构建/导入可移植执行证据包,并导入与回执绑定的复核证据。 | @@ -561,7 +568,9 @@ dyro --dry-run task run API-101 | `task loop/daemon/stats/decisions` | 受控批处理、调度、台账报表和决策门禁。 | | `dispatch` | 可选本地多 Agent 派发(L0–L4);仅建议,不替代 gates/merge。 | -实现细节见[零摩擦全局首页 ADR](docs/adr/0003-zero-friction-global-home.md)、[架构与 Profile 契约](docs/architecture.md)、[工作区蓝图契约](docs/workspace-blueprints.md)、[既有控制面迁移指南](docs/migrating-existing-control-planes.md),以及维护者用的 [PyPI 发布说明](docs/publishing.md)。 +第一方座位用 `dyro integration` 安装。Proof 用 `dyro proof verify` 重绑,不重跑 gates。 + +实现细节见[零摩擦全局首页 ADR](docs/adr/0003-zero-friction-global-home.md)、[架构与 Profile 契约](docs/architecture.md)、[工作区蓝图契约](docs/workspace-blueprints.md)、[既有控制面迁移指南](docs/migrating-existing-control-planes.md)、[可选生图 sidecar](docs/image-sidecar.md),以及维护者用的 [PyPI 发布说明](docs/publishing.md)。 ## 语言与文档 @@ -574,3 +583,7 @@ DyroEngineeringFlow 提供完整的本地工作流闭环,以及让高保障团 ### 与 Graph Engineering 的关系(可选读) 行业里有时把「多节点 + 路由/并行 + 校验」的工作拓扑称作 **Graph Engineering**(相对单 agent loop)。Dyro 的交付拓扑与之实质相近(TaskGraph、状态机、gates、复核、merge,以及可选的 dispatch 子图),但产品身份仍是 **交付控制面**,不是 agent 编排框架,也不是 Knowledge Graph / GraphRAG。dispatch 仅为建议。详见[架构文档](docs/architecture.md#与-graph-engineering-的关系可选读)。 + +## 相关兄弟项目 + +[`local-image-gen`](https://github.com/DandreYang/local-image-gen) 是可选的第一方生图 CLI。同屋不是同一产品:它不是 Dyro 编码工具,也不是托管座位。安装 Dyro 不会顺便装上它。详见[生图 sidecar](docs/image-sidecar.md)。 diff --git a/docs/architecture.md b/docs/architecture.md index aa3f254..52d79b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,6 +8,7 @@ DyroEngineeringFlow Core(`dyro` CLI) ├─ home: 全局工作区入口、最近目标、任意目录导航(无交付权限) ├─ workspace: anchors、逐仓基线、开发线、Hotfix、存储模式、doctor + ├─ image sidecar: 可选的 `local-image-gen` 发现与安装引导(不是座位,也不是编码工具) ├─ launch: Agent adapter 的安全 argv 模板 ├─ dispatch: 任务 DAG、决策点、冲突组、状态机、回执、复核与外部签收 ├─ verify: gates、日志、台账和统计 diff --git a/docs/image-sidecar.md b/docs/image-sidecar.md new file mode 100644 index 0000000..7eebc4f --- /dev/null +++ b/docs/image-sidecar.md @@ -0,0 +1,69 @@ +# Optional image sidecar (`local-image-gen`) + +`local-image-gen` is a sibling first-party CLI. It is **not** a Dyro coding tool, +**not** a managed Skill seat, and **not** part of Objective / Change Set / gates +/ merge / push. Dyro only helps a person discover it and read a normalized +health report. Image generation stays on the upstream command. + +Official source: + +## What Dyro does + +| Command | What it is allowed to do | +| --- | --- | +| `dyro doctor` | Cheap `PATH` lookup for the `local-image-gen` wrapper. JSON adds `sidecars.local_image_gen.state` = `absent` or `present`. Missing sidecar never fails the workspace. | +| `dyro image doctor` | The only command that may spawn `local-image-gen --doctor`. Reports `absent`, `needs_setup`, `ready`, or `unavailable`. | +| `dyro image install` | Prints the official repository and `install.sh` URL. `--yes` opens the GitHub page. Dyro never runs a remote install script. | +| `local-image-gen …` | Actual generation. Dyro does not wrap billed generate. | + +```bash +dyro doctor --format json +dyro image doctor --format json +dyro --dry-run image doctor +dyro --dry-run image install +dyro image install --yes +``` + +`dyro --dry-run doctor` and `dyro --dry-run image doctor` must not spawn the +sidecar. `dyro --dry-run image install` must not open a browser. + +Do **not** install this through `dyro tool install`. Home / `dyro open` stay +coding-tool launchers. + +## Workspace output + +When a Dyro workspace (`dyro.toml`) is an ancestor and the user omits +`-o` / `--out-dir`, `local-image-gen` writes to `/outputs/images/`. +Those files are generated artifacts: they are not Proof, they do not belong in +`repositories/`, and they are not a task worktree. Workspace `doctor()` does +not treat `outputs/images/` as structural damage. + +The Codex image path is experimental on the upstream side. Confirm current +status in that repository before relying on it. + +## What the navigator seat must not do + +`dyro-control-plane` does not run `dyro image`. Isolated Console does not +allowlist `image doctor` or `image install` (`install --yes` opens a browser). +Personal skill directories are never scanned to discover this sidecar. Only the +PATH wrapper named `local-image-gen` counts as installed. + +## Normalized `dyro image doctor` JSON + +Dyro does not pass through the upstream `--doctor` document. Default output +omits local paths, login files, `api_base`, and secrets. `--include-paths` +may add `output_dir` and `workspace` only. + +```json +{ + "id": "local-image-gen", + "optional": true, + "state": "absent", + "version": "0.1.0", + "usable_providers": ["grok", "codex"] +} +``` + +`ready` means the upstream report succeeded and at least one provider has a +subscription or API key. A wrapper on PATH with no backend is `needs_setup`, +not a workspace error. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index f8f05be..7719ad8 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -119,3 +119,10 @@ project. The user must confirm onboarding separately. The selected workspace is a default working directory, not an operating-system sandbox; OpenClaw can still reach other paths allowed to the current user unless its own sandboxing is configured. + +## Out of catalog + +`local-image-gen` is an optional sidecar, not a coding tool. Do not add it to +`TOOL_DEFINITIONS` or Skill seats. Discover it with `dyro doctor` / `dyro image +doctor`, and install it from the official repository. See +[image sidecar](image-sidecar.md). diff --git a/src/dyro/cli.py b/src/dyro/cli.py index 47c153b..a4e6f7a 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -131,6 +131,14 @@ remove_workspace, set_default_workspace, ) +from .image_sidecar import ( + ABSENT_INFO_LINE, + SOURCE_URL, + discover_sidecar, + install_image_sidecar, + probe_sidecar, + require_interactive_install, +) from .integrations import ( INTEGRATION_CHOICES, IntegrationState, @@ -497,6 +505,7 @@ def _control_plane_command(args: argparse.Namespace) -> str: "command", "workspace_command", "integration_command", + "image_command", "line_command", "changeset_command", "objective_command", @@ -526,6 +535,7 @@ def _control_plane_error_code( return { "changeset": "CHANGESET_UNAVAILABLE", "doctor": "WORKSPACE_UNHEALTHY", + "image": "SIDECAR_UNREADABLE", "integration": "INTEGRATION_UNAVAILABLE", "line": "LINE_UNAVAILABLE", "next": "NEXT_STEP_UNAVAILABLE", @@ -1547,6 +1557,7 @@ def cmd_doctor(args: argparse.Namespace) -> None: budget = _control_plane_budget(args) if args.format == "json" else None findings = doctor(config, read_budget=budget) failures = [item for item in findings if item.startswith("FAIL")] + sidecar = discover_sidecar() if args.format == "json": _print_control_plane_json( "doctor", @@ -1556,6 +1567,7 @@ def cmd_doctor(args: argparse.Namespace) -> None: _doctor_finding_payload(item, include_paths=args.include_paths) for item in findings ], + sidecars={"local_image_gen": sidecar.as_dict()}, ) if failures: raise SystemExit(2) @@ -1564,11 +1576,64 @@ def cmd_doctor(args: argparse.Namespace) -> None: print(muted(f"Profile:{config.name} · 检查仓库、基线与隔离工作区。")) for finding in findings: _print_doctor_finding(finding) + if sidecar.state == "absent": + print(ABSENT_INFO_LINE) if failures: raise DyroError("doctor 发现结构错误") print("\n" + success("检查通过。") + " 下一步:" + terminal_value("dyro")) +def cmd_image_doctor(args: argparse.Namespace) -> None: + if args.dry_run: + presence = discover_sidecar() + if args.format == "json": + _print_control_plane_json("image_doctor", **presence.as_dict()) + return + print("DRY RUN: 未探测 local-image-gen") + if presence.state == "absent": + print(ABSENT_INFO_LINE) + else: + print("PATH 上已有 local-image-gen;未查询后端。") + return + probe = probe_sidecar() + if args.format == "json": + _print_control_plane_json( + "image_doctor", + **probe.as_dict(include_paths=args.include_paths), + ) + if probe.state == "unavailable": + raise SystemExit(2) + return + print("\n" + title("━━ local-image-gen ━━")) + if probe.state == "absent": + print(ABSENT_INFO_LINE) + print("下一步:" + terminal_value("dyro image install")) + return + if probe.state == "ready": + backends = "、".join(probe.usable_providers) or "-" + print(success("状态:ready") + (f" · {probe.version}" if probe.version else "")) + print(f"可用后端:{backends}") + print("下一步:直接运行 " + terminal_value("local-image-gen") + "。Dyro 不代跑出图。") + return + if probe.state == "needs_setup": + print(muted("状态:needs_setup")) + print(probe.message or "已安装 local-image-gen,但没有可用订阅或 API key。") + print(f"来源:{SOURCE_URL}") + print("下一步:按上游文档登录或配置密钥后,再运行 " + terminal_value("dyro image doctor")) + return + print(danger(probe.message or "sidecar 不可读")) + raise SystemExit(2) + + +def cmd_image_install(args: argparse.Namespace) -> None: + require_interactive_install( + yes=args.yes, + dry_run=args.dry_run, + tty=sys.stdin.isatty() and sys.stdout.isatty(), + ) + install_image_sidecar(yes=args.yes, dry_run=args.dry_run) + + def cmd_terminology_check(args: argparse.Namespace) -> None: root = ( _config(args).root @@ -4086,6 +4151,39 @@ def build_parser() -> argparse.ArgumentParser: help="在 JSON 中显式包含本机诊断路径", ) doctor_parser.set_defaults(func=cmd_doctor) + image = sub.add_parser( + "image", + help="发现并引导安装可选的 local-image-gen sidecar;不代跑计费出图", + ) + image_sub = image.add_subparsers(dest="image_command", required=True) + image_doctor = image_sub.add_parser( + "doctor", + help="探测 local-image-gen 是否在 PATH,以及是否有可用后端", + ) + image_doctor.add_argument( + "--format", choices=("text", "json"), default="text" + ) + image_doctor.add_argument( + "--include-paths", + action="store_true", + help="在 JSON 中显式包含本机产出目录与工作区路径", + ) + image_doctor.set_defaults(func=cmd_image_doctor) + image_install = image_sub.add_parser( + "install", + help="展示官方安装来源;不会执行远程安装脚本", + ) + image_install.add_argument( + "--yes", action="store_true", help="确认后打开官方仓库页面" + ) + image_install.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + default=argparse.SUPPRESS, + help="仅展示安装来源,不打开浏览器;也兼容全局 --dry-run", + ) + image_install.set_defaults(func=cmd_image_install) terminology = sub.add_parser("terminology", help="使用仓库外策略扫描候选术语") terminology_sub = terminology.add_subparsers( dest="terminology_command", required=True diff --git a/src/dyro/image_sidecar.py b/src/dyro/image_sidecar.py new file mode 100644 index 0000000..ef4956b --- /dev/null +++ b/src/dyro/image_sidecar.py @@ -0,0 +1,252 @@ +"""Optional local-image-gen discovery. Not a coding tool and not a Skill seat. + +``dyro doctor`` may only ask whether the PATH wrapper exists. The only process +that may spawn ``local-image-gen --doctor`` is ``dyro image doctor``. Dyro never +wraps billed image generation. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import shutil +import subprocess +from typing import Callable +import webbrowser + +from .errors import DyroError + + +SIDECAR_ID = "local-image-gen" +WRAPPER_NAME = "local-image-gen" +SOURCE_URL = "https://github.com/DandreYang/local-image-gen" +INSTALL_SCRIPT_URL = ( + "https://raw.githubusercontent.com/DandreYang/local-image-gen/main/install.sh" +) +DOCTOR_TIMEOUT_SECONDS = 5 +ABSENT_INFO_LINE = ( + f"未安装 {SIDECAR_ID}(可选)。来源:{SOURCE_URL}" +) + +PresenceState = str +ProbeState = str + + +@dataclass(frozen=True) +class SidecarPresence: + """Cheap PATH discovery used by workspace ``dyro doctor``.""" + + id: str = SIDECAR_ID + optional: bool = True + state: PresenceState = "absent" + + def as_dict(self) -> dict[str, object]: + return {"id": self.id, "optional": self.optional, "state": self.state} + + +@dataclass(frozen=True) +class SidecarProbe: + """Normalized ``local-image-gen --doctor`` result. Never a raw passthrough.""" + + id: str = SIDECAR_ID + optional: bool = True + state: ProbeState = "absent" + version: str | None = None + usable_providers: tuple[str, ...] = () + output_dir: str | None = None + workspace: str | None = None + message: str = "" + + def as_dict(self, *, include_paths: bool = False) -> dict[str, object]: + payload: dict[str, object] = { + "id": self.id, + "optional": self.optional, + "state": self.state, + } + if self.version is not None: + payload["version"] = self.version + if self.state != "absent": + payload["usable_providers"] = list(self.usable_providers) + if include_paths: + if self.output_dir: + payload["output_dir"] = self.output_dir + if self.workspace: + payload["workspace"] = self.workspace + return payload + + +def which_wrapper(name: str = WRAPPER_NAME) -> str | None: + return shutil.which(name) + + +def run_sidecar_doctor( + executable: str, *, timeout: float = DOCTOR_TIMEOUT_SECONDS +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + (executable, "--doctor"), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + text=True, + timeout=timeout, + ) + + +def open_source_url(url: str) -> bool: + return webbrowser.open(url) + + +def discover_sidecar( + *, which: Callable[[str], str | None] | None = None +) -> SidecarPresence: + """Return absent/present from PATH. Never spawn the sidecar.""" + + lookup = which or which_wrapper + try: + found = lookup(WRAPPER_NAME) + except OSError: + found = None + return SidecarPresence(state="present" if found else "absent") + + +def probe_sidecar( + *, + which: Callable[[str], str | None] | None = None, + run: Callable[..., subprocess.CompletedProcess[str]] | None = None, + timeout: float = DOCTOR_TIMEOUT_SECONDS, +) -> SidecarProbe: + """Spawn ``local-image-gen --doctor`` once and normalize the envelope.""" + + lookup = which or which_wrapper + try: + executable = lookup(WRAPPER_NAME) + except OSError: + executable = None + if not executable: + return SidecarProbe(state="absent", message=ABSENT_INFO_LINE) + runner = run or run_sidecar_doctor + try: + completed = runner(executable, timeout=timeout) + except subprocess.TimeoutExpired: + return SidecarProbe(state="unavailable", message="sidecar 不可读:探测超时") + except (OSError, subprocess.SubprocessError, TypeError): + return SidecarProbe(state="unavailable", message="sidecar 不可读") + if completed.returncode != 0: + return SidecarProbe(state="unavailable", message="sidecar 不可读") + return normalize_doctor_json(completed.stdout) + + +def normalize_doctor_json(raw: object) -> SidecarProbe: + """Accept exactly one JSON object. Missing fields are unknown, not fatal.""" + + if not isinstance(raw, str): + return SidecarProbe(state="unavailable", message="sidecar 不可读") + text = raw.strip() + if not text: + return SidecarProbe(state="unavailable", message="sidecar 不可读") + try: + payload = json.loads(text) + except json.JSONDecodeError: + return SidecarProbe(state="unavailable", message="sidecar 不可读") + if not isinstance(payload, dict): + return SidecarProbe(state="unavailable", message="sidecar 不可读") + + version = payload.get("version") + version_text = version if isinstance(version, str) and version else None + usable = _usable_providers(payload.get("providers")) + output_dir, workspace = _optional_paths(payload.get("dyro")) + success = payload.get("success") + if success is True and usable: + state: ProbeState = "ready" + message = "" + elif success is False: + state = "unavailable" + message = "sidecar 不可读" + else: + state = "needs_setup" + message = "已安装 local-image-gen,但没有可用订阅或 API key。" + return SidecarProbe( + state=state, + version=version_text, + usable_providers=usable, + output_dir=output_dir, + workspace=workspace, + message=message, + ) + + +def render_install_guide() -> str: + return "\n".join( + ( + f"未检测到 {SIDECAR_ID}", + f"官方来源:{SOURCE_URL}", + f"安装脚本:{INSTALL_SCRIPT_URL}", + "安装范围:用户 PATH 上的包装命令(不是编码工具,也不是托管座位)", + "安装方式:打开官方安装页面", + "安全说明:Dyro 不会代为执行远程安装脚本", + "安装之后:确认 PATH 上有 local-image-gen 后运行 dyro image doctor", + ) + ) + + +def install_image_sidecar( + *, + yes: bool, + dry_run: bool, + ask: Callable[[str], str] | None = None, + open_url: Callable[[str], bool] | None = None, +) -> bool: + """Print the official source. Never download or execute install.sh.""" + + ask = ask or input + opener = open_url or open_source_url + print(render_install_guide()) + if dry_run: + print("DRY RUN: 未安装、未打开浏览器") + return False + if not yes: + confirmed = ask("是否继续?[y/N]:").strip().lower() + if confirmed not in {"y", "yes"}: + print("已取消;没有安装任何工具。") + return False + if not opener(SOURCE_URL): + print(f"未能自动打开浏览器,请手动访问:{SOURCE_URL}") + else: + print("已打开官方安装页面;安装完成后重新运行 dyro image doctor。") + return False + + +def require_interactive_install(*, yes: bool, dry_run: bool, tty: bool) -> None: + if not yes and not dry_run and not tty: + raise DyroError( + "非交互环境不会打开安装页面;请在终端中运行,或审阅计划后显式添加 --yes" + ) + + +def _usable_providers(raw: object) -> tuple[str, ...]: + if not isinstance(raw, list): + return () + names: list[str] = [] + seen: set[str] = set() + for item in raw: + if not isinstance(item, dict): + continue + name = item.get("provider") + if not isinstance(name, str) or not name or name in seen: + continue + if item.get("subscription") is True or item.get("api_key") is True: + seen.add(name) + names.append(name) + return tuple(names) + + +def _optional_paths(raw: object) -> tuple[str | None, str | None]: + if not isinstance(raw, dict): + return None, None + output_dir = raw.get("output_dir") + workspace = raw.get("workspace") + return ( + output_dir if isinstance(output_dir, str) and output_dir else None, + workspace if isinstance(workspace, str) and workspace else None, + ) diff --git a/tests/test_image_sidecar.py b/tests/test_image_sidecar.py new file mode 100644 index 0000000..4ea01c8 --- /dev/null +++ b/tests/test_image_sidecar.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from dyro.cli import main +from dyro.config import load +from dyro.console.inspection import IsolatedOverviewService +from dyro.errors import DyroError +from dyro.image_sidecar import ( + ABSENT_INFO_LINE, + INSTALL_SCRIPT_URL, + SIDECAR_ID, + SOURCE_URL, + discover_sidecar, + install_image_sidecar, + normalize_doctor_json, + probe_sidecar, + require_interactive_install, +) +from dyro.integrations.manager import SKILL_INTEGRATIONS +from dyro.tooling import TOOL_DEFINITIONS +from dyro.workspace import doctor + +from .support import WorkspaceCase + + +READY_UPSTREAM = { + "success": True, + "command": "doctor", + "version": "0.1.0", + "cli": "local-image-gen", + "harness": "grok", + "dyro": { + "optional": True, + "cli": "dyro 0.7.4", + "workspace": "/secret/workspace", + "workspace_name": "demo", + "output_dir": "/secret/workspace/outputs/images", + }, + "providers": [ + { + "provider": "grok", + "subscription": True, + "api_key": False, + "login": "/Users/secret/.grok/auth.json", + "api_base": "https://example.invalid/v1", + "default_model": "grok-imagine-image-2.0", + }, + { + "provider": "codex", + "subscription": False, + "api_key": True, + }, + ], +} + +NEEDS_SETUP_UPSTREAM = { + "success": True, + "command": "doctor", + "version": "0.1.0", + "providers": [ + { + "provider": "grok", + "subscription": False, + "api_key": False, + } + ], +} + + +def _which_present(name: str) -> str | None: + return f"/fake/{name}" if name == SIDECAR_ID else None + + +def _which_absent(name: str) -> str | None: + return None + + +def _completed(payload: object, *, returncode: int = 0) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + (SIDECAR_ID, "--doctor"), + returncode, + stdout=json.dumps(payload) if not isinstance(payload, str) else payload, + stderr="", + ) + + +class NormalizeDoctorTests(unittest.TestCase): + def test_ready_requires_success_and_a_usable_backend(self) -> None: + probe = normalize_doctor_json(json.dumps(READY_UPSTREAM)) + self.assertEqual(probe.state, "ready") + self.assertEqual(probe.version, "0.1.0") + self.assertEqual(probe.usable_providers, ("grok", "codex")) + default = probe.as_dict() + self.assertEqual(default["state"], "ready") + self.assertNotIn("output_dir", default) + self.assertNotIn("workspace", default) + self.assertNotIn("login", json.dumps(default)) + self.assertNotIn("api_base", json.dumps(default)) + with_paths = probe.as_dict(include_paths=True) + self.assertEqual(with_paths["output_dir"], "/secret/workspace/outputs/images") + self.assertEqual(with_paths["workspace"], "/secret/workspace") + self.assertNotIn("login", json.dumps(with_paths)) + + def test_path_present_without_backend_is_needs_setup(self) -> None: + probe = normalize_doctor_json(json.dumps(NEEDS_SETUP_UPSTREAM)) + self.assertEqual(probe.state, "needs_setup") + self.assertEqual(probe.usable_providers, ()) + + def test_malformed_or_failed_payload_is_unavailable(self) -> None: + self.assertEqual(normalize_doctor_json("not-json").state, "unavailable") + self.assertEqual(normalize_doctor_json("[]").state, "unavailable") + self.assertEqual( + normalize_doctor_json(json.dumps({"success": False, "providers": []})).state, + "unavailable", + ) + + def test_missing_fields_do_not_raise(self) -> None: + probe = normalize_doctor_json("{}") + self.assertEqual(probe.state, "needs_setup") + self.assertIsNone(probe.version) + self.assertEqual(probe.usable_providers, ()) + + +class DiscoverAndProbeTests(unittest.TestCase): + def test_discover_is_which_only(self) -> None: + def boom(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + raise AssertionError("workspace doctor must not spawn local-image-gen") + + self.assertEqual(discover_sidecar(which=_which_absent).state, "absent") + self.assertEqual(discover_sidecar(which=_which_present).state, "present") + probe = probe_sidecar(which=_which_absent, run=boom) + self.assertEqual(probe.state, "absent") + + def test_probe_normalizes_spawned_json_and_records_timeouts(self) -> None: + def run_ready(executable: str, **_: object) -> subprocess.CompletedProcess[str]: + self.assertEqual(executable, "/fake/local-image-gen") + return _completed(READY_UPSTREAM) + + def run_timeout(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + raise subprocess.TimeoutExpired((SIDECAR_ID, "--doctor"), 5) + + ready = probe_sidecar(which=_which_present, run=run_ready) + self.assertEqual(ready.state, "ready") + self.assertEqual(ready.usable_providers, ("grok", "codex")) + timed_out = probe_sidecar(which=_which_present, run=run_timeout) + self.assertEqual(timed_out.state, "unavailable") + self.assertIn("超时", timed_out.message) + + +class InstallGuideTests(unittest.TestCase): + def test_dry_run_and_yes_never_execute_a_remote_script(self) -> None: + opened: list[str] = [] + output = StringIO() + with redirect_stdout(output): + self.assertFalse( + install_image_sidecar( + yes=True, + dry_run=True, + open_url=lambda url: opened.append(url) or True, + ) + ) + rendered = output.getvalue() + self.assertIn(SOURCE_URL, rendered) + self.assertIn(INSTALL_SCRIPT_URL, rendered) + self.assertIn("Dyro 不会代为执行远程安装脚本", rendered) + self.assertIn("DRY RUN", rendered) + self.assertEqual(opened, []) + + output = StringIO() + with redirect_stdout(output): + self.assertFalse( + install_image_sidecar( + yes=True, + dry_run=False, + open_url=lambda url: opened.append(url) or True, + ) + ) + self.assertEqual(opened, [SOURCE_URL]) + self.assertNotIn("curl", output.getvalue()) + self.assertNotIn("bash", output.getvalue()) + + def test_noninteractive_install_requires_yes_or_dry_run(self) -> None: + with self.assertRaisesRegex(DyroError, "非交互环境"): + require_interactive_install(yes=False, dry_run=False, tty=False) + require_interactive_install(yes=True, dry_run=False, tty=False) + require_interactive_install(yes=False, dry_run=True, tty=False) + + +class ImageCliTests(WorkspaceCase): + def setUp(self) -> None: + super().setUp() + self.registry_tmp = tempfile.TemporaryDirectory(prefix="dyro-image-home-") + self.registry_environment = patch.dict( + os.environ, {"DYRO_HOME": self.registry_tmp.name}, clear=False + ) + self.registry_environment.start() + + def tearDown(self) -> None: + self.registry_environment.stop() + self.registry_tmp.cleanup() + super().tearDown() + + def _json(self, argv: list[str]) -> dict[str, object]: + output = StringIO() + with redirect_stdout(output): + main(argv) + return json.loads(output.getvalue()) + + def test_workspace_doctor_json_is_presence_only_and_does_not_spawn(self) -> None: + def boom(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + raise AssertionError("dyro doctor must not spawn local-image-gen") + + with ( + patch("dyro.image_sidecar.which_wrapper", side_effect=_which_absent), + patch("dyro.image_sidecar.run_sidecar_doctor", side_effect=boom), + ): + payload = self._json( + ["--root", str(self.root), "doctor", "--format", "json"] + ) + sidecar = payload["sidecars"]["local_image_gen"] + self.assertTrue(payload["passed"]) + self.assertEqual(sidecar["id"], SIDECAR_ID) + self.assertTrue(sidecar["optional"]) + self.assertEqual(sidecar["state"], "absent") + self.assertNotIn("usable_providers", sidecar) + self.assertNotIn("version", sidecar) + + with ( + patch("dyro.image_sidecar.which_wrapper", side_effect=_which_present), + patch("dyro.image_sidecar.run_sidecar_doctor", side_effect=boom), + ): + payload = self._json( + ["--root", str(self.root), "doctor", "--format", "json"] + ) + self.assertEqual(payload["sidecars"]["local_image_gen"]["state"], "present") + + def test_workspace_doctor_text_absent_line_is_not_a_finding(self) -> None: + output = StringIO() + with ( + patch("dyro.image_sidecar.which_wrapper", side_effect=_which_absent), + redirect_stdout(output), + ): + main(["--root", str(self.root), "doctor"]) + rendered = output.getvalue() + self.assertIn(ABSENT_INFO_LINE, rendered) + self.assertNotIn(f"FAIL {ABSENT_INFO_LINE}", rendered) + self.assertNotIn(f"WARN {ABSENT_INFO_LINE}", rendered) + self.assertFalse(any(line.startswith("FAIL") and SIDECAR_ID in line for line in rendered.splitlines())) + self.assertFalse(any(line.startswith("WARN") and SIDECAR_ID in line for line in rendered.splitlines())) + + def test_dry_run_doctor_and_image_doctor_never_spawn(self) -> None: + def boom(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + raise AssertionError("dry-run must not spawn local-image-gen") + + with ( + patch("dyro.image_sidecar.which_wrapper", side_effect=_which_present), + patch("dyro.image_sidecar.run_sidecar_doctor", side_effect=boom), + ): + workspace = self._json( + ["--root", str(self.root), "--dry-run", "doctor", "--format", "json"] + ) + image = self._json(["--dry-run", "image", "doctor", "--format", "json"]) + self.assertEqual(workspace["sidecars"]["local_image_gen"]["state"], "present") + self.assertEqual(image["kind"], "image_doctor") + self.assertEqual(image["state"], "present") + self.assertNotIn("usable_providers", image) + + def test_image_doctor_ready_needs_setup_and_absent(self) -> None: + def run_ready(executable: str, **_: object) -> subprocess.CompletedProcess[str]: + return _completed(READY_UPSTREAM) + + def run_setup(executable: str, **_: object) -> subprocess.CompletedProcess[str]: + return _completed(NEEDS_SETUP_UPSTREAM) + + with ( + patch("dyro.image_sidecar.which_wrapper", side_effect=_which_present), + patch("dyro.image_sidecar.run_sidecar_doctor", side_effect=run_ready), + ): + ready = self._json(["image", "doctor", "--format", "json"]) + self.assertEqual(ready["state"], "ready") + self.assertEqual(ready["usable_providers"], ["grok", "codex"]) + dumped = json.dumps(ready) + self.assertNotIn("/secret", dumped) + self.assertNotIn("api_base", dumped) + self.assertNotIn("login", dumped) + + with ( + patch("dyro.image_sidecar.which_wrapper", side_effect=_which_present), + patch("dyro.image_sidecar.run_sidecar_doctor", side_effect=run_ready), + ): + with_paths = self._json( + ["image", "doctor", "--format", "json", "--include-paths"] + ) + self.assertEqual(with_paths["output_dir"], "/secret/workspace/outputs/images") + + with ( + patch("dyro.image_sidecar.which_wrapper", side_effect=_which_present), + patch("dyro.image_sidecar.run_sidecar_doctor", side_effect=run_setup), + ): + setup = self._json(["image", "doctor", "--format", "json"]) + self.assertEqual(setup["state"], "needs_setup") + self.assertEqual(setup["usable_providers"], []) + + with patch("dyro.image_sidecar.which_wrapper", side_effect=_which_absent): + absent = self._json(["image", "doctor", "--format", "json"]) + self.assertEqual(absent["state"], "absent") + + def test_image_doctor_unavailable_exits_nonzero_without_workspace_damage(self) -> None: + def run_bad(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + return _completed("not-json") + + stderr = StringIO() + with ( + patch("dyro.image_sidecar.which_wrapper", side_effect=_which_present), + patch("dyro.image_sidecar.run_sidecar_doctor", side_effect=run_bad), + redirect_stdout(StringIO()) as stdout, + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main(["image", "doctor", "--format", "json"]) + self.assertEqual(raised.exception.code, 2) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["state"], "unavailable") + self.assertEqual(payload["kind"], "image_doctor") + + def test_image_install_dry_run_does_not_open_browser(self) -> None: + opened: list[str] = [] + output = StringIO() + with ( + patch("dyro.image_sidecar.open_source_url", side_effect=opened.append), + redirect_stdout(output), + ): + main(["--dry-run", "image", "install"]) + main(["image", "install", "--dry-run"]) + self.assertEqual(opened, []) + self.assertIn("DRY RUN", output.getvalue()) + self.assertIn(SOURCE_URL, output.getvalue()) + + def test_image_install_yes_opens_github_only(self) -> None: + opened: list[str] = [] + output = StringIO() + with ( + patch( + "dyro.image_sidecar.open_source_url", + side_effect=lambda url: opened.append(url) or True, + ), + redirect_stdout(output), + ): + main(["image", "install", "--yes"]) + self.assertEqual(opened, [SOURCE_URL]) + self.assertNotIn("curl", output.getvalue()) + self.assertNotIn("| bash", output.getvalue()) + + def test_tool_list_and_home_catalog_exclude_sidecar(self) -> None: + ids = {definition.id for definition in TOOL_DEFINITIONS} + commands = {definition.command for definition in TOOL_DEFINITIONS} + self.assertNotIn(SIDECAR_ID, ids) + self.assertNotIn(SIDECAR_ID, commands) + self.assertNotIn("image", ids) + integration_ids = {spec.integration_id for spec in SKILL_INTEGRATIONS} + self.assertNotIn("image", integration_ids) + self.assertNotIn(SIDECAR_ID, integration_ids) + output = StringIO() + with redirect_stdout(output): + main(["--root", str(self.root), "tool", "list"]) + self.assertNotIn(SIDECAR_ID, output.getvalue()) + self.assertNotIn("local-image-gen", output.getvalue()) + + def test_control_plane_skill_does_not_teach_image(self) -> None: + skill = ( + Path(__file__).resolve().parents[1] + / "src" + / "dyro" + / "integrations" + / "assets" + / "dyro-control-plane" + / "SKILL.md" + ) + content = skill.read_text(encoding="utf-8") + self.assertNotIn("`image`", content) + self.assertNotIn("dyro image", content) + for forbidden_action in ("`console`", "`dispatch`", "`task gates`"): + self.assertIn(forbidden_action, content) + + def test_isolated_console_rejects_image_commands(self) -> None: + self.assertFalse( + IsolatedOverviewService._safe_command( + "dyro --workspace demo image doctor", "demo" + ) + ) + self.assertFalse( + IsolatedOverviewService._safe_command( + "dyro --workspace demo image install --yes", "demo" + ) + ) + + def test_outputs_images_does_not_change_structural_fail(self) -> None: + config = load(self.root) + before = [item for item in doctor(config) if item.startswith("FAIL")] + output = self.root / "outputs" / "images" + output.mkdir(parents=True) + output.joinpath("demo.png").write_bytes(b"not-a-real-image") + after = [item for item in doctor(config) if item.startswith("FAIL")] + self.assertEqual(before, after) + self.assertEqual(before, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integrations.py b/tests/test_integrations.py index fa3bcea..c0d452e 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -130,6 +130,8 @@ def test_packaged_skill_is_concise_and_has_required_metadata(self) -> None: self.assertIn(command, content) for forbidden_action in ("`console`", "`dispatch`", "`task gates`"): self.assertIn(forbidden_action, content) + self.assertNotIn("`image`", content) + self.assertNotIn("dyro image", content) self.assertIn("skip global discovery", content) self.assertIn("Never add `--include-paths`", content) for private_pattern in (