boring-cli 0.1.1__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.
@@ -0,0 +1,52 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual environments
24
+ venv/
25
+ ENV/
26
+ env/
27
+ .venv/
28
+
29
+ # IDE
30
+ .idea/
31
+ .vscode/
32
+ *.swp
33
+ *.swo
34
+ *~
35
+
36
+ # Testing
37
+ .pytest_cache/
38
+ .coverage
39
+ htmlcov/
40
+ .tox/
41
+ .nox/
42
+
43
+ # Build
44
+ *.manifest
45
+ *.spec
46
+
47
+ # OS
48
+ .DS_Store
49
+ Thumbs.db
50
+
51
+ # Project specific
52
+ .boring-agents/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Boring Agents Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: boring-cli
3
+ Version: 0.1.1
4
+ Summary: CLI tool for managing Lark Suite tasks
5
+ Project-URL: Homepage, https://github.com/anhbinhnguyen/boring-cli
6
+ Project-URL: Repository, https://github.com/anhbinhnguyen/boring-cli
7
+ Project-URL: Issues, https://github.com/anhbinhnguyen/boring-cli/issues
8
+ Author: Boring Agents Team
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,lark,productivity,tasks
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: click>=8.0.0
25
+ Requires-Dist: httpx>=0.26.0
26
+ Requires-Dist: pyyaml>=6.0
27
+ Requires-Dist: rich>=13.0.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: black>=23.0.0; extra == 'dev'
30
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
31
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # Boring CLI
36
+
37
+ [![PyPI version](https://badge.fury.io/py/boring-cli.svg)](https://badge.fury.io/py/boring-cli)
38
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
40
+
41
+ CLI tool for managing Lark Suite tasks from the command line.
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install boring-cli
47
+ ```
48
+
49
+ ## Quick Start
50
+
51
+ ### 1. Setup
52
+
53
+ Configure the CLI and login to Lark:
54
+
55
+ ```bash
56
+ boring setup
57
+ ```
58
+
59
+ This will prompt you for:
60
+ - Server URL (your Boring Agents API server)
61
+ - Bugs output directory (where tasks will be downloaded)
62
+ - Lark task list GUIDs
63
+ - Lark OAuth login
64
+
65
+ ### 2. Download Tasks
66
+
67
+ Download tasks with Critical/Blocked/High labels to your local folder:
68
+
69
+ ```bash
70
+ boring download
71
+ ```
72
+
73
+ Download with specific labels:
74
+
75
+ ```bash
76
+ boring download --labels "Critical,Blocked"
77
+ ```
78
+
79
+ ### 3. Solve Tasks
80
+
81
+ Move completed tasks (from your bugs folder) to the Solved section in Lark:
82
+
83
+ ```bash
84
+ boring solve
85
+ ```
86
+
87
+ Keep local folders after solving:
88
+
89
+ ```bash
90
+ boring solve --keep
91
+ ```
92
+
93
+ ### 4. Check Status
94
+
95
+ View your current configuration:
96
+
97
+ ```bash
98
+ boring status
99
+ ```
100
+
101
+ ## Commands
102
+
103
+ | Command | Description |
104
+ |---------|-------------|
105
+ | `boring setup` | Configure CLI and login to Lark |
106
+ | `boring download` | Download tasks to local folder |
107
+ | `boring solve` | Move tasks to Solved section |
108
+ | `boring status` | Show current configuration |
109
+ | `boring --version` | Show version |
110
+ | `boring --help` | Show help |
111
+
112
+ ## Configuration
113
+
114
+ Configuration is stored in `~/.boring-agents/config.yaml`:
115
+
116
+ ```yaml
117
+ server_url: https://boring.omelet.tech/api
118
+ jwt_token: eyJhbGc...
119
+ bugs_dir: /path/to/bugs
120
+ tasklist_guid: xxxx-xxxx-xxxx
121
+ section_guid: xxxx-xxxx-xxxx
122
+ solved_section_guid: xxxx-xxxx-xxxx
123
+ ```
124
+
125
+ ## Requirements
126
+
127
+ - Python 3.9+
128
+ - A running [Boring Agents](https://github.com/anhbinhnguyen/boring-agents) server
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ # Clone the repo
134
+ git clone https://github.com/anhbinhnguyen/boring-cli.git
135
+ cd boring-cli
136
+
137
+ # Install in development mode
138
+ pip install -e ".[dev]"
139
+
140
+ # Run tests
141
+ pytest
142
+
143
+ # Format code
144
+ black src/
145
+ ruff check src/
146
+ ```
147
+
148
+ ## License
149
+
150
+ MIT
@@ -0,0 +1,116 @@
1
+ # Boring CLI
2
+
3
+ [![PyPI version](https://badge.fury.io/py/boring-cli.svg)](https://badge.fury.io/py/boring-cli)
4
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ CLI tool for managing Lark Suite tasks from the command line.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install boring-cli
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ### 1. Setup
18
+
19
+ Configure the CLI and login to Lark:
20
+
21
+ ```bash
22
+ boring setup
23
+ ```
24
+
25
+ This will prompt you for:
26
+ - Server URL (your Boring Agents API server)
27
+ - Bugs output directory (where tasks will be downloaded)
28
+ - Lark task list GUIDs
29
+ - Lark OAuth login
30
+
31
+ ### 2. Download Tasks
32
+
33
+ Download tasks with Critical/Blocked/High labels to your local folder:
34
+
35
+ ```bash
36
+ boring download
37
+ ```
38
+
39
+ Download with specific labels:
40
+
41
+ ```bash
42
+ boring download --labels "Critical,Blocked"
43
+ ```
44
+
45
+ ### 3. Solve Tasks
46
+
47
+ Move completed tasks (from your bugs folder) to the Solved section in Lark:
48
+
49
+ ```bash
50
+ boring solve
51
+ ```
52
+
53
+ Keep local folders after solving:
54
+
55
+ ```bash
56
+ boring solve --keep
57
+ ```
58
+
59
+ ### 4. Check Status
60
+
61
+ View your current configuration:
62
+
63
+ ```bash
64
+ boring status
65
+ ```
66
+
67
+ ## Commands
68
+
69
+ | Command | Description |
70
+ |---------|-------------|
71
+ | `boring setup` | Configure CLI and login to Lark |
72
+ | `boring download` | Download tasks to local folder |
73
+ | `boring solve` | Move tasks to Solved section |
74
+ | `boring status` | Show current configuration |
75
+ | `boring --version` | Show version |
76
+ | `boring --help` | Show help |
77
+
78
+ ## Configuration
79
+
80
+ Configuration is stored in `~/.boring-agents/config.yaml`:
81
+
82
+ ```yaml
83
+ server_url: https://boring.omelet.tech/api
84
+ jwt_token: eyJhbGc...
85
+ bugs_dir: /path/to/bugs
86
+ tasklist_guid: xxxx-xxxx-xxxx
87
+ section_guid: xxxx-xxxx-xxxx
88
+ solved_section_guid: xxxx-xxxx-xxxx
89
+ ```
90
+
91
+ ## Requirements
92
+
93
+ - Python 3.9+
94
+ - A running [Boring Agents](https://github.com/anhbinhnguyen/boring-agents) server
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ # Clone the repo
100
+ git clone https://github.com/anhbinhnguyen/boring-cli.git
101
+ cd boring-cli
102
+
103
+ # Install in development mode
104
+ pip install -e ".[dev]"
105
+
106
+ # Run tests
107
+ pytest
108
+
109
+ # Format code
110
+ black src/
111
+ ruff check src/
112
+ ```
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "boring-cli"
7
+ version = "0.1.1"
8
+ description = "CLI tool for managing Lark Suite tasks"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [
12
+ { name = "Boring Agents Team" }
13
+ ]
14
+ keywords = ["lark", "tasks", "cli", "productivity"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Utilities",
27
+ ]
28
+ requires-python = ">=3.9"
29
+ dependencies = [
30
+ "click>=8.0.0",
31
+ "httpx>=0.26.0",
32
+ "pyyaml>=6.0",
33
+ "rich>=13.0.0",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pytest>=7.0.0",
39
+ "pytest-cov>=4.0.0",
40
+ "black>=23.0.0",
41
+ "ruff>=0.1.0",
42
+ ]
43
+
44
+ [project.scripts]
45
+ boring = "boring.main:main"
46
+
47
+ [project.urls]
48
+ Homepage = "https://github.com/anhbinhnguyen/boring-cli"
49
+ Repository = "https://github.com/anhbinhnguyen/boring-cli"
50
+ Issues = "https://github.com/anhbinhnguyen/boring-cli/issues"
51
+
52
+ [tool.hatch.build.targets.sdist]
53
+ include = [
54
+ "/src",
55
+ ]
56
+
57
+ [tool.hatch.build.targets.wheel]
58
+ packages = ["src/boring"]
59
+
60
+ [tool.ruff]
61
+ line-length = 100
62
+ target-version = "py39"
63
+
64
+ [tool.black]
65
+ line-length = 100
66
+ target-version = ["py39"]
@@ -0,0 +1,3 @@
1
+ """Boring CLI - Manage Lark Suite tasks from the command line."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,121 @@
1
+ """HTTP client for Boring Agents API."""
2
+
3
+ from typing import Optional
4
+
5
+ import httpx
6
+
7
+ from .config import get_jwt_token, get_server_url
8
+
9
+
10
+ class APIClient:
11
+ """Client for interacting with the Boring Agents API."""
12
+
13
+ def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None):
14
+ self.base_url = base_url or get_server_url()
15
+ self.token = token or get_jwt_token()
16
+
17
+ def _headers(self) -> dict:
18
+ return {
19
+ "Authorization": f"Bearer {self.token}",
20
+ "Content-Type": "application/json",
21
+ }
22
+
23
+ def _check_config(self) -> None:
24
+ if not self.base_url:
25
+ raise Exception("Server URL not configured. Run 'boring setup' first.")
26
+ if not self.token:
27
+ raise Exception("Not logged in. Run 'boring setup' first.")
28
+
29
+ def get_login_url(self) -> str:
30
+ """Get the Lark OAuth login URL."""
31
+ if not self.base_url:
32
+ raise Exception("Server URL not configured.")
33
+ with httpx.Client() as client:
34
+ response = client.get(f"{self.base_url}/api/v1/auth/login")
35
+ response.raise_for_status()
36
+ return response.json().get("auth_url")
37
+
38
+ def complete_login(self, code: str) -> dict:
39
+ """Complete the OAuth login with the authorization code."""
40
+ if not self.base_url:
41
+ raise Exception("Server URL not configured.")
42
+ with httpx.Client() as client:
43
+ response = client.get(
44
+ f"{self.base_url}/api/v1/auth/callback", params={"code": code}
45
+ )
46
+ response.raise_for_status()
47
+ return response.json()
48
+
49
+ def get_me(self) -> dict:
50
+ """Get current user information."""
51
+ self._check_config()
52
+ with httpx.Client() as client:
53
+ response = client.get(
54
+ f"{self.base_url}/api/v1/auth/me", headers=self._headers()
55
+ )
56
+ response.raise_for_status()
57
+ return response.json()
58
+
59
+ def get_tasks(
60
+ self, labels: Optional[str] = None, section_guid: Optional[str] = None
61
+ ) -> dict:
62
+ """Get tasks with optional filters."""
63
+ self._check_config()
64
+ params = {}
65
+ if labels:
66
+ params["labels"] = labels
67
+ if section_guid:
68
+ params["section_guid"] = section_guid
69
+
70
+ with httpx.Client() as client:
71
+ response = client.get(
72
+ f"{self.base_url}/api/v1/tasks/",
73
+ headers=self._headers(),
74
+ params=params,
75
+ )
76
+ response.raise_for_status()
77
+ return response.json()
78
+
79
+ def get_critical_tasks(self) -> dict:
80
+ """Get critical and blocked tasks."""
81
+ self._check_config()
82
+ with httpx.Client() as client:
83
+ response = client.get(
84
+ f"{self.base_url}/api/v1/tasks/critical", headers=self._headers()
85
+ )
86
+ response.raise_for_status()
87
+ return response.json()
88
+
89
+ def download_tasks(
90
+ self, labels: Optional[str] = None, section_guid: Optional[str] = None
91
+ ) -> dict:
92
+ """Download tasks as markdown content."""
93
+ self._check_config()
94
+ params = {}
95
+ if labels:
96
+ params["labels"] = labels
97
+ if section_guid:
98
+ params["section_guid"] = section_guid
99
+
100
+ with httpx.Client(timeout=120) as client:
101
+ response = client.get(
102
+ f"{self.base_url}/api/v1/tasks/download",
103
+ headers=self._headers(),
104
+ params=params,
105
+ )
106
+ response.raise_for_status()
107
+ return response.json()
108
+
109
+ def solve_task(
110
+ self, task_guid: str, tasklist_guid: str, section_guid: str
111
+ ) -> dict:
112
+ """Move a task to the solved section."""
113
+ self._check_config()
114
+ with httpx.Client() as client:
115
+ response = client.post(
116
+ f"{self.base_url}/api/v1/tasks/{task_guid}/solve",
117
+ headers=self._headers(),
118
+ json={"tasklist_guid": tasklist_guid, "section_guid": section_guid},
119
+ )
120
+ response.raise_for_status()
121
+ return response.json()
@@ -0,0 +1 @@
1
+ """Boring CLI commands."""
@@ -0,0 +1,89 @@
1
+ """Download command for Boring CLI."""
2
+
3
+ import base64
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.progress import Progress, SpinnerColumn, TextColumn
10
+
11
+ from .. import config
12
+ from ..client import APIClient
13
+
14
+ console = Console()
15
+
16
+
17
+ @click.command()
18
+ @click.option("--labels", default=None, help="Comma-separated labels to filter")
19
+ def download(labels: str):
20
+ """Download tasks from Lark and save as markdown files."""
21
+ if not config.is_configured():
22
+ console.print("[bold red]CLI not configured.[/bold red] Run 'boring setup' first.")
23
+ raise click.Abort()
24
+
25
+ bugs_dir = config.get_bugs_dir()
26
+ section_guid = config.get_section_guid()
27
+
28
+ if not bugs_dir:
29
+ console.print("[bold red]Bugs directory not configured.[/bold red] Run 'boring setup' first.")
30
+ raise click.Abort()
31
+
32
+ console.print(f"[bold]Downloading tasks to:[/bold] [cyan]{bugs_dir}[/cyan]")
33
+ if section_guid:
34
+ console.print(f"[dim]Filtering by section: {section_guid}[/dim]")
35
+ if labels:
36
+ console.print(f"[dim]Filtering by labels: {labels}[/dim]")
37
+
38
+ client = APIClient()
39
+
40
+ with Progress(
41
+ SpinnerColumn(),
42
+ TextColumn("[progress.description]{task.description}"),
43
+ console=console,
44
+ ) as progress:
45
+ task = progress.add_task("Fetching tasks from server...", total=None)
46
+
47
+ try:
48
+ result = client.download_tasks(labels=labels, section_guid=section_guid)
49
+ except Exception as e:
50
+ console.print(f"[bold red]Failed to download tasks:[/bold red] {e}")
51
+ raise click.Abort()
52
+
53
+ progress.update(task, description="Processing tasks...")
54
+
55
+ tasks = result.get("tasks", [])
56
+ count = result.get("count", 0)
57
+
58
+ if count == 0:
59
+ console.print("[yellow]No tasks found matching criteria.[/yellow]")
60
+ return
61
+
62
+ console.print(f"\n[bold green]Found {count} task(s)[/bold green]\n")
63
+
64
+ os.makedirs(bugs_dir, exist_ok=True)
65
+
66
+ for i, task_data in enumerate(tasks, 1):
67
+ guid = task_data.get("guid")
68
+ summary = task_data.get("summary", "No title")[:50]
69
+ console.print(f"[{i}/{count}] Processing: [cyan]{summary}[/cyan]...")
70
+
71
+ task_dir = Path(bugs_dir) / guid
72
+ task_dir.mkdir(parents=True, exist_ok=True)
73
+
74
+ md_path = task_dir / "description.md"
75
+ with open(md_path, "w", encoding="utf-8") as f:
76
+ f.write(task_data.get("markdown_content", ""))
77
+
78
+ for img in task_data.get("images", []):
79
+ img_index = img.get("index", 1)
80
+ img_data = img.get("data")
81
+ if img_data:
82
+ img_bytes = base64.b64decode(img_data)
83
+ img_path = task_dir / f"image_{img_index}.jpg"
84
+ with open(img_path, "wb") as f:
85
+ f.write(img_bytes)
86
+
87
+ console.print(f" [dim]Saved to {task_dir}/description.md[/dim]")
88
+
89
+ console.print(f"\n[bold green]Done![/bold green] {count} task(s) saved to '{bugs_dir}/'")
@@ -0,0 +1,132 @@
1
+ """Setup command for Boring CLI."""
2
+
3
+ import threading
4
+ import webbrowser
5
+ from http.server import BaseHTTPRequestHandler, HTTPServer
6
+ from urllib.parse import parse_qs, urlparse
7
+
8
+ import click
9
+ from rich.console import Console
10
+
11
+ from .. import config
12
+ from ..client import APIClient
13
+
14
+ console = Console()
15
+
16
+
17
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
18
+ """Handler for OAuth callback."""
19
+
20
+ code = None
21
+
22
+ def do_GET(self):
23
+ query = urlparse(self.path).query
24
+ params = parse_qs(query)
25
+ if "code" in params:
26
+ OAuthCallbackHandler.code = params["code"][0]
27
+ self.send_response(200)
28
+ self.send_header("Content-type", "text/html")
29
+ self.end_headers()
30
+ self.wfile.write(
31
+ b"""
32
+ <html>
33
+ <body style="font-family: sans-serif; text-align: center; padding: 50px;">
34
+ <h1 style="color: #10B981;">Login Successful!</h1>
35
+ <p>You can close this window and return to the terminal.</p>
36
+ </body>
37
+ </html>
38
+ """
39
+ )
40
+ else:
41
+ self.send_response(400)
42
+ self.end_headers()
43
+ self.wfile.write(b"Missing code parameter")
44
+
45
+ def log_message(self, format, *args):
46
+ pass
47
+
48
+
49
+ @click.command()
50
+ @click.option(
51
+ "--server-url",
52
+ prompt="Server URL",
53
+ default=lambda: config.get_server_url() or "https://boring.omelet.tech/api",
54
+ help="URL of the Boring Agents API server",
55
+ )
56
+ def setup(server_url: str):
57
+ """Configure the CLI and login to Lark."""
58
+ console.print("\n[bold blue]Configuring Boring CLI...[/bold blue]")
59
+ console.print(f"Server URL: [cyan]{server_url}[/cyan]")
60
+
61
+ config.set_server_url(server_url)
62
+
63
+ bugs_dir = click.prompt(
64
+ "Bugs output directory", default=config.get_bugs_dir() or "/tmp/bugs"
65
+ )
66
+ config.set_bugs_dir(bugs_dir)
67
+
68
+ tasklist_guid = click.prompt(
69
+ "Tasklist GUID (from Lark)", default=config.get_tasklist_guid() or ""
70
+ )
71
+ if tasklist_guid:
72
+ config.set_tasklist_guid(tasklist_guid)
73
+
74
+ section_guid = click.prompt(
75
+ "In-progress Section GUID", default=config.get_section_guid() or ""
76
+ )
77
+ if section_guid:
78
+ config.set_section_guid(section_guid)
79
+
80
+ solved_section_guid = click.prompt(
81
+ "Solved Section GUID", default=config.get_solved_section_guid() or ""
82
+ )
83
+ if solved_section_guid:
84
+ config.set_solved_section_guid(solved_section_guid)
85
+
86
+ console.print("\n[bold]Starting Lark OAuth login...[/bold]")
87
+
88
+ server = HTTPServer(("localhost", 9876), OAuthCallbackHandler)
89
+ server_thread = threading.Thread(target=server.handle_request)
90
+ server_thread.start()
91
+
92
+ client = APIClient()
93
+ try:
94
+ auth_url = client.get_login_url()
95
+ full_auth_url = auth_url + "&redirect_uri=http://localhost:9876/callback"
96
+ console.print("\n[yellow]Opening browser for Lark login...[/yellow]")
97
+ console.print(f"If browser doesn't open, visit: [link]{full_auth_url}[/link]")
98
+ webbrowser.open(full_auth_url)
99
+ except Exception as e:
100
+ console.print(f"\n[yellow]Note: Could not get auth URL from server: {e}[/yellow]")
101
+ console.print("Please complete OAuth flow manually and get the code.")
102
+ code = click.prompt("Enter the OAuth code")
103
+ OAuthCallbackHandler.code = code
104
+
105
+ if not OAuthCallbackHandler.code:
106
+ console.print("[dim]Waiting for OAuth callback...[/dim]")
107
+ server_thread.join(timeout=300)
108
+
109
+ if OAuthCallbackHandler.code:
110
+ try:
111
+ result = client.complete_login(OAuthCallbackHandler.code)
112
+ token = result.get("token", {}).get("access_token")
113
+ user = result.get("user", {})
114
+
115
+ config.set_jwt_token(token)
116
+
117
+ console.print("\n[bold green]Login successful![/bold green]")
118
+ console.print(
119
+ f"Logged in as: [cyan]{user.get('name') or user.get('email') or 'User'}[/cyan]"
120
+ )
121
+ console.print(f"\nConfiguration saved to: [dim]{config.CONFIG_FILE}[/dim]")
122
+ except Exception as e:
123
+ console.print(f"\n[bold red]Login failed: {e}[/bold red]")
124
+ raise click.Abort()
125
+ else:
126
+ console.print("\n[bold red]OAuth flow timed out or failed.[/bold red]")
127
+ raise click.Abort()
128
+
129
+ server.server_close()
130
+ console.print(
131
+ "\n[bold green]Setup complete![/bold green] You can now use 'boring download' and 'boring solve'."
132
+ )
@@ -0,0 +1,82 @@
1
+ """Solve command for Boring CLI."""
2
+
3
+ import os
4
+ import shutil
5
+
6
+ import click
7
+ from rich.console import Console
8
+
9
+ from .. import config
10
+ from ..client import APIClient
11
+
12
+ console = Console()
13
+
14
+
15
+ def get_task_folders(bugs_dir: str) -> list:
16
+ """Get all task folders from the bugs directory."""
17
+ folders = []
18
+ if not os.path.exists(bugs_dir):
19
+ return folders
20
+ for name in os.listdir(bugs_dir):
21
+ path = os.path.join(bugs_dir, name)
22
+ # UUID format check
23
+ if os.path.isdir(path) and len(name) == 36 and name.count("-") == 4:
24
+ folders.append((name, path))
25
+ return folders
26
+
27
+
28
+ @click.command()
29
+ @click.option("--keep", is_flag=True, help="Keep local folders after solving")
30
+ def solve(keep: bool):
31
+ """Move completed tasks to Solved section in Lark."""
32
+ if not config.is_configured():
33
+ console.print("[bold red]CLI not configured.[/bold red] Run 'boring setup' first.")
34
+ raise click.Abort()
35
+
36
+ bugs_dir = config.get_bugs_dir()
37
+ tasklist_guid = config.get_tasklist_guid()
38
+ solved_section_guid = config.get_solved_section_guid()
39
+
40
+ if not bugs_dir:
41
+ console.print("[bold red]Bugs directory not configured.[/bold red] Run 'boring setup' first.")
42
+ raise click.Abort()
43
+
44
+ if not tasklist_guid or not solved_section_guid:
45
+ console.print(
46
+ "[bold red]Tasklist GUID and Solved Section GUID required.[/bold red] "
47
+ "Run 'boring setup' first."
48
+ )
49
+ raise click.Abort()
50
+
51
+ task_folders = get_task_folders(bugs_dir)
52
+
53
+ if not task_folders:
54
+ console.print("[yellow]No tasks found in bugs folder.[/yellow]")
55
+ return
56
+
57
+ console.print(f"[bold]Found {len(task_folders)} task(s) to move to Solved[/bold]\n")
58
+
59
+ client = APIClient()
60
+ success_count = 0
61
+
62
+ for task_guid, folder_path in task_folders:
63
+ try:
64
+ result = client.solve_task(
65
+ task_guid=task_guid,
66
+ tasklist_guid=tasklist_guid,
67
+ section_guid=solved_section_guid,
68
+ )
69
+
70
+ if result.get("success"):
71
+ console.print(f"[green]OK[/green] - {task_guid}")
72
+ if not keep:
73
+ shutil.rmtree(folder_path)
74
+ success_count += 1
75
+ else:
76
+ console.print(f"[red]FAIL[/red] - {task_guid}: {result.get('message')}")
77
+ except Exception as e:
78
+ console.print(f"[red]ERROR[/red] - {task_guid}: {e}")
79
+
80
+ console.print(
81
+ f"\n[bold green]Done![/bold green] Moved {success_count}/{len(task_folders)} task(s) to Solved."
82
+ )
@@ -0,0 +1,83 @@
1
+ """Status command for Boring CLI."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from .. import config
8
+
9
+ console = Console()
10
+
11
+
12
+ @click.command()
13
+ def status():
14
+ """Show current configuration status."""
15
+ cfg = config.load_config()
16
+
17
+ table = Table(title="Boring CLI Configuration")
18
+ table.add_column("Setting", style="cyan")
19
+ table.add_column("Value", style="green")
20
+ table.add_column("Status", style="yellow")
21
+
22
+ # Server URL
23
+ server_url = cfg.get("server_url", "")
24
+ table.add_row(
25
+ "Server URL",
26
+ server_url or "[dim]Not set[/dim]",
27
+ "[green]OK[/green]" if server_url else "[red]Missing[/red]",
28
+ )
29
+
30
+ # JWT Token
31
+ jwt_token = cfg.get("jwt_token", "")
32
+ token_display = f"{jwt_token[:20]}..." if jwt_token else "[dim]Not set[/dim]"
33
+ table.add_row(
34
+ "JWT Token",
35
+ token_display,
36
+ "[green]OK[/green]" if jwt_token else "[red]Missing[/red]",
37
+ )
38
+
39
+ # Bugs Directory
40
+ bugs_dir = cfg.get("bugs_dir", "")
41
+ table.add_row(
42
+ "Bugs Directory",
43
+ bugs_dir or "[dim]Not set[/dim]",
44
+ "[green]OK[/green]" if bugs_dir else "[red]Missing[/red]",
45
+ )
46
+
47
+ # Tasklist GUID
48
+ tasklist_guid = cfg.get("tasklist_guid", "")
49
+ table.add_row(
50
+ "Tasklist GUID",
51
+ tasklist_guid or "[dim]Not set[/dim]",
52
+ "[green]OK[/green]" if tasklist_guid else "[yellow]Optional[/yellow]",
53
+ )
54
+
55
+ # Section GUID
56
+ section_guid = cfg.get("section_guid", "")
57
+ table.add_row(
58
+ "Section GUID",
59
+ section_guid or "[dim]Not set[/dim]",
60
+ "[green]OK[/green]" if section_guid else "[yellow]Optional[/yellow]",
61
+ )
62
+
63
+ # Solved Section GUID
64
+ solved_section_guid = cfg.get("solved_section_guid", "")
65
+ table.add_row(
66
+ "Solved Section GUID",
67
+ solved_section_guid or "[dim]Not set[/dim]",
68
+ "[green]OK[/green]" if solved_section_guid else "[yellow]Optional[/yellow]",
69
+ )
70
+
71
+ console.print()
72
+ console.print(table)
73
+ console.print()
74
+
75
+ if config.is_configured():
76
+ console.print("[bold green]CLI is properly configured![/bold green]")
77
+ else:
78
+ console.print(
79
+ "[bold yellow]CLI is not fully configured.[/bold yellow] "
80
+ "Run 'boring setup' to complete configuration."
81
+ )
82
+
83
+ console.print(f"\n[dim]Config file: {config.CONFIG_FILE}[/dim]")
@@ -0,0 +1,97 @@
1
+ """Configuration management for Boring CLI."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import yaml
8
+
9
+ CONFIG_DIR = Path.home() / ".boring-agents"
10
+ CONFIG_FILE = CONFIG_DIR / "config.yaml"
11
+
12
+
13
+ def ensure_config_dir() -> None:
14
+ """Ensure the config directory exists."""
15
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
16
+
17
+
18
+ def load_config() -> dict:
19
+ """Load configuration from file."""
20
+ if not CONFIG_FILE.exists():
21
+ return {}
22
+ with open(CONFIG_FILE, "r") as f:
23
+ return yaml.safe_load(f) or {}
24
+
25
+
26
+ def save_config(config: dict) -> None:
27
+ """Save configuration to file."""
28
+ ensure_config_dir()
29
+ with open(CONFIG_FILE, "w") as f:
30
+ yaml.dump(config, f, default_flow_style=False)
31
+
32
+
33
+ def get_value(key: str) -> Optional[str]:
34
+ """Get a configuration value."""
35
+ return load_config().get(key)
36
+
37
+
38
+ def set_value(key: str, value: str) -> None:
39
+ """Set a configuration value."""
40
+ config = load_config()
41
+ config[key] = value
42
+ save_config(config)
43
+
44
+
45
+ # Convenience accessors
46
+ def get_server_url() -> Optional[str]:
47
+ return get_value("server_url")
48
+
49
+
50
+ def get_jwt_token() -> Optional[str]:
51
+ return get_value("jwt_token")
52
+
53
+
54
+ def get_bugs_dir() -> Optional[str]:
55
+ return get_value("bugs_dir")
56
+
57
+
58
+ def get_tasklist_guid() -> Optional[str]:
59
+ return get_value("tasklist_guid")
60
+
61
+
62
+ def get_section_guid() -> Optional[str]:
63
+ return get_value("section_guid")
64
+
65
+
66
+ def get_solved_section_guid() -> Optional[str]:
67
+ return get_value("solved_section_guid")
68
+
69
+
70
+ def set_server_url(url: str) -> None:
71
+ set_value("server_url", url)
72
+
73
+
74
+ def set_jwt_token(token: str) -> None:
75
+ set_value("jwt_token", token)
76
+
77
+
78
+ def set_bugs_dir(path: str) -> None:
79
+ set_value("bugs_dir", path)
80
+
81
+
82
+ def set_tasklist_guid(guid: str) -> None:
83
+ set_value("tasklist_guid", guid)
84
+
85
+
86
+ def set_section_guid(guid: str) -> None:
87
+ set_value("section_guid", guid)
88
+
89
+
90
+ def set_solved_section_guid(guid: str) -> None:
91
+ set_value("solved_section_guid", guid)
92
+
93
+
94
+ def is_configured() -> bool:
95
+ """Check if the CLI is properly configured."""
96
+ config = load_config()
97
+ return all([config.get("server_url"), config.get("jwt_token"), config.get("bugs_dir")])
@@ -0,0 +1,39 @@
1
+ """Main entry point for Boring CLI."""
2
+
3
+ import click
4
+
5
+ from . import __version__
6
+ from .commands.download import download
7
+ from .commands.setup import setup
8
+ from .commands.solve import solve
9
+ from .commands.status import status
10
+
11
+
12
+ @click.group()
13
+ @click.version_option(version=__version__, prog_name="boring")
14
+ def cli():
15
+ """Boring CLI - Manage Lark tasks from the command line.
16
+
17
+ \b
18
+ Quick start:
19
+ boring setup Configure and login to Lark
20
+ boring download Download tasks to local folder
21
+ boring solve Move completed tasks to Solved
22
+ boring status Show current configuration
23
+ """
24
+ pass
25
+
26
+
27
+ cli.add_command(setup)
28
+ cli.add_command(download)
29
+ cli.add_command(solve)
30
+ cli.add_command(status)
31
+
32
+
33
+ def main():
34
+ """Main entry point."""
35
+ cli()
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()