Skip to content
Open
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
72 changes: 58 additions & 14 deletions bin/claude-smart.js
Original file line number Diff line number Diff line change
Expand Up @@ -1710,7 +1710,7 @@ function printHelp() {
" 4. Restart OpenCode.",
"",
"Update:",
" npx claude-smart update Reinstall Claude Code support from this package",
" npx claude-smart update Update Claude Code support from this package",
" npx claude-smart update --host codex Reinstall Codex support from this package",
" npx claude-smart update --host opencode Reinstall OpenCode support from this package",
" npx claude-smart setup Configure managed/read-only/global setup",
Expand Down Expand Up @@ -2155,12 +2155,52 @@ async function runUpdate(args) {
return;
}

if (!hasClaudeCli()) {
process.stderr.write(
"error: 'claude' CLI not found on PATH. " +
"Install Claude Code first: https://claude.com/claude-code\n",
);
process.exit(1);
}

const pluginRoot = findClaudeCodePluginRoot();
if (pluginRoot) {
stopClaudeSmartServices(pluginRoot);
}
process.stdout.write("Updating claude-smart by reinstalling from this package...\n");
await runInstall(args, { retryInstallAfterUninstall: true });

const setup = configureReflexioSetup(HOST_CLAUDE_CODE);
process.stdout.write("Updating claude-smart from this package...\n");
let code = await runClaude(["plugin", "marketplace", "add", PACKAGE_ROOT], {
spinnerLabel: "Adding marketplace…",
});
if (code !== 0) {
process.stderr.write(
`error: \`claude plugin marketplace add ${PACKAGE_ROOT}\` failed (exit ${code})\n`,
);
process.exit(code);
}
Comment on lines 2166 to +2181

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid taking services offline before marketplace registration succeeds.

Both implementations exit on marketplace-add failure without restoring the services they already stopped.

  • bin/claude-smart.js#L2166-L2181: move stopClaudeSmartServices after successful marketplace registration.
  • plugin/src/claude_smart/cli.py#L1645-L1666: run marketplace registration before stopping dashboard/backend services.
  • tests/test_cli_install.py#L480-L485: update the expected ordering and cover marketplace-add failure without service shutdown.
📍 Affects 3 files
  • bin/claude-smart.js#L2166-L2181 (this comment)
  • plugin/src/claude_smart/cli.py#L1645-L1666
  • tests/test_cli_install.py#L480-L485
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/claude-smart.js` around lines 2166 - 2181, Move stopClaudeSmartServices
in bin/claude-smart.js to after successful marketplace registration; reorder
marketplace registration before dashboard/backend shutdown in
plugin/src/claude_smart/cli.py; update tests/test_cli_install.py to expect the
new ordering and verify marketplace-add failure leaves services running.


code = await runClaude(["plugin", "update", PLUGIN_SPEC], {
spinnerLabel: "Updating claude-smart…",
});
if (code !== 0) {
process.stderr.write(
`warning: \`claude plugin update ${PLUGIN_SPEC}\` failed (exit ${code}); falling back to reinstall.\n`,
);
await runInstall(args, { retryInstallAfterUninstall: true });
return;
}

await bootstrapClaudeCodeRuntime(setup.readOnly, "updated");
process.stdout.write(
[
"",
"claude-smart updated and dependencies are prepared. Restart Claude Code in your project.",
"The reflexio backend and dashboard auto-start on session start.",
"Opt out with CLAUDE_SMART_BACKEND_AUTOSTART=0 or CLAUDE_SMART_DASHBOARD_AUTOSTART=0.",
"",
].join("\n"),
);
}

async function runUpdateCodex(args) {
Expand Down Expand Up @@ -2282,6 +2322,20 @@ async function runInstall(args, options = {}) {
}
}

await bootstrapClaudeCodeRuntime(readOnly, "installed");

process.stdout.write(
[
"",
"claude-smart installed and dependencies are prepared. Restart Claude Code in your project.",
"The reflexio backend and dashboard auto-start on session start.",
"Opt out with CLAUDE_SMART_BACKEND_AUTOSTART=0 or CLAUDE_SMART_DASHBOARD_AUTOSTART=0.",
"",
].join("\n"),
);
}

