phonedex-cli 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.
- phonedex_cli-0.1.0/LICENSE +21 -0
- phonedex_cli-0.1.0/PKG-INFO +125 -0
- phonedex_cli-0.1.0/README.md +101 -0
- phonedex_cli-0.1.0/pyproject.toml +35 -0
- phonedex_cli-0.1.0/setup.cfg +4 -0
- phonedex_cli-0.1.0/src/phonedex_cli/__init__.py +2 -0
- phonedex_cli-0.1.0/src/phonedex_cli/__main__.py +4 -0
- phonedex_cli-0.1.0/src/phonedex_cli/app.py +369 -0
- phonedex_cli-0.1.0/src/phonedex_cli/core.py +276 -0
- phonedex_cli-0.1.0/src/phonedex_cli.egg-info/PKG-INFO +125 -0
- phonedex_cli-0.1.0/src/phonedex_cli.egg-info/SOURCES.txt +15 -0
- phonedex_cli-0.1.0/src/phonedex_cli.egg-info/dependency_links.txt +1 -0
- phonedex_cli-0.1.0/src/phonedex_cli.egg-info/entry_points.txt +2 -0
- phonedex_cli-0.1.0/src/phonedex_cli.egg-info/requires.txt +2 -0
- phonedex_cli-0.1.0/src/phonedex_cli.egg-info/top_level.txt +1 -0
- phonedex_cli-0.1.0/tests/test_app.py +54 -0
- phonedex_cli-0.1.0/tests/test_core.py +119 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Austin Harvey
|
|
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,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: phonedex-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An optional animated terminal cockpit and setup companion for PhoneDex
|
|
5
|
+
Author: Austin Harvey
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: phonedex,terminal,sip,telephony,cli
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Communications :: Telephony
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: rich<16,>=13.7
|
|
22
|
+
Requires-Dist: psutil<8,>=5.9
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# PhoneDex CLI
|
|
26
|
+
|
|
27
|
+
**An optional, colorful terminal control deck for [PhoneDex](https://github.com/dexifried/Phonedex).**
|
|
28
|
+
|
|
29
|
+
A Rich-powered ASCII dashboard, short startup animations, step-by-step setup, diagnostic panels, a managed-controller switchboard, and safe calling controls. **PhoneDex CLI is a separate frontend**, not a fork of the SIP bridge. Uninstalling it leaves PhoneDex's existing installation, config, and calls intact.
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
____ _ ____
|
|
33
|
+
| _ \| |__ ___ _ __ ___| _ \ _____ __
|
|
34
|
+
| |_) | '_ \ / _ \| '_ \ / _ \ | | |/ _ \ \/ /
|
|
35
|
+
| __/| | | | (_) | | | | __/ |_| | __/> <
|
|
36
|
+
|_| |_| |_|\___/|_| |_|\___|____/ \___/_/\_\
|
|
37
|
+
|
|
38
|
+
TERMINAL CONTROL // an optional PhoneDex companion
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
Requires Python **3.11+**. Linux, macOS and Windows use the same commands. Git is needed **only** if you want the CLI to clone PhoneDex for you.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Clone or unpack this separate phonedex-cli project
|
|
47
|
+
cd phonedex-cli
|
|
48
|
+
python -m pip install -e .
|
|
49
|
+
|
|
50
|
+
# First-time users: interactive setup wizard
|
|
51
|
+
phonedex-cli wizard
|
|
52
|
+
|
|
53
|
+
# Regular users: animated control deck
|
|
54
|
+
phonedex-cli
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
On some systems use `python3` and `python3 -m pip`. The installation above is editable/developer mode; end users can instead use `python -m pip install .` from the unpacked project. For isolation use a dedicated virtual environment. **This repository has not yet been released on PyPI.** Do not assume `pip install phonedex-cli` works from PyPI.
|
|
58
|
+
|
|
59
|
+
### Existing PhoneDex installation?
|
|
60
|
+
|
|
61
|
+
Either choose **existing** in `phonedex-cli wizard` or use:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
phonedex-cli --checkout /path/to/Phonedex status
|
|
65
|
+
phonedex-cli --checkout /path/to/Phonedex doctor --mode local
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`PHONEDEX_HOME=/path/to/Phonedex` is also supported. The wizard saves the source path to a local config file, not the PhoneDex checkout. It never overwrites an existing `.env`.
|
|
69
|
+
|
|
70
|
+
### Commands
|
|
71
|
+
|
|
72
|
+
| Command | Behavior |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `phonedex-cli` or `phonedex-cli menu` | Interactive ASCII/Rich terminal dashboard |
|
|
75
|
+
| `phonedex-cli wizard` | Link/clone source, run optional bootstrap, local Codex login, delegate to official setup, inspect diagnostics |
|
|
76
|
+
| `phonedex-cli doctor --mode local` | Official read-only source checks; change to `remote` when using a Worker |
|
|
77
|
+
| `phonedex-cli setup --mode local` | Delegates to the existing core setup wizard |
|
|
78
|
+
| `phonedex-cli status` | Local checkout, Codex command presence, privacy mode, CLI-managed process state |
|
|
79
|
+
| `phonedex-cli start` | Start a CLI-managed controller **only with confirmation**; queued work can dispatch calls |
|
|
80
|
+
| `phonedex-cli stop` | Stop **only** the controller this CLI started and recorded |
|
|
81
|
+
| `phonedex-cli restart` | Confirm and restart a CLI-managed controller |
|
|
82
|
+
| `phonedex-cli logs --lines 40` | Recent managed-controller logs (best-effort masking of secret-bearing lines) |
|
|
83
|
+
| `phonedex-cli settings` | Private local editor: transcripts, retention, daily call cap, allowlisted phone numbers |
|
|
84
|
+
| `phonedex-cli dial --to +14165550123` | Allowlisted **dry run** (no call) |
|
|
85
|
+
| `phonedex-cli dial --to +14165550123 --place-call` | Explicit, **potentially billable** real call, requires confirmation |
|
|
86
|
+
|
|
87
|
+
Use `--no-color --no-animation` for inaccessible or non-interactive terminals. Animation is automatically disabled when the output isn't a terminal or when `CI` is set. `--yes` applies **only** to `dial --place-call` and bypasses its second confirmation for deliberate automation; it still requires an allowlisted number and upstream PhoneDex safeguards.
|
|
88
|
+
|
|
89
|
+
## Architecture and privacy
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
phonedex-cli (optional UI)
|
|
93
|
+
│
|
|
94
|
+
├─ ~/.config/phonedex-cli/config.json → installed source path only
|
|
95
|
+
├─ controller.json / controller.log → CLI-managed subprocess only
|
|
96
|
+
│
|
|
97
|
+
└─ invokes official PhoneDex commands:
|
|
98
|
+
phonedex.py setup / doctor / dial / controller
|
|
99
|
+
│
|
|
100
|
+
core .env, SIP gateway, GPT Live, optional Worker
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- The CLI does **not** request or store SIP passwords, OAuth tokens, or AgentPhone keys. The official PhoneDex setup handles provider secrets privately.
|
|
104
|
+
- The separate CLI only edits four whitelisted **non-secret** `.env` settings. It uses owner-restricted atomic replacement on POSIX, preserves unrelated variables and comments, and refuses symlink writes.
|
|
105
|
+
- Configuration edits take effect for new PhoneDex processes. An already-running controller generally needs a restart; this CLI never restarts it automatically.
|
|
106
|
+
- The CLI does **not** configure DNS, TLS certificates, Cloudflare resources, firewalls, port forwarding, telephony numbers, or SIP trunks.
|
|
107
|
+
- Setup, status, diagnostics, and default dialing perform **no real phone call**. Starting the controller can execute requests already queued in a configured Worker; it requires confirmation.
|
|
108
|
+
- Transcript storage defaults to `off` in the core. Choosing `worker` or `both` shares transcript text with **your own** Worker/KV. Changes apply to new calls and don't erase existing data.
|
|
109
|
+
- The log viewer masks lines with common secret labels on a best-effort basis. Logs may still reveal phone numbers, call content, and metadata. Do not publish raw logs.
|
|
110
|
+
- The controller PID registry records creation time and exact script path. **Stop will refuse** to terminate an unrelated process or a stale/reused PID. Controllers started outside this CLI are deliberately not managed.
|
|
111
|
+
- Operational correctness and carrier compatibility depend on the PhoneDex core and your provider. Use [PhoneDex's release status](https://github.com/dexifried/Phonedex/blob/main/docs/RELEASE_STATUS.md).
|
|
112
|
+
|
|
113
|
+
**Note:** PhoneDex uses an undocumented GPT Live subscription endpoint. Its availability and permission to use it may change. This CLI provides no guarantee of service or compliant use and is not affiliated with OpenAI, Codex, or AgentPhone.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
python -m pip install -e . pytest
|
|
119
|
+
python -m pytest -q
|
|
120
|
+
python -m phonedex_cli --no-animation --no-color status
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Offline tests cover config preservation, allowlisting, confirmation gates, symlink protection, stale-process safety, and dry-run defaults. CI is configured for Windows, Linux and macOS, but actual results depend on running the workflow. Live carrier calls are **not** part of these tests.
|
|
124
|
+
|
|
125
|
+
Licensed MIT. © 2026 Austin Harvey.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# PhoneDex CLI
|
|
2
|
+
|
|
3
|
+
**An optional, colorful terminal control deck for [PhoneDex](https://github.com/dexifried/Phonedex).**
|
|
4
|
+
|
|
5
|
+
A Rich-powered ASCII dashboard, short startup animations, step-by-step setup, diagnostic panels, a managed-controller switchboard, and safe calling controls. **PhoneDex CLI is a separate frontend**, not a fork of the SIP bridge. Uninstalling it leaves PhoneDex's existing installation, config, and calls intact.
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
____ _ ____
|
|
9
|
+
| _ \| |__ ___ _ __ ___| _ \ _____ __
|
|
10
|
+
| |_) | '_ \ / _ \| '_ \ / _ \ | | |/ _ \ \/ /
|
|
11
|
+
| __/| | | | (_) | | | | __/ |_| | __/> <
|
|
12
|
+
|_| |_| |_|\___/|_| |_|\___|____/ \___/_/\_\
|
|
13
|
+
|
|
14
|
+
TERMINAL CONTROL // an optional PhoneDex companion
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
Requires Python **3.11+**. Linux, macOS and Windows use the same commands. Git is needed **only** if you want the CLI to clone PhoneDex for you.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Clone or unpack this separate phonedex-cli project
|
|
23
|
+
cd phonedex-cli
|
|
24
|
+
python -m pip install -e .
|
|
25
|
+
|
|
26
|
+
# First-time users: interactive setup wizard
|
|
27
|
+
phonedex-cli wizard
|
|
28
|
+
|
|
29
|
+
# Regular users: animated control deck
|
|
30
|
+
phonedex-cli
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
On some systems use `python3` and `python3 -m pip`. The installation above is editable/developer mode; end users can instead use `python -m pip install .` from the unpacked project. For isolation use a dedicated virtual environment. **This repository has not yet been released on PyPI.** Do not assume `pip install phonedex-cli` works from PyPI.
|
|
34
|
+
|
|
35
|
+
### Existing PhoneDex installation?
|
|
36
|
+
|
|
37
|
+
Either choose **existing** in `phonedex-cli wizard` or use:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
phonedex-cli --checkout /path/to/Phonedex status
|
|
41
|
+
phonedex-cli --checkout /path/to/Phonedex doctor --mode local
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`PHONEDEX_HOME=/path/to/Phonedex` is also supported. The wizard saves the source path to a local config file, not the PhoneDex checkout. It never overwrites an existing `.env`.
|
|
45
|
+
|
|
46
|
+
### Commands
|
|
47
|
+
|
|
48
|
+
| Command | Behavior |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `phonedex-cli` or `phonedex-cli menu` | Interactive ASCII/Rich terminal dashboard |
|
|
51
|
+
| `phonedex-cli wizard` | Link/clone source, run optional bootstrap, local Codex login, delegate to official setup, inspect diagnostics |
|
|
52
|
+
| `phonedex-cli doctor --mode local` | Official read-only source checks; change to `remote` when using a Worker |
|
|
53
|
+
| `phonedex-cli setup --mode local` | Delegates to the existing core setup wizard |
|
|
54
|
+
| `phonedex-cli status` | Local checkout, Codex command presence, privacy mode, CLI-managed process state |
|
|
55
|
+
| `phonedex-cli start` | Start a CLI-managed controller **only with confirmation**; queued work can dispatch calls |
|
|
56
|
+
| `phonedex-cli stop` | Stop **only** the controller this CLI started and recorded |
|
|
57
|
+
| `phonedex-cli restart` | Confirm and restart a CLI-managed controller |
|
|
58
|
+
| `phonedex-cli logs --lines 40` | Recent managed-controller logs (best-effort masking of secret-bearing lines) |
|
|
59
|
+
| `phonedex-cli settings` | Private local editor: transcripts, retention, daily call cap, allowlisted phone numbers |
|
|
60
|
+
| `phonedex-cli dial --to +14165550123` | Allowlisted **dry run** (no call) |
|
|
61
|
+
| `phonedex-cli dial --to +14165550123 --place-call` | Explicit, **potentially billable** real call, requires confirmation |
|
|
62
|
+
|
|
63
|
+
Use `--no-color --no-animation` for inaccessible or non-interactive terminals. Animation is automatically disabled when the output isn't a terminal or when `CI` is set. `--yes` applies **only** to `dial --place-call` and bypasses its second confirmation for deliberate automation; it still requires an allowlisted number and upstream PhoneDex safeguards.
|
|
64
|
+
|
|
65
|
+
## Architecture and privacy
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
phonedex-cli (optional UI)
|
|
69
|
+
│
|
|
70
|
+
├─ ~/.config/phonedex-cli/config.json → installed source path only
|
|
71
|
+
├─ controller.json / controller.log → CLI-managed subprocess only
|
|
72
|
+
│
|
|
73
|
+
└─ invokes official PhoneDex commands:
|
|
74
|
+
phonedex.py setup / doctor / dial / controller
|
|
75
|
+
│
|
|
76
|
+
core .env, SIP gateway, GPT Live, optional Worker
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- The CLI does **not** request or store SIP passwords, OAuth tokens, or AgentPhone keys. The official PhoneDex setup handles provider secrets privately.
|
|
80
|
+
- The separate CLI only edits four whitelisted **non-secret** `.env` settings. It uses owner-restricted atomic replacement on POSIX, preserves unrelated variables and comments, and refuses symlink writes.
|
|
81
|
+
- Configuration edits take effect for new PhoneDex processes. An already-running controller generally needs a restart; this CLI never restarts it automatically.
|
|
82
|
+
- The CLI does **not** configure DNS, TLS certificates, Cloudflare resources, firewalls, port forwarding, telephony numbers, or SIP trunks.
|
|
83
|
+
- Setup, status, diagnostics, and default dialing perform **no real phone call**. Starting the controller can execute requests already queued in a configured Worker; it requires confirmation.
|
|
84
|
+
- Transcript storage defaults to `off` in the core. Choosing `worker` or `both` shares transcript text with **your own** Worker/KV. Changes apply to new calls and don't erase existing data.
|
|
85
|
+
- The log viewer masks lines with common secret labels on a best-effort basis. Logs may still reveal phone numbers, call content, and metadata. Do not publish raw logs.
|
|
86
|
+
- The controller PID registry records creation time and exact script path. **Stop will refuse** to terminate an unrelated process or a stale/reused PID. Controllers started outside this CLI are deliberately not managed.
|
|
87
|
+
- Operational correctness and carrier compatibility depend on the PhoneDex core and your provider. Use [PhoneDex's release status](https://github.com/dexifried/Phonedex/blob/main/docs/RELEASE_STATUS.md).
|
|
88
|
+
|
|
89
|
+
**Note:** PhoneDex uses an undocumented GPT Live subscription endpoint. Its availability and permission to use it may change. This CLI provides no guarantee of service or compliant use and is not affiliated with OpenAI, Codex, or AgentPhone.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python -m pip install -e . pytest
|
|
95
|
+
python -m pytest -q
|
|
96
|
+
python -m phonedex_cli --no-animation --no-color status
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Offline tests cover config preservation, allowlisting, confirmation gates, symlink protection, stale-process safety, and dry-run defaults. CI is configured for Windows, Linux and macOS, but actual results depend on running the workflow. Live carrier calls are **not** part of these tests.
|
|
100
|
+
|
|
101
|
+
Licensed MIT. © 2026 Austin Harvey.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "phonedex-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "An optional animated terminal cockpit and setup companion for PhoneDex"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{name = "Austin Harvey"}]
|
|
13
|
+
dependencies = ["rich>=13.7,<16", "psutil>=5.9,<8"]
|
|
14
|
+
keywords = ["phonedex", "terminal", "sip", "telephony", "cli"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: End Users/Desktop",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Communications :: Telephony",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
phonedex-cli = "phonedex_cli.app:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Rich-powered PhoneDex companion. No SIP internals are duplicated here."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from rich import box
|
|
13
|
+
from rich.align import Align
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.live import Live
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.prompt import Confirm, IntPrompt, Prompt
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from . import core
|
|
23
|
+
|
|
24
|
+
ASCII = r"""
|
|
25
|
+
____ _ ____
|
|
26
|
+
| _ \| |__ ___ _ __ ___| _ \ _____ __
|
|
27
|
+
| |_) | '_ \ / _ \| '_ \ / _ \ | | |/ _ \ \/ /
|
|
28
|
+
| __/| | | | (_) | | | | __/ |_| | __/> <
|
|
29
|
+
|_| |_| |_|\___/|_| |_|\___|____/ \___/_/\_\
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
class Cockpit:
|
|
33
|
+
def __init__(self, *, no_color: bool = False, no_animation: bool = False, checkout: str | None = None):
|
|
34
|
+
self.console = Console(no_color=no_color or bool(os.environ.get("NO_COLOR")))
|
|
35
|
+
self.animations = not no_animation and self.console.is_terminal and not os.environ.get("CI")
|
|
36
|
+
self.checkout_override = checkout
|
|
37
|
+
|
|
38
|
+
def banner(self) -> None:
|
|
39
|
+
art = Text(ASCII, style="bold bright_cyan")
|
|
40
|
+
if self.animations:
|
|
41
|
+
# A short, skippable terminal-only text reveal; never animates in CI/pipes.
|
|
42
|
+
with Live(Align.center(Text("Initializing PhoneDex", style="bright_cyan")),
|
|
43
|
+
console=self.console, transient=True, auto_refresh=False) as live:
|
|
44
|
+
for shown in range(0, len(ASCII) + 16, max(12, len(ASCII) // 9)):
|
|
45
|
+
live.update(Align.center(Text(ASCII[:shown], style="bold bright_cyan")), refresh=True)
|
|
46
|
+
time.sleep(0.025)
|
|
47
|
+
self.console.print(Align.center(art))
|
|
48
|
+
self.console.print(Align.center(Text("TERMINAL CONTROL // an optional PhoneDex companion", style="bold magenta")))
|
|
49
|
+
self.console.print(Align.center(Text(f"v{__version__} · local-first · separate from the SIP engine", style="dim")))
|
|
50
|
+
self.console.print()
|
|
51
|
+
|
|
52
|
+
def transition(self, message: str) -> None:
|
|
53
|
+
if not self.animations:
|
|
54
|
+
return
|
|
55
|
+
with self.console.status(f"[cyan]{message}[/cyan]", spinner="dots12"):
|
|
56
|
+
time.sleep(0.32)
|
|
57
|
+
|
|
58
|
+
def root(self) -> Path | None:
|
|
59
|
+
return core.get_checkout(self.checkout_override)
|
|
60
|
+
|
|
61
|
+
def require_root(self) -> Path:
|
|
62
|
+
root = self.root()
|
|
63
|
+
if root is None:
|
|
64
|
+
raise RuntimeError("PhoneDex source checkout not configured. Run 'phonedex-cli wizard' first.")
|
|
65
|
+
return root
|
|
66
|
+
|
|
67
|
+
def status(self) -> None:
|
|
68
|
+
root = self.root()
|
|
69
|
+
state, record = core.controller_status(root)
|
|
70
|
+
table = Table(title="PhoneDex system overview", box=box.ROUNDED, header_style="bold cyan")
|
|
71
|
+
table.add_column("Component", style="bold")
|
|
72
|
+
table.add_column("Status")
|
|
73
|
+
table.add_column("Detail", overflow="fold")
|
|
74
|
+
table.add_row("Core checkout", "[green]Found[/green]" if root else "[yellow]Not configured[/yellow]", str(root) if root else "Run wizard")
|
|
75
|
+
table.add_row("Python", "[green]Available[/green]" if sys.version_info >= (3, 11) else "[red]Too old[/red]", sys.version.split()[0])
|
|
76
|
+
table.add_row("Codex CLI", "[green]Detected[/green]" if shutil.which("codex") else "[yellow]Not in PATH[/yellow]", "Sign in locally using codex login" if shutil.which("codex") else "Install separately before GPT Live use")
|
|
77
|
+
label = {"running": "[green]Running[/green]", "stopped": "[dim]Stopped[/dim]", "stale": "[yellow]Not running (stale PID)[/yellow]", "other-checkout": "[yellow]Different checkout[/yellow]"}[state]
|
|
78
|
+
table.add_row("Managed controller", label, f"PID {record.get('pid')}" if state == "running" else "")
|
|
79
|
+
if root:
|
|
80
|
+
details = core.parse_env(root / ".env")
|
|
81
|
+
mode = details.get("PHONEDEX_TRANSCRIPT_MODE", "off")
|
|
82
|
+
table.add_row("Local config", "[green]Present[/green]" if (root / ".env").is_file() else "[yellow]Not set up[/yellow]", f"transcripts={mode}; recipients={len([n for n in details.get('PHONEDEX_ALLOWED_RECIPIENTS', '').split(',') if n.strip()])}")
|
|
83
|
+
venv = root / ".venv"
|
|
84
|
+
table.add_row("Python environment", "[green]Isolated[/green]" if venv.is_dir() else "[yellow]Not bootstrapped[/yellow]", str(venv) if venv.is_dir() else "Run wizard bootstrap")
|
|
85
|
+
self.console.print(table)
|
|
86
|
+
self.console.print("[dim]Status shows only processes started by this CLI. It does not inspect or control unrelated live gateways.[/dim]")
|
|
87
|
+
|
|
88
|
+
def check(self, mode: str = "local") -> int:
|
|
89
|
+
root = self.require_root()
|
|
90
|
+
self.transition("Reading readiness diagnostics")
|
|
91
|
+
results = [
|
|
92
|
+
("Setup readiness", core.core_argv(root, "setup", "--mode", mode, "--check")),
|
|
93
|
+
("Runtime doctor", core.core_argv(root, "doctor", "--mode", mode)),
|
|
94
|
+
]
|
|
95
|
+
result_code = 0
|
|
96
|
+
for name, command in results:
|
|
97
|
+
self.console.rule(f"[cyan]{name}[/cyan]")
|
|
98
|
+
try:
|
|
99
|
+
result = core.safe_exec(command, cwd=root)
|
|
100
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
101
|
+
self.console.print(f"[red]{name} could not run:[/red] {exc}")
|
|
102
|
+
result_code = 1
|
|
103
|
+
continue
|
|
104
|
+
for line in core.redact(result.stdout).splitlines():
|
|
105
|
+
if "=" in line and not line.startswith("["):
|
|
106
|
+
key, _, value = line.partition("=")
|
|
107
|
+
if value.strip() in {"ok", "valid", "none", "true"}:
|
|
108
|
+
self.console.print(f"[green]●[/green] {key}: {value}")
|
|
109
|
+
else:
|
|
110
|
+
self.console.print(f"[cyan]•[/cyan] {key}: {value}")
|
|
111
|
+
else:
|
|
112
|
+
self.console.print(line)
|
|
113
|
+
if result.stderr:
|
|
114
|
+
self.console.print("[yellow]Runtime detail:[/yellow] " + core.redact(result.stderr)[-1200:])
|
|
115
|
+
if result.returncode:
|
|
116
|
+
result_code = 1
|
|
117
|
+
self.console.print("[dim]Read-only checks. No call placed, firewall changed, or Worker deployed.[/dim]")
|
|
118
|
+
return result_code
|
|
119
|
+
|
|
120
|
+
def setup(self, mode: str = "local") -> int:
|
|
121
|
+
root = self.require_root()
|
|
122
|
+
self.console.print(Panel("This delegates to the [bold]official PhoneDex setup wizard[/bold].\nIt only writes local configuration with your permission; it cannot provision a provider or place calls.", title="Official core setup", border_style="cyan"))
|
|
123
|
+
if (root / ".env").exists():
|
|
124
|
+
self.console.print("[yellow]An existing .env was found. The core wizard refuses to overwrite it.[/yellow]")
|
|
125
|
+
if not Confirm.ask("Launch PhoneDex's local setup now?", default=True):
|
|
126
|
+
return 0
|
|
127
|
+
return subprocess.call(core.core_argv(root, "setup", "--mode", mode), cwd=root)
|
|
128
|
+
|
|
129
|
+
def wizard(self) -> None:
|
|
130
|
+
self.banner()
|
|
131
|
+
self.console.print(Panel("[bold]1[/bold] Connect a PhoneDex checkout\n[bold]2[/bold] Prepare Python dependencies\n[bold]3[/bold] Codex login (performed locally)\n[bold]4[/bold] SIP/provider configuration\n[bold]5[/bold] Read-only diagnostics", title="First-run wizard", border_style="bright_cyan"))
|
|
132
|
+
root = self.root()
|
|
133
|
+
if root:
|
|
134
|
+
self.console.print(f"[green]✓[/green] Existing PhoneDex checkout: [bold]{root}[/bold]")
|
|
135
|
+
if not Confirm.ask("Use this checkout?", default=True):
|
|
136
|
+
root = None
|
|
137
|
+
if root is None:
|
|
138
|
+
action = Prompt.ask("PhoneDex source", choices=["existing", "clone"], default="clone")
|
|
139
|
+
if action == "existing":
|
|
140
|
+
while True:
|
|
141
|
+
candidate = Path(Prompt.ask("Absolute or relative path to PhoneDex checkout")).expanduser()
|
|
142
|
+
try:
|
|
143
|
+
root = core.validate_checkout(candidate)
|
|
144
|
+
break
|
|
145
|
+
except ValueError as exc:
|
|
146
|
+
self.console.print(f"[red]{exc}[/red]")
|
|
147
|
+
if not Confirm.ask("Try another path?", default=True):
|
|
148
|
+
return
|
|
149
|
+
else:
|
|
150
|
+
default = str(Path.home() / "PhoneDex")
|
|
151
|
+
target = Path(Prompt.ask("Clone into", default=default)).expanduser()
|
|
152
|
+
self.console.print("Clone source: [cyan]https://github.com/dexifried/Phonedex[/cyan]")
|
|
153
|
+
if not Confirm.ask(f"Clone the official public source to {target}?", default=True):
|
|
154
|
+
return
|
|
155
|
+
self.transition("Preparing checkout")
|
|
156
|
+
core.clone_checkout(target)
|
|
157
|
+
root = core.validate_checkout(target)
|
|
158
|
+
core.set_checkout(root)
|
|
159
|
+
self.console.print(f"[green]✓[/green] Source linked: {root}")
|
|
160
|
+
mode = Prompt.ask("Setup mode", choices=["local", "remote"], default="local")
|
|
161
|
+
self.console.print("[dim]Local = outbound calling without Cloudflare. Remote = optional Worker-based agent control.[/dim]")
|
|
162
|
+
if Confirm.ask("Run the official dependency bootstrap?", default=True):
|
|
163
|
+
bootstrap = [sys.executable, str(root / "scripts" / "bootstrap.py")]
|
|
164
|
+
self.console.print("[dim]Core bootstrap asks before creating its .venv or installing dependencies.[/dim]")
|
|
165
|
+
code = subprocess.call(bootstrap, cwd=root)
|
|
166
|
+
if code:
|
|
167
|
+
self.console.print(f"[yellow]Bootstrap exited with code {code}. You can retry from the core checkout.[/yellow]")
|
|
168
|
+
if shutil.which("codex"):
|
|
169
|
+
if Confirm.ask("Open Codex's own local login flow now?", default=False):
|
|
170
|
+
subprocess.call(["codex", "login"])
|
|
171
|
+
else:
|
|
172
|
+
self.console.print("[yellow]Codex CLI is not in PATH. Install it and run 'codex login' before actual use.[/yellow]")
|
|
173
|
+
if Confirm.ask("Open the official PhoneDex SIP setup wizard?", default=True):
|
|
174
|
+
try:
|
|
175
|
+
self.setup(mode)
|
|
176
|
+
except (OSError, FileNotFoundError) as exc:
|
|
177
|
+
self.console.print(f"[yellow]Could not launch setup: {exc}[/yellow]")
|
|
178
|
+
if Confirm.ask("Run read-only readiness checks?", default=True):
|
|
179
|
+
self.check(mode)
|
|
180
|
+
self.console.print(Panel("[bold green]Wizard finished.[/bold green] No calls have been placed.\nCheck networking, provider settings, and the approved-recipient allowlist before any live call.", border_style="green"))
|
|
181
|
+
|
|
182
|
+
def start(self) -> None:
|
|
183
|
+
root = self.require_root()
|
|
184
|
+
self.console.print("[yellow]Starting the remote controller can accept queued call requests and potentially trigger billable calls.[/yellow]")
|
|
185
|
+
self.console.print("[dim]Only use this on a configured, trusted Worker with caller allowlisting and billing limits.[/dim]")
|
|
186
|
+
if not Confirm.ask("Start the local PhoneDex controller process?", default=False):
|
|
187
|
+
self.console.print("Cancelled; controller not started.")
|
|
188
|
+
return
|
|
189
|
+
pid = core.start_controller(root)
|
|
190
|
+
self.console.print(f"[green]Controller started[/green] (PID {pid}). Use [bold]phonedex-cli stop[/bold] to stop this CLI-managed process.")
|
|
191
|
+
|
|
192
|
+
def stop(self) -> None:
|
|
193
|
+
root = self.require_root()
|
|
194
|
+
if core.stop_controller(root):
|
|
195
|
+
self.console.print("[green]Controller stopped.[/green]")
|
|
196
|
+
else:
|
|
197
|
+
self.console.print("[yellow]No running controller managed by this CLI for this checkout.[/yellow]")
|
|
198
|
+
|
|
199
|
+
def restart(self) -> None:
|
|
200
|
+
root = self.require_root()
|
|
201
|
+
state, _ = core.controller_status(root)
|
|
202
|
+
self.console.print("[yellow]Restarting may resume queued call dispatch.[/yellow]")
|
|
203
|
+
if not Confirm.ask("Restart the CLI-managed controller?", default=False):
|
|
204
|
+
return
|
|
205
|
+
if state == "running":
|
|
206
|
+
if not core.stop_controller(root):
|
|
207
|
+
raise RuntimeError("Could not stop controller")
|
|
208
|
+
pid = core.start_controller(root)
|
|
209
|
+
self.console.print(f"[green]Controller running[/green] (PID {pid})")
|
|
210
|
+
|
|
211
|
+
def logs(self, lines: int = 40) -> None:
|
|
212
|
+
logfile = core.home_dir() / "controller.log"
|
|
213
|
+
if not logfile.is_file():
|
|
214
|
+
self.console.print("[yellow]No CLI-managed controller logs yet.[/yellow]")
|
|
215
|
+
return
|
|
216
|
+
self.console.print("[yellow]Logs may contain personal call metadata. The viewer masks obvious secret-bearing lines, not phone numbers.[/yellow]")
|
|
217
|
+
# Avoid huge log reads: tail by seeking from end, in binary mode.
|
|
218
|
+
with logfile.open("rb") as fh:
|
|
219
|
+
fh.seek(0, os.SEEK_END)
|
|
220
|
+
pos = fh.tell()
|
|
221
|
+
data = bytearray()
|
|
222
|
+
while pos > 0 and data.count(b"\n") <= lines:
|
|
223
|
+
read = min(pos, 4096)
|
|
224
|
+
pos -= read
|
|
225
|
+
fh.seek(pos)
|
|
226
|
+
data[:0] = fh.read(read)
|
|
227
|
+
if len(data) > 1024 * 1024:
|
|
228
|
+
break
|
|
229
|
+
text = data.decode("utf-8", errors="replace")
|
|
230
|
+
self.console.print(core.redact("\n".join(text.splitlines()[-lines:])), markup=False, highlight=False)
|
|
231
|
+
|
|
232
|
+
def settings(self) -> None:
|
|
233
|
+
root = self.require_root()
|
|
234
|
+
env = root / ".env"
|
|
235
|
+
if not env.is_file():
|
|
236
|
+
raise RuntimeError("Core .env not configured. Run wizard or setup first.")
|
|
237
|
+
while True:
|
|
238
|
+
values = core.parse_env(env)
|
|
239
|
+
self.console.print(Panel(f"Transcript storage: [bold]{values.get('PHONEDEX_TRANSCRIPT_MODE','off')}[/bold]\n"
|
|
240
|
+
f"Local retention: [bold]{values.get('PHONEDEX_TRANSCRIPT_RETENTION_DAYS','7')} days[/bold]\n"
|
|
241
|
+
f"Daily call cap: [bold]{values.get('PHONEDEX_MAX_CALLS_PER_DAY','12')}[/bold]\n"
|
|
242
|
+
f"Allowlisted recipients: [bold]{len([v for v in values.get('PHONEDEX_ALLOWED_RECIPIENTS','').split(',') if v.strip()])}[/bold]",
|
|
243
|
+
title="Private settings", border_style="magenta"))
|
|
244
|
+
choice = Prompt.ask("Edit", choices=["privacy", "retention", "limit", "allowlist", "back"], default="back")
|
|
245
|
+
if choice == "back":
|
|
246
|
+
return
|
|
247
|
+
if choice == "privacy":
|
|
248
|
+
self.console.print("[dim]off=none; local=SQLite; worker=Worker/KV; both=local+Worker. Changes affect new calls only.[/dim]")
|
|
249
|
+
value = Prompt.ask("Transcript mode", choices=["off", "local", "worker", "both"], default=values.get("PHONEDEX_TRANSCRIPT_MODE", "off"))
|
|
250
|
+
if value in ("worker", "both") and not Confirm.ask("This uploads transcript text to your configured Worker. Continue?", default=False):
|
|
251
|
+
continue
|
|
252
|
+
key = "PHONEDEX_TRANSCRIPT_MODE"
|
|
253
|
+
elif choice == "retention":
|
|
254
|
+
key, value = "PHONEDEX_TRANSCRIPT_RETENTION_DAYS", str(IntPrompt.ask("Local retention (1-90 days)", default=7))
|
|
255
|
+
elif choice == "limit":
|
|
256
|
+
key, value = "PHONEDEX_MAX_CALLS_PER_DAY", str(IntPrompt.ask("Maximum calls per day (1-1000)", default=12))
|
|
257
|
+
else:
|
|
258
|
+
key = "PHONEDEX_ALLOWED_RECIPIENTS"
|
|
259
|
+
self.console.print("[dim]E.164 format, separated by commas. Leave blank to block all outbound recipients.[/dim]")
|
|
260
|
+
value = Prompt.ask("Allowed numbers", default=values.get(key, ""), show_default=False)
|
|
261
|
+
core.update_env(env, key, value)
|
|
262
|
+
self.console.print("[green]Setting saved.[/green] New PhoneDex processes will read it. Restart any already-running controller to apply changes there.")
|
|
263
|
+
|
|
264
|
+
def dial(self, to: str, mode: str = "normal", place_call: bool = False, yes: bool = False, duration: int = 60) -> int:
|
|
265
|
+
root = self.require_root()
|
|
266
|
+
if not core.E164.fullmatch(to):
|
|
267
|
+
raise ValueError("Use E.164 recipient format, e.g. +14165550123")
|
|
268
|
+
configured = core.parse_env(root / ".env")
|
|
269
|
+
allowed = {n.strip() for n in configured.get("PHONEDEX_ALLOWED_RECIPIENTS", "").split(",") if n.strip()}
|
|
270
|
+
if to not in allowed:
|
|
271
|
+
raise ValueError("Recipient not in PHONEDEX_ALLOWED_RECIPIENTS (default-deny)")
|
|
272
|
+
if not 20 <= duration <= 300:
|
|
273
|
+
raise ValueError("Duration must be between 20 and 300 seconds")
|
|
274
|
+
argv = core.core_argv(root, "dial", "--to", to, "--mode", mode, "--duration", str(duration))
|
|
275
|
+
if place_call:
|
|
276
|
+
self.console.print(Panel(f"[bold yellow]REAL, POTENTIALLY BILLABLE CALL[/bold yellow]\nRecipient: {to}\nMax duration: {duration} seconds", border_style="yellow"))
|
|
277
|
+
if not yes and not Confirm.ask("Place this live call now?", default=False):
|
|
278
|
+
self.console.print("Call cancelled.")
|
|
279
|
+
return 0
|
|
280
|
+
argv += ["--place-call"]
|
|
281
|
+
else:
|
|
282
|
+
self.console.print("[green]Dry run only.[/green] No live call will be placed.")
|
|
283
|
+
return subprocess.call(argv, cwd=root)
|
|
284
|
+
|
|
285
|
+
def menu(self) -> None:
|
|
286
|
+
self.banner()
|
|
287
|
+
while True:
|
|
288
|
+
self.status()
|
|
289
|
+
self.console.print(Panel("[cyan]1[/cyan] Setup wizard [cyan]2[/cyan] Readiness doctor [cyan]3[/cyan] Start controller\n"
|
|
290
|
+
"[cyan]4[/cyan] Stop controller [cyan]5[/cyan] Restart controller [cyan]6[/cyan] View logs\n"
|
|
291
|
+
"[cyan]7[/cyan] Settings [cyan]8[/cyan] Dial (dry run first) [cyan]0[/cyan] Exit",
|
|
292
|
+
border_style="bright_magenta", title="Control deck"))
|
|
293
|
+
choice = Prompt.ask("Select", choices=list("012345678"), default="0")
|
|
294
|
+
if choice == "0":
|
|
295
|
+
return
|
|
296
|
+
try:
|
|
297
|
+
if choice == "1": self.wizard()
|
|
298
|
+
elif choice == "2": self.check()
|
|
299
|
+
elif choice == "3": self.start()
|
|
300
|
+
elif choice == "4": self.stop()
|
|
301
|
+
elif choice == "5": self.restart()
|
|
302
|
+
elif choice == "6": self.logs()
|
|
303
|
+
elif choice == "7": self.settings()
|
|
304
|
+
else:
|
|
305
|
+
number = Prompt.ask("E.164 destination")
|
|
306
|
+
minutes = IntPrompt.ask("Max duration (seconds)", default=60)
|
|
307
|
+
self.dial(number, duration=minutes)
|
|
308
|
+
if Confirm.ask("Try a real call to this approved number?", default=False):
|
|
309
|
+
self.dial(number, duration=minutes, place_call=True)
|
|
310
|
+
except (ValueError, RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
311
|
+
self.console.print(f"[red]Action failed:[/red] {exc}")
|
|
312
|
+
self.console.print()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def parser() -> argparse.ArgumentParser:
|
|
316
|
+
p = argparse.ArgumentParser(description="Optional Rich terminal interface for PhoneDex")
|
|
317
|
+
p.add_argument("--checkout", help="Path to existing official PhoneDex source checkout")
|
|
318
|
+
p.add_argument("--no-color", action="store_true", help="Disable ANSI coloring")
|
|
319
|
+
p.add_argument("--no-animation", action="store_true", help="Disable startup transitions")
|
|
320
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
321
|
+
commands = p.add_subparsers(dest="action")
|
|
322
|
+
commands.add_parser("menu", help="Interactive terminal control deck")
|
|
323
|
+
commands.add_parser("wizard", help="Guided first-run setup")
|
|
324
|
+
commands.add_parser("status", help="Show configuration and managed controller status")
|
|
325
|
+
doc = commands.add_parser("doctor", help="Run official read-only PhoneDex diagnostics")
|
|
326
|
+
doc.add_argument("--mode", choices=["local", "remote"], default="local")
|
|
327
|
+
setup = commands.add_parser("setup", help="Run official PhoneDex setup wizard")
|
|
328
|
+
setup.add_argument("--mode", choices=["local", "remote"], default="local")
|
|
329
|
+
commands.add_parser("start", help="Start local controller with explicit confirmation")
|
|
330
|
+
commands.add_parser("stop", help="Stop only the controller started by this CLI")
|
|
331
|
+
commands.add_parser("restart", help="Restart CLI-managed controller with confirmation")
|
|
332
|
+
logs = commands.add_parser("logs", help="Display recent CLI-managed controller logs")
|
|
333
|
+
logs.add_argument("--lines", type=int, default=40)
|
|
334
|
+
commands.add_parser("settings", help="Change non-secret PhoneDex settings locally")
|
|
335
|
+
dial = commands.add_parser("dial", help="Allowlisted dry run by default; real calls opt-in")
|
|
336
|
+
dial.add_argument("--to", required=True, help="E.164 approved destination")
|
|
337
|
+
dial.add_argument("--mode", choices=["normal", "business"], default="normal")
|
|
338
|
+
dial.add_argument("--duration", type=int, default=60)
|
|
339
|
+
dial.add_argument("--place-call", action="store_true", help="Opt in to an actual carrier call")
|
|
340
|
+
dial.add_argument("--yes", action="store_true", help="Skip interactive live-call confirmation (automation only)")
|
|
341
|
+
return p
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def main(argv: list[str] | None = None) -> int:
|
|
345
|
+
args = parser().parse_args(argv)
|
|
346
|
+
cli = Cockpit(no_color=args.no_color, no_animation=args.no_animation, checkout=args.checkout)
|
|
347
|
+
action = args.action or "menu"
|
|
348
|
+
try:
|
|
349
|
+
if action == "menu": cli.menu()
|
|
350
|
+
elif action == "wizard": cli.wizard()
|
|
351
|
+
elif action == "status": cli.banner(); cli.status()
|
|
352
|
+
elif action == "doctor": return cli.check(args.mode)
|
|
353
|
+
elif action == "setup": return cli.setup(args.mode)
|
|
354
|
+
elif action == "start": cli.start()
|
|
355
|
+
elif action == "stop": cli.stop()
|
|
356
|
+
elif action == "restart": cli.restart()
|
|
357
|
+
elif action == "logs": cli.logs(max(1, min(args.lines, 500)))
|
|
358
|
+
elif action == "settings": cli.settings()
|
|
359
|
+
elif action == "dial": return cli.dial(args.to, args.mode, args.place_call, args.yes, args.duration)
|
|
360
|
+
return 0
|
|
361
|
+
except (ValueError, RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
362
|
+
cli.console.print(f"[red]PhoneDex CLI:[/red] {exc}")
|
|
363
|
+
return 2
|
|
364
|
+
except KeyboardInterrupt:
|
|
365
|
+
cli.console.print("\n[dim]Exited. Background controller, if running, is unchanged.[/dim]")
|
|
366
|
+
return 130
|
|
367
|
+
|
|
368
|
+
if __name__ == "__main__":
|
|
369
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Side-effect-aware local integration with the public PhoneDex checkout.
|
|
2
|
+
|
|
3
|
+
Nothing in this module provisions a SIP trunk, forwards ports or places a call.
|
|
4
|
+
The CLI delegates actual SIP work and configuration validation to PhoneDex.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import psutil
|
|
19
|
+
|
|
20
|
+
UPSTREAM = "https://github.com/dexifried/Phonedex.git"
|
|
21
|
+
E164 = re.compile(r"\+[1-9]\d{6,14}\Z")
|
|
22
|
+
CONFIGURABLE = {"PHONEDEX_TRANSCRIPT_MODE", "PHONEDEX_TRANSCRIPT_RETENTION_DAYS",
|
|
23
|
+
"PHONEDEX_MAX_CALLS_PER_DAY", "PHONEDEX_ALLOWED_RECIPIENTS"}
|
|
24
|
+
SENSITIVE = re.compile(r"(password|token|secret|api.?key|authorization|bearer)", re.I)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def home_dir() -> Path:
|
|
28
|
+
override = os.environ.get("PHONEDEX_CLI_HOME")
|
|
29
|
+
if override:
|
|
30
|
+
return Path(override).expanduser()
|
|
31
|
+
if os.name == "nt" and os.environ.get("APPDATA"):
|
|
32
|
+
return Path(os.environ["APPDATA"]) / "PhoneDexCLI"
|
|
33
|
+
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "phonedex-cli"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def preferences_file() -> Path:
|
|
37
|
+
return home_dir() / "config.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def managed_file() -> Path:
|
|
41
|
+
return home_dir() / "controller.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _private_write(path: Path, data: str) -> None:
|
|
45
|
+
"""Atomic owner-only write on POSIX; inherits local user ACLs on Windows."""
|
|
46
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
if path.is_symlink():
|
|
48
|
+
raise ValueError(f"Refusing to write through symlink: {path}")
|
|
49
|
+
temp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
50
|
+
fd = os.open(temp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
51
|
+
try:
|
|
52
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as out:
|
|
53
|
+
out.write(data)
|
|
54
|
+
out.flush()
|
|
55
|
+
os.fsync(out.fileno())
|
|
56
|
+
os.replace(temp, path)
|
|
57
|
+
finally:
|
|
58
|
+
temp.unlink(missing_ok=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def preferences() -> dict:
|
|
62
|
+
try:
|
|
63
|
+
val = json.loads(preferences_file().read_text(encoding="utf-8"))
|
|
64
|
+
return val if isinstance(val, dict) else {}
|
|
65
|
+
except (OSError, ValueError):
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def set_checkout(path: Path) -> None:
|
|
70
|
+
path = validate_checkout(path)
|
|
71
|
+
prefs = preferences()
|
|
72
|
+
prefs["checkout"] = str(path)
|
|
73
|
+
_private_write(preferences_file(), json.dumps(prefs, indent=2) + "\n")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_checkout(explicit: str | None = None) -> Path | None:
|
|
77
|
+
raw = explicit or os.environ.get("PHONEDEX_HOME") or preferences().get("checkout")
|
|
78
|
+
if not raw:
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
return validate_checkout(Path(raw))
|
|
82
|
+
except (ValueError, OSError):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate_checkout(path: Path) -> Path:
|
|
87
|
+
candidate = path.expanduser().resolve()
|
|
88
|
+
for file in ("phonedex.py", "onboarding.py", "scripts/bootstrap.py"):
|
|
89
|
+
if not (candidate / file).is_file():
|
|
90
|
+
raise ValueError(f"Not a compatible PhoneDex checkout (missing {file}): {candidate}")
|
|
91
|
+
return candidate
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def core_python(root: Path) -> str:
|
|
95
|
+
if os.name == "nt":
|
|
96
|
+
venv = root / ".venv" / "Scripts" / "python.exe"
|
|
97
|
+
else:
|
|
98
|
+
venv = root / ".venv" / "bin" / "python"
|
|
99
|
+
return str(venv) if venv.is_file() else sys.executable
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def core_argv(root: Path, *args: str) -> list[str]:
|
|
103
|
+
return [core_python(root), str(root / "phonedex.py"), *args]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def safe_exec(argv: list[str], *, cwd: Path | None = None, timeout: int = 90) -> subprocess.CompletedProcess[str]:
|
|
107
|
+
return subprocess.run(argv, cwd=cwd, text=True, encoding="utf-8", errors="replace",
|
|
108
|
+
capture_output=True, timeout=timeout, check=False)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_env(path: Path) -> dict[str, str]:
|
|
112
|
+
result: dict[str, str] = {}
|
|
113
|
+
if not path.exists():
|
|
114
|
+
return result
|
|
115
|
+
for line in path.read_text(encoding="utf-8-sig").splitlines():
|
|
116
|
+
stripped = line.strip()
|
|
117
|
+
if not stripped or stripped.startswith("#") or "=" not in line:
|
|
118
|
+
continue
|
|
119
|
+
k, v = line.split("=", 1)
|
|
120
|
+
key = k.strip()
|
|
121
|
+
if not re.fullmatch(r"PHONEDEX_[A-Z0-9_]+", key):
|
|
122
|
+
continue
|
|
123
|
+
v = v.strip()
|
|
124
|
+
if len(v) >= 2 and v[0] in "\"'" and v[-1] == v[0]:
|
|
125
|
+
v = v[1:-1]
|
|
126
|
+
result[key] = v
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def update_env(path: Path, key: str, value: str) -> None:
|
|
131
|
+
if key not in CONFIGURABLE:
|
|
132
|
+
raise ValueError(f"Setting not supported by CLI editor: {key}")
|
|
133
|
+
if path.is_symlink():
|
|
134
|
+
raise ValueError("Refusing to edit symlinked .env")
|
|
135
|
+
if not path.is_file():
|
|
136
|
+
raise ValueError("No .env found. Run PhoneDex setup first.")
|
|
137
|
+
if "\n" in value or "\r" in value or "#" in value:
|
|
138
|
+
raise ValueError("Invalid configuration value")
|
|
139
|
+
if key == "PHONEDEX_TRANSCRIPT_MODE" and value not in {"off", "local", "worker", "both"}:
|
|
140
|
+
raise ValueError("Invalid transcript mode")
|
|
141
|
+
if key == "PHONEDEX_TRANSCRIPT_RETENTION_DAYS" and not (value.isascii() and value.isdecimal() and 1 <= int(value) <= 90):
|
|
142
|
+
raise ValueError("Retention must be 1–90 days")
|
|
143
|
+
if key == "PHONEDEX_MAX_CALLS_PER_DAY" and not (value.isascii() and value.isdecimal() and 1 <= int(value) <= 1000):
|
|
144
|
+
raise ValueError("Daily limit must be 1–1000")
|
|
145
|
+
if key == "PHONEDEX_ALLOWED_RECIPIENTS":
|
|
146
|
+
numbers = [n.strip() for n in value.split(",") if n.strip()]
|
|
147
|
+
if any(not E164.fullmatch(n) for n in numbers) or len(numbers) != len(set(numbers)):
|
|
148
|
+
raise ValueError("Only unique E.164 phone numbers are allowed")
|
|
149
|
+
value = ",".join(numbers)
|
|
150
|
+
original = path.read_text(encoding="utf-8-sig")
|
|
151
|
+
parts = original.splitlines(keepends=True)
|
|
152
|
+
found = False
|
|
153
|
+
for i, line in enumerate(parts):
|
|
154
|
+
if re.match(r"^\s*" + re.escape(key) + r"\s*=", line):
|
|
155
|
+
if found:
|
|
156
|
+
raise ValueError("Duplicate setting present; resolve it manually")
|
|
157
|
+
parts[i] = key + "=" + value + ("\r\n" if line.endswith("\r\n") else "\n")
|
|
158
|
+
found = True
|
|
159
|
+
if not found:
|
|
160
|
+
if parts and not parts[-1].endswith(("\r", "\n")):
|
|
161
|
+
parts.append("\n")
|
|
162
|
+
parts.append(key + "=" + value + "\n")
|
|
163
|
+
_private_write(path, "".join(parts))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _record() -> dict:
|
|
167
|
+
try:
|
|
168
|
+
record = json.loads(managed_file().read_text(encoding="utf-8"))
|
|
169
|
+
return record if isinstance(record, dict) else {}
|
|
170
|
+
except (OSError, ValueError):
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _matches(record: dict) -> psutil.Process | None:
|
|
175
|
+
try:
|
|
176
|
+
pid, created, script, root = int(record["pid"]), float(record["created"]), record["script"], record["root"]
|
|
177
|
+
if pid == os.getpid():
|
|
178
|
+
return None
|
|
179
|
+
proc = psutil.Process(pid)
|
|
180
|
+
if not proc.is_running() or proc.status() == psutil.STATUS_ZOMBIE or abs(proc.create_time() - created) >= 2:
|
|
181
|
+
return None
|
|
182
|
+
cmd = proc.cmdline()
|
|
183
|
+
# A stale or PID-reused record must never terminate another process.
|
|
184
|
+
if str(Path(script).resolve()) not in [str(Path(c).resolve()) for c in cmd]:
|
|
185
|
+
return None
|
|
186
|
+
if "controller" not in cmd:
|
|
187
|
+
return None
|
|
188
|
+
if str(Path(script).resolve().parent) != str(Path(root).resolve()):
|
|
189
|
+
return None
|
|
190
|
+
return proc
|
|
191
|
+
except (psutil.Error, OSError, ValueError, KeyError, TypeError):
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def controller_status(root: Path | None = None) -> tuple[str, dict]:
|
|
196
|
+
record = _record()
|
|
197
|
+
if not record:
|
|
198
|
+
return "stopped", {}
|
|
199
|
+
proc = _matches(record)
|
|
200
|
+
if proc is None:
|
|
201
|
+
return "stale", record
|
|
202
|
+
if root is not None and Path(record["root"]).resolve() != root.resolve():
|
|
203
|
+
return "other-checkout", record
|
|
204
|
+
return "running", record
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def start_controller(root: Path) -> int:
|
|
208
|
+
state, _ = controller_status()
|
|
209
|
+
if state == "running":
|
|
210
|
+
raise RuntimeError("A controller managed by this CLI is already running")
|
|
211
|
+
logfile = home_dir() / "controller.log"
|
|
212
|
+
logfile.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
argv = core_argv(root, "controller")
|
|
214
|
+
options = {"cwd": root, "stdin": subprocess.DEVNULL}
|
|
215
|
+
if os.name == "nt":
|
|
216
|
+
options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
|
217
|
+
else:
|
|
218
|
+
options["start_new_session"] = True
|
|
219
|
+
with logfile.open("a", encoding="utf-8") as output:
|
|
220
|
+
proc = subprocess.Popen(argv, stdout=output, stderr=subprocess.STDOUT, **options)
|
|
221
|
+
try:
|
|
222
|
+
started = psutil.Process(proc.pid).create_time()
|
|
223
|
+
_private_write(managed_file(), json.dumps({"pid": proc.pid, "created": started,
|
|
224
|
+
"root": str(root.resolve()), "script": str((root / "phonedex.py").resolve()),
|
|
225
|
+
"log": str(logfile)}, indent=2) + "\n")
|
|
226
|
+
except Exception:
|
|
227
|
+
# Never orphan a controller if we cannot register it safely.
|
|
228
|
+
proc.terminate()
|
|
229
|
+
raise
|
|
230
|
+
time.sleep(0.2)
|
|
231
|
+
if proc.poll() is not None:
|
|
232
|
+
return_code = proc.returncode
|
|
233
|
+
managed_file().unlink(missing_ok=True)
|
|
234
|
+
raise RuntimeError(f"Controller exited during startup (code {return_code}); view logs")
|
|
235
|
+
return proc.pid
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def stop_controller(root: Path) -> bool:
|
|
239
|
+
state, record = controller_status(root)
|
|
240
|
+
if state != "running":
|
|
241
|
+
return False
|
|
242
|
+
proc = _matches(record)
|
|
243
|
+
if proc is None:
|
|
244
|
+
return False
|
|
245
|
+
proc.terminate()
|
|
246
|
+
try:
|
|
247
|
+
proc.wait(timeout=8)
|
|
248
|
+
except psutil.TimeoutExpired:
|
|
249
|
+
# Do not force-kill; callers can inspect process and stop it manually.
|
|
250
|
+
raise RuntimeError("Controller did not stop within 8 seconds; not force-killing")
|
|
251
|
+
managed_file().unlink(missing_ok=True)
|
|
252
|
+
return True
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def clone_checkout(dest: Path) -> None:
|
|
256
|
+
if dest.exists():
|
|
257
|
+
raise ValueError("Destination already exists; refusing to overwrite it")
|
|
258
|
+
git = shutil.which("git")
|
|
259
|
+
if not git:
|
|
260
|
+
raise RuntimeError("Git was not found; install Git or point the CLI to an existing checkout")
|
|
261
|
+
# Public upstream only; no credentials or local arbitrary URLs.
|
|
262
|
+
completed = safe_exec([git, "clone", "--depth", "1", UPSTREAM, str(dest)], timeout=180)
|
|
263
|
+
if completed.returncode:
|
|
264
|
+
raise RuntimeError("Git clone failed: " + completed.stderr[-1500:])
|
|
265
|
+
validate_checkout(dest)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def redact(text: str) -> str:
|
|
269
|
+
"""Best-effort log/output redaction; never claim complete sanitization."""
|
|
270
|
+
clean = []
|
|
271
|
+
for line in text.splitlines():
|
|
272
|
+
if SENSITIVE.search(line):
|
|
273
|
+
clean.append("[redacted sensitive line]")
|
|
274
|
+
else:
|
|
275
|
+
clean.append(line)
|
|
276
|
+
return "\n".join(clean)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: phonedex-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An optional animated terminal cockpit and setup companion for PhoneDex
|
|
5
|
+
Author: Austin Harvey
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: phonedex,terminal,sip,telephony,cli
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Communications :: Telephony
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: rich<16,>=13.7
|
|
22
|
+
Requires-Dist: psutil<8,>=5.9
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# PhoneDex CLI
|
|
26
|
+
|
|
27
|
+
**An optional, colorful terminal control deck for [PhoneDex](https://github.com/dexifried/Phonedex).**
|
|
28
|
+
|
|
29
|
+
A Rich-powered ASCII dashboard, short startup animations, step-by-step setup, diagnostic panels, a managed-controller switchboard, and safe calling controls. **PhoneDex CLI is a separate frontend**, not a fork of the SIP bridge. Uninstalling it leaves PhoneDex's existing installation, config, and calls intact.
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
____ _ ____
|
|
33
|
+
| _ \| |__ ___ _ __ ___| _ \ _____ __
|
|
34
|
+
| |_) | '_ \ / _ \| '_ \ / _ \ | | |/ _ \ \/ /
|
|
35
|
+
| __/| | | | (_) | | | | __/ |_| | __/> <
|
|
36
|
+
|_| |_| |_|\___/|_| |_|\___|____/ \___/_/\_\
|
|
37
|
+
|
|
38
|
+
TERMINAL CONTROL // an optional PhoneDex companion
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
Requires Python **3.11+**. Linux, macOS and Windows use the same commands. Git is needed **only** if you want the CLI to clone PhoneDex for you.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Clone or unpack this separate phonedex-cli project
|
|
47
|
+
cd phonedex-cli
|
|
48
|
+
python -m pip install -e .
|
|
49
|
+
|
|
50
|
+
# First-time users: interactive setup wizard
|
|
51
|
+
phonedex-cli wizard
|
|
52
|
+
|
|
53
|
+
# Regular users: animated control deck
|
|
54
|
+
phonedex-cli
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
On some systems use `python3` and `python3 -m pip`. The installation above is editable/developer mode; end users can instead use `python -m pip install .` from the unpacked project. For isolation use a dedicated virtual environment. **This repository has not yet been released on PyPI.** Do not assume `pip install phonedex-cli` works from PyPI.
|
|
58
|
+
|
|
59
|
+
### Existing PhoneDex installation?
|
|
60
|
+
|
|
61
|
+
Either choose **existing** in `phonedex-cli wizard` or use:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
phonedex-cli --checkout /path/to/Phonedex status
|
|
65
|
+
phonedex-cli --checkout /path/to/Phonedex doctor --mode local
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`PHONEDEX_HOME=/path/to/Phonedex` is also supported. The wizard saves the source path to a local config file, not the PhoneDex checkout. It never overwrites an existing `.env`.
|
|
69
|
+
|
|
70
|
+
### Commands
|
|
71
|
+
|
|
72
|
+
| Command | Behavior |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `phonedex-cli` or `phonedex-cli menu` | Interactive ASCII/Rich terminal dashboard |
|
|
75
|
+
| `phonedex-cli wizard` | Link/clone source, run optional bootstrap, local Codex login, delegate to official setup, inspect diagnostics |
|
|
76
|
+
| `phonedex-cli doctor --mode local` | Official read-only source checks; change to `remote` when using a Worker |
|
|
77
|
+
| `phonedex-cli setup --mode local` | Delegates to the existing core setup wizard |
|
|
78
|
+
| `phonedex-cli status` | Local checkout, Codex command presence, privacy mode, CLI-managed process state |
|
|
79
|
+
| `phonedex-cli start` | Start a CLI-managed controller **only with confirmation**; queued work can dispatch calls |
|
|
80
|
+
| `phonedex-cli stop` | Stop **only** the controller this CLI started and recorded |
|
|
81
|
+
| `phonedex-cli restart` | Confirm and restart a CLI-managed controller |
|
|
82
|
+
| `phonedex-cli logs --lines 40` | Recent managed-controller logs (best-effort masking of secret-bearing lines) |
|
|
83
|
+
| `phonedex-cli settings` | Private local editor: transcripts, retention, daily call cap, allowlisted phone numbers |
|
|
84
|
+
| `phonedex-cli dial --to +14165550123` | Allowlisted **dry run** (no call) |
|
|
85
|
+
| `phonedex-cli dial --to +14165550123 --place-call` | Explicit, **potentially billable** real call, requires confirmation |
|
|
86
|
+
|
|
87
|
+
Use `--no-color --no-animation` for inaccessible or non-interactive terminals. Animation is automatically disabled when the output isn't a terminal or when `CI` is set. `--yes` applies **only** to `dial --place-call` and bypasses its second confirmation for deliberate automation; it still requires an allowlisted number and upstream PhoneDex safeguards.
|
|
88
|
+
|
|
89
|
+
## Architecture and privacy
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
phonedex-cli (optional UI)
|
|
93
|
+
│
|
|
94
|
+
├─ ~/.config/phonedex-cli/config.json → installed source path only
|
|
95
|
+
├─ controller.json / controller.log → CLI-managed subprocess only
|
|
96
|
+
│
|
|
97
|
+
└─ invokes official PhoneDex commands:
|
|
98
|
+
phonedex.py setup / doctor / dial / controller
|
|
99
|
+
│
|
|
100
|
+
core .env, SIP gateway, GPT Live, optional Worker
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- The CLI does **not** request or store SIP passwords, OAuth tokens, or AgentPhone keys. The official PhoneDex setup handles provider secrets privately.
|
|
104
|
+
- The separate CLI only edits four whitelisted **non-secret** `.env` settings. It uses owner-restricted atomic replacement on POSIX, preserves unrelated variables and comments, and refuses symlink writes.
|
|
105
|
+
- Configuration edits take effect for new PhoneDex processes. An already-running controller generally needs a restart; this CLI never restarts it automatically.
|
|
106
|
+
- The CLI does **not** configure DNS, TLS certificates, Cloudflare resources, firewalls, port forwarding, telephony numbers, or SIP trunks.
|
|
107
|
+
- Setup, status, diagnostics, and default dialing perform **no real phone call**. Starting the controller can execute requests already queued in a configured Worker; it requires confirmation.
|
|
108
|
+
- Transcript storage defaults to `off` in the core. Choosing `worker` or `both` shares transcript text with **your own** Worker/KV. Changes apply to new calls and don't erase existing data.
|
|
109
|
+
- The log viewer masks lines with common secret labels on a best-effort basis. Logs may still reveal phone numbers, call content, and metadata. Do not publish raw logs.
|
|
110
|
+
- The controller PID registry records creation time and exact script path. **Stop will refuse** to terminate an unrelated process or a stale/reused PID. Controllers started outside this CLI are deliberately not managed.
|
|
111
|
+
- Operational correctness and carrier compatibility depend on the PhoneDex core and your provider. Use [PhoneDex's release status](https://github.com/dexifried/Phonedex/blob/main/docs/RELEASE_STATUS.md).
|
|
112
|
+
|
|
113
|
+
**Note:** PhoneDex uses an undocumented GPT Live subscription endpoint. Its availability and permission to use it may change. This CLI provides no guarantee of service or compliant use and is not affiliated with OpenAI, Codex, or AgentPhone.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
python -m pip install -e . pytest
|
|
119
|
+
python -m pytest -q
|
|
120
|
+
python -m phonedex_cli --no-animation --no-color status
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Offline tests cover config preservation, allowlisting, confirmation gates, symlink protection, stale-process safety, and dry-run defaults. CI is configured for Windows, Linux and macOS, but actual results depend on running the workflow. Live carrier calls are **not** part of these tests.
|
|
124
|
+
|
|
125
|
+
Licensed MIT. © 2026 Austin Harvey.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/phonedex_cli/__init__.py
|
|
5
|
+
src/phonedex_cli/__main__.py
|
|
6
|
+
src/phonedex_cli/app.py
|
|
7
|
+
src/phonedex_cli/core.py
|
|
8
|
+
src/phonedex_cli.egg-info/PKG-INFO
|
|
9
|
+
src/phonedex_cli.egg-info/SOURCES.txt
|
|
10
|
+
src/phonedex_cli.egg-info/dependency_links.txt
|
|
11
|
+
src/phonedex_cli.egg-info/entry_points.txt
|
|
12
|
+
src/phonedex_cli.egg-info/requires.txt
|
|
13
|
+
src/phonedex_cli.egg-info/top_level.txt
|
|
14
|
+
tests/test_app.py
|
|
15
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
phonedex_cli
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from phonedex_cli import app
|
|
7
|
+
from phonedex_cli import core
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_version(capsys):
|
|
11
|
+
with pytest.raises(SystemExit) as exc:
|
|
12
|
+
app.main(["--version"])
|
|
13
|
+
assert exc.value.code == 0
|
|
14
|
+
assert "0.1.0" in capsys.readouterr().out
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_dial_requires_existing_checkout():
|
|
18
|
+
with patch.object(core, "get_checkout", return_value=None):
|
|
19
|
+
assert app.main(["--no-animation", "dial", "--to", "+12125550123"]) == 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_dial_dry_run_default(tmp_path, monkeypatch):
|
|
23
|
+
(tmp_path / ".env").write_text("PHONEDEX_ALLOWED_RECIPIENTS=+12125550123\n")
|
|
24
|
+
with patch.object(core, "get_checkout", return_value=tmp_path):
|
|
25
|
+
with patch.object(app.subprocess, "call", return_value=0) as call:
|
|
26
|
+
assert app.main(["--no-color", "--no-animation", "dial", "--to", "+12125550123"]) == 0
|
|
27
|
+
argv = call.call_args[0][0]
|
|
28
|
+
assert "--place-call" not in argv
|
|
29
|
+
assert "dial" in argv
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_dial_not_allowlisted(tmp_path):
|
|
33
|
+
(tmp_path / ".env").write_text("PHONEDEX_ALLOWED_RECIPIENTS=\n")
|
|
34
|
+
with patch.object(core, "get_checkout", return_value=tmp_path):
|
|
35
|
+
with patch.object(app.subprocess, "call") as call:
|
|
36
|
+
assert app.main(["--no-animation", "dial", "--to", "+12125550123", "--place-call", "--yes"]) == 2
|
|
37
|
+
call.assert_not_called()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_real_call_requires_confirmation(tmp_path):
|
|
41
|
+
(tmp_path / ".env").write_text("PHONEDEX_ALLOWED_RECIPIENTS=+12125550123\n")
|
|
42
|
+
with patch.object(core, "get_checkout", return_value=tmp_path):
|
|
43
|
+
with patch.object(app.Confirm, "ask", return_value=False):
|
|
44
|
+
with patch.object(app.subprocess, "call") as call:
|
|
45
|
+
assert app.main(["--no-animation", "dial", "--to", "+12125550123", "--place-call"]) == 0
|
|
46
|
+
call.assert_not_called()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_start_requires_explicit_confirmation(tmp_path):
|
|
50
|
+
with patch.object(core, "get_checkout", return_value=tmp_path):
|
|
51
|
+
with patch.object(app.Confirm, "ask", return_value=False):
|
|
52
|
+
with patch.object(core, "start_controller") as start:
|
|
53
|
+
assert app.main(["--no-animation", "start"]) == 0
|
|
54
|
+
start.assert_not_called()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from phonedex_cli import core
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def valid_root(tmp_path):
|
|
12
|
+
for rel in ["phonedex.py", "onboarding.py", "scripts/bootstrap.py"]:
|
|
13
|
+
path = tmp_path / rel
|
|
14
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
path.write_text("# stub\n")
|
|
16
|
+
return tmp_path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_checkout_validation(tmp_path):
|
|
20
|
+
with pytest.raises(ValueError, match="missing phonedex.py"):
|
|
21
|
+
core.validate_checkout(tmp_path)
|
|
22
|
+
assert core.validate_checkout(valid_root(tmp_path)) == tmp_path.resolve()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_preference_keeps_checkout_without_touching_core(tmp_path, monkeypatch):
|
|
26
|
+
monkeypatch.setenv("PHONEDEX_CLI_HOME", str(tmp_path / "settings"))
|
|
27
|
+
src = valid_root(tmp_path / "core")
|
|
28
|
+
core.set_checkout(src)
|
|
29
|
+
assert core.get_checkout() == src.resolve()
|
|
30
|
+
assert not (src / ".env").exists()
|
|
31
|
+
assert json.loads(core.preferences_file().read_text())["checkout"] == str(src)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_update_env_preserves_existing_secrets_and_comments(tmp_path):
|
|
35
|
+
p = tmp_path / ".env"
|
|
36
|
+
p.write_text("# private settings\nPHONEDEX_SIP_PASSWORD=do-not-leak\nPHONEDEX_TRANSCRIPT_MODE=off\n", encoding="utf-8")
|
|
37
|
+
core.update_env(p, "PHONEDEX_TRANSCRIPT_MODE", "local")
|
|
38
|
+
content = p.read_text()
|
|
39
|
+
assert "PHONEDEX_SIP_PASSWORD=do-not-leak" in content
|
|
40
|
+
assert "# private settings" in content
|
|
41
|
+
assert "PHONEDEX_TRANSCRIPT_MODE=local" in content
|
|
42
|
+
assert core.parse_env(p)["PHONEDEX_TRANSCRIPT_MODE"] == "local"
|
|
43
|
+
if os.name != "nt":
|
|
44
|
+
assert (p.stat().st_mode & 0o077) == 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_update_env_guards(tmp_path):
|
|
48
|
+
p = tmp_path / ".env"
|
|
49
|
+
p.write_text("PHONEDEX_ALLOWED_RECIPIENTS=\n")
|
|
50
|
+
for bad in ["123", "+1212\nPHONEDEX_SIP_PASSWORD=pwn", "+12125550101,+12125550101"]:
|
|
51
|
+
with pytest.raises(ValueError):
|
|
52
|
+
core.update_env(p, "PHONEDEX_ALLOWED_RECIPIENTS", bad)
|
|
53
|
+
with pytest.raises(ValueError):
|
|
54
|
+
core.update_env(p, "PHONEDEX_SIP_PASSWORD", "change-me")
|
|
55
|
+
with pytest.raises(ValueError):
|
|
56
|
+
core.update_env(p, "PHONEDEX_TRANSCRIPT_MODE", "everything")
|
|
57
|
+
core.update_env(p, "PHONEDEX_ALLOWED_RECIPIENTS", "+12125550101,+14165550123")
|
|
58
|
+
assert core.parse_env(p)["PHONEDEX_ALLOWED_RECIPIENTS"] == "+12125550101,+14165550123"
|
|
59
|
+
for bad in ["0", "91", "-1", "2x"]:
|
|
60
|
+
with pytest.raises(ValueError):
|
|
61
|
+
core.update_env(p, "PHONEDEX_TRANSCRIPT_RETENTION_DAYS", bad)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_update_env_rejects_duplicate_key(tmp_path):
|
|
65
|
+
path = tmp_path / ".env"
|
|
66
|
+
path.write_text("PHONEDEX_TRANSCRIPT_MODE=off\nPHONEDEX_TRANSCRIPT_MODE=both\n")
|
|
67
|
+
with pytest.raises(ValueError, match="Duplicate"):
|
|
68
|
+
core.update_env(path, "PHONEDEX_TRANSCRIPT_MODE", "local")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_update_env_refuses_symlink(tmp_path):
|
|
72
|
+
original = tmp_path / "target"
|
|
73
|
+
original.write_text("PHONEDEX_TRANSCRIPT_MODE=off\n")
|
|
74
|
+
link = tmp_path / ".env"
|
|
75
|
+
try:
|
|
76
|
+
link.symlink_to(original)
|
|
77
|
+
except (OSError, NotImplementedError):
|
|
78
|
+
pytest.skip("Symlink creation unsupported")
|
|
79
|
+
with pytest.raises(ValueError, match="symlink"):
|
|
80
|
+
core.update_env(link, "PHONEDEX_TRANSCRIPT_MODE", "local")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_no_arbitrary_git_url(tmp_path, monkeypatch):
|
|
84
|
+
monkeypatch.setattr(core.shutil, "which", lambda _: None)
|
|
85
|
+
with pytest.raises(RuntimeError, match="Git was not found"):
|
|
86
|
+
core.clone_checkout(tmp_path / "fresh")
|
|
87
|
+
with pytest.raises(ValueError, match="exists"):
|
|
88
|
+
core.clone_checkout(tmp_path)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_pid_reuse_cannot_target_another_process(tmp_path):
|
|
92
|
+
process = MagicMock()
|
|
93
|
+
process.is_running.return_value = True
|
|
94
|
+
process.status.return_value = "running"
|
|
95
|
+
process.create_time.return_value = 1234
|
|
96
|
+
process.cmdline.return_value = ["python", "something-else.py", "controller"]
|
|
97
|
+
record = {"pid": 50001, "created": 1234, "script": str(tmp_path / "phonedex.py"), "root": str(tmp_path)}
|
|
98
|
+
with patch.object(core.psutil, "Process", return_value=process):
|
|
99
|
+
assert core._matches(record) is None
|
|
100
|
+
process.cmdline.return_value = ["python", str(tmp_path / "phonedex.py"), "controller"]
|
|
101
|
+
process.create_time.return_value = 1
|
|
102
|
+
with patch.object(core.psutil, "Process", return_value=process):
|
|
103
|
+
assert core._matches(record) is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_controller_cannot_stop_unmanaged(tmp_path, monkeypatch):
|
|
107
|
+
monkeypatch.setenv("PHONEDEX_CLI_HOME", str(tmp_path / "settings"))
|
|
108
|
+
assert core.stop_controller(tmp_path) is False
|
|
109
|
+
core._private_write(core.managed_file(), json.dumps({"pid": 99999, "created": 20, "script": "/other", "root": "/other"}))
|
|
110
|
+
with patch.object(core.psutil, "Process", side_effect=core.psutil.NoSuchProcess(99999)):
|
|
111
|
+
assert core.stop_controller(tmp_path) is False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_redacts_secret_lines():
|
|
115
|
+
value = "SIP_PASSWORD=hunter2\nCHECK_MODE=local\nAuthorization: Bearer test\n"
|
|
116
|
+
result = core.redact(value)
|
|
117
|
+
assert "hunter2" not in result
|
|
118
|
+
assert "Bearer test" not in result
|
|
119
|
+
assert "CHECK_MODE=local" in result
|