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 +0 -0
- arcana_cli/commands/__init__.py +0 -0
- arcana_cli/commands/agent.py +349 -0
- arcana_cli/commands/cards.py +47 -0
- arcana_cli/commands/connect.py +112 -0
- arcana_cli/commands/run.py +205 -0
- arcana_cli/commands/soul.py +54 -0
- arcana_cli/constants.py +33 -0
- arcana_cli/main.py +26 -0
- arcana_cli/ui/__init__.py +0 -0
- arcana_cli/ui/card_panel.py +67 -0
- arcana_cli/ui/card_picker.py +266 -0
- arcana_cli/ui/theme.py +217 -0
- arcana_cli-0.1.0.dist-info/METADATA +179 -0
- arcana_cli-0.1.0.dist-info/RECORD +19 -0
- arcana_cli-0.1.0.dist-info/WHEEL +4 -0
- arcana_cli-0.1.0.dist-info/entry_points.txt +2 -0
- arcana_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- arcana_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
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)
|