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
2 changes: 2 additions & 0 deletions cobo_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
open,
post_api,
put_api,
skill,
webhook,
)
from cobo_cli.data.auth_methods import AuthMethodType
Expand Down Expand Up @@ -132,6 +133,7 @@ def version():
cli.add_command(env)
cli.add_command(logs)
cli.add_command(auth)
cli.add_command(skill)
cli.add_command(webhook)

# Add API commands
Expand Down
2 changes: 2 additions & 0 deletions cobo_cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .open import open
from .post import post_api
from .put import put_api
from .skill import skill
from .webhook import webhook

__all__ = [
Expand All @@ -31,5 +32,6 @@
"auth",
"logs",
"graphql",
"skill",
"webhook",
]
56 changes: 56 additions & 0 deletions cobo_cli/commands/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import shlex

import click

Expand Down Expand Up @@ -85,5 +86,60 @@ def show_config_path(ctx: click.Context):
click.echo(f"Configuration file path: {absolute_path}")


# (env_var_name, settings_attr) — single source of truth for export list
ENV_EXPORT = [
("COBO_ENV", "environment"),
("COBO_API_SECRET", "api_secret"),
("COBO_API_KEY", "api_key"),
("COBO_API_HOST", "api_host"),
]


def _format_shell(name: str, value: str) -> str:
return f"export {name}={shlex.quote(value)}"


def _format_powershell(name: str, value: str) -> str:
safe = str(value).replace("'", "''")
return f"$env:{name} = '{safe}'"


def _format_cmd(name: str, value: str) -> str:
escaped = str(value).replace("^", "^^").replace('"', '^"')
return f'set "{name}={escaped}"'


_ENV_FORMATTERS = {
"shell": _format_shell,
"powershell": _format_powershell,
"cmd": _format_cmd,
}


@config.command("env")
@click.option(
"--format",
"fmt",
type=click.Choice(["shell", "powershell", "cmd"]),
default="shell",
help="Output format: shell (bash/zsh), powershell, or cmd (Windows CMD).",
)
@click.pass_context
def config_env(ctx: click.Context, fmt: str):
"""Print env vars from current config for use in your shell.

macOS/Linux (bash/zsh): eval $(cobo config env)
Windows PowerShell: cobo config env --format powershell | Invoke-Expression
Windows CMD: cobo config env --format cmd > env.bat && env.bat
"""
command_context: CommandContext = ctx.obj
config_manager = command_context.config_manager
formatter = _ENV_FORMATTERS[fmt]
for name, attr in ENV_EXPORT:
value = getattr(config_manager.settings, attr, None)
if value is not None:
click.echo(formatter(name, value))


if __name__ == "__main__":
config()
55 changes: 55 additions & 0 deletions cobo_cli/commands/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
from dotenv import get_key, load_dotenv, set_key # Import dotenv functions
from nacl.signing import SigningKey

