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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Optional terminal cockpit for the PhoneDex core."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .app import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ phonedex-cli = phonedex_cli.app:main
@@ -0,0 +1,2 @@
1
+ rich<16,>=13.7
2
+ psutil<8,>=5.9
@@ -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