diff --git a/.claude/commands/issue.md b/.claude/commands/issue.md new file mode 100644 index 0000000..eceb8bd --- /dev/null +++ b/.claude/commands/issue.md @@ -0,0 +1,86 @@ +--- +description: Fetch details of a GitHub issue, create a dedicated local branch, implement the changes, verify, and commit. +argument-hint: " e.g. 42" +--- + +Work on a GitHub issue for dlight-client. The issue number to address is: **$ARGUMENTS** + +Follow these steps exactly, stopping and reporting to the user if any step fails. + +## 1. Validate inputs + +- If no issue number was provided in `$ARGUMENTS`, stop and ask the user: "Please specify the issue number you want to work on, e.g., `/issue 42`." +- Ensure `$ARGUMENTS` is a valid number. If not, stop and inform the user. + +## 2. Fetch issue details + +Run the following command to retrieve the issue details: +```bash +gh issue view "$ARGUMENTS" +``` + +Read and analyze the output to understand: +- The problem statement. +- The proposed changes. +- The acceptance criteria. + +If the command fails (e.g. because of network issues or if the issue does not exist), stop and report to the user. + +## 3. Create a branch + +Extract the title from the issue output and derive a slug: +- Lowercase, alphanumeric, and hyphens only. +- Format: `issue-$ARGUMENTS-` (max ~50 characters total). +- Example: Issue 42 "pre-commit config for ruff" → `issue-42-ruff-pre-commit` + +Check out a new branch: +```bash +git checkout -b +``` + +## 4. Implement changes + +Locate the target files in the repository. Implement the changes requested in the issue description, ensuring: +- You adhere to PEP 8 coding style and existing project conventions. +- You do not introduce unrelated modifications. +- If there are new public methods or parameters, you update the documentation in `docs/` as required by `CLAUDE.md`. + +## 5. Verify the changes + +Run the test suite and verify formatting/linting before committing: +```bash +# Run unit tests +python -m unittest discover tests/ + +# Run ruff check and format check +ruff check . +ruff format --check . +``` + +If any check fails, resolve the issues before moving forward. + +## 6. Commit the changes + +Stage only the modified and newly created files: +```bash +git add +``` +Do not stage helper scripts or build artifacts. + +Compose a conventional commit message that references and closes the issue: +- Format: `(): (closes #$ARGUMENTS)` +- Example: `chore(ci): add pre-commit config for ruff (closes #42)` + +Commit the staged changes: +```bash +git commit -m "" +``` + +## 7. Confirm + +Tell the user: +- What issue was worked on (number and title). +- The name of the branch created. +- The list of files modified. +- The commit message and hash. +- Remind them that they can run `/commit` to push the branch and prepare a pull request. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 29eb869..e0f1b20 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,3 +13,9 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 5 + + - package-ecosystem: "pre-commit" + directory: "/" + schedule: + interval: "weekly" + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2890d2e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58b6cbf..bd8864a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,8 @@ git clone https://github.com/irishsmurf/dlight-client.git cd dlight-client python -m venv .venv && source .venv/bin/activate pip install -e . -pip install pytest pytest-asyncio flake8 ruff +pip install pytest pytest-asyncio flake8 ruff pre-commit +pre-commit install python -m pytest tests/ ``` @@ -54,6 +55,15 @@ ruff check dlightclient/ - Type hints on all public methods - Comments only when the *why* is non-obvious +### Pre-commit Hooks + +To ensure consistent formatting and linting, please set up the pre-commit hooks: + +```bash +pip install pre-commit +pre-commit install +``` + ## Commit conventions [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `chore:`, `test:`, `refactor:`, `perf:`. One logical change per commit. diff --git a/claude_skills_gemini.py b/claude_skills_gemini.py new file mode 100644 index 0000000..d6d2f06 --- /dev/null +++ b/claude_skills_gemini.py @@ -0,0 +1,444 @@ +""" +Claude Code skills exported as Google Gemini function declarations. + +Usage with google-genai (newer unified SDK): + from google import genai + from google.genai import types + client = genai.Client() + response = client.models.generate_content( + model="gemini-2.0-flash", + contents="...", + config=types.GenerateContentConfig(tools=GEMINI_TOOLS), + ) + +Usage with google-generativeai (legacy SDK): + import google.generativeai as genai + model = genai.GenerativeModel("gemini-pro", tools=GEMINI_TOOLS) +""" + +_SKILLS: list[dict] = [ + # ── Dev workflow ────────────────────────────────────────────────────────── + { + "name": "commit", + "description": ( + "Stage changes, update CHANGELOG [Unreleased], commit, and push to a new branch ready for a PR." + ), + "parameters": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Optional commit message override.", + }, + }, + }, + }, + { + "name": "release", + "description": ( + "Cut a dlight-client release — bumps version, promotes CHANGELOG, " + "commits, tags, and pushes to trigger automated PyPI publish." + ), + "parameters": { + "type": "object", + "properties": { + "version": { + "type": "string", + "description": ("Version string to release (e.g. '1.7.0'). Omit to auto-bump the patch version."), + }, + }, + }, + }, + { + "name": "review_pr", + "description": ("Fetch inline review comments on a GitHub PR, triage them, apply valid fixes, and push."), + "parameters": { + "type": "object", + "properties": { + "pr_number": { + "type": "integer", + "description": "The pull request number to review and fix.", + }, + }, + "required": ["pr_number"], + }, + }, + { + "name": "issue", + "description": ( + "Fetch details of a GitHub issue, create a dedicated local branch, " + "implement the changes, verify, and commit." + ), + "parameters": { + "type": "object", + "properties": { + "issue_number": { + "type": "integer", + "description": "The GitHub issue number to work on.", + }, + }, + "required": ["issue_number"], + }, + }, + { + "name": "review", + "description": "Review a pull request for correctness, style, and completeness.", + "parameters": { + "type": "object", + "properties": { + "pr_number": { + "type": "integer", + "description": "GitHub PR number to review.", + }, + }, + }, + }, + { + "name": "code_review", + "description": ( + "Review the current diff for correctness bugs and cleanups. " + "Can post findings as inline PR comments or apply fixes directly." + ), + "parameters": { + "type": "object", + "properties": { + "effort": { + "type": "string", + "enum": ["low", "medium", "high", "max", "ultra"], + "description": ("Review depth. 'ultra' runs a deep multi-agent cloud review."), + }, + "fix": { + "type": "boolean", + "description": "Apply findings to the working tree after review.", + }, + "comment": { + "type": "boolean", + "description": "Post findings as inline PR comments.", + }, + "pr_number": { + "type": "integer", + "description": ("GitHub PR number to review. Omit to review the local diff."), + }, + }, + }, + }, + { + "name": "security_review", + "description": "Perform a security-focused review of the current code changes.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Optional focus area for the security review.", + }, + }, + }, + }, + { + "name": "simplify", + "description": ( + "Review changed code for reuse, simplification, and efficiency cleanups, " + "then apply the fixes. Does not hunt for bugs — use code_review for that." + ), + "parameters": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "verify", + "description": ( + "Verify that a code change actually works by running the app and " + "observing behavior. Use to confirm a fix works before pushing." + ), + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": ("What to verify (e.g. 'the login flow works after the auth fix')."), + }, + }, + }, + }, + { + "name": "run", + "description": ( + "Launch and drive the project app to observe a change. " + "Use to run, start, or screenshot the app, or confirm a change works." + ), + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "What aspect of the app to run or verify.", + }, + }, + }, + }, + # ── Configuration & tooling ─────────────────────────────────────────────── + { + "name": "update_config", + "description": ( + "Configure the Claude Code harness via settings.json. " + "Use for permissions, hooks, env vars, and automated behaviors." + ), + "parameters": { + "type": "object", + "properties": { + "instruction": { + "type": "string", + "description": ("What to configure (e.g. 'allow npm commands', 'set DEBUG=true')."), + }, + }, + "required": ["instruction"], + }, + }, + { + "name": "keybindings_help", + "description": ( + "Customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json." + ), + "parameters": { + "type": "object", + "properties": { + "instruction": { + "type": "string", + "description": ("What keybinding change to make (e.g. 'rebind ctrl+s', 'add a chord shortcut')."), + }, + }, + "required": ["instruction"], + }, + }, + { + "name": "fewer_permission_prompts", + "description": ( + "Scan transcripts for common read-only Bash and MCP tool calls, " + "then add a prioritized allowlist to .claude/settings.json to reduce prompts." + ), + "parameters": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "init", + "description": "Initialize a new CLAUDE.md file with codebase documentation.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + # ── Scheduling & automation ─────────────────────────────────────────────── + { + "name": "loop", + "description": ( + "Run a prompt or slash command on a recurring interval. " + "Use for polling, scheduled checks, or repeating a task." + ), + "parameters": { + "type": "object", + "properties": { + "interval": { + "type": "string", + "description": ("How often to run (e.g. '5m', '1h'). Omit for self-paced."), + }, + "command": { + "type": "string", + "description": "The slash command or prompt to run each iteration.", + }, + }, + "required": ["command"], + }, + }, + { + "name": "schedule", + "description": ( + "Create, update, list, or run scheduled cloud agents that execute on a " + "cron schedule. Also supports one-time scheduled runs." + ), + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "list", "update", "delete", "run"], + "description": "What to do with the schedule.", + }, + "cron": { + "type": "string", + "description": ( + "Cron expression (e.g. '0 9 * * 1-5') or natural language ('every weekday at 9am')." + ), + }, + "command": { + "type": "string", + "description": "The task or slash command to schedule.", + }, + }, + }, + }, + # ── Reference / docs ───────────────────────────────────────────────────── + { + "name": "claude_api", + "description": ( + "Reference the Claude API / Anthropic SDK — model IDs, pricing, params, " + "streaming, tool use, MCP, agents, caching, token counting, model migration." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": ("What to look up (e.g. 'latest model IDs', 'how to use streaming')."), + }, + }, + "required": ["query"], + }, + }, + # ── Cloudflare platform ─────────────────────────────────────────────────── + { + "name": "cloudflare", + "description": ( + "Cloudflare platform skill covering Workers, Pages, KV, D1, R2, AI, " + "Vectorize, networking, security, and infrastructure-as-code." + ), + "parameters": { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The Cloudflare development task to accomplish.", + }, + }, + "required": ["task"], + }, + }, + { + "name": "agents_sdk", + "description": ( + "Build AI agents on Cloudflare Workers using the Agents SDK. " + "Covers stateful agents, durable workflows, WebSockets, MCP servers, and more." + ), + "parameters": { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The agent task to build or configure.", + }, + }, + "required": ["task"], + }, + }, + { + "name": "durable_objects", + "description": ( + "Create and review Cloudflare Durable Objects for stateful coordination " + "(chat rooms, multiplayer, booking), RPC, SQLite, alarms, and WebSockets." + ), + "parameters": { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The Durable Objects task to build or review.", + }, + }, + "required": ["task"], + }, + }, + { + "name": "wrangler", + "description": ( + "Cloudflare Workers CLI for deploying, developing, and managing Workers, " + "KV, R2, D1, Vectorize, AI, Queues, Workflows, Pipelines, and Secrets Store." + ), + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The wrangler operation to perform.", + }, + }, + "required": ["command"], + }, + }, + { + "name": "workers_best_practices", + "description": ( + "Review and author Cloudflare Workers code against production best practices. " + "Covers streaming, floating promises, global state, secrets, bindings, observability." + ), + "parameters": { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The Workers code to review or write.", + }, + }, + "required": ["task"], + }, + }, + { + "name": "cloudflare_email_service", + "description": ( + "Send and receive transactional emails with Cloudflare Email Service. " + "Covers email sending, routing, SPF/DKIM/DMARC, and Workers integration." + ), + "parameters": { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The email service task to implement.", + }, + }, + "required": ["task"], + }, + }, + { + "name": "sandbox_sdk", + "description": ( + "Build sandboxed applications for secure code execution — AI code interpreters, " + "CI/CD systems, interactive dev environments, or untrusted code execution." + ), + "parameters": { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The sandboxed execution task to build.", + }, + }, + "required": ["task"], + }, + }, + { + "name": "web_perf", + "description": ( + "Analyze web performance using Chrome DevTools. Measures Core Web Vitals " + "(LCP, INP, CLS), identifies render-blocking resources, layout shifts, " + "and caching issues." + ), + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "URL of the page to audit.", + }, + }, + "required": ["url"], + }, + }, +] + +# Ready to pass directly to either Gemini SDK: +# google-genai → config=types.GenerateContentConfig(tools=GEMINI_TOOLS) +# google-generativeai → genai.GenerativeModel(..., tools=GEMINI_TOOLS) +GEMINI_TOOLS = [{"function_declarations": _SKILLS}] diff --git a/dlightclient/_frame.py b/dlightclient/_frame.py index 65c8cb1..bea2e46 100644 --- a/dlightclient/_frame.py +++ b/dlightclient/_frame.py @@ -42,9 +42,7 @@ def encode_command(command: Dict[str, Any]) -> bytes: try: return json.dumps(command).encode("utf-8") except TypeError as e: - raise DLightCommandError( - f"Failed to serialize command to JSON: {e}\nCommand: {mask_command(command)}" - ) from e + raise DLightCommandError(f"Failed to serialize command to JSON: {e}\nCommand: {mask_command(command)}") from e async def read_response( diff --git a/dlightclient/cli.py b/dlightclient/cli.py index b4c2388..754761d 100644 --- a/dlightclient/cli.py +++ b/dlightclient/cli.py @@ -198,12 +198,8 @@ async def main() -> None: action="store_true", help="Automatically interact with the first discovered device (use with --discover).", ) - parser.add_argument( - "--ssl", action="store_true", help="Enable SSL/TLS for TCP commands." - ) - parser.add_argument( - "--insecure", action="store_true", help="Allow unverified SSL certificates (use with --ssl)." - ) + parser.add_argument("--ssl", action="store_true", help="Enable SSL/TLS for TCP commands.") + parser.add_argument("--insecure", action="store_true", help="Allow unverified SSL certificates (use with --ssl).") parser.add_argument( "-v", "--verbose", action="count", default=0, help="Increase logging verbosity (-v for INFO, -vv for DEBUG)." ) diff --git a/dlightclient/client.py b/dlightclient/client.py index 6b10977..dccf90e 100644 --- a/dlightclient/client.py +++ b/dlightclient/client.py @@ -156,8 +156,9 @@ async def _async_send_tcp_command( operation = f"command {command.get('commandType', 'UNKNOWN')} to {target_ip}:{port}" json_data = encode_command(command) - _LOGGER.debug(f"Prepared {operation} ({len(json_data)} bytes, SSL: {bool(ssl)}): " - f"{json.dumps(mask_command(command))!r}") + _LOGGER.debug( + f"Prepared {operation} ({len(json_data)} bytes, SSL: {bool(ssl)}): {json.dumps(mask_command(command))!r}" + ) for attempt in range(self.max_retries + 1): try: diff --git a/dlightclient/device.py b/dlightclient/device.py index a375296..a18648e 100644 --- a/dlightclient/device.py +++ b/dlightclient/device.py @@ -272,13 +272,9 @@ async def apply_scene( temperature = scene.temperature else: if brightness is None or temperature is None: - raise ValueError( - "Provide a LightScene object or both brightness and temperature keyword args" - ) + raise ValueError("Provide a LightScene object or both brightness and temperature keyword args") - _LOGGER.info( - f"Device {self.id}: Applying scene (brightness={brightness}%, temperature={temperature}K)" - ) + _LOGGER.info(f"Device {self.id}: Applying scene (brightness={brightness}%, temperature={temperature}K)") _old = self._clone_state(self._state) diff --git a/tests/test_device.py b/tests/test_device.py index 6e89575..8190770 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -534,6 +534,7 @@ def setUp(self): def test_duplicate_registration_ignored(self): def cb(d, o, n): pass + self.device.on_state_change(cb) self.device.on_state_change(cb) self.assertEqual(len(self.device._state_listeners), 1) @@ -662,10 +663,10 @@ def setUp(self): self.mock_client.set_color_temperature.return_value = {"status": STATUS_SUCCESS} def test_builtin_scenes_have_correct_values(self): - self.assertEqual(LightScene.READING, LightScene(brightness=70, temperature=4000)) - self.assertEqual(LightScene.EVENING, LightScene(brightness=30, temperature=2700)) + self.assertEqual(LightScene.READING, LightScene(brightness=70, temperature=4000)) + self.assertEqual(LightScene.EVENING, LightScene(brightness=30, temperature=2700)) self.assertEqual(LightScene.DAYLIGHT, LightScene(brightness=100, temperature=6000)) - self.assertEqual(LightScene.FOCUS, LightScene(brightness=100, temperature=5000)) + self.assertEqual(LightScene.FOCUS, LightScene(brightness=100, temperature=5000)) async def test_apply_scene_with_scene_object(self): result = await self.device.apply_scene(LightScene.READING) @@ -736,9 +737,7 @@ async def test_ping_returns_true_when_reachable(self): self.mock_client.query_device_info.return_value = {"status": STATUS_SUCCESS} result = await self.device.ping() self.assertTrue(result) - self.mock_client.query_device_info.assert_awaited_once_with( - self.test_ip, self.test_id, timeout=2.0 - ) + self.mock_client.query_device_info.assert_awaited_once_with(self.test_ip, self.test_id, timeout=2.0) async def test_ping_returns_false_on_timeout(self): self.mock_client.query_device_info.side_effect = DLightTimeoutError("timed out") @@ -753,9 +752,7 @@ async def test_ping_returns_false_on_connection_error(self): async def test_ping_custom_timeout_is_forwarded(self): self.mock_client.query_device_info.return_value = {"status": STATUS_SUCCESS} await self.device.ping(timeout=0.5) - self.mock_client.query_device_info.assert_awaited_once_with( - self.test_ip, self.test_id, timeout=0.5 - ) + self.mock_client.query_device_info.assert_awaited_once_with(self.test_ip, self.test_id, timeout=0.5) async def test_ping_does_not_update_state_cache(self): self.mock_client.query_device_info.return_value = {"status": STATUS_SUCCESS} diff --git a/tests/test_frame.py b/tests/test_frame.py index 90dea64..35b27e3 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -76,7 +76,7 @@ async def test_invalid_json_rejected(self): await read_response(reader, 1.0, "test") async def test_invalid_utf8_rejected(self): - bad = b'\xff\xfe\xfd' + bad = b"\xff\xfe\xfd" reader = self._reader_with(struct.pack(">I", len(bad)) + bad) with self.assertRaisesRegex(DLightResponseError, "Failed to decode"): await read_response(reader, 1.0, "test") diff --git a/tests/test_security_fix_verification.py b/tests/test_security_fix_verification.py index 30b4cb4..f4b4470 100644 --- a/tests/test_security_fix_verification.py +++ b/tests/test_security_fix_verification.py @@ -81,11 +81,7 @@ def side_effect(obj, *args, **kwargs): mock_json_dumps.side_effect = side_effect - command = { - "commandType": "SSID_CONNECT", - "ssid": self.ssid, - "password": self.password - } + command = {"commandType": "SSID_CONNECT", "ssid": self.ssid, "password": self.password} with self.assertRaises(DLightCommandError) as cm: await self.client._async_send_tcp_command(self.target_ip, command) diff --git a/tools/fake_dlight_server.py b/tools/fake_dlight_server.py index 8f04537..e302bae 100644 --- a/tools/fake_dlight_server.py +++ b/tools/fake_dlight_server.py @@ -295,9 +295,7 @@ async def main() -> None: device = FakeDLight(args.device_id, args.model, args.sw_version, args.hw_version) failures = FailureModes() - server = await asyncio.start_server( - lambda r, w: _handle_tcp(device, failures, r, w), args.host, args.tcp_port - ) + server = await asyncio.start_server(lambda r, w: _handle_tcp(device, failures, r, w), args.host, args.tcp_port) _LOGGER.info("Fake dLight %r (TCP) listening on %s:%d", device.device_id, args.host, args.tcp_port) control_server = None @@ -314,9 +312,7 @@ async def main() -> None: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.bind((args.host, DISCOVERY_PORT)) - transport, _ = await loop.create_datagram_endpoint( - lambda: _DiscoveryResponder(device), sock=sock - ) + transport, _ = await loop.create_datagram_endpoint(lambda: _DiscoveryResponder(device), sock=sock) _LOGGER.info("Discovery responder (UDP) listening on %s:%d", args.host, DISCOVERY_PORT) try: