anythink 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.
- anythink/__init__.py +10 -0
- anythink/app/__init__.py +1 -0
- anythink/app/chat.py +231 -0
- anythink/app/context.py +75 -0
- anythink/cli.py +333 -0
- anythink/commands/__init__.py +1 -0
- anythink/commands/base.py +36 -0
- anythink/commands/handlers.py +376 -0
- anythink/commands/registry.py +71 -0
- anythink/config/__init__.py +1 -0
- anythink/config/manager.py +155 -0
- anythink/config/models.py +102 -0
- anythink/config/personas.py +93 -0
- anythink/config/schema.py +26 -0
- anythink/exceptions.py +57 -0
- anythink/files/__init__.py +1 -0
- anythink/files/reader.py +161 -0
- anythink/keys/__init__.py +1 -0
- anythink/keys/manager.py +97 -0
- anythink/plugins/__init__.py +1 -0
- anythink/plugins/manager.py +71 -0
- anythink/plugins/models.py +17 -0
- anythink/providers/__init__.py +25 -0
- anythink/providers/anthropic.py +138 -0
- anythink/providers/base.py +128 -0
- anythink/providers/cohere.py +123 -0
- anythink/providers/gemini.py +152 -0
- anythink/providers/groq.py +121 -0
- anythink/providers/lm_studio.py +28 -0
- anythink/providers/mistral.py +124 -0
- anythink/providers/ollama.py +130 -0
- anythink/providers/openai.py +150 -0
- anythink/providers/registry.py +67 -0
- anythink/py.typed +0 -0
- anythink/search/__init__.py +1 -0
- anythink/search/base.py +35 -0
- anythink/search/duckduckgo.py +58 -0
- anythink/search/registry.py +48 -0
- anythink/search/serpapi.py +67 -0
- anythink/session/__init__.py +1 -0
- anythink/session/manager.py +82 -0
- anythink/session/models.py +115 -0
- anythink/ui/__init__.py +1 -0
- anythink/ui/banner.py +28 -0
- anythink/ui/console.py +31 -0
- anythink/ui/input.py +30 -0
- anythink/ui/renderer.py +59 -0
- anythink/ui/status.py +46 -0
- anythink/ui/theme.py +80 -0
- anythink-0.1.0.dist-info/METADATA +290 -0
- anythink-0.1.0.dist-info/RECORD +54 -0
- anythink-0.1.0.dist-info/WHEEL +4 -0
- anythink-0.1.0.dist-info/entry_points.txt +19 -0
- anythink-0.1.0.dist-info/licenses/LICENSE +21 -0
anythink/__init__.py
ADDED
anythink/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Anythink application orchestration package."""
|
anythink/app/chat.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Interactive chat loop orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from anythink import __version__
|
|
11
|
+
from anythink.app.context import AppContext
|
|
12
|
+
from anythink.commands.registry import CommandRegistry
|
|
13
|
+
from anythink.config.models import ModelAlias
|
|
14
|
+
from anythink.exceptions import AnythinkError, SearchError
|
|
15
|
+
from anythink.files.reader import FileAttachment, ImageAttachment, TextAttachment
|
|
16
|
+
from anythink.providers.base import BaseProvider, ChatMessage, ContentPart, ImagePart, TextPart, TokenUsage
|
|
17
|
+
from anythink.search.base import SearchResult
|
|
18
|
+
from anythink.session.models import Session
|
|
19
|
+
from anythink.ui.banner import print_banner
|
|
20
|
+
from anythink.ui.input import make_prompt_session
|
|
21
|
+
from anythink.ui.renderer import StreamRenderer
|
|
22
|
+
from anythink.ui.status import ContextStatusBar
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ChatState:
|
|
27
|
+
"""Mutable per-session chat state."""
|
|
28
|
+
|
|
29
|
+
provider: BaseProvider
|
|
30
|
+
model_id: str
|
|
31
|
+
context_window: int
|
|
32
|
+
history: list[ChatMessage] = field(default_factory=list)
|
|
33
|
+
total_tokens_used: int = 0
|
|
34
|
+
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
35
|
+
session_name: str = ""
|
|
36
|
+
pending_attachments: list[FileAttachment] = field(default_factory=list)
|
|
37
|
+
search_enabled: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _format_search_results(results: list[SearchResult], query: str) -> str:
|
|
41
|
+
lines = [f"[Web Search: {query!r}]"]
|
|
42
|
+
for i, r in enumerate(results, 1):
|
|
43
|
+
lines.append(f"{i}. {r.title} — {r.url}")
|
|
44
|
+
if r.snippet:
|
|
45
|
+
lines.append(f" {r.snippet}")
|
|
46
|
+
return "\n".join(lines)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ChatApp:
|
|
50
|
+
"""Interactive chat loop: banner → prompt → stream → repeat."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
ctx: AppContext,
|
|
55
|
+
command_registry: CommandRegistry | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.ctx = ctx
|
|
58
|
+
self._registry = command_registry or CommandRegistry.from_entry_points()
|
|
59
|
+
|
|
60
|
+
async def run(self) -> int:
|
|
61
|
+
"""Run the interactive chat loop. Returns an exit code (0 = normal exit)."""
|
|
62
|
+
ctx = self.ctx
|
|
63
|
+
|
|
64
|
+
state = self._resolve_state()
|
|
65
|
+
if state is None:
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
renderer = StreamRenderer(console=ctx.console, theme=ctx.theme)
|
|
69
|
+
status_bar = ContextStatusBar(theme=ctx.theme, max_tokens=state.context_window)
|
|
70
|
+
session = make_prompt_session(slash_commands=self._registry.names())
|
|
71
|
+
|
|
72
|
+
print_banner(ctx.console, ctx.theme, __version__)
|
|
73
|
+
ctx.console.print(
|
|
74
|
+
Text(
|
|
75
|
+
f" Provider: {state.provider.name} • Model: {state.model_id}",
|
|
76
|
+
style=ctx.theme.muted,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
ctx.console.print()
|
|
80
|
+
|
|
81
|
+
while True:
|
|
82
|
+
if state.total_tokens_used > 0:
|
|
83
|
+
ctx.console.print(status_bar.render(state.total_tokens_used))
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
user_input: str = await session.prompt_async([("class:prompt", "You: ")])
|
|
87
|
+
except KeyboardInterrupt:
|
|
88
|
+
ctx.console.print(Text("\nInterrupted.", style=ctx.theme.muted))
|
|
89
|
+
break
|
|
90
|
+
except EOFError:
|
|
91
|
+
break
|
|
92
|
+
|
|
93
|
+
stripped = user_input.strip()
|
|
94
|
+
if not stripped:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
# Slash commands
|
|
98
|
+
if stripped.startswith("/"):
|
|
99
|
+
result = await self._registry.dispatch(stripped, ctx, state)
|
|
100
|
+
if result.message:
|
|
101
|
+
style = ctx.theme.error if result.error else ctx.theme.secondary
|
|
102
|
+
ctx.console.print(Text(result.message, style=style))
|
|
103
|
+
if result.should_exit:
|
|
104
|
+
break
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Legacy bare "exit" / "quit" shortcuts
|
|
108
|
+
if stripped.lower() in ("exit", "quit"):
|
|
109
|
+
ctx.console.print(Text("Goodbye!", style=ctx.theme.primary))
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
# Accumulate extra content parts (search results + file attachments)
|
|
113
|
+
extra_parts: list[ContentPart] = []
|
|
114
|
+
|
|
115
|
+
if state.search_enabled:
|
|
116
|
+
backend = ctx.search_registry.get_available(ctx.config.search_provider)
|
|
117
|
+
if backend is not None:
|
|
118
|
+
try:
|
|
119
|
+
search_results = await backend.search(stripped)
|
|
120
|
+
if search_results:
|
|
121
|
+
extra_parts.append(
|
|
122
|
+
TextPart(_format_search_results(search_results, stripped))
|
|
123
|
+
)
|
|
124
|
+
except SearchError as exc:
|
|
125
|
+
ctx.console.print(
|
|
126
|
+
Text(f" [Search failed: {exc.user_message}]", style=ctx.theme.error)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
for att in state.pending_attachments:
|
|
130
|
+
if isinstance(att, TextAttachment):
|
|
131
|
+
extra_parts.append(TextPart(f"[File: {att.filename}]\n{att.content}"))
|
|
132
|
+
elif isinstance(att, ImageAttachment):
|
|
133
|
+
extra_parts.append(att.image_part)
|
|
134
|
+
state.pending_attachments.clear()
|
|
135
|
+
|
|
136
|
+
if extra_parts:
|
|
137
|
+
if stripped:
|
|
138
|
+
extra_parts.append(TextPart(stripped))
|
|
139
|
+
user_msg = ChatMessage(role="user", content=extra_parts)
|
|
140
|
+
else:
|
|
141
|
+
user_msg = ChatMessage(role="user", content=stripped)
|
|
142
|
+
state.history.append(user_msg)
|
|
143
|
+
|
|
144
|
+
ctx.console.print(Text("\nAssistant: ", style=ctx.theme.accent))
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
chunk_stream = state.provider.stream_chat(
|
|
148
|
+
messages=state.history,
|
|
149
|
+
model=state.model_id,
|
|
150
|
+
)
|
|
151
|
+
full_text, usage = await renderer.stream(chunk_stream)
|
|
152
|
+
except AnythinkError as exc:
|
|
153
|
+
ctx.console.print(Text(f"\n[Error] {exc.user_message}", style=ctx.theme.error))
|
|
154
|
+
state.history.pop()
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
state.history.append(ChatMessage(role="assistant", content=full_text))
|
|
158
|
+
if usage:
|
|
159
|
+
state.total_tokens_used = usage.total_tokens
|
|
160
|
+
|
|
161
|
+
ctx.console.print()
|
|
162
|
+
|
|
163
|
+
# Autosave when configured and conversation is non-empty
|
|
164
|
+
if ctx.config.session_autosave and state.history:
|
|
165
|
+
session = Session(
|
|
166
|
+
id=state.session_id,
|
|
167
|
+
provider=state.provider.name,
|
|
168
|
+
model_id=state.model_id,
|
|
169
|
+
messages=list(state.history),
|
|
170
|
+
name=state.session_name,
|
|
171
|
+
)
|
|
172
|
+
ctx.session_manager.save(session)
|
|
173
|
+
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
def _resolve_state(self) -> ChatState | None:
|
|
177
|
+
"""Resolve the active provider+model from config. Returns None and prints an error on failure."""
|
|
178
|
+
ctx = self.ctx
|
|
179
|
+
|
|
180
|
+
alias_name = ctx.config.default_model_alias
|
|
181
|
+
if not alias_name:
|
|
182
|
+
ctx.console.print(
|
|
183
|
+
Text(
|
|
184
|
+
"No default model configured. Run `anythink setup` to get started.",
|
|
185
|
+
style=ctx.theme.error,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
alias: ModelAlias | None = ctx.model_registry.get(alias_name)
|
|
191
|
+
if alias is None:
|
|
192
|
+
ctx.console.print(
|
|
193
|
+
Text(
|
|
194
|
+
f"Model alias '{alias_name}' not found. Run `anythink model list` to see available aliases.",
|
|
195
|
+
style=ctx.theme.error,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
api_key = ctx.key_manager.get_key(alias.provider)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
provider = ctx.provider_registry.instantiate(
|
|
204
|
+
alias.provider,
|
|
205
|
+
api_key=api_key,
|
|
206
|
+
base_url=ctx.config.local_servers.get(alias.provider),
|
|
207
|
+
)
|
|
208
|
+
except AnythinkError as exc:
|
|
209
|
+
ctx.console.print(
|
|
210
|
+
Text(
|
|
211
|
+
f"Failed to load provider '{alias.provider}': {exc.user_message}",
|
|
212
|
+
style=ctx.theme.error,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
if api_key is None and provider.requires_api_key:
|
|
218
|
+
ctx.console.print(
|
|
219
|
+
Text(
|
|
220
|
+
f"No API key for '{alias.provider}'. Run `anythink keys add {alias.provider}` to add one.",
|
|
221
|
+
style=ctx.theme.error,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
return ChatState(
|
|
227
|
+
provider=provider,
|
|
228
|
+
model_id=alias.model_id,
|
|
229
|
+
context_window=alias.context_window,
|
|
230
|
+
search_enabled=ctx.config.web_search_enabled,
|
|
231
|
+
)
|
anythink/app/context.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""AppContext — dependency-injection container for the Anythink app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import IO
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from anythink.config.manager import ConfigManager, Paths, _resolve_paths
|
|
11
|
+
from anythink.config.models import ModelRegistry
|
|
12
|
+
from anythink.config.personas import PersonaManager
|
|
13
|
+
from anythink.config.schema import AppConfig
|
|
14
|
+
from anythink.keys.manager import KeyManager
|
|
15
|
+
from anythink.plugins.manager import PluginManager
|
|
16
|
+
from anythink.providers.registry import ProviderRegistry
|
|
17
|
+
from anythink.search.registry import SearchRegistry
|
|
18
|
+
from anythink.session.manager import SessionManager
|
|
19
|
+
from anythink.ui.console import make_console
|
|
20
|
+
from anythink.ui.theme import Theme, get_theme
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AppContext:
|
|
25
|
+
"""Mutable container for every Anythink sub-system.
|
|
26
|
+
|
|
27
|
+
Constructed once at startup and threaded through the call stack.
|
|
28
|
+
No module-level globals; tests inject Console(file=StringIO()) here.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
config: AppConfig
|
|
32
|
+
paths: Paths
|
|
33
|
+
console: Console
|
|
34
|
+
theme: Theme
|
|
35
|
+
config_manager: ConfigManager
|
|
36
|
+
key_manager: KeyManager
|
|
37
|
+
provider_registry: ProviderRegistry
|
|
38
|
+
model_registry: ModelRegistry
|
|
39
|
+
persona_manager: PersonaManager
|
|
40
|
+
session_manager: SessionManager
|
|
41
|
+
search_registry: SearchRegistry
|
|
42
|
+
plugin_manager: PluginManager
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def create(
|
|
46
|
+
cls,
|
|
47
|
+
paths: Paths | None = None,
|
|
48
|
+
console_file: IO[str] | None = None,
|
|
49
|
+
) -> AppContext:
|
|
50
|
+
"""Build a fully wired AppContext from scratch."""
|
|
51
|
+
resolved = paths or _resolve_paths()
|
|
52
|
+
resolved.ensure_dirs()
|
|
53
|
+
|
|
54
|
+
config_manager = ConfigManager(paths=resolved)
|
|
55
|
+
config = config_manager.load()
|
|
56
|
+
theme = get_theme(config.active_theme)
|
|
57
|
+
console = make_console(theme, file=console_file)
|
|
58
|
+
key_manager = KeyManager(paths=resolved)
|
|
59
|
+
|
|
60
|
+
return cls(
|
|
61
|
+
config=config,
|
|
62
|
+
paths=resolved,
|
|
63
|
+
console=console,
|
|
64
|
+
theme=theme,
|
|
65
|
+
config_manager=config_manager,
|
|
66
|
+
key_manager=key_manager,
|
|
67
|
+
provider_registry=ProviderRegistry(),
|
|
68
|
+
model_registry=ModelRegistry(path=resolved.models_file),
|
|
69
|
+
persona_manager=PersonaManager(path=resolved.personas_file),
|
|
70
|
+
session_manager=SessionManager(sessions_dir=resolved.sessions_dir),
|
|
71
|
+
search_registry=SearchRegistry.from_entry_points(
|
|
72
|
+
api_keys={"serpapi": key_manager.get_key("serpapi")}
|
|
73
|
+
),
|
|
74
|
+
plugin_manager=PluginManager(),
|
|
75
|
+
)
|
anythink/cli.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Anythink CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from typing_extensions import Annotated
|
|
9
|
+
|
|
10
|
+
from anythink import __version__
|
|
11
|
+
from anythink.app.chat import ChatApp
|
|
12
|
+
from anythink.app.context import AppContext
|
|
13
|
+
from anythink.config.manager import ConfigManager
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="anythink",
|
|
17
|
+
help="Think anything. Ask anything. — A universal AI-powered CLI chatbot.",
|
|
18
|
+
no_args_is_help=False,
|
|
19
|
+
add_completion=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
keys_app = typer.Typer(name="keys", help="Manage API keys in the OS keychain.")
|
|
23
|
+
model_app = typer.Typer(name="model", help="Manage model aliases.")
|
|
24
|
+
plugins_app = typer.Typer(name="plugins", help="Manage Anythink plugins.")
|
|
25
|
+
|
|
26
|
+
app.add_typer(keys_app, name="keys")
|
|
27
|
+
app.add_typer(model_app, name="model")
|
|
28
|
+
app.add_typer(plugins_app, name="plugins")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _version_callback(value: bool) -> None:
|
|
32
|
+
if value:
|
|
33
|
+
typer.echo(f"anythink {__version__}")
|
|
34
|
+
raise typer.Exit()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.callback(invoke_without_command=True)
|
|
38
|
+
def main(
|
|
39
|
+
ctx: typer.Context,
|
|
40
|
+
version: Annotated[
|
|
41
|
+
bool,
|
|
42
|
+
typer.Option("--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit."),
|
|
43
|
+
] = False,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Start an interactive chat session."""
|
|
46
|
+
if ctx.invoked_subcommand is not None:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
config_manager = ConfigManager()
|
|
50
|
+
if not config_manager.is_configured():
|
|
51
|
+
typer.echo("Anythink is not configured yet. Run `anythink setup` to get started.")
|
|
52
|
+
raise typer.Exit(1)
|
|
53
|
+
|
|
54
|
+
app_ctx = AppContext.create(paths=config_manager.paths)
|
|
55
|
+
exit_code = asyncio.run(ChatApp(app_ctx).run())
|
|
56
|
+
raise typer.Exit(exit_code)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command("setup")
|
|
60
|
+
def setup_wizard() -> None:
|
|
61
|
+
"""Run the interactive first-run setup wizard."""
|
|
62
|
+
typer.echo("Setup wizard coming in Phase 11.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── keys sub-commands ──────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
@keys_app.command("list")
|
|
68
|
+
def keys_list() -> None:
|
|
69
|
+
"""List all configured providers and their key status."""
|
|
70
|
+
from anythink.keys.manager import KeyManager
|
|
71
|
+
|
|
72
|
+
km = KeyManager(paths=ConfigManager().paths)
|
|
73
|
+
providers = km.list_providers()
|
|
74
|
+
if not providers:
|
|
75
|
+
typer.echo("No API keys configured. Use `anythink keys add <provider>` to add one.")
|
|
76
|
+
return
|
|
77
|
+
typer.echo("Configured API keys:")
|
|
78
|
+
for p in providers:
|
|
79
|
+
typer.echo(f" {p:<20} [set]")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@keys_app.command("add")
|
|
83
|
+
def keys_add(provider: str = typer.Argument(..., help="Provider name (e.g. groq, openai).")) -> None:
|
|
84
|
+
"""Add an API key for a provider."""
|
|
85
|
+
from anythink.exceptions import KeychainError
|
|
86
|
+
from anythink.keys.manager import KeyManager
|
|
87
|
+
|
|
88
|
+
km = KeyManager(paths=ConfigManager().paths)
|
|
89
|
+
api_key: str = typer.prompt(f"Enter API key for '{provider}'", hide_input=True)
|
|
90
|
+
if not api_key.strip():
|
|
91
|
+
typer.echo("Error: API key cannot be empty.", err=True)
|
|
92
|
+
raise typer.Exit(1)
|
|
93
|
+
try:
|
|
94
|
+
km.set_key(provider, api_key.strip())
|
|
95
|
+
typer.echo(f"API key for '{provider}' saved successfully.")
|
|
96
|
+
except KeychainError as exc:
|
|
97
|
+
typer.echo(f"Error: {exc.user_message}", err=True)
|
|
98
|
+
raise typer.Exit(1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@keys_app.command("show")
|
|
102
|
+
def keys_show(provider: str = typer.Argument(..., help="Provider name.")) -> None:
|
|
103
|
+
"""Show the stored key for a provider (masked)."""
|
|
104
|
+
from anythink.exceptions import KeychainError
|
|
105
|
+
from anythink.keys.manager import KeyManager
|
|
106
|
+
|
|
107
|
+
km = KeyManager(paths=ConfigManager().paths)
|
|
108
|
+
try:
|
|
109
|
+
key = km.get_key(provider)
|
|
110
|
+
except KeychainError as exc:
|
|
111
|
+
typer.echo(f"Error: {exc.user_message}", err=True)
|
|
112
|
+
raise typer.Exit(1)
|
|
113
|
+
if key is None:
|
|
114
|
+
typer.echo(f"No API key found for '{provider}'.")
|
|
115
|
+
raise typer.Exit(1)
|
|
116
|
+
masked = key[:4] + "*" * max(len(key) - 8, 0) + key[-4:] if len(key) > 8 else "****"
|
|
117
|
+
typer.echo(f"Key for '{provider}': {masked}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@keys_app.command("update")
|
|
121
|
+
def keys_update(provider: str = typer.Argument(..., help="Provider name.")) -> None:
|
|
122
|
+
"""Replace the stored key for a provider."""
|
|
123
|
+
from anythink.exceptions import KeychainError
|
|
124
|
+
from anythink.keys.manager import KeyManager
|
|
125
|
+
|
|
126
|
+
km = KeyManager(paths=ConfigManager().paths)
|
|
127
|
+
if not km.has_key(provider):
|
|
128
|
+
typer.echo(f"No existing key for '{provider}'. Use `anythink keys add {provider}` to add one.")
|
|
129
|
+
raise typer.Exit(1)
|
|
130
|
+
api_key: str = typer.prompt(f"Enter new API key for '{provider}'", hide_input=True)
|
|
131
|
+
if not api_key.strip():
|
|
132
|
+
typer.echo("Error: API key cannot be empty.", err=True)
|
|
133
|
+
raise typer.Exit(1)
|
|
134
|
+
try:
|
|
135
|
+
km.set_key(provider, api_key.strip())
|
|
136
|
+
typer.echo(f"API key for '{provider}' updated successfully.")
|
|
137
|
+
except KeychainError as exc:
|
|
138
|
+
typer.echo(f"Error: {exc.user_message}", err=True)
|
|
139
|
+
raise typer.Exit(1)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@keys_app.command("delete")
|
|
143
|
+
def keys_delete(
|
|
144
|
+
provider: str = typer.Argument(..., help="Provider name."),
|
|
145
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Remove a stored key from the keychain."""
|
|
148
|
+
from anythink.exceptions import KeychainError
|
|
149
|
+
from anythink.keys.manager import KeyManager
|
|
150
|
+
|
|
151
|
+
if not yes and not typer.confirm(f"Delete API key for '{provider}'?"):
|
|
152
|
+
typer.echo("Cancelled.")
|
|
153
|
+
raise typer.Exit(0)
|
|
154
|
+
km = KeyManager(paths=ConfigManager().paths)
|
|
155
|
+
try:
|
|
156
|
+
km.delete_key(provider)
|
|
157
|
+
typer.echo(f"API key for '{provider}' deleted.")
|
|
158
|
+
except KeychainError as exc:
|
|
159
|
+
typer.echo(f"Error: {exc.user_message}", err=True)
|
|
160
|
+
raise typer.Exit(1)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@keys_app.command("test")
|
|
164
|
+
def keys_test(provider: str = typer.Argument(..., help="Provider name.")) -> None:
|
|
165
|
+
"""Validate the stored key by making a test API call."""
|
|
166
|
+
from anythink.exceptions import AnythinkError
|
|
167
|
+
from anythink.keys.manager import KeyManager
|
|
168
|
+
from anythink.providers.registry import ProviderRegistry
|
|
169
|
+
|
|
170
|
+
km = KeyManager(paths=ConfigManager().paths)
|
|
171
|
+
api_key = km.get_key(provider)
|
|
172
|
+
if api_key is None:
|
|
173
|
+
typer.echo(f"No API key found for '{provider}'. Add one with: anythink keys add {provider}")
|
|
174
|
+
raise typer.Exit(1)
|
|
175
|
+
|
|
176
|
+
pr = ProviderRegistry()
|
|
177
|
+
try:
|
|
178
|
+
prov = pr.instantiate(provider, api_key=api_key)
|
|
179
|
+
except AnythinkError as exc:
|
|
180
|
+
typer.echo(f"Error: {exc.user_message}", err=True)
|
|
181
|
+
raise typer.Exit(1)
|
|
182
|
+
|
|
183
|
+
typer.echo(f"Testing connection to '{provider}'...")
|
|
184
|
+
ok: bool = asyncio.run(prov.test_connection())
|
|
185
|
+
if ok:
|
|
186
|
+
typer.echo(f" Connection to '{provider}' is working.")
|
|
187
|
+
else:
|
|
188
|
+
typer.echo(f" Connection to '{provider}' failed.", err=True)
|
|
189
|
+
raise typer.Exit(1)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── model sub-commands ─────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
@model_app.command("list")
|
|
195
|
+
def model_list() -> None:
|
|
196
|
+
"""List all configured model aliases."""
|
|
197
|
+
from anythink.config.models import ModelRegistry
|
|
198
|
+
|
|
199
|
+
registry = ModelRegistry(path=ConfigManager().paths.models_file)
|
|
200
|
+
aliases = registry.list_all()
|
|
201
|
+
if not aliases:
|
|
202
|
+
typer.echo("No model aliases configured. Use `anythink model add` to add one.")
|
|
203
|
+
return
|
|
204
|
+
header = f" {'Alias':<20} {'Provider':<14} {'Model ID':<30} {'Context':>10} Vision"
|
|
205
|
+
typer.echo(header)
|
|
206
|
+
typer.echo(" " + "-" * (len(header) - 2))
|
|
207
|
+
for a in aliases:
|
|
208
|
+
vision = "yes" if a.supports_vision else "no"
|
|
209
|
+
typer.echo(f" {a.alias:<20} {a.provider:<14} {a.model_id:<30} {a.context_window:>10,} {vision}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@model_app.command("add")
|
|
213
|
+
def model_add() -> None:
|
|
214
|
+
"""Add a new model alias interactively."""
|
|
215
|
+
from anythink.config.models import ModelAlias, ModelRegistry
|
|
216
|
+
|
|
217
|
+
registry = ModelRegistry(path=ConfigManager().paths.models_file)
|
|
218
|
+
|
|
219
|
+
alias: str = typer.prompt("Alias name (your personal name for this model)")
|
|
220
|
+
if registry.exists(alias):
|
|
221
|
+
typer.echo(f"Alias '{alias}' already exists. Remove it first with: anythink model remove {alias}", err=True)
|
|
222
|
+
raise typer.Exit(1)
|
|
223
|
+
|
|
224
|
+
provider: str = typer.prompt("Provider (groq, openai, anthropic, gemini, ollama, ...)")
|
|
225
|
+
model_id: str = typer.prompt("Model ID (e.g. llama3-8b-8192, gpt-4o)")
|
|
226
|
+
context_raw: str = typer.prompt("Context window size (tokens)", default="4096")
|
|
227
|
+
try:
|
|
228
|
+
context_window = int(context_raw)
|
|
229
|
+
except ValueError:
|
|
230
|
+
typer.echo("Error: context window must be an integer.", err=True)
|
|
231
|
+
raise typer.Exit(1)
|
|
232
|
+
|
|
233
|
+
supports_vision: bool = typer.confirm("Does this model support image input?", default=False)
|
|
234
|
+
|
|
235
|
+
registry.add(ModelAlias(
|
|
236
|
+
alias=alias,
|
|
237
|
+
provider=provider,
|
|
238
|
+
model_id=model_id,
|
|
239
|
+
context_window=context_window,
|
|
240
|
+
supports_vision=supports_vision,
|
|
241
|
+
))
|
|
242
|
+
typer.echo(f"Model alias '{alias}' added successfully.")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@model_app.command("remove")
|
|
246
|
+
def model_remove(
|
|
247
|
+
alias: str = typer.Argument(..., help="Alias name to remove."),
|
|
248
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Remove a model alias."""
|
|
251
|
+
from anythink.config.models import ModelRegistry
|
|
252
|
+
from anythink.exceptions import ConfigError
|
|
253
|
+
|
|
254
|
+
registry = ModelRegistry(path=ConfigManager().paths.models_file)
|
|
255
|
+
if not registry.exists(alias):
|
|
256
|
+
typer.echo(f"Alias '{alias}' not found.", err=True)
|
|
257
|
+
raise typer.Exit(1)
|
|
258
|
+
|
|
259
|
+
if not yes and not typer.confirm(f"Remove model alias '{alias}'?"):
|
|
260
|
+
typer.echo("Cancelled.")
|
|
261
|
+
raise typer.Exit(0)
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
registry.remove(alias)
|
|
265
|
+
typer.echo(f"Model alias '{alias}' removed.")
|
|
266
|
+
except ConfigError as exc:
|
|
267
|
+
typer.echo(f"Error: {exc.user_message}", err=True)
|
|
268
|
+
raise typer.Exit(1)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ── plugins sub-commands ──────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
@plugins_app.command("list")
|
|
274
|
+
def plugins_list() -> None:
|
|
275
|
+
"""List all installed Anythink plugins."""
|
|
276
|
+
from anythink.plugins.manager import PluginManager
|
|
277
|
+
|
|
278
|
+
pm = PluginManager()
|
|
279
|
+
plugins = pm.list_plugins()
|
|
280
|
+
if not plugins:
|
|
281
|
+
typer.echo("No plugins installed.")
|
|
282
|
+
return
|
|
283
|
+
for p in plugins:
|
|
284
|
+
desc = f" — {p.description}" if p.description else ""
|
|
285
|
+
typer.echo(f"{p.name} {p.version}{desc}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@plugins_app.command("info")
|
|
289
|
+
def plugins_info(package: str = typer.Argument(..., help="Plugin package name.")) -> None:
|
|
290
|
+
"""Show details about an installed plugin."""
|
|
291
|
+
from anythink.plugins.manager import PluginManager
|
|
292
|
+
|
|
293
|
+
pm = PluginManager()
|
|
294
|
+
p = pm.get_plugin(package)
|
|
295
|
+
if p is None:
|
|
296
|
+
typer.echo(f"Plugin '{package}' not found.", err=True)
|
|
297
|
+
raise typer.Exit(1)
|
|
298
|
+
typer.echo(f"Name: {p.name}")
|
|
299
|
+
typer.echo(f"Version: {p.version}")
|
|
300
|
+
typer.echo(f"Description: {p.description}")
|
|
301
|
+
typer.echo(f"Author: {p.author}")
|
|
302
|
+
typer.echo(f"Groups: {', '.join(p.entry_point_groups)}")
|
|
303
|
+
if p.homepage:
|
|
304
|
+
typer.echo(f"Homepage: {p.homepage}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@plugins_app.command("install")
|
|
308
|
+
def plugins_install(package: str = typer.Argument(..., help="PyPI package name to install.")) -> None:
|
|
309
|
+
"""Install a plugin package from PyPI."""
|
|
310
|
+
from anythink.plugins.manager import PluginManager
|
|
311
|
+
|
|
312
|
+
pm = PluginManager()
|
|
313
|
+
typer.echo(f"Installing '{package}'...")
|
|
314
|
+
ok, output = pm.install(package)
|
|
315
|
+
if ok:
|
|
316
|
+
typer.echo(f"Installed '{package}'. Restart anythink to load it.")
|
|
317
|
+
else:
|
|
318
|
+
typer.echo(f"Installation failed:\n{output[:500]}", err=True)
|
|
319
|
+
raise typer.Exit(1)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@plugins_app.command("remove")
|
|
323
|
+
def plugins_remove(package: str = typer.Argument(..., help="Plugin package name to remove.")) -> None:
|
|
324
|
+
"""Remove an installed plugin package."""
|
|
325
|
+
from anythink.plugins.manager import PluginManager
|
|
326
|
+
|
|
327
|
+
pm = PluginManager()
|
|
328
|
+
ok, output = pm.remove(package)
|
|
329
|
+
if ok:
|
|
330
|
+
typer.echo(f"Removed '{package}'. Restart anythink to apply changes.")
|
|
331
|
+
else:
|
|
332
|
+
typer.echo(f"Removal failed:\n{output[:500]}", err=True)
|
|
333
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Slash command system for Anythink."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Core types for the slash command system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from anythink.app.chat import ChatState
|
|
10
|
+
from anythink.app.context import AppContext
|
|
11
|
+
from anythink.commands.registry import CommandRegistry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CommandResult:
|
|
16
|
+
"""Return value from a slash command handler."""
|
|
17
|
+
|
|
18
|
+
should_exit: bool = False # True → break out of the chat loop
|
|
19
|
+
message: str | None = None # optional text to print after handling
|
|
20
|
+
error: bool = False # True → print message in error style
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
CommandHandler = Callable[
|
|
24
|
+
["AppContext", str, "ChatState", "CommandRegistry"],
|
|
25
|
+
Awaitable[CommandResult],
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SlashCommand:
|
|
31
|
+
"""A registered slash command."""
|
|
32
|
+
|
|
33
|
+
name: str # e.g. "help" (no leading slash)
|
|
34
|
+
description: str
|
|
35
|
+
handler: CommandHandler
|
|
36
|
+
usage: str = field(default="") # e.g. "/help [command]"
|