cometapi-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.
@@ -0,0 +1,229 @@
1
+ """Interactive multi-turn chat REPL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from prompt_toolkit import PromptSession
11
+ from prompt_toolkit.formatted_text import HTML
12
+ from prompt_toolkit.history import FileHistory
13
+ from rich.markdown import Markdown
14
+ from rich.panel import Panel
15
+ from rich.text import Text
16
+
17
+ from ..config import CONFIG_DIR, get_client, get_default_model
18
+ from ..console import console, err_console
19
+
20
+ SLASH_COMMANDS = {
21
+ "/model": "Switch model: /model <name>",
22
+ "/system": "Set system prompt: /system <prompt>",
23
+ "/clear": "Clear conversation history",
24
+ "/history": "Show conversation history",
25
+ "/save": "Save conversation: /save <file.json|file.md>",
26
+ "/tokens": "Show approximate token count",
27
+ "/help": "Show available commands",
28
+ "/exit": "Exit chat (also Ctrl+D)",
29
+ }
30
+
31
+
32
+ def _banner(model: str) -> Panel:
33
+ text = Text.assemble(
34
+ ("CometAPI Chat", "bold cyan"),
35
+ " • Model: ",
36
+ (model, "bold green"),
37
+ "\n",
38
+ ("Type /help for commands, /exit or Ctrl+D to quit.", "dim"),
39
+ )
40
+ return Panel(text, border_style="cyan", padding=(0, 1))
41
+
42
+
43
+ def _estimate_tokens(messages: list[dict[str, str]]) -> int:
44
+ """Rough token estimate (~4 chars per token)."""
45
+ total_chars = sum(len(m.get("content", "")) for m in messages)
46
+ return total_chars // 4
47
+
48
+
49
+ def _format_history(messages: list[dict[str, str]]) -> None:
50
+ for msg in messages:
51
+ role = msg["role"]
52
+ content = msg["content"]
53
+ if role == "system":
54
+ console.print(f"[bold magenta]system:[/] {content}")
55
+ elif role == "user":
56
+ console.print(f"[bold blue]you:[/] {content}")
57
+ elif role == "assistant":
58
+ console.print(f"[bold green]assistant:[/] {content}")
59
+
60
+
61
+ def _save_conversation(messages: list[dict[str, str]], filepath: str, model: str) -> None:
62
+ path = Path(filepath)
63
+ if path.suffix in (".json",):
64
+ data = {
65
+ "model": model,
66
+ "exported_at": datetime.now(tz=timezone.utc).isoformat(),
67
+ "messages": messages,
68
+ }
69
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
70
+ console.print(f"[green]Saved to {path}[/]")
71
+ elif path.suffix in (".md", ".markdown"):
72
+ lines = [f"# Chat Export — {model}\n"]
73
+ for msg in messages:
74
+ role = msg["role"].capitalize()
75
+ lines.append(f"## {role}\n")
76
+ lines.append(msg["content"])
77
+ lines.append("")
78
+ path.write_text("\n".join(lines), encoding="utf-8")
79
+ console.print(f"[green]Saved to {path}[/]")
80
+ else:
81
+ err_console.print("[yellow]Unsupported format. Use .json or .md[/]")
82
+
83
+
84
+ def _send_message(
85
+ client: object,
86
+ messages: list[dict[str, str]],
87
+ model: str,
88
+ stream: bool = True,
89
+ temperature: float | None = None,
90
+ max_tokens: int | None = None,
91
+ ) -> str:
92
+ """Send messages to the API and return the assistant response."""
93
+ kwargs: dict = {"model": model, "messages": messages, "stream": stream}
94
+ if temperature is not None:
95
+ kwargs["temperature"] = temperature
96
+ if max_tokens is not None:
97
+ kwargs["max_tokens"] = max_tokens
98
+
99
+ if stream:
100
+ response = client.chat.completions.create(**kwargs) # type: ignore[union-attr]
101
+ full_text = ""
102
+ for chunk in response:
103
+ if not chunk.choices:
104
+ continue
105
+ delta = chunk.choices[0].delta.content or ""
106
+ sys.stdout.write(delta)
107
+ sys.stdout.flush()
108
+ full_text += delta
109
+ sys.stdout.write("\n")
110
+ sys.stdout.flush()
111
+ return full_text
112
+ else:
113
+ response = client.chat.completions.create(**kwargs) # type: ignore[union-attr]
114
+ text = response.choices[0].message.content or ""
115
+ console.print(Markdown(text))
116
+ return text
117
+
118
+
119
+ def run_chat_repl(
120
+ model: str | None = None,
121
+ system: str | None = None,
122
+ temperature: float | None = None,
123
+ max_tokens: int | None = None,
124
+ stream: bool = True,
125
+ ) -> None:
126
+ """Run interactive multi-turn chat REPL."""
127
+ client = get_client()
128
+ current_model = model or get_default_model()
129
+
130
+ messages: list[dict[str, str]] = []
131
+ if system:
132
+ messages.append({"role": "system", "content": system})
133
+
134
+ console.print(_banner(current_model))
135
+
136
+ # Set up prompt_toolkit session with persistent history
137
+ history_file = CONFIG_DIR / "chat_history"
138
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
139
+ session: PromptSession[str] = PromptSession(
140
+ history=FileHistory(str(history_file)),
141
+ multiline=False,
142
+ )
143
+
144
+ while True:
145
+ try:
146
+ user_input = session.prompt(HTML("<b><ansiblue>you → </ansiblue></b>")).strip()
147
+ except (EOFError, KeyboardInterrupt):
148
+ console.print("\n[dim]Goodbye![/]")
149
+ break
150
+
151
+ if not user_input:
152
+ continue
153
+
154
+ # Handle slash commands
155
+ if user_input.startswith("/"):
156
+ cmd_parts = user_input.split(maxsplit=1)
157
+ cmd = cmd_parts[0].lower()
158
+ arg = cmd_parts[1] if len(cmd_parts) > 1 else ""
159
+
160
+ if cmd in ("/exit", "/quit", "/q"):
161
+ console.print("[dim]Goodbye![/]")
162
+ break
163
+
164
+ elif cmd == "/help":
165
+ for slash_cmd, desc in SLASH_COMMANDS.items():
166
+ console.print(f" [cyan]{slash_cmd:<12}[/] {desc}")
167
+
168
+ elif cmd == "/model":
169
+ if arg:
170
+ current_model = arg
171
+ console.print(f"[green]Model switched to {current_model}[/]")
172
+ else:
173
+ console.print(f"[cyan]Current model: {current_model}[/]")
174
+
175
+ elif cmd == "/system":
176
+ if arg:
177
+ # Remove existing system message if any
178
+ messages = [m for m in messages if m["role"] != "system"]
179
+ messages.insert(0, {"role": "system", "content": arg})
180
+ console.print(f"[green]System prompt set ({len(arg)} chars).[/]")
181
+ else:
182
+ sys_msg = next((m["content"] for m in messages if m["role"] == "system"), None)
183
+ if sys_msg:
184
+ console.print(f"[cyan]System prompt:[/] {sys_msg}")
185
+ else:
186
+ console.print("[dim]No system prompt set.[/]")
187
+
188
+ elif cmd == "/clear":
189
+ sys_msg = next((m for m in messages if m["role"] == "system"), None)
190
+ messages = [sys_msg] if sys_msg else []
191
+ console.print("[green]History cleared.[/]")
192
+
193
+ elif cmd == "/history":
194
+ if not messages:
195
+ console.print("[dim]No messages yet.[/]")
196
+ else:
197
+ _format_history(messages)
198
+
199
+ elif cmd == "/save":
200
+ if arg:
201
+ _save_conversation(messages, arg, current_model)
202
+ else:
203
+ err_console.print("[yellow]Usage: /save <filename.json|filename.md>[/]")
204
+
205
+ elif cmd == "/tokens":
206
+ tokens = _estimate_tokens(messages)
207
+ console.print(f"[cyan]~{tokens} tokens[/] in {len(messages)} messages")
208
+
209
+ else:
210
+ err_console.print(f"[yellow]Unknown command: {cmd}. Type /help for available commands.[/]")
211
+
212
+ continue
213
+
214
+ # Regular message — send to API
215
+ messages.append({"role": "user", "content": user_input})
216
+
217
+ try:
218
+ reply = _send_message(
219
+ client, messages, current_model,
220
+ stream=stream, temperature=temperature, max_tokens=max_tokens,
221
+ )
222
+ messages.append({"role": "assistant", "content": reply})
223
+ except KeyboardInterrupt:
224
+ console.print("\n[dim]Response interrupted.[/]")
225
+ # Remove the user message that didn't get a response
226
+ messages.pop()
227
+ except Exception as e:
228
+ err_console.print(f"[red bold]Error:[/] {e}")
229
+ # Keep the user message in history for retry
@@ -0,0 +1,174 @@
1
+ """Config management and init wizard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from rich.panel import Panel
9
+ from rich.prompt import Confirm, Prompt
10
+
11
+ from ..config import CONFIG_FILE, VALID_KEYS, load_config, mask_secret, save_config
12
+ from ..console import console, err_console
13
+ from ..errors import handle_errors
14
+ from ..formatters import OutputFormat, output, resolve_format
15
+
16
+ config_app = typer.Typer(name="config", help="Manage CLI configuration.")
17
+
18
+
19
+ @handle_errors
20
+ def init(ctx: typer.Context) -> None:
21
+ """Interactive setup wizard — configure API keys and preferences."""
22
+ from ..config import get_default_model
23
+
24
+ console.print(
25
+ Panel(
26
+ "[bold blue]CometAPI CLI Setup[/bold blue]\n\n"
27
+ "Let's configure your CometAPI client.\n"
28
+ "You can change these settings later with [bold]cometapi config set[/bold].",
29
+ title="🚀 Welcome",
30
+ border_style="blue",
31
+ )
32
+ )
33
+
34
+ console.print(
35
+ Panel(
36
+ "[bold yellow]⚠ Security Notice[/bold yellow]\n\n"
37
+ "Your API key and access token are sensitive credentials.\n"
38
+ "• Never share keys in public repos, logs, or chat messages.\n"
39
+ "• You are responsible for all usage and charges incurred with your keys.\n\n"
40
+ "[bold]Create your API key at:[/bold] https://www.cometapi.com/console/token\n"
41
+ "[bold]Get your access token at:[/bold] https://www.cometapi.com/console/personal",
42
+ border_style="yellow",
43
+ )
44
+ )
45
+
46
+ cfg = load_config()
47
+
48
+ # API Key
49
+ current_key = cfg.get("api_key", "")
50
+ default_display = mask_secret(current_key) if current_key else None
51
+ api_key = Prompt.ask(
52
+ "[bold]API Key[/bold] (COMETAPI_KEY)",
53
+ default=default_display,
54
+ )
55
+ if api_key and api_key != default_display:
56
+ cfg["api_key"] = api_key
57
+
58
+ # Validate connectivity
59
+ if cfg.get("api_key"):
60
+ console.print("[dim]Testing API connectivity...[/]", end=" ")
61
+ try:
62
+ from cometapi_cli.client import CometClient
63
+
64
+ test_client = CometClient(api_key=cfg["api_key"])
65
+ test_client.models.list()
66
+ console.print("[green]✓ Connected[/green]")
67
+ except Exception:
68
+ console.print("[yellow]⚠ Could not verify (check later with cometapi doctor)[/yellow]")
69
+
70
+ # Access Token (optional)
71
+ console.print()
72
+ current_token = cfg.get("access_token", "")
73
+ if Confirm.ask(
74
+ "[bold]Configure access token?[/bold] (for account/stats commands)",
75
+ default=bool(current_token),
76
+ ):
77
+ default_token_display = mask_secret(current_token) if current_token else None
78
+ access_token = Prompt.ask(
79
+ "[bold]Access Token[/bold] (COMETAPI_ACCESS_TOKEN)",
80
+ default=default_token_display,
81
+ )
82
+ if access_token and access_token != default_token_display:
83
+ cfg["access_token"] = access_token
84
+
85
+ # Default model
86
+ console.print()
87
+ default_model = Prompt.ask(
88
+ "[bold]Default model[/bold]",
89
+ default=cfg.get("default_model", get_default_model()),
90
+ )
91
+ cfg["default_model"] = default_model
92
+
93
+ # Save
94
+ save_config(cfg)
95
+
96
+ console.print()
97
+ console.print(
98
+ Panel(
99
+ f"[green]Configuration saved to[/green] {CONFIG_FILE}\n"
100
+ "[dim]File permissions: 0600 (owner read/write only)[/dim]\n\n"
101
+ "[dim]Credentials are stored locally. Never commit this file to version control.[/dim]\n\n"
102
+ "Next steps:\n"
103
+ " [bold]cometapi doctor[/bold] — verify your setup\n"
104
+ " [bold]cometapi models[/bold] — list available models\n"
105
+ " [bold]cometapi chat[/bold] — start chatting",
106
+ title="✅ Setup Complete",
107
+ border_style="green",
108
+ )
109
+ )
110
+
111
+
112
+ @config_app.command("show")
113
+ @handle_errors
114
+ def config_show(
115
+ ctx: typer.Context,
116
+ output_format: Annotated[OutputFormat | None, typer.Option("--format", "-f", help="Output format.")] = None,
117
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
118
+ ) -> None:
119
+ """Display current configuration (secrets are masked)."""
120
+ fmt = resolve_format(ctx, json_output, output_format)
121
+ cfg = load_config()
122
+
123
+ if not cfg:
124
+ console.print("[dim]No configuration file found. Run [bold]cometapi init[/bold] to create one.[/]")
125
+ return
126
+
127
+ display = {}
128
+ for key, value in cfg.items():
129
+ if key in ("api_key", "access_token"):
130
+ display[key] = mask_secret(str(value))
131
+ else:
132
+ display[key] = value
133
+
134
+ output(display, fmt, title="CometAPI Configuration")
135
+
136
+
137
+ @config_app.command("set")
138
+ @handle_errors
139
+ def config_set(
140
+ key: Annotated[str, typer.Argument(help="Configuration key.")],
141
+ value: Annotated[str, typer.Argument(help="Configuration value.")],
142
+ ) -> None:
143
+ """Set a configuration value."""
144
+ if key not in VALID_KEYS:
145
+ err_console.print(f"[red bold]Error:[/] Unknown key '{key}'. Valid keys: {', '.join(sorted(VALID_KEYS))}")
146
+ raise typer.Exit(code=2)
147
+
148
+ cfg = load_config()
149
+ cfg[key] = value
150
+ save_config(cfg)
151
+ display_value = mask_secret(value) if key in ("api_key", "access_token") else value
152
+ console.print(f"[green]✓[/green] Set [bold]{key}[/bold] = {display_value}")
153
+
154
+
155
+ @config_app.command("unset")
156
+ @handle_errors
157
+ def config_unset(
158
+ key: Annotated[str, typer.Argument(help="Configuration key to remove.")],
159
+ ) -> None:
160
+ """Remove a configuration value."""
161
+ cfg = load_config()
162
+ if key not in cfg:
163
+ err_console.print(f"[yellow]Key '{key}' is not set.[/yellow]")
164
+ return
165
+
166
+ del cfg[key]
167
+ save_config(cfg)
168
+ console.print(f"[green]✓[/green] Removed [bold]{key}[/bold]")
169
+
170
+
171
+ @config_app.command("path")
172
+ def config_path() -> None:
173
+ """Show the configuration file path."""
174
+ console.print(str(CONFIG_FILE))
@@ -0,0 +1,144 @@
1
+ """Doctor command — diagnostics and health checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from ..config import CONFIG_FILE, get_client, load_config, mask_secret
12
+ from ..console import console
13
+ from ..errors import ConfigMissing, ExitCode, handle_errors
14
+ from ..formatters import OutputFormat, output, resolve_format
15
+
16
+
17
+ def _check_mark(ok: bool) -> str:
18
+ return "[green]✓[/green]" if ok else "[red]✗[/red]"
19
+
20
+
21
+ def _warn_mark() -> str:
22
+ return "[yellow]⚠[/yellow]"
23
+
24
+
25
+ @handle_errors
26
+ def doctor(
27
+ ctx: typer.Context,
28
+ output_format: Annotated[OutputFormat | None, typer.Option("--format", "-f", help="Output format.")] = None,
29
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
30
+ ) -> None:
31
+ """Check CLI configuration and API connectivity."""
32
+ from .. import __version__
33
+
34
+ fmt = resolve_format(ctx, json_output, output_format)
35
+ cfg = load_config()
36
+ results: dict = {}
37
+ all_ok = True
38
+
39
+ # Check 1: Config file
40
+ config_exists = CONFIG_FILE.exists()
41
+ results["config_file"] = {
42
+ "status": "ok" if config_exists else "warning",
43
+ "path": str(CONFIG_FILE),
44
+ }
45
+
46
+ # Check 2: API key
47
+ api_key = os.environ.get("COMETAPI_KEY") or cfg.get("api_key", "")
48
+ key_source = "environment" if os.environ.get("COMETAPI_KEY") else ("config" if cfg.get("api_key") else "none")
49
+ has_key = bool(api_key)
50
+ results["api_key"] = {
51
+ "status": "ok" if has_key else "error",
52
+ "masked": mask_secret(api_key) if api_key else "not set",
53
+ "source": key_source,
54
+ }
55
+ if not has_key:
56
+ all_ok = False
57
+
58
+ # Check 3: Connectivity
59
+ if has_key:
60
+ try:
61
+ client = get_client()
62
+ client.models.list()
63
+ base_url = os.environ.get("COMETAPI_BASE_URL") or cfg.get("base_url", "https://api.cometapi.com/v1")
64
+ results["connectivity"] = {"status": "ok", "base_url": base_url}
65
+ except ConfigMissing:
66
+ results["connectivity"] = {"status": "error", "message": "API key required"}
67
+ all_ok = False
68
+ except Exception as e:
69
+ exc_type = type(e).__name__
70
+ if "AuthenticationError" in exc_type:
71
+ results["connectivity"] = {"status": "warning", "message": "connected but auth failed"}
72
+ else:
73
+ results["connectivity"] = {"status": "error", "message": str(e)[:100]}
74
+ all_ok = False
75
+ else:
76
+ results["connectivity"] = {"status": "skipped", "message": "no API key"}
77
+
78
+ # Check 4: Access token
79
+ access_token = os.environ.get("COMETAPI_ACCESS_TOKEN") or cfg.get("access_token", "")
80
+ results["access_token"] = {
81
+ "status": "ok" if access_token else "warning",
82
+ "masked": mask_secret(access_token) if access_token else "not set (optional)",
83
+ }
84
+
85
+ # Check 5: Versions
86
+ results["versions"] = {
87
+ "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
88
+ "cometapi_cli": __version__,
89
+ }
90
+
91
+ # JSON / YAML output
92
+ if fmt in (OutputFormat.JSON, OutputFormat.YAML):
93
+ output(results, fmt)
94
+ if not all_ok:
95
+ raise typer.Exit(code=ExitCode.CONFIG_MISSING)
96
+ return
97
+
98
+ # Rich formatted output
99
+ console.print()
100
+ console.print("[bold]CometAPI Doctor[/bold]")
101
+ console.print("─" * 40)
102
+
103
+ # Config file
104
+ mark = _check_mark(config_exists) if config_exists else _warn_mark()
105
+ status_text = "found" if config_exists else "not found"
106
+ console.print(f" {mark} Config file: {status_text} ({CONFIG_FILE})")
107
+
108
+ # API key
109
+ mark = _check_mark(has_key)
110
+ key_display = mask_secret(api_key) if api_key else "not set"
111
+ console.print(f" {mark} API key: {key_display} [dim]({key_source})[/dim]")
112
+ if not has_key:
113
+ console.print(" [dim]Create one at: https://www.cometapi.com/console/token[/dim]")
114
+
115
+ # Connectivity
116
+ conn = results["connectivity"]
117
+ if conn["status"] == "ok":
118
+ console.print(f" {_check_mark(True)} API connectivity: {conn['base_url']}")
119
+ elif conn["status"] == "warning":
120
+ console.print(f" {_warn_mark()} API connectivity: {conn['message']}")
121
+ elif conn["status"] == "skipped":
122
+ console.print(" [dim]- API connectivity: skipped (no API key)[/dim]")
123
+ else:
124
+ console.print(f" {_check_mark(False)} API connectivity: {conn.get('message', 'failed')}")
125
+
126
+ # Access token
127
+ token_ok = bool(access_token)
128
+ mark = _check_mark(token_ok) if token_ok else _warn_mark()
129
+ token_display = mask_secret(access_token) if access_token else "not set (optional)"
130
+ console.print(f" {mark} Access token: {token_display}")
131
+ if not token_ok:
132
+ console.print(" [dim]Get one at: https://www.cometapi.com/console/personal[/dim]")
133
+
134
+ # Versions
135
+ v = results["versions"]
136
+ ver_line = f"Python {v['python']} · cometapi-cli {v['cometapi_cli']}"
137
+ console.print(f" {_check_mark(True)} {ver_line}")
138
+
139
+ console.print()
140
+ if all_ok:
141
+ console.print("[green bold]All checks passed![/green bold]")
142
+ else:
143
+ console.print("[yellow]Some checks need attention. Run [bold]cometapi init[/bold] to configure.[/yellow]")
144
+ raise typer.Exit(code=ExitCode.CONFIG_MISSING)