From c46c285a74d7b250113d55ffbccc0eea998f120c Mon Sep 17 00:00:00 2001 From: Dandre Yang Date: Tue, 4 Aug 2026 15:12:48 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20setup=20=E8=87=AA=E5=8A=A8=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=20Console=20=E5=B7=A5=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/dyro/cli.py | 133 ++++++++++++++++++++++++++++++++++++++++++---- src/dyro/hub.py | 36 +++++++++++++ tests/test_cli.py | 118 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a4d54a4..ae57150 100644 --- a/README.md +++ b/README.md @@ -384,7 +384,7 @@ dyro bootstrap --yes dyro doctor ``` -After setup, run `dyro`. The first run inside a project registers it in a reversible global home. From then on, the same command can resume a recent development line, hotfix, or existing task worktree from any directory—without remembering `--root`: +`dyro setup` registers the completed Profile in the reversible global home by default, so it is visible in Console immediately. The first registered workspace becomes the default; later setup runs keep the existing default unless you pass `--default`. Use `--no-register` for CI or temporary workspaces. From then on, bare `dyro` can resume a recent development line, hotfix, or existing task worktree from any directory—without remembering `--root`: ```bash dyro diff --git a/src/dyro/cli.py b/src/dyro/cli.py index 79b26a2..c9d7ada 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -79,9 +79,12 @@ sort_home_tools, ) from .hub import ( + WorkspaceRecord, + WorkspaceRegistrationPlan, add_workspace, get_workspace, load_registry, + preview_workspace_registration, remove_workspace, set_default_workspace, ) @@ -381,16 +384,73 @@ def _setup_provider_preset() -> str | None: return None -def _render_interactive_setup_plan(plan: SetupPlan) -> None: +def _setup_registration_plan( + root: Path, name: str, args: argparse.Namespace +) -> WorkspaceRegistrationPlan | None: + if args.no_register: + return None + return preview_workspace_registration( + root, name=name, make_default=args.make_default + ) + + +def _render_setup_registration_plan( + registration: WorkspaceRegistrationPlan | None, +) -> None: + if registration is None: + print(" - Console:不登记全局入口(--no-register)") + return + name = registration.name + root = registration.root + action = "已登记,保持现有入口" if registration.already_registered else "将登记全局入口" + default = ";设为默认项目" if registration.becomes_default else ";不改变默认项目" + print(f" - Console:{action} {name} → {root}{default}") + + +def _register_setup_workspace( + config: Config, *, register: bool, make_default: bool +) -> WorkspaceRecord | None: + if not register: + return None + record = add_workspace( + config.root, name=config.name, make_default=make_default + ) + default = "(默认项目)" if load_registry().default == record.name else "" + print(f"已登记全局入口:{record.name}{default}") + return record + + +def _print_setup_completion( + config: Config, registration: WorkspaceRecord | None +) -> None: + print("\n设置完成:") + print(f" - Profile:{config.name}") + if registration is None: + print(" - Console:未登记(--no-register)") + else: + print(f" - Console:{registration.name} 已可在 dyro console 中查看") + print("下一步:dyro start") + + +def _render_interactive_setup_plan( + plan: SetupPlan, registration: WorkspaceRegistrationPlan | None +) -> None: print("\n设置计划(尚未修改任何文件):") for item in render_setup_plan(plan): print(" - " + item) + _render_setup_registration_plan(registration) if plan.needs_bootstrap: print(" - 将仅 clone 缺失且已明确提供 remote 的仓库") print(" - 不会移动、覆盖或清理现有 Git 仓库") -def _apply_setup_plan(plan: SetupPlan, *, dry_run: bool) -> None: +def _apply_setup_plan( + plan: SetupPlan, + *, + dry_run: bool, + register: bool, + make_default: bool, +) -> None: if dry_run: print("DRY RUN: 上述计划不会写入文件、执行 clone 或创建 Git worktree") return @@ -410,6 +470,9 @@ def _apply_setup_plan(plan: SetupPlan, *, dry_run: bool) -> None: ) config = load(plan.root) _ensure_state_directories(config.root) + registration = _register_setup_workspace( + config, register=register, make_default=make_default + ) if plan.needs_bootstrap: for message in bootstrap(config, branch=plan.default_base): print(message) @@ -427,7 +490,7 @@ def _apply_setup_plan(plan: SetupPlan, *, dry_run: bool) -> None: print(finding) if any(finding.startswith("FAIL") for finding in findings): raise DyroError("设置已保存,但 doctor 发现问题;请修复后运行 dyro next") - print("\n设置完成。下一步:dyro next") + _print_setup_completion(config, registration) def _interactive_setup(args: argparse.Namespace) -> None: @@ -437,10 +500,21 @@ def _interactive_setup(args: argparse.Namespace) -> None: print("Dyro 首次设置。先检查环境,确认前不会修改文件。") if config_file.exists(): config = load(root) + registration = _setup_registration_plan(config.root, config.name, args) print(f"已发现 Profile:{config_file}({len(config.repositories)} 个仓库)") + _render_setup_registration_plan(registration) for finding in doctor(config): print(finding) - print("下一步:dyro next") + if args.dry_run or registration is None: + print("下一步:dyro next") + return + if not args.yes and not _ask_yes_no("将此 Profile 登记到全局 Console", default=False): + print("已取消;没有修改全局入口。") + return + record = _register_setup_workspace( + config, register=True, make_default=args.make_default + ) + _print_setup_completion(config, record) return repositories = discover_repositories(root) if root.exists() and not is_git_repository(root) else [] @@ -484,14 +558,25 @@ def _interactive_setup(args: argparse.Namespace) -> None: branch=f"feat/{line_id}" if line_id else None, provider_preset=provider, ) - _render_interactive_setup_plan(plan) + registration = _setup_registration_plan(plan.root, plan.name, args) + _render_interactive_setup_plan(plan, registration) if args.dry_run: - _apply_setup_plan(plan, dry_run=True) + _apply_setup_plan( + plan, + dry_run=True, + register=not args.no_register, + make_default=args.make_default, + ) return if not args.yes and not _ask_yes_no("应用此设置计划", default=False): print("已取消;没有修改任何文件。") return - _apply_setup_plan(plan, dry_run=False) + _apply_setup_plan( + plan, + dry_run=False, + register=not args.no_register, + make_default=args.make_default, + ) def _setup_quick(args: argparse.Namespace) -> None: @@ -511,14 +596,17 @@ def _non_interactive_setup(args: argparse.Namespace) -> None: if config_file.exists(): config = load(root) print(f"复用已有 Profile:{config_file}") + registration = _setup_registration_plan(config.root, config.name, args) else: repositories = discover_repositories(root) if not repositories: raise DyroError("未发现 Git 仓库;请先 clone 仓库到工作区,或使用 dyro init --wizard") name = args.name or _default_workspace_name(root) validate_id(name, "workspace 名称") + registration = _setup_registration_plan(root, name, args) if args.dry_run: print(f"DRY RUN: 将创建 {config_file},自动登记 {len(repositories)} 个 Git 仓库") + _render_setup_registration_plan(registration) if not args.no_line: print(f"DRY RUN: 将创建开发线 {args.line}(分支 {args.branch or f'feat/{args.line}'})") return @@ -527,8 +615,17 @@ def _non_interactive_setup(args: argparse.Namespace) -> None: config = load(root) created = True print(f"已创建 Profile,并自动登记 {len(repositories)} 个 Git 仓库") + if config_file.exists() or not args.dry_run: + _render_setup_registration_plan(registration) if not args.dry_run: _ensure_state_directories(config.root) + registered = _register_setup_workspace( + config, + register=not args.no_register, + make_default=args.make_default, + ) + else: + registered = None if not args.no_line: _require_yes(args, "setup 创建开发线") try: @@ -551,8 +648,8 @@ def _non_interactive_setup(args: argparse.Namespace) -> None: print(finding) if any(finding.startswith("FAIL") for finding in findings): raise DyroError("setup 已完成基础配置,但 doctor 仍发现结构错误") - if created: - print("下一步:dyro next") + if created or registered is not None: + _print_setup_completion(config, registered) def _uses_interactive_setup(args: argparse.Namespace) -> bool: @@ -2114,7 +2211,23 @@ def build_parser() -> argparse.ArgumentParser: setup.add_argument("--line", default="dev", help="首条功能开发线 ID;默认 dev") setup.add_argument("--branch", help="首条开发线分支;默认 feat/") setup.add_argument("--no-line", action="store_true", help="仅建立 Profile,不创建 Git worktree 开发线") - setup.add_argument("--yes", action="store_true", help="确认创建首条 Git worktree 开发线") + setup.add_argument( + "--yes", + action="store_true", + help="确认需要确认的 setup 计划步骤,包括首条 Git worktree 与全局入口登记", + ) + setup_registration = setup.add_mutually_exclusive_group() + setup_registration.add_argument( + "--no-register", + action="store_true", + help="不加入 Console 的全局工作区列表;适合 CI 与临时环境", + ) + setup_registration.add_argument( + "--default", + dest="make_default", + action="store_true", + help="登记后设为裸 dyro 的默认项目", + ) setup_mode = setup.add_mutually_exclusive_group() setup_mode.add_argument("--interactive", action="store_true", help="强制运行交互式首次设置") setup_mode.add_argument("--non-interactive", action="store_true", help="禁用交互提示;适合脚本与 CI") diff --git a/src/dyro/hub.py b/src/dyro/hub.py index caf5a00..becf70a 100644 --- a/src/dyro/hub.py +++ b/src/dyro/hub.py @@ -32,6 +32,16 @@ class WorkspaceRegistry: workspaces: tuple[WorkspaceRecord, ...] = () +@dataclass(frozen=True) +class WorkspaceRegistrationPlan: + """A read-only preview of adding one workspace to the global entry list.""" + + name: str + root: Path + already_registered: bool + becomes_default: bool + + def registry_home() -> Path: override = os.environ.get("DYRO_HOME", "").strip() if override: @@ -168,6 +178,32 @@ def _profile_root(path: str | Path) -> Config: ) from exc +def preview_workspace_registration( + path: str | Path, *, name: str, make_default: bool = False +) -> WorkspaceRegistrationPlan: + """Validate and describe a future global workspace registration without writing.""" + + root = Path(path).expanduser().absolute().resolve() + alias = validate_id(name, "工作区别名") + registry = load_registry() + same_name = next( + (item for item in registry.workspaces if item.name == alias), None + ) + same_root = next( + (item for item in registry.workspaces if item.root == root), None + ) + if same_name is not None and same_name.root != root: + raise DyroError(f"工作区别名 {alias} 已指向 {same_name.root}") + if same_root is not None and same_root.name != alias: + raise DyroError(f"工作区路径已经登记为 {same_root.name}:{root}") + return WorkspaceRegistrationPlan( + name=alias, + root=root, + already_registered=same_name is not None or same_root is not None, + becomes_default=make_default or not registry.default, + ) + + def add_workspace( path: str | Path, *, name: str | None = None, make_default: bool = False ) -> WorkspaceRecord: diff --git a/tests/test_cli.py b/tests/test_cli.py index c2fe9af..341d85a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ from pathlib import Path import json +import os import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout @@ -9,6 +10,7 @@ from dyro.cli import _route_experiment_surface, _setup_provider_preset, main from dyro.changesets import get_changeset from dyro.config import load +from dyro.hub import load_registry from dyro.tasks import load_task, status, task_template from dyro.workspace import create_line, get_line @@ -16,6 +18,17 @@ class CliTests(unittest.TestCase): + def setUp(self) -> None: + self.registry_tmp = tempfile.TemporaryDirectory(prefix="dyro-registry-") + 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() + def test_runtime_is_not_an_experiment_surface(self) -> None: routed = _route_experiment_surface( [ @@ -99,6 +112,111 @@ def test_setup_discovers_repositories_and_creates_a_first_line(self) -> None: self.assertEqual(get_line(config, "dev").branch, "feat/dev") self.assertTrue((root / "versions/dev/api").is_dir()) + registry = load_registry() + self.assertEqual(registry.default, "demo") + self.assertEqual( + [(record.name, record.root) for record in registry.workspaces], + [("demo", root.resolve())], + ) + + def test_setup_keeps_an_existing_default_workspace(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-cli-") as tmp: + root = Path(tmp) / "workspace" + repository = root / "repositories/api" + repository.mkdir(parents=True) + from .support import shell + + shell("git", "init", "-b", "main", cwd=repository) + shell("git", "config", "user.name", "Test User", cwd=repository) + shell("git", "config", "user.email", "test@example.com", cwd=repository) + repository.joinpath("README.md").write_text("anchor\n", encoding="utf-8") + shell("git", "add", "README.md", cwd=repository) + shell("git", "commit", "-m", "chore: initial", cwd=repository) + + existing = Path(tmp) / "existing" + main(["init", str(existing), "--name", "existing"]) + main(["workspace", "add", str(existing), "--default"]) + + main(["setup", str(root), "--name", "demo", "--no-line"]) + + registry = load_registry() + self.assertEqual(registry.default, "existing") + self.assertEqual( + {record.name for record in registry.workspaces}, {"demo", "existing"} + ) + + def test_setup_can_explicitly_set_the_default_workspace(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-cli-") as tmp: + root = Path(tmp) / "workspace" + repository = root / "repositories/api" + repository.mkdir(parents=True) + from .support import shell + + shell("git", "init", "-b", "main", cwd=repository) + + existing = Path(tmp) / "existing" + main(["init", str(existing), "--name", "existing"]) + main(["workspace", "add", str(existing), "--default"]) + + main( + [ + "setup", + str(root), + "--name", + "demo", + "--no-line", + "--default", + ] + ) + + self.assertEqual(load_registry().default, "demo") + + def test_setup_can_skip_global_workspace_registration(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-cli-") as tmp: + root = Path(tmp) / "workspace" + repository = root / "repositories/api" + repository.mkdir(parents=True) + from .support import shell + + shell("git", "init", "-b", "main", cwd=repository) + + main( + [ + "setup", + str(root), + "--name", + "demo", + "--no-line", + "--no-register", + ] + ) + + self.assertEqual(load_registry().workspaces, ()) + + def test_setup_rejects_an_alias_conflict_before_writing_a_profile(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-cli-") as tmp: + existing = Path(tmp) / "existing" + main(["init", str(existing), "--name", "demo"]) + main(["workspace", "add", str(existing)]) + + root = Path(tmp) / "workspace" + repository = root / "repositories/api" + repository.mkdir(parents=True) + from .support import shell + + shell("git", "init", "-b", "main", cwd=repository) + + stderr = StringIO() + with ( + redirect_stderr(stderr), + self.assertRaises(SystemExit) as raised, + ): + main(["setup", str(root), "--name", "demo", "--no-line"]) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("工作区别名 demo 已指向", stderr.getvalue()) + self.assertFalse((root / "dyro.toml").exists()) + def test_setup_accepts_dry_run_after_the_command_without_writing(self) -> None: with tempfile.TemporaryDirectory(prefix="dyro-cli-") as tmp: root = Path(tmp) / "workspace" From 94b10a2a6f2b0551ec3e419d1de40ff1ec70a2e3 Mon Sep 17 00:00:00 2001 From: Dandre Yang Date: Tue, 4 Aug 2026 16:16:24 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E7=BC=96=E7=A0=81=E5=B7=A5=E5=85=B7=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +-- docs/tool-catalog.md | 15 +++- src/dyro/cli.py | 12 ++- src/dyro/home.py | 205 ++++++++++++++++++++++++++++++++---------- src/dyro/tooling.py | 38 ++++++++ tests/test_cli.py | 11 ++- tests/test_hub.py | 123 ++++++++++++++++++++++++- tests/test_tooling.py | 14 +++ 8 files changed, 374 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index ae57150..567a6fd 100644 --- a/README.md +++ b/README.md @@ -397,11 +397,15 @@ on the local machine are labeled `open workspace only` and receive no gate, review, merge, or push authority. Explicit commands such as `dyro open dev --agent codex` continue to launch directly for scripts. -The picker detects Cursor Desktop separately from Cursor CLI and supports -OpenClaw as a launch-only external runtime. Ready tools appear before tools that -need setup or installation; the current workspace's last choice, project -recommendation, personal default, and pinned order provide stable tie-breaking. -Selecting a missing supported tool opens a confirmation-first guided installer: +The picker detects Cursor Desktop separately from Cursor CLI, supports +Antigravity CLI (`agy`) and Qoder CLI (`qodercli`) as launch-only terminal +tools, opens ZCode as a launch-only desktop tool, and supports OpenClaw as a +launch-only external runtime. It begins with at most three common choices; +type `m` for the full catalog or enter a tool ID, label, or command directly. +Ready tools appear before tools that need setup or installation; the current +workspace's last choice, project recommendation, personal default, and pinned +order provide stable tie-breaking. Selecting a missing supported tool opens a +confirmation-first guided installer: ```bash dyro tool list diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 436f111..aa6b162 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -75,11 +75,22 @@ user can inspect and choose the platform-specific installer. This applies to Cursor CLI and Hermes; Cursor Desktop likewise opens the official download page. -Current catalog entries include Codex, Claude Code, Cursor Desktop, Cursor CLI, -Grok, OpenCode, OpenClaw, Hermes, Kimi Code, and Shell. An entry can be known +Current catalog entries include Antigravity CLI, Codex, Claude Code, Cursor +Desktop, Cursor CLI, Grok, OpenCode, OpenClaw, Hermes, Kimi Code, Qoder CLI, +ZCode, and Shell. Antigravity uses the official `agy` command; Qoder uses +`qodercli`; ZCode is opened as a desktop workspace tool. An entry can be known without having an audited installation recipe; such entries fail closed with an actionable message. +## Compact Home picker + +Home starts by showing at most three common choices: the most recent or +default tool, project recommendation, user pins, and available Dyro adapters. +This keeps everyday startup short while retaining the full catalog. Enter a +tool ID, label, or command directly, or type `m` to expand the full list, +including guided-install choices. The shorter display does not change launch +authority: launch-only tools still only open the selected workspace. + ## Cursor Desktop and OpenClaw Cursor Desktop is distinct from Cursor CLI. Dyro detects the `cursor` command, diff --git a/src/dyro/cli.py b/src/dyro/cli.py index c9d7ada..da56f39 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -365,7 +365,17 @@ def _setup_provider_preset() -> str | None: discovered = [ command - for command in ("codex", "claude", "cursor-agent", "grok", "opencode", "hermes", "kimi") + for command in ( + "agy", + "codex", + "claude", + "cursor-agent", + "grok", + "opencode", + "hermes", + "kimi", + "qodercli", + ) if shutil.which(command) ] if not discovered: diff --git a/src/dyro/home.py b/src/dyro/home.py index f9c71ef..f7bb616 100644 --- a/src/dyro/home.py +++ b/src/dyro/home.py @@ -200,11 +200,26 @@ def _cursor_desktop_argv(workspace: Path) -> tuple[tuple[str, ...], bool]: return ("cursor", str(workspace)), False +def _zcode_desktop_argv(workspace: Path) -> tuple[tuple[str, ...], bool]: + if shutil.which("zcode") is not None: + return ("zcode", str(workspace)), True + if sys.platform == "darwin": + candidates = ( + Path("/Applications/ZCode.app"), + Path.home() / "Applications" / "ZCode.app", + ) + if any(candidate.is_dir() for candidate in candidates) and shutil.which("open"): + return ("open", "-a", "ZCode", str(workspace)), True + return ("zcode", str(workspace)), False + + def _launcher_tool( definition: ToolDefinition, *, config: Config, workspace: Path ) -> HomeTool: if definition.id == "cursor-desktop": argv, installed = _cursor_desktop_argv(workspace) + elif definition.id == "zcode": + argv, installed = _zcode_desktop_argv(workspace) else: argv = expand_argv( definition.launch, @@ -669,6 +684,10 @@ def _choose_action( switch_index = console_index + 1 if can_switch: print(f" {switch_index}) 切换项目") + new_line_index = switch_index + 1 if can_switch else console_index + 1 + new_hotfix_index = new_line_index + 1 + print(f" {new_line_index}) 开启新的功能开发线") + print(f" {new_hotfix_index}) 处理新的线上问题 / Hotfix") print(" q) 退出") if not interactive_terminal(): print( @@ -691,6 +710,10 @@ def _choose_action( return "console", "" if can_switch and selected == switch_index: return "switch", "" + if selected == new_line_index: + return "new-line", "" + if selected == new_hotfix_index: + return "new-hotfix", "" raise DyroError("菜单编号超出范围") @@ -705,40 +728,74 @@ def _tool_state_labels( definition = tool_definition(tool.id) if tool.kind == "adapter": if tool.state == ToolState.READY: - labels = ["已配置"] + labels = ["Dyro 已接入"] elif tool.state == ToolState.INSTALLABLE: - labels = ["已配置但不可用", "可引导安装"] + labels = ["已接入,待安装"] else: - labels = ["已配置但不可用"] + labels = ["已接入但不可用"] elif tool.state == ToolState.READY: interface = definition.interface if definition else "terminal" if interface == "desktop": - labels = ["已安装", "桌面应用", "仅打开工作区"] + labels = ["桌面应用"] elif interface == "runtime": - labels = ["已安装", "外部运行时", "仅打开工作区"] + labels = ["外部运行时"] elif tool.id == "shell": labels = ["终端兜底"] else: - labels = ["已安装", "仅打开工作区"] + labels = ["打开工作区"] elif tool.state == ToolState.NEEDS_SETUP: - labels = ["已安装", "待初始化", "仅打开工作区"] + labels = ["待初始化"] elif tool.state == ToolState.INSTALLABLE: - labels = ["未安装", "可引导安装"] + labels = ["未安装,可引导安装"] else: - labels = ["未安装", "暂无内置安装方案"] + labels = ["不可用"] if tool.id == last_tool: - labels.append("上次使用") + labels.append("上次") if tool.id == recommended_tool: labels.append("项目推荐") if tool.id == preferences.default_tool: - labels.append("个人默认") + labels.append("默认") if tool.id in preferences.pinned_tools: - labels.append("已置顶") + labels.append("置顶") if tool.id == default.id: labels.append("回车默认") return labels +def _primary_home_tools( + tools: list[HomeTool], + *, + default: HomeTool, + last_tool: str, + recommended_tool: str, + preferences: ToolPreferences, +) -> list[HomeTool]: + by_id = {tool.id: tool for tool in tools} + preferred_ids = ( + default.id, + last_tool, + recommended_tool, + preferences.default_tool, + *preferences.pinned_tools, + ) + primary: list[HomeTool] = [] + for tool_id in preferred_ids: + tool = by_id.get(tool_id) + if tool and (tool.available or tool == default) and tool not in primary: + primary.append(tool) + for tool in tools: + if tool.kind == "adapter" and tool.available and tool not in primary: + primary.append(tool) + return primary[:3] + + +def _matches_home_tool(tool: HomeTool, value: str) -> bool: + if tool.id.lower() == value or tool.label.lower() == value: + return True + definition = tool_definition(tool.id) + return bool(definition and definition.command.lower() == value) + + def _install_id(config: Config, tool: HomeTool) -> str: if tool.kind != "adapter": return tool.id @@ -777,44 +834,79 @@ def _choose_tool( preferences=preferences, ) ready = [tool for tool in tools if tool.state == ToolState.READY] - available = [tool for tool in tools if tool.available] - if not available: + selectable = [ + tool + for tool in tools + if tool.available or tool.state == ToolState.INSTALLABLE + ] + if not selectable: print_agent_discovery(config) raise DyroError("当前项目没有可启动的编码工具;请选择可引导安装的工具") - default = ready[0] if ready else available[0] - print("\n使用哪个编码工具?\n") - for index, tool in enumerate(tools, start=1): - labels = _tool_state_labels( - tool, - default=default, - last_tool=last_tool, - recommended_tool=config.recommended_tool, - preferences=preferences, + default = ready[0] if ready else selectable[0] + show_all = False + while True: + displayed = ( + tools + if show_all + else _primary_home_tools( + tools, + default=default, + last_tool=last_tool, + recommended_tool=config.recommended_tool, + preferences=preferences, + ) ) - print(f" {index}) {tool.label}({','.join(labels)})") - print(" q) 退出") - raw = ( - input(f"\n请选择(输入编号或工具名,直接回车默认 {default.label}):") - .strip() - .lower() - ) - if not raw: - return default - if raw in {"q", "quit"}: - return None - if raw.isdigit() and 1 <= int(raw) <= len(tools): - selected = tools[int(raw) - 1] - else: - selected = next( - ( - tool - for tool in tools - if tool.id.lower() == raw or tool.label.lower() == raw - ), - None, + print("\n全部编码工具:\n" if show_all else "\n常用编码工具:\n") + for index, tool in enumerate(displayed, start=1): + labels = _tool_state_labels( + tool, + default=default, + last_tool=last_tool, + recommended_tool=config.recommended_tool, + preferences=preferences, + ) + print(f" {index}) {tool.label}({','.join(labels)})") + if show_all: + print(" b) 返回常用工具") + else: + hidden_ready = sum( + tool.available and tool not in displayed for tool in tools + ) + installable = sum( + tool.state == ToolState.INSTALLABLE for tool in tools + ) + details = [] + if hidden_ready: + details.append(f"{hidden_ready} 个已安装") + if installable: + details.append(f"{installable} 个可安装") + suffix = f"({','.join(details)})" if details else "" + print(f" m) 更多工具{suffix}") + print(" q) 退出") + raw = ( + input( + f"\n请选择(编号或工具名,直接回车默认 {default.label}):" + ) + .strip() + .lower() ) - if selected is None: - raise DyroError("无效的编码工具选择") + if not raw: + return default + if raw in {"q", "quit"}: + return None + if raw in {"m", "more", "更多"}: + show_all = True + continue + if raw in {"b", "back", "返回"} and show_all: + show_all = False + continue + if raw.isdigit() and 1 <= int(raw) <= len(displayed): + selected = displayed[int(raw) - 1] + else: + selected = next((tool for tool in tools if _matches_home_tool(tool, raw)), None) + if selected is None: + raise DyroError("无效的编码工具选择") + break if selected.state == ToolState.INSTALLABLE: install_id = _install_id(config, selected) if not install_tool(install_id, yes=False, dry_run=dry_run): @@ -862,6 +954,21 @@ def _switch_workspace(dry_run: bool) -> None: _run_config_home(load(record.root), record, dry_run) +def _print_new_line_guidance(config: Config) -> None: + print("\n新功能开发线会创建隔离 Git worktree。") + print("先确认功能 ID、范围、参与仓库与基线;确认后才会修改 Git 工作区。") + print( + "下一步:" + f"dyro line create --base {shlex.quote(config.policy.default_base)} --yes" + ) + + +def _print_new_hotfix_guidance() -> None: + print("\nHotfix 需要已核实的生产 release 分支、tag 或部署 SHA。") + print("确认问题 ID 和基线后,才会创建隔离的 Hotfix worktree。") + print("下一步:dyro hotfix create <问题ID> --base <已核实的 release/tag/SHA> --yes") + + def _run_config_home( config: Config, record: WorkspaceRecord | None, dry_run: bool ) -> None: @@ -903,6 +1010,12 @@ def _run_config_home( if kind == "switch": _switch_workspace(dry_run) return + if kind == "new-line": + _print_new_line_guidance(config) + return + if kind == "new-hotfix": + _print_new_hotfix_guidance() + return if kind == "task": task = load_task(config, target_id) target_workspace = existing_task_workspace(config, task) diff --git a/src/dyro/tooling.py b/src/dyro/tooling.py index 93ff186..008c0ec 100644 --- a/src/dyro/tooling.py +++ b/src/dyro/tooling.py @@ -61,6 +61,18 @@ class ToolPreferences: TOOL_DEFINITIONS = ( + ToolDefinition( + "antigravity", + "Antigravity CLI", + "agy", + "terminal", + ("agy",), + install=InstallGuide( + "https://antigravity.google/download", + "Antigravity 官方用户级安装目录", + remote_script_only=True, + ), + ), ToolDefinition( "codex", "Codex", @@ -170,6 +182,32 @@ class ToolPreferences: risk=_NPM_RISK, ), ), + ToolDefinition( + "qoder", + "Qoder CLI", + "qodercli", + "terminal", + ("qodercli",), + install=InstallGuide( + "https://docs.qoder.com/en/cli/quick-start", + _NPM_SCOPE, + ("npm", "install", "-g", "@qoder-ai/qodercli"), + prerequisite="npm", + risk=_NPM_RISK, + ), + ), + ToolDefinition( + "zcode", + "ZCode", + "zcode", + "desktop", + ("zcode", "{workspace}"), + install=InstallGuide( + "https://zcode.z.ai/en/docs/install", + "当前操作系统的桌面应用", + remote_script_only=True, + ), + ), ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 341d85a..42cec46 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -242,7 +242,16 @@ def test_setup_rejects_conflicting_interaction_modes(self) -> None: self.assertIn("not allowed with argument", stderr.getvalue()) def test_setup_reports_detected_but_unintegrated_providers_without_registering_them(self) -> None: - discovered = {"claude", "cursor-agent", "grok", "opencode", "hermes", "kimi"} + discovered = { + "agy", + "claude", + "cursor-agent", + "grok", + "opencode", + "hermes", + "kimi", + "qodercli", + } output = StringIO() with ( patch("dyro.cli.shutil.which", side_effect=lambda command: f"/fake/{command}" if command in discovered else None), diff --git a/tests/test_hub.py b/tests/test_hub.py index a374cf5..9bbe778 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -13,6 +13,8 @@ from dyro.config import load from dyro.home import ( HomeTool, + _choose_action, + _choose_tool, _openclaw_needs_setup, home_tools, sort_home_tools, @@ -175,6 +177,7 @@ def test_no_argument_home_opens_registered_line_from_any_directory(self) -> None add_workspace(self.root, name="demo", make_default=True) output = StringIO() with ( + patch("dyro.home.Path.cwd", return_value=self.root.parent), patch("dyro.home.interactive_terminal", return_value=True), patch("builtins.input", return_value=""), redirect_stdout(output), @@ -184,7 +187,7 @@ def test_no_argument_home_opens_registered_line_from_any_directory(self) -> None rendered = output.getvalue() self.assertIn("test-workspace", rendered) self.assertIn("alpha", rendered) - self.assertIn("使用哪个编码工具", rendered) + self.assertIn("常用编码工具", rendered) self.assertIn("noop", rendered) self.assertIn("/usr/bin/true", rendered) self.assertEqual(load_registry().workspaces[0].last_target, "") @@ -194,6 +197,7 @@ def test_home_console_choice_honors_dry_run(self) -> None: add_workspace(self.root, name="demo", make_default=True) output = StringIO() with ( + patch("dyro.home.Path.cwd", return_value=self.root.parent), patch("dyro.home.interactive_terminal", return_value=True), patch("builtins.input", return_value="3"), patch("dyro.console.launcher.create_console_http_server") as server_factory, @@ -204,6 +208,55 @@ def test_home_console_choice_honors_dry_run(self) -> None: self.assertIn("DRY RUN: 将启动只读本地 Console", output.getvalue()) server_factory.assert_not_called() + def test_home_lists_safe_new_work_entrypoints(self) -> None: + output = StringIO() + with ( + patch("dyro.home.interactive_terminal", return_value=True), + patch("builtins.input", return_value="3"), + redirect_stdout(output), + ): + action = _choose_action([], can_switch=False) + + self.assertEqual(action, ("new-line", "")) + self.assertIn("开启新的功能开发线", output.getvalue()) + self.assertIn("处理新的线上问题 / Hotfix", output.getvalue()) + + def test_home_new_feature_entrypoint_only_prints_next_step(self) -> None: + add_workspace(self.root, name="demo", make_default=True) + before_config = self.root.joinpath("dyro.toml").read_bytes() + before_registry = load_registry() + output = StringIO() + with ( + patch("dyro.home.interactive_terminal", return_value=True), + patch("builtins.input", return_value="3"), + redirect_stdout(output), + ): + main(["--root", str(self.root)]) + + rendered = output.getvalue() + self.assertIn("新功能开发线会创建隔离 Git worktree", rendered) + self.assertIn("dyro line create --base main --yes", rendered) + self.assertEqual(self.root.joinpath("dyro.toml").read_bytes(), before_config) + self.assertEqual(load_registry(), before_registry) + + def test_home_hotfix_entrypoint_only_prints_next_step(self) -> None: + add_workspace(self.root, name="demo", make_default=True) + before_config = self.root.joinpath("dyro.toml").read_bytes() + before_registry = load_registry() + output = StringIO() + with ( + patch("dyro.home.interactive_terminal", return_value=True), + patch("builtins.input", return_value="4"), + redirect_stdout(output), + ): + main(["--root", str(self.root)]) + + rendered = output.getvalue() + self.assertIn("Hotfix 需要已核实的生产 release 分支、tag 或部署 SHA", rendered) + self.assertIn("dyro hotfix create <问题ID> --base", rendered) + self.assertEqual(self.root.joinpath("dyro.toml").read_bytes(), before_config) + self.assertEqual(load_registry(), before_registry) + def test_home_can_open_a_detected_tool_without_granting_adapter_capabilities( self, ) -> None: @@ -213,6 +266,7 @@ def test_home_can_open_a_detected_tool_without_granting_adapter_capabilities( answers = iter(["", "claude"]) output = StringIO() with ( + patch("dyro.home.Path.cwd", return_value=self.root.parent), patch("dyro.home.interactive_terminal", return_value=True), patch("builtins.input", side_effect=lambda _: next(answers)), patch( @@ -224,8 +278,7 @@ def test_home_can_open_a_detected_tool_without_granting_adapter_capabilities( main(["--dry-run"]) rendered = output.getvalue() - self.assertIn("Claude Code", rendered) - self.assertIn("仅打开工作区", rendered) + self.assertIn("更多工具", rendered) self.assertIn("$ claude", rendered) self.assertNotIn("claude", load(self.root).adapters) self.assertEqual(self.root.joinpath("dyro.toml").read_bytes(), before) @@ -245,6 +298,67 @@ def test_home_exposes_detected_codex_without_configuring_an_adapter( self.assertTrue(codex.available) self.assertNotIn("codex", load(self.root).adapters) + def test_home_detects_antigravity_qoder_and_zcode_as_launch_only_tools( + self, + ) -> None: + discovered = {"agy", "qodercli", "zcode"} + with patch( + "dyro.home.shutil.which", + side_effect=lambda name: f"/fake/{name}" if name in discovered else None, + ): + tools = home_tools(load(self.root), workspace=self.root) + + by_id = {tool.id: tool for tool in tools} + self.assertEqual(by_id["antigravity"].argv, ("agy",)) + self.assertEqual(by_id["qoder"].argv, ("qodercli",)) + self.assertEqual(by_id["zcode"].argv, ("zcode", str(self.root))) + for tool_id in ("antigravity", "qoder", "zcode"): + self.assertEqual(by_id[tool_id].kind, "launcher") + self.assertEqual(by_id[tool_id].state, ToolState.READY) + self.assertNotIn(tool_id, load(self.root).adapters) + + def test_home_tool_picker_shows_common_choices_before_full_catalog(self) -> None: + discovered = {"agy", "claude", "kimi", "qodercli", "zcode"} + answers = iter(["m", "antigravity"]) + output = StringIO() + with ( + patch( + "dyro.home.shutil.which", + side_effect=lambda name: f"/fake/{name}" if name in discovered else None, + ), + patch("builtins.input", side_effect=lambda _: next(answers)), + redirect_stdout(output), + ): + tool = _choose_tool( + load(self.root), None, workspace=self.root, dry_run=True + ) + + self.assertIsNotNone(tool) + self.assertEqual(tool.id if tool else "", "antigravity") + rendered = output.getvalue() + self.assertIn("更多工具", rendered) + self.assertIn("Antigravity CLI", rendered) + + def test_home_tool_picker_offers_installable_default_when_nothing_is_ready( + self, + ) -> None: + installable = HomeTool( + "qoder", "Qoder CLI", "launcher", ("qodercli",), (), ToolState.INSTALLABLE + ) + output = StringIO() + with ( + patch("dyro.home.home_tools", return_value=[installable]), + patch("builtins.input", return_value=""), + redirect_stdout(output), + ): + tool = _choose_tool( + load(self.root), None, workspace=self.root, dry_run=True + ) + + self.assertEqual(tool, installable) + self.assertIn("Qoder CLI", output.getvalue()) + self.assertIn("未安装,可引导安装", output.getvalue()) + def test_home_detects_cursor_desktop_separately_from_cursor_cli(self) -> None: def detected(name: str) -> str | None: return "/fake/cursor" if name == "cursor" else None @@ -408,6 +522,7 @@ def run(argv: tuple[str, ...], **_: object) -> subprocess.CompletedProcess[str]: output = StringIO() before = self.root.joinpath("dyro.toml").read_bytes() with ( + patch("dyro.home.Path.cwd", return_value=self.root.parent), patch("dyro.home.interactive_terminal", return_value=True), patch("dyro.home.shutil.which", side_effect=detected), patch("dyro.home._openclaw_needs_setup", return_value=True), @@ -444,6 +559,7 @@ def test_unhealthy_line_does_not_block_opening_a_healthy_line(self) -> None: output = StringIO() with ( + patch("dyro.home.Path.cwd", return_value=self.root.parent), patch("dyro.home.interactive_terminal", return_value=True), patch("builtins.input", return_value=""), redirect_stdout(output), @@ -638,6 +754,7 @@ def test_agent_discovery_reports_configured_missing_command(self) -> None: def test_first_use_without_registry_is_actionable_and_non_mutating(self) -> None: output = StringIO() with ( + patch("dyro.home.Path.cwd", return_value=self.root.parent), patch("dyro.home.interactive_terminal", return_value=False), redirect_stdout(output), ): diff --git a/tests/test_tooling.py b/tests/test_tooling.py index 126e7f7..d476b5a 100644 --- a/tests/test_tooling.py +++ b/tests/test_tooling.py @@ -15,6 +15,7 @@ install_tool, load_tool_preferences, save_tool_preferences, + tool_definition, ) @@ -54,6 +55,19 @@ def test_preferences_reject_unknown_fields_and_duplicate_pins(self) -> None: class GuidedInstallerTests(unittest.TestCase): + def test_catalog_includes_antigravity_qoder_and_zcode(self) -> None: + antigravity = tool_definition("antigravity") + qoder = tool_definition("qoder") + zcode = tool_definition("zcode") + + self.assertEqual(antigravity.command if antigravity else "", "agy") + self.assertEqual(qoder.command if qoder else "", "qodercli") + self.assertEqual(zcode.interface if zcode else "", "desktop") + self.assertEqual( + qoder.install.argv if qoder and qoder.install else (), + ("npm", "install", "-g", "@qoder-ai/qodercli"), + ) + def test_command_recipe_is_explicit_argv_and_requires_confirmation(self) -> None: calls: list[tuple[str, ...]] = [] From b0bbc6880f845055fba9b0ff38597088947c5266 Mon Sep 17 00:00:00 2001 From: Dandre Yang Date: Tue, 4 Aug 2026 16:38:13 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20Codex=20?= =?UTF-8?q?=E4=B8=8E=20Claude=20=E6=A1=8C=E9=9D=A2=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- docs/tool-catalog.md | 22 +++++++++---- src/dyro/home.py | 76 ++++++++++++++++++++++++++++++++----------- src/dyro/tooling.py | 24 ++++++++++++++ tests/test_hub.py | 61 ++++++++++++++++++++++++++++++++++ tests/test_tooling.py | 6 +++- 6 files changed, 164 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 567a6fd..9e55273 100644 --- a/README.md +++ b/README.md @@ -397,7 +397,8 @@ on the local machine are labeled `open workspace only` and receive no gate, review, merge, or push authority. Explicit commands such as `dyro open dev --agent codex` continue to launch directly for scripts. -The picker detects Cursor Desktop separately from Cursor CLI, supports +The picker detects Codex App and Claude Code Desktop separately from their +CLI counterparts, detects Cursor Desktop separately from Cursor CLI, supports Antigravity CLI (`agy`) and Qoder CLI (`qodercli`) as launch-only terminal tools, opens ZCode as a launch-only desktop tool, and supports OpenClaw as a launch-only external runtime. It begins with at most three common choices; diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index aa6b162..1f08be3 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -75,12 +75,12 @@ user can inspect and choose the platform-specific installer. This applies to Cursor CLI and Hermes; Cursor Desktop likewise opens the official download page. -Current catalog entries include Antigravity CLI, Codex, Claude Code, Cursor -Desktop, Cursor CLI, Grok, OpenCode, OpenClaw, Hermes, Kimi Code, Qoder CLI, -ZCode, and Shell. Antigravity uses the official `agy` command; Qoder uses -`qodercli`; ZCode is opened as a desktop workspace tool. An entry can be known -without having an audited installation recipe; such entries fail closed with -an actionable message. +Current catalog entries include Antigravity CLI, Codex CLI, Codex App, Claude +Code CLI, Claude Code Desktop, Cursor Desktop, Cursor CLI, Grok, OpenCode, +OpenClaw, Hermes, Kimi Code, Qoder CLI, ZCode, and Shell. Antigravity uses the +official `agy` command; Qoder uses `qodercli`; ZCode is opened as a desktop +workspace tool. An entry can be known without having an audited installation +recipe; such entries fail closed with an actionable message. ## Compact Home picker @@ -91,13 +91,21 @@ tool ID, label, or command directly, or type `m` to expand the full list, including guided-install choices. The shorter display does not change launch authority: launch-only tools still only open the selected workspace. -## Cursor Desktop and OpenClaw +## Desktop apps and OpenClaw Cursor Desktop is distinct from Cursor CLI. Dyro detects the `cursor` command, the standard macOS application bundle, or the standard per-user Windows application path, then opens the selected workspace as a launch-only desktop tool. +Codex App and Claude Code Desktop are likewise separate from their terminal +tools. On macOS, Dyro detects their application bundles before offering them, +including the ChatGPT app's Codex entry and Claude Code's URL-handler bundle. +Codex uses `codex app ` when the CLI is available. Claude uses its +official Code deep link with the selected folder; Claude Desktop still asks the +user to confirm that folder before it is adopted. Neither desktop launch path +turns into a Dyro adapter. + OpenClaw is treated as an external runtime, not a Dyro adapter. A configured installation receives the selected path through `OPENCLAW_WORKSPACE_DIR`. A fresh installation starts official onboarding with that workspace and diff --git a/src/dyro/home.py b/src/dyro/home.py index f7bb616..62bf2e6 100644 --- a/src/dyro/home.py +++ b/src/dyro/home.py @@ -6,6 +6,7 @@ import shlex import shutil import sys +from urllib.parse import urlencode from .config import CONFIG_NAME, Config, expand_argv, load from .errors import DyroError, ValidationError @@ -200,6 +201,41 @@ def _cursor_desktop_argv(workspace: Path) -> tuple[tuple[str, ...], bool]: return ("cursor", str(workspace)), False +def _macos_app_name(*names: str) -> str | None: + if sys.platform != "darwin": + return None + for name in names: + candidates = ( + Path("/Applications") / f"{name}.app", + Path.home() / "Applications" / f"{name}.app", + ) + if any(candidate.is_dir() for candidate in candidates): + return name + return None + + +def _codex_desktop_argv(workspace: Path) -> tuple[tuple[str, ...], bool]: + argv = ("codex", "app", str(workspace)) + app_name = _macos_app_name("Codex", "ChatGPT") + if app_name is None: + return argv, False + if shutil.which("codex") is not None: + return argv, True + if shutil.which("open") is not None: + return ("open", "-a", app_name, str(workspace)), True + return argv, False + + +def _claude_desktop_argv(workspace: Path) -> tuple[tuple[str, ...], bool]: + url = "claude://code/new?" + urlencode({"folder": str(workspace)}) + if ( + _macos_app_name("Claude", "Claude Code URL Handler") is not None + and shutil.which("open") is not None + ): + return ("open", url), True + return ("open", url), False + + def _zcode_desktop_argv(workspace: Path) -> tuple[tuple[str, ...], bool]: if shutil.which("zcode") is not None: return ("zcode", str(workspace)), True @@ -218,6 +254,10 @@ def _launcher_tool( ) -> HomeTool: if definition.id == "cursor-desktop": argv, installed = _cursor_desktop_argv(workspace) + elif definition.id == "codex-desktop": + argv, installed = _codex_desktop_argv(workspace) + elif definition.id == "claude-desktop": + argv, installed = _claude_desktop_argv(workspace) elif definition.id == "zcode": argv, installed = _zcode_desktop_argv(workspace) else: @@ -396,25 +436,23 @@ def print_agent_discovery(config: Config) -> None: else "未安装;暂无内置安装方案" ) print(f"{command:16} {'已检测' if installed else '-':10} {state:16} {note}") - cursor_definition = next( - definition - for definition in TOOL_DEFINITIONS - if definition.id == "cursor-desktop" - ) - cursor_desktop = _launcher_tool( - cursor_definition, config=config, workspace=config.root - ) - desktop_state = "已检测" if cursor_desktop.available else "-" - desktop_note = ( - "首页可打开工作区;不获得执行、门禁或复核权限" - if cursor_desktop.available - else "未安装;可运行 dyro tool install cursor-desktop" - ) - desktop_integration = "尚未集成" if cursor_desktop.available else "-" - print( - f"{'cursor-desktop':16} {desktop_state:10} " - f"{desktop_integration:16} {desktop_note}" - ) + for definition in TOOL_DEFINITIONS: + if definition.interface != "desktop": + continue + desktop_tool = _launcher_tool( + definition, config=config, workspace=config.root + ) + desktop_state = "已检测" if desktop_tool.available else "-" + desktop_note = ( + "首页可打开工作区;不获得执行、门禁或复核权限" + if desktop_tool.available + else f"未安装;可运行 dyro tool install {definition.id}" + ) + desktop_integration = "尚未集成" if desktop_tool.available else "-" + print( + f"{definition.id:16} {desktop_state:10} " + f"{desktop_integration:16} {desktop_note}" + ) for adapter_id in sorted(config.adapters): executable = _adapter_executable(config, adapter_id) if Path(executable).name in known_commands: diff --git a/src/dyro/tooling.py b/src/dyro/tooling.py index 008c0ec..c61d6d3 100644 --- a/src/dyro/tooling.py +++ b/src/dyro/tooling.py @@ -87,6 +87,18 @@ class ToolPreferences: risk=_NPM_RISK, ), ), + ToolDefinition( + "codex-desktop", + "Codex App", + "codex-desktop", + "desktop", + ("codex", "app", "{workspace}"), + install=InstallGuide( + "https://openai.com/codex/", + "当前操作系统的 Codex 桌面应用", + remote_script_only=True, + ), + ), ToolDefinition( "claude", "Claude Code", @@ -101,6 +113,18 @@ class ToolPreferences: risk=_NPM_RISK, ), ), + ToolDefinition( + "claude-desktop", + "Claude Code Desktop", + "claude-desktop", + "desktop", + (), + install=InstallGuide( + "https://claude.com/download", + "当前操作系统的 Claude 桌面应用", + remote_script_only=True, + ), + ), ToolDefinition( "cursor-desktop", "Cursor Desktop", diff --git a/tests/test_hub.py b/tests/test_hub.py index 9bbe778..097dd66 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -15,6 +15,7 @@ HomeTool, _choose_action, _choose_tool, + _macos_app_name, _openclaw_needs_setup, home_tools, sort_home_tools, @@ -317,6 +318,66 @@ def test_home_detects_antigravity_qoder_and_zcode_as_launch_only_tools( self.assertEqual(by_id[tool_id].state, ToolState.READY) self.assertNotIn(tool_id, load(self.root).adapters) + def test_home_detects_codex_and_claude_desktops_as_launch_only_tools( + self, + ) -> None: + def detected(name: str) -> str | None: + return "/fake/" + name if name in {"codex", "open"} else None + + with ( + patch("dyro.home.sys.platform", "darwin"), + patch("dyro.home.Path.is_dir", return_value=True), + patch("dyro.home.shutil.which", side_effect=detected), + ): + tools = home_tools(load(self.root), workspace=self.root) + + by_id = {tool.id: tool for tool in tools} + self.assertEqual( + by_id["codex-desktop"].argv, + ("codex", "app", str(self.root)), + ) + claude_argv = by_id["claude-desktop"].argv + self.assertEqual(claude_argv[0], "open") + self.assertTrue(claude_argv[1].startswith("claude://code/new?folder=")) + for tool_id in ("codex-desktop", "claude-desktop"): + self.assertEqual(by_id[tool_id].kind, "launcher") + self.assertEqual(by_id[tool_id].state, ToolState.READY) + self.assertNotIn(tool_id, load(self.root).adapters) + + def test_macos_desktop_detection_accepts_codex_and_claude_app_variants( + self, + ) -> None: + with ( + patch("dyro.home.sys.platform", "darwin"), + patch( + "dyro.home.Path.is_dir", + side_effect=(False, False, True, False, False, True), + ), + ): + self.assertEqual(_macos_app_name("Codex", "ChatGPT"), "ChatGPT") + self.assertEqual( + _macos_app_name("Claude", "Claude Code URL Handler"), + "Claude Code URL Handler", + ) + + def test_agent_discovery_lists_detected_codex_and_claude_desktops(self) -> None: + def detected(name: str) -> str | None: + return "/fake/" + name if name in {"codex", "open"} else None + + output = StringIO() + with ( + patch("dyro.home.sys.platform", "darwin"), + patch("dyro.home.Path.is_dir", return_value=True), + patch("dyro.home.shutil.which", side_effect=detected), + redirect_stdout(output), + ): + main(["--root", str(self.root), "agent", "discover"]) + + rendered = output.getvalue() + self.assertIn("codex-desktop", rendered) + self.assertIn("claude-desktop", rendered) + self.assertIn("尚未集成", rendered) + def test_home_tool_picker_shows_common_choices_before_full_catalog(self) -> None: discovered = {"agy", "claude", "kimi", "qodercli", "zcode"} answers = iter(["m", "antigravity"]) diff --git a/tests/test_tooling.py b/tests/test_tooling.py index d476b5a..5e29090 100644 --- a/tests/test_tooling.py +++ b/tests/test_tooling.py @@ -55,14 +55,18 @@ def test_preferences_reject_unknown_fields_and_duplicate_pins(self) -> None: class GuidedInstallerTests(unittest.TestCase): - def test_catalog_includes_antigravity_qoder_and_zcode(self) -> None: + def test_catalog_includes_desktop_and_new_terminal_tools(self) -> None: antigravity = tool_definition("antigravity") + codex_desktop = tool_definition("codex-desktop") + claude_desktop = tool_definition("claude-desktop") qoder = tool_definition("qoder") zcode = tool_definition("zcode") self.assertEqual(antigravity.command if antigravity else "", "agy") self.assertEqual(qoder.command if qoder else "", "qodercli") self.assertEqual(zcode.interface if zcode else "", "desktop") + self.assertEqual(codex_desktop.interface if codex_desktop else "", "desktop") + self.assertEqual(claude_desktop.interface if claude_desktop else "", "desktop") self.assertEqual( qoder.install.argv if qoder and qoder.install else (), ("npm", "install", "-g", "@qoder-ai/qodercli"), From f5cd7f2b9212ff1dec2967170f4718217c89969f Mon Sep 17 00:00:00 2001 From: Dandre Yang Date: Tue, 4 Aug 2026 23:26:40 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20Dyro=20?= =?UTF-8?q?=E5=BC=95=E5=AF=BC=E4=B8=8E=E6=8E=A7=E5=88=B6=E5=8F=B0=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dyro/cli.py | 1014 +++++++++++++++++------- src/dyro/console/assets.py | 12 +- src/dyro/console/assets/app.js | 159 +++- src/dyro/console/assets/index.html | 56 +- src/dyro/console/assets/styles.css | 318 ++++++-- src/dyro/console/inspection.py | 2 +- src/dyro/console/overview.py | 5 +- src/dyro/home.py | 1162 +++++++++++++++++++++++++--- src/dyro/terminal.py | 72 ++ src/dyro/workspace.py | 98 ++- tests/test_cli.py | 273 ++++++- tests/test_console_assets.py | 10 + tests/test_console_overview.py | 8 + tests/test_hub.py | 532 ++++++++++++- tests/test_terminal.py | 36 + tests/test_workspace.py | 23 +- 16 files changed, 3192 insertions(+), 588 deletions(-) create mode 100644 src/dyro/terminal.py create mode 100644 tests/test_terminal.py diff --git a/src/dyro/cli.py b/src/dyro/cli.py index da56f39..70ffb37 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -20,7 +20,12 @@ preflight_join_plan, render_join_plan, ) -from .changesets import create_changeset, get_changeset, list_changesets, verify_changeset +from .changesets import ( + create_changeset, + get_changeset, + list_changesets, + verify_changeset, +) from .config import CONFIG_NAME, Config, load, validate_id from .console.launcher import launch_console, render_console_plan from .continuation.attention import ( @@ -64,7 +69,12 @@ supervised_outcomes_payload, supervised_wave_payload, ) -from .continuation.triggers import TriggerConfig, TriggerKind, TriggerProbeInput, probe_builtin +from .continuation.triggers import ( + TriggerConfig, + TriggerKind, + TriggerProbeInput, + probe_builtin, +) from .evidence import build_execution_bundle, unpack_execution_bundle from .errors import DyroError, ValidationError from .home import ( @@ -103,8 +113,16 @@ repository_input_from_path, sibling_workspace_for, ) -from .profile import append_adapter, command_adapter, config_value, preset_adapter, set_config_value, test_adapter +from .profile import ( + append_adapter, + command_adapter, + config_value, + preset_adapter, + set_config_value, + test_adapter, +) from .state import atomic_write_text, exclusive_lock +from .terminal import danger, muted, success, title, value as terminal_value, warning from .graph import ( build_task_graph, explain_task, @@ -163,7 +181,7 @@ from .workspace import create_line, doctor, get_line, list_lines, status_rows -CONFIG_TEMPLATE = '''schema_version = 1 +CONFIG_TEMPLATE = """schema_version = 1 [workspace] name = "{name}" @@ -199,7 +217,7 @@ path = "repositories/clients/web" mount = "clients/web" verify = [["npm", "test", "--", "--runInBand"]] -''' +""" def _config(args: argparse.Namespace) -> Config: @@ -214,7 +232,9 @@ def _config(args: argparse.Namespace) -> Config: return resolve_workspace( start=Path.cwd(), interactive=interactive, - chooser=(lambda label, values: _choose(label, list(values))) if interactive else None, + chooser=(lambda label, values: _choose(label, list(values))) + if interactive + else None, ) return load(root) @@ -245,12 +265,16 @@ def _repository_assignments(values: list[str] | None, label: str) -> dict[str, s def _require_yes(args: argparse.Namespace, label: str) -> None: if not args.yes and not args.dry_run: - raise DyroError(f"{label} 会创建或修改 Git worktree;请先使用 --dry-run 检查,再加 --yes 执行") + raise DyroError( + f"{label} 会创建或修改 Git worktree;请先使用 --dry-run 检查,再加 --yes 执行" + ) def _require_objective_yes(args: argparse.Namespace, label: str) -> None: if not args.yes and not args.dry_run: - raise DyroError(f"{label} 会修改 Objective 状态;请先使用 --dry-run 检查,再加 --yes 执行") + raise DyroError( + f"{label} 会修改 Objective 状态;请先使用 --dry-run 检查,再加 --yes 执行" + ) def _objective_contract_from_args(args: argparse.Namespace, config: Config) -> str: @@ -262,23 +286,36 @@ def _objective_contract_from_args(args: argparse.Namespace, config: Config) -> s if not args.id or not args.title: raise DyroError("非文件模式必须提供 --id 与 --title") interactive = sys.stdin.isatty() and sys.stdout.isatty() - selected_line = args.line or resolve_line( - config, - interactive=interactive, - chooser=(lambda label, values: _choose(label, list(values))) if interactive else None, - ).id - targets = tuple(item.strip() for item in (args.targets or "").split(",") if item.strip()) + selected_line = ( + args.line + or resolve_line( + config, + interactive=interactive, + chooser=(lambda label, values: _choose(label, list(values))) + if interactive + else None, + ).id + ) + targets = tuple( + item.strip() for item in (args.targets or "").split(",") if item.strip() + ) if not targets: - raise DyroError("非文件模式必须提供 --targets TASK_ID[,TASK_ID...];交互选择将在后续版本提供") + raise DyroError( + "非文件模式必须提供 --targets TASK_ID[,TASK_ID...];交互选择将在后续版本提供" + ) mode = args.mode or RequestedMode.SUPERVISED.value - operations = tuple(args.operation or (Operation.EXECUTE.value, Operation.REVIEW.value)) + operations = tuple( + args.operation or (Operation.EXECUTE.value, Operation.REVIEW.value) + ) return "\n".join( ( "schema_version = 1", f"id = {json.dumps(args.id, ensure_ascii=False)}", f"title = {json.dumps(args.title, ensure_ascii=False)}", f"line = {json.dumps(selected_line, ensure_ascii=False)}", - "targets = [" + ", ".join(json.dumps(item, ensure_ascii=False) for item in targets) + "]", + "targets = [" + + ", ".join(json.dumps(item, ensure_ascii=False) for item in targets) + + "]", "", "[continuation]", f"requested_mode = {json.dumps(mode)}", @@ -315,7 +352,9 @@ def cmd_init(args: argparse.Namespace) -> None: elif args.discover: repositories = discover_repositories(root) if not repositories: - raise DyroError("未发现 Git 仓库;可先 clone 仓库,或使用 dyro init --wizard") + raise DyroError( + "未发现 Git 仓库;可先 clone 仓库,或使用 dyro init --wizard" + ) content = render_config(args.name, repositories, args.base) else: content = CONFIG_TEMPLATE.format(name=args.name) @@ -324,13 +363,18 @@ def cmd_init(args: argparse.Namespace) -> None: (root / relative).mkdir(parents=True, exist_ok=True) print(f"已初始化 {root}") if args.discover: - print(f"已自动登记 {len(repositories)} 个本地 Git 仓库;下一步:运行 dyro doctor") + print( + f"已自动登记 {len(repositories)} 个本地 Git 仓库;下一步:运行 dyro doctor" + ) else: print("下一步:登记 repositories,随后运行 dyro doctor") def _default_workspace_name(root: Path) -> str: - candidate = "".join(character if character.isascii() and character.isalnum() else "-" for character in root.name).strip("-._") + candidate = "".join( + character if character.isascii() and character.isalnum() else "-" + for character in root.name + ).strip("-._") candidate = candidate or "my-workspace" if not candidate[0].isalnum(): candidate = "workspace-" + candidate @@ -408,13 +452,17 @@ def _render_setup_registration_plan( registration: WorkspaceRegistrationPlan | None, ) -> None: if registration is None: - print(" - Console:不登记全局入口(--no-register)") + print(" - Console:" + muted("不登记全局入口(--no-register)")) return name = registration.name root = registration.root - action = "已登记,保持现有入口" if registration.already_registered else "将登记全局入口" + action = ( + "已登记,保持现有入口" if registration.already_registered else "将登记全局入口" + ) default = ";设为默认项目" if registration.becomes_default else ";不改变默认项目" - print(f" - Console:{action} {name} → {root}{default}") + print( + f" - Console:{action} {terminal_value(name)} → {terminal_value(root)}{default}" + ) def _register_setup_workspace( @@ -422,36 +470,48 @@ def _register_setup_workspace( ) -> WorkspaceRecord | None: if not register: return None - record = add_workspace( - config.root, name=config.name, make_default=make_default - ) + record = add_workspace(config.root, name=config.name, make_default=make_default) default = "(默认项目)" if load_registry().default == record.name else "" - print(f"已登记全局入口:{record.name}{default}") + print(success(f"已登记全局入口:{record.name}{default}")) return record def _print_setup_completion( config: Config, registration: WorkspaceRecord | None ) -> None: - print("\n设置完成:") - print(f" - Profile:{config.name}") + print("\n" + success("━━ 设置完成 ━━")) + print(f" - Profile:{terminal_value(config.name)}") if registration is None: - print(" - Console:未登记(--no-register)") + print(" - Console:" + muted("未登记(--no-register)")) else: - print(f" - Console:{registration.name} 已可在 dyro console 中查看") - print("下一步:dyro start") + print( + f" - Console:{terminal_value(registration.name)} 已可在 dyro console 中查看" + ) + print("下一步:" + terminal_value("dyro start")) def _render_interactive_setup_plan( plan: SetupPlan, registration: WorkspaceRegistrationPlan | None ) -> None: - print("\n设置计划(尚未修改任何文件):") + print("\n" + title("━━ 设置计划 ━━")) + print(muted("尚未修改任何文件。")) for item in render_setup_plan(plan): print(" - " + item) _render_setup_registration_plan(registration) if plan.needs_bootstrap: - print(" - 将仅 clone 缺失且已明确提供 remote 的仓库") - print(" - 不会移动、覆盖或清理现有 Git 仓库") + print(" - " + muted("将仅 clone 缺失且已明确提供 remote 的仓库")) + print(" - " + muted("不会移动、覆盖或清理现有 Git 仓库")) + + +def _print_doctor_finding(finding: str) -> None: + if finding.startswith("PASS"): + print(success(finding)) + elif finding.startswith("WARN"): + print(warning(finding)) + elif finding.startswith("FAIL"): + print(danger(finding)) + else: + print(finding) def _apply_setup_plan( @@ -494,10 +554,14 @@ def _apply_setup_plan( base=plan.default_base, kind="line", ) - print(f"已创建开发线 {line.id}({line.branch})") + print( + success("已创建开发线 ") + + terminal_value(line.id) + + f"({terminal_value(line.branch)})" + ) findings = doctor(config) for finding in findings: - print(finding) + _print_doctor_finding(finding) if any(finding.startswith("FAIL") for finding in findings): raise DyroError("设置已保存,但 doctor 发现问题;请修复后运行 dyro next") _print_setup_completion(config, registration) @@ -507,18 +571,23 @@ def _interactive_setup(args: argparse.Namespace) -> None: root = Path(args.path).expanduser().resolve() config_file = root / CONFIG_NAME suggested_base = args.base or "main" - print("Dyro 首次设置。先检查环境,确认前不会修改文件。") + print("\n" + title("━━ Dyro 首次设置 ━━")) + print(muted("先检查环境,确认前不会修改文件。")) if config_file.exists(): config = load(root) registration = _setup_registration_plan(config.root, config.name, args) - print(f"已发现 Profile:{config_file}({len(config.repositories)} 个仓库)") + print( + f"已发现 Profile:{terminal_value(config_file)}({len(config.repositories)} 个仓库)" + ) _render_setup_registration_plan(registration) for finding in doctor(config): - print(finding) + _print_doctor_finding(finding) if args.dry_run or registration is None: - print("下一步:dyro next") + print("下一步:" + terminal_value("dyro next")) return - if not args.yes and not _ask_yes_no("将此 Profile 登记到全局 Console", default=False): + if not args.yes and not _ask_yes_no( + "将此 Profile 登记到全局 Console", default=False + ): print("已取消;没有修改全局入口。") return record = _register_setup_workspace( @@ -527,24 +596,38 @@ def _interactive_setup(args: argparse.Namespace) -> None: _print_setup_completion(config, record) return - repositories = discover_repositories(root) if root.exists() and not is_git_repository(root) else [] + repositories = ( + discover_repositories(root) + if root.exists() and not is_git_repository(root) + else [] + ) if is_git_repository(root): remote = origin_url(root) if not remote: - print("当前目录是 Git 仓库,但没有 origin。Dyro 不会把控制状态写进该仓库。") - print("请先为它配置 origin,或在包含多个仓库的独立目录中运行 dyro setup。") + print( + warning( + "当前目录是 Git 仓库,但没有 origin。Dyro 不会把控制状态写进该仓库。" + ) + ) + print( + muted( + "请先为它配置 origin,或在包含多个仓库的独立目录中运行 dyro setup。" + ) + ) return source_branch = current_branch(root) if source_branch and not args.base: suggested_base = source_branch suggested_root = sibling_workspace_for(root) - raw_root = _ask_value("为这个项目创建独立 Dyro 工作区", default=str(suggested_root)) + raw_root = _ask_value( + "为这个项目创建独立 Dyro 工作区", default=str(suggested_root) + ) root = Path(raw_root).expanduser().resolve() if root.exists() and any(root.iterdir()): raise DyroError(f"建议工作区必须为空或不存在:{root}") repository = repository_from_remote(remote) repositories = [repository] - print("将从当前仓库的 origin clone 新 anchor;当前仓库保持不变。") + print(muted("将从当前仓库的 origin clone 新 anchor;当前仓库保持不变。")) elif not repositories: remote = _ask_value("未发现本地 Git 仓库。输入一个 Git remote(留空退出)") if not remote: @@ -592,10 +675,11 @@ def _interactive_setup(args: argparse.Namespace) -> None: def _setup_quick(args: argparse.Namespace) -> None: root = Path(args.path).expanduser().resolve() config = load(root) - print(f"检查现有 Profile:{config.root}") + print("\n" + title("━━ 快速检查 ━━")) + print(f"检查现有 Profile:{terminal_value(config.root)}") for finding in doctor(config): - print(finding) - print("下一步:dyro next") + _print_doctor_finding(finding) + print("下一步:" + terminal_value("dyro next")) def _non_interactive_setup(args: argparse.Namespace) -> None: @@ -605,26 +689,34 @@ def _non_interactive_setup(args: argparse.Namespace) -> None: created = False if config_file.exists(): config = load(root) - print(f"复用已有 Profile:{config_file}") + print(f"复用已有 Profile:{terminal_value(config_file)}") registration = _setup_registration_plan(config.root, config.name, args) else: repositories = discover_repositories(root) if not repositories: - raise DyroError("未发现 Git 仓库;请先 clone 仓库到工作区,或使用 dyro init --wizard") + raise DyroError( + "未发现 Git 仓库;请先 clone 仓库到工作区,或使用 dyro init --wizard" + ) name = args.name or _default_workspace_name(root) validate_id(name, "workspace 名称") registration = _setup_registration_plan(root, name, args) if args.dry_run: - print(f"DRY RUN: 将创建 {config_file},自动登记 {len(repositories)} 个 Git 仓库") + print( + f"DRY RUN: 将创建 {config_file},自动登记 {len(repositories)} 个 Git 仓库" + ) _render_setup_registration_plan(registration) if not args.no_line: - print(f"DRY RUN: 将创建开发线 {args.line}(分支 {args.branch or f'feat/{args.line}'})") + print( + f"DRY RUN: 将创建开发线 {args.line}(分支 {args.branch or f'feat/{args.line}'})" + ) return root.mkdir(parents=True, exist_ok=True) - atomic_write_text(config_file, render_config(name, repositories, args.base or "main")) + atomic_write_text( + config_file, render_config(name, repositories, args.base or "main") + ) config = load(root) created = True - print(f"已创建 Profile,并自动登记 {len(repositories)} 个 Git 仓库") + print(success(f"已创建 Profile,并自动登记 {len(repositories)} 个 Git 仓库")) if config_file.exists() or not args.dry_run: _render_setup_registration_plan(registration) if not args.dry_run: @@ -650,12 +742,18 @@ def _non_interactive_setup(args: argparse.Namespace) -> None: kind="line", dry_run=args.dry_run, ) - print(f"{'DRY RUN: ' if args.dry_run else ''}已创建开发线 {line.id}({line.branch})") + prefix = "DRY RUN: " if args.dry_run else "" + print( + prefix + + success("已创建开发线 ") + + terminal_value(line.id) + + f"({terminal_value(line.branch)})" + ) else: print(f"开发线已存在:{existing.id}({existing.branch})") findings = doctor(config) for finding in findings: - print(finding) + _print_doctor_finding(finding) if any(finding.startswith("FAIL") for finding in findings): raise DyroError("setup 已完成基础配置,但 doctor 仍发现结构错误") if created or registered is not None: @@ -692,9 +790,15 @@ def cmd_setup(args: argparse.Namespace) -> None: def cmd_repo_list(args: argparse.Namespace) -> None: config = _config(args) - print(f"{'ID':18} {'ANCHOR':36} {'MOUNT':28} REMOTE") + print("\n" + title("━━ 已登记仓库 ━━")) + print(muted(f"Profile:{config.name} · {len(config.repositories)} 个仓库")) + print(muted(f"{'ID':18} {'ANCHOR':36} {'MOUNT':28} REMOTE")) for repository_id, repository in sorted(config.repositories.items()): - print(f"{repository_id:18} {repository.path:36} {repository.mount:28} {'configured' if repository.remote else '-'}") + remote = success("configured") if repository.remote else muted("-") + print( + f"{terminal_value(f'{repository_id:18}')} " + f"{repository.path:36} {repository.mount:28} {remote}" + ) def cmd_repo_add(args: argparse.Namespace) -> None: @@ -721,23 +825,34 @@ def cmd_repo_add(args: argparse.Namespace) -> None: def cmd_bootstrap(args: argparse.Namespace) -> None: _require_yes(args, "bootstrap") config = _config(args) + print("\n" + title("━━ 初始化仓库 ━━")) + print(muted("只处理当前 Profile 中缺失的 anchor 仓库。")) for message in bootstrap(config, dry_run=args.dry_run): print(message) if not args.dry_run: + print("\n" + title("━━ 初始化后检查 ━━")) for finding in doctor(config): - print(finding) + _print_doctor_finding(finding) def cmd_doctor(args: argparse.Namespace) -> None: - findings = doctor(_config(args)) + config = _config(args) + print("\n" + title("━━ Dyro 健康检查 ━━")) + print(muted(f"Profile:{config.name} · 检查仓库、基线与隔离工作区。")) + findings = doctor(config) for finding in findings: - print(finding) + _print_doctor_finding(finding) if any(item.startswith("FAIL") for item in findings): raise DyroError("doctor 发现结构错误") + print("\n" + success("检查通过。") + " 下一步:" + terminal_value("dyro")) def cmd_terminology_check(args: argparse.Namespace) -> None: - root = _config(args).root if args.workspace_alias else Path(args.root or ".").expanduser().resolve() + root = ( + _config(args).root + if args.workspace_alias + else Path(args.root or ".").expanduser().resolve() + ) policy = load_terminology_policy( root, policy_file=Path(args.policy_file) if args.policy_file else None, @@ -801,7 +916,12 @@ def cmd_workspace_add(args: argparse.Namespace) -> None: name=args.name, make_default=args.default, ) - print(f"已登记工作区:{record.name} -> {record.root}") + print( + success("已登记工作区:") + + terminal_value(record.name) + + " → " + + terminal_value(record.root) + ) def cmd_workspace_list(args: argparse.Namespace) -> None: @@ -809,16 +929,25 @@ def cmd_workspace_list(args: argparse.Namespace) -> None: if not registry.workspaces: print("还没有登记全局工作区。下一步:dyro workspace add <路径>") return - print(f"{'默认':4} {'名称':20} {'状态':8} 路径") + print("\n" + title("━━ 全局工作区 ━━")) + print(muted("这里只管理首页入口,不会移动或删除项目文件。")) + print(muted(f"{'默认':4} {'名称':20} {'状态':8} 路径")) for record in registry.workspaces: - marker = "*" if record.name == registry.default else "-" + marker = ( + success(f"{'●':4}") + if record.name == registry.default + else muted(f"{'·':4}") + ) try: load(record.root) except (DyroError, ValidationError): - state = "不可用" + state = danger(f"{'不可用':8}") else: - state = "可用" - print(f"{marker:4} {record.name:20} {state:8} {record.root}") + state = success(f"{'可用':8}") + print( + f"{marker}{terminal_value(f'{record.name:20}')} {state} " + f"{terminal_value(record.root)}" + ) def cmd_workspace_default(args: argparse.Namespace) -> None: @@ -833,9 +962,7 @@ def cmd_workspace_default(args: argparse.Namespace) -> None: def cmd_workspace_remove(args: argparse.Namespace) -> None: get_workspace(args.name) if not args.yes and not args.dry_run: - raise DyroError( - "移除只会删除全局首页入口,不会删除项目文件;确认后请加 --yes" - ) + raise DyroError("移除只会删除全局首页入口,不会删除项目文件;确认后请加 --yes") if args.dry_run: print(f"DRY RUN: 将移除工作区入口 {args.name};不会删除项目文件") return @@ -902,7 +1029,9 @@ def cmd_join(args: argparse.Namespace) -> None: return if not args.yes: if not sys.stdin.isatty(): - raise DyroError("join 会创建工作区和 Git worktree;请先使用 --dry-run,再加 --yes 执行") + raise DyroError( + "join 会创建工作区和 Git worktree;请先使用 --dry-run,再加 --yes 执行" + ) if not _ask_yes_no("应用此加入计划", default=False): print("已取消;没有修改任何文件。") return @@ -924,9 +1053,7 @@ def cmd_join(args: argparse.Namespace) -> None: for scope, repository_id, _branch, _head, _upstream, dirty in status_rows(config): if scope in selected_scopes and dirty == 0: clean_scopes[repository_id].add(scope) - clean_count = sum( - scopes == selected_scopes for scopes in clean_scopes.values() - ) + clean_count = sum(scopes == selected_scopes for scopes in clean_scopes.values()) print(f"仓库:{clean_count}/{len(config.repositories)} clean") print("下一步:dyro") @@ -940,8 +1067,12 @@ def cmd_status(args: argparse.Namespace) -> None: def cmd_agent_list(args: argparse.Namespace) -> None: config = _config(args) + print("\n" + title("━━ 已登记 Agent ━━")) for adapter_id, adapter in sorted(config.adapters.items()): - print(f"{adapter_id:16} launch={shlex.join(adapter.launch)}") + print( + f"{terminal_value(f'{adapter_id:16}')} " + f"{muted('launch=')}{shlex.join(adapter.launch)}" + ) def cmd_agent_add(args: argparse.Namespace) -> None: @@ -955,14 +1086,17 @@ def cmd_agent_add(args: argparse.Namespace) -> None: raise DyroError(f"Agent command 解析失败:{exc}") from exc adapter = command_adapter(args.id, command) append_adapter(config, adapter, dry_run=args.dry_run) - print(f"{'DRY RUN: 将添加' if args.dry_run else '已添加'} Agent adapter:{adapter.id}") + print( + f"{'DRY RUN: 将添加' if args.dry_run else '已添加'} Agent adapter:{adapter.id}" + ) def cmd_agent_test(args: argparse.Namespace) -> None: checks = test_adapter(_config(args), args.id) failures = [] for mode, available, executable in checks: - print(f"{'PASS' if available else 'FAIL'} {args.id}.{mode}: {executable}") + rendered = f"{'PASS' if available else 'FAIL'} {args.id}.{mode}: {executable}" + print(success(rendered) if available else danger(rendered)) if not available: failures.append(mode) if failures: @@ -995,7 +1129,15 @@ def cmd_tool_list(args: argparse.Namespace) -> None: ToolState.INSTALLABLE: "可引导安装", ToolState.UNAVAILABLE: "不可用", } - print(f"{'ID':20} {'状态':12} {'类型':10} 名称") + print("\n" + title("━━ 编码工具 ━━")) + print(muted("顺序已综合上次使用、项目推荐与个人偏好。")) + print(muted(f"{'ID':20} {'状态':12} {'类型':10} 名称")) + state_styles = { + ToolState.READY: success, + ToolState.NEEDS_SETUP: warning, + ToolState.INSTALLABLE: warning, + ToolState.UNAVAILABLE: danger, + } for tool in tools: markers: list[str] = [] if record and tool.id == record.last_agent: @@ -1006,8 +1148,9 @@ def cmd_tool_list(args: argparse.Namespace) -> None: markers.append("个人默认") suffix = f" [{' / '.join(markers)}]" if markers else "" print( - f"{tool.id:20} {labels[tool.state]:12} {tool.kind:10} " - f"{tool.label}{suffix}" + f"{terminal_value(f'{tool.id:20}')} " + f"{state_styles[tool.state](f'{labels[tool.state]:12}')} " + f"{muted(f'{tool.kind:10}')} {terminal_value(tool.label)}{muted(suffix)}" ) @@ -1051,9 +1194,7 @@ def cmd_tool_pin(args: argparse.Namespace) -> None: return set_pinned_tools(tool_ids) print( - "已清除工具置顶顺序" - if not tool_ids - else "工具置顶顺序:" + ", ".join(tool_ids) + "已清除工具置顶顺序" if not tool_ids else "工具置顶顺序:" + ", ".join(tool_ids) ) @@ -1143,7 +1284,9 @@ def cmd_config_set(args: argparse.Namespace) -> None: value = set_config_value(config, args.key, args.value, dry_run=args.dry_run) if not args.dry_run: load(config.root) - print(f"{'DRY RUN: 将设置' if args.dry_run else '已设置'} {args.key} = {json.dumps(value, ensure_ascii=False)}") + print( + f"{'DRY RUN: 将设置' if args.dry_run else '已设置'} {args.key} = {json.dumps(value, ensure_ascii=False)}" + ) def cmd_open(args: argparse.Namespace) -> None: @@ -1178,13 +1321,22 @@ def cmd_start(args: argparse.Namespace) -> None: failures = [finding for finding in findings if finding.startswith("FAIL")] if failures: print("\n".join(failures)) - raise DyroError("工作区尚未就绪;先修复 doctor 失败项,或运行 dyro bootstrap --yes") + raise DyroError( + "工作区尚未就绪;先修复 doctor 失败项,或运行 dyro bootstrap --yes" + ) if not config.adapters: raise DyroError("尚未配置可启动的 Agent;先运行 dyro next 查看安全的下一步") line_id = args.line or _choose("开发线", [line.id for line in list_lines(config)]) line = get_line(config, line_id, args.kind) agent = args.agent or _choose("Agent", sorted(config.adapters)) - open_args = argparse.Namespace(root=str(config.root), line=line.id, kind=line.kind, agent=agent, prompt=args.prompt or "", dry_run=args.dry_run) + open_args = argparse.Namespace( + root=str(config.root), + line=line.id, + kind=line.kind, + agent=agent, + prompt=args.prompt or "", + dry_run=args.dry_run, + ) cmd_open(open_args) @@ -1204,7 +1356,9 @@ def cmd_next(args: argparse.Namespace) -> None: print("工作区还不能开始任务:") for finding in failures: print(" " + finding) - print("下一步:dyro doctor;若仓库缺失且已配置 remote,则运行 dyro bootstrap --yes") + print( + "下一步:dyro doctor;若仓库缺失且已配置 remote,则运行 dyro bootstrap --yes" + ) return lines = list_lines(config) if not lines: @@ -1212,12 +1366,18 @@ def cmd_next(args: argparse.Namespace) -> None: return if not config.adapters: if shutil.which("codex"): - print("工作区已就绪,检测到 Codex 尚未加入 Profile。下一步:dyro agent add codex --preset codex") + print( + "工作区已就绪,检测到 Codex 尚未加入 Profile。下一步:dyro agent add codex --preset codex" + ) else: - print("工作区已就绪,但尚未配置可启动的 Agent。下一步:dyro agent add --command '…'") + print( + "工作区已就绪,但尚未配置可启动的 Agent。下一步:dyro agent add --command '…'" + ) return if len(lines) == 1 and len(config.adapters) == 1: - print(f"工作区已就绪。下一步:dyro start --line {lines[0].id} --agent {next(iter(config.adapters))}") + print( + f"工作区已就绪。下一步:dyro start --line {lines[0].id} --agent {next(iter(config.adapters))}" + ) return print("工作区已就绪。下一步:dyro start") @@ -1231,17 +1391,24 @@ def cmd_line_list(args: argparse.Namespace) -> None: print(f"{'KIND':8} {'ID':28} {'BRANCH':30} {'BASE':24} REPOSITORIES") for line in lines: repositories = ", ".join( - f"{repo_id}@{line.base_for(repo_id)}[{line.storage_for(repo_id)}]" for repo_id in line.repositories + f"{repo_id}@{line.base_for(repo_id)}[{line.storage_for(repo_id)}]" + for repo_id in line.repositories + ) + print( + f"{line.kind:8} {line.id:28} {line.branch:30} {line.base:24} {repositories}" ) - print(f"{line.kind:8} {line.id:28} {line.branch:30} {line.base:24} {repositories}") def _create_line(args: argparse.Namespace, kind: str) -> None: config = _config(args) _require_yes(args, "创建开发线") - branch = args.branch or (f"hotfix/{args.id}" if kind == "hotfix" else f"feat/{args.id}") + branch = args.branch or ( + f"hotfix/{args.id}" if kind == "hotfix" else f"feat/{args.id}" + ) if kind == "hotfix" and not args.base: - raise DyroError("Hotfix 必须显式提供 --base(已核实的 release/tag/deployed SHA)") + raise DyroError( + "Hotfix 必须显式提供 --base(已核实的 release/tag/deployed SHA)" + ) base = args.base or config.policy.default_base repository_bases = _repository_assignments(args.repo_base, "--repo-base") storage_modes = _repository_assignments(args.storage, "--storage") @@ -1256,8 +1423,12 @@ def _create_line(args: argparse.Namespace, kind: str) -> None: kind=kind, dry_run=args.dry_run, ) - bases = ", ".join(f"{repo_id}={line.base_for(repo_id)}" for repo_id in line.repositories) - print(f"{'DRY RUN: ' if args.dry_run else ''}已创建 {line.kind} {line.id},分支 {line.branch},仓库基线:{bases}") + bases = ", ".join( + f"{repo_id}={line.base_for(repo_id)}" for repo_id in line.repositories + ) + print( + f"{'DRY RUN: ' if args.dry_run else ''}已创建 {line.kind} {line.id},分支 {line.branch},仓库基线:{bases}" + ) def cmd_line_create(args: argparse.Namespace) -> None: @@ -1277,8 +1448,13 @@ def cmd_changeset_create(args: argparse.Namespace) -> None: repositories=_repositories(args.repos), dry_run=args.dry_run, ) - heads = ", ".join(f"{repository}={changeset.heads[repository][:12]}" for repository in changeset.repositories) - print(f"{'DRY RUN: ' if args.dry_run else ''}已创建 Change Set {changeset.id}:{heads}") + heads = ", ".join( + f"{repository}={changeset.heads[repository][:12]}" + for repository in changeset.repositories + ) + print( + f"{'DRY RUN: ' if args.dry_run else ''}已创建 Change Set {changeset.id}:{heads}" + ) def cmd_changeset_list(args: argparse.Namespace) -> None: @@ -1288,7 +1464,9 @@ def cmd_changeset_list(args: argparse.Namespace) -> None: return print(f"{'ID':28} {'LINE':24} {'BRANCH':28} REPOSITORIES") for changeset in changesets: - print(f"{changeset.id:28} {changeset.line:24} {changeset.branch:28} {', '.join(changeset.repositories)}") + print( + f"{changeset.id:28} {changeset.line:24} {changeset.branch:28} {', '.join(changeset.repositories)}" + ) def cmd_changeset_verify(args: argparse.Namespace) -> None: @@ -1315,8 +1493,13 @@ def cmd_task_create(args: argparse.Namespace) -> None: raise DyroError(f"任务目录已存在:{path}") path.mkdir(parents=True) mount = config.repositories[args.repository].mount - atomic_write_text(path / "task.toml", task_template(args.id, args.title, args.line, args.repository, mount)) - atomic_write_text(path / "handoff.md", f"# {args.title}\n\n- 目标:\n- 范围:\n- 验收:\n") + atomic_write_text( + path / "task.toml", + task_template(args.id, args.title, args.line, args.repository, mount), + ) + atomic_write_text( + path / "handoff.md", f"# {args.title}\n\n- 目标:\n- 范围:\n- 验收:\n" + ) print(f"已创建任务:{path}") @@ -1356,7 +1539,9 @@ def cmd_task_binding(args: argparse.Namespace) -> None: def cmd_task_list(args: argparse.Namespace) -> None: config = _config(args) for task in list_tasks(config): - print(f"{task.id:30} {task_status(config, task):16} {task.line:20} {task.title}") + print( + f"{task.id:30} {task_status(config, task):16} {task.line:20} {task.title}" + ) def cmd_task_board(args: argparse.Namespace) -> None: @@ -1393,15 +1578,11 @@ def cmd_task_open(args: argparse.Namespace) -> None: def cmd_task_claim(args: argparse.Namespace) -> None: config = _config(args) task = load_task(config, args.id) - requested_output = ( - Path(args.output).expanduser() if args.output else None - ) + requested_output = Path(args.output).expanduser() if args.output else None if requested_output is not None and ( requested_output.exists() or requested_output.is_symlink() ): - raise DyroError( - f"拒绝覆盖已有 claim 导出文件:{requested_output}" - ) + raise DyroError(f"拒绝覆盖已有 claim 导出文件:{requested_output}") output = ( requested_output.parent.resolve() / requested_output.name if requested_output is not None @@ -1412,9 +1593,7 @@ def cmd_task_claim(args: argparse.Namespace) -> None: try: output.parent.mkdir(parents=True, exist_ok=True) except OSError as exc: - raise DyroError( - f"无法创建 claim 导出目录:{output.parent}" - ) from exc + raise DyroError(f"无法创建 claim 导出目录:{output.parent}") from exc flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL flags |= getattr(os, "O_CLOEXEC", 0) flags |= getattr(os, "O_NOFOLLOW", 0) @@ -1422,9 +1601,7 @@ def cmd_task_claim(args: argparse.Namespace) -> None: try: descriptor = os.open(output, flags, initial_mode) except FileExistsError as exc: - raise DyroError( - f"拒绝覆盖已有 claim 导出文件:{output}" - ) from exc + raise DyroError(f"拒绝覆盖已有 claim 导出文件:{output}") from exc except OSError as exc: raise DyroError(f"无法创建 claim 导出文件:{output}") from exc try: @@ -1487,7 +1664,9 @@ def cmd_task_claim_release(args: argparse.Namespace) -> None: config = _config(args) task = load_task(config, args.id) - print(f"{task.id} -> {release_task_claim(config, task, runner=args.by, dry_run=args.dry_run)}") + print( + f"{task.id} -> {release_task_claim(config, task, runner=args.by, dry_run=args.dry_run)}" + ) def cmd_task_next(args: argparse.Namespace) -> None: @@ -1500,7 +1679,10 @@ def cmd_task_next(args: argparse.Namespace) -> None: if not args.run: for task in candidates: print(f"{task.id:30} {task.line:20} {task.title}") - print("运行:dyro task next --run --yes" + (" --id <任务ID>" if len(candidates) > 1 else "")) + print( + "运行:dyro task next --run --yes" + + (" --id <任务ID>" if len(candidates) > 1 else "") + ) return _require_yes(args, "启动下一个任务") if len(candidates) > 1: @@ -1565,7 +1747,9 @@ def cmd_task_evidence_execution(args: argparse.Namespace) -> None: receipt=evidence["receipt"], gates=evidence["gates"] if evidence["gates"].is_file() else None, heads=evidence["heads"] if evidence["heads"].is_file() else None, - provenance=evidence["provenance"] if evidence["provenance"].is_file() else None, + provenance=evidence["provenance"] + if evidence["provenance"].is_file() + else None, allow_legacy_provenance=args.allow_legacy, dry_run=args.dry_run, ) @@ -1715,20 +1899,28 @@ def cmd_witness_serve(args: argparse.Namespace) -> None: raise ValidationError( f"环境变量 {args.auth_token_env} 未设置;本地测试才可使用 --allow-unauthenticated" ) - if args.allow_unauthenticated and args.host not in {"127.0.0.1", "::1", "localhost"}: + if args.allow_unauthenticated and args.host not in { + "127.0.0.1", + "::1", + "localhost", + }: raise ValidationError("--allow-unauthenticated 只能绑定 loopback host") if (args.tls_cert is None) != (args.tls_key is None): raise ValidationError("Witness TLS cert 与 key 必须同时设置") if args.tls_cert is None: if not args.allow_http: - raise ValidationError("Witness 必须设置 TLS,或显式使用仅本地的 --allow-http") + raise ValidationError( + "Witness 必须设置 TLS,或显式使用仅本地的 --allow-http" + ) if args.host not in {"127.0.0.1", "::1", "localhost"}: raise ValidationError("--allow-http 只能绑定 loopback host") bindings: dict[str, str] = {} for value in args.client_workspace_binding: key_id, separator, workspace_id = value.partition("=") if not separator or not key_id or not workspace_id: - raise ValidationError("--client-workspace-binding 必须为 KEY_ID=WORKSPACE_ID") + raise ValidationError( + "--client-workspace-binding 必须为 KEY_ID=WORKSPACE_ID" + ) if key_id in bindings: raise ValidationError(f"--client-workspace-binding 重复:{key_id}") bindings[key_id] = workspace_id @@ -1772,7 +1964,9 @@ def cmd_witness_serve(args: argparse.Namespace) -> None: def cmd_task_evidence_review(args: argparse.Namespace) -> None: config = _config(args) task = load_task(config, args.id) - print(f"{task.id} -> {import_review_evidence(config, task, review=Path(args.file), dry_run=args.dry_run)}") + print( + f"{task.id} -> {import_review_evidence(config, task, review=Path(args.file), dry_run=args.dry_run)}" + ) def cmd_task_evidence_review_build(args: argparse.Namespace) -> None: @@ -1797,7 +1991,8 @@ def cmd_task_evidence_review_build(args: argparse.Namespace) -> None: return atomic_write_bytes( output, - json.dumps(record, ensure_ascii=False, sort_keys=True, indent=2).encode("utf-8") + b"\n", + json.dumps(record, ensure_ascii=False, sort_keys=True, indent=2).encode("utf-8") + + b"\n", ) print(f"{task.id} -> signed review: {output}") @@ -1819,7 +2014,13 @@ def cmd_task_evidence_generations(args: argparse.Namespace) -> None: ) target_ids = {record.generation_id for record in targets} for record in records: - state = "current" if record.current else "temporary" if record.temporary else "history" + state = ( + "current" + if record.current + else "temporary" + if record.temporary + else "history" + ) action = "prune" if record.generation_id in target_ids else "keep" print( f"{record.generation_id}\t{state}\t{record.modified_at.isoformat()}\t" @@ -1835,7 +2036,10 @@ def cmd_task_merge(args: argparse.Namespace) -> None: config = _config(args) task = load_task(config, args.id) merge_task(config, task, push=args.push, dry_run=args.dry_run) - print(f"{'DRY RUN: ' if args.dry_run else ''}已合并 {task.id}" + (" 并推送" if args.push else "")) + print( + f"{'DRY RUN: ' if args.dry_run else ''}已合并 {task.id}" + + (" 并推送" if args.push else "") + ) def cmd_task_decisions(args: argparse.Namespace) -> None: @@ -1854,7 +2058,9 @@ def cmd_task_stats(args: argparse.Namespace) -> None: return print(f"{'AGENT':18} {'EXEC':>5} {'EXEC OK':>8} {'REVIEW':>7} {'REVIEW OK':>10}") for agent, counters in sorted(report.items()): - print(f"{agent:18} {counters['executor']:>5} {counters['executor_ok']:>8} {counters['review']:>7} {counters['review_ok']:>10}") + print( + f"{agent:18} {counters['executor']:>5} {counters['executor_ok']:>8} {counters['review']:>7} {counters['review_ok']:>10}" + ) def cmd_task_loop(args: argparse.Namespace) -> None: @@ -1879,7 +2085,9 @@ def cmd_objective_list(args: argparse.Namespace) -> None: config = _config(args) records = list_objectives(config) if not records: - print("暂无 Objective。下一步:dyro objective start --file --yes") + print( + "暂无 Objective。下一步:dyro objective start --file --yes" + ) return print(f"{'OBJECTIVE':28} {'STATE':8} {'RESULT':16} {'REV':4} {'LINE':20} TARGETS") for record in records: @@ -1909,7 +2117,14 @@ def _read_objective_plan(config: Config, objective_id: str): def cmd_objective_plan(args: argparse.Namespace) -> None: _, plan = _read_objective_plan(_config(args), args.id) if args.format == "json": - print(json.dumps(continuation_plan_payload(plan), ensure_ascii=False, sort_keys=True, indent=2)) + print( + json.dumps( + continuation_plan_payload(plan), + ensure_ascii=False, + sort_keys=True, + indent=2, + ) + ) return print(render_plan_text(plan)) @@ -1917,7 +2132,14 @@ def cmd_objective_plan(args: argparse.Namespace) -> None: def cmd_objective_explain(args: argparse.Namespace) -> None: _, plan = _read_objective_plan(_config(args), args.id) if args.format == "json": - print(json.dumps(continuation_plan_payload(plan), ensure_ascii=False, sort_keys=True, indent=2)) + print( + json.dumps( + continuation_plan_payload(plan), + ensure_ascii=False, + sort_keys=True, + indent=2, + ) + ) return print(render_plan_text(plan)) @@ -1937,7 +2159,9 @@ def cmd_objective_tick(args: argparse.Namespace) -> None: record = get_objective(config, args.id, recover=False) snapshot = build_scheduler_snapshot(config, objective=record) plan = build_continuation_plan(snapshot) - tick = build_scheduler_tick(snapshot, plan, max_parallel=record.objective.budget.max_parallel) + tick = build_scheduler_tick( + snapshot, plan, max_parallel=record.objective.budget.max_parallel + ) if args.format == "json": print(render_scheduler_tick_json(tick)) return @@ -1971,15 +2195,28 @@ def cmd_objective_apply(args: argparse.Namespace) -> None: print(render_supervised_wave_text(wave)) if args.dry_run: if args.format == "json": - print(json.dumps({"wave": supervised_wave_payload(wave), "dry_run": True}, ensure_ascii=False, sort_keys=True, indent=2)) + print( + json.dumps( + {"wave": supervised_wave_payload(wave), "dry_run": True}, + ensure_ascii=False, + sort_keys=True, + indent=2, + ) + ) else: - print("DRY RUN: 未创建 owner lease、Action intent、Action-start 或 Task 执行。") + print( + "DRY RUN: 未创建 owner lease、Action intent、Action-start 或 Task 执行。" + ) return if args.yes and args.confirm_sha != wave.confirmation_sha256: - raise DyroError("--yes 必须同时提供当前 Confirmation SHA-256;请先运行 objective apply --dry-run 后复制摘要") + raise DyroError( + "--yes 必须同时提供当前 Confirmation SHA-256;请先运行 objective apply --dry-run 后复制摘要" + ) if not args.yes: if not (sys.stdin.isatty() and sys.stdout.isatty()): - raise DyroError("非交互执行必须提供 --yes 与 --confirm-sha;先使用 --dry-run 查看精确 wave") + raise DyroError( + "非交互执行必须提供 --yes 与 --confirm-sha;先使用 --dry-run 查看精确 wave" + ) if not _ask_yes_no("确认按上述顺序执行该受监督 Action wave", default=False): print("已取消;未写入 Objective 或 Task 状态。") return @@ -1987,7 +2224,11 @@ def cmd_objective_apply(args: argparse.Namespace) -> None: if args.format == "json": print( json.dumps( - {"wave": supervised_wave_payload(wave), "dry_run": False, "outcomes": supervised_outcomes_payload(outcomes)}, + { + "wave": supervised_wave_payload(wave), + "dry_run": False, + "outcomes": supervised_outcomes_payload(outcomes), + }, ensure_ascii=False, sort_keys=True, indent=2, @@ -2009,7 +2250,9 @@ def _trigger_cli_time(value: str | None, label: str) -> datetime | None: return parsed.astimezone(timezone.utc) -def _trigger_cli_facts(values: list[str] | None, label: str) -> tuple[tuple[str, str], ...]: +def _trigger_cli_facts( + values: list[str] | None, label: str +) -> tuple[tuple[str, str], ...]: facts: list[tuple[str, str]] = [] seen: set[str] = set() for value in values or []: @@ -2030,15 +2273,21 @@ def _trigger_payload(observation, *, delivery: str | None = None) -> dict[str, o "state": observation.state.value, "summary": observation.summary, "evidence_ref": observation.evidence_ref, - "observed_at": observation.observed_at.isoformat() if observation.observed_at else None, - "next_probe_at": observation.next_probe_at.isoformat() if observation.next_probe_at else None, + "observed_at": observation.observed_at.isoformat() + if observation.observed_at + else None, + "next_probe_at": observation.next_probe_at.isoformat() + if observation.next_probe_at + else None, } if delivery is not None: payload["delivery"] = delivery return payload -def _print_trigger_observation(args: argparse.Namespace, observation, *, delivery: str | None = None) -> None: +def _print_trigger_observation( + args: argparse.Namespace, observation, *, delivery: str | None = None +) -> None: payload = _trigger_payload(observation, delivery=delivery) if args.format == "json": print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) @@ -2055,7 +2304,9 @@ def cmd_trigger_list(args: argparse.Namespace) -> None: rows = [ { "kind": kind.value, - "execution": "bounded_adapter_required" if kind is TriggerKind.PROVIDER else "builtin_read_only", + "execution": "bounded_adapter_required" + if kind is TriggerKind.PROVIDER + else "builtin_read_only", } for kind in TriggerKind ] @@ -2069,7 +2320,9 @@ def cmd_trigger_list(args: argparse.Namespace) -> None: def cmd_trigger_probe(args: argparse.Namespace) -> None: kind = TriggerKind(args.kind) if kind is TriggerKind.PROVIDER: - raise DyroError("provider Trigger 只能由已登记的有界 adapter 调用;CLI 不接受命令、URL 或脚本") + raise DyroError( + "provider Trigger 只能由已登记的有界 adapter 调用;CLI 不接受命令、URL 或脚本" + ) now = _trigger_cli_time(args.at, "--at") or datetime.now(timezone.utc) not_before = _trigger_cli_time(args.not_before, "--not-before") if args.signal and kind is not TriggerKind.MANUAL_SIGNAL: @@ -2108,7 +2361,9 @@ def _cmd_objective_transition(args: argparse.Namespace, action: str) -> None: "reconcile": reconcile_objective, } record = handlers[action](config, args.id, dry_run=args.dry_run) - print(f"{'DRY RUN: ' if args.dry_run else ''}{record.objective.id} -> {record.operator_state} r{record.revision}") + print( + f"{'DRY RUN: ' if args.dry_run else ''}{record.objective.id} -> {record.operator_state} r{record.revision}" + ) def cmd_objective_pause(args: argparse.Namespace) -> None: @@ -2131,14 +2386,18 @@ def cmd_objective_scope_add(args: argparse.Namespace) -> None: config = _config(args) _require_objective_yes(args, "扩展 Objective scope") record = add_objective_target(config, args.id, args.task, dry_run=args.dry_run) - print(f"{'DRY RUN: ' if args.dry_run else ''}{record.objective.id} r{record.revision} targets={', '.join(record.objective.targets)}") + print( + f"{'DRY RUN: ' if args.dry_run else ''}{record.objective.id} r{record.revision} targets={', '.join(record.objective.targets)}" + ) def cmd_objective_scope_remove(args: argparse.Namespace) -> None: config = _config(args) _require_objective_yes(args, "缩减 Objective scope") record = remove_objective_target(config, args.id, args.task, dry_run=args.dry_run) - print(f"{'DRY RUN: ' if args.dry_run else ''}{record.objective.id} r{record.revision} targets={', '.join(record.objective.targets)}") + print( + f"{'DRY RUN: ' if args.dry_run else ''}{record.objective.id} r{record.revision} targets={', '.join(record.objective.targets)}" + ) def _daemon_select_runnable(config: Config, tasks: list, *, limit: int) -> list: @@ -2156,9 +2415,17 @@ def cmd_task_daemon(args: argparse.Namespace) -> None: assert_legacy_scheduler_allowed(config, (task.id for task in tasks)) queued = _daemon_select_runnable(config, tasks, limit=max(1, args.parallel)) if queued: - with ThreadPoolExecutor(max_workers=max(1, args.parallel), thread_name_prefix="dyro-dispatch") as pool: + with ThreadPoolExecutor( + max_workers=max(1, args.parallel), thread_name_prefix="dyro-dispatch" + ) as pool: futures = { - pool.submit(run_task, config, task, dry_run=args.dry_run, legacy_scheduler=True): task + pool.submit( + run_task, + config, + task, + dry_run=args.dry_run, + legacy_scheduler=True, + ): task for task in queued } for future in as_completed(futures): @@ -2169,8 +2436,13 @@ def cmd_task_daemon(args: argparse.Namespace) -> None: print(f"skip {task.id}: {exc}") review_queue = list(plan_tasks(config).review) if review_queue: - with ThreadPoolExecutor(max_workers=max(1, args.parallel), thread_name_prefix="dyro-review") as pool: - futures = {pool.submit(review_task, config, task, dry_run=args.dry_run): task for task in review_queue} + with ThreadPoolExecutor( + max_workers=max(1, args.parallel), thread_name_prefix="dyro-review" + ) as pool: + futures = { + pool.submit(review_task, config, task, dry_run=args.dry_run): task + for task in review_queue + } for future in as_completed(futures): task = futures[future] try: @@ -2184,17 +2456,26 @@ def cmd_task_daemon(args: argparse.Namespace) -> None: def _add_common(parser: argparse.ArgumentParser) -> None: location = parser.add_mutually_exclusive_group() - location.add_argument("--root", help="工作区根目录;默认从当前目录向上查找 dyro.toml") + location.add_argument( + "--root", help="工作区根目录;默认从当前目录向上查找 dyro.toml" + ) location.add_argument( "--workspace", dest="workspace_alias", help="全局登记的工作区别名;可从任意目录使用", ) - parser.add_argument("--dry-run", action="store_true", help="仅输出计划,不写文件、不调用 Agent 或 Git 写操作") + parser.add_argument( + "--dry-run", + action="store_true", + help="仅输出计划,不写文件、不调用 Agent 或 Git 写操作", + ) def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="dyro", description="DyroEngineeringFlow:本地优先的多仓工程自动化与交付控制平台") + parser = argparse.ArgumentParser( + prog="dyro", + description="DyroEngineeringFlow:本地优先的多仓工程自动化与交付控制平台", + ) parser.add_argument("--version", action="version", version=f"dyro {__version__}") _add_common(parser) sub = parser.add_subparsers(dest="command") @@ -2210,17 +2491,29 @@ def build_parser() -> argparse.ArgumentParser: init.add_argument("--name", default="my-workspace") init.add_argument("--base", default="main", help="--discover 时写入的默认基线分支") init_mode = init.add_mutually_exclusive_group() - init_mode.add_argument("--wizard", action="store_true", help="交互式登记真实仓库与可选 remote") - init_mode.add_argument("--discover", action="store_true", help="自动发现当前目录下的 Git 仓库并登记 origin") + init_mode.add_argument( + "--wizard", action="store_true", help="交互式登记真实仓库与可选 remote" + ) + init_mode.add_argument( + "--discover", + action="store_true", + help="自动发现当前目录下的 Git 仓库并登记 origin", + ) init.set_defaults(func=cmd_init) - setup = sub.add_parser("setup", help="首次引导:预览并安全创建 Profile、仓库与首条开发线") + setup = sub.add_parser( + "setup", help="首次引导:预览并安全创建 Profile、仓库与首条开发线" + ) setup.add_argument("path", nargs="?", default=".") setup.add_argument("--name", help="新 Profile 的工作区名称;默认由目录名推断") setup.add_argument("--base", help="首条开发线与新 Profile 的默认基线;默认 main") setup.add_argument("--line", default="dev", help="首条功能开发线 ID;默认 dev") setup.add_argument("--branch", help="首条开发线分支;默认 feat/") - setup.add_argument("--no-line", action="store_true", help="仅建立 Profile,不创建 Git worktree 开发线") + setup.add_argument( + "--no-line", + action="store_true", + help="仅建立 Profile,不创建 Git worktree 开发线", + ) setup.add_argument( "--yes", action="store_true", @@ -2239,9 +2532,15 @@ def build_parser() -> argparse.ArgumentParser: help="登记后设为裸 dyro 的默认项目", ) setup_mode = setup.add_mutually_exclusive_group() - setup_mode.add_argument("--interactive", action="store_true", help="强制运行交互式首次设置") - setup_mode.add_argument("--non-interactive", action="store_true", help="禁用交互提示;适合脚本与 CI") - setup.add_argument("--quick", action="store_true", help="只检查现有 Profile 并给出下一步") + setup_mode.add_argument( + "--interactive", action="store_true", help="强制运行交互式首次设置" + ) + setup_mode.add_argument( + "--non-interactive", action="store_true", help="禁用交互提示;适合脚本与 CI" + ) + setup.add_argument( + "--quick", action="store_true", help="只检查现有 Profile 并给出下一步" + ) setup.add_argument( "--dry-run", dest="dry_run", @@ -2253,32 +2552,50 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("home", help="打开当前或默认项目首页").set_defaults(func=cmd_home) console = sub.add_parser("console", help="启动只读本地项目控制台") - console.add_argument("--no-open", action="store_true", help="不自动打开浏览器,打印一次性本地 URL") - console.add_argument("--port", type=int, default=0, help="loopback 端口;默认 0 由系统分配") + console.add_argument( + "--no-open", action="store_true", help="不自动打开浏览器,打印一次性本地 URL" + ) + console.add_argument( + "--port", type=int, default=0, help="loopback 端口;默认 0 由系统分配" + ) console.set_defaults(func=cmd_console) workspace = sub.add_parser("workspace", help="管理可从任意目录进入的全局工作区") workspace_sub = workspace.add_subparsers(dest="workspace_command", required=True) workspace_add = workspace_sub.add_parser("add", help="登记已有 Dyro 工作区") workspace_add.add_argument("path", nargs="?", default=".") - workspace_add.add_argument("--name", help="便于记忆的工作区别名;默认读取 Profile 名称") - workspace_add.add_argument("--default", action="store_true", help="设为裸 dyro 的默认项目") + workspace_add.add_argument( + "--name", help="便于记忆的工作区别名;默认读取 Profile 名称" + ) + workspace_add.add_argument( + "--default", action="store_true", help="设为裸 dyro 的默认项目" + ) workspace_add.set_defaults(func=cmd_workspace_add) workspace_sub.add_parser("list", help="显示已登记工作区及可用状态").set_defaults( func=cmd_workspace_list ) - workspace_default = workspace_sub.add_parser("default", help="设置裸 dyro 的默认项目") + workspace_default = workspace_sub.add_parser( + "default", help="设置裸 dyro 的默认项目" + ) workspace_default.add_argument("name") workspace_default.set_defaults(func=cmd_workspace_default) - workspace_remove = workspace_sub.add_parser("remove", help="移除全局入口,不删除项目文件") + workspace_remove = workspace_sub.add_parser( + "remove", help="移除全局入口,不删除项目文件" + ) workspace_remove.add_argument("name") workspace_remove.add_argument("--yes", action="store_true") workspace_remove.set_defaults(func=cmd_workspace_remove) blueprint = sub.add_parser("blueprint", help="验证可复用的团队工作区蓝图") blueprint_sub = blueprint.add_subparsers(dest="blueprint_command", required=True) - blueprint_validate = blueprint_sub.add_parser("validate", help="只读验证蓝图结构与固定基线") - blueprint_validate.add_argument("source", help="本地 TOML/目录、HTTPS 文件或 Git 仓库") - blueprint_validate.add_argument("--ref", dest="blueprint_ref", help="Git 蓝图仓库的分支或 tag") + blueprint_validate = blueprint_sub.add_parser( + "validate", help="只读验证蓝图结构与固定基线" + ) + blueprint_validate.add_argument( + "source", help="本地 TOML/目录、HTTPS 文件或 Git 仓库" + ) + blueprint_validate.add_argument( + "--ref", dest="blueprint_ref", help="Git 蓝图仓库的分支或 tag" + ) blueprint_validate.add_argument( "--file", dest="blueprint_file", @@ -2289,7 +2606,9 @@ def build_parser() -> argparse.ArgumentParser: join = sub.add_parser("join", help="从团队蓝图安全创建独立的多仓工作区") join.add_argument("source", help="本地 TOML/目录、HTTPS 文件或 Git 仓库") - join.add_argument("--path", help="目标目录;默认 ~/DyroProjects/") + join.add_argument( + "--path", help="目标目录;默认 ~/DyroProjects/" + ) join.add_argument("--line", help="要创建的开发线;默认交互选择或使用蓝图默认值") join.add_argument("--ref", dest="blueprint_ref", help="Git 蓝图仓库的分支或 tag") join.add_argument( @@ -2300,7 +2619,9 @@ def build_parser() -> argparse.ArgumentParser: ) join.add_argument("--yes", action="store_true", help="确认执行已展示的加入计划") registration = join.add_mutually_exclusive_group() - registration.add_argument("--no-register", action="store_true", help="不登记到裸 dyro 的全局首页") + registration.add_argument( + "--no-register", action="store_true", help="不登记到裸 dyro 的全局首页" + ) registration.add_argument( "--default", dest="make_default", @@ -2318,7 +2639,9 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("doctor", help="验证动态工作区结构").set_defaults(func=cmd_doctor) terminology = sub.add_parser("terminology", help="使用仓库外策略扫描候选术语") - terminology_sub = terminology.add_subparsers(dest="terminology_command", required=True) + terminology_sub = terminology.add_subparsers( + dest="terminology_command", required=True + ) terminology_check = terminology_sub.add_parser( "check", help="扫描工作区、分支、diff 与提交候选;策略不写入仓库", @@ -2340,9 +2663,13 @@ def build_parser() -> argparse.ArgumentParser: ) terminology_check.set_defaults(func=cmd_terminology_check) status_parser = sub.add_parser("status", help="显示 anchors 与开发线 Git 状态") - status_parser.add_argument("--all", action="store_true", help="汇总所有全局登记工作区") + status_parser.add_argument( + "--all", action="store_true", help="汇总所有全局登记工作区" + ) status_parser.set_defaults(func=cmd_status) - bootstrap_parser = sub.add_parser("bootstrap", help="clone 配置了 remote 的缺失仓库 anchor") + bootstrap_parser = sub.add_parser( + "bootstrap", help="clone 配置了 remote 的缺失仓库 anchor" + ) bootstrap_parser.add_argument("--yes", action="store_true") bootstrap_parser.set_defaults(func=cmd_bootstrap) repo = sub.add_parser("repo", help="免手改 TOML 的仓库配置管理") @@ -2352,21 +2679,31 @@ def build_parser() -> argparse.ArgumentParser: repo_add.add_argument("path", help="工作区内的仓库路径") repo_add.add_argument("--id", help="仓库标识;默认使用目录名") repo_add.add_argument("--mount", help="开发线内挂载路径;默认智能推断") - repo_add.add_argument("--remote", help="缺失路径的 clone remote,或覆盖自动发现的 origin") + repo_add.add_argument( + "--remote", help="缺失路径的 clone remote,或覆盖自动发现的 origin" + ) repo_add.set_defaults(func=cmd_repo_add) agent = sub.add_parser("agent", help="Agent adapters") agent_sub = agent.add_subparsers(dest="agent_command", required=True) - agent_sub.add_parser("list", help="显示已登记的 Agent adapter").set_defaults(func=cmd_agent_list) - agent_sub.add_parser("discover", help="检测本机 Agent,并区分已配置与尚未集成").set_defaults( - func=cmd_agent_discover + agent_sub.add_parser("list", help="显示已登记的 Agent adapter").set_defaults( + func=cmd_agent_list + ) + agent_sub.add_parser( + "discover", help="检测本机 Agent,并区分已配置与尚未集成" + ).set_defaults(func=cmd_agent_discover) + agent_add = agent_sub.add_parser( + "add", help="通过预设或命令登记 Agent,无需编辑 TOML" ) - agent_add = agent_sub.add_parser("add", help="通过预设或命令登记 Agent,无需编辑 TOML") agent_add.add_argument("id") agent_source = agent_add.add_mutually_exclusive_group(required=True) agent_source.add_argument("--preset", choices=("codex", "noop")) - agent_source.add_argument("--command", help="作为 launch/read/write 的 argv 命令行;不会经 shell 执行") + agent_source.add_argument( + "--command", help="作为 launch/read/write 的 argv 命令行;不会经 shell 执行" + ) agent_add.set_defaults(func=cmd_agent_add) - agent_test = agent_sub.add_parser("test", help="仅检查 adapter 可执行文件是否可用,不启动 Agent") + agent_test = agent_sub.add_parser( + "test", help="仅检查 adapter 可执行文件是否可用,不启动 Agent" + ) agent_test.add_argument("id") agent_test.set_defaults(func=cmd_agent_test) tool = sub.add_parser("tool", help="发现、排序和安全安装本地编码工具") @@ -2376,7 +2713,9 @@ def build_parser() -> argparse.ArgumentParser: ) tool_install = tool_sub.add_parser("install", help="显示并执行内置的官方安装方案") tool_install.add_argument("id") - tool_install.add_argument("--yes", action="store_true", help="确认执行已展示的安装命令或打开官方页面") + tool_install.add_argument( + "--yes", action="store_true", help="确认执行已展示的安装命令或打开官方页面" + ) tool_install.set_defaults(func=cmd_tool_install) tool_default = tool_sub.add_parser("default", help="设置个人默认工具") tool_default.add_argument("id", nargs="?") @@ -2388,14 +2727,18 @@ def build_parser() -> argparse.ArgumentParser: tool_pin.set_defaults(func=cmd_tool_pin) update = sub.add_parser("update", help="检测并安全更新 Dyro") update_sub = update.add_subparsers(dest="update_command", required=True) - update_sub.add_parser("check", help="立即检查官方 PyPI 的最新稳定版本").set_defaults( - func=cmd_update_check - ) + update_sub.add_parser( + "check", help="立即检查官方 PyPI 的最新稳定版本" + ).set_defaults(func=cmd_update_check) update_now = update_sub.add_parser("now", help="显示计划并更新到最新稳定版本") - update_now.add_argument("--yes", action="store_true", help="确认执行已展示的更新命令") + update_now.add_argument( + "--yes", action="store_true", help="确认执行已展示的更新命令" + ) update_now.set_defaults(func=cmd_update_now) update_auto = update_sub.add_parser("auto", help="管理补丁版本自动更新") - update_auto.add_argument("mode", choices=("on", "off", "status"), nargs="?", default="status") + update_auto.add_argument( + "mode", choices=("on", "off", "status"), nargs="?", default="status" + ) update_auto.set_defaults(func=cmd_update_auto) update_sub.add_parser("enable", help="开启每日首次交互运行更新检测").set_defaults( func=cmd_update_enabled @@ -2414,12 +2757,16 @@ def build_parser() -> argparse.ArgumentParser: config_set.set_defaults(func=cmd_config_set) key = sub.add_parser("key", help="Ed25519 签名密钥与工作区信任根") key_sub = key.add_subparsers(dest="key_command", required=True) - key_generate = key_sub.add_parser("generate", help="生成 runner 或 approver Ed25519 密钥对") + key_generate = key_sub.add_parser( + "generate", help="生成 runner 或 approver Ed25519 密钥对" + ) key_generate.add_argument("id") key_generate.add_argument("--private-key", required=True) key_generate.add_argument("--public-key", required=True) key_generate.set_defaults(func=cmd_key_generate) - key_trust = key_sub.add_parser("trust", help="将公钥安装到用途隔离的工作区 trust store") + key_trust = key_sub.add_parser( + "trust", help="将公钥安装到用途隔离的工作区 trust store" + ) key_trust.add_argument("id") key_trust.add_argument( "--purpose", @@ -2431,7 +2778,9 @@ def build_parser() -> argparse.ArgumentParser: key_trust.add_argument("--not-before", help="ISO-8601 生效时间;必须包含时区") key_trust.add_argument("--not-after", help="ISO-8601 失效时间;必须包含时区") key_trust.set_defaults(func=cmd_key_trust) - key_revoke = key_sub.add_parser("revoke", help="撤销指定用途的 trusted key ID;保留公钥与审计记录") + key_revoke = key_sub.add_parser( + "revoke", help="撤销指定用途的 trusted key ID;保留公钥与审计记录" + ) key_revoke.add_argument("id") key_revoke.add_argument( "--purpose", @@ -2446,9 +2795,15 @@ def build_parser() -> argparse.ArgumentParser: choices=TRUST_PURPOSES, required=True, ) - key_list.add_argument("--show-status", action="store_true", help="同时显示 pending、expired 与 revoked key") + key_list.add_argument( + "--show-status", + action="store_true", + help="同时显示 pending、expired 与 revoked key", + ) key_list.set_defaults(func=cmd_key_list) - key_sub.add_parser("audit", help="输出 trust/revoke JSONL 审计记录").set_defaults(func=cmd_key_audit) + key_sub.add_parser("audit", help="输出 trust/revoke JSONL 审计记录").set_defaults( + func=cmd_key_audit + ) key_audit_sync = key_sub.add_parser( "audit-sync", help="将本地 trust 审计链同步到远程 Witness", @@ -2470,7 +2825,9 @@ def build_parser() -> argparse.ArgumentParser: key_audit_sync.set_defaults(func=cmd_key_audit_sync) witness = sub.add_parser("witness", help="运行独立的远程 audit Witness 服务") witness_sub = witness.add_subparsers(dest="witness_command", required=True) - witness_serve = witness_sub.add_parser("serve", help="验证批次、签发回执并持久化 Witness ledger") + witness_serve = witness_sub.add_parser( + "serve", help="验证批次、签发回执并持久化 Witness ledger" + ) witness_serve.add_argument("--storage-root", required=True) witness_serve.add_argument("--client-trust-root", required=True) witness_serve.add_argument("--witness-id", required=True) @@ -2514,7 +2871,9 @@ def build_parser() -> argparse.ArgumentParser: start.add_argument("--agent") start.add_argument("--prompt", default="") start.set_defaults(func=cmd_start) - sub.add_parser("next", help="根据当前状态给出新手的唯一安全下一步").set_defaults(func=cmd_next) + sub.add_parser("next", help="根据当前状态给出新手的唯一安全下一步").set_defaults( + func=cmd_next + ) line = sub.add_parser("line", help="功能开发线") line_sub = line.add_subparsers(dest="line_command", required=True) @@ -2525,9 +2884,21 @@ def build_parser() -> argparse.ArgumentParser: line_create.add_argument("id") line_create.add_argument("--branch") line_create.add_argument("--base") - line_create.add_argument("--repos", help="逗号分隔;默认全部 configured repositories") - line_create.add_argument("--repo-base", action="append", metavar="REPOSITORY=REF", help="为一个仓库覆盖默认基线;可重复") - line_create.add_argument("--storage", action="append", metavar="REPOSITORY=MODE", help="仓库存储方式:linked-worktree 或 anchor-reference;可重复") + line_create.add_argument( + "--repos", help="逗号分隔;默认全部 configured repositories" + ) + line_create.add_argument( + "--repo-base", + action="append", + metavar="REPOSITORY=REF", + help="为一个仓库覆盖默认基线;可重复", + ) + line_create.add_argument( + "--storage", + action="append", + metavar="REPOSITORY=MODE", + help="仓库存储方式:linked-worktree 或 anchor-reference;可重复", + ) line_create.add_argument("--yes", action="store_true") line_create.set_defaults(func=cmd_line_create) @@ -2538,8 +2909,18 @@ def build_parser() -> argparse.ArgumentParser: hotfix_create.add_argument("--branch") hotfix_create.add_argument("--base", required=True) hotfix_create.add_argument("--repos") - hotfix_create.add_argument("--repo-base", action="append", metavar="REPOSITORY=REF", help="为一个仓库覆盖 --base;可重复") - hotfix_create.add_argument("--storage", action="append", metavar="REPOSITORY=MODE", help="仓库存储方式:linked-worktree 或 anchor-reference;可重复") + hotfix_create.add_argument( + "--repo-base", + action="append", + metavar="REPOSITORY=REF", + help="为一个仓库覆盖 --base;可重复", + ) + hotfix_create.add_argument( + "--storage", + action="append", + metavar="REPOSITORY=MODE", + help="仓库存储方式:linked-worktree 或 anchor-reference;可重复", + ) hotfix_create.add_argument("--yes", action="store_true") hotfix_create.set_defaults(func=cmd_hotfix_create) @@ -2555,33 +2936,53 @@ def build_parser() -> argparse.ArgumentParser: changeset_verify.add_argument("id") changeset_verify.set_defaults(func=cmd_changeset_verify) - objective = sub.add_parser("objective", help="持久化并观察一个跨任务 Objective;此阶段不执行 Task") + objective = sub.add_parser( + "objective", help="持久化并观察一个跨任务 Objective;此阶段不执行 Task" + ) objective_sub = objective.add_subparsers(dest="objective_command", required=True) - objective_start = objective_sub.add_parser("start", help="固定 Objective 合约、目标和依赖闭包") + objective_start = objective_sub.add_parser( + "start", help="固定 Objective 合约、目标和依赖闭包" + ) objective_start.add_argument("--file", help="完整 Objective v1 TOML 合约") objective_start.add_argument("--id") objective_start.add_argument("--title") objective_start.add_argument("--line") objective_start.add_argument("--targets", help="逗号分隔的 Task ID;非文件模式必填") - objective_start.add_argument("--mode", choices=tuple(item.value for item in RequestedMode)) - objective_start.add_argument("--operation", action="append", choices=tuple(item.value for item in Operation)) + objective_start.add_argument( + "--mode", choices=tuple(item.value for item in RequestedMode) + ) + objective_start.add_argument( + "--operation", action="append", choices=tuple(item.value for item in Operation) + ) objective_start.add_argument("--yes", action="store_true") objective_start.set_defaults(func=cmd_objective_start) - objective_sub.add_parser("list", help="列出已接受的 Objective").set_defaults(func=cmd_objective_list) - objective_status = objective_sub.add_parser("status", help="显示 Objective 状态和派生结果") + objective_sub.add_parser("list", help="列出已接受的 Objective").set_defaults( + func=cmd_objective_list + ) + objective_status = objective_sub.add_parser( + "status", help="显示 Objective 状态和派生结果" + ) objective_status.add_argument("id") objective_status.set_defaults(func=cmd_objective_status) - objective_plan = objective_sub.add_parser("plan", help="只读生成确定性 Objective action plan,不执行任务") + objective_plan = objective_sub.add_parser( + "plan", help="只读生成确定性 Objective action plan,不执行任务" + ) objective_plan.add_argument("id") objective_plan.add_argument("--format", choices=("text", "json"), default="text") objective_plan.set_defaults(func=cmd_objective_plan) - objective_explain = objective_sub.add_parser("explain", help="解释 Objective 当前的可推进项与阻塞原因") + objective_explain = objective_sub.add_parser( + "explain", help="解释 Objective 当前的可推进项与阻塞原因" + ) objective_explain.add_argument("id") objective_explain.add_argument("--format", choices=("text", "json"), default="text") objective_explain.set_defaults(func=cmd_objective_explain) - objective_graph = objective_sub.add_parser("graph", help="渲染 Objective、Task、Decision 与 Action 的只读组合图") + objective_graph = objective_sub.add_parser( + "graph", help="渲染 Objective、Task、Decision 与 Action 的只读组合图" + ) objective_graph.add_argument("id") - objective_graph.add_argument("--format", choices=("mermaid", "json"), default="mermaid") + objective_graph.add_argument( + "--format", choices=("mermaid", "json"), default="mermaid" + ) objective_graph.set_defaults(func=cmd_objective_graph) objective_tick = objective_sub.add_parser( "tick", help="预览下一组有界 Objective Action;不创建 intent 或执行任务" @@ -2593,29 +2994,43 @@ def build_parser() -> argparse.ArgumentParser: "attention", help="显示安全且只读的 Objective Attention 投影" ) objective_attention.add_argument("id") - objective_attention.add_argument("--format", choices=("text", "json"), default="text") + objective_attention.add_argument( + "--format", choices=("text", "json"), default="text" + ) objective_attention.set_defaults(func=cmd_objective_attention) objective_apply = objective_sub.add_parser( "apply", help="显示精确 Action wave;确认后仅受监督地执行 execute/review,不 merge 或 push", ) objective_apply.add_argument("id") - objective_apply.add_argument("--confirm-sha", help="非交互 --yes 必填;必须等于当前 Confirmation SHA-256") - objective_apply.add_argument("--yes", action="store_true", help="确认当前显示的精确 wave 后执行") + objective_apply.add_argument( + "--confirm-sha", help="非交互 --yes 必填;必须等于当前 Confirmation SHA-256" + ) + objective_apply.add_argument( + "--yes", action="store_true", help="确认当前显示的精确 wave 后执行" + ) objective_apply.add_argument("--format", choices=("text", "json"), default="text") objective_apply.set_defaults(func=cmd_objective_apply) for command, function, help_text in ( ("pause", cmd_objective_pause, "暂停后续推进,不写完成状态"), ("resume", cmd_objective_resume, "恢复 paused Objective 的 ownership"), ("stop", cmd_objective_stop, "终止 Objective;不能再恢复"), - ("reconcile", cmd_objective_reconcile, "重新固定 TaskGraph scope 与 contract 哈希"), + ( + "reconcile", + cmd_objective_reconcile, + "重新固定 TaskGraph scope 与 contract 哈希", + ), ): parser_item = objective_sub.add_parser(command, help=help_text) parser_item.add_argument("id") parser_item.add_argument("--yes", action="store_true") parser_item.set_defaults(func=function) - objective_scope = objective_sub.add_parser("scope", help="显式调整 Objective targets 并建立新 revision") - objective_scope_sub = objective_scope.add_subparsers(dest="objective_scope_command", required=True) + objective_scope = objective_sub.add_parser( + "scope", help="显式调整 Objective targets 并建立新 revision" + ) + objective_scope_sub = objective_scope.add_subparsers( + dest="objective_scope_command", required=True + ) for command, function, help_text in ( ("add", cmd_objective_scope_add, "将同一开发线的 Task 加入 targets"), ("remove", cmd_objective_scope_remove, "从 targets 移除一个 Task"), @@ -2626,42 +3041,70 @@ def build_parser() -> argparse.ArgumentParser: parser_item.add_argument("--yes", action="store_true") parser_item.set_defaults(func=function) - trigger = sub.add_parser("trigger", help="只读 Trigger 观测与有界 provider 协议入口") + trigger = sub.add_parser( + "trigger", help="只读 Trigger 观测与有界 provider 协议入口" + ) trigger_sub = trigger.add_subparsers(dest="trigger_command", required=True) - trigger_list = trigger_sub.add_parser("list", help="列出内置 Trigger 类型与 provider 边界") + trigger_list = trigger_sub.add_parser( + "list", help="列出内置 Trigger 类型与 provider 边界" + ) trigger_list.add_argument("--format", choices=("text", "json"), default="text") trigger_list.set_defaults(func=cmd_trigger_list) - trigger_probe = trigger_sub.add_parser("probe", help="用显式事实执行一次只读内置 Trigger 观测") - trigger_probe.add_argument("kind", choices=tuple(item.value for item in TriggerKind)) + trigger_probe = trigger_sub.add_parser( + "probe", help="用显式事实执行一次只读内置 Trigger 观测" + ) + trigger_probe.add_argument( + "kind", choices=tuple(item.value for item in TriggerKind) + ) trigger_probe.add_argument("--id", default="manual-probe") - trigger_probe.add_argument("--at", help="观测时间(带时区 ISO-8601);默认当前 UTC 时间") - trigger_probe.add_argument("--not-before", help="time_due 的最早触发时间(带时区 ISO-8601)") - trigger_probe.add_argument("--current", action="append", metavar="KEY=VALUE", help="当前事实;可重复指定") - trigger_probe.add_argument("--previous", action="append", metavar="KEY=VALUE", help="上次事实;可重复指定") + trigger_probe.add_argument( + "--at", help="观测时间(带时区 ISO-8601);默认当前 UTC 时间" + ) + trigger_probe.add_argument( + "--not-before", help="time_due 的最早触发时间(带时区 ISO-8601)" + ) + trigger_probe.add_argument( + "--current", action="append", metavar="KEY=VALUE", help="当前事实;可重复指定" + ) + trigger_probe.add_argument( + "--previous", action="append", metavar="KEY=VALUE", help="上次事实;可重复指定" + ) trigger_probe.add_argument("--signal", help="manual_signal 的非空人工信号") trigger_probe.add_argument("--format", choices=("text", "json"), default="text") trigger_probe.set_defaults(func=cmd_trigger_probe) - trigger_signal = trigger_sub.add_parser("signal", help="输出一次临时人工信号观测,不写入任务或控制面") + trigger_signal = trigger_sub.add_parser( + "signal", help="输出一次临时人工信号观测,不写入任务或控制面" + ) trigger_signal.add_argument("signal") trigger_signal.add_argument("--id", default="manual-signal") - trigger_signal.add_argument("--at", help="观测时间(带时区 ISO-8601);默认当前 UTC 时间") + trigger_signal.add_argument( + "--at", help="观测时间(带时区 ISO-8601);默认当前 UTC 时间" + ) trigger_signal.add_argument("--format", choices=("text", "json"), default="text") trigger_signal.set_defaults(func=cmd_trigger_signal) task = sub.add_parser("task", help="任务编排") task_sub = task.add_subparsers(dest="task_command", required=True) task_graph = task_sub.add_parser("graph", help="编译、校验或渲染任务图") - task_graph.add_argument("action", nargs="?", choices=("show", "check"), default="show") + task_graph.add_argument( + "action", nargs="?", choices=("show", "check"), default="show" + ) task_graph.add_argument("--line", help="只显示或校验指定开发线") task_graph.add_argument("--format", choices=("mermaid", "json"), default="mermaid") task_graph.set_defaults(func=cmd_task_graph) - task_explain = task_sub.add_parser("explain", help="解释任务当前为什么可调度或被阻塞") + task_explain = task_sub.add_parser( + "explain", help="解释任务当前为什么可调度或被阻塞" + ) task_explain.add_argument("id") task_explain.set_defaults(func=cmd_task_explain) - task_attempts = task_sub.add_parser("attempts", help="显示任务的本地执行 provenance") + task_attempts = task_sub.add_parser( + "attempts", help="显示任务的本地执行 provenance" + ) task_attempts.add_argument("id") task_attempts.set_defaults(func=cmd_task_attempts) - task_binding = task_sub.add_parser("binding", help="输出 review 所需的完整 attempt 与 plan binding") + task_binding = task_sub.add_parser( + "binding", help="输出 review 所需的完整 attempt 与 plan binding" + ) task_binding.add_argument("id") task_binding.set_defaults(func=cmd_task_binding) task_create = task_sub.add_parser("create") @@ -2680,7 +3123,9 @@ def build_parser() -> argparse.ArgumentParser: task_run = task_sub.add_parser("run") task_run.add_argument("id") task_run.set_defaults(func=cmd_task_run) - task_open = task_sub.add_parser("open", help="进入已存在的任务工作树,不改变任务状态") + task_open = task_sub.add_parser( + "open", help="进入已存在的任务工作树,不改变任务状态" + ) task_open.add_argument("id") task_open.add_argument("--agent", default="codex") task_open.add_argument("--prompt", default="") @@ -2689,20 +3134,32 @@ def build_parser() -> argparse.ArgumentParser: task_claim.add_argument("id") task_claim.add_argument("--by", required=True, help="执行器实例或受信任身份") task_claim.add_argument("--key-id", help="与 claim 绑定的 trusted execution key ID") - task_claim.add_argument("--lease-seconds", type=int, default=3600, help="claim 租约秒数;默认 3600") + task_claim.add_argument( + "--lease-seconds", type=int, default=3600, help="claim 租约秒数;默认 3600" + ) task_claim.add_argument( "--output", help="把新 claim 以 0600 权限导出到 runner 交接路径;拒绝覆盖", ) task_claim.set_defaults(func=cmd_task_claim) - task_claim_renew = task_sub.add_parser("claim-renew", help="由当前 runner 续租未过期 claim") + task_claim_renew = task_sub.add_parser( + "claim-renew", help="由当前 runner 续租未过期 claim" + ) task_claim_renew.add_argument("id") - task_claim_renew.add_argument("--by", required=True, help="当前执行器实例或受信任身份") - task_claim_renew.add_argument("--lease-seconds", type=int, default=3600, help="续租秒数;默认 3600") + task_claim_renew.add_argument( + "--by", required=True, help="当前执行器实例或受信任身份" + ) + task_claim_renew.add_argument( + "--lease-seconds", type=int, default=3600, help="续租秒数;默认 3600" + ) task_claim_renew.set_defaults(func=cmd_task_claim_renew) - task_claim_release = task_sub.add_parser("claim-release", help="释放当前 runner 的 claim") + task_claim_release = task_sub.add_parser( + "claim-release", help="释放当前 runner 的 claim" + ) task_claim_release.add_argument("id") - task_claim_release.add_argument("--by", required=True, help="当前执行器实例或受信任身份") + task_claim_release.add_argument( + "--by", required=True, help="当前执行器实例或受信任身份" + ) task_claim_release.set_defaults(func=cmd_task_claim_release) task_next = task_sub.add_parser("next", help="显示或启动下一个满足依赖的任务") task_next.add_argument("--id") @@ -2729,32 +3186,64 @@ def build_parser() -> argparse.ArgumentParser: task_signoff.set_defaults(func=cmd_task_signoff) evidence = task_sub.add_parser("evidence", help="构建或导入隔离执行器证据") evidence_sub = evidence.add_subparsers(dest="evidence_command", required=True) - evidence_execution = evidence_sub.add_parser("execution", help="导入执行回执与门禁结果") + evidence_execution = evidence_sub.add_parser( + "execution", help="导入执行回执与门禁结果" + ) evidence_execution.add_argument("id") evidence_input = evidence_execution.add_mutually_exclusive_group(required=True) evidence_input.add_argument("--receipt") - evidence_input.add_argument("--bundle", help="由 task evidence build 生成的可移植 ZIP 证据包") - evidence_execution.add_argument("--gates", help="外部门禁 JSON;任务含 gates 时必填") - evidence_execution.add_argument("--heads", help="执行后逐仓 Git HEAD JSON;DONE 回执时必填") - evidence_execution.add_argument("--provenance", help="可选的外部 execution provenance JSON") - evidence_execution.add_argument("--allow-legacy", action="store_true", help="显式允许导入缺少 provenance 的旧证据") + evidence_input.add_argument( + "--bundle", help="由 task evidence build 生成的可移植 ZIP 证据包" + ) + evidence_execution.add_argument( + "--gates", help="外部门禁 JSON;任务含 gates 时必填" + ) + evidence_execution.add_argument( + "--heads", help="执行后逐仓 Git HEAD JSON;DONE 回执时必填" + ) + evidence_execution.add_argument( + "--provenance", help="可选的外部 execution provenance JSON" + ) + evidence_execution.add_argument( + "--allow-legacy", + action="store_true", + help="显式允许导入缺少 provenance 的旧证据", + ) evidence_execution.set_defaults(func=cmd_task_evidence_execution) - evidence_build = evidence_sub.add_parser("build", help="在隔离 runner 中运行门禁并构建可导入 ZIP 证据包") + evidence_build = evidence_sub.add_parser( + "build", help="在隔离 runner 中运行门禁并构建可导入 ZIP 证据包" + ) evidence_build.add_argument("id") - evidence_build.add_argument("--workspace", required=True, help="隔离 runner 中任务分支的多仓工作区") - evidence_build.add_argument("--receipt", required=True, help="执行器写出的 receipt.md") - evidence_build.add_argument("--output", required=True, help="新 ZIP 证据包的输出路径;拒绝覆盖已有文件") + evidence_build.add_argument( + "--workspace", required=True, help="隔离 runner 中任务分支的多仓工作区" + ) + evidence_build.add_argument( + "--receipt", required=True, help="执行器写出的 receipt.md" + ) + evidence_build.add_argument( + "--output", required=True, help="新 ZIP 证据包的输出路径;拒绝覆盖已有文件" + ) evidence_build.add_argument("--signing-key", help="Ed25519 runner 私钥 PEM") - evidence_build.add_argument("--key-id", help="已安装到 execution trust store 的 key ID") - evidence_build.add_argument("--claim", help="控制面导出的 claim.json;默认读取任务目录") + evidence_build.add_argument( + "--key-id", help="已安装到 execution trust store 的 key ID" + ) + evidence_build.add_argument( + "--claim", help="控制面导出的 claim.json;默认读取任务目录" + ) evidence_build.set_defaults(func=cmd_task_evidence_build) - evidence_review = evidence_sub.add_parser("review", help="导入 receipt-bound 复核结果") + evidence_review = evidence_sub.add_parser( + "review", help="导入 receipt-bound 复核结果" + ) evidence_review.add_argument("id") evidence_review.add_argument("--file", required=True) evidence_review.set_defaults(func=cmd_task_evidence_review) - evidence_review_build = evidence_sub.add_parser("review-build", help="构建独立 reviewer 签名的 review JSON") + evidence_review_build = evidence_sub.add_parser( + "review-build", help="构建独立 reviewer 签名的 review JSON" + ) evidence_review_build.add_argument("id") - evidence_review_build.add_argument("--file", required=True, help="包含 verdict 与绑定字段的 review.md") + evidence_review_build.add_argument( + "--file", required=True, help="包含 verdict 与绑定字段的 review.md" + ) evidence_review_build.add_argument("--reviewer", required=True) evidence_review_build.add_argument("--output", required=True) evidence_review_build.add_argument("--signing-key", required=True) @@ -2765,7 +3254,9 @@ def build_parser() -> argparse.ArgumentParser: help="列出证据世代,或按年龄与保留数量安全清理非当前世代", ) evidence_generations.add_argument("id") - evidence_generations.add_argument("--prune", action="store_true", help="执行或预演清理计划") + evidence_generations.add_argument( + "--prune", action="store_true", help="执行或预演清理计划" + ) evidence_generations.add_argument("--older-than-days", type=int, default=30) evidence_generations.add_argument("--keep", type=int, default=10) evidence_generations.add_argument("--yes", action="store_true") @@ -2816,9 +3307,7 @@ def _route_experiment_surface(raw: list[str]) -> tuple[str, list[str]] | None: dispatch_common.add_argument("--home") dispatch_common.add_argument("--dry-run", action="store_true") _, dispatch_remaining = dispatch_common.parse_known_args(forwarded) - dispatch_command = ( - dispatch_remaining[0] if dispatch_remaining else "" - ) + dispatch_command = dispatch_remaining[0] if dispatch_remaining else "" selected_root = global_args.root if global_args.workspace_alias: selected_root = str(get_workspace(global_args.workspace_alias).root) @@ -2859,9 +3348,7 @@ def _maybe_run_daily_update(*, install=perform_update) -> None: state = load_update_state() except (DyroError, OSError): return - print( - f"\n发现 Dyro {result.latest_version}(当前 {result.current_version})。" - ) + print(f"\n发现 Dyro {result.latest_version}(当前 {result.current_version})。") if state.auto_patch and result.kind == UpdateKind.PATCH: print("已开启补丁版本自动更新,正在安全更新……") try: @@ -2900,7 +3387,16 @@ def main(argv: list[str] | None = None) -> None: else: cmd_home(args) except DyroError as exc: - parser.exit(2, f"错误:{exc}\n") + parser.exit(2, danger(f"错误:{exc}\n", stream=sys.stderr)) + except (KeyboardInterrupt, EOFError): + parser.exit( + 130, + muted( + "\n已停止当前操作;未完成的步骤不会继续。" + "若刚开始执行写入、clone 或创建 worktree,请运行 dyro doctor 确认状态。\n", + stream=sys.stderr, + ), + ) if __name__ == "__main__": diff --git a/src/dyro/console/assets.py b/src/dyro/console/assets.py index 7909f83..47bb010 100644 --- a/src/dyro/console/assets.py +++ b/src/dyro/console/assets.py @@ -22,18 +22,18 @@ class ConsoleAsset: ASSET_MANIFEST = { "index.html": ( "text/html; charset=utf-8", - "8980f489da7e7fbd6c6023fc3338c7ff768e14dfade2c13d6b53b69f6304821a", - 1949, + "4018afa6a9bfa3b694fa7c1c8cbc2fec0b31a9594691c5fe9ca123ad519ea389", + 3040, ), "app.js": ( "text/javascript; charset=utf-8", - "034405c85e20d7fbcde3d07b0bf0a29abf2c20c9ce5be16b579b48561d69965f", - 10484, + "2ece45334c7e51ece5bb13122c7f09575c7664c4b3bfa43451fb01b7cbed3805", + 14011, ), "styles.css": ( "text/css; charset=utf-8", - "4759e04327b0a29d0bcc039d09d64f633670014ecf7fe111244ec78f60d091af", - 4842, + "c622a5eda03264a2495f15830a8a061308a3bed6235c6425155b8995eb5069b9", + 10178, ), } diff --git a/src/dyro/console/assets/app.js b/src/dyro/console/assets/app.js index 24fcdcc..e0018d8 100644 --- a/src/dyro/console/assets/app.js +++ b/src/dyro/console/assets/app.js @@ -1,6 +1,15 @@ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/; const TOKEN_KEY = "dyro.console.bearer"; const state = { bearer: "", etags: new Map(), timer: null, focus: "" }; +const HEALTH_LABELS = { healthy: "健康", degraded: "需关注", unavailable: "不可用" }; +const FRESHNESS_LABELS = { fresh: "新鲜", partial: "部分可用", stale: "待更新" }; +const AVAILABILITY_LABELS = { available: "可用", unavailable: "不可用" }; +const ERROR_LABELS = { + LOCAL_READ_UNAVAILABLE: "本地状态暂时不可读取", + OVERVIEW_UNAVAILABLE: "工作区概览暂时不可读取", + WORKSPACE_UNAVAILABLE: "工作区当前不可用", + SESSION_REJECTED: "本地会话未建立", +}; const $ = (id) => document.getElementById(id); @@ -18,6 +27,16 @@ function count(value) { return Number.isInteger(value) && value >= 0 ? value : 0; } +function displayLabel(value, labels) { + const raw = text(value); + return labels[raw] || raw || "未提供"; +} + +function userError(value) { + const code = text(value); + return ERROR_LABELS[code] || "本地状态暂时不可读取"; +} + function element(name, value = "") { const node = document.createElement(name); if (value) node.textContent = value; @@ -84,6 +103,55 @@ function addBadge(parent, label, level = "") { parent.append(badge); } +function workspaceAttention(summary) { + const attention = summary.attention_counts || {}; + if (count(attention.repair_required)) return 0; + if (count(attention.needs_user)) return 1; + if (count(attention.ready)) return 2; + if (count(attention.waiting)) return 3; + if (count(attention.paused)) return 4; + return 5; +} + +function priorityWorkspace(workspaces) { + const focused = workspaces.find((summary) => text(summary.alias) === state.focus); + if (focused && text(focused.recommendation && focused.recommendation.command)) return focused; + return [...workspaces] + .sort((left, right) => workspaceAttention(left) - workspaceAttention(right)) + .find((summary) => text(summary.recommendation && summary.recommendation.command)); +} + +function overviewState(attention) { + if (count(attention && attention.repair_required)) return "需要修复"; + if (count(attention && attention.needs_user)) return "等待你的处理"; + if (count(attention && attention.ready)) return "有工作可推进"; + if (count(attention && attention.waiting)) return "等待外部条件"; + if (count(attention && attention.paused)) return "存在已暂停工作"; + return "全部正常"; +} + +function renderPrimaryAction(workspaces) { + const summary = priorityWorkspace(workspaces); + const guidance = $("primary-guidance"); + const command = $("primary-command"); + const button = $("primary-copy"); + const recommendation = summary && summary.recommendation; + const nextCommand = text(recommendation && recommendation.command); + if (!summary || !nextCommand) { + guidance.textContent = "下一步 · 等待工作区建议"; + command.textContent = "尚无可复制的推荐命令"; + button.dataset.command = ""; + button.disabled = true; + button.textContent = "复制命令"; + return; + } + guidance.textContent = `下一步 · ${text(summary.display_name) || text(summary.alias)}`; + command.textContent = nextCommand; + button.dataset.command = nextCommand; + button.disabled = false; + button.textContent = "复制命令"; +} + async function copyCommand(command, button) { try { await navigator.clipboard.writeText(command); @@ -124,6 +192,9 @@ function renderCounts(attention) { ]) { const card = element("div"); card.className = "count"; + if (key === "repair_required" && count(attention && attention[key])) card.dataset.level = "danger"; + if (key === "needs_user" && count(attention && attention[key])) card.dataset.level = "warning"; + if (!count(attention && attention[key])) card.dataset.level = "success"; card.append(element("strong", String(count(attention && attention[key]))), element("span", label)); root.append(card); } @@ -131,42 +202,46 @@ function renderCounts(attention) { function renderWorkspaceCard(summary) { const card = element("article"); - card.className = "workspace-card"; - const header = element("header"); + card.className = "workspace-row"; + const identity = element("div"); + identity.className = "workspace-identity"; const title = element("h3", text(summary.display_name) || text(summary.alias) || "未命名工作区"); - const badges = element("div"); - badges.className = "badges"; - addBadge(badges, text(summary.health) || "unavailable", attentionLevel(summary)); - addBadge(badges, text(summary.freshness) || "partial"); - header.append(title, badges); - card.append(header); - - const meta = element("p", `别名:${text(summary.alias)} · Task:${count(summary.task_count)} · Active Objective:${count(summary.active_objective_count)}`); + const meta = element("p", `别名:${text(summary.alias)} · 任务 ${count(summary.task_count)} · 活跃目标 ${count(summary.active_objective_count)}`); meta.className = "workspace-meta"; - card.append(meta); - const statuses = summary.task_status_counts && typeof summary.task_status_counts === "object" - ? Object.entries(summary.task_status_counts).filter(([, value]) => Number.isInteger(value) && value > 0) - : []; - if (statuses.length) { - const execution = element("p", `任务状态:${statuses.map(([key, value]) => `${key} ${value}`).join(" · ")}`); - execution.className = "workspace-meta"; - card.append(execution); - } + identity.append(title, meta); + card.append(identity); + + const health = element("div"); + health.className = "workspace-signal"; + health.append(element("span", "状态")); + health.firstChild.className = "workspace-signal-label"; + addBadge(health, displayLabel(summary.health, HEALTH_LABELS), attentionLevel(summary)); + card.append(health); - const recommendation = summary.recommendation || {}; - const reason = text(recommendation.reason) || "LOCAL_READ_UNAVAILABLE"; - const priority = element("p", `当前关注:${reason}`); - priority.className = "priority"; - card.append(priority); - const command = text(recommendation.command); - if (command) card.append(commandRow(command)); + const freshness = element("div"); + freshness.className = "workspace-signal"; + freshness.append(element("span", "新鲜度")); + freshness.firstChild.className = "workspace-signal-label"; + addBadge(freshness, displayLabel(summary.freshness, FRESHNESS_LABELS)); + card.append(freshness); - const footer = element("footer"); + const tasks = element("div", String(count(summary.task_count))); + tasks.className = "workspace-count"; + tasks.dataset.label = "任务数"; + card.append(tasks); + const objectives = element("div", String(count(summary.objective_count))); + objectives.className = "workspace-count"; + objectives.dataset.label = "目标数"; + card.append(objectives); + + const action = element("div"); + action.className = "workspace-action"; const view = element("button", "查看摘要"); + view.className = "secondary"; view.type = "button"; view.addEventListener("click", () => loadWorkspace(text(summary.alias))); - footer.append(view); - card.append(footer); + action.append(view); + card.append(action); return card; } @@ -174,9 +249,12 @@ function renderOverview(payload) { const data = payload && payload.data; if (!data || !Array.isArray(data.workspaces)) throw new Error("OVERVIEW_UNAVAILABLE"); const total = count(data.total_workspaces); - $("overview-summary").textContent = total ? `已加载 ${total} 个本地工作区;先处理有明确恢复路径的事项。` : "尚未登记工作区。可运行 dyro setup、dyro join 或 dyro workspace add。"; - $("captured-at").textContent = text(payload.captured_at) ? `采样时间:${text(payload.captured_at)}` : ""; + const attention = data.attention_counts || {}; + $("overview-heading").textContent = total ? overviewState(attention) : "尚未登记工作区"; + $("overview-summary").textContent = total ? `已加载 ${total} 个本地工作区;优先使用下一步命令继续已识别的工程工作。` : "尚未登记工作区。可运行 dyro setup、dyro join 或 dyro workspace add。"; + $("captured-at").textContent = text(payload.captured_at) ? `采样于 ${new Date(text(payload.captured_at)).toLocaleString("zh-CN")}` : ""; renderCounts(data.attention_counts || {}); + renderPrimaryAction(data.workspaces); const list = $("workspace-list"); list.replaceChildren(); if (!data.workspaces.length) { @@ -186,7 +264,6 @@ function renderOverview(payload) { return; } for (const summary of data.workspaces) list.append(renderWorkspaceCard(summary)); - if (state.focus) loadWorkspace(state.focus, true); } function definition(label, value) { @@ -208,11 +285,11 @@ async function loadWorkspace(alias, silent = false) { grid.className = "detail-grid"; grid.append( definition("别名", text(summary.alias)), - definition("健康", text(summary.health)), - definition("可用性", text(summary.availability)), - definition("Task 总数", String(count(summary.task_count))), + definition("健康", displayLabel(summary.health, HEALTH_LABELS)), + definition("可用性", displayLabel(summary.availability, AVAILABILITY_LABELS)), + definition("任务总数", String(count(summary.task_count))), definition("开发线", String(count(summary.line_count))), - definition("Objective", String(count(summary.objective_count))), + definition("目标", String(count(summary.objective_count))), ); content.replaceChildren(grid); const command = text(summary.recommendation && summary.recommendation.command); @@ -230,10 +307,11 @@ async function loadWorkspace(alias, silent = false) { function showError(error) { const code = text(error && error.message) || "LOCAL_READ_UNAVAILABLE"; - setStatus(`无法读取本地状态:${code}。可重新运行 dyro console。`, true); + const message = userError(code); + setStatus(`${message}。可重新运行 dyro console。`, true); const list = $("workspace-list"); list.replaceChildren(); - const notice = element("p", `读取失败:${code}。Console 未修改任何项目文件。`); + const notice = element("p", `${message}。Console 未修改任何项目文件。`); notice.className = "error"; list.append(notice); } @@ -262,6 +340,11 @@ function scheduleRefresh() { async function start() { $("refresh").addEventListener("click", async () => { await refresh(); scheduleRefresh(); }); + $("primary-copy").addEventListener("click", () => { + const button = $("primary-copy"); + const command = text(button.dataset.command); + if (command) copyCommand(command, button); + }); $("detail-close").addEventListener("click", () => { $("workspace-detail").hidden = true; }); document.addEventListener("visibilitychange", () => { if (document.hidden) { diff --git a/src/dyro/console/assets/index.html b/src/dyro/console/assets/index.html index 552a968..118de9f 100644 --- a/src/dyro/console/assets/index.html +++ b/src/dyro/console/assets/index.html @@ -3,39 +3,67 @@ - + Dyro Console
-
-
-

项目总览

-

优先处理需要关注的工程

-

正在读取本地工作区状态。

+
+
+
+

项目态势

+

正在读取工程状态

+

正在读取本地工作区状态。

+
+ +
+
+
+
+

下一步

+ 正在准备推荐命令… +
+
-
-
-

工作区

+
+

注册项目

+

工作区

+

-
+
+ +
+