from cobo_cli.data.auth_methods import AuthMethodType
from cobo_cli.data.context import CommandContext
from cobo_cli.data.environments import EnvironmentType
from cobo_cli.data.manifest import Manifest
from cobo_cli.utils.api import make_request

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -195,5 +198,57 @@ def handle_app_key_generation(pubkey: str, secret: str, force: bool, file: str =
set_key(dotenv_path, "APP_SECRET", secret, quote_mode="never")


@keys.command(
"register", help="Register a dev API key to Cobo platform (dev environment only)."
)
@click.option(
"--pubkey",
type=str,
help="Public key to register. Defaults to api_key in config.",
)
@click.pass_context
def register_key(ctx: click.Context, pubkey: str):
"""Register an API public key to Cobo platform."""
command_context: CommandContext = ctx.obj

if (
command_context.env != EnvironmentType.DEVELOPMENT
and command_context.env != EnvironmentType.SANDBOX
):
raise ClickException(
"This command can only be used in the dev environment. "
"Use 'cobo env set dev' to switch."
)

if not pubkey:
pubkey = command_context.config_manager.get_config("api_key")

if not pubkey:
raise ClickException("No public key provided or found in config.")

response = make_request(
ctx,
"POST",
"/developers/cli_dev_api_key",
auth=AuthMethodType.USER,
json={"api_key": pubkey},
)

if response.status_code != 200 and response.status_code != 201:
try:
error_data = response.json()
error_msg = (
error_data.get("error_message")
or error_data.get("message")
or response.text
)
except Exception:
error_msg = response.text
raise ClickException(f"Failed to register API key: {error_msg}")

click.echo("API key registered successfully.")
click.echo(f"Public key: {pubkey}")


if __name__ == "__main__":
keys()
211 changes: 211 additions & 0 deletions cobo_cli/commands/skill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import shutil
from pathlib import Path
from typing import Dict

import click

# Supported AI coding agents and their skill directory names
AGENT_SKILL_DIRS: Dict[str, str] = {
"claude": ".claude",
"cursor": ".cursor",
}

AGENT_DISPLAY_NAMES: Dict[str, str] = {
"claude": "Claude Code",
"cursor": "Cursor",
}

SKILL_NAME = "cobo-waas"


def _get_skill_path(agent: str, scope: str) -> Path:
"""Get the skill installation path for an agent and scope."""
agent_dir = AGENT_SKILL_DIRS[agent]
if scope == "global":
return Path.home() / agent_dir / "skills" / SKILL_NAME
else: # local
return Path.cwd() / agent_dir / "skills" / SKILL_NAME


def _get_all_skill_paths(agent: str) -> Dict[str, Path]:
"""Get both global and local paths for an agent."""
return {
"global": _get_skill_path(agent, "global"),
"local": _get_skill_path(agent, "local"),
}


@click.group(
"skill",
invoke_without_command=True,
context_settings=dict(help_option_names=["-h", "--help"]),
help="Install and manage AI coding agent skills.",
)
@click.pass_context
def skill(ctx: click.Context):
"""Install and manage AI coding agent skills."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())


@skill.command("install")
@click.argument("agent", type=click.Choice(["claude", "cursor", "all"]))
@click.option(
"-s",
"--scope",
type=click.Choice(["global", "local"]),
default="global",
help="Installation scope: 'global' (~/.claude/skills) or 'local' (./.claude/skills in current project)",
)
@click.option("-f", "--force", is_flag=True, help="Overwrite existing skill")
def install(agent: str, scope: str, force: bool):
"""Install cobo-waas skill for an AI coding agent.

AGENT can be: claude, cursor, or all

\b
Examples:
cobo skill install claude # Install globally for Claude Code
cobo skill install claude --scope local # Install in current project
cobo skill install all # Install globally for all agents
"""
skill_src = _get_package_skill_path()

if agent == "all":
agents = list(AGENT_SKILL_DIRS.keys())
else:
agents = [agent]

for agent_name in agents:
target = _get_skill_path(agent_name, scope)
display_name = AGENT_DISPLAY_NAMES.get(agent_name, agent_name)
scope_label = "global" if scope == "global" else "project"

if target.exists() and not force:
click.echo(f"Skill already exists at {target}. Use --force to overwrite.")
continue

# Create parent directory if needed
target.parent.mkdir(parents=True, exist_ok=True)

# Remove existing skill if force is set
if target.exists():
shutil.rmtree(target)

# Copy skill files
shutil.copytree(skill_src, target)
click.echo(f"Installed {SKILL_NAME} ({scope_label}) for {display_name}")
click.echo(f" Path: {target}")


@skill.command("list")
def list_skills():
"""List available skills."""
click.echo("Available skills:")
click.echo(f" {SKILL_NAME} - Cobo WaaS 2.0 API operations for AI coding agents")
click.echo("")
click.echo("Supported agents: claude, cursor")
click.echo("")
click.echo("Usage:")
click.echo(" cobo skill install claude # Install globally")
click.echo(" cobo skill install claude --scope local # Install in project")
click.echo(" cobo skill install all # Install for all agents")


@skill.command("remove")
@click.argument("agent", type=click.Choice(["claude", "cursor", "all"]))
@click.option(
"-s",
"--scope",
type=click.Choice(["global", "local", "all"]),
default="all",
help="Which installation to remove: 'global', 'local', or 'all' (default)",
)
def remove(agent: str, scope: str):
"""Remove installed skill from an AI coding agent.

AGENT can be: claude, cursor, or all

\b
Examples:
cobo skill remove claude # Remove all installations for Claude
cobo skill remove claude --scope global # Remove only global installation
cobo skill remove all # Remove from all agents
"""
if agent == "all":
agents = list(AGENT_SKILL_DIRS.keys())
else:
agents = [agent]

scopes_to_check = ["global", "local"] if scope == "all" else [scope]

for agent_name in agents:
display_name = AGENT_DISPLAY_NAMES.get(agent_name, agent_name)
removed_any = False

for s in scopes_to_check:
target = _get_skill_path(agent_name, s)
if target.exists():
shutil.rmtree(target)
click.echo(f"Removed {SKILL_NAME} ({s}) from {display_name}")
removed_any = True

if not removed_any:
click.echo(f"No skill installed for {display_name}")


@skill.command("status")
def status():
"""Show skill installation status."""
click.echo("Skill installation status:")
click.echo("")

for agent_name in AGENT_SKILL_DIRS.keys():
display_name = AGENT_DISPLAY_NAMES.get(agent_name, agent_name)
paths = _get_all_skill_paths(agent_name)

global_installed = paths["global"].exists()
local_installed = paths["local"].exists()

click.echo(f" {display_name}:")

if global_installed:
click.echo(" Global: Installed")
click.echo(f" Path: {paths['global']}")
else:
click.echo(" Global: Not installed")

if local_installed:
click.echo(" Local: Installed")
click.echo(f" Path: {paths['local']}")
else:
click.echo(f" Local: Not installed (would be at {paths['local']})")

click.echo("")


def _get_package_skill_path() -> Path:
"""Get the path to skills bundled with the package."""
# Method 1: Development mode (skills/ in repo root)
dev_path = Path(__file__).parent.parent.parent / "skills" / SKILL_NAME
if dev_path.exists():
return dev_path

# Method 2: Installed package (skills/ alongside cobo_cli/)
try:
import cobo_cli

pkg_path = Path(cobo_cli.__file__).parent.parent / "skills" / SKILL_NAME
if pkg_path.exists():
return pkg_path
except ImportError:
pass

raise click.ClickException(
f"Could not find {SKILL_NAME} skill in package. "
"Please reinstall cobo-cli: pip install --force-reinstall cobo-cli"
)


if __name__ == "__main__":
skill()
15 changes: 0 additions & 15 deletions cobo_cli/commands/tests/testenv/README.md

This file was deleted.

Empty file.
Loading