priests 0.4.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.
- priests/__init__.py +1 -0
- priests/cli/__init__.py +0 -0
- priests/cli/config_cmd.py +58 -0
- priests/cli/init_cmd.py +193 -0
- priests/cli/main.py +49 -0
- priests/cli/model_cmd.py +125 -0
- priests/cli/profile_cmd.py +103 -0
- priests/cli/providers_cmd.py +86 -0
- priests/cli/run_cmd.py +295 -0
- priests/cli/service_cmd.py +90 -0
- priests/config/__init__.py +0 -0
- priests/config/loader.py +119 -0
- priests/config/model.py +89 -0
- priests/engine_factory.py +158 -0
- priests/memory/__init__.py +0 -0
- priests/memory/extractor.py +118 -0
- priests/profile/__init__.py +0 -0
- priests/profile/config.py +24 -0
- priests/registry.py +157 -0
- priests/service/__init__.py +0 -0
- priests/service/app.py +39 -0
- priests/service/routes/__init__.py +0 -0
- priests/service/routes/health.py +12 -0
- priests/service/routes/run.py +103 -0
- priests/service/routes/sessions.py +54 -0
- priests/service/schemas.py +55 -0
- priests-0.4.0.dist-info/METADATA +17 -0
- priests-0.4.0.dist-info/RECORD +31 -0
- priests-0.4.0.dist-info/WHEEL +4 -0
- priests-0.4.0.dist-info/entry_points.txt +2 -0
- priests-0.4.0.dist-info/licenses/LICENSE +21 -0
priests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
priests/cli/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import tomli_w
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.syntax import Syntax
|
|
10
|
+
|
|
11
|
+
from priests.config.loader import is_initialized, load_config, set_config_value
|
|
12
|
+
|
|
13
|
+
config_app = typer.Typer(help="View and edit configuration.")
|
|
14
|
+
console = Console()
|
|
15
|
+
err_console = Console(stderr=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _strip_none(obj):
|
|
19
|
+
"""Recursively remove None values — TOML has no null type."""
|
|
20
|
+
if isinstance(obj, dict):
|
|
21
|
+
return {k: _strip_none(v) for k, v in obj.items() if v is not None}
|
|
22
|
+
return obj
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@config_app.command("show")
|
|
26
|
+
def config_show(
|
|
27
|
+
config_file: Annotated[Path | None, typer.Option("--config", help="Path to priests.toml.")] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Print the current resolved configuration."""
|
|
30
|
+
if not is_initialized(config_file):
|
|
31
|
+
err_console.print("[yellow]Not initialized.[/yellow] Run [bold]priests init[/bold] first.")
|
|
32
|
+
raise typer.Exit(1)
|
|
33
|
+
config = load_config(config_file)
|
|
34
|
+
raw = _strip_none(config.model_dump(mode="json"))
|
|
35
|
+
toml_str = tomli_w.dumps(raw)
|
|
36
|
+
console.print(Syntax(toml_str, "toml", theme="ansi_dark", background_color="default"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@config_app.command("set")
|
|
40
|
+
def config_set(
|
|
41
|
+
key: Annotated[str, typer.Argument(help="Dotted key, e.g. default.model or service.port.")],
|
|
42
|
+
value: Annotated[str, typer.Argument(help="New value.")],
|
|
43
|
+
config_file: Annotated[Path | None, typer.Option("--config", help="Path to priests.toml.")] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Set a configuration value and save it."""
|
|
46
|
+
if not is_initialized(config_file):
|
|
47
|
+
err_console.print("[yellow]Not initialized.[/yellow] Run [bold]priests init[/bold] first.")
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
try:
|
|
50
|
+
saved_path = set_config_value(key, value, config_file)
|
|
51
|
+
console.print(f"[green]Set[/green] {key} = {value!r}")
|
|
52
|
+
console.print(f"[dim]Saved to {saved_path}[/dim]")
|
|
53
|
+
except KeyError as e:
|
|
54
|
+
err_console.print(f"[red]Error:[/red] {e}")
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
except (ValueError, TypeError) as e:
|
|
57
|
+
err_console.print(f"[red]Invalid value:[/red] {e}")
|
|
58
|
+
raise typer.Exit(1)
|
priests/cli/init_cmd.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import questionary
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from priests.config.loader import is_initialized, save_config
|
|
12
|
+
from priests.config.model import (
|
|
13
|
+
AnthropicConfig,
|
|
14
|
+
AppConfig,
|
|
15
|
+
DefaultsConfig,
|
|
16
|
+
OllamaConfig,
|
|
17
|
+
OpenAICompatConfig,
|
|
18
|
+
PathsConfig,
|
|
19
|
+
ProvidersConfig,
|
|
20
|
+
ServiceConfig,
|
|
21
|
+
)
|
|
22
|
+
from priests.engine_factory import _bootstrap_profiles
|
|
23
|
+
from priests.registry import ProviderInfo, get_provider, list_providers
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
err_console = Console(stderr=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _arrow_select(prompt: str, choices: list[questionary.Choice]) -> str:
|
|
30
|
+
"""Arrow-key selection. Aborts with a clean message on Ctrl-C."""
|
|
31
|
+
result = questionary.select(prompt, choices=choices, use_shortcuts=False).ask()
|
|
32
|
+
if result is None:
|
|
33
|
+
raise typer.Abort()
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fetch_ollama_models(base_url: str) -> list[str] | None:
|
|
38
|
+
"""Return sorted model names from Ollama, or None if unreachable."""
|
|
39
|
+
try:
|
|
40
|
+
r = httpx.get(f"{base_url.rstrip('/')}/api/tags", timeout=5.0)
|
|
41
|
+
r.raise_for_status()
|
|
42
|
+
return sorted(m["name"] for m in r.json().get("models", []))
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _select_ollama_model(default_url: str) -> tuple[str, str]:
|
|
48
|
+
"""Return (model_name, confirmed_base_url). Retries on bad URL."""
|
|
49
|
+
base_url = default_url
|
|
50
|
+
|
|
51
|
+
while True:
|
|
52
|
+
console.print(f"[dim]Connecting to Ollama at {base_url} ...[/dim]")
|
|
53
|
+
models = _fetch_ollama_models(base_url)
|
|
54
|
+
|
|
55
|
+
if models is None:
|
|
56
|
+
err_console.print(f"[red]Could not connect to Ollama at {base_url}[/red]")
|
|
57
|
+
base_url = typer.prompt("Enter Ollama base URL").strip().rstrip("/")
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
if not models:
|
|
61
|
+
console.print("[yellow]No local models found.[/yellow] Make sure you have pulled at least one model.")
|
|
62
|
+
console.print(" e.g. [bold]ollama pull qwen3:8b[/bold]\n")
|
|
63
|
+
model = typer.prompt("Or enter a model name manually").strip()
|
|
64
|
+
return model, base_url
|
|
65
|
+
|
|
66
|
+
model = _arrow_select(
|
|
67
|
+
"Select model:",
|
|
68
|
+
[questionary.Choice(title=m) for m in models],
|
|
69
|
+
)
|
|
70
|
+
return model, base_url
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _select_model(info: ProviderInfo) -> str:
|
|
74
|
+
"""Select a model for a non-Ollama provider.
|
|
75
|
+
|
|
76
|
+
Shows an arrow selector when known_models is non-empty, with an
|
|
77
|
+
'Enter manually' escape hatch. Falls back to free-text when the list
|
|
78
|
+
is empty (OpenRouter, Custom).
|
|
79
|
+
"""
|
|
80
|
+
if not info.known_models:
|
|
81
|
+
return typer.prompt("Model name").strip()
|
|
82
|
+
|
|
83
|
+
_MANUAL = "__manual__"
|
|
84
|
+
choices = [questionary.Choice(title=m) for m in info.known_models]
|
|
85
|
+
choices.append(questionary.Choice(title="Enter manually…", value=_MANUAL))
|
|
86
|
+
|
|
87
|
+
selected = _arrow_select("Select model:", choices)
|
|
88
|
+
if selected == _MANUAL:
|
|
89
|
+
return typer.prompt("Model name").strip()
|
|
90
|
+
return selected
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _register_model(config: AppConfig, provider: str, model: str) -> None:
|
|
94
|
+
"""Add provider/model to config.models.options if not already present."""
|
|
95
|
+
entry = f"{provider}/{model}"
|
|
96
|
+
if entry not in config.models.options:
|
|
97
|
+
config.models.options.append(entry)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _apply_provider_to_config(
|
|
101
|
+
providers: ProvidersConfig,
|
|
102
|
+
provider: str,
|
|
103
|
+
api_key: str,
|
|
104
|
+
custom_base_url: str,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Write api_key (and base_url for custom) into the providers config in-place."""
|
|
107
|
+
if provider == "anthropic":
|
|
108
|
+
providers.anthropic = AnthropicConfig(api_key=api_key)
|
|
109
|
+
elif provider == "custom":
|
|
110
|
+
providers.custom = OpenAICompatConfig(api_key=api_key, base_url=custom_base_url)
|
|
111
|
+
elif provider != "ollama":
|
|
112
|
+
info = get_provider(provider)
|
|
113
|
+
base_url = info.default_base_url if info else ""
|
|
114
|
+
setattr(providers, provider, OpenAICompatConfig(api_key=api_key, base_url=base_url))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def init_command(
|
|
118
|
+
force: Annotated[bool, typer.Option("--force", help="Re-initialize even if already set up.")] = False,
|
|
119
|
+
config_file: Annotated[Path | None, typer.Option("--config", help="Path to priests.toml.")] = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Initialize priests: configure provider, model, and scaffold profiles."""
|
|
122
|
+
if is_initialized(config_file) and not force:
|
|
123
|
+
console.print("[yellow]priests is already initialized.[/yellow]")
|
|
124
|
+
console.print("Run [bold]priests config show[/bold] to see current settings.")
|
|
125
|
+
console.print("Use [bold]--force[/bold] to re-initialize.")
|
|
126
|
+
raise typer.Exit()
|
|
127
|
+
|
|
128
|
+
console.print("[bold]Welcome to priests![/bold]")
|
|
129
|
+
console.print("Let's set up your configuration.\n")
|
|
130
|
+
|
|
131
|
+
# --- Provider ---
|
|
132
|
+
providers_list = list_providers()
|
|
133
|
+
provider_name = _arrow_select(
|
|
134
|
+
"Select a provider:",
|
|
135
|
+
[questionary.Choice(title=f"{p.name} — {p.label}", value=p.name) for p in providers_list],
|
|
136
|
+
)
|
|
137
|
+
console.print()
|
|
138
|
+
|
|
139
|
+
info = next(p for p in providers_list if p.name == provider_name)
|
|
140
|
+
|
|
141
|
+
# --- API key + model ---
|
|
142
|
+
ollama_base_url = "http://localhost:11434"
|
|
143
|
+
api_key = ""
|
|
144
|
+
custom_base_url = ""
|
|
145
|
+
|
|
146
|
+
if provider_name == "ollama":
|
|
147
|
+
model, ollama_base_url = _select_ollama_model(ollama_base_url)
|
|
148
|
+
else:
|
|
149
|
+
if provider_name == "custom":
|
|
150
|
+
custom_base_url = typer.prompt("Base URL (e.g. https://my-server/v1)").strip().rstrip("/")
|
|
151
|
+
if info.needs_api_key:
|
|
152
|
+
api_key = typer.prompt("API key", hide_input=False).strip()
|
|
153
|
+
model = _select_model(info)
|
|
154
|
+
|
|
155
|
+
console.print()
|
|
156
|
+
|
|
157
|
+
# --- Paths ---
|
|
158
|
+
default_profiles_dir = str(Path.home() / ".priests" / "profiles")
|
|
159
|
+
default_sessions_db = str(Path.home() / ".priests" / "sessions.db")
|
|
160
|
+
|
|
161
|
+
profiles_dir_str = typer.prompt("Profiles directory", default=default_profiles_dir)
|
|
162
|
+
sessions_db_str = typer.prompt("Sessions database", default=default_sessions_db)
|
|
163
|
+
|
|
164
|
+
# --- Build and save config ---
|
|
165
|
+
providers = ProvidersConfig(ollama=OllamaConfig(base_url=ollama_base_url))
|
|
166
|
+
_apply_provider_to_config(providers, provider_name, api_key, custom_base_url)
|
|
167
|
+
|
|
168
|
+
config = AppConfig(
|
|
169
|
+
default=DefaultsConfig(provider=provider_name, model=model),
|
|
170
|
+
paths=PathsConfig(
|
|
171
|
+
profiles_dir=Path(profiles_dir_str),
|
|
172
|
+
sessions_db=Path(sessions_db_str),
|
|
173
|
+
),
|
|
174
|
+
service=ServiceConfig(),
|
|
175
|
+
providers=providers,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
_register_model(config, provider_name, model)
|
|
179
|
+
saved_path = save_config(config, config_file)
|
|
180
|
+
|
|
181
|
+
profiles_root = Path(profiles_dir_str).expanduser()
|
|
182
|
+
_bootstrap_profiles(profiles_root)
|
|
183
|
+
|
|
184
|
+
console.print(f"\n[green]Initialized![/green] Config saved to {saved_path}")
|
|
185
|
+
console.print(f" provider : {provider_name}")
|
|
186
|
+
console.print(f" model : {model}")
|
|
187
|
+
console.print(f" profiles : {profiles_root}")
|
|
188
|
+
console.print("\n[bold]Next steps:[/bold]")
|
|
189
|
+
console.print(" [bold]priests run[/bold] start an interactive chat")
|
|
190
|
+
console.print(" [bold]priests run --prompt \"...\"[/bold] send a single prompt")
|
|
191
|
+
console.print(" [bold]priests profile init \"my_profile\"[/bold] create a custom profile")
|
|
192
|
+
console.print(" [bold]priests model add[/bold] configure an additional provider")
|
|
193
|
+
console.print(" [bold]priests --help[/bold] / [bold]priests <command> --help[/bold]")
|
priests/cli/main.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from typer.core import TyperGroup
|
|
5
|
+
|
|
6
|
+
from priests import __version__
|
|
7
|
+
from priests.cli.init_cmd import init_command
|
|
8
|
+
from priests.cli.run_cmd import run_app
|
|
9
|
+
from priests.cli.profile_cmd import profile_app
|
|
10
|
+
from priests.cli.config_cmd import config_app
|
|
11
|
+
from priests.cli.model_cmd import model_app
|
|
12
|
+
from priests.cli.providers_cmd import providers_app
|
|
13
|
+
from priests.cli.service_cmd import service_app
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _DefaultRunGroup(TyperGroup):
|
|
17
|
+
"""Route unknown subcommands to 'run' so `priests "prompt"` works as a shortcut."""
|
|
18
|
+
|
|
19
|
+
def resolve_command(self, ctx, args: list) -> tuple:
|
|
20
|
+
cmd_name = args[0] if args else None
|
|
21
|
+
if cmd_name and cmd_name not in self.commands:
|
|
22
|
+
args.insert(0, "run")
|
|
23
|
+
return super().resolve_command(ctx, args)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="priests",
|
|
28
|
+
help="AI dispatch CLI and service.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
cls=_DefaultRunGroup,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
app.command("init")(init_command)
|
|
34
|
+
app.add_typer(run_app, name="run")
|
|
35
|
+
app.add_typer(profile_app, name="profile")
|
|
36
|
+
app.add_typer(config_app, name="config")
|
|
37
|
+
app.add_typer(model_app, name="model")
|
|
38
|
+
app.add_typer(providers_app, name="providers")
|
|
39
|
+
app.add_typer(service_app, name="service")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.callback(invoke_without_command=True)
|
|
43
|
+
def root(
|
|
44
|
+
ctx: typer.Context,
|
|
45
|
+
version: bool = typer.Option(False, "--version", "-V", is_eager=True, help="Show version and exit."),
|
|
46
|
+
) -> None:
|
|
47
|
+
if version:
|
|
48
|
+
typer.echo(f"priests {__version__}")
|
|
49
|
+
raise typer.Exit()
|
priests/cli/model_cmd.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import questionary
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from priests.cli.init_cmd import (
|
|
11
|
+
_apply_provider_to_config,
|
|
12
|
+
_arrow_select,
|
|
13
|
+
_register_model,
|
|
14
|
+
_select_model,
|
|
15
|
+
_select_ollama_model,
|
|
16
|
+
)
|
|
17
|
+
from priests.config.loader import is_initialized, load_config, save_config
|
|
18
|
+
from priests.registry import list_providers
|
|
19
|
+
|
|
20
|
+
model_app = typer.Typer(help="Manage model defaults and provider setup.")
|
|
21
|
+
console = Console()
|
|
22
|
+
err_console = Console(stderr=True)
|
|
23
|
+
|
|
24
|
+
_ADD_NEW = "__add_new__"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@model_app.command("list")
|
|
28
|
+
def model_list(
|
|
29
|
+
config_file: Annotated[Path | None, typer.Option("--config", help="Path to priests.toml.")] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""List all added models."""
|
|
32
|
+
if not is_initialized(config_file):
|
|
33
|
+
err_console.print("[yellow]Not initialized.[/yellow] Run [bold]priests init[/bold] first.")
|
|
34
|
+
raise typer.Exit(1)
|
|
35
|
+
|
|
36
|
+
config = load_config(config_file)
|
|
37
|
+
if not config.models.options:
|
|
38
|
+
console.print("[dim]No models added yet. Run [bold]priests model add[/bold].[/dim]")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
for entry in config.models.options:
|
|
42
|
+
marker = "[green]*[/green] " if entry == f"{config.default.provider}/{config.default.model}" else " "
|
|
43
|
+
console.print(f"{marker}{entry}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@model_app.command("default")
|
|
47
|
+
def model_default(
|
|
48
|
+
config_file: Annotated[Path | None, typer.Option("--config", help="Path to priests.toml.")] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Set the default model from your added models list."""
|
|
51
|
+
if not is_initialized(config_file):
|
|
52
|
+
err_console.print("[yellow]Not initialized.[/yellow] Run [bold]priests init[/bold] first.")
|
|
53
|
+
raise typer.Exit(1)
|
|
54
|
+
|
|
55
|
+
config = load_config(config_file)
|
|
56
|
+
|
|
57
|
+
if config.models.options:
|
|
58
|
+
choices = [questionary.Choice(title=m) for m in config.models.options]
|
|
59
|
+
choices.append(questionary.Choice(title="Add new model…", value=_ADD_NEW))
|
|
60
|
+
selected = _arrow_select("Select default model:", choices)
|
|
61
|
+
else:
|
|
62
|
+
selected = _ADD_NEW
|
|
63
|
+
|
|
64
|
+
if selected == _ADD_NEW:
|
|
65
|
+
provider_name, model = _run_add_flow(config, config_file)
|
|
66
|
+
else:
|
|
67
|
+
provider_name, model = selected.split("/", 1)
|
|
68
|
+
|
|
69
|
+
config.default.provider = provider_name
|
|
70
|
+
config.default.model = model
|
|
71
|
+
|
|
72
|
+
saved_path = save_config(config, config_file)
|
|
73
|
+
console.print(f"\n[green]Default updated.[/green] Saved to {saved_path}")
|
|
74
|
+
console.print(f" {provider_name}/{model}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@model_app.command("add")
|
|
78
|
+
def model_add(
|
|
79
|
+
config_file: Annotated[Path | None, typer.Option("--config", help="Path to priests.toml.")] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Configure an additional provider and add a model to your list."""
|
|
82
|
+
if not is_initialized(config_file):
|
|
83
|
+
err_console.print("[yellow]Not initialized.[/yellow] Run [bold]priests init[/bold] first.")
|
|
84
|
+
raise typer.Exit(1)
|
|
85
|
+
|
|
86
|
+
config = load_config(config_file)
|
|
87
|
+
provider_name, model = _run_add_flow(config, config_file)
|
|
88
|
+
|
|
89
|
+
console.print(f"\n[green]Model added.[/green] {provider_name}/{model}")
|
|
90
|
+
console.print(f"[dim]Use with: priests run --provider {provider_name} --model {model}[/dim]")
|
|
91
|
+
console.print(f"[dim]Set as default: priests model default[/dim]")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _run_add_flow(config, config_file) -> tuple[str, str]:
|
|
95
|
+
"""Shared flow: select provider, enter key, select model, save. Returns (provider, model)."""
|
|
96
|
+
all_providers = list_providers()
|
|
97
|
+
provider_name = _arrow_select(
|
|
98
|
+
"Select provider:",
|
|
99
|
+
[questionary.Choice(title=f"{p.name} — {p.label}", value=p.name) for p in all_providers],
|
|
100
|
+
)
|
|
101
|
+
console.print()
|
|
102
|
+
|
|
103
|
+
info = next(p for p in all_providers if p.name == provider_name)
|
|
104
|
+
|
|
105
|
+
if provider_name == "ollama":
|
|
106
|
+
current_url = config.providers.ollama.base_url
|
|
107
|
+
model, ollama_base_url = _select_ollama_model(current_url)
|
|
108
|
+
config.providers.ollama.base_url = ollama_base_url
|
|
109
|
+
api_key = ""
|
|
110
|
+
custom_base_url = ""
|
|
111
|
+
else:
|
|
112
|
+
custom_base_url = ""
|
|
113
|
+
if provider_name == "custom":
|
|
114
|
+
current_url = config.providers.custom.base_url if config.providers.custom else ""
|
|
115
|
+
custom_base_url = typer.prompt("Base URL", default=current_url or "https://").strip().rstrip("/")
|
|
116
|
+
api_key = ""
|
|
117
|
+
if info.needs_api_key:
|
|
118
|
+
api_key = typer.prompt("API key").strip()
|
|
119
|
+
model = _select_model(info)
|
|
120
|
+
|
|
121
|
+
_apply_provider_to_config(config.providers, provider_name, api_key, custom_base_url)
|
|
122
|
+
_register_model(config, provider_name, model)
|
|
123
|
+
save_config(config, config_file)
|
|
124
|
+
|
|
125
|
+
return provider_name, model
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from priests.config.loader import load_config
|
|
11
|
+
|
|
12
|
+
profile_app = typer.Typer(help="Manage profiles.")
|
|
13
|
+
console = Console()
|
|
14
|
+
err_console = Console(stderr=True)
|
|
15
|
+
|
|
16
|
+
_PROFILE_MD_STUB = """\
|
|
17
|
+
# {name}
|
|
18
|
+
|
|
19
|
+
You are a helpful assistant.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
_PROFILE_TOML_STUB = """\
|
|
23
|
+
# Profile settings for {name}
|
|
24
|
+
|
|
25
|
+
# Set to false to disable memory loading and saving for this profile.
|
|
26
|
+
# Useful for tool profiles (dictionary, formatter, etc.) that don't need user memory.
|
|
27
|
+
memories = true
|
|
28
|
+
|
|
29
|
+
# Override the global memory limit for this profile (number of auto_YYYYMMDD.md files to keep).
|
|
30
|
+
# Uncomment to override.
|
|
31
|
+
# memories_limit = 50
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_RULES_MD_STUB = """\
|
|
35
|
+
# Rules
|
|
36
|
+
|
|
37
|
+
Be honest. Do not make things up.
|
|
38
|
+
Be concise unless the user asks for depth.
|
|
39
|
+
|
|
40
|
+
## Memory
|
|
41
|
+
|
|
42
|
+
Define what this profile should remember and how.
|
|
43
|
+
- Use `<memory type="user">` for stable facts about the user (name, preferences, background).
|
|
44
|
+
- Use `<memory type="note">` for role-important things (e.g. birthdays, key constraints).
|
|
45
|
+
- Use `<memory>` for daily observations and short-term context.
|
|
46
|
+
|
|
47
|
+
Replace this section with specific guidance for this profile's role.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@profile_app.command("list")
|
|
52
|
+
def profile_list(
|
|
53
|
+
profiles_dir: Annotated[Path | None, typer.Option("--profiles-dir", help="Profiles directory.")] = None,
|
|
54
|
+
config_file: Annotated[Path | None, typer.Option("--config", help="Path to priests.toml.")] = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""List available profiles."""
|
|
57
|
+
config = load_config(config_file)
|
|
58
|
+
root = (profiles_dir or config.paths.profiles_dir).expanduser()
|
|
59
|
+
|
|
60
|
+
if not root.exists():
|
|
61
|
+
console.print(f"[dim]Profiles directory not found: {root}[/dim]")
|
|
62
|
+
console.print("[dim]Use 'priests profile init NAME' to create your first profile.[/dim]")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
profiles = sorted(
|
|
66
|
+
d.name for d in root.iterdir() if d.is_dir() and (d / "PROFILE.md").exists()
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if not profiles:
|
|
70
|
+
console.print(f"[dim]No profiles found in {root}[/dim]")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
table = Table(show_header=False, box=None, pad_edge=False)
|
|
74
|
+
for name in profiles:
|
|
75
|
+
table.add_row(f" {name}")
|
|
76
|
+
|
|
77
|
+
console.print(table)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@profile_app.command("init")
|
|
81
|
+
def profile_init(
|
|
82
|
+
name: Annotated[str, typer.Argument(help="Profile name to create.")],
|
|
83
|
+
profiles_dir: Annotated[Path | None, typer.Option("--profiles-dir", help="Profiles directory.")] = None,
|
|
84
|
+
config_file: Annotated[Path | None, typer.Option("--config", help="Path to priests.toml.")] = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Scaffold a new profile directory."""
|
|
87
|
+
config = load_config(config_file)
|
|
88
|
+
root = (profiles_dir or config.paths.profiles_dir).expanduser()
|
|
89
|
+
profile_dir = root / name
|
|
90
|
+
|
|
91
|
+
if profile_dir.exists():
|
|
92
|
+
err_console.print(f"[red]Profile '{name}' already exists at {profile_dir}[/red]")
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
|
|
95
|
+
profile_dir.mkdir(parents=True)
|
|
96
|
+
(profile_dir / "PROFILE.md").write_text(_PROFILE_MD_STUB.format(name=name))
|
|
97
|
+
(profile_dir / "RULES.md").write_text(_RULES_MD_STUB)
|
|
98
|
+
(profile_dir / "CUSTOM.md").write_text("")
|
|
99
|
+
(profile_dir / "profile.toml").write_text(_PROFILE_TOML_STUB.format(name=name))
|
|
100
|
+
(profile_dir / "memories").mkdir()
|
|
101
|
+
|
|
102
|
+
console.print(f"[green]Created profile '{name}'[/green] at {profile_dir}")
|
|
103
|
+
console.print(f" Edit [bold]{profile_dir / 'PROFILE.md'}[/bold] to define the identity.")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from priests.cli.init_cmd import _fetch_ollama_models
|
|
11
|
+
from priests.config.loader import load_config
|
|
12
|
+
from priests.registry import get_provider, list_providers
|
|
13
|
+
|
|
14
|
+
providers_app = typer.Typer(help="List available providers and their models.")
|
|
15
|
+
console = Console()
|
|
16
|
+
err_console = Console(stderr=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@providers_app.callback(invoke_without_command=True)
|
|
20
|
+
def providers_list(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
config_file: Annotated[Path | None, typer.Option("--config", help="Path to priests.toml.")] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""List all supported providers."""
|
|
25
|
+
if ctx.invoked_subcommand is not None:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
config = load_config(config_file)
|
|
29
|
+
|
|
30
|
+
table = Table(show_header=True, header_style="bold", box=None, pad_edge=False, min_width=60)
|
|
31
|
+
table.add_column("Provider", style="bold", min_width=16)
|
|
32
|
+
table.add_column("Label")
|
|
33
|
+
table.add_column("Configured", justify="center", min_width=12)
|
|
34
|
+
|
|
35
|
+
for info in list_providers():
|
|
36
|
+
if info.name == "ollama":
|
|
37
|
+
configured = "[green]local[/green]"
|
|
38
|
+
else:
|
|
39
|
+
cfg = getattr(config.providers, info.name, None)
|
|
40
|
+
configured = "[green]yes[/green]" if (cfg and getattr(cfg, "api_key", None)) else "[dim]no[/dim]"
|
|
41
|
+
|
|
42
|
+
table.add_row(info.name, info.label, configured)
|
|
43
|
+
|
|
44
|
+
console.print(table)
|
|
45
|
+
console.print(f"\n[dim]Run [bold]priests providers <name>[/bold] to list models.[/dim]")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@providers_app.command("models")
|
|
49
|
+
def provider_models(
|
|
50
|
+
name: Annotated[str, typer.Argument(help="Provider name (e.g. openai, groq, ollama).")],
|
|
51
|
+
config_file: Annotated[Path | None, typer.Option("--config", help="Path to priests.toml.")] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""List known models for a provider."""
|
|
54
|
+
info = get_provider(name)
|
|
55
|
+
if info is None:
|
|
56
|
+
err_console.print(f"[red]Unknown provider:[/red] {name}")
|
|
57
|
+
err_console.print(f"[dim]Run [bold]priests providers[/bold] to see available providers.[/dim]")
|
|
58
|
+
raise typer.Exit(1)
|
|
59
|
+
|
|
60
|
+
console.print(f"[bold]{info.name}[/bold] — {info.label}\n")
|
|
61
|
+
|
|
62
|
+
if info.known_models is None:
|
|
63
|
+
# Ollama: fetch dynamically
|
|
64
|
+
config = load_config(config_file)
|
|
65
|
+
base_url = config.providers.ollama.base_url
|
|
66
|
+
console.print(f"[dim]Fetching models from {base_url} ...[/dim]")
|
|
67
|
+
models = _fetch_ollama_models(base_url)
|
|
68
|
+
if models is None:
|
|
69
|
+
err_console.print(f"[red]Could not connect to Ollama at {base_url}[/red]")
|
|
70
|
+
raise typer.Exit(1)
|
|
71
|
+
if not models:
|
|
72
|
+
console.print("[yellow]No models found.[/yellow] Pull one with [bold]ollama pull <model>[/bold].")
|
|
73
|
+
return
|
|
74
|
+
for m in models:
|
|
75
|
+
console.print(f" {m}")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if not info.known_models:
|
|
79
|
+
console.print("[dim]No curated model list — enter the model name manually.[/dim]")
|
|
80
|
+
if name == "openrouter":
|
|
81
|
+
console.print("[dim]Browse models at https://openrouter.ai/models[/dim]")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
for m in info.known_models:
|
|
85
|
+
console.print(f" {m}")
|
|
86
|
+
console.print(f"\n[dim]Use: [bold]priests run --provider {name} --model <model>[/bold][/dim]")
|