async function bootstrapClaudeCodeRuntime(readOnly, action) {
try {
await refreshClaudeCodeCacheIfVendorMissing();
const pluginRoot = await bootstrapClaudeCodeInstall();
Expand All @@ -2299,23 +2353,13 @@ async function runInstall(args, options = {}) {
}
} catch (err) {
process.stderr.write(
`error: claude-smart installed, but dependency bootstrap failed: ${err && err.message ? err.message : err}\n`,
`error: claude-smart ${action}, but dependency bootstrap failed: ${err && err.message ? err.message : err}\n`,
);
process.stderr.write(
"Fix the issue above, then run /claude-smart:restart or restart Claude Code to retry.\n",
);
process.exit(1);
}

process.stdout.write(
[
"",
"claude-smart installed and dependencies are prepared. Restart Claude Code in your project.",
"The reflexio backend and dashboard auto-start on session start.",
"Opt out with CLAUDE_SMART_BACKEND_AUTOSTART=0 or CLAUDE_SMART_DASHBOARD_AUTOSTART=0.",
"",
].join("\n"),
);
}

async function runInstallCodex(args) {
Expand Down
108 changes: 80 additions & 28 deletions plugin/src/claude_smart/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,15 @@
from urllib.parse import urlparse
from urllib.request import url2pathname

from claude_smart import context_format, cs_cite, env_config, ids, publish, runtime, state
from claude_smart import (
context_format,
cs_cite,
env_config,
ids,
publish,
runtime,
state,
)
from claude_smart.reflexio_adapter import Adapter

_HOST_CLAUDE_CODE = runtime.HOST_CLAUDE_CODE
Expand Down Expand Up @@ -987,7 +995,9 @@ def _configure_reflexio_setup(host: str = _HOST_CLAUDE_CODE) -> bool:
os.environ.pop("CLAUDE_SMART_MANAGED_SETUP", None)
added = env_config.ensure_local_env_defaults(_CLAUDE_SMART_ENV_PATH, host=host)
if added:
sys.stdout.write(f"Seeded {_CLAUDE_SMART_ENV_PATH} with {', '.join(added)}.\n")
sys.stdout.write(
f"Seeded {_CLAUDE_SMART_ENV_PATH} with {', '.join(added)}.\n"
)
return read_only


Expand Down Expand Up @@ -1071,7 +1081,9 @@ def _opencode_global_config_dir() -> Path:
return base / "opencode"


def _opencode_config_path(*, global_config: bool = False, cwd: Path | None = None) -> Path:
def _opencode_config_path(
*, global_config: bool = False, cwd: Path | None = None
) -> Path:
if global_config:
config_dir = _opencode_global_config_dir()
for name in _OPENCODE_CONFIG_NAMES:
Expand Down Expand Up @@ -1108,9 +1120,9 @@ def _is_default_opencode_package_path(package_path: Path) -> bool:
if _same_real_path(package_path, _OPENCODE_LOCAL_PACKAGE_DIR):
return True
try:
return package_path.resolve(strict=False) == _OPENCODE_LOCAL_PACKAGE_DIR.resolve(
return package_path.resolve(
strict=False
)
) == _OPENCODE_LOCAL_PACKAGE_DIR.resolve(strict=False)
except OSError:
return False

Expand Down Expand Up @@ -1192,7 +1204,9 @@ def _has_extraction_provider() -> bool:
resolved = Path(cli_path).expanduser()
if resolved.is_file() and os.access(resolved, os.X_OK):
return True
return bool(shutil.which("claude") or shutil.which("codex") or _resolve_opencode_path())
return bool(
shutil.which("claude") or shutil.which("codex") or _resolve_opencode_path()
)


def _extraction_provider_error() -> str:
Expand Down Expand Up @@ -1221,7 +1235,9 @@ def _persist_opencode_path() -> list[str]:
if not resolved:
return []
os.environ[_OPENCODE_PATH_ENV] = resolved
return env_config.set_env_vars(_CLAUDE_SMART_ENV_PATH, {_OPENCODE_PATH_ENV: resolved})
return env_config.set_env_vars(
_CLAUDE_SMART_ENV_PATH, {_OPENCODE_PATH_ENV: resolved}
)


def _opencode_prerequisite_error() -> str | None:
Expand Down Expand Up @@ -1358,7 +1374,9 @@ def _bootstrap_opencode_install(read_only: bool) -> tuple[bool, str]:
if not bash:
return False, "bash is required to bootstrap claude-smart dependencies"
scripts_dir = plugin_root / "scripts"
result = subprocess.run([bash, str(scripts_dir / "smart-install.sh")], cwd=plugin_root)
result = subprocess.run(
[bash, str(scripts_dir / "smart-install.sh")], cwd=plugin_root
)
if result.returncode != 0:
return False, f"smart-install.sh failed in {plugin_root}"
if _INSTALL_FAILURE_MARKER.is_file():
Expand Down Expand Up @@ -1572,10 +1590,22 @@ def cmd_install(args: argparse.Namespace) -> int:
sys.stderr.write(f"error: {' '.join(cmd)} failed (exit {exc.returncode})\n")
return exc.returncode or 1

rc = _finish_claude_code_install(read_only=read_only, action="installed")
if rc != 0:
return rc

sys.stdout.write(
"\nclaude-smart installed and dependencies are prepared. "
"Restart Claude Code in your project.\n"
)
return 0


def _finish_claude_code_install(*, read_only: bool, action: str) -> int:
bootstrapped, message = _bootstrap_claude_code_install()
if not bootstrapped:
sys.stderr.write(
f"error: claude-smart installed, but dependency bootstrap failed: {message}\n"
f"error: claude-smart {action}, but dependency bootstrap failed: {message}\n"
)
sys.stderr.write(
"Fix the issue above, then run /claude-smart:restart or restart Claude Code to retry.\n"
Expand All @@ -1588,20 +1618,11 @@ def cmd_install(args: argparse.Namespace) -> int:
"Installed read-only hook manifest; publish interactions hooks are disabled.\n"
)
sys.stdout.write(f"Prepared claude-smart runtime at {message}.\n")

sys.stdout.write(
"\nclaude-smart installed and dependencies are prepared. "
"Restart Claude Code in your project.\n"
)
return 0


def cmd_update(args: argparse.Namespace) -> int:
"""Update claude-smart by reinstalling from this package.

The simplified install path registers the bundled package root as the
marketplace, so update is a stop + reinstall instead of
``claude plugin update``.
"""Update claude-smart through Claude Code's plugin update path.

Args:
args (argparse.Namespace): Parsed CLI args.
Expand All @@ -1614,12 +1635,44 @@ def cmd_update(args: argparse.Namespace) -> int:
if getattr(args, "host", _HOST_CLAUDE_CODE) == _HOST_OPENCODE:
return cmd_update_opencode(args)

if not shutil.which("claude"):
sys.stderr.write(
"error: 'claude' CLI not found on PATH. "
"Install Claude Code first: https://claude.com/claude-code\n"
)
return 1

_run_service(_DASHBOARD_SCRIPT, "stop")
_run_service(_BACKEND_SCRIPT, "stop")
sys.stdout.write("Updating claude-smart by reinstalling from this package...\n")
install_args = argparse.Namespace(**vars(args), refresh_existing=True)
install_args.host = _HOST_CLAUDE_CODE
return cmd_install(install_args)
read_only = _configure_reflexio_setup(host=_HOST_CLAUDE_CODE)

sys.stdout.write("Updating claude-smart from this package...\n")
for cmd in (
["claude", "plugin", "marketplace", "add", str(_REPO_ROOT)],
["claude", "plugin", "update", _PLUGIN_SPEC],
):
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as exc:
if cmd[1:3] == ["plugin", "update"]:
sys.stderr.write(
f"warning: {' '.join(cmd)} failed (exit {exc.returncode}); "
"falling back to reinstall.\n"
)
install_args = argparse.Namespace(**vars(args), refresh_existing=True)
install_args.host = _HOST_CLAUDE_CODE
return cmd_install(install_args)
sys.stderr.write(f"error: {' '.join(cmd)} failed (exit {exc.returncode})\n")
return exc.returncode or 1

rc = _finish_claude_code_install(read_only=read_only, action="updated")
if rc != 0:
return rc
sys.stdout.write(
"\nclaude-smart updated and dependencies are prepared. "
"Restart Claude Code in your project.\n"
)
return 0


def cmd_update_codex(_args: argparse.Namespace) -> int:
Expand Down Expand Up @@ -1750,13 +1803,12 @@ def cmd_uninstall_opencode(args: argparse.Namespace) -> int:
except OSError:
pass
if changed:
sys.stdout.write(f"Removed claude-smart OpenCode plugin entries from {config_path}.\n")
sys.stdout.write(
f"Removed claude-smart OpenCode plugin entries from {config_path}.\n"
)
else:
sys.stdout.write("OpenCode config did not include claude-smart.\n")
sys.stdout.write(
"Restart OpenCode to apply. "
f"{_LOCAL_DATA_NOTICE}"
)
sys.stdout.write(f"Restart OpenCode to apply. {_LOCAL_DATA_NOTICE}")
return 0


Expand Down
57 changes: 50 additions & 7 deletions tests/test_cli_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,7 @@ def test_install_setup_updates_existing_host_and_local_defaults(
) -> None:
env_path = tmp_path / ".claude-smart" / ".env"
env_path.parent.mkdir()
env_path.write_text(
"# keep\n"
"CLAUDE_SMART_HOST=codex\n"
'CLAUDE_SMART_READ_ONLY="1"\n'
)
env_path.write_text('# keep\nCLAUDE_SMART_HOST=codex\nCLAUDE_SMART_READ_ONLY="1"\n')
monkeypatch.setattr(cli, "_CLAUDE_SMART_ENV_PATH", env_path)
monkeypatch.setenv("CLAUDE_SMART_USE_LOCAL_EMBEDDING", "0")

Expand All @@ -235,7 +231,9 @@ def test_install_setup_updates_existing_host_and_local_defaults(
assert 'CLAUDE_SMART_READ_ONLY="1"' in text


def test_opencode_install_persists_resolved_cli_path(monkeypatch, tmp_path: Path) -> None:
def test_opencode_install_persists_resolved_cli_path(
monkeypatch, tmp_path: Path
) -> None:
runtime_env_path = tmp_path / ".claude-smart" / ".env"
opencode = tmp_path / "bin" / "opencode"
opencode.parent.mkdir()
Expand Down Expand Up @@ -453,20 +451,65 @@ def test_update_parser_accepts_host_codex() -> None:
assert args.host == "codex"


def test_cmd_update_claude_code_stops_services_then_installs(monkeypatch) -> None:
def test_cmd_update_claude_code_stops_services_then_updates(
monkeypatch,
tmp_path: Path,
) -> None:
calls: list[tuple[str, str] | list[str]] = []

def fake_run_service(script, subcmd):
calls.append((script.name, subcmd))
return 0

def fake_run(cmd, check=False):
calls.append([str(part) for part in cmd])
return argparse.Namespace(returncode=0)

monkeypatch.setattr(cli.shutil, "which", lambda name: f"/bin/{name}")
monkeypatch.setattr(cli, "_configure_reflexio_setup", lambda **_kwargs: False)
monkeypatch.setattr(cli, "_run_service", fake_run_service)
monkeypatch.setattr(cli.subprocess, "run", fake_run)
monkeypatch.setattr(
cli, "_bootstrap_claude_code_install", lambda: (True, str(tmp_path / "plugin"))
)
monkeypatch.setattr(cli, "_restore_publish_hooks_from_source", lambda _root: None)

rc = cli.cmd_update(argparse.Namespace(host="claude-code", marker="kept"))

assert rc == 0
assert calls == [
("dashboard-service.sh", "stop"),
("backend-service.sh", "stop"),
["claude", "plugin", "marketplace", "add", str(cli._REPO_ROOT)],
["claude", "plugin", "update", cli._PLUGIN_SPEC],
]


def test_cmd_update_claude_code_falls_back_to_install(
monkeypatch,
) -> None:
calls: list[tuple[str, str]] = []

def fake_run_service(script, subcmd):
calls.append((script.name, subcmd))
return 0

def fake_run(cmd, check=False):
command = [str(part) for part in cmd]
if command[1:3] == ["plugin", "update"]:
raise subprocess.CalledProcessError(returncode=17, cmd=command)
return argparse.Namespace(returncode=0)

def fake_install(args):
calls.append(("install", args.host))
assert args.refresh_existing is True
assert args.marker == "kept"
return 0

monkeypatch.setattr(cli.shutil, "which", lambda name: f"/bin/{name}")
monkeypatch.setattr(cli, "_configure_reflexio_setup", lambda **_kwargs: False)
monkeypatch.setattr(cli, "_run_service", fake_run_service)
monkeypatch.setattr(cli.subprocess, "run", fake_run)
monkeypatch.setattr(cli, "cmd_install", fake_install)

rc = cli.cmd_update(argparse.Namespace(host="claude-code", marker="kept"))
Expand Down
Loading
Loading