diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9258ef3..331ac1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,10 @@ jobs: shell: bash run: ./dist/${{ matrix.bin }} --help + - name: init --help + shell: bash + run: ./dist/${{ matrix.bin }} init --help + - name: login --no-browser prints URL shell: bash run: | diff --git a/README.md b/README.md index ffb5796..701ab2e 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,11 @@ Installs a standalone binary on `postinstall` (macOS arm64/x64, Linux x64, Windo ```bash npm install -g olostep-cli -olostep --version +olostep init ``` +`olostep init` is the recommended first step — it signs you in, installs the Olostep skills into your AI agents, and configures the MCP server, all in one command. (Run `olostep --version` to check the install.) + Or run without installing: ```bash @@ -55,13 +57,19 @@ olostep --version ## Sign in -The fastest way: +The fastest way to set up everything — sign in, skills, and MCP server: + +```bash +olostep init +``` + +To **just sign in** (no skills/MCP): ```bash olostep login ``` -This opens your browser, you click **Authorize**, and the CLI saves your key to a local credentials file. You're done. +Either one opens your browser; you click **Authorize**, and the CLI saves your key to a local credentials file. ```bash olostep login # browser flow diff --git a/config/config.py b/config/config.py index 1fa41e6..d89be47 100644 --- a/config/config.py +++ b/config/config.py @@ -142,8 +142,9 @@ def resolve_api_key(api_key: str | None = None) -> str: if cred: return cred raise ValueError( - f"Missing API key. Set {ENV_API_KEY} or {ENV_API_TOKEN}, add a project `.env`, " - f"or run `olostep login` (saves {CREDENTIALS_FILE_NAME} under the app config directory)." + "Not signed in. Run `olostep init` to sign in and set up skills + the " + f"MCP server, or `olostep login` to just sign in. You can also set " + f"{ENV_API_KEY} / {ENV_API_TOKEN} or add a project `.env`." ) diff --git a/main.py b/main.py index 909136f..cece7c7 100644 --- a/main.py +++ b/main.py @@ -220,6 +220,136 @@ def login_cmd( ) +def _is_authenticated() -> bool: + try: + return bool(resolve_api_key()) + except (ValueError, SystemExit): + return False + + +@app.command("init") +def init_cmd( + no_browser: bool = typer.Option( + False, "--no-browser", help="Print the authorize URL instead of opening a browser." + ), + skills_only: bool = typer.Option( + False, "--skills-only", help="Only install skills (skip the MCP server)." + ), + mcp_only: bool = typer.Option( + False, "--mcp-only", help="Only install the MCP server (skip skills)." + ), + relogin: bool = typer.Option( + False, "--relogin", help="Sign in again even if already authenticated." + ), +): + """Set up Olostep in one step: sign in, install skills, install the MCP server. + + This is the recommended first command after `npm install -g olostep-cli`. + + [bold]Examples:[/bold] + + [dim]$[/dim] olostep init + [dim]$[/dim] olostep init --skills-only + [dim]$[/dim] olostep init --no-browser + """ + if skills_only and mcp_only: + raise typer.BadParameter("Use only one of --skills-only or --mcp-only.") + + typer.secho("") + typer.secho(" Setting up Olostep…", bold=True) + typer.secho("") + + # 1. Authenticate. Skills install without a key, so --skills-only skips + # login entirely. A failed login is non-fatal: skills still install, + # only the MCP server (which needs a key) is skipped. + authed = _is_authenticated() + if not skills_only and (relogin or not authed): + try: + _run_login_flow(poll_seconds=3.0, timeout_s=600.0, no_browser=no_browser, env_file=None) + authed = True + except typer.Exit: + typer.secho( + " — Sign-in didn't complete — installing skills only. " + "Run `olostep init` again or `olostep login` to add the MCP server.", + fg="yellow", + ) + authed = False + elif authed and not skills_only: + typer.secho(" ✓ Already signed in", fg="green") + + # 2. Install skills. + if not mcp_only: + opts = InstallOptions( + source=CLI_LOCAL_SKILLS_DIR, + cli_local_dir=CLI_LOCAL_SKILLS_DIR, + canonical_dir=DEFAULT_CANONICAL_DIR, + lockfile_path=DEFAULT_LOCKFILE, + agent=[], + all_agents=True, + global_install=True, + agent_skills_dir=None, + skill=[], + exclude=[], + dry_run=False, + overwrite=True, + link_mode="auto", + yes=True, + ) + try: + sk = run_skills_install(opts) + n = len(sk["selected_skills"]) + tgts = sk["targets"] + if tgts: + typer.secho( + f" ✓ Installed {n} skills across {len(tgts)} " + f"agent{'s' if len(tgts) != 1 else ''}: {', '.join(tgts)}", + fg="green", + ) + else: + typer.secho( + f" ✓ Installed {n} skills (no AI agents detected yet)", fg="green" + ) + except (ValueError, OSError) as exc: + typer.secho(f" — Skills install skipped: {exc}", fg="yellow") + + # 3. Install the MCP server (needs an API key — skipped if sign-in failed). + if not skills_only and not authed: + typer.secho( + " — MCP server skipped (not signed in). Run `olostep init` again.", + fg="yellow", + ) + elif not skills_only: + try: + key = resolve_api_key() + targets = resolve_mcp_targets(agents=[], all_agents=True) + if not targets: + typer.secho( + " — MCP server: no agents detected — run `olostep mcp install --agent ` later", + fg="yellow", + ) + else: + run_mcp_install( + agents=targets, api_key=key, transport="http", + global_install=True, overwrite=True, dry_run=False, + ) + typer.secho( + f" ✓ Olostep MCP server installed in {len(targets)} " + f"agent{'s' if len(targets) != 1 else ''}: {', '.join(targets)}", + fg="green", + ) + except (ValueError, OSError, SystemExit) as exc: + typer.secho(f" — MCP install skipped: {exc}", fg="yellow") + + typer.secho("") + typer.secho(" You're ready. Restart your AI agent, then try:", dim=True) + typer.secho(' olostep scrape https://example.com') + typer.secho(' olostep answer "what is olostep"') + typer.secho("") + typer.secho(" olostep status # see what's set up", dim=True) + typer.secho(" olostep --help # all commands", dim=True) + typer.secho("") + + @app.command("status") def status_cmd( as_json: bool = typer.Option(False, "--json", help="Machine-readable JSON output."), diff --git a/npm/README.md b/npm/README.md index 8570de7..a525c99 100644 --- a/npm/README.md +++ b/npm/README.md @@ -12,9 +12,11 @@ Installing this package downloads a **standalone binary** for your OS. You do no ```bash npm install -g olostep-cli -olostep --version +olostep init ``` +`olostep init` is the recommended first step — it signs you in, installs the Olostep skills into your AI agents, and configures the MCP server in one command. + Run without installing: ```bash @@ -29,13 +31,13 @@ On install, a postinstall step downloads the matching binary from the package's ## Sign in -The fastest way: +`olostep init` sets up everything (sign in + skills + MCP server). To **just sign in**: ```bash olostep login ``` -This opens your browser, you click **Authorize**, and the CLI saves your key locally. You're done. +This opens your browser, you click **Authorize**, and the CLI saves your key locally. ```bash olostep login --no-browser # print the URL (useful over SSH) diff --git a/npm/scripts/postinstall.js b/npm/scripts/postinstall.js index a7e133e..9db58d7 100644 --- a/npm/scripts/postinstall.js +++ b/npm/scripts/postinstall.js @@ -74,6 +74,14 @@ async function main() { } console.log("olostep binary installed successfully."); + console.log(""); + console.log(" Next step — set up Olostep in one command:"); + console.log(""); + console.log(" olostep init"); + console.log(""); + console.log(" It signs you in, installs the Olostep skills into your AI"); + console.log(" agents, and configures the MCP server. Or run `olostep --help`."); + console.log(""); } main().catch((err) => { diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 3364f5c..22cef6a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, patch import pytest +import typer from typer.testing import CliRunner from main import app @@ -565,3 +566,86 @@ def test_update_registry_unreachable(self): result = runner.invoke(app, ["update", "--check"]) assert result.exit_code == 0 assert "Couldn't reach" in result.output + + +class TestInitCommand: + _fake_skills = {"selected_skills": ["scrape", "answers"], "targets": ["cursor"]} + + def test_init_runs_login_skills_and_mcp(self): + with patch("main._is_authenticated", return_value=False), \ + patch("main._run_login_flow") as login, \ + patch("main.run_skills_install", return_value=self._fake_skills) as skills, \ + patch("main.resolve_mcp_targets", return_value=["cursor"]), \ + patch("main.resolve_api_key", return_value="k"), \ + patch("main.run_mcp_install", return_value=[]) as mcp: + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0 + login.assert_called_once() + skills.assert_called_once() + mcp.assert_called_once() + assert "Setting up Olostep" in result.output + # Skills install must target all detected agents, globally. + opts = skills.call_args.args[0] + assert opts.all_agents is True and opts.global_install is True + # MCP install must use the hosted transport. + assert mcp.call_args.kwargs["transport"] == "http" + + def test_init_skills_only_does_not_log_in(self): + """--skills-only needs no API key, so it must not trigger login.""" + with patch("main._is_authenticated", return_value=False), \ + patch("main._run_login_flow") as login, \ + patch("main.run_skills_install", return_value=self._fake_skills) as skills, \ + patch("main.run_mcp_install", return_value=[]) as mcp: + result = runner.invoke(app, ["init", "--skills-only"]) + assert result.exit_code == 0 + login.assert_not_called() + skills.assert_called_once() + mcp.assert_not_called() + + def test_init_continues_when_login_fails(self): + """A failed sign-in must not abort init — skills still install, MCP is skipped.""" + with patch("main._is_authenticated", return_value=False), \ + patch("main._run_login_flow", side_effect=typer.Exit(code=1)), \ + patch("main.run_skills_install", return_value=self._fake_skills) as skills, \ + patch("main.run_mcp_install", return_value=[]) as mcp: + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0 + skills.assert_called_once() # skills still installed + mcp.assert_not_called() # MCP skipped (no key) + assert "Sign-in didn't complete" in result.output + + def test_init_skips_login_when_authenticated(self): + with patch("main._is_authenticated", return_value=True), \ + patch("main._run_login_flow") as login, \ + patch("main.run_skills_install", return_value=self._fake_skills), \ + patch("main.resolve_mcp_targets", return_value=[]), \ + patch("main.resolve_api_key", return_value="k"), \ + patch("main.run_mcp_install", return_value=[]): + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0 + login.assert_not_called() + assert "Already signed in" in result.output + + def test_init_skills_only_skips_mcp(self): + with patch("main._is_authenticated", return_value=True), \ + patch("main.run_skills_install", return_value=self._fake_skills) as skills, \ + patch("main.run_mcp_install", return_value=[]) as mcp: + result = runner.invoke(app, ["init", "--skills-only"]) + assert result.exit_code == 0 + skills.assert_called_once() + mcp.assert_not_called() + + def test_init_mcp_only_skips_skills(self): + with patch("main._is_authenticated", return_value=True), \ + patch("main.run_skills_install", return_value=self._fake_skills) as skills, \ + patch("main.resolve_mcp_targets", return_value=["cursor"]), \ + patch("main.resolve_api_key", return_value="k"), \ + patch("main.run_mcp_install", return_value=[]) as mcp: + result = runner.invoke(app, ["init", "--mcp-only"]) + assert result.exit_code == 0 + skills.assert_not_called() + mcp.assert_called_once() + + def test_init_rejects_both_only_flags(self): + result = runner.invoke(app, ["init", "--skills-only", "--mcp-only"]) + assert result.exit_code != 0 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 9a11de7..701ae9b 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -84,7 +84,7 @@ def test_missing_key_raises(self, monkeypatch, tmp_path): old_tok = os.environ.pop("OLOSTEP_API_TOKEN", None) monkeypatch.setattr("config.config.get_credentials_path", lambda: tmp_path / "none.json") try: - with pytest.raises(ValueError, match="Missing API key"): + with pytest.raises(ValueError, match="Not signed in"): resolve_api_key() finally: if old_key: