Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,16 @@ source ~/.bashrc # if you use bash
```bash
cp config.example.yml config.yml
```
Then open `config.yml` and set your API key. The easiest way is to create a `.env` file:
Then add your API key with the `auth` command (opencode-style):
```bash
echo "OLLAMA_API_KEY=your-key-here" > .env
motion auth login ollama-cloud # prompts for your key, stored locally
motion auth login openai
motion auth login claude
motion auth list # see which providers have keys
motion auth logout ollama-cloud # remove a key
```
> **No need to add models one by one.** The harness ships with a built-in catalog of Anthropic, OpenAI, and Ollama Cloud models. Just add the API key and they're all available.
Keys are stored in `~/.config/motion-harness/auth.json` (0600 perms) — never in `config.yml`. You can also use env vars (`OLLAMA_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`). Lookup order: **auth store → env var → config.yml**.
> **No need to add models one by one.** The harness ships with a built-in catalog of Anthropic, OpenAI, and Ollama Cloud models. Just add the API key and they're all available — press `Ctrl+O` to browse/search them.

**5. Launch:**
```bash
Expand Down Expand Up @@ -121,7 +126,8 @@ A high-performance terminal interface built with `Textual`, designed for daily-d
| :-- | :-- |
| `Ctrl+T` | Cycle theme |
| `Ctrl+B` | Toggle context panel |
| `Ctrl+O` | Switch model (provider → model) |
| `Ctrl+O` | Switch model (browse/search all models) |
| `Ctrl+R` (in model dialog) | Refresh the model list (scrapes latest Ollama Cloud models) |
| `Ctrl+K` | Command palette (all commands) |
| `Ctrl+E` | Open external editor for the message |
| `F8` / `Ctrl+Shift+T` | Toggle interaction trace panel |
Expand All @@ -132,15 +138,26 @@ A high-performance terminal interface built with `Textual`, designed for daily-d
| `Shift+Enter` (chat input) | New line |
| `↑` / `↓` (chat input) | Prompt history |
| `/skill save <name>` | Save last reply as a skill |
| `/auth list` | List stored API keys |
| `/auth login <provider>` | Store an API key for a provider |
| `/auth logout <provider>` | Remove a stored API key |
| `Ctrl+C` / `Ctrl+X` | Cancel current request (does not quit) |
| `Ctrl+Q` | Quit (kills the process) |

**Dashboard integration**: one-click open to the admin dashboard at `https://localhost:7860/`.
**CLI commands**:
| Command | Action |
| :-- | :-- |
| `motion` | Launch the TUI |
| `motion --chat` | Launch the REPL chat |
| `motion --list` | List providers/models |
| `motion --provider <id>` | Launch with a specific provider/model |
| `motion auth login <provider>` | Store an API key (prompts, hidden input) |
| `motion auth logout <provider>` | Remove a stored API key |
| `motion auth list` | List which providers have keys |

### ⚠️ Known Limitations (v2 TUI)
- Trace persistence is per-session (not yet written to disk).
- Theme contrast validation is manual; the bundled themes are tuned for readability but very-low-contrast combinations are not auto-corrected.
- Shortcut help overlay (`?`) reflects MainScreen + ChatPane bindings; the Tasks/Skills/KB/Memory/Settings panes are defined but not mounted in the current focused-chat layout.
- Clipboard copy falls back to inserting the response into the input box when the terminal lacks clipboard support.
- File/document ingestion (image / PDF / DOCX / XLSX) is on the roadmap — see [Roadmap](docs/roadmap.md).

Expand Down
20 changes: 0 additions & 20 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,6 @@ providers:
temperature: 0.7
max_tokens: 4096

claude-3-5:
name: "Claude 3.5 Sonnet"
endpoint: "https://api.anthropic.com"
api_key: null # Set via ANTHROPIC_API_KEY env var
provider_type: "cloud"
options:
model: "claude-3-5-sonnet-20241022"
temperature: 0.7
max_tokens: 4096

local-llama:
name: "Llama 3 (Local)"
endpoint: "http://localhost:11434"
Expand All @@ -68,16 +58,6 @@ providers:
temperature: 0.6
embed_model: "nomic-embed-text"

gpt-4o:
name: "GPT-4o"
endpoint: "https://api.openai.com/v1"
api_key: null # Set via OPENAI_API_KEY env var
provider_type: "cloud"
options:
model: "gpt-4o"
temperature: 0.8
max_tokens: 4096

# Embedding configuration
# Local providers use Ollama /api/embeddings; cloud-only setups
# fall back to a deterministic hash-based vector.
Expand Down
63 changes: 63 additions & 0 deletions core/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Local API-key store (opencode-style auth).

