bumblebee-cli 0.2.0 → 0.3.0
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.
- package/dist/index.js +4214 -0
- package/dist/index.js.map +1 -0
- package/package.json +32 -18
- package/templates/skills/bb-agent/SKILL.md +180 -0
- package/templates/skills/bb-agent/references/bb-commands.md +124 -0
- package/templates/skills/bb-agent/references/investigate-workflow.md +150 -0
- package/templates/skills/bb-agent/references/parallel-workflow.md +105 -0
- package/templates/skills/bb-agent/references/prompts.md +144 -0
- package/templates/skills/bb-agent/references/status-transitions.md +93 -0
- package/templates/skills/bb-agent/references/workflow.md +178 -0
- package/README.md +0 -49
- package/bin/bb.mjs +0 -132
- package/python/bb_cli/__init__.py +0 -0
- package/python/bb_cli/__main__.py +0 -3
- package/python/bb_cli/api_client.py +0 -45
- package/python/bb_cli/commands/__init__.py +0 -0
- package/python/bb_cli/commands/agent.py +0 -2287
- package/python/bb_cli/commands/auth.py +0 -79
- package/python/bb_cli/commands/board.py +0 -47
- package/python/bb_cli/commands/comment.py +0 -34
- package/python/bb_cli/commands/daemon.py +0 -153
- package/python/bb_cli/commands/init.py +0 -83
- package/python/bb_cli/commands/item.py +0 -192
- package/python/bb_cli/commands/label.py +0 -43
- package/python/bb_cli/commands/project.py +0 -175
- package/python/bb_cli/commands/sprint.py +0 -84
- package/python/bb_cli/config.py +0 -136
- package/python/bb_cli/main.py +0 -44
- package/python/bb_cli/progress.py +0 -117
- package/python/bb_cli/streaming.py +0 -168
- package/python/pyproject.toml +0 -18
- package/python/requirements.txt +0 -4
- package/scripts/build.sh +0 -20
- package/scripts/postinstall.mjs +0 -146
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
|
|
3
|
-
import httpx
|
|
4
|
-
import typer
|
|
5
|
-
from rich import print as rprint
|
|
6
|
-
from rich.prompt import IntPrompt
|
|
7
|
-
from rich.table import Table
|
|
8
|
-
|
|
9
|
-
from ..api_client import api_get, api_post
|
|
10
|
-
from ..config import (
|
|
11
|
-
get_project_path,
|
|
12
|
-
is_local_config_active,
|
|
13
|
-
load_config,
|
|
14
|
-
load_local_config,
|
|
15
|
-
save_config,
|
|
16
|
-
save_local_config,
|
|
17
|
-
set_project_path,
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
app = typer.Typer(help="Project management")
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _select_project_interactive() -> dict:
|
|
24
|
-
"""Fetch projects from API and let the user pick one interactively."""
|
|
25
|
-
try:
|
|
26
|
-
projects = api_get("/api/projects")
|
|
27
|
-
except httpx.ConnectError:
|
|
28
|
-
rprint("[red]Cannot connect to the API server.[/red]")
|
|
29
|
-
raise typer.Exit(1)
|
|
30
|
-
except httpx.HTTPStatusError as exc:
|
|
31
|
-
if exc.response.status_code == 401:
|
|
32
|
-
rprint("[red]Not authenticated. Run [bold]bb login[/bold] first.[/red]")
|
|
33
|
-
else:
|
|
34
|
-
rprint(f"[red]API error: {exc.response.status_code}[/red]")
|
|
35
|
-
raise typer.Exit(1)
|
|
36
|
-
|
|
37
|
-
if not projects:
|
|
38
|
-
rprint("[yellow]No projects found. Create one with [bold]bb project create <name>[/bold].[/yellow]")
|
|
39
|
-
raise typer.Exit(1)
|
|
40
|
-
|
|
41
|
-
table = Table(title="Select a project")
|
|
42
|
-
table.add_column("#", style="bold", justify="right")
|
|
43
|
-
table.add_column("Slug", style="cyan")
|
|
44
|
-
table.add_column("Name")
|
|
45
|
-
for i, p in enumerate(projects, 1):
|
|
46
|
-
table.add_row(str(i), p["slug"], p["name"])
|
|
47
|
-
rprint(table)
|
|
48
|
-
|
|
49
|
-
choice = IntPrompt.ask(
|
|
50
|
-
"Pick a project",
|
|
51
|
-
choices=[str(i) for i in range(1, len(projects) + 1)],
|
|
52
|
-
)
|
|
53
|
-
return projects[choice - 1]
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
@app.command(name="list")
|
|
57
|
-
def list_projects():
|
|
58
|
-
"""List all projects."""
|
|
59
|
-
projects = api_get("/api/projects")
|
|
60
|
-
table = Table(title="Projects")
|
|
61
|
-
table.add_column("Slug", style="cyan")
|
|
62
|
-
table.add_column("Name")
|
|
63
|
-
table.add_column("Local Path")
|
|
64
|
-
table.add_column("Repo")
|
|
65
|
-
for p in projects:
|
|
66
|
-
local = get_project_path(p["slug"]) or "-"
|
|
67
|
-
table.add_row(p["slug"], p["name"], local, p.get("repo_url") or "-")
|
|
68
|
-
rprint(table)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
@app.command()
|
|
72
|
-
def create(
|
|
73
|
-
name: str = typer.Argument(...),
|
|
74
|
-
slug: str = typer.Option(None, help="Project slug (defaults to name kebab-cased)"),
|
|
75
|
-
repo_url: str = typer.Option(None, help="Repository URL"),
|
|
76
|
-
):
|
|
77
|
-
"""Create a new project."""
|
|
78
|
-
if not slug:
|
|
79
|
-
slug = name.lower().replace(" ", "-")
|
|
80
|
-
project = api_post("/api/projects", json={"name": name, "slug": slug, "repo_url": repo_url})
|
|
81
|
-
rprint(f"[green]Created project [bold]{project['slug']}[/bold][/green]")
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
@app.command()
|
|
85
|
-
def switch(
|
|
86
|
-
slug: str = typer.Argument(None),
|
|
87
|
-
path: str = typer.Option(None, "-p", "--path", help="Local source code directory"),
|
|
88
|
-
):
|
|
89
|
-
"""Switch to a project as the active context.
|
|
90
|
-
|
|
91
|
-
If no slug is provided, shows an interactive project picker.
|
|
92
|
-
"""
|
|
93
|
-
if slug is None:
|
|
94
|
-
selected = _select_project_interactive()
|
|
95
|
-
slug = selected["slug"]
|
|
96
|
-
else:
|
|
97
|
-
# Verify project exists
|
|
98
|
-
api_get(f"/api/projects/{slug}")
|
|
99
|
-
# Write to local config if .bumblebee/ exists, otherwise global
|
|
100
|
-
if is_local_config_active():
|
|
101
|
-
local_cfg = load_local_config()
|
|
102
|
-
local_cfg["current_project"] = slug
|
|
103
|
-
save_local_config(local_cfg)
|
|
104
|
-
else:
|
|
105
|
-
cfg = load_config()
|
|
106
|
-
cfg["current_project"] = slug
|
|
107
|
-
save_config(cfg)
|
|
108
|
-
if path:
|
|
109
|
-
resolved = str(Path(path).resolve())
|
|
110
|
-
set_project_path(slug, resolved)
|
|
111
|
-
rprint(f"[green]Switched to [bold]{slug}[/bold] -> {resolved}[/green]")
|
|
112
|
-
else:
|
|
113
|
-
existing = get_project_path(slug)
|
|
114
|
-
rprint(f"[green]Switched to project [bold]{slug}[/bold][/green]")
|
|
115
|
-
if not existing:
|
|
116
|
-
rprint("[dim]Tip: use [bold]bb project link <path>[/bold] to set the source code directory.[/dim]")
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
@app.command()
|
|
120
|
-
def link(
|
|
121
|
-
path: str = typer.Argument(".", help="Path to the source code directory (default: current dir)"),
|
|
122
|
-
):
|
|
123
|
-
"""Link current project to a local source code directory."""
|
|
124
|
-
cfg = load_config()
|
|
125
|
-
slug = cfg.get("current_project")
|
|
126
|
-
if not slug:
|
|
127
|
-
rprint("[red]No project selected. Run [bold]bb project switch <slug>[/bold] first.[/red]")
|
|
128
|
-
raise typer.Exit(1)
|
|
129
|
-
resolved = str(Path(path).resolve())
|
|
130
|
-
if not Path(resolved).is_dir():
|
|
131
|
-
rprint(f"[red]Directory not found: {resolved}[/red]")
|
|
132
|
-
raise typer.Exit(1)
|
|
133
|
-
set_project_path(slug, resolved)
|
|
134
|
-
rprint(f"[green]Linked [bold]{slug}[/bold] -> {resolved}[/green]")
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
@app.command()
|
|
138
|
-
def current():
|
|
139
|
-
"""Show the current active project."""
|
|
140
|
-
cfg = load_config()
|
|
141
|
-
slug = cfg.get("current_project")
|
|
142
|
-
if not slug:
|
|
143
|
-
rprint("[yellow]No project selected. Use [bold]bb project switch <slug>[/bold].[/yellow]")
|
|
144
|
-
raise typer.Exit(1)
|
|
145
|
-
project = api_get(f"/api/projects/{slug}")
|
|
146
|
-
rprint(f"[bold]{project['name']}[/bold] ({project['slug']})")
|
|
147
|
-
local = get_project_path(slug)
|
|
148
|
-
if local:
|
|
149
|
-
rprint(f"Path: {local}")
|
|
150
|
-
else:
|
|
151
|
-
rprint("[dim]Path: not linked - use [bold]bb project link <path>[/bold][/dim]")
|
|
152
|
-
if project.get("repo_url"):
|
|
153
|
-
rprint(f"Repo: {project['repo_url']}")
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
@app.command()
|
|
157
|
-
def refresh():
|
|
158
|
-
"""Re-pick the active project from the API and link the current directory."""
|
|
159
|
-
selected = _select_project_interactive()
|
|
160
|
-
slug = selected["slug"]
|
|
161
|
-
|
|
162
|
-
# Update config
|
|
163
|
-
if is_local_config_active():
|
|
164
|
-
local_cfg = load_local_config()
|
|
165
|
-
local_cfg["current_project"] = slug
|
|
166
|
-
save_local_config(local_cfg)
|
|
167
|
-
else:
|
|
168
|
-
cfg = load_config()
|
|
169
|
-
cfg["current_project"] = slug
|
|
170
|
-
save_config(cfg)
|
|
171
|
-
|
|
172
|
-
# Auto-link current directory
|
|
173
|
-
resolved = str(Path.cwd().resolve())
|
|
174
|
-
set_project_path(slug, resolved)
|
|
175
|
-
rprint(f"[green]Switched to [bold]{selected['name']}[/bold] ({slug}) -> {resolved}[/green]")
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import typer
|
|
2
|
-
from rich import print as rprint
|
|
3
|
-
from rich.table import Table
|
|
4
|
-
|
|
5
|
-
from ..api_client import api_get, api_post, api_put
|
|
6
|
-
from ..config import get_current_project
|
|
7
|
-
|
|
8
|
-
app = typer.Typer(help="Sprint management")
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def _require_project() -> str:
|
|
12
|
-
slug = get_current_project()
|
|
13
|
-
if not slug:
|
|
14
|
-
rprint("[red]No project selected. Run [bold]bb project switch <slug>[/bold] first.[/red]")
|
|
15
|
-
raise typer.Exit(1)
|
|
16
|
-
return slug
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@app.command(name="list")
|
|
20
|
-
def list_sprints():
|
|
21
|
-
"""List sprints in the current project."""
|
|
22
|
-
slug = _require_project()
|
|
23
|
-
sprints = api_get(f"/api/projects/{slug}/sprints")
|
|
24
|
-
table = Table(title=f"Sprints - {slug}")
|
|
25
|
-
table.add_column("ID", style="cyan")
|
|
26
|
-
table.add_column("Name")
|
|
27
|
-
table.add_column("Status", style="green")
|
|
28
|
-
table.add_column("Start")
|
|
29
|
-
table.add_column("End")
|
|
30
|
-
for s in sprints:
|
|
31
|
-
table.add_row(
|
|
32
|
-
s["id"][:8],
|
|
33
|
-
s["name"],
|
|
34
|
-
s["status"],
|
|
35
|
-
s.get("start_date") or "-",
|
|
36
|
-
s.get("end_date") or "-",
|
|
37
|
-
)
|
|
38
|
-
rprint(table)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@app.command()
|
|
42
|
-
def create(
|
|
43
|
-
name: str = typer.Argument(...),
|
|
44
|
-
goal: str = typer.Option(None, "-g", "--goal"),
|
|
45
|
-
):
|
|
46
|
-
"""Create a new sprint."""
|
|
47
|
-
slug = _require_project()
|
|
48
|
-
data = {"name": name}
|
|
49
|
-
if goal:
|
|
50
|
-
data["goal"] = goal
|
|
51
|
-
sprint = api_post(f"/api/projects/{slug}/sprints", json=data)
|
|
52
|
-
rprint(f"[green]Created sprint: {sprint['name']}[/green]")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@app.command()
|
|
56
|
-
def start(sprint_id: str = typer.Argument(...)):
|
|
57
|
-
"""Start a sprint (set status to active)."""
|
|
58
|
-
slug = _require_project()
|
|
59
|
-
sprint = api_post(f"/api/projects/{slug}/sprints/{sprint_id}/start")
|
|
60
|
-
rprint(f"[green]Sprint [bold]{sprint['name']}[/bold] is now active.[/green]")
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
@app.command()
|
|
64
|
-
def close(sprint_id: str = typer.Argument(...)):
|
|
65
|
-
"""Close an active sprint."""
|
|
66
|
-
slug = _require_project()
|
|
67
|
-
sprint = api_post(f"/api/projects/{slug}/sprints/{sprint_id}/close")
|
|
68
|
-
rprint(f"[green]Sprint [bold]{sprint['name']}[/bold] completed.[/green]")
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
@app.command()
|
|
72
|
-
def current():
|
|
73
|
-
"""Show the currently active sprint."""
|
|
74
|
-
slug = _require_project()
|
|
75
|
-
sprints = api_get(f"/api/projects/{slug}/sprints")
|
|
76
|
-
active = [s for s in sprints if s["status"] == "active"]
|
|
77
|
-
if not active:
|
|
78
|
-
rprint("[yellow]No active sprint.[/yellow]")
|
|
79
|
-
raise typer.Exit()
|
|
80
|
-
s = active[0]
|
|
81
|
-
rprint(f"[bold]{s['name']}[/bold] ({s['status']})")
|
|
82
|
-
if s.get("goal"):
|
|
83
|
-
rprint(f"Goal: {s['goal']}")
|
|
84
|
-
rprint(f"Period: {s.get('start_date', '?')} -> {s.get('end_date', '?')}")
|
package/python/bb_cli/config.py
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
import toml
|
|
5
|
-
|
|
6
|
-
# Global config (always ~/.bumblebee/)
|
|
7
|
-
GLOBAL_CONFIG_DIR = Path.home() / ".bumblebee"
|
|
8
|
-
GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.toml"
|
|
9
|
-
|
|
10
|
-
# Backward compat alias (used by agent.py for worktrees)
|
|
11
|
-
CONFIG_DIR = GLOBAL_CONFIG_DIR
|
|
12
|
-
|
|
13
|
-
# Local config directory name
|
|
14
|
-
LOCAL_DIR_NAME = ".bumblebee"
|
|
15
|
-
LOCAL_CONFIG_NAME = "config.toml"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def ensure_config_dir():
|
|
19
|
-
GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def find_project_root(start: Path | None = None) -> Path | None:
|
|
23
|
-
"""Walk up from start (default cwd) looking for .bumblebee/ or .git/."""
|
|
24
|
-
current = (start or Path.cwd()).resolve()
|
|
25
|
-
for directory in [current, *current.parents]:
|
|
26
|
-
if (directory / LOCAL_DIR_NAME).is_dir():
|
|
27
|
-
return directory
|
|
28
|
-
if (directory / ".git").exists():
|
|
29
|
-
return directory
|
|
30
|
-
return None
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def get_local_config_dir() -> Path | None:
|
|
34
|
-
"""Return .bumblebee/ directory if it exists in the project root."""
|
|
35
|
-
root = find_project_root()
|
|
36
|
-
if root and (root / LOCAL_DIR_NAME).is_dir():
|
|
37
|
-
return root / LOCAL_DIR_NAME
|
|
38
|
-
return None
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def is_local_config_active() -> bool:
|
|
42
|
-
"""Check if a local .bumblebee/ config exists."""
|
|
43
|
-
return get_local_config_dir() is not None
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _load_global_config() -> dict:
|
|
47
|
-
if GLOBAL_CONFIG_FILE.exists():
|
|
48
|
-
return toml.load(GLOBAL_CONFIG_FILE)
|
|
49
|
-
return {}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def load_local_config() -> dict:
|
|
53
|
-
"""Load config from project-local .bumblebee/config.toml."""
|
|
54
|
-
local_dir = get_local_config_dir()
|
|
55
|
-
if local_dir:
|
|
56
|
-
local_file = local_dir / LOCAL_CONFIG_NAME
|
|
57
|
-
if local_file.exists():
|
|
58
|
-
return toml.load(local_file)
|
|
59
|
-
return {}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def save_local_config(data: dict):
|
|
63
|
-
"""Write config to project-local .bumblebee/config.toml."""
|
|
64
|
-
local_dir = get_local_config_dir()
|
|
65
|
-
if not local_dir:
|
|
66
|
-
raise FileNotFoundError(
|
|
67
|
-
"No .bumblebee/ directory found. Run 'bb init' first."
|
|
68
|
-
)
|
|
69
|
-
local_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
-
with open(local_dir / LOCAL_CONFIG_NAME, "w") as f:
|
|
71
|
-
toml.dump(data, f)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def load_config() -> dict:
|
|
75
|
-
"""Load merged config: local .bumblebee/config.toml over global ~/.bumblebee/config.toml.
|
|
76
|
-
|
|
77
|
-
Env vars (BB_API_URL etc.) are handled in individual getters, not here.
|
|
78
|
-
Credentials (token) are never read from local config.
|
|
79
|
-
"""
|
|
80
|
-
global_cfg = _load_global_config()
|
|
81
|
-
local_cfg = load_local_config()
|
|
82
|
-
|
|
83
|
-
# Shallow merge: local overrides global for top-level keys
|
|
84
|
-
# But never pull token from local config
|
|
85
|
-
local_cfg.pop("token", None)
|
|
86
|
-
|
|
87
|
-
merged = {**global_cfg, **local_cfg}
|
|
88
|
-
return merged
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def save_config(data: dict):
|
|
92
|
-
"""Save config to global ~/.bumblebee/config.toml (backward compatible)."""
|
|
93
|
-
ensure_config_dir()
|
|
94
|
-
with open(GLOBAL_CONFIG_FILE, "w") as f:
|
|
95
|
-
toml.dump(data, f)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def get_api_url() -> str:
|
|
99
|
-
# Env var takes highest priority
|
|
100
|
-
env_url = os.environ.get("BB_API_URL")
|
|
101
|
-
if env_url:
|
|
102
|
-
return env_url
|
|
103
|
-
cfg = load_config()
|
|
104
|
-
return cfg.get("api_url", "http://localhost:8000")
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def get_token() -> str | None:
|
|
108
|
-
"""Get auth token — always from global config only."""
|
|
109
|
-
cfg = _load_global_config()
|
|
110
|
-
return cfg.get("token")
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def get_current_project() -> str | None:
|
|
114
|
-
cfg = load_config()
|
|
115
|
-
return cfg.get("current_project")
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def get_project_path(slug: str | None = None) -> str | None:
|
|
119
|
-
"""Get the local source code path for a project."""
|
|
120
|
-
cfg = load_config()
|
|
121
|
-
target = slug or cfg.get("current_project")
|
|
122
|
-
if not target:
|
|
123
|
-
return None
|
|
124
|
-
projects = cfg.get("projects", {})
|
|
125
|
-
return projects.get(target, {}).get("path")
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def set_project_path(slug: str, path: str):
|
|
129
|
-
"""Set the local source code path for a project."""
|
|
130
|
-
cfg = _load_global_config()
|
|
131
|
-
if "projects" not in cfg:
|
|
132
|
-
cfg["projects"] = {}
|
|
133
|
-
if slug not in cfg["projects"]:
|
|
134
|
-
cfg["projects"][slug] = {}
|
|
135
|
-
cfg["projects"][slug]["path"] = path
|
|
136
|
-
save_config(cfg)
|
package/python/bb_cli/main.py
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import typer
|
|
2
|
-
|
|
3
|
-
from .commands import agent, auth, board, comment, init, item, label, project, sprint
|
|
4
|
-
|
|
5
|
-
app = typer.Typer(
|
|
6
|
-
name="bb",
|
|
7
|
-
help="Bumblebee - Dev Task Management + Claude Code Automation",
|
|
8
|
-
no_args_is_help=True,
|
|
9
|
-
)
|
|
10
|
-
|
|
11
|
-
# Register subcommands
|
|
12
|
-
app.add_typer(auth.app, name="auth", help="Login, register, logout")
|
|
13
|
-
app.add_typer(project.app, name="project")
|
|
14
|
-
app.add_typer(item.app, name="item")
|
|
15
|
-
app.add_typer(sprint.app, name="sprint")
|
|
16
|
-
app.add_typer(comment.app, name="comment")
|
|
17
|
-
app.add_typer(label.app, name="label")
|
|
18
|
-
app.add_typer(board.app, name="board")
|
|
19
|
-
app.add_typer(agent.app, name="agent")
|
|
20
|
-
|
|
21
|
-
# Top-level commands
|
|
22
|
-
app.command(name="init")(init.init)
|
|
23
|
-
|
|
24
|
-
# Convenience aliases at top level
|
|
25
|
-
login_app = typer.Typer()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@app.command(name="login")
|
|
29
|
-
def login(
|
|
30
|
-
email: str = typer.Option(..., prompt=True),
|
|
31
|
-
password: str = typer.Option(..., prompt=True, hide_input=True),
|
|
32
|
-
):
|
|
33
|
-
"""Shortcut for bb auth login."""
|
|
34
|
-
auth.login(email=email, password=password)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@app.command(name="logout")
|
|
38
|
-
def logout():
|
|
39
|
-
"""Shortcut for bb auth logout."""
|
|
40
|
-
auth.logout()
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if __name__ == "__main__":
|
|
44
|
-
app()
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
"""Rich Live progress tracker for batch agent operations."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import time
|
|
6
|
-
import threading
|
|
7
|
-
|
|
8
|
-
from rich.live import Live
|
|
9
|
-
from rich.table import Table
|
|
10
|
-
from rich.text import Text
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class AgentProgressTracker:
|
|
14
|
-
"""Thread-safe progress tracker with Rich Live table display.
|
|
15
|
-
|
|
16
|
-
Usage:
|
|
17
|
-
with AgentProgressTracker() as tracker:
|
|
18
|
-
# From worker threads:
|
|
19
|
-
tracker.update("BB-42", "execute", "running", "Modifying auth.py")
|
|
20
|
-
tracker.complete("BB-42", True, "Done")
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
def __init__(self):
|
|
24
|
-
self._data: dict[str, dict] = {}
|
|
25
|
-
self._lock = threading.Lock()
|
|
26
|
-
self._start_time = time.monotonic()
|
|
27
|
-
self._completed = 0
|
|
28
|
-
self._total = 0
|
|
29
|
-
self._live: Live | None = None
|
|
30
|
-
|
|
31
|
-
def register(self, item_key: str):
|
|
32
|
-
"""Register an item before work starts."""
|
|
33
|
-
with self._lock:
|
|
34
|
-
self._data[item_key] = {
|
|
35
|
-
"phase": "pending",
|
|
36
|
-
"status": "waiting",
|
|
37
|
-
"last_line": "",
|
|
38
|
-
"start": time.monotonic(),
|
|
39
|
-
}
|
|
40
|
-
self._total += 1
|
|
41
|
-
|
|
42
|
-
def update(self, item_key: str, phase: str, status: str, last_line: str):
|
|
43
|
-
"""Update progress for an item (called from worker threads)."""
|
|
44
|
-
with self._lock:
|
|
45
|
-
if item_key not in self._data:
|
|
46
|
-
self._data[item_key] = {"start": time.monotonic()}
|
|
47
|
-
self._data[item_key].update({
|
|
48
|
-
"phase": phase,
|
|
49
|
-
"status": status,
|
|
50
|
-
"last_line": last_line,
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
def complete(self, item_key: str, success: bool, message: str = ""):
|
|
54
|
-
"""Mark an item as completed."""
|
|
55
|
-
with self._lock:
|
|
56
|
-
status = "done" if success else "failed"
|
|
57
|
-
if item_key in self._data:
|
|
58
|
-
self._data[item_key].update({
|
|
59
|
-
"status": status,
|
|
60
|
-
"last_line": message[:60],
|
|
61
|
-
})
|
|
62
|
-
self._completed += 1
|
|
63
|
-
|
|
64
|
-
def _build_table(self) -> Table:
|
|
65
|
-
"""Build the Rich table (called on each Live refresh)."""
|
|
66
|
-
table = Table(show_header=True, header_style="bold cyan", expand=True)
|
|
67
|
-
table.add_column("Item", style="bold", width=12)
|
|
68
|
-
table.add_column("Phase", width=14)
|
|
69
|
-
table.add_column("Status", width=10)
|
|
70
|
-
table.add_column("Last Output", ratio=1)
|
|
71
|
-
|
|
72
|
-
with self._lock:
|
|
73
|
-
for key, info in self._data.items():
|
|
74
|
-
status = info.get("status", "?")
|
|
75
|
-
phase = info.get("phase", "?")
|
|
76
|
-
last_line = info.get("last_line", "")
|
|
77
|
-
|
|
78
|
-
# Color status
|
|
79
|
-
if status == "running":
|
|
80
|
-
status_text = Text(status, style="yellow")
|
|
81
|
-
elif status == "done":
|
|
82
|
-
status_text = Text(status, style="green")
|
|
83
|
-
elif status == "failed":
|
|
84
|
-
status_text = Text(status, style="red")
|
|
85
|
-
else:
|
|
86
|
-
status_text = Text(status, style="dim")
|
|
87
|
-
|
|
88
|
-
table.add_row(key, phase, status_text, Text(last_line, overflow="ellipsis"))
|
|
89
|
-
|
|
90
|
-
elapsed = time.monotonic() - self._start_time
|
|
91
|
-
mins, secs = divmod(int(elapsed), 60)
|
|
92
|
-
|
|
93
|
-
table.caption = f"Elapsed: {mins}m {secs:02d}s | {self._completed}/{self._total} complete | Ctrl+C to abort"
|
|
94
|
-
return table
|
|
95
|
-
|
|
96
|
-
def __enter__(self):
|
|
97
|
-
self._live = Live(self._build_table(), refresh_per_second=2)
|
|
98
|
-
self._live.__enter__()
|
|
99
|
-
# Start refresh loop
|
|
100
|
-
self._refresh_thread = threading.Thread(target=self._refresh_loop, daemon=True)
|
|
101
|
-
self._stop_refresh = threading.Event()
|
|
102
|
-
self._refresh_thread.start()
|
|
103
|
-
return self
|
|
104
|
-
|
|
105
|
-
def __exit__(self, *args):
|
|
106
|
-
self._stop_refresh.set()
|
|
107
|
-
self._refresh_thread.join(timeout=2)
|
|
108
|
-
if self._live:
|
|
109
|
-
self._live.update(self._build_table())
|
|
110
|
-
self._live.__exit__(*args)
|
|
111
|
-
|
|
112
|
-
def _refresh_loop(self):
|
|
113
|
-
"""Refresh the Live display periodically."""
|
|
114
|
-
while not self._stop_refresh.is_set():
|
|
115
|
-
if self._live:
|
|
116
|
-
self._live.update(self._build_table())
|
|
117
|
-
self._stop_refresh.wait(0.5)
|