project-brain 0.1.0__tar.gz
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.
- project_brain-0.1.0/.gitignore +34 -0
- project_brain-0.1.0/PKG-INFO +13 -0
- project_brain-0.1.0/pyproject.toml +36 -0
- project_brain-0.1.0/src/pb/__init__.py +1 -0
- project_brain-0.1.0/src/pb/client.py +122 -0
- project_brain-0.1.0/src/pb/commands/__init__.py +0 -0
- project_brain-0.1.0/src/pb/commands/auth.py +290 -0
- project_brain-0.1.0/src/pb/commands/backfill_embeddings.py +53 -0
- project_brain-0.1.0/src/pb/commands/completion.py +40 -0
- project_brain-0.1.0/src/pb/commands/curate.py +66 -0
- project_brain-0.1.0/src/pb/commands/knowledge.py +137 -0
- project_brain-0.1.0/src/pb/commands/projects.py +68 -0
- project_brain-0.1.0/src/pb/commands/run.py +455 -0
- project_brain-0.1.0/src/pb/commands/tasks.py +166 -0
- project_brain-0.1.0/src/pb/config.py +74 -0
- project_brain-0.1.0/src/pb/main.py +50 -0
- project_brain-0.1.0/src/pb/reporters.py +31 -0
- project_brain-0.1.0/src/pb/services/__init__.py +1 -0
- project_brain-0.1.0/uv.lock +661 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
.venv/
|
|
7
|
+
*.egg
|
|
8
|
+
|
|
9
|
+
# Node
|
|
10
|
+
node_modules/
|
|
11
|
+
dist/
|
|
12
|
+
|
|
13
|
+
# Environment
|
|
14
|
+
.env
|
|
15
|
+
.env.local
|
|
16
|
+
|
|
17
|
+
# IDE
|
|
18
|
+
.vscode/
|
|
19
|
+
.idea/
|
|
20
|
+
*.swp
|
|
21
|
+
*.swo
|
|
22
|
+
|
|
23
|
+
# OS
|
|
24
|
+
.DS_Store
|
|
25
|
+
Thumbs.db
|
|
26
|
+
|
|
27
|
+
# Alembic
|
|
28
|
+
alembic/versions/__pycache__/
|
|
29
|
+
.vercel
|
|
30
|
+
.env*.local
|
|
31
|
+
|
|
32
|
+
changes_summary.txt
|
|
33
|
+
|
|
34
|
+
.claude
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: project-brain
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for ProjectBrain — AI-native project management
|
|
5
|
+
Author-email: Li-Hsuan Lung <li-hsuan.lung@9trinkets.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: click>=8.1
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: rich>=13.0
|
|
11
|
+
Requires-Dist: typer[all]>=0.12
|
|
12
|
+
Provides-Extra: publish
|
|
13
|
+
Requires-Dist: twine>=5.0; extra == 'publish'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "project-brain"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CLI for ProjectBrain — AI-native project management"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Li-Hsuan Lung", email = "li-hsuan.lung@9trinkets.com" },
|
|
9
|
+
]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"click>=8.1",
|
|
12
|
+
"httpx>=0.27",
|
|
13
|
+
"rich>=13.0",
|
|
14
|
+
"typer[all]>=0.12",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
publish = ["twine>=5.0"]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
pb = "pb.main:app"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/pb"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 120
|
|
32
|
+
target-version = "py311"
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = ["E", "F", "I"]
|
|
36
|
+
ignore = ["E501"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Sync httpx client for the ProjectBrain API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import click # Keep for other potential uses, though we're replacing ClickException
|
|
9
|
+
|
|
10
|
+
from pb import config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APIError(Exception):
|
|
14
|
+
"""Custom exception for API errors."""
|
|
15
|
+
def __init__(self, detail: str, code: int | None = None):
|
|
16
|
+
self.detail = detail
|
|
17
|
+
self.code = code
|
|
18
|
+
super().__init__(self.detail)
|
|
19
|
+
|
|
20
|
+
def __str__(self):
|
|
21
|
+
if self.code:
|
|
22
|
+
return f"API error ({self.code}): {self.detail}"
|
|
23
|
+
return self.detail
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _make_client(server: str | None = None, token: str | None = None) -> httpx.Client:
|
|
27
|
+
base = server or config.get_server()
|
|
28
|
+
tok = token or config.get_token()
|
|
29
|
+
if not tok:
|
|
30
|
+
raise APIError(
|
|
31
|
+
"Not authenticated. Run `pb login` first or set PB_TOKEN."
|
|
32
|
+
)
|
|
33
|
+
return httpx.Client(
|
|
34
|
+
base_url=base,
|
|
35
|
+
headers={"Authorization": f"Bearer {tok}"},
|
|
36
|
+
timeout=30.0,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _handle_error(resp: httpx.Response) -> None:
|
|
41
|
+
if resp.status_code < 400:
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
body = resp.json()
|
|
45
|
+
detail = body.get("detail", body)
|
|
46
|
+
except Exception:
|
|
47
|
+
detail = resp.text or f"HTTP {resp.status_code}"
|
|
48
|
+
if isinstance(detail, list):
|
|
49
|
+
detail = "; ".join(
|
|
50
|
+
d.get("msg", str(d)) if isinstance(d, dict) else str(d)
|
|
51
|
+
for d in detail
|
|
52
|
+
)
|
|
53
|
+
raise APIError(detail=str(detail), code=resp.status_code)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def request(
|
|
57
|
+
method: str,
|
|
58
|
+
path: str,
|
|
59
|
+
*,
|
|
60
|
+
params: dict[str, Any] | None = None,
|
|
61
|
+
json_body: dict[str, Any] | None = None,
|
|
62
|
+
server: str | None = None,
|
|
63
|
+
token: str | None = None,
|
|
64
|
+
) -> Any:
|
|
65
|
+
"""Make an authenticated API request and return parsed JSON."""
|
|
66
|
+
with _make_client(server, token) as client:
|
|
67
|
+
resp = client.request(method, path, params=params, json=json_body)
|
|
68
|
+
_handle_error(resp)
|
|
69
|
+
if resp.status_code == 204 or not resp.content:
|
|
70
|
+
return None
|
|
71
|
+
return resp.json()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_project(value: str, *, server: str | None = None) -> str:
|
|
75
|
+
"""Resolve a project identifier to a UUID.
|
|
76
|
+
|
|
77
|
+
Accepts a full UUID, a short UUID prefix (e.g. first 8 chars),
|
|
78
|
+
or a project name (case-insensitive substring match).
|
|
79
|
+
Raises APIError if no match or ambiguous.
|
|
80
|
+
"""
|
|
81
|
+
import re
|
|
82
|
+
# Full UUID — return as-is
|
|
83
|
+
if re.match(r"^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$", value, re.I):
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
projects = request("GET", "/api/projects/", server=server)
|
|
87
|
+
|
|
88
|
+
# Short hex prefix (e.g. "a84c4871") — match against ID start
|
|
89
|
+
if re.match(r"^[0-9a-f]+$", value, re.I) and len(value) >= 4:
|
|
90
|
+
prefix = value.lower()
|
|
91
|
+
matches = [p for p in projects if p["id"].lower().startswith(prefix)]
|
|
92
|
+
if len(matches) == 1:
|
|
93
|
+
return matches[0]["id"]
|
|
94
|
+
if len(matches) > 1:
|
|
95
|
+
names = ", ".join(f"{m['name']} ({m['id'][:8]})" for m in matches)
|
|
96
|
+
raise APIError(f"Ambiguous ID prefix '{value}' — matches: {names}.")
|
|
97
|
+
# Fall through to name search
|
|
98
|
+
|
|
99
|
+
# Name search (case-insensitive substring)
|
|
100
|
+
needle = value.lower()
|
|
101
|
+
matches = [p for p in projects if needle in p["name"].lower()]
|
|
102
|
+
if len(matches) == 1:
|
|
103
|
+
return matches[0]["id"]
|
|
104
|
+
if len(matches) == 0:
|
|
105
|
+
raise APIError(f"No project matching '{value}'. Run `pb projects list` to see available projects.")
|
|
106
|
+
names = ", ".join(m["name"] for m in matches)
|
|
107
|
+
raise APIError(f"Ambiguous project name '{value}' — matches: {names}. Use the full UUID.")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def request_unauth(
|
|
111
|
+
method: str,
|
|
112
|
+
path: str,
|
|
113
|
+
*,
|
|
114
|
+
json_body: dict[str, Any] | None = None,
|
|
115
|
+
server: str | None = None,
|
|
116
|
+
) -> Any:
|
|
117
|
+
"""Make an unauthenticated API request (for login)."""
|
|
118
|
+
base = server or config.get_server()
|
|
119
|
+
with httpx.Client(base_url=base, timeout=30.0) as client:
|
|
120
|
+
resp = client.request(method, path, json=json_body)
|
|
121
|
+
_handle_error(resp)
|
|
122
|
+
return resp.json()
|
|
File without changes
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""pb login / logout / whoami commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import typer
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from typing_extensions import Annotated
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
import socket
|
|
10
|
+
import webbrowser
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
12
|
+
from urllib.parse import parse_qs, urlparse
|
|
13
|
+
|
|
14
|
+
from pb import config
|
|
15
|
+
from pb.client import request, request_unauth, APIError
|
|
16
|
+
from pb.reporters import render, panic
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Fixed port so the redirect URI can be pre-registered in OAuth provider settings.
|
|
20
|
+
# Override with PB_OAUTH_PORT if 8085 conflicts.
|
|
21
|
+
_DEFAULT_OAUTH_PORT = 8085
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_oauth_port() -> int:
|
|
25
|
+
import os
|
|
26
|
+
return int(os.environ.get("PB_OAUTH_PORT", str(_DEFAULT_OAUTH_PORT)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# OAuth callback page — styled to match the ProjectBrain dark UI.
|
|
31
|
+
# Tokens: brand warm gold #A88450, warm neutral slate, Lora + Plus Jakarta Sans.
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
_CALLBACK_HTML = """\
|
|
34
|
+
<!DOCTYPE html>
|
|
35
|
+
<html lang="en">
|
|
36
|
+
<head>
|
|
37
|
+
<meta charset="utf-8">
|
|
38
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
39
|
+
<title>ProjectBrain — Logged In</title>
|
|
40
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
41
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
42
|
+
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@400&family=Plus+Jakarta+Sans:wght@400;500&display=swap" rel="stylesheet">
|
|
43
|
+
<style>
|
|
44
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
45
|
+
body {
|
|
46
|
+
min-height:100vh;
|
|
47
|
+
display:flex;
|
|
48
|
+
align-items:center;
|
|
49
|
+
justify-content:center;
|
|
50
|
+
background:#0A0A0B;
|
|
51
|
+
color:#E8E3DA;
|
|
52
|
+
font-family:'Plus Jakarta Sans',system-ui,sans-serif;
|
|
53
|
+
line-height:1.65;
|
|
54
|
+
letter-spacing:.01em;
|
|
55
|
+
}
|
|
56
|
+
.card {
|
|
57
|
+
text-align:center;
|
|
58
|
+
padding:48px 40px;
|
|
59
|
+
background:#1A1714;
|
|
60
|
+
border:1px solid #272420;
|
|
61
|
+
border-radius:16px;
|
|
62
|
+
max-width:420px;
|
|
63
|
+
width:90%;
|
|
64
|
+
}
|
|
65
|
+
.icon {
|
|
66
|
+
width:56px; height:56px;
|
|
67
|
+
margin:0 auto 20px;
|
|
68
|
+
background:#A88450;
|
|
69
|
+
border-radius:50%;
|
|
70
|
+
display:flex;
|
|
71
|
+
align-items:center;
|
|
72
|
+
justify-content:center;
|
|
73
|
+
}
|
|
74
|
+
.icon svg { width:28px; height:28px; fill:none; stroke:#fff; stroke-width:2.5; stroke-linecap:round; stroke-linejoin:round; }
|
|
75
|
+
h1 {
|
|
76
|
+
font-family:'Lora',serif;
|
|
77
|
+
font-weight:400;
|
|
78
|
+
font-size:26px;
|
|
79
|
+
line-height:1.25;
|
|
80
|
+
margin-bottom:8px;
|
|
81
|
+
}
|
|
82
|
+
p {
|
|
83
|
+
color:#8A8580;
|
|
84
|
+
font-size:15px;
|
|
85
|
+
}
|
|
86
|
+
.tag {
|
|
87
|
+
display:inline-block;
|
|
88
|
+
margin-top:24px;
|
|
89
|
+
padding:6px 14px;
|
|
90
|
+
font-size:12px;
|
|
91
|
+
font-weight:500;
|
|
92
|
+
letter-spacing:.04em;
|
|
93
|
+
text-transform:uppercase;
|
|
94
|
+
color:#C8A570;
|
|
95
|
+
background:#261E15;
|
|
96
|
+
border-radius:6px;
|
|
97
|
+
}
|
|
98
|
+
</style>
|
|
99
|
+
</head>
|
|
100
|
+
<body>
|
|
101
|
+
<div class="card">
|
|
102
|
+
<div class="icon">
|
|
103
|
+
<svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
|
104
|
+
</div>
|
|
105
|
+
<h1>Logged in</h1>
|
|
106
|
+
<p>You can close this tab and return to the terminal.</p>
|
|
107
|
+
<span class="tag">ProjectBrain CLI</span>
|
|
108
|
+
</div>
|
|
109
|
+
</body>
|
|
110
|
+
</html>
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _run_oauth_callback_server(port: int) -> str | None:
|
|
115
|
+
"""Start a one-shot HTTP server to capture the OAuth code."""
|
|
116
|
+
captured: dict[str, str | None] = {"code": None}
|
|
117
|
+
|
|
118
|
+
class Handler(BaseHTTPRequestHandler):
|
|
119
|
+
def do_GET(self) -> None: # noqa: N802
|
|
120
|
+
qs = parse_qs(urlparse(self.path).query)
|
|
121
|
+
captured["code"] = qs.get("code", [None])[0]
|
|
122
|
+
self.send_response(200)
|
|
123
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
124
|
+
self.end_headers()
|
|
125
|
+
body = _CALLBACK_HTML
|
|
126
|
+
self.wfile.write(body.encode("utf-8"))
|
|
127
|
+
|
|
128
|
+
def log_message(self, *_args: object) -> None:
|
|
129
|
+
pass # silence request logs
|
|
130
|
+
|
|
131
|
+
server = HTTPServer(("127.0.0.1", port), Handler)
|
|
132
|
+
server.timeout = 120 # 2 minutes
|
|
133
|
+
server.handle_request()
|
|
134
|
+
server.server_close()
|
|
135
|
+
return captured["code"]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def login(
|
|
139
|
+
ctx: typer.Context,
|
|
140
|
+
token_value: Annotated[Optional[str], typer.Option("--token", help="API token (pb_xxx or JWT).")] = None,
|
|
141
|
+
use_google: Annotated[bool, typer.Option("--google", help="Login with Google (opens browser).")] = False,
|
|
142
|
+
use_github: Annotated[bool, typer.Option("--github", help="Login with GitHub (opens browser).")] = False,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Authenticate with ProjectBrain."""
|
|
145
|
+
server = ctx.obj.get("server")
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
if token_value:
|
|
149
|
+
# Token-based login — validate by calling /api/auth/me
|
|
150
|
+
# Warn if overwriting a human JWT with an agent key
|
|
151
|
+
existing = config.get_file_token()
|
|
152
|
+
if (
|
|
153
|
+
token_value.startswith("pb_")
|
|
154
|
+
and existing
|
|
155
|
+
and not existing.startswith("pb_")
|
|
156
|
+
):
|
|
157
|
+
typer.echo(
|
|
158
|
+
"Warning: overwriting human login session with an agent API key. "
|
|
159
|
+
"Features like `pb run --agent` require human auth."
|
|
160
|
+
)
|
|
161
|
+
config.save(token_value, server)
|
|
162
|
+
try:
|
|
163
|
+
user = request("GET", "/api/auth/me", server=server, token=token_value)
|
|
164
|
+
except APIError:
|
|
165
|
+
config.clear()
|
|
166
|
+
panic(code="invalid_token", message="Invalid token — could not authenticate.")
|
|
167
|
+
render(user)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
if use_google:
|
|
171
|
+
_login_oauth(server, provider="google")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if use_github:
|
|
175
|
+
_login_oauth(server, provider="github")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
# Interactive email/password login
|
|
179
|
+
email = typer.prompt("Email")
|
|
180
|
+
password = typer.prompt("Password", hide_input=True)
|
|
181
|
+
data = request_unauth(
|
|
182
|
+
"POST",
|
|
183
|
+
"/api/auth/login",
|
|
184
|
+
json_body={"email": email, "password": password},
|
|
185
|
+
server=server,
|
|
186
|
+
)
|
|
187
|
+
token = data["access_token"]
|
|
188
|
+
config.save(token, server)
|
|
189
|
+
|
|
190
|
+
# Fetch identity to confirm
|
|
191
|
+
user = request("GET", "/api/auth/me", server=server, token=token)
|
|
192
|
+
render(user)
|
|
193
|
+
except APIError as e:
|
|
194
|
+
panic(code=e.code, message=str(e))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_OAUTH_CONFIG = {
|
|
198
|
+
"google": {
|
|
199
|
+
"env_var": "PB_GOOGLE_CLIENT_ID",
|
|
200
|
+
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
|
201
|
+
"scope": "openid%20email%20profile",
|
|
202
|
+
"extra_params": "&access_type=offline&prompt=select_account",
|
|
203
|
+
"api_path": "/api/auth/google",
|
|
204
|
+
},
|
|
205
|
+
"github": {
|
|
206
|
+
"env_var": "PB_GITHUB_CLIENT_ID",
|
|
207
|
+
"auth_url": "https://github.com/login/oauth/authorize",
|
|
208
|
+
"scope": "read:user%20user:email",
|
|
209
|
+
"extra_params": "",
|
|
210
|
+
"api_path": "/api/auth/github",
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _login_oauth(server: str | None, provider: str) -> None:
|
|
216
|
+
"""Browser-based OAuth flow (Google or GitHub)."""
|
|
217
|
+
import os
|
|
218
|
+
cfg = _OAUTH_CONFIG[provider]
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
client_id = os.environ.get(cfg["env_var"])
|
|
222
|
+
if not client_id:
|
|
223
|
+
try:
|
|
224
|
+
providers = request_unauth("GET", "/api/auth/providers", server=server)
|
|
225
|
+
client_id = (providers.get(provider) or {}).get("client_id")
|
|
226
|
+
except APIError:
|
|
227
|
+
pass # Fail over to the panic below
|
|
228
|
+
if not client_id:
|
|
229
|
+
panic(
|
|
230
|
+
code="oauth_config_missing",
|
|
231
|
+
message=f"{provider.title()} client ID not found. Set {cfg['env_var']} or deploy the /providers endpoint."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
port = _get_oauth_port()
|
|
235
|
+
redirect_uri = f"http://localhost:{port}/callback"
|
|
236
|
+
|
|
237
|
+
params = (
|
|
238
|
+
f"client_id={client_id}"
|
|
239
|
+
f"&redirect_uri={redirect_uri}"
|
|
240
|
+
f"&response_type=code"
|
|
241
|
+
f"&scope={cfg['scope']}"
|
|
242
|
+
f"{cfg['extra_params']}"
|
|
243
|
+
)
|
|
244
|
+
url = f"{cfg['auth_url']}?{params}"
|
|
245
|
+
|
|
246
|
+
typer.echo(f"Opening browser for {provider.title()} login...")
|
|
247
|
+
webbrowser.open(url)
|
|
248
|
+
typer.echo("Waiting for callback (press Ctrl+C to cancel)...")
|
|
249
|
+
|
|
250
|
+
code = _run_oauth_callback_server(port)
|
|
251
|
+
if not code:
|
|
252
|
+
panic(code="oauth_cancelled", message="No authorization code received. Login cancelled.")
|
|
253
|
+
|
|
254
|
+
data = request_unauth(
|
|
255
|
+
"POST",
|
|
256
|
+
cfg['api_path'],
|
|
257
|
+
json_body={"code": code, "redirect_uri": redirect_uri},
|
|
258
|
+
server=server,
|
|
259
|
+
)
|
|
260
|
+
token = data["access_token"]
|
|
261
|
+
config.save(token, server)
|
|
262
|
+
|
|
263
|
+
user = request("GET", "/api/auth/me", server=server, token=token)
|
|
264
|
+
render(user)
|
|
265
|
+
except APIError as e:
|
|
266
|
+
panic(code=e.code, message=str(e))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def whoami(ctx: typer.Context) -> None:
|
|
270
|
+
"""Show current authenticated identity."""
|
|
271
|
+
server = ctx.obj.get("server")
|
|
272
|
+
try:
|
|
273
|
+
user = request("GET", "/api/auth/me", server=server)
|
|
274
|
+
if ctx.obj.get("json"):
|
|
275
|
+
render(user)
|
|
276
|
+
else:
|
|
277
|
+
typer.echo(f"{user['name']} <{user['email']}>")
|
|
278
|
+
typer.echo(f" ID: {user['id']}")
|
|
279
|
+
typer.echo(f" Type: {user['user_type']}")
|
|
280
|
+
src = config.token_source()
|
|
281
|
+
source = "PB_TOKEN" if src == "env" else "~/.pb/config.json"
|
|
282
|
+
typer.echo(f" Auth: {source}")
|
|
283
|
+
except APIError as e:
|
|
284
|
+
panic(code=e.code, message=str(e))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def logout() -> None:
|
|
288
|
+
"""Clear stored credentials."""
|
|
289
|
+
config.clear()
|
|
290
|
+
typer.echo("Logged out.")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import typer
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
5
|
+
from sqlalchemy.orm import sessionmaker
|
|
6
|
+
|
|
7
|
+
# This is a hack to get the app config.
|
|
8
|
+
# In a real application, the CLI and API would share a common package.
|
|
9
|
+
import sys
|
|
10
|
+
sys.path.append("api") # models use `from app.* import …` (relative to api/)
|
|
11
|
+
|
|
12
|
+
from app.config import settings
|
|
13
|
+
from app.models.fact import Fact
|
|
14
|
+
from app.models.decision import Decision
|
|
15
|
+
from app.models.skill import Skill
|
|
16
|
+
from app.services.embeddings import upsert_embedding
|
|
17
|
+
|
|
18
|
+
app = typer.Typer()
|
|
19
|
+
|
|
20
|
+
async def backfill():
|
|
21
|
+
engine = create_async_engine(settings.database_url, echo=False)
|
|
22
|
+
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
23
|
+
|
|
24
|
+
async with AsyncSessionLocal() as db:
|
|
25
|
+
entity_types = {
|
|
26
|
+
"fact": Fact,
|
|
27
|
+
"decision": Decision,
|
|
28
|
+
"skill": Skill,
|
|
29
|
+
}
|
|
30
|
+
for entity_type, model in entity_types.items():
|
|
31
|
+
print(f"Querying all entities of type: {entity_type}...")
|
|
32
|
+
result = await db.execute(select(model))
|
|
33
|
+
entities = result.scalars().all()
|
|
34
|
+
print(f"Found {len(entities)} entities. Processing...")
|
|
35
|
+
for i, entity in enumerate(entities):
|
|
36
|
+
try:
|
|
37
|
+
print(f" - ({i+1}/{len(entities)}) Upserting embedding for {entity_type} {entity.id}...")
|
|
38
|
+
await upsert_embedding(db, entity, entity_type)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(f" - ERROR: Failed to upsert embedding for {entity_type} {entity.id}: {e}")
|
|
41
|
+
|
|
42
|
+
print("Backfill complete. Committing changes.")
|
|
43
|
+
await db.commit()
|
|
44
|
+
|
|
45
|
+
@app.command()
|
|
46
|
+
def main():
|
|
47
|
+
"""Backfill embeddings for existing facts, decisions, and skills."""
|
|
48
|
+
print("Starting embedding backfill process...")
|
|
49
|
+
asyncio.run(backfill())
|
|
50
|
+
print("Backfill process finished successfully.")
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
app()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Show shell completion scripts."""
|
|
2
|
+
import typer
|
|
3
|
+
import os
|
|
4
|
+
from typer import completion
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(
|
|
7
|
+
help="Show shell completion scripts. To install, add the output to your shell's startup file.",
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
@app.callback(invoke_without_command=True)
|
|
11
|
+
def main(
|
|
12
|
+
ctx: typer.Context,
|
|
13
|
+
shell: str = typer.Option(
|
|
14
|
+
os.environ.get("SHELL", "").split("/")[-1],
|
|
15
|
+
"--shell",
|
|
16
|
+
"-s",
|
|
17
|
+
help="The shell to generate completions for.",
|
|
18
|
+
autocompletion=lambda: ["bash", "zsh", "fish", "powershell", "pwsh"],
|
|
19
|
+
),
|
|
20
|
+
install: bool = typer.Option(
|
|
21
|
+
False,
|
|
22
|
+
"--install",
|
|
23
|
+
help="Install completions for the current shell.",
|
|
24
|
+
),
|
|
25
|
+
):
|
|
26
|
+
"""
|
|
27
|
+
Manage shell completions.
|
|
28
|
+
"""
|
|
29
|
+
if ctx.invoked_subcommand is not None:
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
prog_name = os.environ.get("PB_PROG_NAME", "pb")
|
|
33
|
+
if install:
|
|
34
|
+
typer.echo(f"Installing completions for {shell}...")
|
|
35
|
+
completion.install(shell=shell, prog_name=prog_name)
|
|
36
|
+
raise typer.Exit()
|
|
37
|
+
else:
|
|
38
|
+
script = completion.get_completion_script(prog_name=prog_name, complete_var=f"_{prog_name.upper()}_COMPLETE", shell=shell)
|
|
39
|
+
typer.echo(script)
|
|
40
|
+
raise typer.Exit()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""pb curate — project-level memory curator settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing_extensions import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from pb.client import request, resolve_project, APIError
|
|
9
|
+
from pb.reporters import render, panic
|
|
10
|
+
|
|
11
|
+
app = typer.Typer()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("enable")
|
|
15
|
+
def enable_curate(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
project_id: Annotated[str, typer.Option("--project", "-p", help="Project ID.")],
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Enable curation for a project."""
|
|
20
|
+
server = ctx.obj.get("server")
|
|
21
|
+
try:
|
|
22
|
+
project_id = resolve_project(project_id, server=server)
|
|
23
|
+
data = request(
|
|
24
|
+
"PATCH",
|
|
25
|
+
f"/api/projects/{project_id}",
|
|
26
|
+
json_body={"curation_enabled": True},
|
|
27
|
+
server=server,
|
|
28
|
+
)
|
|
29
|
+
render(data)
|
|
30
|
+
except APIError as e:
|
|
31
|
+
panic(code=e.code, message=str(e))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("disable")
|
|
35
|
+
def disable_curate(
|
|
36
|
+
ctx: typer.Context,
|
|
37
|
+
project_id: Annotated[str, typer.Option("--project", "-p", help="Project ID.")],
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Disable curation for a project."""
|
|
40
|
+
server = ctx.obj.get("server")
|
|
41
|
+
try:
|
|
42
|
+
project_id = resolve_project(project_id, server=server)
|
|
43
|
+
data = request(
|
|
44
|
+
"PATCH",
|
|
45
|
+
f"/api/projects/{project_id}",
|
|
46
|
+
json_body={"curation_enabled": False},
|
|
47
|
+
server=server,
|
|
48
|
+
)
|
|
49
|
+
render(data)
|
|
50
|
+
except APIError as e:
|
|
51
|
+
panic(code=e.code, message=str(e))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("status")
|
|
55
|
+
def curate_status(
|
|
56
|
+
ctx: typer.Context,
|
|
57
|
+
project_id: Annotated[str, typer.Option("--project", "-p", help="Project ID.")],
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Show curation status for a project."""
|
|
60
|
+
server = ctx.obj.get("server")
|
|
61
|
+
try:
|
|
62
|
+
project_id = resolve_project(project_id, server=server)
|
|
63
|
+
data = request("GET", f"/api/projects/{project_id}", server=server)
|
|
64
|
+
render(data)
|
|
65
|
+
except APIError as e:
|
|
66
|
+
panic(code=e.code, message=str(e))
|