heyarchie-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
archie_cli/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Archie Connect CLI — `archie` console script.
2
+
3
+ Surfaces the Connect platform as terminal commands. Read-friendly when
4
+ attached to a TTY (rich-rendered tables, colors); machine-friendly when
5
+ piped (`--json`).
6
+
7
+ Auth is bearer-only today: an Archie session JWT or an Archie OAuth JWT.
8
+ WorkOS-managed `sk_*` API keys are not yet validated server-side — see
9
+ `docs/connect/api-keys-flow.md` for the architectural decision pending.
10
+ """
11
+
12
+ __version__ = "0.1.0"
archie_cli/__main__.py ADDED
@@ -0,0 +1,35 @@
1
+ """``archie`` console-script entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from archie_cli import __version__
8
+ from archie_cli.cmds.login import login_cmd
9
+ from archie_cli.cmds.skills import skills_group
10
+ from archie_cli.cmds.workstreams import workstreams_group
11
+
12
+
13
+ @click.group(
14
+ help=(
15
+ "Archie Connect CLI.\n\n"
16
+ "Read-friendly when attached to a TTY; pipe-friendly with --json.\n"
17
+ "Auth: bearer token via --api-key, env ARCHIE_API_KEY, or `archie login`."
18
+ )
19
+ )
20
+ @click.version_option(__version__, prog_name="archie")
21
+ def cli() -> None:
22
+ """archie — Connect CLI."""
23
+
24
+
25
+ cli.add_command(login_cmd)
26
+ cli.add_command(skills_group)
27
+ cli.add_command(workstreams_group)
28
+
29
+
30
+ def main() -> None:
31
+ cli()
32
+
33
+
34
+ if __name__ == "__main__": # pragma: no cover
35
+ main()
archie_cli/auth.py ADDED
@@ -0,0 +1,101 @@
1
+ """Credential storage for the `archie` CLI.
2
+
3
+ Lookup order: explicit flag → env var → on-disk credentials → prompt the
4
+ user. The on-disk file at ``~/.archie/credentials.toml`` is chmod 600.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import stat
11
+ import sys
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ try:
16
+ import tomllib # py311+
17
+ except ImportError: # pragma: no cover
18
+ import tomli as tomllib # type: ignore[no-redef]
19
+
20
+
21
+ CREDENTIALS_PATH = Path.home() / ".archie" / "credentials.toml"
22
+ DEFAULT_PROFILE = "default"
23
+ DEFAULT_API_URL = "https://api.heyarchie.ai"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Credentials:
28
+ """Resolved CLI credentials."""
29
+
30
+ api_url: str
31
+ api_key: str
32
+ profile: str
33
+
34
+
35
+ class CredentialError(Exception):
36
+ """Raised when credentials are missing or unreadable."""
37
+
38
+
39
+ def load_credentials(
40
+ *,
41
+ profile: str = DEFAULT_PROFILE,
42
+ api_url_override: str | None = None,
43
+ api_key_override: str | None = None,
44
+ ) -> Credentials:
45
+ """Resolve credentials, preferring explicit overrides → env → on-disk."""
46
+ api_url = (
47
+ api_url_override
48
+ or os.environ.get("ARCHIE_API_URL")
49
+ or _from_disk(profile, "api_url")
50
+ or DEFAULT_API_URL
51
+ )
52
+ api_key = api_key_override or os.environ.get("ARCHIE_API_KEY") or _from_disk(profile, "api_key")
53
+ if not api_key:
54
+ raise CredentialError(
55
+ "No API key found. Set ARCHIE_API_KEY, run `archie login`, or pass --api-key."
56
+ )
57
+ return Credentials(api_url=api_url.rstrip("/"), api_key=api_key, profile=profile)
58
+
59
+
60
+ def save_credentials(api_url: str, api_key: str, *, profile: str = DEFAULT_PROFILE) -> Path:
61
+ """Persist credentials to ``~/.archie/credentials.toml`` (chmod 600)."""
62
+ CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
63
+ existing: dict[str, dict[str, str]] = {}
64
+ if CREDENTIALS_PATH.exists():
65
+ try:
66
+ with CREDENTIALS_PATH.open("rb") as fh:
67
+ existing = tomllib.load(fh) # type: ignore[assignment]
68
+ except Exception: # pragma: no cover — corrupt file → overwrite
69
+ existing = {}
70
+ existing[profile] = {"api_url": api_url.rstrip("/"), "api_key": api_key}
71
+ text = _render_toml(existing)
72
+ CREDENTIALS_PATH.write_text(text)
73
+ os.chmod(CREDENTIALS_PATH, stat.S_IRUSR | stat.S_IWUSR)
74
+ return CREDENTIALS_PATH
75
+
76
+
77
+ def _from_disk(profile: str, key: str) -> str | None:
78
+ if not CREDENTIALS_PATH.exists():
79
+ return None
80
+ try:
81
+ with CREDENTIALS_PATH.open("rb") as fh:
82
+ data = tomllib.load(fh)
83
+ except Exception as exc: # pragma: no cover
84
+ print(f"warning: failed to read {CREDENTIALS_PATH}: {exc}", file=sys.stderr)
85
+ return None
86
+ section = data.get(profile)
87
+ if not isinstance(section, dict):
88
+ return None
89
+ value = section.get(key)
90
+ return str(value) if value else None
91
+
92
+
93
+ def _render_toml(data: dict[str, dict[str, str]]) -> str:
94
+ out: list[str] = []
95
+ for profile, fields in sorted(data.items()):
96
+ out.append(f"[{profile}]")
97
+ for k in sorted(fields):
98
+ v = fields[k].replace('"', '\\"')
99
+ out.append(f'{k} = "{v}"')
100
+ out.append("")
101
+ return "\n".join(out)
archie_cli/client.py ADDED
@@ -0,0 +1,179 @@
1
+ """HTTP client wrapper around the Archie Connect REST surface.
2
+
3
+ Handles bearer auth, response envelope unwrapping, and SSE streaming
4
+ for cycle event tails. Async — use via ``asyncio.run`` from each click
5
+ command (the click group is sync; commands hop into asyncio).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import AsyncIterator
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from archie_cli.auth import Credentials
17
+
18
+
19
+ class ArchieAPIError(Exception):
20
+ """Raised when the API returns a non-2xx response."""
21
+
22
+ def __init__(self, status_code: int, body: dict[str, Any] | str):
23
+ self.status_code = status_code
24
+ self.body = body
25
+ # Pull out the canonical error envelope shape if present.
26
+ message = ""
27
+ if isinstance(body, dict):
28
+ err = body.get("error") or {}
29
+ if isinstance(err, dict):
30
+ message = err.get("message") or err.get("code") or ""
31
+ else:
32
+ message = str(err)
33
+ if not message:
34
+ message = str(body)[:200]
35
+ super().__init__(f"HTTP {status_code}: {message}")
36
+
37
+
38
+ class ArchieClient:
39
+ """Thin async wrapper over httpx.AsyncClient.
40
+
41
+ One client per command invocation; opens + closes around the call.
42
+ """
43
+
44
+ def __init__(self, creds: Credentials, *, timeout: float = 30.0):
45
+ self._creds = creds
46
+ self._timeout = timeout
47
+
48
+ async def __aenter__(self) -> ArchieClient:
49
+ self._client = httpx.AsyncClient(
50
+ base_url=self._creds.api_url,
51
+ timeout=self._timeout,
52
+ headers={
53
+ "Authorization": f"Bearer {self._creds.api_key}",
54
+ "User-Agent": f"heyarchie-cli/0.1.0 ({httpx.__version__})",
55
+ "Accept": "application/json",
56
+ },
57
+ )
58
+ return self
59
+
60
+ async def __aexit__(self, exc_type, exc, tb) -> None:
61
+ await self._client.aclose()
62
+
63
+ async def invoke_skill(self, slug: str, payload: dict[str, Any]) -> dict[str, Any]:
64
+ """POST /api/v1/skills/{slug}/invoke and return the parsed JSON body."""
65
+ resp = await self._client.post(
66
+ f"/api/v1/skills/{slug}/invoke",
67
+ json=payload,
68
+ )
69
+ return self._unwrap(resp)
70
+
71
+ async def workstreams_list(
72
+ self,
73
+ *,
74
+ scope: str | None = None,
75
+ status: str | None = None,
76
+ limit: int = 20,
77
+ cursor: str | None = None,
78
+ ) -> dict[str, Any]:
79
+ payload: dict[str, Any] = {"limit": limit}
80
+ if scope:
81
+ payload["scope"] = scope
82
+ if status:
83
+ payload["status"] = status
84
+ if cursor:
85
+ payload["cursor"] = cursor
86
+ return await self.invoke_skill("workstream.list", payload)
87
+
88
+ async def workstreams_describe(self, workstream_id: str) -> dict[str, Any]:
89
+ return await self.invoke_skill("workstream.describe", {"workstream_id": workstream_id})
90
+
91
+ async def workstreams_run(
92
+ self,
93
+ workstream_id: str,
94
+ *,
95
+ client_id: str | None = None,
96
+ period: str | None = None,
97
+ runtime_vars: dict[str, Any] | None = None,
98
+ idempotency_key: str | None = None,
99
+ ) -> dict[str, Any]:
100
+ payload: dict[str, Any] = {"workstream_id": workstream_id}
101
+ if client_id:
102
+ payload["client_id"] = client_id
103
+ if period:
104
+ payload["period"] = period
105
+ if runtime_vars:
106
+ payload["runtime_vars"] = runtime_vars
107
+ if idempotency_key:
108
+ payload["idempotency_key"] = idempotency_key
109
+ return await self.invoke_skill("workstream.run", payload)
110
+
111
+ async def workstreams_status(self, cycle_id: str) -> dict[str, Any]:
112
+ return await self.invoke_skill("workstream.status", {"cycle_id": cycle_id})
113
+
114
+ async def workstreams_cancel(
115
+ self,
116
+ cycle_id: str,
117
+ *,
118
+ reason: str | None = None,
119
+ idempotency_key: str | None = None,
120
+ ) -> dict[str, Any]:
121
+ payload: dict[str, Any] = {"cycle_id": cycle_id}
122
+ if reason:
123
+ payload["reason"] = reason
124
+ if idempotency_key:
125
+ payload["idempotency_key"] = idempotency_key
126
+ return await self.invoke_skill("workstream.cancel", payload)
127
+
128
+ async def workstreams_events(self, cycle_id: str) -> AsyncIterator[dict[str, Any]]:
129
+ """Stream SSE events from /api/v1/workstreams/cycles/{cycle_id}/events.
130
+
131
+ Yields one parsed JSON dict per SSE ``data:`` line. Filters out
132
+ comments and empty keepalives. Re-raises on transport failure.
133
+ """
134
+ url = f"/api/v1/workstreams/cycles/{cycle_id}/events"
135
+ async with self._client.stream(
136
+ "GET",
137
+ url,
138
+ headers={"Accept": "text/event-stream"},
139
+ timeout=httpx.Timeout(60.0, read=None),
140
+ ) as resp:
141
+ if resp.status_code >= 400:
142
+ body = await resp.aread()
143
+ try:
144
+ parsed: dict[str, Any] | str = json.loads(body)
145
+ except Exception:
146
+ parsed = body.decode("utf-8", errors="replace")
147
+ raise ArchieAPIError(resp.status_code, parsed)
148
+ buffer: list[str] = []
149
+ async for line in resp.aiter_lines():
150
+ if line == "" and buffer:
151
+ payload = "\n".join(buffer)
152
+ buffer = []
153
+ if not payload.startswith("data:"):
154
+ continue
155
+ data = payload[len("data:") :].strip()
156
+ if not data:
157
+ continue
158
+ try:
159
+ yield json.loads(data)
160
+ except json.JSONDecodeError:
161
+ yield {"raw": data}
162
+ elif line.startswith(":"):
163
+ # SSE comment / keepalive
164
+ continue
165
+ else:
166
+ buffer.append(line)
167
+
168
+ @staticmethod
169
+ def _unwrap(resp: httpx.Response) -> dict[str, Any]:
170
+ try:
171
+ body = resp.json()
172
+ except Exception:
173
+ body = resp.text
174
+ if resp.status_code >= 400:
175
+ raise ArchieAPIError(resp.status_code, body)
176
+ # Connect skill responses wrap the typed output under `output`.
177
+ if isinstance(body, dict) and "output" in body and isinstance(body["output"], dict):
178
+ return body["output"]
179
+ return body if isinstance(body, dict) else {"_raw": body}
@@ -0,0 +1 @@
1
+ """Click subcommand groups for the `archie` CLI."""
@@ -0,0 +1,46 @@
1
+ """``archie login`` — paste an API key, persist to ~/.archie/credentials.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from archie_cli.auth import (
8
+ DEFAULT_API_URL,
9
+ DEFAULT_PROFILE,
10
+ save_credentials,
11
+ )
12
+
13
+
14
+ @click.command("login")
15
+ @click.option(
16
+ "--api-url",
17
+ default=DEFAULT_API_URL,
18
+ show_default=True,
19
+ help="API base URL. Override for staging or self-hosted tiers.",
20
+ )
21
+ @click.option(
22
+ "--api-key",
23
+ default=None,
24
+ help="Paste here, or omit and we'll prompt without echoing.",
25
+ )
26
+ @click.option(
27
+ "--profile",
28
+ default=DEFAULT_PROFILE,
29
+ show_default=True,
30
+ help="Save under a named profile (e.g. dev, staging).",
31
+ )
32
+ def login_cmd(api_url: str, api_key: str | None, profile: str) -> None:
33
+ """Save an API key to ~/.archie/credentials.toml.
34
+
35
+ Today this expects an Archie session JWT or Archie OAuth JWT (sent as
36
+ a Bearer token). WorkOS-issued `sk_*` keys are not yet validated
37
+ server-side — see docs/connect/api-keys-flow.md.
38
+ """
39
+ if not api_key:
40
+ api_key = click.prompt("API key", hide_input=True, confirmation_prompt=False)
41
+ api_key = api_key.strip()
42
+ if not api_key:
43
+ raise click.ClickException("API key cannot be empty.")
44
+ path = save_credentials(api_url=api_url, api_key=api_key, profile=profile)
45
+ click.echo(f"Saved credentials to {path} (profile={profile!r}).")
46
+ click.echo("Try: archie workstreams list")
@@ -0,0 +1,233 @@
1
+ """``archie skills …`` — generic skill catalogue + invoke.
2
+
3
+ Extends the CLI beyond the 9 workstream-specific commands to the full
4
+ public skill catalogue (51 skills at the time of writing). Mirrors the
5
+ SDK shape: ``client.skills.list()`` + ``client.skills.invoke(slug, ...)``.
6
+
7
+ * ``archie skills list [--category X] [--search Q]`` — table of every
8
+ visible skill, with category + cluster + streaming flag.
9
+ * ``archie skills invoke <slug> [--input @file.json | --input '{json}']``
10
+ — POST the input to ``/api/v1/skills/{slug}/invoke`` and render the
11
+ unwrapped response.
12
+
13
+ Auth + ``--json`` + ``--profile`` flags share the same decorator as the
14
+ workstream commands.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json as jsonlib
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import click
26
+ import httpx
27
+ from rich.console import Console
28
+ from rich.json import JSON
29
+ from rich.table import Table
30
+
31
+ from archie_cli.auth import CredentialError, load_credentials
32
+ from archie_cli.client import ArchieAPIError, ArchieClient
33
+
34
+ _console = Console()
35
+ _err_console = Console(stderr=True, style="bold red")
36
+
37
+
38
+ def _global_options(fn):
39
+ """Auth + output flags — same shape as workstreams.py to keep UX uniform."""
40
+ fn = click.option(
41
+ "--json",
42
+ "as_json",
43
+ is_flag=True,
44
+ default=False,
45
+ help="Emit raw JSON to stdout (machine-friendly).",
46
+ )(fn)
47
+ fn = click.option(
48
+ "--profile",
49
+ default=None,
50
+ help="Credentials profile in ~/.archie/credentials.toml (default: 'default').",
51
+ )(fn)
52
+ fn = click.option(
53
+ "--api-url",
54
+ default=None,
55
+ envvar="ARCHIE_API_URL",
56
+ help="API base URL. Falls back to env ARCHIE_API_URL or saved credentials.",
57
+ )(fn)
58
+ fn = click.option(
59
+ "--api-key",
60
+ default=None,
61
+ envvar="ARCHIE_API_KEY",
62
+ help="Bearer token. Falls back to env ARCHIE_API_KEY or saved credentials.",
63
+ )(fn)
64
+ return fn
65
+
66
+
67
+ def _resolve_creds(api_url: str | None, api_key: str | None, profile: str | None):
68
+ try:
69
+ return load_credentials(
70
+ profile=profile or "default",
71
+ api_url_override=api_url,
72
+ api_key_override=api_key,
73
+ )
74
+ except CredentialError as exc:
75
+ raise click.ClickException(str(exc)) from exc
76
+
77
+
78
+ def _run(coro):
79
+ try:
80
+ return asyncio.run(coro)
81
+ except ArchieAPIError as exc:
82
+ _err_console.print(str(exc))
83
+ raise click.exceptions.Exit(2) from exc
84
+ except KeyboardInterrupt:
85
+ _err_console.print("\nInterrupted.")
86
+ raise click.exceptions.Exit(130) from None
87
+
88
+
89
+ @click.group("skills")
90
+ def skills_group() -> None:
91
+ """Browse and invoke the Archie skill catalogue."""
92
+
93
+
94
+ @skills_group.command("list")
95
+ @_global_options
96
+ @click.option("--category", default=None, help="Filter by category (e.g. transactions, tax).")
97
+ @click.option(
98
+ "--search",
99
+ "search",
100
+ default=None,
101
+ help="Case-insensitive substring match on slug or description.",
102
+ )
103
+ def list_cmd(
104
+ api_key: str | None,
105
+ api_url: str | None,
106
+ profile: str | None,
107
+ as_json: bool,
108
+ category: str | None,
109
+ search: str | None,
110
+ ) -> None:
111
+ """List the skill catalogue visible to the caller."""
112
+ creds = _resolve_creds(api_url, api_key, profile)
113
+
114
+ async def go() -> list[dict[str, Any]]:
115
+ # The catalogue lives at /api/v1/skills (GET) — separate from the
116
+ # /api/v1/skills/{slug}/invoke surface. We hit it directly to avoid
117
+ # adding a bespoke client method just for the CLI.
118
+ async with httpx.AsyncClient(
119
+ base_url=creds.api_url,
120
+ headers={"Authorization": f"Bearer {creds.api_key}"},
121
+ timeout=15.0,
122
+ ) as cli:
123
+ resp = await cli.get("/api/v1/skills")
124
+ if resp.status_code >= 400:
125
+ raise ArchieAPIError(
126
+ resp.status_code,
127
+ resp.json()
128
+ if resp.headers.get("content-type", "").startswith("application/json")
129
+ else resp.text,
130
+ )
131
+ body = resp.json()
132
+ return body.get("data") if isinstance(body, dict) else body
133
+
134
+ skills = _run(go()) or []
135
+ if category:
136
+ skills = [s for s in skills if s.get("category") == category]
137
+ if search:
138
+ needle = search.lower()
139
+ skills = [
140
+ s
141
+ for s in skills
142
+ if needle in (s.get("slug", "") + " " + s.get("description", "")).lower()
143
+ ]
144
+
145
+ if as_json:
146
+ sys.stdout.write(jsonlib.dumps(skills, indent=2, default=str))
147
+ sys.stdout.write("\n")
148
+ return
149
+
150
+ if not skills:
151
+ _console.print("[dim]No skills match.[/dim]")
152
+ return
153
+
154
+ table = Table(title=f"{len(skills)} skill(s)")
155
+ table.add_column("Slug", style="cyan")
156
+ table.add_column("Category", style="magenta")
157
+ table.add_column("Streams")
158
+ table.add_column("Description")
159
+ for s in sorted(skills, key=lambda x: x.get("slug", "")):
160
+ table.add_row(
161
+ s.get("slug", ""),
162
+ s.get("category", ""),
163
+ "yes" if s.get("streaming") else "no",
164
+ (s.get("description") or "")[:80],
165
+ )
166
+ _console.print(table)
167
+
168
+
169
+ @skills_group.command("invoke")
170
+ @_global_options
171
+ @click.argument("slug")
172
+ @click.option(
173
+ "--input",
174
+ "input_",
175
+ default="{}",
176
+ show_default=True,
177
+ help=(
178
+ "Skill input as inline JSON (e.g. '--input \\'{\"query\":\"...\"}\\'') "
179
+ "or a file path prefixed with @ (e.g. '--input @body.json'). "
180
+ "Default is empty object."
181
+ ),
182
+ )
183
+ def invoke_cmd(
184
+ api_key: str | None,
185
+ api_url: str | None,
186
+ profile: str | None,
187
+ as_json: bool,
188
+ slug: str,
189
+ input_: str,
190
+ ) -> None:
191
+ """Invoke a skill by its public slug (e.g. ``ask_archie``, ``tax.advise``).
192
+
193
+ The slug accepts both the friendly name (``tax.advise``) and the
194
+ internal slug (``advise_tax``). The route translates either to
195
+ ``/api/v1/skills/{slug}/invoke`` server-side.
196
+ """
197
+ payload = _parse_input(input_)
198
+ creds = _resolve_creds(api_url, api_key, profile)
199
+
200
+ async def go() -> dict[str, Any]:
201
+ async with ArchieClient(creds) as cli:
202
+ return await cli.invoke_skill(slug, payload)
203
+
204
+ out = _run(go())
205
+ if as_json:
206
+ sys.stdout.write(jsonlib.dumps(out, indent=2, default=str))
207
+ sys.stdout.write("\n")
208
+ else:
209
+ _console.print(JSON.from_data(out))
210
+
211
+
212
+ def _parse_input(value: str) -> dict[str, Any]:
213
+ """``--input`` accepts inline JSON, ``@path``, or ``-`` for stdin."""
214
+ text = value
215
+ if value.startswith("@"):
216
+ path = Path(value[1:])
217
+ if not path.exists():
218
+ raise click.BadParameter(f"input file not found: {path}", param_hint="--input")
219
+ text = path.read_text()
220
+ elif value == "-":
221
+ text = sys.stdin.read()
222
+ try:
223
+ parsed = jsonlib.loads(text)
224
+ except jsonlib.JSONDecodeError as exc:
225
+ raise click.BadParameter(
226
+ f"--input is not valid JSON: {exc.msg} (at line {exc.lineno} col {exc.colno})",
227
+ param_hint="--input",
228
+ ) from exc
229
+ if not isinstance(parsed, dict):
230
+ raise click.BadParameter(
231
+ "--input must be a JSON object (got non-object)", param_hint="--input"
232
+ )
233
+ return parsed
@@ -0,0 +1,298 @@
1
+ """``archie workstreams …`` — list, describe, run, status, cancel, logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json as jsonlib
7
+ import sys
8
+ from typing import Any
9
+
10
+ import click
11
+ from rich.console import Console
12
+ from rich.json import JSON
13
+ from rich.table import Table
14
+
15
+ from archie_cli.auth import CredentialError, load_credentials
16
+ from archie_cli.client import ArchieAPIError, ArchieClient
17
+
18
+ _console = Console()
19
+ _err_console = Console(stderr=True, style="bold red")
20
+
21
+
22
+ def _global_options(fn):
23
+ """Attach the auth + output flags every workstream command needs."""
24
+ fn = click.option(
25
+ "--json",
26
+ "as_json",
27
+ is_flag=True,
28
+ default=False,
29
+ help="Emit raw JSON to stdout (machine-friendly).",
30
+ )(fn)
31
+ fn = click.option(
32
+ "--profile",
33
+ default=None,
34
+ help="Credentials profile in ~/.archie/credentials.toml (default: 'default').",
35
+ )(fn)
36
+ fn = click.option(
37
+ "--api-url",
38
+ default=None,
39
+ envvar="ARCHIE_API_URL",
40
+ help="API base URL. Falls back to env ARCHIE_API_URL or saved credentials.",
41
+ )(fn)
42
+ fn = click.option(
43
+ "--api-key",
44
+ default=None,
45
+ envvar="ARCHIE_API_KEY",
46
+ help="Bearer token. Falls back to env ARCHIE_API_KEY or saved credentials.",
47
+ )(fn)
48
+ return fn
49
+
50
+
51
+ def _resolve_creds(api_url: str | None, api_key: str | None, profile: str | None):
52
+ try:
53
+ return load_credentials(
54
+ profile=profile or "default",
55
+ api_url_override=api_url,
56
+ api_key_override=api_key,
57
+ )
58
+ except CredentialError as exc:
59
+ raise click.ClickException(str(exc)) from exc
60
+
61
+
62
+ def _emit(payload: Any, *, as_json: bool) -> None:
63
+ if as_json:
64
+ sys.stdout.write(jsonlib.dumps(payload, indent=2, default=str))
65
+ sys.stdout.write("\n")
66
+ else:
67
+ _console.print(JSON.from_data(payload))
68
+
69
+
70
+ def _run(coro):
71
+ """Bridge sync click handlers to async client calls."""
72
+ try:
73
+ return asyncio.run(coro)
74
+ except ArchieAPIError as exc:
75
+ _err_console.print(str(exc))
76
+ raise click.exceptions.Exit(2) from exc
77
+ except KeyboardInterrupt:
78
+ _err_console.print("\nInterrupted.")
79
+ raise click.exceptions.Exit(130) from None
80
+
81
+
82
+ @click.group("workstreams")
83
+ def workstreams_group() -> None:
84
+ """Operate on Archie workstreams (templates, blueprints, cycles, tasks)."""
85
+
86
+
87
+ @workstreams_group.command("list")
88
+ @_global_options
89
+ @click.option("--scope", type=click.Choice(["plan", "user", "team", "firm", "gallery"]))
90
+ @click.option("--status", type=click.Choice(["draft", "active", "archived"]))
91
+ @click.option("--limit", type=click.IntRange(1, 100), default=20, show_default=True)
92
+ @click.option("--cursor", default=None, help="Opaque pagination cursor from a previous response.")
93
+ def list_cmd(
94
+ api_key: str | None,
95
+ api_url: str | None,
96
+ profile: str | None,
97
+ as_json: bool,
98
+ scope: str | None,
99
+ status: str | None,
100
+ limit: int,
101
+ cursor: str | None,
102
+ ) -> None:
103
+ """List workstreams visible to the caller."""
104
+ creds = _resolve_creds(api_url, api_key, profile)
105
+
106
+ async def go():
107
+ async with ArchieClient(creds) as cli:
108
+ return await cli.workstreams_list(
109
+ scope=scope,
110
+ status=status,
111
+ limit=limit,
112
+ cursor=cursor,
113
+ )
114
+
115
+ out = _run(go())
116
+ if as_json:
117
+ _emit(out, as_json=True)
118
+ return
119
+ items = out.get("items") or []
120
+ if not items:
121
+ _console.print("[dim]No workstreams visible.[/dim]")
122
+ return
123
+ table = Table(title=f"{len(items)} workstream(s)")
124
+ table.add_column("Permakey", style="cyan")
125
+ table.add_column("Name")
126
+ table.add_column("Mode", style="magenta")
127
+ table.add_column("Status")
128
+ for w in items:
129
+ table.add_row(
130
+ w.get("permakey", ""),
131
+ w.get("name", ""),
132
+ w.get("execution_mode", ""),
133
+ w.get("status", ""),
134
+ )
135
+ _console.print(table)
136
+ next_cursor = out.get("next_cursor")
137
+ if next_cursor:
138
+ _console.print(f"\n[dim]More results: --cursor {next_cursor}[/dim]")
139
+
140
+
141
+ @workstreams_group.command("describe")
142
+ @_global_options
143
+ @click.argument("workstream_id")
144
+ def describe_cmd(
145
+ api_key: str | None,
146
+ api_url: str | None,
147
+ profile: str | None,
148
+ as_json: bool,
149
+ workstream_id: str,
150
+ ) -> None:
151
+ """Describe a workstream (UUID, slug, or 7-char permakey)."""
152
+ creds = _resolve_creds(api_url, api_key, profile)
153
+
154
+ async def go():
155
+ async with ArchieClient(creds) as cli:
156
+ return await cli.workstreams_describe(workstream_id)
157
+
158
+ out = _run(go())
159
+ _emit(out, as_json=as_json)
160
+
161
+
162
+ @workstreams_group.command("run")
163
+ @_global_options
164
+ @click.argument("workstream_id")
165
+ @click.option("--client-id", default=None, help="Client UUID to run against.")
166
+ @click.option("--period", default=None, help="Period label (e.g. FY26-Q1).")
167
+ @click.option(
168
+ "--var",
169
+ "vars_",
170
+ multiple=True,
171
+ metavar="K=V",
172
+ help="Runtime variable. May be repeated. Use JSON for non-string values: --var qty='[1,2]'.",
173
+ )
174
+ @click.option(
175
+ "--idempotency-key",
176
+ default=None,
177
+ help="Caller-supplied stable token; safe-retry within 24h.",
178
+ )
179
+ def run_cmd(
180
+ api_key: str | None,
181
+ api_url: str | None,
182
+ profile: str | None,
183
+ as_json: bool,
184
+ workstream_id: str,
185
+ client_id: str | None,
186
+ period: str | None,
187
+ vars_: tuple[str, ...],
188
+ idempotency_key: str | None,
189
+ ) -> None:
190
+ """Start a workstream cycle and print the cycle id + URLs."""
191
+ runtime_vars: dict[str, Any] = {}
192
+ for v in vars_:
193
+ if "=" not in v:
194
+ raise click.BadParameter(f"--var must be K=V (got {v!r})")
195
+ key, _, val = v.partition("=")
196
+ try:
197
+ runtime_vars[key] = jsonlib.loads(val)
198
+ except jsonlib.JSONDecodeError:
199
+ runtime_vars[key] = val
200
+ creds = _resolve_creds(api_url, api_key, profile)
201
+
202
+ async def go():
203
+ async with ArchieClient(creds) as cli:
204
+ return await cli.workstreams_run(
205
+ workstream_id,
206
+ client_id=client_id,
207
+ period=period,
208
+ runtime_vars=runtime_vars or None,
209
+ idempotency_key=idempotency_key,
210
+ )
211
+
212
+ out = _run(go())
213
+ _emit(out, as_json=as_json)
214
+
215
+
216
+ @workstreams_group.command("status")
217
+ @_global_options
218
+ @click.argument("cycle_id")
219
+ def status_cmd(
220
+ api_key: str | None,
221
+ api_url: str | None,
222
+ profile: str | None,
223
+ as_json: bool,
224
+ cycle_id: str,
225
+ ) -> None:
226
+ """Show the current state of a cycle."""
227
+ creds = _resolve_creds(api_url, api_key, profile)
228
+
229
+ async def go():
230
+ async with ArchieClient(creds) as cli:
231
+ return await cli.workstreams_status(cycle_id)
232
+
233
+ out = _run(go())
234
+ _emit(out, as_json=as_json)
235
+
236
+
237
+ @workstreams_group.command("cancel")
238
+ @_global_options
239
+ @click.argument("cycle_id")
240
+ @click.option("--reason", default=None, help="Audit-trail reason (recommended).")
241
+ @click.option("--idempotency-key", default=None, help="Caller-supplied stable token.")
242
+ def cancel_cmd(
243
+ api_key: str | None,
244
+ api_url: str | None,
245
+ profile: str | None,
246
+ as_json: bool,
247
+ cycle_id: str,
248
+ reason: str | None,
249
+ idempotency_key: str | None,
250
+ ) -> None:
251
+ """Cancel a running cycle."""
252
+ creds = _resolve_creds(api_url, api_key, profile)
253
+
254
+ async def go():
255
+ async with ArchieClient(creds) as cli:
256
+ return await cli.workstreams_cancel(
257
+ cycle_id,
258
+ reason=reason,
259
+ idempotency_key=idempotency_key,
260
+ )
261
+
262
+ out = _run(go())
263
+ _emit(out, as_json=as_json)
264
+
265
+
266
+ @workstreams_group.command("logs")
267
+ @_global_options
268
+ @click.argument("cycle_id")
269
+ @click.option("--follow/--no-follow", "follow", default=True, show_default=True)
270
+ def logs_cmd(
271
+ api_key: str | None,
272
+ api_url: str | None,
273
+ profile: str | None,
274
+ as_json: bool,
275
+ cycle_id: str,
276
+ follow: bool,
277
+ ) -> None:
278
+ """Stream cycle events via SSE.
279
+
280
+ Default is `--follow` (long-lived); use `--no-follow` to read whatever is
281
+ buffered and exit.
282
+ """
283
+ creds = _resolve_creds(api_url, api_key, profile)
284
+
285
+ async def go():
286
+ async with ArchieClient(creds) as cli:
287
+ async for event in cli.workstreams_events(cycle_id):
288
+ if as_json:
289
+ sys.stdout.write(jsonlib.dumps(event, default=str) + "\n")
290
+ sys.stdout.flush()
291
+ else:
292
+ _console.print(JSON.from_data(event))
293
+ if not follow:
294
+ # Snapshot mode — exit after the first batch lull.
295
+ if event.get("kind") in {"finalization", "completed", "failed", "cancelled"}:
296
+ break
297
+
298
+ _run(go())
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: heyarchie-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line interface for the Archie Connect platform — workstreams, cycles, skills.
5
+ Project-URL: Homepage, https://developers.heyarchie.ai
6
+ Project-URL: Documentation, https://developers.heyarchie.ai/docs/cli
7
+ Project-URL: Source, https://github.com/heyarchie-ai/archie-platform-v2/tree/alpha/apps/cli
8
+ Author-email: Archie <hey@archie.ai>
9
+ License: Proprietary
10
+ Keywords: archie,cli,connect,workstreams
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: click<9.0,>=8.1
19
+ Requires-Dist: httpx<1.0,>=0.27
20
+ Requires-Dist: rich<14.0,>=13.7
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.6; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # archie CLI
28
+
29
+ Command-line interface for the Archie Connect platform.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ # from PyPI (when published)
35
+ pipx install heyarchie-cli
36
+
37
+ # or from this repo for development
38
+ pip install -e apps/cli
39
+ ```
40
+
41
+ `pipx` is recommended; it installs `archie` as a self-contained executable on
42
+ your PATH without touching system Python packages.
43
+
44
+ ## Authenticate
45
+
46
+ ```bash
47
+ archie login --api-url https://api.heyarchie.ai
48
+ # Then paste your bearer token at the prompt (input is hidden).
49
+ ```
50
+
51
+ This writes `~/.archie/credentials.toml` (chmod 600) under the `[default]`
52
+ profile. Override per-call with `--profile`, `--api-key`, or env vars
53
+ `ARCHIE_API_KEY` / `ARCHIE_API_URL`.
54
+
55
+ > Today the bearer must be an **Archie session JWT** or **Archie OAuth JWT**.
56
+ > WorkOS-issued `sk_*` API keys are not yet validated server-side — see
57
+ > [`docs/connect/api-keys-flow.md`](../../docs/connect/api-keys-flow.md) for
58
+ > the architectural decision pending.
59
+
60
+ ## Commands
61
+
62
+ ```text
63
+ archie login Save bearer credentials.
64
+ archie workstreams list [--scope ...] List visible workstreams.
65
+ archie workstreams describe <id> Show metadata + step graph.
66
+ archie workstreams run <id> [--var k=v] Start a cycle.
67
+ archie workstreams status <cycle_id> Show cycle progress.
68
+ archie workstreams cancel <cycle_id> Cancel a running cycle.
69
+ archie workstreams logs <cycle_id> Stream SSE events live.
70
+ ```
71
+
72
+ `workstream_id` accepts a UUID, a workstream slug, or a 7-char permakey;
73
+ the API resolves all three.
74
+
75
+ ## Examples
76
+
77
+ ```bash
78
+ # List workstreams
79
+ archie workstreams list
80
+
81
+ # JSON output (pipe-friendly)
82
+ archie workstreams list --json | jq '.items[].permakey'
83
+
84
+ # Start a cycle and watch it run
85
+ CYCLE=$(archie workstreams run abc1234 --period 2026-Q1 --json | jq -r .cycle_id)
86
+ archie workstreams logs "$CYCLE"
87
+ ```
88
+
89
+ ## Environment variables
90
+
91
+ | Variable | Purpose | Default |
92
+ |---|---|---|
93
+ | `ARCHIE_API_URL` | API base URL | `https://api.heyarchie.ai` |
94
+ | `ARCHIE_API_KEY` | Bearer token | (none) |
95
+
96
+ CLI flags take precedence over env, which takes precedence over the
97
+ on-disk credentials file.
98
+
99
+ ## Where this lives
100
+
101
+ `apps/cli/` in the `archie-platform-v2` monorepo. PRs welcome.
@@ -0,0 +1,12 @@
1
+ archie_cli/__init__.py,sha256=-0B5hLpuva9c1HFjodsV1uRhOseWbFqpuwkNM6VD4tk,459
2
+ archie_cli/__main__.py,sha256=GX-a-7hRx5xSAkTmZBErQ2w5XxGxDafNSAZzPzgitHk,804
3
+ archie_cli/auth.py,sha256=5Z-1VOVLkJTNrKSdwEsyMTtyUhpx2juQvjsLYTrfUXM,3221
4
+ archie_cli/client.py,sha256=pbPLq2DphIx1tq8b2CLX_K8y2spNxND-HIdcV4doVWo,6453
5
+ archie_cli/cmds/__init__.py,sha256=bPwqo_P2WUBxzLSQyrijoLWpEZjLSF6MrnPyD91CCc4,52
6
+ archie_cli/cmds/login.py,sha256=UHJbl0rl4caqktaHzy-QryPVHeqReL81B_0gzzV9hp0,1424
7
+ archie_cli/cmds/skills.py,sha256=BH-VJ4j8eHwk1RUNdButZ_COX2WIDuPRWZdNSONOCp8,7299
8
+ archie_cli/cmds/workstreams.py,sha256=ol87X60M69pJopL165FH-ahmy4ibfq3m1WiqBNa6pfY,8794
9
+ heyarchie_cli-0.1.0.dist-info/METADATA,sha256=xLSVzTAlVHh-uRFWjs0M14reuiqFmgol70NFixE06c8,3284
10
+ heyarchie_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ heyarchie_cli-0.1.0.dist-info/entry_points.txt,sha256=pL_BexbbOi-N7AtfHCfIQN00nDsgPTqV0iNruhChUzo,52
12
+ heyarchie_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ archie = archie_cli.__main__:main