Skip to content

Commit fa6b917

Browse files
bojielicursoragent
andcommitted
Add pineai-cli: unified CLI for Pine AI voice calls & assistant tasks
Unified CLI wrapping both pine-voice and pine-assistant Python SDKs. Commands: auth (login/status/logout), voice (call/status), chat, send, sessions (list/get/create/delete), task (start/stop). Includes CI workflows, trusted PyPI publishing, and comprehensive error handling. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d132d92 commit fa6b917

14 files changed

Lines changed: 752 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_call:
9+
10+
jobs:
11+
build:
12+
runs-on: ubuntu-latest
13+
14+
strategy:
15+
matrix:
16+
python-version: ["3.10", "3.11", "3.12", "3.13"]
17+
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- name: Set up Python ${{ matrix.python-version }}
22+
uses: actions/setup-python@v5
23+
with:
24+
python-version: ${{ matrix.python-version }}
25+
26+
- name: Install build tools
27+
run: pip install build twine
28+
29+
- name: Install package
30+
run: pip install .
31+
32+
- name: Verify import
33+
run: python -c "from pine_cli import __version__; print(f'pineai-cli {__version__} OK')"
34+
35+
- name: Verify CLI entry point
36+
run: pine --version
37+
38+
- name: Build distribution
39+
run: python -m build
40+
41+
- name: Check distribution
42+
run: twine check dist/*

.github/workflows/publish.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
push:
5+
tags: ["v*"]
6+
7+
jobs:
8+
ci:
9+
uses: ./.github/workflows/ci.yml
10+
11+
publish:
12+
needs: ci
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: read
16+
id-token: write
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- uses: actions/setup-python@v5
21+
with:
22+
python-version: "3.12"
23+
24+
- name: Install build tools
25+
run: pip install build
26+
27+
- name: Build distribution
28+
run: python -m build
29+
30+
- name: Publish to PyPI
31+
uses: pypa/gh-action-pypi-publish@release/v1
32+
with:
33+
attestations: true

.gitignore

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
__pycache__/
2+
*.pyc
3+
*.pyo
4+
dist/
5+
build/
6+
*.egg-info/
7+
*.egg
8+
.eggs/
9+
*.whl
10+
.venv/
11+
venv/
12+
.env
13+
.pytest_cache/
14+
.ruff_cache/
15+
.mypy_cache/

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Pine AI
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.

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Unified command-line interface for [Pine AI](https://www.19pine.ai) — voice ca
55
## Install
66

77
```bash
8-
pip install pine-cli
8+
pip install pineai-cli
99
```
1010

1111
Or install from source:
@@ -101,4 +101,7 @@ Credentials are stored at `~/.pine/config.json` after `pine auth login`. Both vo
101101
- [pine-assistant](https://pypi.org/project/pine-assistant/) — Pine AI Assistant SDK
102102
- [click](https://click.palletsprojects.com/) — CLI framework
103103
- [rich](https://rich.readthedocs.io/) — Terminal formatting
104-
# pine-cli
104+
105+
## License
106+
107+
MIT — see [LICENSE](LICENSE).

pyproject.toml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "pineai-cli"
7+
version = "0.1.0"
8+
description = "Unified CLI for Pine AI — voice calls & assistant tasks from your terminal"
9+
readme = "README.md"
10+
license = "MIT"
11+
requires-python = ">=3.10"
12+
authors = [{ name = "Pine AI" }]
13+
keywords = ["pine", "pine-ai", "cli", "voice", "assistant", "customer-service", "phone"]
14+
classifiers = [
15+
"Development Status :: 3 - Alpha",
16+
"Intended Audience :: Developers",
17+
"Intended Audience :: End Users/Desktop",
18+
"License :: OSI Approved :: MIT License",
19+
"Programming Language :: Python :: 3",
20+
"Programming Language :: Python :: 3.10",
21+
"Programming Language :: Python :: 3.11",
22+
"Programming Language :: Python :: 3.12",
23+
"Programming Language :: Python :: 3.13",
24+
"Environment :: Console",
25+
"Topic :: Communications :: Telephony",
26+
"Topic :: Office/Business",
27+
]
28+
dependencies = [
29+
"pine-voice>=0.1.5",
30+
"pine-assistant>=0.2.0",
31+
"click>=8.1.0",
32+
"rich>=13.0.0",
33+
]
34+
35+
[project.urls]
36+
Homepage = "https://github.com/19PINE-AI/pineai-cli"
37+
Repository = "https://github.com/19PINE-AI/pineai-cli"
38+
Issues = "https://github.com/19PINE-AI/pineai-cli/issues"
39+
Documentation = "https://pineclaw.com"
40+
41+
[project.scripts]
42+
pine = "pine_cli.main:main"
43+
44+
[tool.hatch.build.targets.wheel]
45+
packages = ["src/pine_cli"]
46+
47+
[tool.ruff]
48+
target-version = "py310"
49+
line-length = 120
50+
51+
[tool.ruff.lint]
52+
select = ["E", "F", "I", "W", "UP", "B", "SIM"]
53+
ignore = ["E501"]
54+
55+
[tool.ruff.lint.isort]
56+
known-first-party = ["pine_cli"]

src/pine_cli/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Pine CLI — unified command-line interface for Pine AI."""
2+
3+
__version__ = "0.1.0"

src/pine_cli/auth.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""pine auth login|status|logout — shared authentication for Voice & Assistant."""
2+
3+
from typing import Optional
4+
5+
import click
6+
from rich.console import Console
7+
8+
from pine_cli.config import load_config, save_config, run_async, handle_api_errors
9+
10+
console = Console()
11+
12+
13+
@click.group()
14+
def auth():
15+
"""Authentication commands."""
16+
17+
18+
@auth.command("login")
19+
@click.option("--base-url", default=None, help="Pine AI base URL override")
20+
@handle_api_errors
21+
def login(base_url: Optional[str]):
22+
"""Log in with email verification."""
23+
from pine_assistant.client import AsyncPineAI
24+
25+
async def _login():
26+
cfg = load_config()
27+
url = base_url or cfg.get("base_url", "https://www.19pine.ai")
28+
client = AsyncPineAI(base_url=url)
29+
30+
email = click.prompt("Email")
31+
with console.status("Sending verification code…"):
32+
result = await client.auth.request_code(email)
33+
console.print("[green]✓ Code sent — check your email.[/green]")
34+
35+
code = click.prompt("Verification code")
36+
with console.status("Verifying…"):
37+
verify = await client.auth.verify_code(email, code, result["request_token"])
38+
39+
save_config({
40+
**cfg,
41+
"access_token": verify["access_token"],
42+
"user_id": verify["id"],
43+
"email": verify["email"],
44+
"base_url": url,
45+
})
46+
console.print(f"[green]✓ Logged in as {verify['email']}[/green] (user {verify['id']})")
47+
console.print("[dim]Credentials saved to ~/.pine/config.json[/dim]")
48+
49+
run_async(_login())
50+
51+
52+
@auth.command("status")
53+
def status():
54+
"""Show current authentication status."""
55+
cfg = load_config()
56+
if cfg.get("access_token"):
57+
console.print(f"[green]● Logged in[/green] {cfg.get('email', '?')} (user {cfg.get('user_id', '?')})")
58+
console.print(f"[dim]Base URL: {cfg.get('base_url', 'https://www.19pine.ai')}[/dim]")
59+
else:
60+
console.print("[yellow]○ Not logged in.[/yellow] Run [bold]pine auth login[/bold].")
61+
62+
63+
@auth.command("logout")
64+
def logout():
65+
"""Clear saved credentials."""
66+
save_config({})
67+
console.print("[green]✓ Logged out. Credentials removed.[/green]")

src/pine_cli/chat.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""pine chat / pine send — interactive and one-shot messaging."""
2+
3+
import json
4+
from typing import Optional
5+
6+
import click
7+
from rich.console import Console
8+
from rich.panel import Panel
9+
10+
from pine_assistant.models.events import S2CEvent
11+
from pine_cli.config import get_assistant_client, run_async, handle_api_errors
12+
13+
console = Console()
14+
15+
16+
@click.command("chat")
17+
@click.argument("session_id", required=False)
18+
@handle_api_errors
19+
def chat_cmd(session_id: Optional[str]):
20+
"""Interactive chat with Pine AI (REPL)."""
21+
async def _chat():
22+
client = get_assistant_client()
23+
await client.connect()
24+
25+
sid = session_id
26+
if not sid:
27+
with console.status("Creating session…"):
28+
s = await client.sessions.create()
29+
sid = s["id"]
30+
console.print(f"[dim]Session: {sid}[/dim]")
31+
32+
await client.join_session(sid)
33+
console.print("[cyan]Type your message (Ctrl+C or /quit to exit)[/cyan]\n")
34+
35+
try:
36+
while True:
37+
msg = click.prompt("You", prompt_suffix=": ")
38+
if msg.strip().lower() in ("/quit", "/exit"):
39+
break
40+
async for event in client.chat(sid, msg):
41+
_print_event(event)
42+
except (KeyboardInterrupt, EOFError):
43+
console.print()
44+
finally:
45+
client.leave_session(sid)
46+
await client.disconnect()
47+
48+
run_async(_chat())
49+
50+
51+
@click.command("send")
52+
@click.argument("message")
53+
@click.option("-s", "--session", "session_id", default=None, help="Existing session ID")
54+
@click.option("--json-output", "--json", is_flag=True, help="Output as JSON")
55+
@handle_api_errors
56+
def send_cmd(message: str, session_id: Optional[str], json_output: bool):
57+
"""Send a one-shot message to Pine AI."""
58+
async def _send():
59+
client = get_assistant_client()
60+
await client.connect()
61+
62+
sid = session_id
63+
try:
64+
if not sid:
65+
s = await client.sessions.create()
66+
sid = s["id"]
67+
if not json_output:
68+
console.print(f"[dim]Session: {sid}[/dim]")
69+
70+
await client.join_session(sid)
71+
async for event in client.chat(sid, message):
72+
if json_output:
73+
click.echo(json.dumps({"type": event.type, "data": event.data}))
74+
else:
75+
_print_event(event)
76+
77+
client.leave_session(sid)
78+
finally:
79+
await client.disconnect()
80+
81+
run_async(_send())
82+
83+
84+
def _print_event(event):
85+
"""Render a chat event to the console."""
86+
if event.type == S2CEvent.SESSION_TEXT:
87+
data = event.data if isinstance(event.data, dict) else {}
88+
content = data.get("content", "")
89+
if content:
90+
console.print(f"[green]Pine AI:[/green] {content}")
91+
elif event.type == S2CEvent.SESSION_FORM_TO_USER:
92+
data = event.data if isinstance(event.data, dict) else {}
93+
msg = data.get("message_to_user", "")
94+
console.print(Panel(f"[yellow]{msg}[/yellow]\n{json.dumps(data, indent=2)}",
95+
title="Form Required", border_style="yellow"))
96+
elif event.type == S2CEvent.SESSION_STATE:
97+
data = event.data if isinstance(event.data, dict) else {}
98+
state = data.get("content", "")
99+
if state:
100+
console.print(f"[dim] ● state → {state}[/dim]")
101+
elif event.type == S2CEvent.SESSION_THINKING:
102+
console.print("[dim] ● thinking…[/dim]")
103+
elif event.type == S2CEvent.SESSION_WORK_LOG:
104+
data = event.data if isinstance(event.data, dict) else {}
105+
steps = data.get("steps", [])
106+
for step in steps:
107+
console.print(f"[dim] ● {step.get('step_title', '')} [{step.get('status', '')}][/dim]")

0 commit comments

Comments
 (0)