arcana-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.
arcana_cli/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,349 @@
1
+ """Agent management commands."""
2
+
3
+ from typing import Any
4
+ from uuid import UUID
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from arcana.agents.registry import AgentRegistry
10
+ from arcana.cards.engine import BlendCompatibility, CardEngine
11
+ from arcana.cards.registry import CardRegistry, get_registry
12
+ from arcana.models.connection_store import ConnectionStore
13
+ from arcana.types.agent import Agent as AgentRecord
14
+ from arcana.types.card import Card
15
+ from arcana_cli.constants import AGENTS_BASE, CONNECTIONS_PATH, ROMAN
16
+ from arcana_cli.ui.card_picker import select_card, select_cards
17
+ from arcana_cli.ui.theme import (
18
+ GREEN,
19
+ TXT3,
20
+ dim,
21
+ err,
22
+ hl,
23
+ make_panel,
24
+ make_panel_fit,
25
+ make_table,
26
+ ok,
27
+ status_markup,
28
+ warn,
29
+ )
30
+
31
+ app = typer.Typer(help="Manage agents.")
32
+ console = Console()
33
+
34
+
35
+ def _registry() -> AgentRegistry:
36
+ return AgentRegistry(AGENTS_BASE)
37
+
38
+
39
+ def _store() -> ConnectionStore:
40
+ return ConnectionStore(CONNECTIONS_PATH)
41
+
42
+
43
+ def _validate_card(raw: str) -> Card:
44
+ for candidate in (raw, f"the-{raw}"):
45
+ try:
46
+ return Card(candidate)
47
+ except ValueError:
48
+ pass
49
+ matches = [c for c in Card if raw.lower() in c.value]
50
+ if len(matches) == 1:
51
+ return matches[0]
52
+ raise ValueError(f"Unknown card: {raw!r}")
53
+
54
+
55
+ def _resolve_agent(name_or_id: str) -> AgentRecord:
56
+ """Resolve a name or UUID string to an AgentRecord, with ambiguity detection."""
57
+ reg = _registry()
58
+ try:
59
+ uid = UUID(name_or_id)
60
+ record = reg.get(uid)
61
+ if record is not None and not record.is_archived:
62
+ return record
63
+ console.print(err(f"No agent with ID '{name_or_id}'."))
64
+ raise typer.Exit(1)
65
+ except ValueError:
66
+ pass
67
+ matches = [a for a in reg.list() if a.name == name_or_id]
68
+ if not matches:
69
+ console.print(err(f"No agent '{name_or_id}'."))
70
+ raise typer.Exit(1)
71
+ if len(matches) > 1:
72
+ console.print(err(f"Ambiguous name '{name_or_id}'. Use one of these IDs:"))
73
+ for a in matches:
74
+ console.print(f" {a.id}")
75
+ raise typer.Exit(1)
76
+ return matches[0]
77
+
78
+
79
+ def _pick_connection(default_name: str | None = None) -> tuple[UUID, str]:
80
+ """Show available connections and prompt the user to pick one."""
81
+ store = _store()
82
+ connections = store.all()
83
+ if not connections:
84
+ console.print(err("No model connections configured. Run: arcana connect model"))
85
+ raise typer.Exit(1)
86
+
87
+ table = make_table("Model Connections")
88
+ table.add_column("#", style=TXT3, width=4)
89
+ table.add_column("Name", style="bold")
90
+ table.add_column("Provider")
91
+ table.add_column("Model ID")
92
+ for i, c in enumerate(connections, 1):
93
+ table.add_row(str(i), c.name, str(c.provider), c.model_id)
94
+ console.print(table)
95
+
96
+ prompt_default = default_name or connections[0].name
97
+ choice = typer.prompt("Choose a connection (name or #)", default=prompt_default)
98
+
99
+ try:
100
+ idx = int(choice) - 1
101
+ if 0 <= idx < len(connections):
102
+ conn = connections[idx]
103
+ return conn.id, conn.name
104
+ msg = f"Invalid selection. Enter a number between 1 and {len(connections)}, or a connection name."
105
+ console.print(err(msg))
106
+ raise typer.Exit(1)
107
+ except ValueError:
108
+ pass
109
+
110
+ conn = store.get_by_name(choice)
111
+ if conn is None:
112
+ console.print(err(f"No connection named '{choice}'."))
113
+ raise typer.Exit(1)
114
+ return conn.id, conn.name
115
+
116
+
117
+ def _print_compat(compat: BlendCompatibility, registry: CardRegistry) -> None:
118
+ """Print tension/synergy warnings after modifier selection. Non-blocking."""
119
+ if compat.has_tensions:
120
+ console.print(warn(f"\n ✦ CLASH — {len(compat.tensions)} tension(s) in this blend:"))
121
+ for a, b in compat.tensions:
122
+ console.print(warn(f" ✗ {registry.get(a).name} ↔ {registry.get(b).name}"))
123
+ if compat.has_synergies:
124
+ console.print(dim(f"\n ✦ SYNERGY — {len(compat.synergies)} synergy/ies in this blend:"))
125
+ for a, b in compat.synergies:
126
+ console.print(dim(f" ✓ {registry.get(a).name} + {registry.get(b).name}"))
127
+ if compat.has_tensions or compat.has_synergies:
128
+ console.print()
129
+
130
+
131
+ @app.command("create")
132
+ def create(
133
+ name: str = typer.Option(None, "--name", "-n", help="Agent name"),
134
+ card: str = typer.Option(None, "--card", "-c", help="Card id (e.g. 'hermit')"),
135
+ model: str = typer.Option(None, "--model", "-m", help="Model connection name"),
136
+ ) -> None:
137
+ """Create a new agent. Interactive if no flags provided."""
138
+ if not name:
139
+ name = typer.prompt("Agent name")
140
+
141
+ modifier_cards: list[Card] = []
142
+
143
+ if not card:
144
+ card_enum = select_card("Choose a primary card for this agent")
145
+ if card_enum is None:
146
+ raise typer.Exit()
147
+ if card_enum == Card.WORLD:
148
+ console.print(err("The World is reserved and cannot be assigned."))
149
+ raise typer.Exit(1)
150
+ raw_modifiers = select_cards(
151
+ "Add modifier cards (optional — Space to toggle, Enter to confirm with none)",
152
+ initial=[],
153
+ max_items=CardEngine.MAX_MODIFIERS,
154
+ )
155
+ modifier_cards = [m for m in raw_modifiers if m != card_enum]
156
+ if modifier_cards:
157
+ _print_compat(
158
+ CardEngine(get_registry()).check_compatibility(card_enum, modifier_cards),
159
+ get_registry(),
160
+ )
161
+ else:
162
+ try:
163
+ card_enum = _validate_card(card)
164
+ except ValueError as exc:
165
+ console.print(err(str(exc)))
166
+ raise typer.Exit(1) from exc
167
+ if card_enum == Card.WORLD:
168
+ console.print(err("The World is reserved and cannot be assigned."))
169
+ raise typer.Exit(1)
170
+
171
+ if model:
172
+ conn = _store().get_by_name(model)
173
+ if conn is None:
174
+ console.print(err(f"No connection named '{model}'. Run: arcana connect list"))
175
+ raise typer.Exit(1)
176
+ conn_id, conn_name = conn.id, conn.name
177
+ else:
178
+ conn_id, conn_name = _pick_connection()
179
+
180
+ registry = get_registry()
181
+ record = _registry().create(
182
+ name=name,
183
+ card=card_enum,
184
+ model_connection_id=conn_id,
185
+ modifier_cards=modifier_cards,
186
+ )
187
+ tarot = registry.get(card_enum)
188
+ modifier_str = (
189
+ f" {hl('Modifiers:')} " + ", ".join(registry.get(m).name for m in modifier_cards) + "\n"
190
+ if modifier_cards
191
+ else ""
192
+ )
193
+ console.print(
194
+ make_panel_fit(
195
+ f"[bold {GREEN}]Agent '{record.name}' created.[/]\n\n"
196
+ f" {hl('ID:')} [{TXT3}]{record.id}[/]\n"
197
+ f" {hl('Card:')} {ROMAN[tarot.number]} · {tarot.name} — {tarot.archetype.role}\n"
198
+ + modifier_str
199
+ + f" {hl('Model:')} {conn_name}\n"
200
+ f" {hl('Temp:')} {record.temperature:.2f}",
201
+ title="New Agent",
202
+ card=card_enum,
203
+ )
204
+ )
205
+
206
+
207
+ @app.command("list")
208
+ def list_agents() -> None:
209
+ """List all registered agents."""
210
+ records = _registry().list()
211
+ if not records:
212
+ console.print(dim("No agents yet. Run: arcana agent create"))
213
+ return
214
+
215
+ conn_map = {c.id: c for c in _store().all()}
216
+ table = make_table("Agents")
217
+ table.add_column("Name", style="bold")
218
+ table.add_column("Card")
219
+ table.add_column("Model")
220
+ table.add_column("Status")
221
+ table.add_column("ID", style=TXT3)
222
+ for r in records:
223
+ conn = conn_map.get(r.model_connection_id)
224
+ model_name = conn.name if conn else str(r.model_connection_id)[:8] + "…"
225
+ table.add_row(r.name, r.card.value, model_name, status_markup(r.status.value), str(r.id)[:8] + "…")
226
+ console.print(table)
227
+
228
+
229
+ @app.command("show")
230
+ def show(name: str = typer.Argument(..., help="Agent name or UUID")) -> None:
231
+ """Show full config for an agent."""
232
+ record = _resolve_agent(name)
233
+ conn_map = {c.id: c for c in _store().all()}
234
+ conn = conn_map.get(record.model_connection_id)
235
+ model_label = conn.name if conn else str(record.model_connection_id)
236
+
237
+ modifier_str = ", ".join(c.value for c in record.modifier_cards) or "none"
238
+ tags_str = ", ".join(record.tags) or "none"
239
+ prompt_preview = record.system_prompt[:200] + ("…" if len(record.system_prompt) > 200 else "")
240
+
241
+ console.print(
242
+ make_panel(
243
+ f"{hl('ID:')} [{TXT3}]{record.id}[/]\n"
244
+ f"{hl('Name:')} {record.name}\n"
245
+ f"{hl('Description:')} {record.description or '—'}\n"
246
+ f"{hl('Card:')} {record.card.value}\n"
247
+ f"{hl('Modifiers:')} {modifier_str}\n"
248
+ f"{hl('Model:')} {model_label}\n"
249
+ f"{hl('Temperature:')} {record.temperature:.2f}\n"
250
+ f"{hl('Status:')} {status_markup(record.status.value)}\n"
251
+ f"{hl('Tags:')} {tags_str}\n"
252
+ f"{hl('Created:')} {record.created_at.strftime('%Y-%m-%d %H:%M UTC')}\n\n"
253
+ f"{hl('System prompt:')}\n{prompt_preview}",
254
+ title=record.name,
255
+ card=record.card,
256
+ )
257
+ )
258
+
259
+
260
+ @app.command("edit")
261
+ def edit(
262
+ name: str = typer.Argument(..., help="Agent name or UUID"),
263
+ new_name: str | None = typer.Option(None, "--name", "-n", help="New name"),
264
+ description: str | None = typer.Option(None, "--description", "-d", help="Description"),
265
+ card: str | None = typer.Option(None, "--card", "-c", help="Card id"),
266
+ model: str | None = typer.Option(None, "--model", "-m", help="Connection name"),
267
+ tags: str | None = typer.Option(None, "--tags", "-t", help="Comma-separated tags"),
268
+ ) -> None:
269
+ """Edit an agent's name, description, card, model, or tags."""
270
+ record = _resolve_agent(name)
271
+
272
+ updated_name = new_name if new_name is not None else typer.prompt("Name", default=record.name)
273
+ updated_desc = (
274
+ description if description is not None else typer.prompt("Description", default=record.description or "")
275
+ )
276
+
277
+ if card is not None:
278
+ try:
279
+ updated_card = _validate_card(card)
280
+ except ValueError as exc:
281
+ console.print(err(str(exc)))
282
+ raise typer.Exit(1) from exc
283
+ updated_modifiers = record.modifier_cards
284
+ else:
285
+ picked = select_card("Choose a card", initial=record.card)
286
+ if picked is None:
287
+ raise typer.Exit()
288
+ updated_card = picked
289
+ raw_modifiers = select_cards(
290
+ "Modifier cards (Space to toggle, Enter to confirm)",
291
+ initial=record.modifier_cards,
292
+ max_items=CardEngine.MAX_MODIFIERS,
293
+ )
294
+ updated_modifiers = [m for m in raw_modifiers if m != updated_card]
295
+ if updated_modifiers:
296
+ _print_compat(
297
+ CardEngine(get_registry()).check_compatibility(updated_card, updated_modifiers),
298
+ get_registry(),
299
+ )
300
+
301
+ if model is not None:
302
+ conn = _store().get_by_name(model)
303
+ if conn is None:
304
+ console.print(err(f"No connection named '{model}'."))
305
+ raise typer.Exit(1)
306
+ updated_conn_id = conn.id
307
+ else:
308
+ conn_map = {c.id: c for c in _store().all()}
309
+ current_conn = conn_map.get(record.model_connection_id)
310
+ updated_conn_id, _ = _pick_connection(default_name=current_conn.name if current_conn else None)
311
+
312
+ if tags is not None:
313
+ updated_tags = [t.strip() for t in tags.split(",") if t.strip()]
314
+ else:
315
+ tags_input = typer.prompt("Tags (comma-separated)", default=", ".join(record.tags))
316
+ updated_tags = [t.strip() for t in tags_input.split(",") if t.strip()]
317
+
318
+ updates: dict[str, Any] = {
319
+ "name": updated_name,
320
+ "description": updated_desc,
321
+ "card": updated_card,
322
+ "modifier_cards": updated_modifiers,
323
+ "model_connection_id": updated_conn_id,
324
+ "tags": updated_tags,
325
+ }
326
+ if updated_card != record.card or updated_modifiers != record.modifier_cards:
327
+ config = CardEngine(get_registry()).resolve(updated_card, updated_modifiers)
328
+ updates["temperature"] = config.temperature
329
+ updates["system_prompt"] = config.system_prompt
330
+
331
+ _registry().save(record.model_copy(update=updates))
332
+ console.print(ok(f"Agent '{updated_name}' updated."))
333
+
334
+
335
+ @app.command("delete")
336
+ def delete(
337
+ name: str = typer.Argument(..., help="Agent name or UUID"),
338
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
339
+ ) -> None:
340
+ """Delete an agent (soft-delete)."""
341
+ record = _resolve_agent(name)
342
+ if not yes:
343
+ typer.confirm(f"Delete agent '{record.name}'?", abort=True)
344
+ try:
345
+ _registry().delete(record.id)
346
+ except FileNotFoundError as e:
347
+ console.print(err(f"Agent '{record.name}' was already deleted."))
348
+ raise typer.Exit(1) from e
349
+ console.print(ok(f"Agent '{record.name}' deleted."))
@@ -0,0 +1,47 @@
1
+ """arcana cards — list and show tarot card definitions."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from arcana.cards.registry import get_registry
7
+ from arcana.types.card import Card, TarotCard
8
+ from arcana_cli.ui.card_panel import card_panel
9
+ from arcana_cli.ui.card_picker import select_card
10
+ from arcana_cli.ui.theme import err, warn
11
+
12
+ app = typer.Typer(help="Browse the 22 Major Arcana card definitions.")
13
+ console = Console()
14
+
15
+
16
+ def _resolve_card(name: str) -> TarotCard:
17
+ registry = get_registry()
18
+ for candidate in (name, f"the-{name}"):
19
+ try:
20
+ return registry.get(Card(candidate))
21
+ except ValueError:
22
+ pass
23
+ matches = [c for c in registry.all() if name.lower() in c.id.value or name.lower() in c.name.lower()]
24
+ if len(matches) == 1:
25
+ return matches[0]
26
+ if len(matches) > 1:
27
+ console.print(warn(f"Ambiguous: {', '.join(c.name for c in matches)}"))
28
+ raise typer.Exit(1)
29
+ console.print(err(f"Unknown card: {name!r}"))
30
+ raise typer.Exit(1)
31
+
32
+
33
+ @app.callback(invoke_without_command=True)
34
+ def list_cards(ctx: typer.Context) -> None:
35
+ """Browse the 22 Major Arcana — interactive two-pane picker."""
36
+ if ctx.invoked_subcommand is not None:
37
+ return
38
+ picked = select_card("Browse the Major Arcana")
39
+ if picked is not None:
40
+ console.print(card_panel(get_registry().get(picked), get_registry()))
41
+
42
+
43
+ @app.command("show")
44
+ def show(name: str = typer.Argument(..., help="Card name or key (e.g. 'hermit', 'the-hermit')")) -> None:
45
+ """Show full card details — prompt ingredients, memory weights, synergies."""
46
+ card = _resolve_card(name)
47
+ console.print(card_panel(card, get_registry()))
@@ -0,0 +1,112 @@
1
+ """arcana connect — manage model connections."""
2
+
3
+ import uuid
4
+
5
+ import keyring
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from arcana.models import ConnectionStore
10
+ from arcana.types.model import ModelConnection, ModelProvider
11
+ from arcana_cli.constants import CONNECTIONS_PATH
12
+ from arcana_cli.ui.theme import GREEN, TXT3, dim, err, hl, make_table, ok
13
+
14
+ app = typer.Typer(help="Manage connections to models and services.")
15
+ console = Console()
16
+
17
+ _PROVIDERS = ["ollama", "anthropic", "openai", "openai_compat", "custom"]
18
+ _DEFAULT_ENDPOINTS: dict[str, str] = {
19
+ "ollama": "http://localhost:11434",
20
+ "anthropic": "",
21
+ "openai": "https://api.openai.com/v1",
22
+ "openai_compat": "",
23
+ "custom": "",
24
+ }
25
+ _NEEDS_KEY = {"anthropic", "openai", "openai_compat", "custom"}
26
+
27
+
28
+ @app.command("model")
29
+ def model_cmd(
30
+ provider: str | None = typer.Option(
31
+ None, "--provider", "-p", help="ollama | anthropic | openai | openai_compat | custom"
32
+ ),
33
+ model_id: str | None = typer.Option(None, "--model-id", "-m", help="Model ID (e.g. hermes-3, claude-sonnet-4-6)"),
34
+ name: str | None = typer.Option(None, "--name", "-n", help="Connection name"),
35
+ endpoint: str | None = typer.Option(None, "--endpoint", "-e", help="Custom base URL"),
36
+ api_key: str | None = typer.Option(None, "--api-key", "-k", help="API key (stored in OS keyring)"),
37
+ ) -> None:
38
+ """Add or update a model connection (stored in ~/.arcana/connections/models.json)."""
39
+ if provider is None:
40
+ console.print(dim(f"Providers: {' '.join(_PROVIDERS)}"))
41
+ provider = str(typer.prompt("Provider"))
42
+
43
+ provider = provider.lower().replace("-", "_")
44
+ if provider not in _PROVIDERS:
45
+ console.print(err(f"Unknown provider: {provider!r}. Choose from: {', '.join(_PROVIDERS)}"))
46
+ raise typer.Exit(1)
47
+
48
+ if model_id is None:
49
+ model_id = str(typer.prompt("Model ID (e.g. hermes-3, claude-sonnet-4-6)"))
50
+
51
+ if name is None:
52
+ name = str(typer.prompt("Connection name", default=f"{provider}/{model_id}"))
53
+
54
+ default_ep = _DEFAULT_ENDPOINTS.get(provider, "")
55
+ if endpoint is None:
56
+ if provider in ("ollama", "openai_compat", "custom"):
57
+ endpoint = str(typer.prompt("Endpoint (base URL)", default=default_ep))
58
+ else:
59
+ endpoint = default_ep
60
+
61
+ if api_key is None and provider in _NEEDS_KEY:
62
+ api_key = str(typer.prompt(f"API key for {provider}", hide_input=True, default=""))
63
+
64
+ store = ConnectionStore(CONNECTIONS_PATH)
65
+ existing = store.get_by_name(name)
66
+
67
+ if existing is not None:
68
+ if not typer.confirm(f"Connection '{name}' already exists. Overwrite?"):
69
+ raise typer.Exit()
70
+ conn_id = existing.id
71
+ action = "Updated"
72
+ else:
73
+ conn_id = uuid.uuid4()
74
+ action = "Added"
75
+
76
+ conn = ModelConnection(
77
+ id=conn_id,
78
+ name=name,
79
+ provider=ModelProvider(provider),
80
+ model_id=model_id,
81
+ endpoint=endpoint or "",
82
+ )
83
+
84
+ store.upsert(conn)
85
+
86
+ if api_key:
87
+ keyring.set_password("arcana", f"{conn_id}_api_key", api_key)
88
+
89
+ key_note = f" {hl('API key:')} [{GREEN}]saved to OS keyring[/]\n" if api_key else ""
90
+ details = (
91
+ f"\n {hl('Provider:')} {provider}\n"
92
+ f" {hl('Model:')} {model_id}\n"
93
+ f" {hl('Endpoint:')} {endpoint or '(provider default)'}\n" + key_note
94
+ )
95
+ console.print("\n" + ok(f"{action} connection '{name}'") + details)
96
+
97
+
98
+ @app.command("list")
99
+ def list_cmd() -> None:
100
+ """List all saved model connections."""
101
+ connections = ConnectionStore(CONNECTIONS_PATH).all()
102
+ if not connections:
103
+ console.print(dim("No connections yet. Run: arcana connect model"))
104
+ return
105
+ table = make_table("Model Connections")
106
+ table.add_column("Name", style="bold")
107
+ table.add_column("Provider")
108
+ table.add_column("Model ID")
109
+ table.add_column("Endpoint", style=TXT3)
110
+ for c in connections:
111
+ table.add_row(c.name, str(c.provider), c.model_id, c.endpoint or "(default)")
112
+ console.print(table)