add mcp and online search
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_ACCOUNT_STATE_DIR = Path('.port_sessions')
|
||||
DEFAULT_ACCOUNT_STATE_FILE = DEFAULT_ACCOUNT_STATE_DIR / 'account_runtime.json'
|
||||
ACCOUNT_MANIFEST_PATHS = (
|
||||
Path('.claw-account.json'),
|
||||
Path('.claude/account.json'),
|
||||
Path('.claude/auth.json'),
|
||||
)
|
||||
CREDENTIAL_ENV_VARS = (
|
||||
'OPENAI_API_KEY',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
'LITELLM_MASTER_KEY',
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountProfile:
|
||||
name: str
|
||||
provider: str
|
||||
identity: str
|
||||
source_manifest: str
|
||||
description: str | None = None
|
||||
org: str | None = None
|
||||
auth_mode: str | None = None
|
||||
api_base: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountSessionState:
|
||||
provider: str
|
||||
identity: str
|
||||
logged_in: bool
|
||||
logged_in_at: str
|
||||
profile_name: str | None = None
|
||||
org: str | None = None
|
||||
auth_mode: str | None = None
|
||||
api_base: str | None = None
|
||||
source_manifest: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountStatusReport:
|
||||
logged_in: bool
|
||||
detail: str
|
||||
provider: str | None = None
|
||||
identity: str | None = None
|
||||
profile_name: str | None = None
|
||||
org: str | None = None
|
||||
auth_mode: str | None = None
|
||||
api_base: str | None = None
|
||||
source_manifest: str | None = None
|
||||
manifest_count: int = 0
|
||||
profile_count: int = 0
|
||||
credential_env_vars: tuple[str, ...] = ()
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def as_text(self) -> str:
|
||||
lines = [
|
||||
f'logged_in={self.logged_in}',
|
||||
f'detail={self.detail}',
|
||||
f'manifest_count={self.manifest_count}',
|
||||
f'profile_count={self.profile_count}',
|
||||
]
|
||||
if self.provider:
|
||||
lines.append(f'provider={self.provider}')
|
||||
if self.identity:
|
||||
lines.append(f'identity={self.identity}')
|
||||
if self.profile_name:
|
||||
lines.append(f'profile={self.profile_name}')
|
||||
if self.org:
|
||||
lines.append(f'org={self.org}')
|
||||
if self.auth_mode:
|
||||
lines.append(f'auth_mode={self.auth_mode}')
|
||||
if self.api_base:
|
||||
lines.append(f'api_base={self.api_base}')
|
||||
if self.source_manifest:
|
||||
lines.append(f'source_manifest={self.source_manifest}')
|
||||
if self.credential_env_vars:
|
||||
lines.append('credential_env=' + ','.join(self.credential_env_vars))
|
||||
if self.metadata:
|
||||
for key, value in sorted(self.metadata.items()):
|
||||
lines.append(f'metadata.{key}={value}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountRuntime:
|
||||
cwd: Path
|
||||
profiles: tuple[AccountProfile, ...] = field(default_factory=tuple)
|
||||
manifests: tuple[str, ...] = field(default_factory=tuple)
|
||||
state_path: Path = field(default_factory=lambda: DEFAULT_ACCOUNT_STATE_FILE.resolve())
|
||||
active_session: AccountSessionState | None = None
|
||||
history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
||||
credential_env_vars: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
@classmethod
|
||||
def from_workspace(
|
||||
cls,
|
||||
cwd: Path,
|
||||
additional_working_directories: tuple[str, ...] = (),
|
||||
) -> 'AccountRuntime':
|
||||
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
|
||||
profiles: list[AccountProfile] = []
|
||||
for manifest_path in manifest_paths:
|
||||
profiles.extend(_load_profiles_from_manifest(manifest_path))
|
||||
state_path = (cwd.resolve() / DEFAULT_ACCOUNT_STATE_FILE).resolve()
|
||||
payload = _load_state_payload(state_path)
|
||||
active_session = _session_from_payload(payload.get('active_session'))
|
||||
history_payload = payload.get('history')
|
||||
history = tuple(
|
||||
item for item in history_payload if isinstance(item, dict)
|
||||
) if isinstance(history_payload, list) else ()
|
||||
return cls(
|
||||
cwd=cwd.resolve(),
|
||||
profiles=tuple(profiles),
|
||||
manifests=tuple(str(path) for path in manifest_paths),
|
||||
state_path=state_path,
|
||||
active_session=active_session,
|
||||
history=history,
|
||||
credential_env_vars=_detect_credential_env_vars(),
|
||||
)
|
||||
|
||||
def has_account_state(self) -> bool:
|
||||
return bool(self.profiles or self.active_session is not None or self.credential_env_vars)
|
||||
|
||||
def list_profiles(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> tuple[AccountProfile, ...]:
|
||||
profiles = self.profiles
|
||||
if query:
|
||||
needle = query.lower()
|
||||
profiles = tuple(
|
||||
profile
|
||||
for profile in profiles
|
||||
if needle in profile.name.lower()
|
||||
or needle in profile.provider.lower()
|
||||
or needle in profile.identity.lower()
|
||||
or needle in (profile.org or '').lower()
|
||||
)
|
||||
if limit is not None and limit >= 0:
|
||||
profiles = profiles[:limit]
|
||||
return profiles
|
||||
|
||||
def get_profile(self, name_or_identity: str) -> AccountProfile | None:
|
||||
needle = name_or_identity.strip().lower()
|
||||
if not needle:
|
||||
return None
|
||||
for profile in self.profiles:
|
||||
if profile.name.lower() == needle or profile.identity.lower() == needle:
|
||||
return profile
|
||||
return None
|
||||
|
||||
def login(
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
provider: str | None = None,
|
||||
auth_mode: str | None = None,
|
||||
) -> AccountStatusReport:
|
||||
profile = self.get_profile(target)
|
||||
if profile is not None:
|
||||
session = AccountSessionState(
|
||||
provider=profile.provider,
|
||||
identity=profile.identity,
|
||||
logged_in=True,
|
||||
logged_in_at=_utc_now(),
|
||||
profile_name=profile.name,
|
||||
org=profile.org,
|
||||
auth_mode=profile.auth_mode,
|
||||
api_base=profile.api_base,
|
||||
source_manifest=profile.source_manifest,
|
||||
metadata=dict(profile.metadata),
|
||||
)
|
||||
detail = f'Activated account profile {profile.name}'
|
||||
else:
|
||||
session = AccountSessionState(
|
||||
provider=(provider or 'custom').strip() or 'custom',
|
||||
identity=target.strip(),
|
||||
logged_in=True,
|
||||
logged_in_at=_utc_now(),
|
||||
auth_mode=(auth_mode or 'token').strip() or 'token',
|
||||
metadata={'ephemeral': True},
|
||||
)
|
||||
detail = f'Activated ephemeral account identity {target.strip()}'
|
||||
self.active_session = session
|
||||
self._append_history(
|
||||
{
|
||||
'action': 'login',
|
||||
'provider': session.provider,
|
||||
'identity': session.identity,
|
||||
'profile_name': session.profile_name,
|
||||
'logged_in_at': session.logged_in_at,
|
||||
}
|
||||
)
|
||||
self._persist_state()
|
||||
return self.current_report(detail=detail)
|
||||
|
||||
def logout(self, *, reason: str = 'manual_logout') -> AccountStatusReport:
|
||||
previous = self.active_session
|
||||
detail = (
|
||||
f'Logged out {previous.identity}'
|
||||
if previous is not None
|
||||
else 'No active account session was present.'
|
||||
)
|
||||
if previous is not None:
|
||||
self._append_history(
|
||||
{
|
||||
'action': 'logout',
|
||||
'provider': previous.provider,
|
||||
'identity': previous.identity,
|
||||
'profile_name': previous.profile_name,
|
||||
'reason': reason,
|
||||
'logged_out_at': _utc_now(),
|
||||
}
|
||||
)
|
||||
self.active_session = None
|
||||
self._persist_state()
|
||||
return AccountStatusReport(
|
||||
logged_in=False,
|
||||
detail=detail,
|
||||
manifest_count=len(self.manifests),
|
||||
profile_count=len(self.profiles),
|
||||
credential_env_vars=self.credential_env_vars,
|
||||
)
|
||||
|
||||
def current_report(self, *, detail: str | None = None) -> AccountStatusReport:
|
||||
if self.active_session is None:
|
||||
return AccountStatusReport(
|
||||
logged_in=False,
|
||||
detail=detail or 'No active account session.',
|
||||
manifest_count=len(self.manifests),
|
||||
profile_count=len(self.profiles),
|
||||
credential_env_vars=self.credential_env_vars,
|
||||
)
|
||||
session = self.active_session
|
||||
return AccountStatusReport(
|
||||
logged_in=session.logged_in,
|
||||
detail=detail or f'Active account session for {session.identity}',
|
||||
provider=session.provider,
|
||||
identity=session.identity,
|
||||
profile_name=session.profile_name,
|
||||
org=session.org,
|
||||
auth_mode=session.auth_mode,
|
||||
api_base=session.api_base,
|
||||
source_manifest=session.source_manifest,
|
||||
manifest_count=len(self.manifests),
|
||||
profile_count=len(self.profiles),
|
||||
credential_env_vars=self.credential_env_vars,
|
||||
metadata=dict(session.metadata),
|
||||
)
|
||||
|
||||
def render_summary(self) -> str:
|
||||
lines = [
|
||||
f'Local account manifests: {len(self.manifests)}',
|
||||
f'Configured account profiles: {len(self.profiles)}',
|
||||
]
|
||||
if self.credential_env_vars:
|
||||
lines.append('- Credential env vars: ' + ', '.join(self.credential_env_vars))
|
||||
for profile in self.profiles[:5]:
|
||||
details = [profile.name, profile.provider, profile.identity]
|
||||
if profile.org:
|
||||
details.append(f'org={profile.org}')
|
||||
if profile.auth_mode:
|
||||
details.append(f'auth_mode={profile.auth_mode}')
|
||||
lines.append('- Profile: ' + ' ; '.join(details))
|
||||
if self.active_session is None:
|
||||
lines.append('- Active account session: none')
|
||||
else:
|
||||
session = self.active_session
|
||||
lines.append(
|
||||
f'- Active account session: {session.provider} / {session.identity}'
|
||||
)
|
||||
if session.profile_name:
|
||||
lines.append(f' - profile: {session.profile_name}')
|
||||
if session.auth_mode:
|
||||
lines.append(f' - auth_mode: {session.auth_mode}')
|
||||
if session.org:
|
||||
lines.append(f' - org: {session.org}')
|
||||
if session.api_base:
|
||||
lines.append(f' - api_base: {session.api_base}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_profile(self, name_or_identity: str) -> str:
|
||||
profile = self.get_profile(name_or_identity)
|
||||
if profile is None:
|
||||
return f'# Account Profile\n\nUnknown account profile: {name_or_identity}'
|
||||
lines = [
|
||||
'# Account Profile',
|
||||
'',
|
||||
f'- Name: {profile.name}',
|
||||
f'- Provider: {profile.provider}',
|
||||
f'- Identity: {profile.identity}',
|
||||
f'- Source manifest: {profile.source_manifest}',
|
||||
]
|
||||
if profile.org:
|
||||
lines.append(f'- Org: {profile.org}')
|
||||
if profile.auth_mode:
|
||||
lines.append(f'- Auth mode: {profile.auth_mode}')
|
||||
if profile.api_base:
|
||||
lines.append(f'- API base: {profile.api_base}')
|
||||
if profile.description:
|
||||
lines.extend(['', profile.description])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_profiles_index(self, *, query: str | None = None) -> str:
|
||||
profiles = self.list_profiles(query=query, limit=100)
|
||||
lines = ['# Account Profiles', '']
|
||||
if not profiles:
|
||||
lines.append('No local account profiles discovered.')
|
||||
return '\n'.join(lines)
|
||||
for profile in profiles:
|
||||
details = [profile.name, profile.provider, profile.identity]
|
||||
if profile.org:
|
||||
details.append(f'org={profile.org}')
|
||||
if profile.auth_mode:
|
||||
details.append(f'auth_mode={profile.auth_mode}')
|
||||
lines.append('- ' + ' ; '.join(details))
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _append_history(self, entry: dict[str, Any]) -> None:
|
||||
self.history = (*self.history, entry)
|
||||
|
||||
def _persist_state(self) -> None:
|
||||
payload = {
|
||||
'active_session': (
|
||||
asdict(self.active_session)
|
||||
if self.active_session is not None
|
||||
else None
|
||||
),
|
||||
'history': list(self.history[-100:]),
|
||||
}
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.state_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + '\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
|
||||
def _discover_manifest_paths(
|
||||
cwd: Path,
|
||||
additional_working_directories: tuple[str, ...],
|
||||
) -> tuple[Path, ...]:
|
||||
candidate_roots = [cwd.resolve()]
|
||||
for raw_path in additional_working_directories:
|
||||
path = Path(raw_path).resolve()
|
||||
if path not in candidate_roots:
|
||||
candidate_roots.append(path)
|
||||
discovered: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for root in candidate_roots:
|
||||
for relative_path in ACCOUNT_MANIFEST_PATHS:
|
||||
path = (root / relative_path).resolve()
|
||||
if path in seen or not path.exists() or not path.is_file():
|
||||
continue
|
||||
seen.add(path)
|
||||
discovered.append(path)
|
||||
return tuple(discovered)
|
||||
|
||||
|
||||
def _load_profiles_from_manifest(path: Path) -> list[AccountProfile]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
if isinstance(payload, dict):
|
||||
profiles_payload = payload.get('profiles')
|
||||
if isinstance(profiles_payload, list):
|
||||
return [
|
||||
profile
|
||||
for item in profiles_payload
|
||||
for profile in [_profile_from_payload(item, path)]
|
||||
if profile is not None
|
||||
]
|
||||
single = _profile_from_payload(payload, path)
|
||||
return [single] if single is not None else []
|
||||
return []
|
||||
|
||||
|
||||
def _profile_from_payload(payload: Any, path: Path) -> AccountProfile | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
raw_name = payload.get('name') or payload.get('profile')
|
||||
provider = payload.get('provider')
|
||||
identity = payload.get('identity') or payload.get('email') or payload.get('user')
|
||||
if not isinstance(provider, str) or not provider.strip():
|
||||
return None
|
||||
if not isinstance(identity, str) or not identity.strip():
|
||||
return None
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
raw_name = identity
|
||||
metadata = payload.get('metadata')
|
||||
return AccountProfile(
|
||||
name=str(raw_name).strip(),
|
||||
provider=provider.strip(),
|
||||
identity=identity.strip(),
|
||||
source_manifest=str(path),
|
||||
description=_optional_str(payload.get('description')),
|
||||
org=_optional_str(payload.get('org')),
|
||||
auth_mode=_optional_str(payload.get('authMode') or payload.get('auth_mode')),
|
||||
api_base=_optional_str(payload.get('apiBase') or payload.get('api_base')),
|
||||
metadata=dict(metadata) if isinstance(metadata, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
def _load_state_payload(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _session_from_payload(payload: Any) -> AccountSessionState | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
provider = payload.get('provider')
|
||||
identity = payload.get('identity')
|
||||
if not isinstance(provider, str) or not provider.strip():
|
||||
return None
|
||||
if not isinstance(identity, str) or not identity.strip():
|
||||
return None
|
||||
metadata = payload.get('metadata')
|
||||
return AccountSessionState(
|
||||
provider=provider.strip(),
|
||||
identity=identity.strip(),
|
||||
logged_in=bool(payload.get('logged_in', True)),
|
||||
logged_in_at=str(payload.get('logged_in_at', _utc_now())),
|
||||
profile_name=_optional_str(payload.get('profile_name')),
|
||||
org=_optional_str(payload.get('org')),
|
||||
auth_mode=_optional_str(payload.get('auth_mode')),
|
||||
api_base=_optional_str(payload.get('api_base')),
|
||||
source_manifest=_optional_str(payload.get('source_manifest')),
|
||||
metadata=dict(metadata) if isinstance(metadata, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
def _detect_credential_env_vars() -> tuple[str, ...]:
|
||||
return tuple(
|
||||
key
|
||||
for key in CREDENTIAL_ENV_VARS
|
||||
if isinstance(os.environ.get(key), str) and os.environ.get(key, '').strip()
|
||||
)
|
||||
|
||||
|
||||
def _optional_str(value: Any) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
Reference in New Issue
Block a user