Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions specs/deepwork/REQ-005-cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

## Overview

The DeepWork CLI provides two commands: `serve` (starts the MCP server) and `hook` (runs hook scripts). The CLI is built with Click and serves as the entry point for `deepwork` executable. The `serve` command is the primary runtime entry point, while `hook` provides a generic mechanism for running Python hook modules.
The DeepWork CLI provides two primary commands: `serve` (starts the MCP server) and `hook` (runs hook scripts), plus two deprecated back-compat commands: `install` and `sync`. The CLI is built with Click and serves as the entry point for `deepwork` executable. The `serve` command is the primary runtime entry point, while `hook` provides a generic mechanism for running Python hook modules.

## Requirements

### REQ-005.1: CLI Entry Point

1. The CLI MUST be a Click group command named `cli`.
2. The CLI MUST provide a `--version` option sourced from the `deepwork` package version.
3. The CLI MUST register both `serve` and `hook` as subcommands.
3. The CLI MUST register `serve`, `hook`, `install`, and `sync` as subcommands.
4. The CLI MUST be callable as `deepwork` from the command line (via package entry point).

### REQ-005.2: serve Command
Expand Down Expand Up @@ -41,3 +41,20 @@ The DeepWork CLI provides two commands: `serve` (starts the MCP server) and `hoo
9. If the module does not have a `main()` function, the system MUST raise `HookError`.
10. `HookError` exceptions MUST be caught and printed to stderr with exit code 1.
11. Other unexpected exceptions MUST be caught and printed to stderr with exit code 1.

### REQ-005.4: Deprecated install and sync Commands

> **SCHEDULED REMOVAL: June 1st, 2026; details in PR https://github.com/Unsupervisedcom/deepwork/pull/227.** These commands exist only for
> backwards compatibility with users who installed DeepWork globally via
> `brew` or `uv`. Once all users have migrated to the Claude Code plugin
> distribution model, this entire section and all associated code and tests
> SHOULD be deleted.

1. Both `install` and `sync` MUST be registered as Click commands with `hidden=True` so they do not appear in `--help` output.
2. Both commands MUST execute the same shared implementation (`_run_install_deprecation`).
3. The shared implementation MUST write plugin configuration to `.claude/settings.json`, creating the `.claude/` directory if it does not exist.
4. The configuration MUST merge into existing settings without overwriting unrelated keys (e.g., `permissions`).
5. The written `extraKnownMarketplaces` MUST contain a `deepwork-plugins` entry with source `{"source": "github", "repo": "Unsupervisedcom/deepwork"}`.
6. The written `enabledPlugins` MUST set `deepwork@deepwork-plugins` and `learning-agents@deepwork-plugins` to `true`.
7. If `.claude/settings.json` exists but contains invalid JSON, the command MUST treat it as an empty settings object (not crash).
8. Both commands MUST print a deprecation message that includes `brew uninstall deepwork` and `uv tool uninstall deepwork` instructions.
95 changes: 95 additions & 0 deletions src/deepwork/cli/install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Install/sync deprecation commands for DeepWork CLI.

# DEPRECATION NOTICE: Remove after June 1st, 2026; details in PR https://github.com/Unsupervisedcom/deepwork/pull/227
# These commands exist only for backwards compatibility with users who
# have DeepWork installed globally via brew or uv. By June 1st, 2026,
# all users should have migrated to the Claude plugin distribution model
# and these commands can be safely deleted.
"""

import json
from pathlib import Path

import click

_DEPRECATION_MESSAGE = """
╔══════════════════════════════════════════════════════════════════════╗
║ DEEPWORK DISTRIBUTION CHANGE ║
╚══════════════════════════════════════════════════════════════════════╝

DeepWork is no longer installed as a standalone CLI tool — it is now
distributed as a Claude Code plugin.

I have auto-installed the DeepWork plugin in this project for you,
but you must now uninstall the global CLI install you have previously done.

To uninstall, try BOTH of the following to make sure it is fully removed:

brew uninstall deepwork
uv tool uninstall deepwork

(It's safe to run both — one will succeed and the other will simply
report that it's not installed.)

Once uninstalled, DeepWork will continue to work automatically as a
Claude Code plugin whenever you use Claude in a project with DeepWork
configured.
"""


def _install_plugin_config() -> None:
"""Write marketplace and plugin configuration to .claude/settings.json.

Merges the DeepWork plugin marketplace and plugin entries into the
project's existing Claude settings without overwriting other config.
"""
settings_path = Path(".claude") / "settings.json"
settings_path.parent.mkdir(parents=True, exist_ok=True)

# Load existing settings or start fresh
if settings_path.exists():
try:
settings = json.loads(settings_path.read_text())
except (json.JSONDecodeError, OSError):
settings = {}
else:
settings = {}

# Merge extraKnownMarketplaces
marketplaces = settings.setdefault("extraKnownMarketplaces", {})
marketplaces["deepwork-plugins"] = {
"source": {
"source": "github",
"repo": "Unsupervisedcom/deepwork",
}
}

# Merge enabledPlugins
plugins = settings.setdefault("enabledPlugins", {})
plugins["deepwork@deepwork-plugins"] = True
plugins["learning-agents@deepwork-plugins"] = True

settings_path.write_text(json.dumps(settings, indent=2) + "\n")


def _run_install_deprecation() -> None:
"""Shared implementation for both install and sync commands.

# DEPRECATION NOTICE: Remove after June 1st, 2026; details in PR https://github.com/Unsupervisedcom/deepwork/pull/227
"""
_install_plugin_config()
click.echo(_DEPRECATION_MESSAGE)


# DEPRECATION NOTICE: Remove after June 1st, 2026; details in PR https://github.com/Unsupervisedcom/deepwork/pull/227
@click.command(hidden=True)
def install() -> None:
"""(Deprecated) Install DeepWork — now distributed as a Claude plugin."""
_run_install_deprecation()


# DEPRECATION NOTICE: Remove after June 1st, 2026; details in PR https://github.com/Unsupervisedcom/deepwork/pull/227
@click.command(hidden=True)
def sync() -> None:
"""(Deprecated) Sync DeepWork — now distributed as a Claude plugin."""
_run_install_deprecation()
7 changes: 7 additions & 0 deletions src/deepwork/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,18 @@ def cli() -> None:

# Import commands
from deepwork.cli.hook import hook # noqa: E402
from deepwork.cli.install import install, sync # noqa: E402
from deepwork.cli.serve import serve # noqa: E402

cli.add_command(hook)
cli.add_command(serve)

# DEPRECATION NOTICE: Remove after June 1st, 2026; details in PR https://github.com/Unsupervisedcom/deepwork/pull/227
# install and sync are hidden back-compat commands that tell users
# to migrate to the Claude plugin distribution model.
cli.add_command(install)
cli.add_command(sync)


if __name__ == "__main__":
cli()
220 changes: 220 additions & 0 deletions tests/unit/test_install_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""Tests for deprecated install/sync CLI commands -- validates REQ-005.4.

SCHEDULED REMOVAL: June 1st, 2026; details in PR https://github.com/Unsupervisedcom/deepwork/pull/227
Delete this entire file when REQ-005.4 and the install/sync commands are removed.
"""

import json
from pathlib import Path

from click.testing import CliRunner

from deepwork.cli.install import install, sync
from deepwork.cli.main import cli


class TestInstallCommandOutput:
"""Tests for the install command's deprecation message."""

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.8).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_install_prints_deprecation_message(self, tmp_path: str) -> None:
"""install must print a deprecation message with uninstall instructions."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
result = runner.invoke(install)

assert result.exit_code == 0
assert "no longer installed" in result.output.lower()
assert "brew uninstall deepwork" in result.output
assert "uv tool uninstall deepwork" in result.output

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.8).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_sync_prints_deprecation_message(self, tmp_path: str) -> None:
"""sync must print the same deprecation message as install."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
result = runner.invoke(sync)

assert result.exit_code == 0
assert "brew uninstall deepwork" in result.output
assert "uv tool uninstall deepwork" in result.output

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.2).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_install_and_sync_produce_identical_output(self, tmp_path: str) -> None:
"""Both commands must execute the same shared implementation."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
install_result = runner.invoke(install)
with runner.isolated_filesystem(temp_dir=tmp_path):
sync_result = runner.invoke(sync)

assert install_result.output == sync_result.output


class TestInstallHiddenFromHelp:
"""Tests that install and sync are hidden from CLI help."""

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.1).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_install_not_in_help(self) -> None:
"""install must not appear in --help output."""
runner = CliRunner()
result = runner.invoke(cli, ["--help"])

assert result.exit_code == 0
lines = result.output.splitlines()
command_lines = []
in_commands = False
for line in lines:
if line.strip().lower().startswith("commands:"):
in_commands = True
continue
if in_commands and line.strip():
command_lines.append(line)

command_names = [line.split()[0] for line in command_lines if line.strip()]
assert "install" not in command_names

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.1).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_sync_not_in_help(self) -> None:
"""sync must not appear in --help output."""
runner = CliRunner()
result = runner.invoke(cli, ["--help"])

assert result.exit_code == 0
lines = result.output.splitlines()
command_lines = []
in_commands = False
for line in lines:
if line.strip().lower().startswith("commands:"):
in_commands = True
continue
if in_commands and line.strip():
command_lines.append(line)

command_names = [line.split()[0] for line in command_lines if line.strip()]
assert "sync" not in command_names

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.1).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_install_still_invocable(self, tmp_path: str) -> None:
"""Hidden commands must still be invocable directly."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
result = runner.invoke(cli, ["install"])

assert result.exit_code == 0

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.1).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_sync_still_invocable(self, tmp_path: str) -> None:
"""Hidden commands must still be invocable directly."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
result = runner.invoke(cli, ["sync"])

assert result.exit_code == 0


class TestPluginConfigCreation:
"""Tests for auto-installing plugin config into .claude/settings.json."""

@staticmethod
def _read_settings() -> dict:
"""Read .claude/settings.json relative to CWD."""
return json.loads(Path(".claude/settings.json").read_text())

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.3).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_creates_settings_file_from_scratch(self, tmp_path: str) -> None:
"""install must create .claude/settings.json when it does not exist."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
result = runner.invoke(install)
assert result.exit_code == 0
settings = self._read_settings()

assert "extraKnownMarketplaces" in settings
assert "enabledPlugins" in settings

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.5).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_marketplace_entry_is_correct(self, tmp_path: str) -> None:
"""extraKnownMarketplaces must contain deepwork-plugins with correct source."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
runner.invoke(install)
settings = self._read_settings()

mp = settings["extraKnownMarketplaces"]["deepwork-plugins"]
assert mp == {
"source": {
"source": "github",
"repo": "Unsupervisedcom/deepwork",
}
}

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.6).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_enabled_plugins_are_correct(self, tmp_path: str) -> None:
"""enabledPlugins must include both deepwork and learning-agents plugins."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
runner.invoke(install)
settings = self._read_settings()

assert settings["enabledPlugins"]["deepwork@deepwork-plugins"] is True
assert settings["enabledPlugins"]["learning-agents@deepwork-plugins"] is True

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.4).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_merges_with_existing_settings(self, tmp_path: str) -> None:
"""install must preserve existing keys in settings.json."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
Path(".claude").mkdir()
existing = {
"permissions": {"allow": ["Bash(git:*)"]},
"enabledPlugins": {},
}
Path(".claude/settings.json").write_text(json.dumps(existing))

runner.invoke(install)
settings = self._read_settings()

# Original permissions must still be present
assert settings["permissions"] == {"allow": ["Bash(git:*)"]}
# New plugin config must be added
assert "deepwork-plugins" in settings["extraKnownMarketplaces"]
assert settings["enabledPlugins"]["deepwork@deepwork-plugins"] is True

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.7).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_handles_invalid_json_gracefully(self, tmp_path: str) -> None:
"""install must not crash if settings.json contains invalid JSON."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
Path(".claude").mkdir()
Path(".claude/settings.json").write_text("{invalid json!!")

result = runner.invoke(install)
assert result.exit_code == 0
settings = self._read_settings()

assert "extraKnownMarketplaces" in settings
assert "enabledPlugins" in settings

# THIS TEST VALIDATES A HARD REQUIREMENT (REQ-005.4.3).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
def test_creates_claude_directory_if_missing(self, tmp_path: str) -> None:
"""install must create the .claude/ directory if it does not exist."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
assert not Path(".claude").exists()
result = runner.invoke(install)
assert result.exit_code == 0
assert Path(".claude/settings.json").exists()