Keys are stored in a per-user JSON file with ``0600`` permissions, keeping
secrets out of ``config.yml`` and the shell environment. Lookup order when
resolving a key for a provider: **auth store → environment variable →
config.yml**.
"""

import json
import os
from pathlib import Path
from typing import Dict, Optional

AUTH_DIR = Path(os.getenv("MOTION_AUTH_DIR", str(Path.home() / ".config" / "motion-harness")))
AUTH_FILE = AUTH_DIR / "auth.json"


def _load() -> Dict[str, str]:
if not AUTH_FILE.exists():
return {}
try:
with open(AUTH_FILE, "r") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception:
return {}


def _save(data: Dict[str, str]) -> None:
AUTH_DIR.mkdir(parents=True, exist_ok=True)
with open(AUTH_FILE, "w") as f:
json.dump(data, f, indent=2)
try:
os.chmod(AUTH_FILE, 0o600)
except Exception:
pass


def set_key(provider: str, key: str) -> None:
"""Store (or overwrite) an API key for a provider."""
data = _load()
data[provider] = key
_save(data)


def get_key(provider: str) -> Optional[str]:
"""Return the stored key for a provider, or None."""
return _load().get(provider)


def remove_key(provider: str) -> bool:
"""Remove a stored key. Returns True if a key was removed."""
data = _load()
if provider in data:
del data[provider]
_save(data)
return True
return False


def list_keys() -> Dict[str, str]:
"""Return all stored keys as {provider: key}."""
return _load()
14 changes: 11 additions & 3 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from dataclasses import dataclass

from core.catalog import merge_catalog
from core import auth

@dataclass
class AppConfig:
Expand Down Expand Up @@ -66,14 +67,17 @@ def get_provider_config(self, provider_id: str) -> Dict[str, Any]:
if not config:
raise ValueError(f"Unknown provider: {base_id}")

# Resolve api_key from environment
# Resolve api_key: auth store → env var → config.yml
env_key = f"{base_id.replace('-', '_').upper()}_API_KEY"
env_val = os.environ.get(env_key)
if not env_val:
prefix = base_id.split('-')[0].upper()
generic_key = f"{prefix}_API_KEY"
env_val = os.environ.get(generic_key)
if env_val:
stored = auth.get_key(base_id)
if stored:
config = {**config, "api_key": stored}
elif env_val:
config = {**config, "api_key": env_val}

# Resolve model: explicit model_name > default_model > options.model
Expand Down Expand Up @@ -111,7 +115,11 @@ def has_api_key(self, provider_id: str) -> bool:
providers = self.data.get("providers", {})
cfg = providers.get(base_id, {})

# Check config file first
# Check auth store first
if auth.get_key(base_id):
return True

# Check config file
config_key = cfg.get("api_key")
if config_key:
return True
Expand Down
8 changes: 8 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ operate.
(`context_query`) to the model on every turn to reduce hallucination
- [x] Remove "Thinking…" placeholder from the response area
- [x] Full theme-aware response rendering (code syntax colors follow theme)
- [x] **Auth store (opencode-style)** — `motion auth login/logout/list` + `/auth`
commands; keys stored in `~/.config/motion-harness/auth.json` (0600), never
in `config.yml`. Lookup order: auth store → env var → config.yml.
- [x] **Model dialog (Ctrl+O)** — lists every model individually (all cloud
models appear once a key is set) with live search + `Ctrl+R` refresh
(scrapes the latest Ollama Cloud model list).
- [x] Remove dead code — unused Tasks/Skills/KB/Memory/Settings panes and the
hardcoded dashboard admin key.

## Phase 2 — Document & File Ingestion (next)

Expand Down
61 changes: 60 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from core.config import ConfigManager
from core.caveman import CavemanProtocol
from core.learning import SkillSynthesizer, Trajectory
from core import auth
from memory.db import MemoryDB, EMBEDDING_DIM
from memory.retriever import HybridRetriever
import asyncio
Expand Down Expand Up @@ -237,6 +238,54 @@ async def interactive_chat(provider_id: str | None = None):
agent.memory.close()


def cmd_auth(args) -> None:
"""Handle `motion auth login|logout|list`."""
action = args.auth_action
if action == "list":
keys = auth.list_keys()
if not keys:
print("No API keys stored.")
return
print("Stored API keys:")
for provider, key in sorted(keys.items()):
masked = f"{key[:4]}…{key[-4:]}" if len(key) > 8 else "…"
print(f" {provider:20s} {masked}")
return

if action == "login":
provider = args.auth_provider
if not provider:
print("Usage: motion auth login <provider>")
return
cm = ConfigManager()
try:
cm.get_provider_config(provider)
except ValueError as e:
print(f"Unknown provider: {provider}")
print("Available providers:")
for pid, name, models, is_default, has_key in cm.list_providers():
print(f" {pid:20s} {name}")
return
import getpass
key = getpass.getpass(f"API key for {provider}: ").strip()
if not key:
print("No key entered; aborting.")
return
auth.set_key(provider, key)
print(f"Saved API key for {provider} → {auth.AUTH_FILE}")
return

if action == "logout":
provider = args.auth_provider
if not provider:
print("Usage: motion auth logout <provider>")
return
if auth.remove_key(provider):
print(f"Removed API key for {provider}.")
else:
print(f"No stored key for {provider}.")


if __name__ == "__main__":
import sys
import argparse
Expand All @@ -246,9 +295,19 @@ async def interactive_chat(provider_id: str | None = None):
parser.add_argument("--list", action="store_true", help="List available providers")
parser.add_argument("--provider", type=str, default=None, help="Provider to use (e.g. ollama-cloud, ollama-cloud/gemma4:31b, claude-3-5)")
parser.add_argument("--chat", action="store_true", help="Launch in chat REPL mode instead of TUI")
sub = parser.add_subparsers(dest="command")
auth_parser = sub.add_parser("auth", help="Manage provider API keys")
auth_sub = auth_parser.add_subparsers(dest="auth_action", required=True)
auth_sub.add_parser("list", help="List stored API keys")
login_p = auth_sub.add_parser("login", help="Store an API key for a provider")
login_p.add_argument("auth_provider", nargs="?", help="Provider id (e.g. ollama-cloud)")
logout_p = auth_sub.add_parser("logout", help="Remove a stored API key")
logout_p.add_argument("auth_provider", nargs="?", help="Provider id (e.g. ollama-cloud)")
args = parser.parse_args()

if args.list:
if args.command == "auth":
cmd_auth(args)
elif args.list:
list_providers()
elif args.test:
asyncio.run(test_compression())
Expand Down
Loading
Loading