From f7304bf55369fbed3596cf6ccb9162e176d180c6 Mon Sep 17 00:00:00 2001 From: Paul Hernandez <60959+phernandez@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:18:17 -0500 Subject: [PATCH] feat(cli): improve workspace and cloud bisync command discoverability (#905) Signed-off-by: phernandez Co-authored-by: Claude Opus 4.8 (1M context) --- src/basic_memory/cli/app.py | 1 + src/basic_memory/cli/commands/__init__.py | 2 ++ .../cli/commands/cloud/project_sync.py | 6 ++-- src/basic_memory/cli/commands/workspace.py | 23 ++++++++++++++ src/basic_memory/cli/main.py | 1 + tests/cli/cloud/test_project_sync_command.py | 3 ++ tests/cli/test_workspace_stub.py | 31 +++++++++++++++++++ 7 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 src/basic_memory/cli/commands/workspace.py create mode 100644 tests/cli/test_workspace_stub.py diff --git a/src/basic_memory/cli/app.py b/src/basic_memory/cli/app.py index 5c8addc2..13ef9aff 100644 --- a/src/basic_memory/cli/app.py +++ b/src/basic_memory/cli/app.py @@ -94,6 +94,7 @@ def app_callback( "reindex", "update", "watch", + "workspace", } if ( not version diff --git a/src/basic_memory/cli/commands/__init__.py b/src/basic_memory/cli/commands/__init__.py index fb0c187e..f7776ef5 100644 --- a/src/basic_memory/cli/commands/__init__.py +++ b/src/basic_memory/cli/commands/__init__.py @@ -9,6 +9,7 @@ from . import ( format, schema, update, + workspace, ) __all__ = [ @@ -27,4 +28,5 @@ __all__ = [ "format", "schema", "update", + "workspace", ] diff --git a/src/basic_memory/cli/commands/cloud/project_sync.py b/src/basic_memory/cli/commands/cloud/project_sync.py index 698b8c8a..9e513c10 100644 --- a/src/basic_memory/cli/commands/cloud/project_sync.py +++ b/src/basic_memory/cli/commands/cloud/project_sync.py @@ -146,7 +146,7 @@ def _get_sync_project( @cloud_app.command("sync") def sync_project_command( - name: str = typer.Option(..., "--name", help="Project name to sync"), + name: str = typer.Option(..., "--name", "--project", help="Project name to sync"), dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"), ) -> None: @@ -193,7 +193,7 @@ def sync_project_command( @cloud_app.command("bisync") def bisync_project_command( - name: str = typer.Option(..., "--name", help="Project name to bisync"), + name: str = typer.Option(..., "--name", "--project", help="Project name to bisync"), dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"), resync: bool = typer.Option(False, "--resync", help="Force new baseline"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"), @@ -256,7 +256,7 @@ def bisync_project_command( @cloud_app.command("check") def check_project_command( - name: str = typer.Option(..., "--name", help="Project name to check"), + name: str = typer.Option(..., "--name", "--project", help="Project name to check"), one_way: bool = typer.Option(False, "--one-way", help="Check one direction only (faster)"), ) -> None: """Verify file integrity between local and cloud. diff --git a/src/basic_memory/cli/commands/workspace.py b/src/basic_memory/cli/commands/workspace.py new file mode 100644 index 00000000..7ac3c092 --- /dev/null +++ b/src/basic_memory/cli/commands/workspace.py @@ -0,0 +1,23 @@ +"""Top-level `workspace` stub that redirects users to `bm cloud workspace`.""" + +import typer + +from basic_memory.cli.app import app + + +# Trigger: user runs `bm workspace`, `bm workspace list`, etc. +# Why: workspace verbs live under `bm cloud workspace`; a bare top-level miss +# only emits Typer's terse "No such command 'workspace'." with exit 2. +# Outcome: allow_extra_args + ignore_unknown_options absorb any trailing tokens +# (e.g. `list`, `set-default foo`) so every invocation reaches this body and +# prints actionable guidance instead of being rejected as bad usage. +@app.command( + "workspace", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) +def workspace_stub(ctx: typer.Context) -> None: + """Point users to the real workspace verbs under `bm cloud workspace`.""" + typer.echo("'bm workspace' is not a command. Workspace verbs live under 'bm cloud workspace':") + typer.echo(" bm cloud workspace list") + typer.echo(" bm cloud workspace set-default ") + raise typer.Exit(1) diff --git a/src/basic_memory/cli/main.py b/src/basic_memory/cli/main.py index de7e067d..46d9c94d 100644 --- a/src/basic_memory/cli/main.py +++ b/src/basic_memory/cli/main.py @@ -31,6 +31,7 @@ if not _version_only_invocation(sys.argv[1:]): status, tool, update, + workspace, ) warnings.filterwarnings("ignore") # pragma: no cover diff --git a/tests/cli/cloud/test_project_sync_command.py b/tests/cli/cloud/test_project_sync_command.py index c0dbcc01..50a9e332 100644 --- a/tests/cli/cloud/test_project_sync_command.py +++ b/tests/cli/cloud/test_project_sync_command.py @@ -19,6 +19,9 @@ runner = CliRunner() [ ["cloud", "sync", "--name", "research"], ["cloud", "bisync", "--name", "research"], + # --project is an alias for --name (issue #817) + ["cloud", "sync", "--project", "research"], + ["cloud", "bisync", "--project", "research"], ], ) def test_cloud_sync_commands_skip_explicit_cloud_project_sync(monkeypatch, argv, config_manager): diff --git a/tests/cli/test_workspace_stub.py b/tests/cli/test_workspace_stub.py new file mode 100644 index 00000000..0d8cf5c1 --- /dev/null +++ b/tests/cli/test_workspace_stub.py @@ -0,0 +1,31 @@ +"""Tests for the top-level `bm workspace` stub (issue #821). + +The stub redirects users to `bm cloud workspace` instead of letting Typer emit a +bare "No such command 'workspace'." with exit code 2. +""" + +import pytest +from typer.testing import CliRunner + +from basic_memory.cli.app import app + +# Importing the module registers the workspace_stub command on the top-level app. +import basic_memory.cli.commands.workspace # noqa: F401 + +runner = CliRunner() + + +@pytest.mark.parametrize( + "argv", + [ + ["workspace"], + ["workspace", "list"], + ["workspace", "set-default", "foo"], + ], +) +def test_workspace_stub_redirects_to_cloud_workspace(argv): + """Every `bm workspace ...` form exits 1 and points to `bm cloud workspace`.""" + result = runner.invoke(app, argv) + + assert result.exit_code == 1, result.output + assert "bm cloud workspace" in result.output