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.
Files changed (54) hide show
  1. anythink/__init__.py +10 -0
  2. anythink/app/__init__.py +1 -0
  3. anythink/app/chat.py +231 -0
  4. anythink/app/context.py +75 -0
  5. anythink/cli.py +333 -0
  6. anythink/commands/__init__.py +1 -0
  7. anythink/commands/base.py +36 -0
  8. anythink/commands/handlers.py +376 -0
  9. anythink/commands/registry.py +71 -0
  10. anythink/config/__init__.py +1 -0
  11. anythink/config/manager.py +155 -0
  12. anythink/config/models.py +102 -0
  13. anythink/config/personas.py +93 -0
  14. anythink/config/schema.py +26 -0
  15. anythink/exceptions.py +57 -0
  16. anythink/files/__init__.py +1 -0
  17. anythink/files/reader.py +161 -0
  18. anythink/keys/__init__.py +1 -0
  19. anythink/keys/manager.py +97 -0
  20. anythink/plugins/__init__.py +1 -0
  21. anythink/plugins/manager.py +71 -0
  22. anythink/plugins/models.py +17 -0
  23. anythink/providers/__init__.py +25 -0
  24. anythink/providers/anthropic.py +138 -0
  25. anythink/providers/base.py +128 -0
  26. anythink/providers/cohere.py +123 -0
  27. anythink/providers/gemini.py +152 -0
  28. anythink/providers/groq.py +121 -0
  29. anythink/providers/lm_studio.py +28 -0
  30. anythink/providers/mistral.py +124 -0
  31. anythink/providers/ollama.py +130 -0
  32. anythink/providers/openai.py +150 -0
  33. anythink/providers/registry.py +67 -0
  34. anythink/py.typed +0 -0
  35. anythink/search/__init__.py +1 -0
  36. anythink/search/base.py +35 -0
  37. anythink/search/duckduckgo.py +58 -0
  38. anythink/search/registry.py +48 -0
  39. anythink/search/serpapi.py +67 -0
  40. anythink/session/__init__.py +1 -0
  41. anythink/session/manager.py +82 -0
  42. anythink/session/models.py +115 -0
  43. anythink/ui/__init__.py +1 -0
  44. anythink/ui/banner.py +28 -0
  45. anythink/ui/console.py +31 -0
  46. anythink/ui/input.py +30 -0
  47. anythink/ui/renderer.py +59 -0
  48. anythink/ui/status.py +46 -0
  49. anythink/ui/theme.py +80 -0
  50. anythink-0.1.0.dist-info/METADATA +290 -0
  51. anythink-0.1.0.dist-info/RECORD +54 -0
  52. anythink-0.1.0.dist-info/WHEEL +4 -0
  53. anythink-0.1.0.dist-info/entry_points.txt +19 -0
  54. anythink-0.1.0.dist-info/licenses/LICENSE +21 -0
anythink/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Anythink — Think anything. Ask anything."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("anythink")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["__version__"]
@@ -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
+ )
@@ -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]"