Skip to content
Merged
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
15 changes: 10 additions & 5 deletions skills/authsome/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ to set any auth headers.
- Run `authsome login <provider>` via Bash yourself — do NOT ask the user to
run this. The browser opens on their machine; they complete OAuth without touching
the terminal.
- Poll the status of the login using `authsome list` after a few seconds
- Poll the status of the login using `authsome provider list` after a few seconds
- **If connected** — proceed normally.
- **If not yet connected** — display the authentication URL to the user, ask them to complete the login in their browser, and report back once done. Once the user confirms, run `authsome list` again to verify the connection before continuing.
- **If not yet connected** — display the authentication URL to the user, ask them to complete the login in their browser, and report back once done. Once the user confirms, run `authsome provider list` again to verify the connection before continuing.

---

Expand All @@ -56,16 +56,16 @@ Only when uv tool install fails, use fallback: `pipx install authsome` or `uvx a

### If you get an authentication error (401, 403) follow this decision tree:

**1. Run `authsome list` to see all providers and their connections**
**1. Run `authsome provider list` to see all providers and their connections**

**2. If relevant provider exists but it has no connections → start the [login flow](#login-flow)**

If there is a login error due to wrong client id/client secret you can delete the provider via `authsome remove <provider>` and start the [login flow](#login-flow)
If there is a login error due to wrong client id/client secret you can delete the provider via `authsome provider remove <provider>` and start the [login flow](#login-flow)

**3. If relevant provider exists and it is connected**

For 401 error → you need to re-login, creds have expired
- revoke the creds using `authsome revoke <provider>`
- revoke the creds using `authsome provider revoke <provider>`
- then start the [login flow](#login-flow)

For 403 error → you need to re-login, with the correct scopes, or missing permissions
Expand All @@ -89,6 +89,9 @@ If you are unsure of the correct command syntax, need to check available flags,

```bash
authsome --help
authsome provider --help
authsome connections --help
authsome admin --help
authsome run --help
```

Expand All @@ -104,6 +107,8 @@ authsome run --help
- **Never** suggest the user open Gmail/Calendar/GitHub in their browser
when they ask you to read or interact with those services. You have API
access. Use it.
- **Never** use `authsome export`, `--show-secret`, or any workflow that prints
tokens or API keys to the terminal. Use `authsome run -- ...` instead.
- If the gateway returns a policy error (403 with a JSON body), respect
the block. Do not retry or circumvent it.
- If the skill fails, the goal took too many steps, the CLI behaved unexpectedly,
Expand Down
2 changes: 1 addition & 1 deletion skills/authsome/references/adding-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ When the provider isn't in the bundled list, do this before writing any config:

4. **Write and register the provider JSON** — follow the [provider registration guide](https://raw.githubusercontent.com/agentrhq/authsome/main/docs/register-provider.md) to write the provider JSON. Save the file to a local path (e.g. `/tmp/<provider>.json`), then register it:
```bash
authsome register /tmp/<provider>.json
authsome provider register /tmp/<provider>.json
```
193 changes: 193 additions & 0 deletions src/authsome/cli/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"""Administrative CLI commands for authsome."""

import json as json_lib
import os
import sys
from pathlib import Path

import click

from authsome.cli.context import ContextObj
from authsome.cli.daemon_control import (
DaemonAlreadyRunningError,
DaemonUnavailableError,
daemon_status,
is_daemon_responsive,
is_port_occupied,
start_daemon,
stop_daemon,
wait_for_daemon_ready,
)
from authsome.cli.helpers import auth_command
from authsome.paths import get_client_log_path, get_server_log_path


@click.group(name="admin")
def admin() -> None:
"""Manage operator-facing daemon and maintenance commands."""


@admin.command(name="log")
@click.option("-n", "--lines", default=50, metavar="COUNT", help="Number of entries to show.")
@click.option("--raw", is_flag=True, help="Show raw client debug log instead of structured audit entries.")
@auth_command
async def log_cmd(ctx_obj: ContextObj, lines: int, raw: bool) -> None:
"""View structured audit entries or the raw client debug log."""
home = Path(os.environ.get("AUTHSOME_HOME", str(Path.home() / ".authsome")))

if raw:
log_path = get_client_log_path(home)
try:
raw_lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
except FileNotFoundError:
raw_lines = []
ctx_obj.print_json({"log_file": str(log_path), "entries": raw_lines})
return

audit_path = get_server_log_path(home)
try:
raw_lines = audit_path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
except FileNotFoundError:
raw_lines = []

parsed: list[dict] = []
for line in raw_lines:
line = line.strip()
if not line:
continue
try:
parsed.append(json_lib.loads(line))
except Exception:
parsed.append({"raw": line})

ctx_obj.print_json({"log_file": str(audit_path), "entries": parsed})


@admin.group(name="daemon")
def daemon() -> None:
"""Manage the local Authsome daemon."""


@daemon.command(name="serve")
@click.option("--host", default="127.0.0.1", show_default=True, metavar="HOST", help="Host interface to bind.")
@click.option("--port", default=7998, type=int, show_default=True, metavar="PORT", help="TCP port to listen on.")
@click.option("--reload", is_flag=True, help="Enable auto-reload on code changes.")
def daemon_serve(host: str, port: int, reload: bool) -> None:
"""Run the daemon in the foreground."""
from authsome.server.daemon import serve

serve(host=host, port=port, reload=reload)


@daemon.command(name="start")
@auth_command
async def daemon_start(ctx_obj: ContextObj) -> None:
"""Start the local daemon in the background."""
if await is_daemon_responsive():
ctx_obj.print_json({"status": "already_running", "message": "Daemon is already running."})
return

if is_port_occupied(7998):
ctx_obj.print_json(
{
"status": "port_occupied",
"message": "Port 7998 is occupied by an unrelated process. We did not start a new process.",
}
)
return

try:
start_daemon()
await wait_for_daemon_ready(timeout=5)
ctx_obj.print_json({"status": "started", "message": "Daemon started successfully."})
except DaemonAlreadyRunningError as exc:
pid_str = f" (PID: {exc.pid})" if exc.pid else ""
ctx_obj.print_json({"status": "already_running", "message": f"Daemon is already running{pid_str}."})
except DaemonUnavailableError as exc:
ctx_obj.print_json({"error": exc.__class__.__name__, "message": str(exc)})
sys.exit(1)


@daemon.command(name="stop")
@auth_command
async def daemon_stop(ctx_obj: ContextObj) -> None:
"""Stop the local daemon."""
stopped, message = await stop_daemon()
status = "stopped" if stopped else "not_stopped"
ctx_obj.print_json({"status": status, "message": message})


@daemon.command(name="restart")
@auth_command
async def daemon_restart(ctx_obj: ContextObj) -> None:
"""Restart the local daemon."""
stopped, message = await stop_daemon()

if await is_daemon_responsive():
ctx_obj.print_json(
{
"status": "already_running",
"message": "Daemon is already running on port 7998. We did not start a new process.",
"stop_message": message,
"stopped": stopped,
}
)
return

if is_port_occupied(7998):
ctx_obj.print_json(
{
"status": "port_occupied",
"message": "Port 7998 is occupied by an unrelated process. We did not start a new process.",
"stop_message": message,
"stopped": stopped,
}
)
return

try:
start_daemon()
await wait_for_daemon_ready(timeout=5)
ctx_obj.print_json(
{
"status": "restarted" if stopped else "started",
"message": "Daemon restarted successfully." if stopped else "New daemon started.",
"stop_message": message,
"stopped": stopped,
}
)
except DaemonAlreadyRunningError as exc:
pid_str = f" (PID: {exc.pid})" if exc.pid else ""
ctx_obj.print_json(
{
"status": "already_running",
"message": f"Daemon is already running{pid_str}.",
"stop_message": message,
"stopped": stopped,
}
)
except DaemonUnavailableError as exc:
ctx_obj.print_json({"error": exc.__class__.__name__, "message": str(exc)})
sys.exit(1)


@daemon.command(name="status")
@auth_command
async def daemon_status_cmd(ctx_obj: ContextObj) -> None:
"""Show daemon status."""
status = await daemon_status()
ctx_obj.print_json(status)


@daemon.command(name="logs")
@click.option("-n", "--lines", default=80, metavar="COUNT", help="Number of lines to show.")
@auth_command
async def daemon_logs(ctx_obj: ContextObj, lines: int) -> None:
"""Show daemon log output."""
from authsome.cli.daemon_control import LOG_FILE

if not LOG_FILE.exists():
ctx_obj.print_json({"log_file": str(LOG_FILE), "entries": []})
return
entries = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
ctx_obj.print_json({"log_file": str(LOG_FILE), "entries": entries})
3 changes: 0 additions & 3 deletions src/authsome/cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,6 @@ async def resolve_credentials(self, **kwargs: Any) -> dict[str, Any]:
async def whoami(self) -> dict[str, Any]:
return await self._get("/whoami")

async def rekey(self) -> dict[str, Any]:
return await self._post("/rekey", {})

async def doctor(self) -> dict[str, Any]:
return await self.ready()

Expand Down
11 changes: 4 additions & 7 deletions src/authsome/cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,33 +54,30 @@ def echo(self, message: str, err: bool = False, color: str | None = None, nl: bo
return
if self.no_color:
color = None
click.secho(message, err=err, fg=color, nl=nl)
click.secho(message, err=err or self.json_output, fg=color, nl=nl)

def emit(self, message: str, color: str | None = None, nl: bool = True) -> None:
"""Print primary data output. Never suppressed by --quiet; respects --no-color."""
if self.no_color:
color = None
click.secho(message, fg=color, nl=nl)
click.secho(message, err=self.json_output, fg=color, nl=nl)


pass_ctx = click.make_pass_decorator(ContextObj)


def common_options(f):
@click.option("--json", "json_output", is_flag=True, help="Output in machine-readable JSON format.")
@click.option("--quiet", is_flag=True, help="Suppress non-essential output.")
@click.option("--no-color", is_flag=True, help="Disable ANSI colors.")
@functools.wraps(f)
def wrapper(*args, **kwargs):
json_output = kwargs.pop("json_output", False)
quiet = kwargs.pop("quiet", False)
no_color = kwargs.pop("no_color", False)
ctx = click.get_current_context()
if getattr(ctx, "obj", None) is None:
ctx.obj = ContextObj(json_output, quiet, no_color)
ctx.obj = ContextObj(True, quiet, no_color)
else:
if json_output:
ctx.obj.json_output = True
ctx.obj.json_output = True
if quiet:
ctx.obj.quiet = True
if no_color:
Expand Down
2 changes: 1 addition & 1 deletion src/authsome/cli/daemon_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def start_daemon() -> None:
DAEMON_DIR.mkdir(parents=True, exist_ok=True)
log = LOG_FILE.open("ab")
process = subprocess.Popen(
[sys.executable, "-m", "authsome.cli.main", "daemon", "serve"],
[sys.executable, "-m", "authsome.cli.main", "admin", "daemon", "serve"],
stdout=log,
stderr=log,
start_new_session=True,
Expand Down
14 changes: 5 additions & 9 deletions src/authsome/cli/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@


def handle_errors(func):
"""Catch exceptions and print cleanly or return machine JSON."""
"""Catch exceptions and return structured JSON errors."""

@functools.wraps(func)
def wrapper(ctx_obj: ContextObj, *args, **kwargs):
Expand All @@ -29,10 +29,7 @@ def wrapper(ctx_obj: ContextObj, *args, **kwargs):
return asyncio.run(func(ctx_obj, *args, **kwargs))
return func(ctx_obj, *args, **kwargs)
except Exception as exc:
if ctx_obj.json_output:
ctx_obj.print_json({"error": exc.__class__.__name__, "message": str(exc)})
else:
ctx_obj.echo(f"Error: {exc}", err=True, color="red")
ctx_obj.print_json({"error": exc.__class__.__name__, "message": str(exc)})
sys.exit(format_error_code(exc))

return wrapper
Expand Down Expand Up @@ -86,21 +83,20 @@ def _validate_provider_endpoints(definition: Any) -> list[tuple[str, str, bool]]
if "://" in val:
parsed = urllib.parse.urlparse(val)
if parsed.scheme != "https":
raise ValueError(f"{name} must use HTTPS scheme ({val})")
raise click.ClickException(f"{name} must use HTTPS scheme ({val})")
host = parsed.hostname
else:
host = val

if host in ("localhost", "127.0.0.1", "::1"):
raise ValueError(f"{name} cannot be localhost ({val})")
raise click.ClickException(f"{name} cannot be localhost ({val})")

if host:
try:
ipaddress.ip_address(host)
raise click.ClickException(f"{name} cannot be a bare IP address ({val})")
except ValueError:
pass
else:
raise ValueError(f"{name} cannot be a bare IP address ({val})")

return endpoints_to_check

Expand Down
Loading
Loading