Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -135,5 +135,5 @@
}
],
"results": {},
"generated_at": "2026-08-16T20:45:51Z"
"generated_at": "2026-08-17T03:34:09Z"
}
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ rendering, and CLI dispatch separate.

Public files may contain documentation-only values such as `192.0.2.10` and
generic example names. Site inventory belongs in the private `home-ops` repo.
Secrets remain in a password manager or local mode-`0600` files, never Git.
Secrets may live in the private `home-ops` configuration deployed mode `0600`,
but never in this public repository, logs, issues, or command output.
32 changes: 15 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,43 +29,42 @@ Python 3.11 or newer is required by the current implementation.
## Install

```bash
git clone https://github.com/cnberry/switchctl.git
cd switchctl
./script/install
cd /path/to/private/home-ops
./bin/bootstrap-ctls switchctl
```

`script/install` is the stable repository contract used by private deployment
automation. Today it installs the Python package with `pipx`; it can be replaced
by a Rust or binary installer later without changing callers. `just install`
uses the same contract.
The private `home-ops` bootstrap is the canonical installer: it populates the
real switch inventory, calls this repository's stable `script/install`
contract, and creates `/usr/local/bin/switchctl` backed by an isolated system
environment under `/usr/local/lib/home-ops/ctls`.

## Configure private switches

Install the sanitized example outside the repository, then replace it with your
own endpoint inventory:

```bash
mkdir -p ~/.config/switchctl
install -m 600 config/switches.example.json ~/.config/switchctl/config.json
sudo install -d -m 700 /usr/local/config/switchctl
sudo install -m 600 config/switches.example.json /usr/local/config/switchctl/config.json
```

Set `SWITCHCTL_CONFIG=/path/to/config.json` or pass global `--config PATH` to
select another private file. Host addresses, device IDs, backend IDs, rooms,
names, notes, and tags are private deployment data and belong in a private
configuration repository.

Tuya local keys should not be committed even to a private repository. Use a
named environment reference in config:
Store each Tuya local key directly in the private mode-`0600` configuration:

```json
{
"local_key_env": "SWITCHCTL_EXAMPLE_OUTLET_LOCAL_KEY"
"local_key": "replace-with-local-key"
}
```

Then supply that variable from a password manager before running the command.
The legacy inline `local_key` field remains supported for a tightly permissioned
local config file.
The public repository and command output must never contain or print real keys.
The private `home-ops` repository is the deployment source of truth and its
bootstrap installs the complete configuration without runtime environment
variables.

## Inspect state

Expand Down Expand Up @@ -99,10 +98,9 @@ targets fan out only after that explicit guard. See

| Data | Default path | Git policy |
| --- | --- | --- |
| Switch inventory | `~/.config/switchctl/config.json` | Private config repo; no keys |
| Switch inventory and local keys | `/usr/local/config/switchctl/config.json` | Private config repo only |
| Manual state | `~/.local/state/switchctl/manual-state.json` | Never commit |
| Tuya cloud extraction config | `~/.config/switchctl/tinytuya.json` | Never commit |
| Tuya local keys | Named environment variables or local config | Never commit |

Generated configuration and manual state files use mode `0600`.

Expand Down
4 changes: 2 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ command already exists.
- Read state before a write when the exact endpoint or current state is unclear.
- Use `--yes` only after the selector, resulting target set, and action are clear.
- Report the post-write state returned by the backend.
- Never print or request a local key in chat; use the configured environment
variable or local mode-`0600` config.
- Never print or request a local key in chat; use the deployed mode-`0600`
private config.
- Follow the Tuya onboarding runbook instead of improvising cloud extraction.

## Commands
Expand Down
2 changes: 1 addition & 1 deletion config/switches.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"host": "192.0.2.10",
"port": 6668,
"device_id": "replace-with-device-id",
"local_key_env": "SWITCHCTL_EXAMPLE_OUTLET_LOCAL_KEY",
"local_key": "replace-with-local-key",
"switch_dp": "1",
"protocol_version": "3.4",
"notes": ["Use documentation-only values in public examples."]
Expand Down
4 changes: 2 additions & 2 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ network hardware.

The Tuya backend uses `tinytuya` in-process for LAN status and setpoint calls.
Required private fields are host, device ID, local key, and switch datapoint;
protocol version defaults to 3.3. Local keys may come from an environment name
declared by `local_key_env`, keeping the secret out of tracked configuration.
protocol version defaults to 3.3. Local keys are read from the private
mode-`0600` configuration and are never included in command output.

The backend checks TCP reachability before protocol calls. A set operation reads
status again and returns the state from that response. Device families and
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
## Installer direction

Deployment automation calls `script/install`, never a language-specific package
manager. The current script uses `pipx`; a Rust migration should replace it with
manager. The current script creates a system venv; a Rust migration should replace it with
a verified binary or `cargo` installation while preserving that entry point.

## Out of scope by default
Expand Down
8 changes: 4 additions & 4 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

## Config is not found

Create `~/.config/switchctl/config.json` from the public example, install the
Create `/usr/local/config/switchctl/config.json` from the public example, install the
private home-ops inventory, or set `SWITCHCTL_CONFIG`. Repository-local runtime
configuration is intentionally unsupported.

## A local key is missing

If the target uses `local_key_env`, load that exact environment variable from a
password manager in the process running `switchctl`. Inline keys remain
supported only for local mode-`0600` configuration.
Add the target's `local_key` to the private source configuration, run the
`home-ops` bootstrap, and confirm `/usr/local/config/switchctl/config.json` is
owned by the operating user with mode `0600`. Never paste the key into logs.

