diff --git a/docs/README-python-client.md b/docs/README-python-client.md index 7e5d1cafd..9524e45ab 100644 --- a/docs/README-python-client.md +++ b/docs/README-python-client.md @@ -527,6 +527,7 @@ asyncio.run(main()) The `rocketride` command is installed automatically with the package. ```bash +rocketride init # Scaffold .rocketride/ in the current directory rocketride start pipeline.json # Start a pipeline rocketride upload *.pdf --token # Upload files to a running pipeline rocketride status --token # Monitor task progress @@ -536,7 +537,8 @@ rocketride events ALL --token # Stream task events rocketride rrext_store get_all_projects # List stored projects ``` -All commands accept `--uri` and `--apikey` flags, or read from environment variables. +`rocketride init` runs entirely offline — it creates `.rocketride/docs/`, installs agent stubs (CLAUDE.md, cursor rules, etc.) for any detected coding agents, and adds `.rocketride/` to `.gitignore`. Pass `--agent ` to force a specific stub or `--no-agents` to skip them. +All other commands accept `--uri` and `--apikey` flags, or read from environment variables. ## Configuration diff --git a/packages/client-python/pyproject.toml b/packages/client-python/pyproject.toml index 2b21429c1..1e9c6a235 100644 --- a/packages/client-python/pyproject.toml +++ b/packages/client-python/pyproject.toml @@ -75,4 +75,8 @@ where = ["src"] include = ["rocketride*"] [tool.setuptools.package-data] -rocketride = ["py.typed"] +rocketride = [ + "py.typed", + "cli/templates/docs/*.md", + "cli/templates/stubs/*", +] diff --git a/packages/client-python/scripts/tasks.js b/packages/client-python/scripts/tasks.js index db7c28920..05b817c56 100644 --- a/packages/client-python/scripts/tasks.js +++ b/packages/client-python/scripts/tasks.js @@ -31,7 +31,9 @@ * clean - Remove build artifacts */ const path = require('path'); -const { execCommand, syncDir, formatSyncStats, removeDirs, removeMatching, removeDirAndParents, PROJECT_ROOT, BUILD_ROOT, DIST_ROOT, mkdir, copyFile, exists, startServer, stopServer, bracket, parallel, hasSourceChanged, saveSourceHash, setState } = require('../../../scripts/lib'); +const crypto = require('crypto'); +const fsp = require('fs').promises; +const { execCommand, syncDir, formatSyncStats, removeDirs, removeMatching, removeDirAndParents, PROJECT_ROOT, BUILD_ROOT, DIST_ROOT, mkdir, copyFile, exists, startServer, stopServer, bracket, parallel, fingerprint, saveSourceHash, getState, setState } = require('../../../scripts/lib'); const PACKAGE_DIR = path.join(__dirname, '..'); const SRC_DIR = path.join(PACKAGE_DIR, 'src', 'rocketride'); @@ -52,6 +54,13 @@ const DOCS_DIR = path.join(PROJECT_ROOT, 'docs'); const README_SRC = path.join(DOCS_DIR, 'README-python-client.md'); const README_DEST = path.join(BUILD_DIR, 'README.md'); +// Init ships the agent docs and stubs as wheel package data; +// Source of truth lives in the repo at docs/agents/ + docs/stubs/; the build +// copies them into BUILD_DIR so they end up at the package_data path declared +const AGENT_DOCS_SRC = path.join(DOCS_DIR, 'agents'); +const AGENT_STUBS_SRC = path.join(DOCS_DIR, 'stubs'); +const TEMPLATES_DEST_BASE = path.join(BUILD_DIR, 'src', 'rocketride', 'cli', 'templates'); + // ============================================================================ // Action Factories // ============================================================================ @@ -65,6 +74,16 @@ function makeCopyReadmeAction() { }; } +function makeCopyInitTemplatesAction() { + return { + run: async (ctx, task) => { + const docsStats = await syncDir(AGENT_DOCS_SRC, path.join(TEMPLATES_DEST_BASE, 'docs'), { package: true }); + const stubsStats = await syncDir(AGENT_STUBS_SRC, path.join(TEMPLATES_DEST_BASE, 'stubs'), { package: true }); + task.output = `docs: ${formatSyncStats(docsStats)} | stubs: ${formatSyncStats(stubsStats)}`; + }, + }; +} + function makeSyncClientPythonAction() { return { run: async (ctx, task) => { @@ -88,14 +107,50 @@ function makeWheelSourceAction() { // State key for source fingerprint const SRC_HASH_KEY = 'client-python.srcHash'; +// Inputs that affect wheel contents — keep in sync with the wheel-build steps. +// SRC_DIR is the package source; AGENT_DOCS_SRC + AGENT_STUBS_SRC are bundled +// into the wheel as `cli/templates/` package data; README_SRC is copied to +// BUILD_DIR/README.md before `python -m build` runs; pyproject.toml + LICENSE +// are top-level packaging metadata copied via wheel-source's syncDir(PACKAGE_DIR). +const WHEEL_INPUT_DIRS = [SRC_DIR, AGENT_DOCS_SRC, AGENT_STUBS_SRC]; +const WHEEL_INPUT_FILES = [README_SRC, path.join(PACKAGE_DIR, 'pyproject.toml'), path.join(PACKAGE_DIR, 'LICENSE')]; + +async function computeWheelInputsHash() { + const dirHashes = await Promise.all(WHEEL_INPUT_DIRS.map((d) => fingerprint(d))); + const fileStats = await Promise.all( + WHEEL_INPUT_FILES.map(async (f) => { + try { + const s = await fsp.stat(f); + return `${path.basename(f)}:${s.size}:${s.mtimeMs}`; + } catch { + return `${path.basename(f)}:missing`; + } + }) + ); + const combined = [...dirHashes.map((h) => h ?? 'missing'), ...fileStats].join('|'); + return crypto.createHash('md5').update(combined).digest('hex'); +} + +// True only if DIST_DIR contains at least one wheel or sdist artifact; +// guards against an empty/half-built dist dir from a previously failed build. +async function hasWheelArtifacts() { + try { + const entries = await fsp.readdir(DIST_DIR); + return entries.some((name) => name.endsWith('.whl') || name.endsWith('.tar.gz')); + } catch { + return false; + } +} + function makeWheelBuildAction() { return { run: async (ctx, task) => { - // Check if source changed - const { changed, hash } = await hasSourceChanged(SRC_DIR, SRC_HASH_KEY); - const outputExists = await exists(DIST_DIR); + // Check if any wheel input (src, agent docs/stubs, README) changed + const hash = await computeWheelInputsHash(); + const savedHash = await getState(SRC_HASH_KEY); + const artifactsExist = await hasWheelArtifacts(); - if (!changed && outputExists) { + if (hash === savedHash && artifactsExist) { task.output = 'No changes detected'; return; } @@ -223,6 +278,7 @@ module.exports = { actions: [ // Internal actions { name: 'client-python:copy-readme', action: makeCopyReadmeAction }, + { name: 'client-python:copy-init-templates', action: makeCopyInitTemplatesAction }, { name: 'client-python:sync-source', action: makeSyncClientPythonAction }, { name: 'client-python:wheel-source', action: makeWheelSourceAction }, { name: 'client-python:wheel-build', action: makeWheelBuildAction }, @@ -236,7 +292,7 @@ module.exports = { name: 'client-python:build', action: () => ({ description: 'Build Python client', - steps: ['server:build', 'client-python:sync-source', 'client-python:wheel-source', 'client-python:copy-readme', 'client-python:wheel-build', 'client-python:sync'], + steps: ['server:build', 'client-python:sync-source', 'client-python:wheel-source', 'client-python:copy-readme', 'client-python:copy-init-templates', 'client-python:wheel-build', 'client-python:sync'], }), }, { diff --git a/packages/client-python/src/rocketride/cli/commands/__init__.py b/packages/client-python/src/rocketride/cli/commands/__init__.py index 7edf47528..acb5cbb20 100644 --- a/packages/client-python/src/rocketride/cli/commands/__init__.py +++ b/packages/client-python/src/rocketride/cli/commands/__init__.py @@ -43,6 +43,7 @@ from .events import EventsCommand from .list import ListCommand from .store import StoreCommand +from .init import InitCommand __all__ = [ 'StartCommand', @@ -52,4 +53,5 @@ 'EventsCommand', 'ListCommand', 'StoreCommand', + 'InitCommand', ] diff --git a/packages/client-python/src/rocketride/cli/commands/init.py b/packages/client-python/src/rocketride/cli/commands/init.py new file mode 100644 index 000000000..babd06d18 --- /dev/null +++ b/packages/client-python/src/rocketride/cli/commands/init.py @@ -0,0 +1,409 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +RocketRide CLI Init Command. + +Scaffolds a RocketRide workspace in the current directory: copies the agent +docs into `.rocketride/docs/`, installs agent stub files (CLAUDE.md, cursor +rules, etc.) for any detected coding agents, and adds `.rocketride/` to +`.gitignore`. Mirrors what the VS Code extension does on project open +(see `apps/vscode/src/agents/agent-manager.ts`) so workspaces created from +the terminal and from the IDE are interchangeable. + +Does NOT require a running server, an API key, or the VS Code extension. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from .base import BaseCommand + +if TYPE_CHECKING: + from rocketride import RocketRideClient + + +# Markers used to delimit RocketRide content inside agent stub files. Matches +# apps/vscode/src/agents/base-installer.ts so files written by the CLI can be +# updated by the VS Code extension and vice-versa. +_MARKER_BEGIN = '' +_MARKER_END = '' + +_GITIGNORE_ENTRY = '.rocketride/' + +# Doc files copied verbatim into .rocketride/docs/ — same list as +# apps/vscode/src/agents/agent-manager.ts:DOC_FILES. +_DOC_FILES = ( + 'ROCKETRIDE_README.md', + 'ROCKETRIDE_QUICKSTART.md', + 'ROCKETRIDE_PIPELINE_RULES.md', + 'ROCKETRIDE_COMPONENT_REFERENCE.md', + 'ROCKETRIDE_COMMON_MISTAKES.md', + 'ROCKETRIDE_python_API.md', + 'ROCKETRIDE_typescript_API.md', +) + +# Map of CLI-facing agent keys -> (stub source filename, target path relative +# to workspace root). Mirrors the per-agent installers under +# apps/vscode/src/agents/. +_AGENTS: dict[str, tuple[str, str]] = { + 'cursor': ('cursor.mdc', '.cursor/rules/rocketride.mdc'), + 'claude-code': ('claude-code.md', '.claude/rules/rocketride.md'), + 'windsurf': ('windsurf.md', '.windsurf/rules/rocketride.md'), + 'copilot': ('copilot-instructions.md', '.github/copilot-instructions.md'), + 'claude-md': ('CLAUDE.md', 'CLAUDE.md'), + 'agents-md': ('AGENTS.md', 'AGENTS.md'), +} + +# Order matters: stubs are installed in this order so logs read top-down the +# same way regardless of detection path. +_AGENT_ORDER = ('cursor', 'claude-code', 'windsurf', 'copilot', 'claude-md', 'agents-md') + +# Used when no agent is detected. CLAUDE.md / AGENTS.md are universal — +# installing them is a safe default for "user ran init in a vanilla terminal". +_FALLBACK_AGENTS = ('claude-md', 'agents-md') + + +class InitCommand(BaseCommand): + """Scaffold a RocketRide workspace from the terminal.""" + + async def execute(self, client: 'RocketRideClient') -> int: + """Scaffold the .rocketride workspace and any agent stub files.""" + target_root = Path(self.args.path).resolve() if self.args.path else Path.cwd() + + if not target_root.exists(): + print(f'Error: target directory does not exist: {target_root}') + return 1 + if not target_root.is_dir(): + print(f'Error: target is not a directory: {target_root}') + return 1 + + try: + templates = _resolve_templates_dir() + except FileNotFoundError as e: + print(f'Error: {e}') + return 1 + + force = bool(getattr(self.args, 'force', False)) + no_overwrite = bool(getattr(self.args, 'no_overwrite', False)) + if force and no_overwrite: + print('Error: --force and --no-overwrite are mutually exclusive') + return 1 + + agents = self._select_agents(target_root) + + # Fail-fast: every required template must exist before we touch the + # target directory. Otherwise a broken install/checkout could produce a + # half-scaffolded workspace. + missing = _missing_templates(templates, agents) + if missing: + print('Error: required RocketRide templates are missing:') + for rel in missing: + print(f' - {rel}') + print('Reinstall the rocketride package, or check that docs/agents/ and docs/stubs/ are present in your checkout.') + return 1 + + print(f'Initializing RocketRide workspace in {target_root}') + + # Docs: prompt on conflict (or honor --force / --no-overwrite). + try: + self._install_docs(templates / 'docs', target_root, force=force, no_overwrite=no_overwrite) + except _Aborted as e: + print(f'Aborted: {e}') + return 1 + + # Stubs: marker-based merge — already idempotent, no prompt needed. + for agent in agents: + stub_src, stub_target_rel = _AGENTS[agent] + self._install_stub( + stub_path=templates / 'stubs' / stub_src, + target_path=target_root / stub_target_rel, + target_root=target_root, + agent=agent, + ) + + self._ensure_gitignore(target_root) + + print('Done. See .rocketride/docs/ROCKETRIDE_README.md to get started.') + return 0 + + # ------------------------------------------------------------------ + # Agent selection + # ------------------------------------------------------------------ + + def _select_agents(self, target_root: Path) -> tuple[str, ...]: + """Return the ordered list of agent keys to install stubs for.""" + if getattr(self.args, 'no_agents', False): + return () + + chosen = getattr(self.args, 'agent', None) + if chosen: + if 'all' in chosen: + return _AGENT_ORDER + # Preserve canonical order for deterministic output. + return tuple(a for a in _AGENT_ORDER if a in chosen) + + detected = _detect_agents(target_root) + if detected: + return detected + return _FALLBACK_AGENTS + + # ------------------------------------------------------------------ + # Docs + # ------------------------------------------------------------------ + + def _install_docs(self, source_dir: Path, target_root: Path, *, force: bool, no_overwrite: bool) -> None: + target_dir = target_root / '.rocketride' / 'docs' + target_dir.mkdir(parents=True, exist_ok=True) + + for name in _DOC_FILES: + src = source_dir / name + dest = target_dir / name + new_content = src.read_bytes() + + if dest.exists(): + existing = dest.read_bytes() + # Strip CR so \r\n vs \n doesn't trigger a false rewrite. + if existing.replace(b'\r\n', b'\n') == new_content.replace(b'\r\n', b'\n'): + continue # already up to date + if no_overwrite: + print(f' - kept .rocketride/docs/{name} (--no-overwrite)') + continue + if not force and not _confirm_overwrite(f'.rocketride/docs/{name}'): + raise _Aborted('user declined to overwrite') + + dest.write_bytes(new_content) + print(f' + wrote .rocketride/docs/{name}') + + # Prune obsolete docs that aren't in the canonical list, matching the + # VS Code installer (apps/vscode/src/agents/agent-manager.ts) so a CLI + # re-init produces the same .rocketride/docs/ contents as an IDE re-init. + expected = set(_DOC_FILES) + try: + for entry in target_dir.iterdir(): + if entry.is_file() and entry.name not in expected: + entry.unlink() + print(f' - removed .rocketride/docs/{entry.name} (obsolete)') + except OSError: + pass + + # ------------------------------------------------------------------ + # Stubs (marker-merged) + # ------------------------------------------------------------------ + + def _install_stub(self, *, stub_path: Path, target_path: Path, target_root: Path, agent: str) -> None: + """Merge the stub template into target_path using the marker protocol.""" + stub_content = stub_path.read_text(encoding='utf-8') + target_path.parent.mkdir(parents=True, exist_ok=True) + + existing = '' + if target_path.exists(): + existing = target_path.read_text(encoding='utf-8') + + merged = _merge_marked_content(existing, stub_content) + if _normalize_text(merged) == _normalize_text(existing): + return # nothing to do + + target_path.write_text(merged, encoding='utf-8') + try: + rel = target_path.relative_to(target_root).as_posix() + except ValueError: + rel = str(target_path) + print(f' + wrote {rel} ({agent})') + + # ------------------------------------------------------------------ + # .gitignore + # ------------------------------------------------------------------ + + def _ensure_gitignore(self, target_root: Path) -> None: + path = target_root / '.gitignore' + existing = '' + if path.exists(): + existing = path.read_text(encoding='utf-8') + + for line in existing.splitlines(): + if line.strip() == _GITIGNORE_ENTRY: + return + + new_content = existing.rstrip('\n') + if new_content: + new_content += '\n' + new_content += _GITIGNORE_ENTRY + '\n' + path.write_text(new_content, encoding='utf-8') + print(' + updated .gitignore') + + +# ---------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------- + + +class _Aborted(RuntimeError): + pass + + +def _missing_templates(templates, agents: tuple[str, ...]) -> list[str]: + """Return relative paths of any required template files absent from the install. + + Checks every doc in `_DOC_FILES` plus the stub source for each requested + agent. The returned list is empty when the install is complete. + """ + missing: list[str] = [] + docs_dir = templates / 'docs' + for name in _DOC_FILES: + if not (docs_dir / name).is_file(): + missing.append(f'docs/{name}') + stubs_dir = templates / 'stubs' + for agent in agents: + stub_name = _AGENTS[agent][0] + if not (stubs_dir / stub_name).is_file(): + missing.append(f'stubs/{stub_name}') + return missing + + +def _normalize_text(s: str) -> str: + """Strip CR so \\r\\n vs \\n doesn't trigger a false rewrite.""" + return s.replace('\r\n', '\n') + + +def _confirm_overwrite(label: str) -> bool: + """Prompt y/N. In non-TTY contexts, refuse — caller should pass --force or --no-overwrite.""" + if not sys.stdin.isatty(): + print(f' ? {label} exists and differs from template. Re-run with --force or --no-overwrite.') + return False + try: + answer = input(f' ? {label} exists and differs. Overwrite? [y/N] ').strip().lower() + except EOFError: + return False + return answer in ('y', 'yes') + + +def _merge_marked_content(existing: str, stub_content: str) -> str: + """Port of base-installer.ts mergeContent — marker-aware merge. + + - Empty target: write stub as-is + - Markers present in target: replace marked block with stub's marked block + - Otherwise: append stub with a blank-line separator + """ + if existing == '': + return stub_content + + begin = existing.find(_MARKER_BEGIN) + end = existing.find(_MARKER_END) + if begin != -1 and end != -1 and end > begin: + before = existing[:begin] + after = existing[end + len(_MARKER_END) :] + return before + _extract_marked(stub_content) + after + + return existing.rstrip() + '\n\n' + stub_content + + +def _extract_marked(stub_content: str) -> str: + """Pull out the BEGIN..END block from stub content, wrapping if absent.""" + begin = stub_content.find(_MARKER_BEGIN) + end = stub_content.find(_MARKER_END) + if begin != -1 and end != -1 and end > begin: + return stub_content[begin : end + len(_MARKER_END)] + return f'{_MARKER_BEGIN}\n{stub_content}\n{_MARKER_END}' + + +def _detect_agents(project_root: Path) -> tuple[str, ...]: + """Pick agents based on env vars, project markers, and home-dir markers.""" + found: set[str] = set() + + # Project-level markers — strongest signal. + if (project_root / '.cursor').is_dir(): + found.add('cursor') + if (project_root / '.claude').is_dir(): + found.add('claude-code') + if (project_root / '.windsurf').is_dir(): + found.add('windsurf') + if (project_root / '.github' / 'copilot-instructions.md').exists(): + found.add('copilot') + if (project_root / 'CLAUDE.md').exists(): + found.add('claude-md') + if (project_root / 'AGENTS.md').exists(): + found.add('agents-md') + + # Env vars set by IDE-launched terminals. + if os.environ.get('CURSOR_TRACE_ID'): + found.add('cursor') + if os.environ.get('CLAUDECODE') or os.environ.get('CLAUDE_CODE'): + found.add('claude-code') + if os.environ.get('TERM_PROGRAM', '').lower() == 'vscode' and not found.intersection({'cursor'}): + # Plain VS Code → Copilot is the built-in agent. + found.add('copilot') + + # Home-directory installs. + home_raw = os.environ.get('USERPROFILE') or os.environ.get('HOME') + home = Path(home_raw) if home_raw else None + if home and (home / '.claude').is_dir(): + found.add('claude-code') + if home and (home / '.cursor').is_dir(): + found.add('cursor') + + return tuple(a for a in _AGENT_ORDER if a in found) + + +def _resolve_templates_dir() -> Path: + """Locate the bundled templates directory. + + Production wheel: lives at `rocketride/cli/templates/` (package data). + Dev checkout: walk up from this file looking for a sibling `docs/` with + `agents/` and `stubs/` subdirs (matches the repo layout). + """ + # Bundled location next to this file (installed via package_data). + here = Path(__file__).resolve() + bundled = here.parent.parent / 'templates' + if (bundled / 'docs').is_dir() and (bundled / 'stubs').is_dir(): + return bundled + + # Dev fallback: walk up to find the repo's docs/ folder. + for ancestor in here.parents: + candidate_docs = ancestor / 'docs' / 'agents' + candidate_stubs = ancestor / 'docs' / 'stubs' + if candidate_docs.is_dir() and candidate_stubs.is_dir(): + return _ViewDir(candidate_docs.parent) # type: ignore[return-value] + + raise FileNotFoundError('RocketRide templates not found. Reinstall the rocketride package, or run from a checkout that contains docs/agents/ and docs/stubs/.') + + +class _ViewDir: + """Lightweight Path-like that maps `docs` -> docs/agents and `stubs` -> docs/stubs. + + Used only by the dev fallback so the InitCommand can use the same + `templates/docs` and `templates/stubs` paths regardless of source. + """ + + def __init__(self, repo_docs: Path) -> None: + self._repo_docs = repo_docs + + def __truediv__(self, name: str) -> Path: + if name == 'docs': + return self._repo_docs / 'agents' + if name == 'stubs': + return self._repo_docs / 'stubs' + return self._repo_docs / name diff --git a/packages/client-python/src/rocketride/cli/main.py b/packages/client-python/src/rocketride/cli/main.py index 62ec080e1..ecb57c54e 100644 --- a/packages/client-python/src/rocketride/cli/main.py +++ b/packages/client-python/src/rocketride/cli/main.py @@ -67,6 +67,7 @@ from .commands.events import EventsCommand from .commands.list import ListCommand from .commands.store import StoreCommand +from .commands.init import InitCommand try: # Try importing from installed package first @@ -463,6 +464,40 @@ def add_common_args(subparser): help='Output results in JSON format', ) + # Init command - scaffolds a RocketRide workspace; no server required. + init_parser = subparsers.add_parser( + 'init', + help='Initialize a RocketRide workspace in the current directory', + description=('Scaffold a RocketRide workspace: copy agent docs into .rocketride/docs/, install agent stubs (CLAUDE.md, cursor rules, etc.) for any detected coding agents, and add .rocketride/ to .gitignore.'), + ) + init_parser.add_argument( + 'path', + nargs='?', + default=None, + help='Target directory (default: current working directory)', + ) + init_parser.add_argument( + '--agent', + action='append', + choices=['cursor', 'claude-code', 'windsurf', 'copilot', 'claude-md', 'agents-md', 'all'], + help='Force-install a specific agent stub. Repeatable. Use "all" for every stub.', + ) + init_parser.add_argument( + '--no-agents', + action='store_true', + help='Skip agent stubs; only write docs and update .gitignore.', + ) + init_parser.add_argument( + '--force', + action='store_true', + help='Overwrite existing files in .rocketride/docs/ without prompting.', + ) + init_parser.add_argument( + '--no-overwrite', + action='store_true', + help='Keep existing files in .rocketride/docs/ instead of prompting.', + ) + # Store command - file store and domain storage operations store_common_parser = argparse.ArgumentParser(add_help=False) add_common_args(store_common_parser) @@ -535,6 +570,17 @@ async def run(self) -> int: parser.print_help() return 1 + # Init runs entirely offline — no server, no apikey, no client lifecycle. + if self.args.command == 'init': + try: + self.command = InitCommand(self, self.args) + return await self.command.execute(None) + except KeyboardInterrupt: + print('\nOperation interrupted by user') + return 1 + finally: + self.cancel() + # Validate we have something for apikey if not self.args.apikey: self.args.apikey = '' diff --git a/packages/client-python/tests/test_cli_init.py b/packages/client-python/tests/test_cli_init.py new file mode 100644 index 000000000..363fd5e3d --- /dev/null +++ b/packages/client-python/tests/test_cli_init.py @@ -0,0 +1,328 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Tests for the rocketride init CLI command.""" + +import asyncio +import os +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from rocketride.cli.commands.init import ( + InitCommand, + _detect_agents, + _merge_marked_content, + _MARKER_BEGIN, + _MARKER_END, + _DOC_FILES as _DOC_FILE_NAMES, +) + + +def _run(coro): + return asyncio.run(coro) + + +def _make_args(path, **overrides): + defaults = dict( + path=str(path), + agent=None, + no_agents=False, + force=False, + no_overwrite=False, + ) + defaults.update(overrides) + return SimpleNamespace(**defaults) + + +def _isolate_env() -> dict: + """Build an env dict with no agent-detection signals. + + Strips CURSOR_TRACE_ID, CLAUDECODE, etc. so detection is purely + project-marker-driven during tests. + """ + return {k: v for k, v in os.environ.items() if k not in {'CURSOR_TRACE_ID', 'CLAUDECODE', 'CLAUDE_CODE', 'TERM_PROGRAM', 'HOME', 'USERPROFILE'}} + + +class InitCommandTests(unittest.TestCase): + """End-to-end tests for InitCommand against a temp workspace.""" + + def setUp(self) -> None: + """Create a temp workspace and isolate the environment from the host.""" + self._tmp = tempfile.TemporaryDirectory() + self.workspace = Path(self._tmp.name) + # Force HOME/USERPROFILE to a tmp location so home-dir detection + # never picks up the developer's actual ~/.claude or ~/.cursor. + self._fake_home = tempfile.TemporaryDirectory() + self._env_patcher = patch.dict( + os.environ, + {**_isolate_env(), 'HOME': self._fake_home.name, 'USERPROFILE': self._fake_home.name}, + clear=True, + ) + self._env_patcher.start() + + def tearDown(self) -> None: + """Restore the environment and remove temp directories.""" + self._env_patcher.stop() + self._fake_home.cleanup() + self._tmp.cleanup() + + # ------------------------------------------------------------------ + # Docs + gitignore + # ------------------------------------------------------------------ + + def test_creates_docs_directory_with_all_seven_files(self) -> None: + """All seven canonical doc files land under .rocketride/docs/.""" + cmd = InitCommand(cli=None, args=_make_args(self.workspace, no_agents=True)) + rc = _run(cmd.execute(None)) + self.assertEqual(rc, 0) + + docs_dir = self.workspace / '.rocketride' / 'docs' + self.assertTrue(docs_dir.is_dir()) + for name in _DOC_FILE_NAMES: + with self.subTest(doc=name): + self.assertTrue((docs_dir / name).is_file(), f'missing: {name}') + + def test_appends_gitignore_entry(self) -> None: + """Init appends `.rocketride/` to a missing or partial .gitignore.""" + cmd = InitCommand(cli=None, args=_make_args(self.workspace, no_agents=True)) + _run(cmd.execute(None)) + + gitignore = (self.workspace / '.gitignore').read_text(encoding='utf-8') + self.assertIn('.rocketride/', gitignore.splitlines()) + + def test_gitignore_entry_is_not_duplicated(self) -> None: + """Init does not add a second `.rocketride/` line if one already exists.""" + gitignore_path = self.workspace / '.gitignore' + gitignore_path.write_text('node_modules/\n.rocketride/\n', encoding='utf-8') + + cmd = InitCommand(cli=None, args=_make_args(self.workspace, no_agents=True)) + _run(cmd.execute(None)) + + lines = gitignore_path.read_text(encoding='utf-8').splitlines() + self.assertEqual(lines.count('.rocketride/'), 1) + + # ------------------------------------------------------------------ + # Agent selection + # ------------------------------------------------------------------ + + def test_no_agents_flag_skips_stub_writes(self) -> None: + """`--no-agents` writes docs but no agent stub files.""" + cmd = InitCommand(cli=None, args=_make_args(self.workspace, no_agents=True)) + _run(cmd.execute(None)) + + # No stubs written anywhere. + self.assertFalse((self.workspace / 'CLAUDE.md').exists()) + self.assertFalse((self.workspace / 'AGENTS.md').exists()) + self.assertFalse((self.workspace / '.cursor').exists()) + + def test_no_detection_falls_back_to_universal_agent_files(self) -> None: + """When no IDE/agent is detected, install CLAUDE.md and AGENTS.md fallbacks.""" + cmd = InitCommand(cli=None, args=_make_args(self.workspace)) + _run(cmd.execute(None)) + + self.assertTrue((self.workspace / 'CLAUDE.md').is_file()) + self.assertTrue((self.workspace / 'AGENTS.md').is_file()) + + def test_explicit_agent_flag_installs_only_that_stub(self) -> None: + """`--agent cursor` installs only Cursor; fallback files are skipped.""" + cmd = InitCommand(cli=None, args=_make_args(self.workspace, agent=['cursor'])) + _run(cmd.execute(None)) + + self.assertTrue((self.workspace / '.cursor' / 'rules' / 'rocketride.mdc').is_file()) + # Should NOT install the fallback files when --agent is explicit. + self.assertFalse((self.workspace / 'CLAUDE.md').exists()) + self.assertFalse((self.workspace / 'AGENTS.md').exists()) + + def test_agent_all_installs_every_stub(self) -> None: + """`--agent all` installs stubs for every supported agent.""" + cmd = InitCommand(cli=None, args=_make_args(self.workspace, agent=['all'])) + _run(cmd.execute(None)) + + self.assertTrue((self.workspace / '.cursor' / 'rules' / 'rocketride.mdc').is_file()) + self.assertTrue((self.workspace / '.claude' / 'rules' / 'rocketride.md').is_file()) + self.assertTrue((self.workspace / '.windsurf' / 'rules' / 'rocketride.md').is_file()) + self.assertTrue((self.workspace / '.github' / 'copilot-instructions.md').is_file()) + self.assertTrue((self.workspace / 'CLAUDE.md').is_file()) + self.assertTrue((self.workspace / 'AGENTS.md').is_file()) + + # ------------------------------------------------------------------ + # Idempotency + # ------------------------------------------------------------------ + + def test_second_run_is_a_noop_when_nothing_changed(self) -> None: + """Re-running init produces byte-identical files (idempotent).""" + cmd = InitCommand(cli=None, args=_make_args(self.workspace, agent=['all'])) + _run(cmd.execute(None)) + + snapshot = {p: p.read_bytes() for p in self.workspace.rglob('*') if p.is_file()} + + cmd2 = InitCommand(cli=None, args=_make_args(self.workspace, agent=['all'])) + rc = _run(cmd2.execute(None)) + self.assertEqual(rc, 0) + + for path, content in snapshot.items(): + with self.subTest(path=str(path)): + self.assertEqual(path.read_bytes(), content, f'changed: {path}') + + def test_force_overwrites_modified_doc_files(self) -> None: + """`--force` replaces locally edited doc files with the canonical version.""" + cmd = InitCommand(cli=None, args=_make_args(self.workspace, no_agents=True)) + _run(cmd.execute(None)) + + readme = self.workspace / '.rocketride' / 'docs' / 'ROCKETRIDE_README.md' + readme.write_text('LOCAL EDIT\n', encoding='utf-8') + + cmd2 = InitCommand(cli=None, args=_make_args(self.workspace, no_agents=True, force=True)) + rc = _run(cmd2.execute(None)) + self.assertEqual(rc, 0) + self.assertNotEqual(readme.read_text(encoding='utf-8'), 'LOCAL EDIT\n') + + def test_no_overwrite_preserves_modified_doc_files(self) -> None: + """`--no-overwrite` leaves locally edited doc files untouched.""" + cmd = InitCommand(cli=None, args=_make_args(self.workspace, no_agents=True)) + _run(cmd.execute(None)) + + readme = self.workspace / '.rocketride' / 'docs' / 'ROCKETRIDE_README.md' + readme.write_text('LOCAL EDIT\n', encoding='utf-8') + + cmd2 = InitCommand(cli=None, args=_make_args(self.workspace, no_agents=True, no_overwrite=True)) + rc = _run(cmd2.execute(None)) + self.assertEqual(rc, 0) + self.assertEqual(readme.read_text(encoding='utf-8'), 'LOCAL EDIT\n') + + def test_force_and_no_overwrite_together_are_rejected(self) -> None: + """Passing both `--force` and `--no-overwrite` exits with a non-zero status.""" + cmd = InitCommand(cli=None, args=_make_args(self.workspace, no_agents=True, force=True, no_overwrite=True)) + rc = _run(cmd.execute(None)) + self.assertEqual(rc, 1) + + # ------------------------------------------------------------------ + # Stub merge protocol + # ------------------------------------------------------------------ + + def test_existing_claude_md_user_content_is_preserved_around_markers(self) -> None: + """Pre-existing CLAUDE.md content survives the merge unchanged.""" + existing = 'My existing instructions.\nKeep this.\n' + (self.workspace / 'CLAUDE.md').write_text(existing, encoding='utf-8') + + cmd = InitCommand(cli=None, args=_make_args(self.workspace, agent=['claude-md'])) + _run(cmd.execute(None)) + + result = (self.workspace / 'CLAUDE.md').read_text(encoding='utf-8') + self.assertIn('My existing instructions.', result) + self.assertIn('Keep this.', result) + self.assertIn(_MARKER_BEGIN, result) + self.assertIn(_MARKER_END, result) + + def test_rerun_only_replaces_marked_section(self) -> None: + """Re-running init only rewrites the marker block; user edits outside it remain.""" + path = self.workspace / 'CLAUDE.md' + path.write_text('User content above.\n', encoding='utf-8') + + cmd = InitCommand(cli=None, args=_make_args(self.workspace, agent=['claude-md'])) + _run(cmd.execute(None)) + + # Hand-edit user content after the markers to simulate ongoing local edits. + body = path.read_text(encoding='utf-8') + path.write_text(body + '\n\nMore user content below.\n', encoding='utf-8') + + cmd2 = InitCommand(cli=None, args=_make_args(self.workspace, agent=['claude-md'])) + _run(cmd2.execute(None)) + + result = path.read_text(encoding='utf-8') + self.assertIn('User content above.', result) + self.assertIn('More user content below.', result) + + +class DetectAgentsTests(unittest.TestCase): + """Unit tests for the `_detect_agents` heuristic.""" + + def setUp(self) -> None: + """Create an empty project root and isolate HOME/USERPROFILE.""" + self._tmp = tempfile.TemporaryDirectory() + self._fake_home = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self._env_patcher = patch.dict( + os.environ, + {'HOME': self._fake_home.name, 'USERPROFILE': self._fake_home.name}, + clear=True, + ) + self._env_patcher.start() + + def tearDown(self) -> None: + """Restore the environment and remove temp directories.""" + self._env_patcher.stop() + self._fake_home.cleanup() + self._tmp.cleanup() + + def test_empty_project_detects_nothing(self) -> None: + """A bare directory with no markers yields no detected agents.""" + self.assertEqual(_detect_agents(self.root), ()) + + def test_cursor_dir_detects_cursor(self) -> None: + """A `.cursor/` directory in the project is detected as Cursor.""" + (self.root / '.cursor').mkdir() + self.assertIn('cursor', _detect_agents(self.root)) + + def test_existing_claude_md_detects_claude_md(self) -> None: + """An existing CLAUDE.md file is detected as the claude-md agent.""" + (self.root / 'CLAUDE.md').write_text('x', encoding='utf-8') + self.assertIn('claude-md', _detect_agents(self.root)) + + def test_claudecode_env_var_detects_claude_code(self) -> None: + """The CLAUDECODE env var causes Claude Code to be detected.""" + with patch.dict(os.environ, {'CLAUDECODE': '1'}, clear=False): + self.assertIn('claude-code', _detect_agents(self.root)) + + +class MergeMarkedContentTests(unittest.TestCase): + """Unit tests for the `_merge_marked_content` marker-block merge helper.""" + + def test_empty_existing_returns_stub_verbatim(self) -> None: + """Merging into empty content returns the stub unchanged.""" + stub = f'{_MARKER_BEGIN}\nX\n{_MARKER_END}\n' + self.assertEqual(_merge_marked_content('', stub), stub) + + def test_replaces_marked_block_in_existing(self) -> None: + """Existing marker block is replaced; surrounding user content is kept.""" + existing = f'before\n{_MARKER_BEGIN}\nOLD\n{_MARKER_END}\nafter\n' + stub = f'{_MARKER_BEGIN}\nNEW\n{_MARKER_END}\n' + merged = _merge_marked_content(existing, stub) + self.assertIn('before', merged) + self.assertIn('after', merged) + self.assertIn('NEW', merged) + self.assertNotIn('OLD', merged) + + def test_appends_when_no_markers_present(self) -> None: + """If existing content has no markers, the stub is appended.""" + existing = 'plain user content\n' + stub = f'{_MARKER_BEGIN}\nX\n{_MARKER_END}\n' + merged = _merge_marked_content(existing, stub) + self.assertTrue(merged.startswith('plain user content')) + self.assertIn('X', merged) + + +if __name__ == '__main__': + unittest.main()