diff --git a/.github/workflows/zcode-acceptance.yml b/.github/workflows/zcode-acceptance.yml index c9b8b9274d..fc74e32c7d 100644 --- a/.github/workflows/zcode-acceptance.yml +++ b/.github/workflows/zcode-acceptance.yml @@ -101,7 +101,8 @@ jobs: node $env:ZCODE_CLI_BIN --version - name: Run all plugin Node tests including native host discovery - run: node --test integrations/zcode/plugins/powercontext/tests/*.test.mjs + # Process deadline checks must not compete with other test files for runner CPU and disk. + run: node --test --test-concurrency=1 integrations/zcode/plugins/powercontext/tests/*.test.mjs - name: Run two independent controlled CLI acceptances run: >- diff --git a/e2e/bub/tests/test_harbor_job_config.py b/e2e/bub/tests/test_harbor_job_config.py index b11847792e..dd9fc420e4 100644 --- a/e2e/bub/tests/test_harbor_job_config.py +++ b/e2e/bub/tests/test_harbor_job_config.py @@ -845,6 +845,16 @@ def _kimi_installation(home: Path) -> dict[str, object]: for path in sorted((home / "plugins").rglob("*")): if path.is_file(): text = path.read_text(encoding="utf-8") + if ( + path.relative_to(home).as_posix() + == "plugins/managed/powercontext/skills/powercontext-project-context/SKILL.md" + ): + # Each installation must point at its own installed script before paths are normalized for comparison. + script = home / "plugins/managed/powercontext/scripts/scope.mjs" + encoded_script = json.dumps(str(script), ensure_ascii=False) + assert script.is_file() + assert encoded_script in text + assert "__POWERCONTEXT_SCOPE_SCRIPT_JSON__" not in text if path.suffix == ".json": files[path.relative_to(home).as_posix()] = json.loads(text) else: diff --git a/integrations/zcode/plugins/powercontext/hooks/stop.mjs b/integrations/zcode/plugins/powercontext/hooks/stop.mjs index 9efb428203..843d01ce7c 100644 --- a/integrations/zcode/plugins/powercontext/hooks/stop.mjs +++ b/integrations/zcode/plugins/powercontext/hooks/stop.mjs @@ -88,7 +88,10 @@ async function main() { // A Stop Hook never blocks or continues the model turn. outputSent = true await new Promise(resolve => process.stdout.write('{}\n', resolve)) + await new Promise(resolve => process.stderr.write('', resolve)) clearTimeout(deadline) + // Completed state and output must not wait for a cancelled transport's remaining handles. + process.exit(0) } } await main() diff --git a/integrations/zcode/plugins/powercontext/tests/boundary.test.mjs b/integrations/zcode/plugins/powercontext/tests/boundary.test.mjs index 9ea73bc63f..846ad04ac7 100644 --- a/integrations/zcode/plugins/powercontext/tests/boundary.test.mjs +++ b/integrations/zcode/plugins/powercontext/tests/boundary.test.mjs @@ -362,7 +362,7 @@ test('pending bounds tracking capacity and keeps persisted receipts content-free } }) -test('real Stop process observes a slow unknown flush and exits without continuing the turn', { timeout: 10000 }, async () => { +async function checkStopProcess(delayedCleanup) { const root = await mkdtemp(join(tmpdir(), 'pc-zcode-stop-')) let flushes = 0 const server = createServer(async (req, res) => { @@ -387,11 +387,23 @@ test('real Stop process observes a slow unknown flush and exits without continui const tracking = await beginCaptureTracking(input, settings, keys, 'scope-boundary', data) await tracking.accepted(1) await leaseWindow() + const preload = join(root, 'transport-cleanup.mjs') + if (delayedCleanup) { + // Simulate a transport that retains a cleanup handle after an aborted real HTTP request. + await writeFile(preload, ` + const originalFetch = globalThis.fetch + globalThis.fetch = async (...args) => { + try { return await originalFetch(...args) } + catch (error) { setTimeout(() => {}, 2500); throw error } + } + `) + } const before = performance.now() const result = await new Promise((resolve, reject) => { const env = { ...process.env, ZCODE_PLUGIN_DATA: data } for (const key of Object.keys(env)) if (key.startsWith('POWERCONTEXT_ZCODE_') || key === 'ZCODE_SESSION_ID') delete env[key] - const child = spawn(process.execPath, [join(installed, 'hooks/stop.mjs')], { env, stdio: ['pipe', 'pipe', 'pipe'] }) + const args = [...(delayedCleanup ? ['--import', pathToFileURL(preload).href] : []), join(installed, 'hooks/stop.mjs')] + const child = spawn(process.execPath, args, { env, stdio: ['pipe', 'pipe', 'pipe'] }) let stdout = '', stderr = '' child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk }) child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk }) @@ -412,4 +424,10 @@ test('real Stop process observes a slow unknown flush and exits without continui await new Promise(resolve => server.close(resolve)) await rm(root, { recursive: true, force: true }) } -}) +} + +for (const delayedCleanup of [false, true]) { + const suffix = delayedCleanup ? ' with delayed transport cleanup' : '' + test(`real Stop process observes a slow unknown flush and exits without continuing the turn${suffix}`, + { timeout: 10000 }, () => checkStopProcess(delayedCleanup)) +} diff --git a/src/powercontext/client/cli.py b/src/powercontext/client/cli.py index ca00eabd44..9a5c689de0 100644 --- a/src/powercontext/client/cli.py +++ b/src/powercontext/client/cli.py @@ -19,13 +19,16 @@ import asyncio import base64 import binascii +import hashlib import json import os import socket +import subprocess import tempfile from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from pathlib import Path +from shutil import which from typing import Annotated, Never, TypeAlias from uuid import uuid4 @@ -78,8 +81,12 @@ CandidatePage, CandidateStatus, Capabilities, + ClearScopeBindingRequest, + ClearScopeBindingResponse, + ContextReference, CreateDreamRunRequest, CreateRemoteSkillTargetRequest, + CreateScopeRequest, DreamOperation, DreamRun, DreamRunPage, @@ -119,13 +126,22 @@ RemoteSkillTargetStatus, RenameRemoteSkillTargetRequest, ResolveExternalSkillRequest, + ResolveScopeBindingRequest, ReviseCandidateRequest, RevokeRemoteSkillTargetRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, ScopedStats, + ScopeExternalReference, ScopeId, + ScopePage, + ScopeQueryField, ScopeSelection, + SetDefaultScopeRequest, + SetScopeBindingRequest, SkillArtifact, SkillGenerationOrigin, SkillProposal, @@ -134,6 +150,7 @@ StatsPeriod, SubtreeScopeSelection, UnpublishRemoteSkillRequest, + UpdateScopeRequest, ) HELP_OPTION_NAMES = ("-h", "--help") @@ -155,8 +172,12 @@ | RemoteSkillPublication | RemoteSkillTarget | ScanExternalSkillsResponse + | ScopeBinding + | ScopeDescriptor + | ScopePage | SkillArtifact | ScopedStats + | ClearScopeBindingResponse ) _ClientOperation: TypeAlias = Callable[[PowerContextClient], Awaitable[_ClientResponse]] @@ -249,8 +270,227 @@ def list_dreams( help="Discover and exactly resolve Agent-native Skills on the Server host.", no_args_is_help=True, ) +scope_app = typer.Typer( + name="scope", + context_settings={"help_option_names": HELP_OPTION_NAMES}, + help="Create, inspect, and bind isolated Scope boundaries.", + no_args_is_help=True, +) +scope_binding_app = typer.Typer( + name="binding", + context_settings={"help_option_names": HELP_OPTION_NAMES}, + help="Bind external integration identities to one Scope.", + no_args_is_help=True, +) +scope_app.add_typer(scope_binding_app, name="binding") candidate_app.add_typer(candidate_revise_app, name="revise") +_BINDING_KIND_SESSION = "session" +_BINDING_KIND_WORKSPACE = "workspace" +_DEFAULT_BINDING_INTEGRATION = "opencode" + + +def _workspace_binding_external_id(cwd: Path, integration: str) -> str: + if integration in {"codex", "claude-code"}: + # These hosts bind the canonical repository root, including when invoked from a subdirectory. + root = cwd + executable = which("git") + if executable is not None: + try: + result = subprocess.run( # noqa: S603 - fixed Git arguments, without a shell. + [executable, "rev-parse", "--show-toplevel"], + cwd=cwd, + check=True, + capture_output=True, + text=True, + timeout=2.0, + ) + if result.stdout.strip(): + root = Path(result.stdout.strip()) + except (OSError, subprocess.SubprocessError): + pass + return hashlib.sha256(os.fsencode(root.resolve(strict=False))).hexdigest() + if integration in {"hermes", "kimi-code"}: + return hashlib.sha256(os.fsencode(cwd.resolve(strict=False))).hexdigest() + if integration in {"opencode", "pi"}: + # Node hosts use path.resolve without resolving symlinks. + return hashlib.sha256(os.path.abspath(cwd).encode("utf-8")).hexdigest() + raise typer.BadParameter( # noqa: TRY003 - user-facing CLI validation. + "Unsupported workspace integration; use an explicit binding key instead.", param_hint="--integration" + ) + + +def _binding_key(integration: str, kind: str, external_id: str) -> ScopeBindingKey: + return ScopeBindingKey(integration=integration, kind=kind, external_id=external_id) + + +def _external_reference(value: str) -> ScopeExternalReference: + kind, separator, reference_value = value.partition("=") + if not separator or not kind.strip() or not reference_value.strip(): + raise typer.BadParameter("--external-reference expects kind=value") # noqa: TRY003 + return ScopeExternalReference(kind=kind.strip(), value=reference_value.strip()) + + +@scope_app.command("create") +def create_scope( + context: typer.Context, + title: Annotated[str, typer.Option(help="Human-readable Scope title.")], + summary: Annotated[str, typer.Option(help="One-line description of the Scope boundary.")], + idempotency_key: Annotated[ + str | None, typer.Option(help="Stable creation key; repeating it returns the same Scope.") + ] = None, + parent_scope_id: Annotated[str | None, typer.Option(help="Optional parent Scope ID.")] = None, + context_reference: Annotated[list[str] | None, typer.Option(help="Related Scope ID; repeatable.")] = None, + external_reference: Annotated[ + list[str] | None, typer.Option(help="External reference as kind=value; repeatable.") + ] = None, +) -> None: + """Create one independent Scope boundary and print its opaque Scope ID.""" + + request = CreateScopeRequest( + title=title, + summary=summary, + idempotency_key=idempotency_key if idempotency_key is not None else uuid4().hex, + parent_scope_id=parent_scope_id, + context_references=[ContextReference(value) for value in context_reference or []], + external_references=[_external_reference(value) for value in external_reference or []], + ) + asyncio.run(_execute(context, lambda client: client.create_scope(request))) + if not _options(context).json_output: + typer.echo("Next: put this ID into the matching client SCOPE_ID environment variable.") + + +@scope_app.command("list") +def list_scopes( + context: typer.Context, + query: Annotated[str | None, typer.Option(help="Bounded substring query; requires --query-field.")] = None, + query_field: Annotated[ + ScopeQueryField | None, typer.Option(help="Field matched by --query; requires --query.") + ] = None, + cursor: Annotated[str | None, typer.Option(help="Opaque cursor from the previous page.")] = None, + limit: Annotated[int, typer.Option(min=1, max=100, help="Maximum Scopes to return.")] = 50, +) -> None: + """List a bounded page of Scope descriptors.""" + + if (query is None) != (query_field is None): + raise typer.BadParameter("--query and --query-field must be supplied together") # noqa: TRY003 + + asyncio.run( + _execute( + context, + lambda client: client.list_scopes(query=query, query_field=query_field, limit=limit, cursor=cursor), + ) + ) + + +@scope_app.command("get") +def get_scope(context: typer.Context, scope_id: Annotated[str, typer.Argument(help="Exact Scope ID.")]) -> None: + """Read one exact Scope descriptor.""" + + asyncio.run(_execute(context, lambda client: client.get_scope(scope_id))) + + +@scope_app.command("update") +def update_scope( + context: typer.Context, + scope_id: Annotated[str, typer.Argument(help="Exact Scope ID.")], + title: Annotated[str, typer.Option(help="Replacement Scope title.")], + summary: Annotated[str, typer.Option(help="Replacement one-line description.")], + expected_version: Annotated[int, typer.Option(min=1, help="Current version read from `scope get`.")], +) -> None: + """Update title and summary, preserving relationships behind an explicit optimistic-lock version.""" + + async def operation(client: PowerContextClient) -> ScopeDescriptor: + current = await client.get_scope(scope_id) + request = UpdateScopeRequest( + expected_version=expected_version, + title=title, + summary=summary, + parent_scope_id=current.parent_scope_id, + context_references=current.context_references, + external_references=current.external_references, + ) + return await client.update_scope(scope_id, request) + + asyncio.run(_execute(context, operation)) + + +@scope_app.command("default") +def show_default_scope(context: typer.Context) -> None: + """Read the host's default Scope target.""" + + asyncio.run(_execute(context, lambda client: client.get_default_scope())) + + +@scope_app.command("set-default") +def set_default_scope(context: typer.Context, scope_id: Annotated[str, typer.Argument(help="Exact Scope ID.")]) -> None: + """Change the host's default Scope target used when no binding matches.""" + + request = SetDefaultScopeRequest(scope_id=scope_id) + asyncio.run(_execute(context, lambda client: client.set_default_scope(request))) + + +@scope_app.command("resolve") +def resolve_scope( + context: typer.Context, + scope_id: Annotated[ + str | None, typer.Option(help="Explicit Scope ID, mirroring a client SCOPE_ID setting.") + ] = None, + cwd: Annotated[ + Path | None, typer.Option(help="Workspace directory for the workspace binding key; defaults to here.") + ] = None, + session_id: Annotated[str | None, typer.Option(help="Session ID for the session binding key.")] = None, + integration: Annotated[str, typer.Option(help="Binding integration name.")] = _DEFAULT_BINDING_INTEGRATION, + allow_default: Annotated[ + bool, typer.Option("--allow-default/--no-allow-default", help="Fall back to the default Scope when unset.") + ] = True, +) -> None: + """Resolve a Scope the way an agent plugin does: explicit ID, then bindings, then the default.""" + + binding_keys = [] + if scope_id is None: + if session_id is not None and session_id.strip(): + binding_keys.append(_binding_key(integration, _BINDING_KIND_SESSION, session_id.strip())) + workspace = cwd if cwd is not None else Path.cwd() + binding_keys.append( + _binding_key(integration, _BINDING_KIND_WORKSPACE, _workspace_binding_external_id(workspace, integration)) + ) + request = ResolveScopeBindingRequest( + explicit_scope_id=scope_id, + binding_keys=binding_keys, + allow_default=allow_default, + ) + asyncio.run(_execute(context, lambda client: client.resolve_scope_binding(request))) + + +@scope_binding_app.command("set") +def set_scope_binding( + context: typer.Context, + scope_id: Annotated[str, typer.Argument(help="Exact Scope ID to bind.")], + kind: Annotated[str, typer.Option(help="Binding kind, e.g. session or workspace.")], + external_id: Annotated[str, typer.Option(help="Integration-specific identity, e.g. a session or path hash.")], + integration: Annotated[str, typer.Option(help="Binding integration name.")] = _DEFAULT_BINDING_INTEGRATION, +) -> None: + """Bind one external integration identity to a Scope.""" + + request = SetScopeBindingRequest( + root=ScopeBinding(key=_binding_key(integration, kind, external_id), scope_id=scope_id) + ) + asyncio.run(_execute(context, lambda client: client.set_scope_binding(request))) + + +@scope_binding_app.command("clear") +def clear_scope_binding( + context: typer.Context, + kind: Annotated[str, typer.Option(help="Binding kind, e.g. session or workspace.")], + external_id: Annotated[str, typer.Option(help="Integration-specific identity, e.g. a session or path hash.")], + integration: Annotated[str, typer.Option(help="Binding integration name.")] = _DEFAULT_BINDING_INTEGRATION, +) -> None: + """Clear one external integration binding.""" + + request = ClearScopeBindingRequest(key=_binding_key(integration, kind, external_id)) + asyncio.run(_execute(context, lambda client: client.clear_scope_binding(request))) + @dataclass(frozen=True, slots=True) class _ClientOptions: @@ -1754,14 +1994,8 @@ def _print_artifact_page(response: ArtifactPage) -> None: typer.echo(f"Next cursor: {response.next_cursor}") -def _print_human_response(response: _ClientResponse) -> None: - if isinstance(response, (DreamRun, DreamRunPage)): - _print_dream_response(response) - return - if isinstance(response, (ListRemoteSkillTargetsResponse, RemoteSkillPublication, RemoteSkillTarget)): - _print_remote_response(response) - return - if isinstance( +def _dump_json_response(response: _ClientResponse) -> bool: + if not isinstance( response, ( Candidate, @@ -1775,11 +2009,60 @@ def _print_human_response(response: _ClientResponse) -> None: SkillArtifact, ), ): - typer.echo(response.model_dump_json(indent=2)) + return False + typer.echo(response.model_dump_json(indent=2)) + return True + + +def _print_scope_response(response: ScopeDescriptor | ScopePage | ScopeBinding | ClearScopeBindingResponse) -> None: + match response: + case ScopeDescriptor(): + _print_scope_descriptor(response) + case ScopePage(): + if not response.items: + typer.echo("No Scopes found.") + return + for item in response.items: + _print_scope_descriptor(item) + if response.next_cursor is not None: + typer.echo(f"Next cursor: {response.next_cursor}") + case ScopeBinding(): + key = response.key + typer.echo(f"Binding {key.integration}/{key.kind}/{key.external_id} -> {response.scope_id}") + case ClearScopeBindingResponse(): + typer.echo("Binding cleared." if response.cleared else "Binding was not set.") + + +def _print_scope_descriptor(response: ScopeDescriptor) -> None: + typer.echo(f"{response.scope_id} {response.title} (version {response.version})") + if response.parent_scope_id is not None: + typer.echo(f" Parent: {response.parent_scope_id}") + for reference in response.context_references: + typer.echo(f" Context reference: {reference.root}") + for reference in response.external_references: + typer.echo(f" External reference: {reference.kind}={reference.value}") + + +def _print_human_response(response: _ClientResponse) -> None: + if isinstance(response, (DreamRun, DreamRunPage)): + _print_dream_response(response) return + if isinstance(response, (ScopeDescriptor, ScopePage, ScopeBinding, ClearScopeBindingResponse)): + _print_scope_response(response) + return + if isinstance(response, (ListRemoteSkillTargetsResponse, RemoteSkillPublication, RemoteSkillTarget)): + _print_remote_response(response) + return + if _dump_json_response(response): + return + if isinstance(response, Capabilities): + _print_capabilities(response) + return + _print_service_response(response) + + +def _print_service_response(response: _ClientResponse) -> None: match response: - case Capabilities(): - _print_capabilities(response) case ReadinessResponse(): typer.echo(f"Status: {response.status}") for name, status in sorted(response.checks.items()): @@ -1790,6 +2073,8 @@ def _print_human_response(response: _ClientResponse) -> None: _print_stats(response) case ArtifactPage(): _print_artifact_page(response) + case _: + return def _print_capabilities(response: Capabilities) -> None: @@ -1981,6 +2266,7 @@ def register_commands(cli: typer.Typer) -> set[str]: cli.add_typer(experience_app, name="experience") cli.add_typer(skill_app, name="skill") cli.add_typer(external_skill_app, name="external-skill") + cli.add_typer(scope_app, name="scope") return { "capabilities", "stats", @@ -1992,6 +2278,7 @@ def register_commands(cli: typer.Typer) -> set[str]: "experience", "skill", "external-skill", + "scope", } diff --git a/tests/test_cli_scope.py b/tests/test_cli_scope.py new file mode 100644 index 0000000000..e83098cf44 --- /dev/null +++ b/tests/test_cli_scope.py @@ -0,0 +1,481 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import hashlib +import os +import subprocess +from pathlib import Path +from shutil import which +from types import TracebackType +from typing import Self + +import pytest +from click import unstyle +from typer.testing import CliRunner + +import powercontext.client.cli as client_cli +from powercontext.cli.app import create_cli +from powercontext.http import ( + ClearScopeBindingRequest, + ClearScopeBindingResponse, + CreateScopeRequest, + ResolveScopeBindingRequest, + ScopeBinding, + ScopeDescriptor, + ScopePage, + SetDefaultScopeRequest, + SetScopeBindingRequest, + UpdateScopeRequest, +) + + +def _descriptor(scope_id: str = "scp_test", **overrides: object) -> ScopeDescriptor: + fields: dict[str, object] = { + "scope_id": scope_id, + "title": "PowerContext", + "summary": "memory for powercontext", + "parent_scope_id": None, + "context_references": [], + "external_references": [], + "version": 1, + } + return ScopeDescriptor.model_validate({**fields, **overrides}) + + +def _workspace_external_id(cwd: Path) -> str: + return hashlib.sha256(os.path.abspath(cwd).encode("utf-8")).hexdigest() + + +class _RecordingClient: + response: object + + def __init__(self) -> None: + self.calls: list[object] = [] + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None + + def _record(self, request: object) -> object: + self.calls.append(request) + return self.response + + +def _recording_type(response: object) -> type: + class Client(_RecordingClient): + async def create_scope(self, request: CreateScopeRequest) -> object: + return self._record(request) + + async def list_scopes(self, **kwargs: object) -> object: + return self._record(kwargs) + + async def get_scope(self, scope_id: str) -> object: + return self._record(scope_id) + + async def update_scope(self, scope_id: str, request: UpdateScopeRequest) -> object: + return self._record((scope_id, request)) + + async def get_default_scope(self) -> object: + return self._record(None) + + async def set_default_scope(self, request: SetDefaultScopeRequest) -> object: + return self._record(request) + + async def resolve_scope_binding(self, request: ResolveScopeBindingRequest) -> object: + return self._record(request) + + async def set_scope_binding(self, request: SetScopeBindingRequest) -> object: + return self._record(request) + + async def clear_scope_binding(self, request: ClearScopeBindingRequest) -> object: + return self._record(request) + + Client.response = response + return Client + + +def _invoke(monkeypatch: pytest.MonkeyPatch, response: object, arguments: list[str]) -> tuple[_RecordingClient, str]: + client = _recording_type(response)() + monkeypatch.setattr(client_cli, "PowerContextClient", lambda *_args, **_kwargs: client) + result = CliRunner().invoke(create_cli([]), arguments) + assert result.exit_code == 0, unstyle(result.output) + return client, unstyle(result.output) + + +def test_scope_create_sends_explicit_idempotency_key_and_prints_next_step(monkeypatch: pytest.MonkeyPatch) -> None: + client, output = _invoke( + monkeypatch, + _descriptor("scp_created"), + [ + "scope", + "create", + "--title", + "PowerContext", + "--summary", + "memory", + "--idempotency-key", + "powercontext-repo", + ], + ) + request = client.calls[0] + assert isinstance(request, CreateScopeRequest) + assert request.title == "PowerContext" + assert request.idempotency_key == "powercontext-repo" + assert "scp_created" in output + assert "SCOPE_ID" in output + + +def test_scope_create_generates_an_idempotency_key_when_omitted(monkeypatch: pytest.MonkeyPatch) -> None: + client, _ = _invoke( + monkeypatch, + _descriptor(), + ["scope", "create", "--title", "PowerContext", "--summary", "memory"], + ) + request = client.calls[0] + assert isinstance(request, CreateScopeRequest) + assert request.idempotency_key + + +def test_scope_create_rejects_malformed_external_references(monkeypatch: pytest.MonkeyPatch) -> None: + client = _recording_type(_descriptor())() + monkeypatch.setattr(client_cli, "PowerContextClient", lambda *_args, **_kwargs: client) + result = CliRunner().invoke( + create_cli([]), + [ + "scope", + "create", + "--title", + "PowerContext", + "--summary", + "memory", + "--external-reference", + "git:", + ], + ) + assert result.exit_code != 0 + assert "expects kind=value" in unstyle(result.output) + assert client.calls == [] + + +def test_scope_create_sends_wellformed_external_references(monkeypatch: pytest.MonkeyPatch) -> None: + client, _ = _invoke( + monkeypatch, + _descriptor(external_references=[{"kind": "git", "value": "github.com/oceanbase/powercontext"}]), + [ + "scope", + "create", + "--title", + "PowerContext", + "--summary", + "memory", + "--external-reference", + "git=github.com/oceanbase/powercontext", + ], + ) + request = client.calls[0] + assert isinstance(request, CreateScopeRequest) + assert [f"{reference.kind}={reference.value}" for reference in request.external_references] == [ + "git=github.com/oceanbase/powercontext" + ] + + +def test_scope_resolve_mirrors_the_plugin_binding_keys(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + workspace = tmp_path / "worktree" + client, output = _invoke( + monkeypatch, + _descriptor("scp_resolved"), + [ + "scope", + "resolve", + "--cwd", + str(workspace), + "--session-id", + "ses_1", + "--no-allow-default", + ], + ) + request = client.calls[0] + assert isinstance(request, ResolveScopeBindingRequest) + assert request.allow_default is False + assert request.explicit_scope_id is None + assert [(key.integration, key.kind, key.external_id) for key in request.binding_keys] == [ + ("opencode", "session", "ses_1"), + ("opencode", "workspace", _workspace_external_id(workspace)), + ] + assert "scp_resolved" in output + + +def test_scope_resolve_passes_the_explicit_scope_id(monkeypatch: pytest.MonkeyPatch) -> None: + client, _ = _invoke( + monkeypatch, + _descriptor(), + ["scope", "resolve", "--scope-id", "git:github.com/oceanbase/powercontext"], + ) + request = client.calls[0] + assert isinstance(request, ResolveScopeBindingRequest) + assert request.explicit_scope_id == "git:github.com/oceanbase/powercontext" + + +def test_scope_update_forwards_the_expected_version(monkeypatch: pytest.MonkeyPatch) -> None: + client, output = _invoke( + monkeypatch, + _descriptor("scp_moved", version=2), + [ + "scope", + "update", + "scp_moved", + "--title", + "Renamed", + "--summary", + "updated summary", + "--expected-version", + "2", + ], + ) + call = client.calls[-1] + assert call == ("scp_moved", UpdateScopeRequest(expected_version=2, title="Renamed", summary="updated summary")) + assert "scp_moved" in output + + +def test_scope_list_prints_every_descriptor_and_the_cursor(monkeypatch: pytest.MonkeyPatch) -> None: + page = ScopePage(items=[_descriptor("scp_a"), _descriptor("scp_b")], next_cursor="cursor-1") + client, output = _invoke(monkeypatch, page, ["scope", "list"]) + assert client.calls[0] == {"query": None, "query_field": None, "limit": 50, "cursor": None} + assert "scp_a" in output + assert "scp_b" in output + assert "Next cursor: cursor-1" in output + + +def test_scope_set_default_forwards_the_exact_scope_id(monkeypatch: pytest.MonkeyPatch) -> None: + client, _ = _invoke(monkeypatch, _descriptor(), ["scope", "set-default", "scp_default"]) + request = client.calls[0] + assert isinstance(request, SetDefaultScopeRequest) + assert request.scope_id == "scp_default" + + +def test_scope_binding_set_prints_the_bound_key(monkeypatch: pytest.MonkeyPatch) -> None: + client, output = _invoke( + monkeypatch, + ScopeBinding.model_validate({ + "key": {"integration": "opencode", "kind": "workspace", "external_id": "abc"}, + "scope_id": "scp_bound", + }), + ["scope", "binding", "set", "scp_bound", "--kind", "workspace", "--external-id", "abc"], + ) + request = client.calls[0] + assert isinstance(request, SetScopeBindingRequest) + key = request.root.key + assert key.integration == "opencode" + assert key.kind == "workspace" + assert key.external_id == "abc" + assert "-> scp_bound" in output + + +def test_scope_binding_clear_reports_the_outcome(monkeypatch: pytest.MonkeyPatch) -> None: + _client, output = _invoke( + monkeypatch, + ClearScopeBindingResponse(cleared=False), + [ + "scope", + "binding", + "clear", + "--kind", + "workspace", + "--external-id", + "abc", + "--integration", + "codex", + ], + ) + assert "Binding was not set." in output + + +def test_scope_binding_clear_reports_a_cleared_binding(monkeypatch: pytest.MonkeyPatch) -> None: + _client, output = _invoke( + monkeypatch, + ClearScopeBindingResponse(cleared=True), + ["scope", "binding", "clear", "--kind", "session", "--external-id", "ses_1"], + ) + assert "Binding cleared." in output + + +@pytest.mark.parametrize("current_version", [1, 2]) +def test_scope_update_preserves_relationships(monkeypatch: pytest.MonkeyPatch, current_version: int) -> None: + descriptor = _descriptor( + version=current_version, + parent_scope_id="scp_parent", + context_references=["scp_related"], + external_references=[{"kind": "git", "value": "example/repo"}], + ) + client, _ = _invoke( + monkeypatch, + descriptor, + [ + "scope", + "update", + "scp_test", + "--title", + "Renamed", + "--summary", + "Updated", + "--expected-version", + "1", + ], + ) + call = client.calls[-1] + assert isinstance(call, tuple) + _, request = call + assert isinstance(request, UpdateScopeRequest) + assert request.expected_version == 1 + assert request.parent_scope_id == descriptor.parent_scope_id + assert request.context_references == descriptor.context_references + assert request.external_references == descriptor.external_references + + +@pytest.mark.parametrize("use_symlink", [False, True]) +def test_codex_scope_resolve_uses_canonical_repository_root( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + use_symlink: bool, +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + executable = which("git") + assert executable is not None + subprocess.run([executable, "init", str(repo)], check=True, capture_output=True) + workspace = repo / "src" + workspace.mkdir() + if use_symlink: + link = tmp_path / "link" + try: + link.symlink_to(workspace, target_is_directory=True) + except OSError: + pytest.skip("Host cannot create directory symlinks") + workspace = link + client, _ = _invoke( + monkeypatch, + _descriptor(), + [ + "scope", + "resolve", + "--integration", + "codex", + "--cwd", + str(workspace), + "--no-allow-default", + ], + ) + request = client.calls[0] + assert isinstance(request, ResolveScopeBindingRequest) + assert request.binding_keys[0].external_id == hashlib.sha256(os.fsencode(repo.resolve())).hexdigest() + + +@pytest.mark.parametrize("integration", ["opencode", "pi", "hermes", "kimi-code", "codex", "claude-code"]) +def test_scope_resolve_non_git_symlink_matches_host_rules( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + integration: str, +) -> None: + directory = tmp_path / "directory" + directory.mkdir() + link = tmp_path / "link" + try: + link.symlink_to(directory, target_is_directory=True) + except OSError: + pytest.skip("Host cannot create directory symlinks") + client, _ = _invoke( + monkeypatch, + _descriptor(), + [ + "scope", + "resolve", + "--integration", + integration, + "--cwd", + str(link), + ], + ) + request = client.calls[0] + assert isinstance(request, ResolveScopeBindingRequest) + expected = os.path.abspath(link) if integration in {"opencode", "pi"} else str(directory.resolve()) + assert request.binding_keys[0].external_id == hashlib.sha256(os.fsencode(expected)).hexdigest() + + +def test_scope_resolve_rejects_unknown_workspace_rules(monkeypatch: pytest.MonkeyPatch) -> None: + client = _recording_type(_descriptor())() + monkeypatch.setattr(client_cli, "PowerContextClient", lambda *_args, **_kwargs: client) + result = CliRunner().invoke(create_cli([]), ["scope", "resolve", "--integration", "unknown-host"]) + assert result.exit_code != 0 + assert "Unsupported workspace integration" in unstyle(result.output) + assert client.calls == [] + + +@pytest.mark.parametrize("integration", ["zcode", "bub", "codex"]) +def test_scope_resolve_explicit_id_does_not_require_workspace_rules( + monkeypatch: pytest.MonkeyPatch, + integration: str, +) -> None: + client, _ = _invoke( + monkeypatch, + _descriptor(), + [ + "scope", + "resolve", + "--scope-id", + "scp_test", + "--integration", + integration, + "--session-id", + "session-1", + "--no-allow-default", + ], + ) + request = client.calls[0] + assert isinstance(request, ResolveScopeBindingRequest) + assert request.explicit_scope_id == "scp_test" + assert request.binding_keys == [] + assert request.allow_default is False + + +@pytest.mark.parametrize("arguments", [["--query", "Example"], ["--query-field", "title"]]) +def test_scope_list_requires_query_and_field_together(monkeypatch: pytest.MonkeyPatch, arguments: list[str]) -> None: + client = _recording_type(ScopePage(items=[]))() + monkeypatch.setattr(client_cli, "PowerContextClient", lambda *_args, **_kwargs: client) + result = CliRunner().invoke(create_cli([]), ["scope", "list", *arguments]) + assert result.exit_code == 2 + assert "--query and --query-field must be supplied together" in unstyle(result.output) + assert client.calls == [] + + +def test_scope_list_forwards_query_with_field(monkeypatch: pytest.MonkeyPatch) -> None: + client, _ = _invoke( + monkeypatch, + ScopePage(items=[]), + [ + "scope", + "list", + "--query", + "Example", + "--query-field", + "title", + ], + ) + assert client.calls[0] == {"query": "Example", "query_field": "title", "limit": 50, "cursor": None}