## A device is unreachable

Expand Down
13 changes: 6 additions & 7 deletions runbooks/tuya-local-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,24 +57,23 @@ If an allowlisted IPv4 project is reached over IPv6 and the provider rejects the
request, constrain the extraction client to IPv4 using current TinyTuya guidance
rather than copying an environment-specific script into public documentation.

## 5. Record non-secret inventory
## 5. Record private inventory

Add the endpoint to private `home-ops` switch configuration with its meaningful
local name, role, room, host, device ID, protocol version, and datapoint. Replace
the local key with an environment reference such as:
local name, role, room, host, device ID, local key, protocol version, and
datapoint:

```json
{
"local_key_env": "SWITCHCTL_EXAMPLE_OUTLET_LOCAL_KEY"
"local_key": "replace-with-local-key"
}
```

Store the actual variable value in a password manager on each authorized
machine.
Keep that repository private and deploy the config with mode `0600`.

## 6. Validate local control

Install the private config, load the key environment variable, and run:
Install the complete private config and run:

```bash
switchctl status example-outlet
Expand Down
17 changes: 13 additions & 4 deletions script/install
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@
set -eu

repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
name=switchctl
install_prefix=${CTL_INSTALL_PREFIX:-/usr/local}
venv_root=${CTL_VENV_ROOT:-$install_prefix/lib/home-ops/ctls}
bin_dir=${CTL_BIN_DIR:-$install_prefix/bin}
venv="$venv_root/$name"
python=${PYTHON:-python3}

if ! command -v pipx >/dev/null 2>&1; then
echo "switchctl: the current Python implementation requires pipx" >&2
echo "switchctl: install pipx, or replace script/install when a binary implementation ships" >&2
if ! command -v "$python" >/dev/null 2>&1; then
echo "$name: Python 3 is required" >&2
exit 1
fi

exec pipx install --force "$repo_root"
install -d -m 755 "$install_prefix/lib" "$install_prefix/lib/home-ops" "$venv_root" "$bin_dir"
"$python" -m venv --clear "$venv"
"$venv/bin/python" -m pip install --disable-pip-version-check "$repo_root"
chmod -R a+rX "$venv"
ln -sfn "$venv/bin/$name" "$bin_dir/$name"
2 changes: 1 addition & 1 deletion switchctl/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from switchctl.models import SwitchTarget

ENV_CONFIG_PATH = "SWITCHCTL_CONFIG"
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "switchctl" / "config.json"
DEFAULT_CONFIG_PATH = Path("/usr/local/config/switchctl/config.json")
DEFAULT_MANUAL_STATE_PATH = Path.home() / ".local" / "state" / "switchctl" / "manual-state.json"
ROOT = Path(__file__).resolve().parent.parent
EXAMPLE_CONFIG_PATH = ROOT / "config" / "switches.example.json"
Expand Down
9 changes: 1 addition & 8 deletions switchctl/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import os
from dataclasses import asdict, dataclass, field
from typing import Any

Expand All @@ -19,16 +18,11 @@ class SwitchTarget:
port: int | None = None
device_id: str | None = None
local_key: str | None = None
local_key_env: str | None = None
switch_dp: str | int | None = None
protocol_version: str | None = None

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SwitchTarget":
local_key_env = data.get("local_key_env")
local_key = data.get("local_key")
if not local_key and local_key_env:
local_key = os.environ.get(local_key_env)
return cls(
id=data["id"],
name=data.get("name", data["id"]),
Expand All @@ -41,8 +35,7 @@ def from_dict(cls, data: dict[str, Any]) -> "SwitchTarget":
host=data.get("host"),
port=data.get("port"),
device_id=data.get("device_id"),
local_key=local_key,
local_key_env=local_key_env,
local_key=data.get("local_key"),
switch_dp=data.get("switch_dp"),
protocol_version=data.get("protocol_version"),
)
Expand Down
1 change: 0 additions & 1 deletion tests/test_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ def make_target(**overrides):
"port": None,
"device_id": None,
"local_key": None,
"local_key_env": None,
"switch_dp": None,
"protocol_version": None,
"notes": [],
Expand Down
19 changes: 14 additions & 5 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import stat

from switchctl.config import default_config, load_targets, redacted_config, write_default_config
from switchctl.config import (
DEFAULT_CONFIG_PATH,
default_config,
load_targets,
redacted_config,
write_default_config,
)


def test_system_config_path_is_the_default():
assert DEFAULT_CONFIG_PATH.as_posix() == "/usr/local/config/switchctl/config.json"


def test_default_config_has_expected_targets(tmp_path):
Expand All @@ -18,17 +28,16 @@ def test_default_config_shape():
assert len(config["targets"]) == 1


def test_local_key_can_come_from_named_environment(monkeypatch, tmp_path):
monkeypatch.setenv("SWITCHCTL_EXAMPLE_OUTLET_LOCAL_KEY", "environment-only")
def test_local_key_comes_from_private_config(tmp_path):
path = tmp_path / "config.json"
path.write_text(
'{"targets": [{"id": "example-outlet", "name": "Example", '
'"role": "outlet", "backend": "tuya-local", '
'"local_key_env": "SWITCHCTL_EXAMPLE_OUTLET_LOCAL_KEY"}]}'
'"local_key": "private-config-value"}]}'
)
_, targets = load_targets(path)
outlet = next(target for target in targets if target.id == "example-outlet")
assert outlet.local_key == "environment-only"
assert outlet.local_key == "private-config-value"


def test_redacted_config_hides_private_fields():
Expand Down
Loading