diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index c221706..7c907c3 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -323,7 +323,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/add-dir` — Add a new working directory - [x] `/agents` — Inspect, create, update, and delete local agent definitions - [x] `/branch` — Create a branch of the current conversation -- [ ] `/bridge` — Connect for remote-control sessions +- [x] `/bridge` — Connect for remote-control sessions (read-only status in this runtime) - [x] `/btw` — Quick side question without interrupting main conversation - [x] `/chrome` — Chrome extension settings - [x] `/color` — Set the prompt bar color for this session @@ -354,7 +354,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/release-notes` — View release notes - [x] `/reload-plugins` — Activate pending plugin changes - [x] `/remote-env` — Configure default remote environment -- [ ] `/remote-setup` — Remote setup configuration +- [x] `/remote-setup` — Remote setup configuration (gh auth status + Claude.ai/code link) - [x] `/rename` — Rename current conversation - [x] `/resume`, `/continue` — Resume a previous conversation - [x] `/rewind`, `/checkpoint` — Restore code/conversation to a previous point diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 364c88b..2654b3e 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -539,6 +539,16 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='List remote environments or set the default profile.', handler=_handle_remote_env, ), + SlashCommandSpec( + names=('bridge', 'remote-control', 'rc'), + description='Report remote-control bridge status (read-only in this runtime).', + handler=_handle_bridge, + ), + SlashCommandSpec( + names=('remote-setup', 'web-setup'), + description='Report Claude Code on the web setup readiness (gh + sign-in checks).', + handler=_handle_remote_setup, + ), ) @@ -2451,6 +2461,107 @@ def _handle_remote_env( ) +def _handle_bridge( + agent: 'LocalCodingAgent', + args: str, + input_text: str, +) -> SlashCommandResult: + runtime = agent.remote_runtime + requested_name = args.strip() or None + lines = ['Remote-control bridge: not implemented in the Python runtime.'] + lines.append( + ' The npm CLI hosts an interactive bridge against claude.ai; this ' + 'runtime only inspects the local remote-runtime state.' + ) + if runtime is None: + lines.append(' (Remote runtime is unavailable.)') + return _local_result(input_text, '\n'.join(lines)) + + connection = runtime.active_connection + if connection is not None: + lines.append('') + lines.append('Active local remote connection:') + lines.append(f' - mode: {connection.mode}') + lines.append(f' - target: {connection.target}') + if connection.profile_name: + lines.append(f' - profile: {connection.profile_name}') + if connection.session_url: + lines.append(f' - session URL: {connection.session_url}') + if connection.workspace_cwd: + lines.append(f' - workspace: {connection.workspace_cwd}') + else: + lines.append('') + lines.append('No active local remote connection.') + if requested_name: + profile = runtime.get_profile(requested_name) + lines.append('') + if profile is None: + lines.append( + f'No matching remote profile for "{requested_name}". ' + 'Run /remote-env to list available profiles.' + ) + else: + lines.append( + f'Matched remote profile "{profile.name}" ' + f'({profile.mode} -> {profile.target}). ' + 'Use the npm CLI bridge to actually connect.' + ) + return _local_result(input_text, '\n'.join(lines)) + + +def _gh_auth_status() -> tuple[str, str]: + """Return (status, detail) — status is one of 'not_installed', + 'authenticated', 'not_authenticated', 'unknown'.""" + import shutil + import subprocess + + if shutil.which('gh') is None: + return ('not_installed', 'gh CLI not on PATH') + try: + result = subprocess.run( + ['gh', 'auth', 'status'], + capture_output=True, + text=True, + timeout=5, + ) + except (subprocess.SubprocessError, OSError) as exc: + return ('unknown', f'gh auth status failed: {exc}') + detail = (result.stderr or result.stdout or '').strip().splitlines() + summary = detail[0] if detail else '' + if result.returncode == 0: + return ('authenticated', summary or 'Authenticated to GitHub') + return ('not_authenticated', summary or 'Not authenticated to GitHub') + + +def _handle_remote_setup( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + code_web_url = 'https://claude.ai/code' + gh_status, gh_detail = _gh_auth_status() + lines = [ + 'Claude Code on the web setup:', + f' Visit {code_web_url} to manage your environments.', + '', + f'GitHub CLI: {gh_status}', + f' {gh_detail}', + ] + if gh_status == 'not_installed': + lines.append(' Install gh from https://cli.github.com to import a GitHub token.') + elif gh_status == 'not_authenticated': + lines.append(' Run `gh auth login` to authenticate before importing your token.') + elif gh_status == 'authenticated': + lines.append(' You can run `gh auth token` to retrieve the token to import on the web.') + lines.append('') + lines.append( + 'Token import / default-environment provisioning is not implemented ' + 'in the Python runtime — complete remote setup from the npm CLI or ' + 'directly on claude.ai/code.' + ) + return _local_result(input_text, '\n'.join(lines)) + + def _prompt_result(input_text: str, prompt: str) -> SlashCommandResult: """Return a prompt-type result — the prompt gets sent to the model.""" return SlashCommandResult( diff --git a/tests/test_remote_slash_commands.py b/tests/test_remote_slash_commands.py new file mode 100644 index 0000000..fd383f8 --- /dev/null +++ b/tests/test_remote_slash_commands.py @@ -0,0 +1,109 @@ +"""Tests for remote/bridge slash commands ported from the npm source. + +Covers /bridge (aliases /remote-control, /rc) and /remote-setup +(alias /web-setup). +""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from src.agent_runtime import LocalCodingAgent +from src.agent_types import AgentRuntimeConfig, ModelConfig + + +def _make_agent(tmp_dir: str) -> LocalCodingAgent: + return LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + + +class BridgeCommandTest(unittest.TestCase): + def test_reports_unsupported_status(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/bridge').final_output + self.assertIn('not implemented', out.lower()) + self.assertIn('No active local remote connection', out) + + def test_remote_control_alias(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-control').final_output + self.assertIn('Remote-control bridge', out) + + def test_rc_alias(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/rc').final_output + self.assertIn('Remote-control bridge', out) + + def test_named_lookup_misses_unknown_profile(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/bridge nope').final_output + self.assertIn('No matching remote profile for "nope"', out) + + def test_named_lookup_matches_known_profile(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / '.remote.json').write_text(json.dumps({ + 'profiles': [ + {'name': 'edge', 'mode': 'ssh', 'target': 'user@edge.example'}, + ], + }), encoding='utf-8') + agent = _make_agent(tmp) + out = agent.run('/bridge edge').final_output + self.assertIn('Matched remote profile "edge"', out) + self.assertIn('user@edge.example', out) + + +class RemoteSetupCommandTest(unittest.TestCase): + def test_includes_web_url(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-setup').final_output + self.assertIn('https://claude.ai/code', out) + self.assertIn('GitHub CLI', out) + + def test_web_setup_alias(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/web-setup').final_output + self.assertIn('https://claude.ai/code', out) + + def test_handles_missing_gh(self) -> None: + with mock.patch('shutil.which', return_value=None): + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-setup').final_output + self.assertIn('not_installed', out) + self.assertIn('cli.github.com', out) + + def test_handles_authenticated_gh(self) -> None: + fake = mock.Mock(returncode=0, stdout='Logged in to github.com as octo', stderr='') + with mock.patch('shutil.which', return_value='/usr/bin/gh'), \ + mock.patch('subprocess.run', return_value=fake): + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-setup').final_output + self.assertIn('authenticated', out) + self.assertIn('gh auth token', out) + + def test_handles_unauthenticated_gh(self) -> None: + fake = mock.Mock(returncode=1, stdout='', stderr='You are not logged into any GitHub hosts') + with mock.patch('shutil.which', return_value='/usr/bin/gh'), \ + mock.patch('subprocess.run', return_value=fake): + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-setup').final_output + self.assertIn('not_authenticated', out) + self.assertIn('gh auth login', out) + + +if __name__ == '__main__': + unittest.main()