diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index eca435c..a84b0f8 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -325,34 +325,34 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/branch` — Create a branch of the current conversation - [ ] `/bridge` — Connect for remote-control sessions - [ ] `/btw` — Quick side question without interrupting main conversation -- [ ] `/chrome` — Chrome extension settings +- [x] `/chrome` — Chrome extension settings - [x] `/color` — Set the prompt bar color for this session - [x] `/compact` — Clear history but keep a summary in context - [x] `/copy` — Copy Claude's last response to clipboard - [x] `/cost` — Show total cost and duration of session -- [ ] `/desktop` — Continue session in Claude Desktop +- [x] `/desktop` — Continue session in Claude Desktop - [x] `/diff` — View uncommitted changes and per-turn diffs - [x] `/doctor` — Diagnose and verify installation and settings - [x] `/effort` — Set effort level for model usage - [x] `/exit` — Exit the REPL - [x] `/export` — Export conversation to file or clipboard -- [ ] `/extra-usage` — Configure extra usage for rate limits +- [x] `/extra-usage` — Configure extra usage for rate limits - [x] `/fast` — Toggle fast mode -- [ ] `/feedback` — Submit feedback +- [x] `/feedback` — Submit feedback (alias `/bug`) - [x] `/files` — List all files currently in context - [ ] `/ide` — Manage IDE integrations and show status -- [ ] `/install-github-app` — Set up GitHub Actions -- [ ] `/install-slack-app` — Install Slack app +- [x] `/install-github-app` — Set up GitHub Actions +- [x] `/install-slack-app` — Install Slack app - [ ] `/keybindings` — Open keybindings config file -- [ ] `/mobile` — QR code for mobile app -- [ ] `/output-style` — Change output style -- [ ] `/passes` — Passes management +- [x] `/mobile` — Mobile app store links (aliases `/ios`, `/android`) +- [x] `/output-style` — Deprecation pointer to `/config` +- [x] `/passes` — Passes management - [ ] `/plugin` — Plugin management - [x] `/pr-comments`, `/pr_comments` — Get comments from a GitHub PR (prompt-type) -- [ ] `/privacy-settings` — View/update privacy settings -- [ ] `/rate-limit-options` — Show options when rate limited -- [ ] `/release-notes` — View release notes -- [ ] `/reload-plugins` — Activate pending plugin changes +- [x] `/privacy-settings` — View/update privacy settings +- [x] `/rate-limit-options` — Show options when rate limited +- [x] `/release-notes` — View release notes +- [x] `/reload-plugins` — Activate pending plugin changes - [ ] `/remote-env` — Configure default remote environment - [ ] `/remote-setup` — Remote setup configuration - [x] `/rename` — Rename current conversation @@ -361,10 +361,10 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [ ] `/sandbox-toggle` — Toggle sandbox mode - [x] `/skills` — List available skills - [x] `/stats` — Usage statistics and activity -- [ ] `/stickers` — Order stickers +- [x] `/stickers` — Order stickers - [x] `/tag` — Toggle a searchable tag on the session - [ ] `/theme` — Change the theme -- [ ] `/upgrade` — Upgrade to Max +- [x] `/upgrade` — Upgrade to Max - [x] `/vim` — Toggle Vim/Normal editing modes - [ ] `/voice` — Toggle voice mode - [ ] Feature-gated: `/buddy`, `/fork`, `/peers`, `/proactive`, `/torch`, `/workflows` (full), etc. diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 1c5eabb..156fea8 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -414,6 +414,81 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='Restore the conversation to a previous point.', handler=_handle_rewind, ), + SlashCommandSpec( + names=('output-style',), + description='Deprecated: use /config to change your output style.', + handler=_handle_output_style, + ), + SlashCommandSpec( + names=('release-notes',), + description='Show local release notes or a link to the changelog.', + handler=_handle_release_notes, + ), + SlashCommandSpec( + names=('feedback', 'bug'), + description='Open the Claude Code feedback page in a browser.', + handler=_handle_feedback, + ), + SlashCommandSpec( + names=('upgrade',), + description='Open the Claude.ai upgrade page in a browser.', + handler=_handle_upgrade, + ), + SlashCommandSpec( + names=('stickers',), + description='Open the Claude Code sticker order page in a browser.', + handler=_handle_stickers, + ), + SlashCommandSpec( + names=('mobile', 'ios', 'android'), + description='Show download links for the Claude mobile apps.', + handler=_handle_mobile, + ), + SlashCommandSpec( + names=('desktop', 'app'), + description='Show the Claude Desktop handoff page link.', + handler=_handle_desktop, + ), + SlashCommandSpec( + names=('install-github-app',), + description='Open the Claude GitHub Actions setup page.', + handler=_handle_install_github_app, + ), + SlashCommandSpec( + names=('install-slack-app',), + description='Open the Claude Slack app installation page.', + handler=_handle_install_slack_app, + ), + SlashCommandSpec( + names=('privacy-settings',), + description='Open the Claude.ai privacy controls page.', + handler=_handle_privacy_settings, + ), + SlashCommandSpec( + names=('extra-usage',), + description='Show extra-usage configuration link.', + handler=_handle_extra_usage, + ), + SlashCommandSpec( + names=('passes',), + description='Show Claude Code guest passes information.', + handler=_handle_passes, + ), + SlashCommandSpec( + names=('rate-limit-options',), + description='Show options when the active account hits a rate limit.', + handler=_handle_rate_limit_options, + ), + SlashCommandSpec( + names=('chrome',), + description='Open the Claude Chrome extension page.', + handler=_handle_chrome, + ), + SlashCommandSpec( + names=('reload-plugins',), + description='Reload local plugin manifests and report counts.', + handler=_handle_reload_plugins, + ), ) @@ -1616,6 +1691,291 @@ def _handle_rewind(agent: 'LocalCodingAgent', args: str, input_text: str) -> Sla ) +_FEEDBACK_URL = 'https://github.com/anthropics/claude-code/issues' +_UPGRADE_URL = 'https://claude.ai/upgrade/max' +_STICKERS_URL = 'https://www.stickermule.com/claudecode' +_MOBILE_IOS_URL = 'https://apps.apple.com/app/claude-by-anthropic/id6473753684' +_MOBILE_ANDROID_URL = 'https://play.google.com/store/apps/details?id=com.anthropic.claude' +_DESKTOP_URL = 'https://claude.ai/download' +_GITHUB_APP_URL = 'https://github.com/apps/claude' +_SLACK_APP_URL = 'https://slack.com/marketplace/A08SF47R6P4-claude' +_PRIVACY_URL = 'https://claude.ai/settings/data-privacy-controls' +_CHROME_EXTENSION_URL = 'https://claude.ai/chrome' +_CHANGELOG_URL = 'https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md' + + +def _try_open_browser(url: str) -> bool: + import os + import webbrowser + + # Avoid spawning a browser in CI / non-interactive environments. + if os.environ.get('CLAUDE_CODE_NO_BROWSER') or os.environ.get('CI'): + return False + try: + return webbrowser.open(url, new=2) + except Exception: + return False + + +def _open_or_link(url: str, *, opening_message: str, fallback_message: str) -> str: + if _try_open_browser(url): + return f'{opening_message}\n {url}' + return f'{fallback_message}\n {url}' + + +def _changelog_path(agent: 'LocalCodingAgent') -> 'Path': + from pathlib import Path + + cwd = Path(agent.runtime_config.cwd) + for candidate in (cwd / 'CHANGELOG.md', cwd / 'docs' / 'CHANGELOG.md'): + if candidate.exists(): + return candidate + return cwd / 'CHANGELOG.md' + + +def _handle_output_style( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + '/output-style has been deprecated. Use /config to change your output style, ' + 'or set it in your settings file. Changes take effect on the next session.', + ) + + +def _handle_release_notes( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + path = _changelog_path(agent) + if path.exists(): + try: + content = path.read_text(encoding='utf-8').strip() + except OSError as exc: + return _local_result(input_text, f'Could not read {path}: {exc}') + # Show only the most recent release block (everything up to the second + # second-level heading, mirroring how the npm command surfaces a single + # version chunk by default). + lines = content.splitlines() + chunk: list[str] = [] + seen_heading = False + for line in lines: + if line.startswith('## '): + if seen_heading: + break + seen_heading = True + chunk.append(line) + return _local_result(input_text, '\n'.join(chunk).strip() or content) + return _local_result( + input_text, + f'No local CHANGELOG.md found. See the full changelog at:\n {_CHANGELOG_URL}', + ) + + +def _handle_feedback( + agent: 'LocalCodingAgent', + args: str, + input_text: str, +) -> SlashCommandResult: + note = args.strip() + body = _open_or_link( + _FEEDBACK_URL, + opening_message='Opening the Claude Code feedback tracker in your browser…', + fallback_message='Submit feedback at:', + ) + if note: + body += f'\n\nDraft note (copy into the report form):\n{note}' + return _local_result(input_text, body) + + +def _handle_upgrade( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + _open_or_link( + _UPGRADE_URL, + opening_message='Opening the Claude.ai upgrade page in your browser…', + fallback_message='Upgrade your account at:', + ), + ) + + +def _handle_stickers( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + _open_or_link( + _STICKERS_URL, + opening_message='Opening the Claude Code sticker page in your browser…', + fallback_message='Order Claude Code stickers at:', + ), + ) + + +def _handle_mobile( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + lines = [ + 'Download the Claude mobile app:', + f' iOS: {_MOBILE_IOS_URL}', + f' Android: {_MOBILE_ANDROID_URL}', + ] + return _local_result(input_text, '\n'.join(lines)) + + +def _handle_desktop( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + import platform + + system = platform.system() + if system not in {'Darwin', 'Windows'}: + return _local_result( + input_text, + f'Claude Desktop is currently available on macOS and Windows only ' + f'(detected: {system}). Download:\n {_DESKTOP_URL}', + ) + return _local_result( + input_text, + _open_or_link( + _DESKTOP_URL, + opening_message='Opening the Claude Desktop download page in your browser…', + fallback_message='Download Claude Desktop at:', + ), + ) + + +def _handle_install_github_app( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + _open_or_link( + _GITHUB_APP_URL, + opening_message='Opening the Claude GitHub App installation page in your browser…', + fallback_message='Set up Claude GitHub Actions at:', + ), + ) + + +def _handle_install_slack_app( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + _open_or_link( + _SLACK_APP_URL, + opening_message='Opening the Claude Slack app marketplace page in your browser…', + fallback_message="Couldn't open browser. Visit:", + ), + ) + + +def _handle_privacy_settings( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + f'Review and manage your privacy settings at:\n {_PRIVACY_URL}', + ) + + +def _handle_extra_usage( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + 'Configure extra usage on a Claude.ai account at:\n' + f' {_UPGRADE_URL}\n' + 'After upgrading, run /login to refresh your local credentials.', + ) + + +def _handle_passes( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + 'Claude Code guest passes are managed in your Claude.ai account.\n' + ' Visit https://claude.ai to sign in and view remaining passes.', + ) + + +def _handle_rate_limit_options( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + lines = [ + 'When the current account hits a rate limit, you can:', + ' - Run /upgrade to move to a higher Claude.ai plan.', + ' - Run /extra-usage to enable per-message billing on a Claude.ai plan.', + ' - Run /login to switch to an API-key billed account.', + f'See {_UPGRADE_URL} for plan details.', + ] + return _local_result(input_text, '\n'.join(lines)) + + +def _handle_chrome( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + _open_or_link( + _CHROME_EXTENSION_URL, + opening_message='Opening the Claude Chrome extension page in your browser…', + fallback_message='Install the Claude Chrome extension at:', + ), + ) + + +def _handle_reload_plugins( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + from pathlib import Path + from .plugin_runtime import PluginRuntime + + runtime = PluginRuntime.from_workspace(Path(agent.runtime_config.cwd)) + agent.plugin_runtime = runtime + plugin_count = len(runtime.manifests) + tool_count = sum(len(manifest.tool_names) for manifest in runtime.manifests) + hook_count = sum(len(manifest.hook_names) for manifest in runtime.manifests) + virtual_count = sum(len(manifest.virtual_tools) for manifest in runtime.manifests) + return _local_result( + input_text, + 'Reloaded plugins: ' + f'{plugin_count} plugin(s) · {tool_count} tool(s) · {hook_count} hook(s) · ' + f'{virtual_count} virtual tool(s)', + ) + + 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_external_slash_commands.py b/tests/test_external_slash_commands.py new file mode 100644 index 0000000..fa376a4 --- /dev/null +++ b/tests/test_external_slash_commands.py @@ -0,0 +1,145 @@ +"""Tests for the informational slash commands ported from the npm source. + +Covers /output-style, /release-notes, /feedback, /upgrade, /stickers, /mobile, +/desktop, /install-github-app, /install-slack-app, /privacy-settings, +/extra-usage, /passes, /rate-limit-options, /chrome, /reload-plugins. +""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + +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 ExternalSlashCommandsTest(unittest.TestCase): + """Each test runs with CLAUDE_CODE_NO_BROWSER=1 so no browser opens.""" + + def setUp(self) -> None: + os.environ['CLAUDE_CODE_NO_BROWSER'] = '1' + + def tearDown(self) -> None: + os.environ.pop('CLAUDE_CODE_NO_BROWSER', None) + + def _run(self, cmd: str) -> str: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + return agent.run(cmd).final_output + + def test_output_style_is_deprecated(self) -> None: + out = self._run('/output-style') + self.assertIn('deprecated', out.lower()) + self.assertIn('/config', out) + + def test_release_notes_falls_back_to_link(self) -> None: + out = self._run('/release-notes') + self.assertIn('CHANGELOG.md', out) + self.assertIn('https://github.com/anthropics/claude-code', out) + + def test_release_notes_reads_local_changelog(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / 'CHANGELOG.md').write_text( + '# Changelog\n\n## 1.2.3\n- did a thing\n\n## 1.2.2\n- old\n', + encoding='utf-8', + ) + agent = _make_agent(tmp) + out = agent.run('/release-notes').final_output + self.assertIn('1.2.3', out) + self.assertIn('did a thing', out) + self.assertNotIn('1.2.2', out) + + def test_feedback_returns_link(self) -> None: + out = self._run('/feedback') + self.assertIn('https://github.com/anthropics/claude-code/issues', out) + + def test_bug_aliases_to_feedback(self) -> None: + out = self._run('/bug') + self.assertIn('https://github.com/anthropics/claude-code/issues', out) + + def test_feedback_includes_user_note(self) -> None: + out = self._run('/feedback the wrap selector keeps eating my prompt') + self.assertIn('Draft note', out) + self.assertIn('wrap selector', out) + + def test_upgrade_returns_link(self) -> None: + out = self._run('/upgrade') + self.assertIn('https://claude.ai/upgrade/max', out) + + def test_stickers_returns_link(self) -> None: + out = self._run('/stickers') + self.assertIn('stickermule.com/claudecode', out) + + def test_mobile_lists_both_stores(self) -> None: + out = self._run('/mobile') + self.assertIn('apps.apple.com', out) + self.assertIn('play.google.com', out) + + def test_ios_alias(self) -> None: + out = self._run('/ios') + self.assertIn('apps.apple.com', out) + + def test_android_alias(self) -> None: + out = self._run('/android') + self.assertIn('play.google.com', out) + + def test_desktop_returns_link(self) -> None: + out = self._run('/desktop') + self.assertIn('claude.ai/download', out) + + def test_app_aliases_to_desktop(self) -> None: + out = self._run('/app') + self.assertIn('claude.ai/download', out) + + def test_install_github_app(self) -> None: + out = self._run('/install-github-app') + self.assertIn('github.com/apps/claude', out) + + def test_install_slack_app(self) -> None: + out = self._run('/install-slack-app') + self.assertIn('slack.com/marketplace/A08SF47R6P4-claude', out) + + def test_privacy_settings(self) -> None: + out = self._run('/privacy-settings') + self.assertIn('claude.ai/settings/data-privacy-controls', out) + + def test_extra_usage_points_to_upgrade(self) -> None: + out = self._run('/extra-usage') + self.assertIn('claude.ai/upgrade/max', out) + self.assertIn('/login', out) + + def test_passes_mentions_claude_ai(self) -> None: + out = self._run('/passes') + self.assertIn('claude.ai', out.lower()) + self.assertIn('passes', out.lower()) + + def test_rate_limit_options_lists_actions(self) -> None: + out = self._run('/rate-limit-options') + self.assertIn('/upgrade', out) + self.assertIn('/extra-usage', out) + self.assertIn('/login', out) + + def test_chrome_returns_link(self) -> None: + out = self._run('/chrome') + self.assertIn('claude.ai/chrome', out) + + def test_reload_plugins_reports_counts(self) -> None: + out = self._run('/reload-plugins') + self.assertIn('Reloaded plugins', out) + self.assertIn('plugin(s)', out) + self.assertIn('tool(s)', out) + self.assertIn('hook(s)', out) + + +if __name__ == '__main__': + unittest.main()