AUXteam commited on
Commit
6a7089a
·
verified ·
1 Parent(s): 9a5fb1f

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .devcontainer/devcontainer.json +15 -0
  2. .dockerignore +42 -0
  3. .gitattributes +4 -0
  4. .github/DEFINITION_OF_DONE.md +53 -0
  5. .github/DOCUMENTATION_REVIEW.md +398 -0
  6. .github/GOVERNANCE.md +85 -0
  7. .github/ISSUE_TEMPLATE/bug_report.md +31 -0
  8. .github/ISSUE_TEMPLATE/feature_request.md +20 -0
  9. .github/LABELING_GUIDE.md +246 -0
  10. .github/PULL_REQUEST_TEMPLATE.md +30 -0
  11. .github/RELEASING.md +44 -0
  12. .github/workflows/branch-naming.yml +50 -0
  13. .github/workflows/dashboard.yml +107 -0
  14. .github/workflows/docs-verify.yml +33 -0
  15. .github/workflows/e2e-recent.yml +317 -0
  16. .github/workflows/go-verify.yml +191 -0
  17. .github/workflows/npm-verify.yml +157 -0
  18. .github/workflows/publish-skill.yml +58 -0
  19. .github/workflows/release.yml +238 -0
  20. .gitignore +50 -0
  21. .goreleaser.yml +61 -0
  22. .hfignore +32 -0
  23. .markdownlint.json +26 -0
  24. .pre-commit-config.yaml +40 -0
  25. Agent.md +257 -0
  26. CODE_OF_CONDUCT.md +46 -0
  27. CONTRIBUTING.md +21 -0
  28. DEFINITION_OF_DONE.md +5 -0
  29. DEVELOPMENT.md +7 -0
  30. Dockerfile +70 -0
  31. LICENSE +21 -0
  32. README.md +371 -9
  33. RELEASE.md +224 -0
  34. SECURITY.md +27 -0
  35. TESTING.md +176 -0
  36. THIRD_PARTY_LICENSES.md +98 -0
  37. assets/docs-no-background-256.png +0 -0
  38. assets/favicon.png +0 -0
  39. assets/pinchtab-headless.png +3 -0
  40. cmd/pinchtab/build_test.go +111 -0
  41. cmd/pinchtab/cmd_bridge.go +47 -0
  42. cmd/pinchtab/cmd_bridge_test.go +36 -0
  43. cmd/pinchtab/cmd_cli.go +593 -0
  44. cmd/pinchtab/cmd_completion.go +69 -0
  45. cmd/pinchtab/cmd_config.go +437 -0
  46. cmd/pinchtab/cmd_config_test.go +50 -0
  47. cmd/pinchtab/cmd_daemon.go +700 -0
  48. cmd/pinchtab/cmd_daemon_test.go +288 -0
  49. cmd/pinchtab/cmd_mcp.go +53 -0
  50. cmd/pinchtab/cmd_security.go +474 -0
.devcontainer/devcontainer.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "PinchTab",
3
+ "image": "mcr.microsoft.com/devcontainers/go:1.23",
4
+ "features": {
5
+ "ghcr.io/devcontainers/features/docker-in-docker:2": {}
6
+ },
7
+ "postCreateCommand": "docker compose up -d && go build -o pinchtab ./cmd/pinchtab && echo 'Done! Run: ./pinchtab health'",
8
+ "forwardPorts": [9867],
9
+ "portsAttributes": {
10
+ "9867": {
11
+ "label": "PinchTab",
12
+ "onAutoForward": "notify"
13
+ }
14
+ }
15
+ }
.dockerignore ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Binaries
2
+ pinchtab
3
+ browser-bridge
4
+
5
+ # Test coverage
6
+ coverage.out
7
+ *.test
8
+
9
+ # Git
10
+ .git/
11
+ .github/
12
+
13
+ # Tests (E2E fixtures, scenarios, results)
14
+ tests/
15
+
16
+ # Local state
17
+ .pinchtab/
18
+ .openclaw/
19
+
20
+ # macOS
21
+ .DS_Store
22
+
23
+ # Editor
24
+ .vscode/
25
+ *.swp
26
+
27
+ # Docs (not needed in image)
28
+ docs/
29
+
30
+ # Development
31
+ node_modules/
32
+ dashboard/node_modules/
33
+ *.log
34
+
35
+ # Build caches
36
+ .gocache/
37
+ .gomodcache/
38
+ tmp/
39
+
40
+ # Markdown (README, RELEASE, etc.)
41
+ *.md
42
+ !dashboard/**/*.md
.gitattributes CHANGED
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/pinchtab-headless.png filter=lfs diff=lfs merge=lfs -text
37
+ docs/media/chart-redesign-preview.png filter=lfs diff=lfs merge=lfs -text
38
+ docs/media/dashboard-instances.jpeg filter=lfs diff=lfs merge=lfs -text
39
+ docs/media/dashboard-settings.jpeg filter=lfs diff=lfs merge=lfs -text
.github/DEFINITION_OF_DONE.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Definition of Done (PR Checklist)
2
+
3
+ ## Automated ✅ (CI/GitHub enforces these)
4
+ These run automatically via `ci.yml`. If your PR fails them, fix and re-push.
5
+ - [ ] Go formatting & linting passes (gofmt, vet, golangci-lint)
6
+ - [ ] Unit + E2E tests pass (`go test ./...` and `./dev e2e`)
7
+ - [ ] Build succeeds (`go build`)
8
+ - [ ] CodeQL security scan passes
9
+ - [ ] Branch naming follows convention
10
+
11
+ ## Manual — Code Quality (Required)
12
+ - [ ] **Error handling explicit** — All errors wrapped with `%w`, no silent failures
13
+ - [ ] **No regressions** — Verify stealth, token efficiency, session persistence work (test locally if unsure)
14
+ - [ ] **SOLID principles** — Functions do one thing, testable, no unnecessary deps
15
+ - [ ] **No redundant comments** — Comments explain *why* or *context*, not *what* the code does
16
+ - ❌ Bad: `// Loop through items` above `for _, item := range items`
17
+ - ❌ Bad: `// Return error` above `return err`
18
+ - ✅ Good: `// Prioritize chromium-browser on ARM64 (Raspberry Pi default)`
19
+ - ✅ Good: `// Chrome may not be installed in CI, so empty result is valid`
20
+
21
+ ## Manual — Testing (Required)
22
+ - [ ] **New/changed functionality has tests** — Same-package unit tests preferred
23
+ - [ ] **Docker E2E tests run locally** — If you modified handlers/bridge/tabs, run: `./dev e2e` (Docker curl E2E) and/or `./dev e2e cli` (Docker CLI E2E)
24
+ - [ ] **npm commands work** (if npm wrapper touched):
25
+ - `npm pack` in `/npm/` produces valid tarball
26
+ - `npm install -g pinchtab` (or from local tarball) succeeds
27
+ - `pinchtab --version` + basic commands work after install
28
+
29
+ ## Manual — Documentation (Required)
30
+ - [ ] **README.md updated** — If user-facing changes (CLI, API, env vars, install)
31
+ - [ ] **/docs/ updated** — If API/architecture/perf changed (optional for small fixes)
32
+
33
+ ## Manual — Review (Required)
34
+ - [ ] **PR description explains what + why** — Especially stealth/perf/compatibility impact
35
+ - [ ] **Commits are atomic** — Logical grouping, good messages
36
+ - [ ] **No breaking changes to npm** — Unless explicitly major version bump
37
+
38
+ ## Conditional (Only if applicable)
39
+ - [ ] Headed-mode tested (if dashboard/UI changes)
40
+ - [ ] Breaking changes documented in PR description (if any)
41
+
42
+ ---
43
+
44
+ ## Quick Checklist (Copy/Paste for PRs)
45
+ ```markdown
46
+ ## Definition of Done
47
+ - [ ] Unit + Docker E2E tests added & passing
48
+ - [ ] Error handling explicit (wrapped with %w)
49
+ - [ ] No regressions in stealth/perf/persistence
50
+ - [ ] No redundant comments (explain why, not what)
51
+ - [ ] README/docs updated (if user-facing)
52
+ - [ ] npm install works (if npm changes)
53
+ ```
.github/DOCUMENTATION_REVIEW.md ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Documentation Review Guide
2
+
3
+ ## Purpose
4
+
5
+ Code is the source of truth. This guide helps agents audit, validate, and maintain documentation to ensure it stays in sync with the codebase.
6
+
7
+ Use this document when asking agents to review documentation for accuracy, completeness, and consistency.
8
+
9
+ ---
10
+
11
+ ## Quick Summary
12
+
13
+ When asked to review documentation, an agent should:
14
+
15
+ 1. **Verify all examples match current code behavior**
16
+ 2. **Check doc structure against actual codebase**
17
+ 3. **Update or remove outdated content**
18
+ 4. **Report findings and improvements**
19
+
20
+ ---
21
+
22
+ ## Detailed Review Process
23
+
24
+ ### Phase 1: Code-to-Docs Validation
25
+
26
+ #### 1.1 Check All Examples
27
+
28
+ For **every code example** in `/docs`:
29
+
30
+ - [ ] **Run the example** (if it's executable)
31
+ - Does it produce the expected output?
32
+ - Does it work with current API/CLI?
33
+ - Are environment variables correct?
34
+
35
+ - [ ] **Verify API endpoints**
36
+ - Do endpoints still exist? (`GET /snapshot`, `POST /action`, etc.)
37
+ - Do request/response formats match?
38
+ - Are all parameters documented?
39
+ - Are deprecated endpoints removed?
40
+
41
+ - [ ] **Verify CLI commands**
42
+ - Do commands still exist? (`pinchtab config set`, `pinchtab health`, etc.)
43
+ - Do they work as documented?
44
+ - Are flags/options correct?
45
+ - Are deprecated commands removed?
46
+
47
+ - [ ] **Verify configuration values**
48
+ - Do env vars still exist? (`CHROME_EXTENSION_PATHS`, `BRIDGE_PORT`, etc.)
49
+ - Are default values correct?
50
+ - Are deprecated config options removed?
51
+
52
+ #### 1.2 Check Content Accuracy
53
+
54
+ For **every paragraph, section, and claim** in `/docs`:
55
+
56
+ - [ ] **API behavior** — Does it accurately describe current behavior?
57
+ - [ ] **Performance notes** — Are benchmarks/timings still valid?
58
+ - [ ] **Stealth/security claims** — Do they match current implementation?
59
+ - [ ] **Feature availability** — Is feature still implemented?
60
+ - [ ] **Limitations** — Are noted limitations still present?
61
+ - [ ] **Requirements** — Are tool versions, Chrome versions, etc. correct?
62
+
63
+ #### 1.3 Cross-Check Against Code
64
+
65
+ Use the code as reference:
66
+
67
+ ```
68
+ For each doc section:
69
+ 1. Find the corresponding code file(s)
70
+ 2. Compare doc description with actual implementation
71
+ 3. If different → docs are WRONG
72
+ 4. If missing → docs are INCOMPLETE
73
+ 5. If outdated → docs are STALE
74
+ ```
75
+
76
+ **Example:**
77
+ - Doc says: "fill action with refs sets the input value"
78
+ - Code says: `FillByNodeID()` exists → docs are CORRECT ✅
79
+ - Code says: no `FillByNodeID()` → docs are WRONG ❌
80
+
81
+ ---
82
+
83
+ ### Phase 2: Structure Review
84
+
85
+ #### 2.1 Check Documentation Structure
86
+
87
+ **File structure should match codebase organization:**
88
+
89
+ ```
90
+ docs/
91
+ ├── api/
92
+ │ ├── endpoints/
93
+ │ │ ├── navigation.md
94
+ │ │ ├── snapshot.md
95
+ │ │ ├── actions.md
96
+ │ │ ├── find.md
97
+ │ │ ├── evaluate.md
98
+ │ │ └── ...
99
+ │ └── ...
100
+ ├── cli/
101
+ │ ├── management.md
102
+ │ ├── config.md
103
+ │ └── ...
104
+ ├── architecture/
105
+ │ └── ...
106
+ └── index.json
107
+ ```
108
+
109
+ **Verify:**
110
+ - [ ] All major API endpoints documented?
111
+ - [ ] All CLI commands documented?
112
+ - [ ] Organization makes sense?
113
+ - [ ] Groups reflect actual functionality?
114
+ - [ ] No duplicate content?
115
+ - [ ] No orphaned docs (not linked from index)?
116
+
117
+ #### 2.2 Check index.json
118
+
119
+ **Verify index.json structure:**
120
+
121
+ ```json
122
+ {
123
+ "sections": [
124
+ {
125
+ "title": "API",
126
+ "path": "api/",
127
+ "items": [
128
+ {
129
+ "title": "Endpoints",
130
+ "path": "api/endpoints/",
131
+ "items": [
132
+ { "title": "Navigation", "path": "api/endpoints/navigation.md" },
133
+ { "title": "Snapshot", "path": "api/endpoints/snapshot.md" },
134
+ ...
135
+ ]
136
+ }
137
+ ]
138
+ }
139
+ ]
140
+ }
141
+ ```
142
+
143
+ **Checks:**
144
+ - [ ] All sections map to actual directories?
145
+ - [ ] All items map to actual files?
146
+ - [ ] No broken paths?
147
+ - [ ] Nesting depth reasonable (not too deep)?
148
+ - [ ] Titles are accurate and clear?
149
+ - [ ] Order makes sense (API before examples)?
150
+
151
+ ---
152
+
153
+ ### Phase 3: Report Findings
154
+
155
+ #### If Changes Were Made
156
+
157
+ **Create a PR with:**
158
+
159
+ 1. **Summary of fixes**
160
+ ```markdown
161
+ ## Documentation Fixes
162
+
163
+ - Updated /docs/api/endpoints/fill.md to document FillByNodeID support (fixes #114)
164
+ - Removed deprecated /docs/api/endpoints/old-nav.md (no longer exists in code)
165
+ - Fixed /docs/cli/config.md examples to use new config set syntax
166
+ - Updated Chrome requirement from 144 to 145+ for extension support
167
+ ```
168
+
169
+ 2. **List of possible improvements** (as comments in PR)
170
+ ```markdown
171
+ ## Suggested Improvements
172
+
173
+ - [ ] /docs/architecture/ could use a "bridge lifecycle" diagram
174
+ - [ ] /docs/api/endpoints/actions.md could add more examples
175
+ - [ ] /docs/cli/config.md could show YAML format examples
176
+ ```
177
+
178
+ 3. **PR description**
179
+ ```markdown
180
+ ## Documentation Review — Code Alignment
181
+
182
+ Verified all examples, API endpoints, CLI commands, and config values against current code.
183
+
184
+ ### Changes Made
185
+ - X examples updated to match current API
186
+ - X endpoints documented/updated
187
+ - X CLI commands verified
188
+ - X deprecated content removed
189
+
190
+ ### No Changes Needed
191
+ - API behavior accurate
192
+ - Structure organized
193
+ - Examples working
194
+
195
+ ### Improvements Suggested
196
+ See comments in PR for enhancement requests.
197
+ ```
198
+
199
+ #### If No Changes Needed but Improvements Found
200
+
201
+ **Create a GitHub issue:**
202
+
203
+ ```markdown
204
+ Title: docs: enhancement - add examples for [feature]
205
+
206
+ Body:
207
+ ## Suggestion
208
+
209
+ The documentation could be improved by:
210
+
211
+ 1. Adding example for feature X (current docs only show basic usage)
212
+ 2. Adding "Common Patterns" section to endpoint Y
213
+ 3. Adding troubleshooting section to CLI guide
214
+
215
+ ## Reasoning
216
+
217
+ Users asking "how do I..." suggests these are common use cases.
218
+
219
+ ## References
220
+
221
+ - See /docs/api/endpoints/[endpoint].md
222
+ - See /docs/cli/[command].md
223
+ ```
224
+
225
+ ---
226
+
227
+ ## Checklist for Agents
228
+
229
+ When given the task "Review documentation", verify:
230
+
231
+ ### Code Accuracy
232
+ - [ ] All examples are tested and working
233
+ - [ ] All API endpoints documented correctly
234
+ - [ ] All CLI commands documented correctly
235
+ - [ ] All config options documented correctly
236
+ - [ ] No deprecated content remains
237
+ - [ ] All current features documented
238
+
239
+ ### Structure
240
+ - [ ] index.json paths are correct
241
+ - [ ] Directory structure makes sense
242
+ - [ ] No orphaned docs
243
+ - [ ] Grouping still makes sense
244
+ - [ ] Navigation depth reasonable
245
+
246
+ ### Output
247
+ - [ ] If changes: Create PR with summary + improvements list
248
+ - [ ] If no changes but improvements: Create GitHub issue with enhancement request
249
+ - [ ] Always: Note which docs were spot-checked and results
250
+
251
+ ---
252
+
253
+ ## Common Documentation Mistakes to Fix
254
+
255
+ ❌ **Outdated examples:**
256
+ ```bash
257
+ # OLD (deprecated)
258
+ pinchtab nav https://pinchtab.com # ← This command was removed
259
+
260
+ # NEW (correct)
261
+ curl -X POST http://localhost:9867/navigate \
262
+ -d '{"url":"https://pinchtab.com"}'
263
+ ```
264
+
265
+ ❌ **Wrong API format:**
266
+ ```javascript
267
+ // OLD (incorrect)
268
+ response = await fetch('/action', {
269
+ body: JSON.stringify({action: 'click', ref: 'e5'})
270
+ })
271
+
272
+ // NEW (correct)
273
+ response = await fetch('/action', {
274
+ body: JSON.stringify({kind: 'click', ref: 'e5'})
275
+ })
276
+ ```
277
+
278
+ ❌ **Missing new features:**
279
+ ```markdown
280
+ <!-- Missing in docs -->
281
+ <!-- Code has FillByNodeID support for fills with refs -->
282
+ <!-- Docs should say: "fill supports both selectors and refs" -->
283
+ ```
284
+
285
+ ❌ **Incorrect config examples:**
286
+ ```bash
287
+ # OLD (deprecated env var name)
288
+ export BRIDGE_NAV_TIMEOUT=30
289
+
290
+ # NEW (correct)
291
+ export PINCHTAB_NAVIGATE_TIMEOUT=30
292
+ ```
293
+
294
+ ---
295
+
296
+ ## Documentation File Locations
297
+
298
+ **Root docs location:** `/docs`
299
+
300
+ **Key files to check:**
301
+
302
+ - `/docs/index.json` — Navigation/structure
303
+ - `/docs/README.md` — Overview
304
+ - `/docs/api/endpoints/*.md` — API documentation
305
+ - `/docs/cli/*.md` — CLI documentation
306
+ - `/docs/configuration.md` — Configuration reference
307
+ - `/docs/architecture/*.md` — Architecture/design docs
308
+ - `/docs/examples/*.md` — Usage examples
309
+
310
+ ---
311
+
312
+ ## When to Ask for Documentation Review
313
+
314
+ **Ask for review:**
315
+ - After major feature additions (new endpoints, CLI commands)
316
+ - After refactoring (config structure changes, API changes)
317
+ - After bug fixes affecting documented behavior
318
+ - Periodically (every quarter) for general sync-up
319
+ - Before major releases (ensure nothing is stale)
320
+
321
+ **Example requests:**
322
+
323
+ > "Review documentation against current code. Check all API examples, CLI commands, and config values. Update docs to match code or remove outdated content. Create a PR with a summary of changes."
324
+
325
+ > "Audit /docs for accuracy. Update any incorrect examples, remove deprecated features, fix broken index.json paths. Create an issue with improvement suggestions if no changes needed."
326
+
327
+ ---
328
+
329
+ ## Quality Gates
330
+
331
+ A documentation review is **complete** when:
332
+
333
+ ✅ All examples are tested and working
334
+ ✅ All API endpoints match code implementation
335
+ ✅ All CLI commands match code implementation
336
+ ✅ All config options match code implementation
337
+ ✅ No deprecated content remains
338
+ ✅ index.json structure is accurate
339
+ ✅ Documentation structure reflects codebase
340
+ ✅ Either: PR with fixes submitted, OR Issue with improvements created
341
+
342
+ ---
343
+
344
+ ## Example Review Output (PR)
345
+
346
+ ```markdown
347
+ ## Documentation Review — Code Alignment
348
+
349
+ All examples, endpoints, and CLI commands verified against current codebase.
350
+
351
+ ### ✅ Changes Made (3 files)
352
+
353
+ 1. **docs/api/endpoints/actions.md**
354
+ - Updated fill example to show ref support (new in PR #119)
355
+ - Added FillByNodeID code path documentation
356
+
357
+ 2. **docs/cli/config.md**
358
+ - Fixed config set examples (new syntax with PR #120)
359
+ - Added YAML output format example
360
+
361
+ 3. **docs/api/endpoints/find.md**
362
+ - Added Find endpoint documentation (new in feat/allocation-strategies)
363
+
364
+ ### ✅ Verified (No Changes Needed)
365
+
366
+ - All navigate endpoint examples working
367
+ - All snapshot filters documented correctly
368
+ - All action kinds (click, type, press, etc.) verified
369
+ - Config sections (server, chrome, orchestrator) accurate
370
+ - Environment variable names current
371
+
372
+ ### 💡 Suggested Improvements
373
+
374
+ See comments below for enhancement requests (low priority):
375
+ - [ ] Add "common patterns" section to actions.md
376
+ - [ ] Add troubleshooting FAQ to cli/config.md
377
+ - [ ] Create /docs/examples/multi-step-workflow.md
378
+
379
+ ### Review Stats
380
+
381
+ - Examples tested: 12/12 ✅
382
+ - Endpoints checked: 8/8 ✅
383
+ - CLI commands checked: 5/5 ✅
384
+ - Config sections checked: 4/4 ✅
385
+ - Deprecated content found: 0 ✅
386
+ - Structure issues found: 0 ✅
387
+ ```
388
+
389
+ ---
390
+
391
+ ## Related Documents
392
+
393
+ - [DEFINITION_OF_DONE.md](./DEFINITION_OF_DONE.md) — PR checklist
394
+ - [LABELING_GUIDE.md](./LABELING_GUIDE.md) — Issue labeling guide
395
+
396
+ ---
397
+
398
+ Last updated: 2026-03-04
.github/GOVERNANCE.md ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GitHub Governance Documents
2
+
3
+ This directory contains pinchtab's governance and workflow documents referenced by developers and automated agents.
4
+
5
+ ## Documents
6
+
7
+ ### [DEFINITION_OF_DONE.md](./DEFINITION_OF_DONE.md)
8
+ **Purpose:** PR checklist for code quality, testing, and documentation before merging.
9
+
10
+ **For:** All contributors submitting PRs
11
+ **How to use:** Check this list before submitting a PR, or ask agents to verify PRs against it.
12
+
13
+ **Key sections:**
14
+ - Automated checks (CI enforces)
15
+ - Manual code quality requirements
16
+ - Testing requirements
17
+ - Documentation requirements
18
+ - Quick checklist for copy/paste
19
+
20
+ ---
21
+
22
+ ### [LABELING_GUIDE.md](./LABELING_GUIDE.md)
23
+ **Purpose:** Reference guide for issue and PR labeling to maintain consistent triage.
24
+
25
+ **For:** Maintainers triaging issues, agents reviewing tickets
26
+ **How to use:** Point agents to this guide when asking them to review and label open issues.
27
+
28
+ **Key sections:**
29
+ - 3-tier labeling system (Type → Status → Priority)
30
+ - Decision tree for triage
31
+ - Guidelines for agents
32
+ - Examples of well-labeled issues
33
+ - Common mistakes to avoid
34
+
35
+ ---
36
+
37
+ ### [DOCUMENTATION_REVIEW.md](./DOCUMENTATION_REVIEW.md)
38
+ **Purpose:** Guide for auditing documentation to ensure it stays in sync with code (code is source of truth).
39
+
40
+ **For:** Agents maintaining documentation, quality assurance
41
+ **How to use:** Point agents to this guide when asking them to audit docs for accuracy and consistency.
42
+
43
+ **Key sections:**
44
+ - Code-to-docs validation (examples, endpoints, commands, config)
45
+ - Structure review (organization, index.json, grouping)
46
+ - Output requirements (PR with fixes or GitHub issue with improvements)
47
+ - Common documentation mistakes to fix
48
+ - Quality gates for completion
49
+
50
+ ---
51
+
52
+ ## Quick Reference
53
+
54
+ | Document | Audit For | Used By |
55
+ |----------|-----------|---------|
56
+ | DEFINITION_OF_DONE.md | PR quality before merge | Developers, agents, reviewers |
57
+ | LABELING_GUIDE.md | Consistent issue triage | Maintainers, agents |
58
+ | DOCUMENTATION_REVIEW.md | Documentation accuracy vs code | Agents, QA |
59
+
60
+ ---
61
+
62
+ ## How Agents Use These
63
+
64
+ When asking an agent to help with PRs, issues, or documentation:
65
+
66
+ **For PR review:**
67
+ > "Review this PR against `.github/DEFINITION_OF_DONE.md` and ensure it meets all requirements."
68
+
69
+ **For issue triage:**
70
+ > "Review open issues and apply labels according to `.github/LABELING_GUIDE.md`. Ensure Type + Status + Priority are consistent."
71
+
72
+ **For documentation audit:**
73
+ > "Review documentation against current code using `.github/DOCUMENTATION_REVIEW.md`. Verify all examples match code, update or remove outdated content, report changes in a PR or improvements in a GitHub issue."
74
+
75
+ ---
76
+
77
+ ## Maintenance
78
+
79
+ - **DEFINITION_OF_DONE.md** — Update when code quality standards change
80
+ - **LABELING_GUIDE.md** — Update when new labels are added or workflow changes
81
+ - This **README.md** — Update when new governance documents are added
82
+
83
+ ---
84
+
85
+ Last updated: 2026-03-04
.github/ISSUE_TEMPLATE/bug_report.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Bug report
3
+ about: Create a report to help us improve
4
+ title: ''
5
+ labels: bug
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+ **Describe the bug**
11
+ A clear and concise description of what the bug is.
12
+
13
+ **To Reproduce**
14
+ Steps to reproduce the behavior:
15
+ 1. Start Pinchtab with `...`
16
+ 2. Send request to `/...` with body `...`
17
+ 3. See error
18
+
19
+ **Expected behavior**
20
+ A clear and concise description of what you expected to happen.
21
+
22
+ **Screenshots/Logs**
23
+ If applicable, add screenshots or console output to help explain your problem.
24
+
25
+ **Environment (please complete the following information):**
26
+ - OS: [e.g. macOS, Ubuntu]
27
+ - Browser Version: [e.g. Chrome 120]
28
+ - Go Version: [e.g. 1.25.0]
29
+
30
+ **Additional context**
31
+ Add any other context about the problem here.
.github/ISSUE_TEMPLATE/feature_request.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Feature request
3
+ about: Suggest an idea for this project
4
+ title: ''
5
+ labels: enhancement
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+ **Is your feature request related to a problem? Please describe.**
11
+ A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12
+
13
+ **Describe the solution you'd like**
14
+ A clear and concise description of what you want to happen.
15
+
16
+ **Describe alternatives you've considered**
17
+ A clear and concise description of any alternative solutions or features you've considered.
18
+
19
+ **Additional context**
20
+ Add any other context or screenshots about the feature request here.
.github/LABELING_GUIDE.md ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Labeling Guide
2
+
3
+ This document describes the labeling system used for issues and PRs in pinchtab. Use this guide when triaging tickets or asking agents to review open issues.
4
+
5
+ ---
6
+
7
+ ## Quick Reference
8
+
9
+ Every issue should have:
10
+ 1. **Exactly one Type label** (bug, enhancement, documentation, question, etc.)
11
+ 2. **One Status label** (ready, in-progress, blocked, fixed-unreleased, needs-investigation)
12
+ 3. **One Priority label** (high, medium, low) — optional for documentation or questions
13
+
14
+ ---
15
+
16
+ ## Tier 1: Issue Type (Mandatory)
17
+
18
+ Choose **exactly one**:
19
+
20
+ | Label | Color | Usage |
21
+ |-------|-------|-------|
22
+ | `bug` | 🔴 Red | Something isn't working correctly |
23
+ | `enhancement` | 🔵 Cyan | New feature or capability request |
24
+ | `documentation` | 🔵 Blue | Improvements/additions to README, docs, or code comments |
25
+ | `question` | 🟣 Purple | Request for clarification or information |
26
+ | `good first issue` | 🟣 Purple | Good for newcomers (in addition to type label) |
27
+ | `help wanted` | 🟢 Green | Needs extra attention or expertise |
28
+ | `dependencies` | 🔵 Blue | Dependency updates (PR label) |
29
+ | `invalid` | 🟡 Yellow | Doesn't seem right; requires clarification |
30
+ | `duplicate` | ⚪ Gray | Already exists (close and reference original) |
31
+ | `wontfix` | ⚪ White | Deliberate decision not to fix |
32
+
33
+ ### Examples
34
+
35
+ - **Bug:** "fill action silently no-ops when using refs" → `bug`
36
+ - **Enhancement:** "Add CHROME_EXTENSION_PATHS support" → `enhancement`
37
+ - **Documentation:** "Update README with new CLI commands" → `documentation`
38
+ - **Question:** "How do I configure stealth mode?" → `question`
39
+
40
+ ---
41
+
42
+ ## Tier 2: Status (Recommended)
43
+
44
+ Choose **one** to show current state:
45
+
46
+ | Label | Color | Meaning | When to Use |
47
+ |-------|-------|---------|------------|
48
+ | `status: ready` | 🔵 Blue | Ready to start work | Issue is fully understood, no blockers, waiting for someone to pick it up |
49
+ | `status: in-progress` | 🟡 Yellow | Actively being worked on | Someone has claimed the issue and is working on a fix/feature |
50
+ | `status: blocked` | 🔴 Red | Blocked by something | Waiting on external dependency, another issue, or decision |
51
+ | `status: fixed-unreleased` | 🟢 Green | Fix merged, not in release | PR is merged but feature/fix isn't in a released version yet |
52
+ | `status: needs-investigation` | 🔴 Red | Needs debugging/research | Not enough info; requires investigation before work can start |
53
+
54
+ ### Workflow
55
+
56
+ ```
57
+ New Issue
58
+
59
+ [Triage] Add Type + Status
60
+
61
+ status: needs-investigation (if unclear) or status: ready (if clear)
62
+
63
+ Developer picks it up
64
+
65
+ status: in-progress
66
+
67
+ PR submitted
68
+
69
+ PR merged
70
+
71
+ status: fixed-unreleased (for bugs) or remove status (for features)
72
+
73
+ Release cut
74
+
75
+ Remove status label (feature is released)
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Tier 3: Priority (Optional, for bugs/enhancements)
81
+
82
+ Choose **one** to indicate urgency:
83
+
84
+ | Label | Color | Level | When to Use |
85
+ |-------|-------|-------|------------|
86
+ | `priority: high` | 🔴 Red | Critical; blocks users | Security vulnerability, critical bug, high-demand feature |
87
+ | `priority: medium` | 🟡 Yellow | Normal; important for roadmap | Important bug or feature; should be done soon |
88
+ | `priority: low` | 🟢 Green | Nice to have; can defer | Minor improvement, polish, or edge case |
89
+
90
+ ### Examples
91
+
92
+ - **High:** "SafePath() fails to block path traversal" (security) → `bug` + `priority: high`
93
+ - **Medium:** "Snapshot doesn't work inside iframes" (affects users) → `bug` + `priority: medium`
94
+ - **Low:** "Consider migrating to PINCHTAB_* env vars" (nice refactor) → `enhancement` + `priority: low`
95
+
96
+ ---
97
+
98
+ ## Special Labels
99
+
100
+ ### `good first issue`
101
+ Add this **in addition to the type label** for issues that are good entry points for new contributors.
102
+
103
+ **Criteria:**
104
+ - Clear problem statement
105
+ - Solution is straightforward
106
+ - Doesn't require deep knowledge of codebase
107
+ - Estimated effort: <4 hours
108
+
109
+ **Example:** "documentation: Add /find endpoint example to README" → `documentation` + `good first issue`
110
+
111
+ ### `help wanted`
112
+ Indicates the issue needs expertise or has been stalled.
113
+
114
+ **When to use:**
115
+ - Needs specific expertise (e.g., "needs Windows testing")
116
+ - Issue has been open >2 weeks without progress
117
+ - Complex problem that benefits from external input
118
+
119
+ ---
120
+
121
+ ## Decision Tree for Triage
122
+
123
+ ```
124
+ New issue arrives
125
+
126
+ 1. Is it a valid issue?
127
+ NO → `invalid` + close
128
+ YES ↓
129
+ 2. What is it?
130
+ → Bug? `bug` + assess severity → `priority: high|medium|low`
131
+ → New feature? `enhancement` + assess importance → `priority: high|medium|low`
132
+ → Docs missing? `documentation` + no priority needed
133
+ → Unclear? `question` + no priority needed
134
+
135
+ 3. Can we start work immediately?
136
+ YES → `status: ready`
137
+ NO ↓
138
+ 4. Why not?
139
+ → Need info → `status: needs-investigation`
140
+ → Waiting on something → `status: blocked`
141
+ → Already fixed → `status: fixed-unreleased`
142
+ ```
143
+
144
+ ---
145
+
146
+ ## Guidelines for Agents
147
+
148
+ When reviewing open issues, follow this checklist:
149
+
150
+ - [ ] **Each issue has exactly one Type label** (bug, enhancement, documentation, question)
151
+ - [ ] **Bugs have Priority labels** (high, medium, low)
152
+ - [ ] **Enhancements have Priority labels** (high, medium, low)
153
+ - [ ] **Each issue has a Status label** (ready, in-progress, blocked, fixed-unreleased, needs-investigation)
154
+ - [ ] **No duplicate labels** (e.g., two type labels on one issue)
155
+ - [ ] **Status matches reality** (e.g., `status: in-progress` has an active PR)
156
+ - [ ] **Stale issues reviewed** (e.g., `status: blocked` for >2 weeks should note why)
157
+
158
+ ---
159
+
160
+ ## Current Label Inventory
161
+
162
+ **Type labels (10):**
163
+ - bug, enhancement, documentation, question, good first issue, help wanted, dependencies, invalid, duplicate, wontfix
164
+
165
+ **Status labels (5):**
166
+ - status: ready, status: in-progress, status: blocked, status: fixed-unreleased, status: needs-investigation
167
+
168
+ **Priority labels (3):**
169
+ - priority: high, priority: medium, priority: low
170
+
171
+ **Code labels (1):**
172
+ - javascript
173
+
174
+ ---
175
+
176
+ ## Examples of Well-Labeled Issues
177
+
178
+ ### Example 1: Security Bug
179
+ ```
180
+ Title: SafePath() fails to block path traversal on Windows
181
+ Labels: bug, priority: high, status: ready
182
+ ```
183
+ **Why:** Critical security issue, ready to work on, high priority.
184
+
185
+ ### Example 2: Feature Request (Ready)
186
+ ```
187
+ Title: feat: Resource Pool with pluggable allocation strategies
188
+ Labels: enhancement, priority: medium, status: ready
189
+ ```
190
+ **Why:** Enhancement, medium priority (important for roadmap), ready to start.
191
+
192
+ ### Example 3: Feature (Blocked)
193
+ ```
194
+ Title: feat: Semantic Element Selection via NLP (/find endpoint)
195
+ Labels: enhancement, priority: medium, status: blocked
196
+ ```
197
+ **Why:** Enhancement, medium priority, but blocked (perhaps waiting on design decision).
198
+
199
+ ### Example 4: Bug (Not Ready)
200
+ ```
201
+ Title: click and humanClick fail to trigger Bootstrap dropdown
202
+ Labels: bug, priority: medium, status: needs-investigation
203
+ ```
204
+ **Why:** Bug, medium priority, but needs investigation (reproduction steps unclear).
205
+
206
+ ### Example 5: Already Fixed
207
+ ```
208
+ Title: Installation issue
209
+ Labels: bug, priority: high, status: fixed-unreleased
210
+ ```
211
+ **Why:** Critical bug, but fix is already merged and waiting for release.
212
+
213
+ ---
214
+
215
+ ## Common Mistakes
216
+
217
+ ❌ **Don't do this:**
218
+
219
+ | Mistake | Why | Fix |
220
+ |---------|-----|-----|
221
+ | Two Type labels on one issue | Ambiguous; what is it really? | Choose one type only |
222
+ | Status label on closed issue | Status should reflect open work | Remove status label when closing |
223
+ | No Priority on bugs | Can't triage effectively | Add priority: high/medium/low to all bugs |
224
+ | `status: in-progress` with no PR | Misleading; is someone actually working on it? | Only use when work is actively underway |
225
+ | Forgetting Status labels | No visibility into progress | Always add a status label during triage |
226
+
227
+ ---
228
+
229
+ ## Related Documents
230
+
231
+ - [DEFINITION_OF_DONE.md](./DEFINITION_OF_DONE.md) — Checklist for PRs before merge
232
+ - [CONTRIBUTING.md](../CONTRIBUTING.md) — Contribution guidelines (if present)
233
+
234
+ ---
235
+
236
+ ## Last Updated
237
+
238
+ 2026-03-04
239
+
240
+ ---
241
+
242
+ ## Summary
243
+
244
+ **For agents:** Use this guide to understand and apply labels when triaging issues. Ensure every issue has Type + Status + Priority (if applicable).
245
+
246
+ **For maintainers:** Review labels during triage and ensure consistency. Use the Decision Tree above as a quick reference.
.github/PULL_REQUEST_TEMPLATE.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## What Changed?
2
+ <!-- Brief description of the change -->
3
+
4
+ ## Why?
5
+ <!-- Motivation, issue being fixed, or feature request -->
6
+
7
+ ## Testing
8
+ <!-- How did you test this? Include command examples if applicable -->
9
+
10
+ - [ ] Unit and/or Docker E2E tests added or updated
11
+ - [ ] Manual testing completed (describe below)
12
+
13
+ ## Checklist
14
+ See [DEFINITION_OF_DONE.md](./DEFINITION_OF_DONE.md) for the full checklist.
15
+
16
+ **Automated (CI enforces):**
17
+ - [ ] gofmt + golangci-lint passes
18
+ - [ ] All tests pass
19
+ - [ ] Build succeeds
20
+
21
+ **Manual:**
22
+ - [ ] Error handling explicit (wrapped with `%w`)
23
+ - [ ] No regressions in stealth/performance/persistence
24
+ - [ ] README/CHANGELOG updated (if user-facing)
25
+ - [ ] npm install works (if npm changes)
26
+
27
+ ## Impact
28
+ <!-- Any performance, compatibility, or breaking changes? -->
29
+
30
+ ---
.github/RELEASING.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Releasing PinchTab
2
+
3
+ This is the release checklist for the GitHub tag-driven release pipeline.
4
+
5
+ ## One-time setup
6
+
7
+ - Create the public tap repository `pinchtab/homebrew-tap`.
8
+ - Add the `HOMEBREW_TAP_GITHUB_TOKEN` Actions secret to `pinchtab/pinchtab`.
9
+ - The token must have write access to `pinchtab/homebrew-tap`.
10
+
11
+ ## What the release workflow does
12
+
13
+ Pushing a tag like `v0.7.0` triggers [release.yml](/Users/luigi/dev/prj/giago/pt-bosch/.github/workflows/release.yml), which:
14
+
15
+ 1. Builds release binaries and creates the GitHub release via GoReleaser.
16
+ 2. Publishes the npm package.
17
+ 3. Builds and publishes container images.
18
+ 4. Generates a Homebrew formula PR against `pinchtab/homebrew-tap`.
19
+
20
+ ## Release steps
21
+
22
+ 1. Verify the branch you want to release is merged to `main`.
23
+ 2. Push the release tag:
24
+
25
+ ```bash
26
+ git tag v0.7.0
27
+ git push origin v0.7.0
28
+ ```
29
+
30
+ 3. Watch the `Release` workflow in GitHub Actions.
31
+ 4. Confirm GoReleaser opens a PR in `pinchtab/homebrew-tap`.
32
+ 5. Merge that PR.
33
+
34
+ After the tap PR is merged, users can install with:
35
+
36
+ ```bash
37
+ brew install pinchtab/tap/pinchtab
38
+ ```
39
+
40
+ ## Notes
41
+
42
+ - The Homebrew formula is not published until the tap PR is merged.
43
+ - No extra automation is required in `homebrew-tap` unless you want auto-merge.
44
+ - The release workflow can also be run manually with `workflow_dispatch` and a tag input.
.github/workflows/branch-naming.yml ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Branch Naming
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize, reopened]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ check-branch-name:
12
+ name: Branch Name Convention
13
+ runs-on: ubuntu-latest
14
+ if: github.event.pull_request.draft == false
15
+ steps:
16
+ - name: Check branch name
17
+ run: |
18
+ BRANCH="${GITHUB_HEAD_REF}"
19
+ echo "Branch: $BRANCH"
20
+
21
+ # Allow long-running branches
22
+ if [[ "$BRANCH" =~ ^(main|develop|staging)$ ]]; then
23
+ echo "✅ Long-running branch: $BRANCH"
24
+ exit 0
25
+ fi
26
+
27
+ # Allow dependabot branches
28
+ if [[ "$BRANCH" =~ ^dependabot/ ]]; then
29
+ echo "✅ Dependabot branch: $BRANCH"
30
+ exit 0
31
+ fi
32
+
33
+ # Enforce prefix/description pattern
34
+ PATTERN="^(feature|feat|fix|hotfix|docs|chore|refactor|test)/[a-z0-9][a-z0-9-]*$"
35
+ if [[ ! "$BRANCH" =~ $PATTERN ]]; then
36
+ echo "❌ Branch name '$BRANCH' doesn't match convention."
37
+ echo ""
38
+ echo "Expected format: <prefix>/<description>"
39
+ echo " Prefixes: feature, feat, fix, hotfix, docs, chore, refactor, test"
40
+ echo " Description: lowercase, hyphens, no special chars"
41
+ echo ""
42
+ echo "Examples:"
43
+ echo " feature/42-add-search-filter"
44
+ echo " fix/chrome-orphan-cleanup"
45
+ echo " hotfix/critical-api-fix"
46
+ echo " docs/update-docker-guide"
47
+ exit 1
48
+ fi
49
+
50
+ echo "✅ Branch name '$BRANCH' follows convention."
.github/workflows/dashboard.yml ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Dashboard
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths:
7
+ - 'dashboard/**'
8
+ - 'internal/api/types/**'
9
+ - 'scripts/build-dashboard.sh'
10
+ - '.github/workflows/dashboard.yml'
11
+ pull_request:
12
+ paths:
13
+ - 'dashboard/**'
14
+ - 'internal/api/types/**'
15
+ - 'scripts/build-dashboard.sh'
16
+ - '.github/workflows/dashboard.yml'
17
+ workflow_dispatch:
18
+
19
+ env:
20
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
21
+
22
+ permissions:
23
+ contents: read
24
+
25
+ defaults:
26
+ run:
27
+ working-directory: dashboard
28
+
29
+ jobs:
30
+ check:
31
+ name: Lint, Type Check & Test
32
+ runs-on: ubuntu-latest
33
+ if: github.event.pull_request.draft == false
34
+ steps:
35
+ - uses: actions/checkout@v5
36
+
37
+ - name: Setup Go
38
+ uses: actions/setup-go@v6
39
+ with:
40
+ go-version: '1.26'
41
+
42
+ - name: Setup Bun
43
+ uses: oven-sh/setup-bun@v2
44
+ with:
45
+ bun-version: latest
46
+
47
+ - name: Install tygo
48
+ run: go install github.com/gzuidhof/tygo@latest
49
+
50
+ - name: Install dependencies
51
+ run: bun install --frozen-lockfile
52
+
53
+ - name: Generate dashboard types
54
+ run: |
55
+ $HOME/go/bin/tygo generate
56
+ npx prettier --write src/generated/types.ts
57
+ git diff --exit-code -- src/generated/types.ts
58
+
59
+ - name: TypeScript check
60
+ run: bun run typecheck
61
+
62
+ - name: ESLint
63
+ run: bun run lint
64
+
65
+ - name: Prettier check
66
+ run: bun run format:check
67
+
68
+ - name: Run tests
69
+ run: bun run test:run
70
+
71
+ build:
72
+ name: Build
73
+ runs-on: ubuntu-latest
74
+ needs: check
75
+ steps:
76
+ - uses: actions/checkout@v5
77
+
78
+ - name: Setup Go
79
+ uses: actions/setup-go@v6
80
+ with:
81
+ go-version: '1.26'
82
+
83
+ - name: Setup Bun
84
+ uses: oven-sh/setup-bun@v2
85
+ with:
86
+ bun-version: latest
87
+
88
+ - name: Install tygo
89
+ run: go install github.com/gzuidhof/tygo@latest
90
+
91
+ - name: Install dependencies
92
+ run: bun install --frozen-lockfile
93
+
94
+ - name: Generate dashboard types
95
+ run: |
96
+ $HOME/go/bin/tygo generate
97
+ npx prettier --write src/generated/types.ts
98
+
99
+ - name: Build
100
+ run: bun run build
101
+
102
+ - name: Upload build artifacts
103
+ uses: actions/upload-artifact@v4
104
+ with:
105
+ name: dashboard-dist
106
+ path: dashboard/dist/
107
+ retention-days: 7
.github/workflows/docs-verify.yml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Documentation Verification
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths:
7
+ - 'docs/**'
8
+ - '.github/workflows/docs-verify.yml'
9
+ pull_request:
10
+ branches: [main]
11
+ paths:
12
+ - 'docs/**'
13
+ - '.github/workflows/docs-verify.yml'
14
+
15
+ env:
16
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
17
+
18
+ permissions:
19
+ contents: read
20
+
21
+ jobs:
22
+ docs-validate:
23
+ name: Validate Documentation
24
+ runs-on: ubuntu-latest
25
+ if: github.event.pull_request.draft == false
26
+ steps:
27
+ - uses: actions/checkout@v5
28
+
29
+ - name: Check docs.json references
30
+ run: |
31
+ echo "🔍 Validating docs.json..."
32
+ ./scripts/check-docs-json.sh
33
+ shell: bash
.github/workflows/e2e-recent.yml ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: E2E Recent Tests
2
+
3
+ permissions:
4
+ contents: read
5
+
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ paths-ignore:
10
+ - '**.md'
11
+ - 'docs/**'
12
+ - 'LICENSE'
13
+ - '.gitignore'
14
+ - 'skill/**'
15
+ - 'plugin/**'
16
+ pull_request:
17
+ branches: [main]
18
+ paths-ignore:
19
+ - '**.md'
20
+ - 'docs/**'
21
+ - 'LICENSE'
22
+ - '.gitignore'
23
+ - 'skill/**'
24
+ - 'plugin/**'
25
+ workflow_dispatch:
26
+
27
+ env:
28
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
29
+
30
+ jobs:
31
+ recent:
32
+ name: E2E Recent Tests
33
+ runs-on: ubuntu-latest
34
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
35
+ timeout-minutes: 10
36
+
37
+ steps:
38
+ - name: Checkout
39
+ uses: actions/checkout@v5
40
+
41
+ - name: Set up Go
42
+ uses: actions/setup-go@v6
43
+ with:
44
+ go-version-file: go.mod
45
+ cache: true
46
+
47
+ - name: Set up Docker Buildx
48
+ uses: docker/setup-buildx-action@v3
49
+
50
+ - name: Run recent E2E tests
51
+ id: e2e
52
+ run: |
53
+ set +e
54
+ mkdir -p tests/e2e/results
55
+
56
+ ./dev e2e recent 2>&1 | tee e2e-output.log
57
+ EXIT_CODE=${PIPESTATUS[0]}
58
+
59
+ PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | tail -1 || true)
60
+ FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | tail -1 || true)
61
+ FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
62
+ PASSED=${PASSED:-0}
63
+ FAILED=${FAILED:-0}
64
+
65
+ echo "passed=$PASSED" >> $GITHUB_OUTPUT
66
+ echo "failed=$FAILED" >> $GITHUB_OUTPUT
67
+
68
+ {
69
+ echo 'failures<<EOF'
70
+ echo "$FAILURES"
71
+ echo 'EOF'
72
+ } >> $GITHUB_OUTPUT
73
+
74
+ exit $EXIT_CODE
75
+ env:
76
+ DOCKER_BUILDKIT: 1
77
+ COMPOSE_DOCKER_CLI_BUILD: 1
78
+
79
+ - name: Dump pinchtab logs on failure
80
+ if: failure()
81
+ run: |
82
+ echo "==== pinchtab logs ===="
83
+ docker compose -f tests/e2e/docker-compose.yml logs pinchtab || true
84
+ echo ""
85
+ echo "==== filtered Chrome/extension lines ===="
86
+ docker compose -f tests/e2e/docker-compose.yml logs pinchtab 2>/dev/null | grep -Ei 'chrome|extension|devtools|load-extension|disable-extensions|headless|warning|error' || true
87
+ echo ""
88
+ echo "==== instance inventory ===="
89
+ INSTANCES_JSON=$(curl -sf http://localhost:9999/instances || true)
90
+ if [ -n "$INSTANCES_JSON" ]; then
91
+ echo "$INSTANCES_JSON"
92
+ if command -v jq >/dev/null 2>&1; then
93
+ echo "$INSTANCES_JSON" | jq -r '.[].id' | while read -r inst_id; do
94
+ [ -z "$inst_id" ] && continue
95
+ echo ""
96
+ echo "==== logs for $inst_id ===="
97
+ curl -sf "http://localhost:9999/instances/$inst_id/logs" || true
98
+ done
99
+ fi
100
+ else
101
+ echo "unable to fetch /instances from localhost:9999"
102
+ fi
103
+
104
+ - name: Post summary
105
+ if: always()
106
+ run: |
107
+ PASSED="${{ steps.e2e.outputs.passed }}"
108
+ FAILED="${{ steps.e2e.outputs.failed }}"
109
+ PASSED="${PASSED:-0}"
110
+ FAILED="${FAILED:-0}"
111
+
112
+ if [ "${{ steps.e2e.outcome }}" = "success" ]; then
113
+ echo "## ✅ E2E Recent Tests Passed" >> $GITHUB_STEP_SUMMARY
114
+ if [ "$PASSED" = "0" ]; then
115
+ echo "All tests passed" >> $GITHUB_STEP_SUMMARY
116
+ else
117
+ echo "**$PASSED** tests passed" >> $GITHUB_STEP_SUMMARY
118
+ fi
119
+ else
120
+ echo "## ❌ E2E Recent Tests Failed" >> $GITHUB_STEP_SUMMARY
121
+ echo "**Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
122
+ echo "**Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
123
+ echo '```' >> $GITHUB_STEP_SUMMARY
124
+ echo "${{ steps.e2e.outputs.failures }}" >> $GITHUB_STEP_SUMMARY
125
+ echo '```' >> $GITHUB_STEP_SUMMARY
126
+ fi
127
+
128
+ - name: Report check annotation
129
+ if: always()
130
+ run: |
131
+ PASSED="${{ steps.e2e.outputs.passed }}"
132
+ FAILED="${{ steps.e2e.outputs.failed }}"
133
+ PASSED="${PASSED:-0}"
134
+ FAILED="${FAILED:-0}"
135
+
136
+ if [ "${{ steps.e2e.outcome }}" = "success" ]; then
137
+ if [ "$PASSED" = "0" ]; then
138
+ echo "::notice title=E2E Recent Tests::✅ All tests passed"
139
+ else
140
+ echo "::notice title=E2E Recent Tests::✅ ${PASSED} tests passed"
141
+ fi
142
+ else
143
+ echo "::error title=E2E Recent Tests::❌ ${FAILED} failed, ${PASSED} passed"
144
+ fi
145
+
146
+ e2e-curl:
147
+ name: E2E Curl Tests
148
+ needs: recent
149
+ runs-on: ubuntu-latest
150
+ timeout-minutes: 15
151
+
152
+ steps:
153
+ - name: Checkout
154
+ uses: actions/checkout@v5
155
+
156
+ - name: Set up Go
157
+ uses: actions/setup-go@v6
158
+ with:
159
+ go-version-file: go.mod
160
+ cache: true
161
+
162
+ - name: Set up Docker Buildx
163
+ uses: docker/setup-buildx-action@v3
164
+
165
+ - name: Run E2E curl tests
166
+ id: e2e
167
+ run: |
168
+ set +e
169
+ mkdir -p tests/e2e/results
170
+ ./dev e2e curl 2>&1 | tee e2e-output.log
171
+ EXIT_CODE=${PIPESTATUS[0]}
172
+
173
+ PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | tail -1 || true)
174
+ FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | tail -1 || true)
175
+ FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
176
+ PASSED=${PASSED:-0}
177
+ FAILED=${FAILED:-0}
178
+
179
+ echo "passed=$PASSED" >> $GITHUB_OUTPUT
180
+ echo "failed=$FAILED" >> $GITHUB_OUTPUT
181
+ {
182
+ echo 'failures<<EOF'
183
+ echo "$FAILURES"
184
+ echo 'EOF'
185
+ } >> $GITHUB_OUTPUT
186
+
187
+ exit $EXIT_CODE
188
+ env:
189
+ DOCKER_BUILDKIT: 1
190
+ COMPOSE_DOCKER_CLI_BUILD: 1
191
+
192
+ - name: Post summary
193
+ if: always()
194
+ run: |
195
+ PASSED="${{ steps.e2e.outputs.passed }}"
196
+ FAILED="${{ steps.e2e.outputs.failed }}"
197
+ PASSED="${PASSED:-0}"
198
+ FAILED="${FAILED:-0}"
199
+
200
+ if [ "${{ steps.e2e.outcome }}" = "success" ]; then
201
+ echo "## ✅ E2E Curl Tests Passed" >> $GITHUB_STEP_SUMMARY
202
+ if [ "$PASSED" = "0" ]; then
203
+ echo "All tests passed" >> $GITHUB_STEP_SUMMARY
204
+ else
205
+ echo "**$PASSED** tests passed" >> $GITHUB_STEP_SUMMARY
206
+ fi
207
+ else
208
+ echo "## ❌ E2E Curl Tests Failed" >> $GITHUB_STEP_SUMMARY
209
+ echo "**Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
210
+ echo "**Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
211
+ echo '```' >> $GITHUB_STEP_SUMMARY
212
+ echo "${{ steps.e2e.outputs.failures }}" >> $GITHUB_STEP_SUMMARY
213
+ echo '```' >> $GITHUB_STEP_SUMMARY
214
+ fi
215
+
216
+ - name: Report check annotation
217
+ if: always()
218
+ run: |
219
+ PASSED="${{ steps.e2e.outputs.passed }}"
220
+ FAILED="${{ steps.e2e.outputs.failed }}"
221
+ PASSED="${PASSED:-0}"
222
+ FAILED="${FAILED:-0}"
223
+ if [ "${{ steps.e2e.outcome }}" = "success" ]; then
224
+ if [ "$PASSED" = "0" ]; then
225
+ echo "::notice title=E2E Curl Tests::✅ All tests passed"
226
+ else
227
+ echo "::notice title=E2E Curl Tests::✅ ${PASSED} tests passed"
228
+ fi
229
+ else
230
+ echo "::error title=E2E Curl Tests::❌ ${FAILED} failed, ${PASSED} passed"
231
+ fi
232
+
233
+ e2e-cli:
234
+ name: E2E CLI Tests
235
+ needs: recent
236
+ runs-on: ubuntu-latest
237
+ timeout-minutes: 15
238
+
239
+ steps:
240
+ - name: Checkout
241
+ uses: actions/checkout@v5
242
+
243
+ - name: Set up Go
244
+ uses: actions/setup-go@v6
245
+ with:
246
+ go-version-file: go.mod
247
+ cache: true
248
+
249
+ - name: Set up Docker Buildx
250
+ uses: docker/setup-buildx-action@v3
251
+
252
+ - name: Run CLI E2E tests
253
+ id: e2e
254
+ run: |
255
+ set +e
256
+ ./dev e2e cli 2>&1 | tee e2e-output.log
257
+ EXIT_CODE=${PIPESTATUS[0]}
258
+
259
+ PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | tail -1 || true)
260
+ FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | tail -1 || true)
261
+ FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
262
+ PASSED=${PASSED:-0}
263
+ FAILED=${FAILED:-0}
264
+
265
+ echo "passed=$PASSED" >> $GITHUB_OUTPUT
266
+ echo "failed=$FAILED" >> $GITHUB_OUTPUT
267
+ {
268
+ echo 'failures<<EOF'
269
+ echo "$FAILURES"
270
+ echo 'EOF'
271
+ } >> $GITHUB_OUTPUT
272
+
273
+ exit $EXIT_CODE
274
+ env:
275
+ DOCKER_BUILDKIT: 1
276
+ COMPOSE_DOCKER_CLI_BUILD: 1
277
+
278
+ - name: Post summary
279
+ if: always()
280
+ run: |
281
+ PASSED="${{ steps.e2e.outputs.passed }}"
282
+ FAILED="${{ steps.e2e.outputs.failed }}"
283
+ PASSED="${PASSED:-0}"
284
+ FAILED="${FAILED:-0}"
285
+
286
+ if [ "${{ steps.e2e.outcome }}" = "success" ]; then
287
+ echo "## ✅ E2E CLI Tests Passed" >> $GITHUB_STEP_SUMMARY
288
+ if [ "$PASSED" = "0" ]; then
289
+ echo "All tests passed" >> $GITHUB_STEP_SUMMARY
290
+ else
291
+ echo "**$PASSED** tests passed" >> $GITHUB_STEP_SUMMARY
292
+ fi
293
+ else
294
+ echo "## ❌ E2E CLI Tests Failed" >> $GITHUB_STEP_SUMMARY
295
+ echo "**Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
296
+ echo "**Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
297
+ echo '```' >> $GITHUB_STEP_SUMMARY
298
+ echo "${{ steps.e2e.outputs.failures }}" >> $GITHUB_STEP_SUMMARY
299
+ echo '```' >> $GITHUB_STEP_SUMMARY
300
+ fi
301
+
302
+ - name: Report check annotation
303
+ if: always()
304
+ run: |
305
+ PASSED="${{ steps.e2e.outputs.passed }}"
306
+ FAILED="${{ steps.e2e.outputs.failed }}"
307
+ PASSED="${PASSED:-0}"
308
+ FAILED="${FAILED:-0}"
309
+ if [ "${{ steps.e2e.outcome }}" = "success" ]; then
310
+ if [ "$PASSED" = "0" ]; then
311
+ echo "::notice title=E2E CLI Tests::✅ All tests passed"
312
+ else
313
+ echo "::notice title=E2E CLI Tests::✅ ${PASSED} tests passed"
314
+ fi
315
+ else
316
+ echo "::error title=E2E CLI Tests::❌ ${FAILED} failed, ${PASSED} passed"
317
+ fi
.github/workflows/go-verify.yml ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Go Verification
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths-ignore:
7
+ - '**.md'
8
+ - 'docs/**'
9
+ - 'LICENSE'
10
+ - '.gitignore'
11
+ - 'skill/**'
12
+ - 'plugin/**'
13
+ pull_request:
14
+ branches: [main]
15
+ paths-ignore:
16
+ - '**.md'
17
+ - 'docs/**'
18
+ - 'LICENSE'
19
+ - '.gitignore'
20
+ - 'skill/**'
21
+ - 'plugin/**'
22
+
23
+ env:
24
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
25
+
26
+ permissions:
27
+ contents: read
28
+
29
+ jobs:
30
+ # Check which files changed to conditionally run jobs
31
+ changes:
32
+ name: Detect Changes
33
+ runs-on: ubuntu-latest
34
+ if: github.event.pull_request.draft == false
35
+ outputs:
36
+ go: ${{ steps.filter.outputs.go }}
37
+ steps:
38
+ - uses: actions/checkout@v5
39
+ - uses: dorny/paths-filter@v3
40
+ id: filter
41
+ with:
42
+ filters: |
43
+ go:
44
+ - '**.go'
45
+ - 'go.mod'
46
+ - 'go.sum'
47
+ - 'cmd/**'
48
+ - 'internal/**'
49
+
50
+ build:
51
+ name: Build & Vet
52
+ needs: changes
53
+ runs-on: ubuntu-latest
54
+ steps:
55
+ - name: Check for Go changes
56
+ id: check
57
+ run: |
58
+ if [ "${{ needs.changes.outputs.go }}" = "true" ]; then
59
+ echo "Go files detected"
60
+ echo "has_go_changes=true" >> $GITHUB_OUTPUT
61
+ else
62
+ echo "⏭️ No Go files changed - docs/workflow-only changes detected"
63
+ echo "has_go_changes=false" >> $GITHUB_OUTPUT
64
+ fi
65
+
66
+ - uses: actions/checkout@v5
67
+
68
+ - uses: actions/setup-go@v6
69
+ if: steps.check.outputs.has_go_changes == 'true'
70
+ with:
71
+ go-version: '1.26'
72
+
73
+ - name: Download deps
74
+ if: steps.check.outputs.has_go_changes == 'true'
75
+ run: go mod download
76
+
77
+ - name: Format check
78
+ if: steps.check.outputs.has_go_changes == 'true'
79
+ run: |
80
+ unformatted=$(gofmt -l .)
81
+ if [ -n "$unformatted" ]; then
82
+ echo "Files not formatted:"
83
+ echo "$unformatted"
84
+ exit 1
85
+ fi
86
+
87
+ - name: Vet
88
+ if: steps.check.outputs.has_go_changes == 'true'
89
+ run: go vet ./...
90
+
91
+ - name: Build
92
+ if: steps.check.outputs.has_go_changes == 'true'
93
+ run: go build -o pinchtab ./cmd/pinchtab
94
+
95
+ - name: Test with coverage
96
+ if: steps.check.outputs.has_go_changes == 'true'
97
+ run: go test ./... -v -count=1 -coverprofile=coverage.out -covermode=atomic
98
+
99
+ - name: Coverage summary
100
+ if: steps.check.outputs.has_go_changes == 'true'
101
+ run: |
102
+ coverage=$(go tool cover -func=coverage.out | tail -1)
103
+ echo "### Coverage" >> $GITHUB_STEP_SUMMARY
104
+ echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
105
+ echo "$coverage" >> $GITHUB_STEP_SUMMARY
106
+ echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
107
+
108
+ lint:
109
+ name: Lint
110
+ needs: changes
111
+ runs-on: ubuntu-latest
112
+ steps:
113
+ - name: Check for Go changes
114
+ id: check
115
+ run: |
116
+ if [ "${{ needs.changes.outputs.go }}" = "true" ]; then
117
+ echo "has_go_changes=true" >> $GITHUB_OUTPUT
118
+ else
119
+ echo "⏭️ No Go files changed, skipping lint"
120
+ echo "has_go_changes=false" >> $GITHUB_OUTPUT
121
+ fi
122
+
123
+ - uses: actions/checkout@v5
124
+
125
+ - uses: actions/setup-go@v6
126
+ if: steps.check.outputs.has_go_changes == 'true'
127
+ with:
128
+ go-version: '1.26'
129
+
130
+ - uses: oven-sh/setup-bun@v2
131
+ if: needs.changes.outputs.go == 'true'
132
+ with:
133
+ bun-version: latest
134
+
135
+ - name: Install tygo
136
+ if: needs.changes.outputs.go == 'true'
137
+ run: go install github.com/gzuidhof/tygo@latest
138
+
139
+ - name: Build dashboard
140
+ if: needs.changes.outputs.go == 'true'
141
+ run: |
142
+ cd dashboard && bun install --frozen-lockfile && cd ..
143
+ ./scripts/build-dashboard.sh
144
+
145
+ - name: golangci-lint
146
+ if: steps.check.outputs.has_go_changes == 'true'
147
+ uses: golangci/golangci-lint-action@v7
148
+ with:
149
+ version: v2.9.0
150
+
151
+ security:
152
+ name: Security Scan
153
+ needs: changes
154
+ runs-on: ubuntu-latest
155
+ steps:
156
+ - name: Check for Go changes
157
+ id: check
158
+ run: |
159
+ if [ "${{ needs.changes.outputs.go }}" = "true" ]; then
160
+ echo "has_go_changes=true" >> $GITHUB_OUTPUT
161
+ else
162
+ echo "⏭️ No Go files changed, skipping security scan"
163
+ echo "has_go_changes=false" >> $GITHUB_OUTPUT
164
+ fi
165
+
166
+ - uses: actions/checkout@v5
167
+
168
+ - uses: actions/setup-go@v6
169
+ if: steps.check.outputs.has_go_changes == 'true'
170
+ with:
171
+ go-version: '1.26'
172
+
173
+ - name: Install gosec
174
+ if: steps.check.outputs.has_go_changes == 'true'
175
+ run: go install github.com/securego/gosec/v2/cmd/gosec@latest
176
+
177
+ - name: Run gosec
178
+ if: steps.check.outputs.has_go_changes == 'true'
179
+ run: gosec -exclude=G301,G302,G304,G306,G404,G107,G115,G703,G704,G705,G706 -fmt=json -out=gosec-results.json ./... || true
180
+
181
+ - name: Check critical findings
182
+ if: steps.check.outputs.has_go_changes == 'true'
183
+ run: |
184
+ # Fail on G112 (Slowloris), G204 (command injection)
185
+ ISSUES=$(cat gosec-results.json | jq '[.Issues[] | select(.rule_id == "G112" or .rule_id == "G204")] | length')
186
+ echo "Critical issues: $ISSUES"
187
+ cat gosec-results.json | jq '.Stats'
188
+ if [ "$ISSUES" -gt 0 ]; then
189
+ cat gosec-results.json | jq '.Issues[] | select(.rule_id == "G112" or .rule_id == "G204")'
190
+ exit 1
191
+ fi
.github/workflows/npm-verify.yml ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: npm Package Verification
2
+
3
+ on:
4
+ pull_request:
5
+ paths:
6
+ - 'npm/**'
7
+ - '.github/workflows/npm-verify.yml'
8
+ push:
9
+ tags:
10
+ - 'v*'
11
+
12
+ env:
13
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ verify:
20
+ name: Verify npm Package
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v5
24
+
25
+ - uses: actions/setup-node@v5
26
+ with:
27
+ node-version: '22'
28
+
29
+ - name: Install dependencies
30
+ working-directory: npm
31
+ run: npm ci
32
+
33
+ - name: Lint (ESLint)
34
+ working-directory: npm
35
+ run: npm run lint
36
+
37
+ - name: Format Check (Prettier)
38
+ working-directory: npm
39
+ run: npm run format:check
40
+
41
+ - name: Build TypeScript
42
+ working-directory: npm
43
+ run: tsc
44
+
45
+ - name: Run tests
46
+ working-directory: npm
47
+ run: npm test
48
+
49
+ - name: Check for source .ts files in package
50
+ working-directory: npm
51
+ run: |
52
+ # Create the tarball and check its actual contents
53
+ npm pack > /dev/null 2>&1
54
+ TARBALL=$(ls -t pinchtab-*.tgz | head -1)
55
+ SOURCES=$(tar -tzf "$TARBALL" | grep -E "\.ts$" | grep -v "\.d\.ts" || true)
56
+ if [ -n "$SOURCES" ]; then
57
+ echo "❌ ERROR: Source .ts files in package:"
58
+ echo "$SOURCES"
59
+ exit 1
60
+ fi
61
+ echo "✅ No source .ts files in tarball"
62
+ rm "$TARBALL"
63
+
64
+ - name: Check for test files in package
65
+ working-directory: npm
66
+ run: |
67
+ TESTS=$(npm pack --dry-run 2>&1 | grep "dist/tests" || true)
68
+ if [ -n "$TESTS" ]; then
69
+ echo "❌ ERROR: Test files in package:"
70
+ echo "$TESTS"
71
+ exit 1
72
+ fi
73
+ echo "✅ No test files in package"
74
+
75
+ - name: Check for source maps in package
76
+ working-directory: npm
77
+ run: |
78
+ MAPS=$(npm pack --dry-run 2>&1 | grep "\.map$" || true)
79
+ if [ -n "$MAPS" ]; then
80
+ echo "❌ ERROR: Source maps in package:"
81
+ echo "$MAPS"
82
+ exit 1
83
+ fi
84
+ echo "✅ No source maps in package"
85
+
86
+ - name: Verify required files exist
87
+ working-directory: npm
88
+ run: |
89
+ REQUIRED=(
90
+ "dist/src/index.js"
91
+ "dist/src/index.d.ts"
92
+ "scripts/postinstall.js"
93
+ "bin/pinchtab"
94
+ "LICENSE"
95
+ )
96
+
97
+ PACK_OUTPUT=$(npm pack --dry-run 2>&1)
98
+
99
+ for file in "${REQUIRED[@]}"; do
100
+ if echo "$PACK_OUTPUT" | grep -q "$file"; then
101
+ echo "✅ $file"
102
+ else
103
+ echo "❌ Missing: $file"
104
+ exit 1
105
+ fi
106
+ done
107
+
108
+ - name: Check postinstall.js syntax
109
+ working-directory: npm
110
+ run: |
111
+ node -c scripts/postinstall.js
112
+ echo "✅ postinstall.js valid JavaScript"
113
+
114
+ - name: Verify package metadata
115
+ working-directory: npm
116
+ run: |
117
+ node -e "
118
+ const pkg = require('./package.json');
119
+ const checks = [
120
+ { name: 'name', value: pkg.name === 'pinchtab' },
121
+ { name: 'version', value: !!pkg.version },
122
+ { name: 'files array', value: Array.isArray(pkg.files) && pkg.files.length > 0 },
123
+ { name: 'postinstall script', value: !!pkg.scripts.postinstall },
124
+ { name: 'bin.pinchtab', value: !!pkg.bin.pinchtab }
125
+ ];
126
+
127
+ let failed = false;
128
+ checks.forEach(check => {
129
+ if (check.value) {
130
+ console.log('✅', check.name);
131
+ } else {
132
+ console.log('❌', check.name, 'missing or invalid');
133
+ failed = true;
134
+ }
135
+ });
136
+
137
+ if (failed) process.exit(1);
138
+ "
139
+
140
+ - name: Check package size
141
+ working-directory: npm
142
+ run: |
143
+ SIZE=$(npm pack --dry-run 2>&1 | tail -1 | awk '{print $NF}' | tr -d 'B')
144
+ echo "📦 Package size: ${SIZE}B"
145
+ if [ "$SIZE" -gt 100000 ]; then
146
+ echo "⚠️ Warning: Package is large (>100KB)"
147
+ fi
148
+
149
+ - name: Security audit
150
+ working-directory: npm
151
+ run: npm audit --audit-level=moderate || true
152
+
153
+ - name: List package contents
154
+ working-directory: npm
155
+ run: |
156
+ echo "📦 Package will contain:"
157
+ npm pack --dry-run 2>&1 | grep "notice" | awk '{print " " $3 " (" $2 ")"}'
.github/workflows/publish-skill.yml ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish Skill
2
+
3
+ on:
4
+ workflow_call:
5
+ inputs:
6
+ version:
7
+ required: true
8
+ type: string
9
+ secrets:
10
+ CLAWHUB_TOKEN:
11
+ required: true
12
+ workflow_dispatch:
13
+ inputs:
14
+ version:
15
+ description: 'Skill version (e.g. 0.2.0, without v prefix)'
16
+ required: true
17
+
18
+ env:
19
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
20
+
21
+ permissions:
22
+ contents: read
23
+
24
+ jobs:
25
+ publish:
26
+ name: Publish to ClawHub
27
+ runs-on: ubuntu-latest
28
+ steps:
29
+ - uses: actions/checkout@v5
30
+
31
+ - uses: actions/setup-node@v5
32
+ with:
33
+ node-version: '22'
34
+
35
+ - name: Install ClawHub CLI
36
+ run: npm i -g clawhub
37
+
38
+ - name: Authenticate
39
+ run: clawhub login --token "$CLAWHUB_TOKEN" --no-browser
40
+ env:
41
+ CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
42
+
43
+ - name: Publish skill
44
+ run: |
45
+ if [ -n "${{ inputs.version }}" ]; then
46
+ VERSION="${{ inputs.version }}"
47
+ elif [ -n "${{ github.event.inputs.version }}" ]; then
48
+ VERSION="${{ github.event.inputs.version }}"
49
+ else
50
+ VERSION=${GITHUB_REF_NAME#v}
51
+ fi
52
+ VERSION=${VERSION#v}
53
+ clawhub publish skills/pinchtab \
54
+ --slug pinchtab \
55
+ --name "Pinchtab" \
56
+ --version "$VERSION" \
57
+ --changelog "Release v$VERSION" \
58
+ --tags latest
.github/workflows/release.yml ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Release
2
+
3
+ # Automatic release pipeline triggered on tag push (v0.7.0, etc.)
4
+ #
5
+ # Pipeline:
6
+ # 1. Checkout tag
7
+ # 2. Build Go binary + create GitHub release (goreleaser)
8
+ # 3. Publish npm package (with postinstall binary download support)
9
+ # 4. Build & push Docker images
10
+ # See .github/RELEASING.md for the operational checklist.
11
+ #
12
+ # Secrets required:
13
+ # - NPM_TOKEN: npm authentication (https://docs.npmjs.com/creating-and-viewing-access-tokens)
14
+ # - DOCKERHUB_USER / DOCKERHUB_TOKEN: Docker Hub credentials
15
+ # - GITHUB_TOKEN: Automatic (GitHub Actions)
16
+ # - HOMEBREW_TAP_GITHUB_TOKEN: PAT with write access to pinchtab/homebrew-tap
17
+ #
18
+ # To create a release:
19
+ # git tag v0.7.0
20
+ # git push origin v0.7.0
21
+
22
+ on:
23
+ push:
24
+ tags: ['v*']
25
+ workflow_dispatch:
26
+ inputs:
27
+ tag:
28
+ description: 'Tag to release (e.g. v0.2.0). Leave empty for dry-run from ref.'
29
+ required: false
30
+ ref:
31
+ description: 'Git ref for dry-run testing (branch, tag, or SHA). Defaults to main.'
32
+ required: false
33
+ default: 'main'
34
+ dry_run:
35
+ description: 'Build release artifacts without publishing them.'
36
+ required: false
37
+ type: boolean
38
+ default: false
39
+
40
+ env:
41
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
42
+ GORELEASER_VERSION: v2.14.3
43
+
44
+ permissions:
45
+ contents: write
46
+
47
+ jobs:
48
+ release:
49
+ name: Release Binaries
50
+ runs-on: ubuntu-latest
51
+ permissions:
52
+ contents: write
53
+ steps:
54
+ - name: Validate workflow inputs
55
+ if: ${{ github.event_name == 'workflow_dispatch' }}
56
+ run: |
57
+ if [ "${{ inputs.dry_run }}" != "true" ] && [ -z "${{ github.event.inputs.tag }}" ]; then
58
+ echo "tag is required unless dry_run=true" >&2
59
+ exit 1
60
+ fi
61
+
62
+ - uses: actions/checkout@v5
63
+ with:
64
+ fetch-depth: 0
65
+ ref: ${{ github.event.inputs.tag || github.event.inputs.ref || github.ref }}
66
+
67
+ - uses: actions/setup-go@v6
68
+ with:
69
+ go-version: '1.26'
70
+
71
+ - uses: oven-sh/setup-bun@v2
72
+ with:
73
+ bun-version: latest
74
+
75
+ - name: Install dashboard dependencies
76
+ working-directory: dashboard
77
+ run: bun install --frozen-lockfile
78
+
79
+ - name: Run GoReleaser
80
+ run: |
81
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ]; then
82
+ ARGS="release --clean --snapshot"
83
+ else
84
+ ARGS="release --clean"
85
+ fi
86
+
87
+ curl -sfL https://goreleaser.com/static/run | VERSION="$GORELEASER_VERSION" bash -s -- $ARGS
88
+ env:
89
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
90
+ HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
91
+
92
+ npm:
93
+ name: Publish to npm
94
+ runs-on: ubuntu-latest
95
+ needs: release
96
+ permissions:
97
+ contents: read
98
+ steps:
99
+ - uses: actions/checkout@v5
100
+ with:
101
+ ref: ${{ github.event.inputs.tag || github.event.inputs.ref || github.ref }}
102
+
103
+ - uses: actions/setup-node@v5
104
+ with:
105
+ node-version: '22'
106
+ registry-url: 'https://registry.npmjs.org'
107
+
108
+ - name: Extract version
109
+ id: version
110
+ run: |
111
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ] && [ -z "${{ github.event.inputs.tag }}" ]; then
112
+ VERSION=$(jq -r .version npm/package.json)
113
+ else
114
+ TAG="${{ github.event.inputs.tag || github.ref_name }}"
115
+ VERSION=${TAG#v} # Remove 'v' prefix
116
+ fi
117
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
118
+
119
+ - name: Update npm package version
120
+ if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }}
121
+ working-directory: npm
122
+ run: |
123
+ CURRENT=$(jq -r .version package.json)
124
+ DESIRED=${{ steps.version.outputs.version }}
125
+ if [ "$CURRENT" != "$DESIRED" ]; then
126
+ npm version "$DESIRED" --no-git-tag-version
127
+ else
128
+ echo "Version already correct: $CURRENT"
129
+ fi
130
+
131
+ - name: Install dependencies
132
+ working-directory: npm
133
+ run: npm ci --ignore-scripts
134
+
135
+ - name: Build TypeScript
136
+ working-directory: npm
137
+ run: npm run build
138
+
139
+ - name: Dry-run npm publish
140
+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
141
+ working-directory: npm
142
+ run: npm publish --dry-run
143
+ env:
144
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
145
+
146
+ - name: Publish to npm
147
+ if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }}
148
+ working-directory: npm
149
+ run: npm publish
150
+ env:
151
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
152
+
153
+ docker:
154
+ name: Docker Image
155
+ runs-on: ubuntu-latest
156
+ needs: release
157
+ permissions:
158
+ contents: read
159
+ packages: write
160
+ steps:
161
+ - uses: actions/checkout@v5
162
+ with:
163
+ ref: ${{ github.event.inputs.tag || github.event.inputs.ref || github.ref }}
164
+
165
+ - name: Extract Docker metadata
166
+ id: meta
167
+ run: |
168
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ]; then
169
+ if [ -n "${{ github.event.inputs.tag }}" ]; then
170
+ TAG="${{ github.event.inputs.tag }}"
171
+ VERSION="${TAG#v}-dryrun-${GITHUB_SHA::7}"
172
+ else
173
+ TAG="dry-run-${GITHUB_SHA::7}"
174
+ VERSION="$(jq -r .version npm/package.json)-dryrun-${GITHUB_SHA::7}"
175
+ fi
176
+ PUSH="false"
177
+ else
178
+ TAG="${{ github.event.inputs.tag || github.ref_name }}"
179
+ VERSION="${TAG#v}"
180
+ PUSH="true"
181
+ fi
182
+
183
+ {
184
+ echo "tag=$TAG"
185
+ echo "version=$VERSION"
186
+ echo "push=$PUSH"
187
+ } >> "$GITHUB_OUTPUT"
188
+
189
+ - name: Docker dry-run note
190
+ if: ${{ steps.meta.outputs.push != 'true' }}
191
+ run: |
192
+ echo "Running a multi-arch Docker build without pushing to GHCR or Docker Hub." >> "$GITHUB_STEP_SUMMARY"
193
+
194
+ - uses: docker/setup-qemu-action@v3
195
+ - uses: docker/setup-buildx-action@v3
196
+
197
+ - uses: docker/login-action@v3
198
+ if: ${{ steps.meta.outputs.push == 'true' }}
199
+ with:
200
+ username: ${{ secrets.DOCKERHUB_USER }}
201
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
202
+
203
+ - uses: docker/login-action@v3
204
+ if: ${{ steps.meta.outputs.push == 'true' }}
205
+ with:
206
+ registry: ghcr.io
207
+ username: ${{ github.actor }}
208
+ password: ${{ secrets.GITHUB_TOKEN }}
209
+
210
+ - uses: docker/build-push-action@v6
211
+ with:
212
+ context: .
213
+ file: ./Dockerfile
214
+ platforms: linux/amd64,linux/arm64
215
+ push: ${{ steps.meta.outputs.push == 'true' }}
216
+ provenance: false
217
+ labels: |
218
+ org.opencontainers.image.source=https://github.com/pinchtab/pinchtab
219
+ org.opencontainers.image.version=${{ steps.meta.outputs.version }}
220
+ tags: |
221
+ ghcr.io/pinchtab/pinchtab:latest
222
+ ghcr.io/pinchtab/pinchtab:${{ steps.meta.outputs.tag }}
223
+ ghcr.io/pinchtab/pinchtab:${{ steps.meta.outputs.version }}
224
+ pinchtab/pinchtab:latest
225
+ pinchtab/pinchtab:${{ steps.meta.outputs.tag }}
226
+ pinchtab/pinchtab:${{ steps.meta.outputs.version }}
227
+
228
+ skill:
229
+ name: Publish Skill
230
+ needs:
231
+ - npm
232
+ - docker
233
+ if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }}
234
+ uses: ./.github/workflows/publish-skill.yml
235
+ with:
236
+ version: ${{ github.event.inputs.tag || github.ref_name }}
237
+ secrets:
238
+ CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
.gitignore ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ .venv/
3
+ plugins/*/.venv/
4
+ __pycache__/
5
+ *.pyc
6
+ browser-bridge
7
+ /pinchtab
8
+ coverage.out
9
+
10
+ # Build artifacts and test output
11
+ dist/
12
+ *.js
13
+ !scripts/**/*.js
14
+ !dashboard/eslint.config.js
15
+ !dashboard/vite.config.ts
16
+ !npm/bin/pinchtab
17
+ # React dashboard build output (generated by scripts/build-dashboard.sh)
18
+ # Go code serves a built-in fallback when these files are absent.
19
+ internal/dashboard/dashboard/assets/
20
+ internal/dashboard/dashboard/dashboard.html
21
+
22
+ # npm package
23
+ npm/node_modules/
24
+ # package-lock.json is committed for reproducible installs via npm ci
25
+
26
+ # OpenClaw workspace files (should never be committed)
27
+ .openclaw/
28
+ SOUL.md
29
+ HEARTBEAT.md
30
+ IDENTITY.md
31
+ USER.md
32
+ BOOTSTRAP.md
33
+ TOOLS.md
34
+ MEMORY.md
35
+ AGENTS.md
36
+ *.test
37
+ memory/
38
+ pinchtab-small
39
+
40
+ # Local development files
41
+ MY_PROJECT_NOTES.md
42
+ debug.log
43
+ pinchtab.exe
44
+
45
+ .gomodcache/
46
+ .gocache
47
+ .DS_Store
48
+ # E2E test results
49
+ tests/e2e/results/*
50
+ !tests/e2e/results/.gitkeep
.goreleaser.yml ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: 2
2
+
3
+ before:
4
+ hooks:
5
+ - ./scripts/build-dashboard.sh
6
+
7
+ builds:
8
+ - main: ./cmd/pinchtab
9
+ ldflags:
10
+ - -s -w -X main.version={{.Version}}
11
+ goos:
12
+ - linux
13
+ - darwin
14
+ - windows
15
+ goarch:
16
+ - amd64
17
+ - arm64
18
+
19
+ archives:
20
+ # Output bare binaries directly (no archives)
21
+ # Note: goreleaser auto-adds .exe for Windows, so don't include it in the template
22
+ - format: binary
23
+ name_template: "pinchtab-{{ .Os }}-{{ .Arch }}"
24
+
25
+ checksum:
26
+ name_template: checksums.txt
27
+
28
+ brews:
29
+ - name: pinchtab
30
+ repository:
31
+ owner: pinchtab
32
+ name: homebrew-tap
33
+ branch: "brew-{{ .ProjectName }}-{{ .Version }}"
34
+ token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
35
+ pull_request:
36
+ enabled: true
37
+ base:
38
+ owner: pinchtab
39
+ name: homebrew-tap
40
+ branch: main
41
+ directory: Formula
42
+ homepage: "https://pinchtab.com"
43
+ description: "High-performance browser automation bridge and multi-instance orchestrator for AI agents"
44
+ license: "MIT"
45
+ test: |
46
+ system "#{bin}/pinchtab version"
47
+ install: |
48
+ bin.install Dir["pinchtab*"].first => "pinchtab"
49
+
50
+ changelog:
51
+ sort: asc
52
+ filters:
53
+ exclude:
54
+ - '^docs:'
55
+ - '^ci:'
56
+ - '^chore:'
57
+
58
+ release:
59
+ github:
60
+ owner: pinchtab
61
+ name: pinchtab
.hfignore ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git/
2
+ .github/
3
+ .devcontainer/
4
+ tests/
5
+ scripts/
6
+ docs/
7
+ npm/
8
+ plugins/
9
+ skills/
10
+ /assets/
11
+ .goreleaser.yml
12
+ .markdownlint.json
13
+ .pre-commit-config.yaml
14
+ CODE_OF_CONDUCT.md
15
+ CONTRIBUTING.md
16
+ DEFINITION_OF_DONE.md
17
+ DEVELOPMENT.md
18
+ RELEASE.md
19
+ SECURITY.md
20
+ TESTING.md
21
+ THIRD_PARTY_LICENSES.md
22
+ dev
23
+ docker-compose.yml
24
+ docker-entrypoint-release.sh
25
+ install.sh
26
+ node_modules/
27
+ .gocache/
28
+ .gomodcache/
29
+ tmp/
30
+ dist/
31
+ dashboard/node_modules/
32
+ dashboard/dist/
.markdownlint.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "default": true,
3
+ "line-length": {
4
+ "line_length": 200,
5
+ "heading_line_length": 200,
6
+ "code_block_line_length": 200,
7
+ "code_blocks": false
8
+ },
9
+ "no-hard-tabs": false,
10
+ "no-bare-urls": false,
11
+ "no-inline-html": false,
12
+ "blanks-around-headings": false,
13
+ "blanks-around-lists": false,
14
+ "blanks-around-fences": false,
15
+ "no-emphasis-as-heading": false,
16
+ "no-multiple-blank-lines": {
17
+ "maximum": 2
18
+ },
19
+ "first-line-heading": false,
20
+ "heading-start-left": true,
21
+ "heading-increment": true,
22
+ "no-duplicate-heading": {
23
+ "allow_different_nesting": true
24
+ },
25
+ "fenced-code-language": false
26
+ }
.pre-commit-config.yaml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ repos:
2
+ # General file checks
3
+ - repo: https://github.com/pre-commit/pre-commit-hooks
4
+ rev: v4.5.0
5
+ hooks:
6
+ - id: trailing-whitespace
7
+ - id: end-of-file-fixer
8
+ - id: check-yaml
9
+ - id: check-json
10
+ exclude: 'tsconfig\..+\.json$'
11
+ - id: check-merge-conflict
12
+ - id: check-added-large-files
13
+ args: ['--maxkb=500']
14
+
15
+ # Go formatting
16
+ - repo: https://github.com/golangci/golangci-lint
17
+ rev: v2.1.6
18
+ hooks:
19
+ - id: golangci-lint
20
+ args: ['--timeout=5m']
21
+
22
+ # Markdown linting (relaxed for docs)
23
+ - repo: https://github.com/igorshubovych/markdownlint-cli
24
+ rev: v0.37.0
25
+ hooks:
26
+ - id: markdownlint
27
+ args: ['--config', '.markdownlint.json']
28
+ # Exclude test files, auto-generated docs, repo root, and skill docs
29
+ exclude: |
30
+ (?x)^(
31
+ tests/.*\.md|
32
+ \.github/.*\.md|
33
+ skill/.*\.md|
34
+ docs/references/.*\.json|
35
+ README\.md|
36
+ CONTRIBUTING\.md|
37
+ CODE_OF_CONDUCT\.md|
38
+ SECURITY\.md|
39
+ RELEASE\.md
40
+ )$
Agent.md ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agent.md
2
+
3
+ ## 1. Deployment Configuration
4
+
5
+ ### Target Space
6
+ - **Profile:** `AUXteam`
7
+ - **Space:** `WitNote`
8
+ - **Full Identifier:** `AUXteam/WitNote`
9
+ - **Frontend Port:** `7860` (mandatory for all Hugging Face Spaces)
10
+
11
+ ### Deployment Method
12
+ Choose the correct SDK based on the app type based on the codebase language:
13
+
14
+ - **Gradio SDK** — for Gradio applications
15
+ - **Streamlit SDK** — for Streamlit applications
16
+ - **Docker SDK** — for all other applications (recommended default for flexibility)
17
+
18
+ ### HF Token
19
+ - The environment variable **`HF_TOKEN` will always be provided at execution time**.
20
+ - Never hardcode the token. Always read it from the environment.
21
+ - All monitoring and log‑streaming commands rely on `$HF_TOKEN`.
22
+
23
+ ### Required Files
24
+ - `Dockerfile` (or `app.py` for Gradio/Streamlit SDKs)
25
+ - `README.md` with Hugging Face YAML frontmatter:
26
+ ```yaml
27
+ ---
28
+ title: <APP NAME>
29
+ sdk: docker | gradio | streamlit
30
+ app_port: 7860
31
+ ---
32
+ ```
33
+ - `.hfignore` to exclude unnecessary files
34
+ - This `Agent.md` file (must be committed before deployment)
35
+
36
+ ---
37
+
38
+ ## 2. API Exposure and Documentation
39
+
40
+ ### Mandatory Endpoints
41
+ Every deployment **must** expose:
42
+
43
+ - **`/health`**
44
+ - Returns HTTP 200 when the app is ready.
45
+ - Required for Hugging Face to transition the Space from *starting* → *running*.
46
+
47
+ - **`/api-docs`**
48
+ - Documents **all** available API endpoints.
49
+ - Must be reachable at:
50
+ `https://HF_PROFILE-WitNote.hf.space/api-docs`
51
+
52
+ ### Functional Endpoints
53
+
54
+ ### /navigate
55
+ - Method: POST
56
+ - Purpose: Navigate the current tab to a specified URL.
57
+ - Request Example:
58
+ ```json
59
+ {
60
+ "url": "https://example.com"
61
+ }
62
+ ```
63
+ - Response Example:
64
+ ```json
65
+ {
66
+ "status": "ok",
67
+ "url": "https://example.com"
68
+ }
69
+ ```
70
+
71
+ ### /text
72
+ - Method: GET
73
+ - Purpose: Extract structured text from the current page.
74
+ - Request Example: `?maxChars=1000&format=markdown`
75
+ - Response Example:
76
+ ```json
77
+ {
78
+ "text": "Extracted text content..."
79
+ }
80
+ ```
81
+
82
+ ### /action
83
+ - Method: POST
84
+ - Purpose: Perform a single action on the page (e.g., click, type).
85
+ - Request Example:
86
+ ```json
87
+ {
88
+ "action": "click",
89
+ "selector": "#submit-btn"
90
+ }
91
+ ```
92
+ - Response Example:
93
+ ```json
94
+ {
95
+ "status": "ok"
96
+ }
97
+ ```
98
+
99
+ ### /snapshot
100
+ - Method: GET
101
+ - Purpose: Get an accessibility snapshot of the current page.
102
+ - Request Example: (no body)
103
+ - Response Example:
104
+ ```json
105
+ {
106
+ "snapshot": [ ... ]
107
+ }
108
+ ```
109
+
110
+ ### /evaluate
111
+ - Method: POST
112
+ - Purpose: Run JavaScript in the current tab.
113
+ - Request Example:
114
+ ```json
115
+ {
116
+ "expression": "document.title"
117
+ }
118
+ ```
119
+ - Response Example:
120
+ ```json
121
+ {
122
+ "result": "Example Domain"
123
+ }
124
+ ```
125
+
126
+ ### /macro
127
+ - Method: POST
128
+ - Purpose: Execute a macro action pipeline.
129
+ - Request Example:
130
+ ```json
131
+ {
132
+ "actions": [
133
+ {"action": "click", "selector": "#btn"}
134
+ ]
135
+ }
136
+ ```
137
+ - Response Example:
138
+ ```json
139
+ {
140
+ "status": "ok"
141
+ }
142
+ ```
143
+
144
+ All endpoints listed here **must** appear in `/api-docs`.
145
+
146
+ ---
147
+
148
+ ## 3. Deployment Workflow
149
+
150
+ ### Standard Deployment Command
151
+ After any code change, run:
152
+
153
+ ```bash
154
+ hf upload AUXteam/WitNote --repo-type=space
155
+ ```
156
+
157
+ This command must be executed **after updating and committing Agent.md**.
158
+
159
+ ### Deployment Steps
160
+ 1. Ensure all code changes are committed.
161
+ 2. Ensure `Agent.md` is updated and committed.
162
+ 3. Run the upload command.
163
+ 4. Wait for the Space to build.
164
+ 5. Monitor logs (see next section).
165
+ 6. When the Space is running, execute all test cases.
166
+
167
+ ### Continuous Deployment Rule
168
+ After **every** relevant edit (logic, dependencies, API changes):
169
+
170
+ - Update `Agent.md`
171
+ - Redeploy using the upload command
172
+ - Re-run all test cases
173
+ - Confirm `/health` and `/api-docs` are functional
174
+
175
+ This applies even for long-running projects.
176
+
177
+ ---
178
+
179
+ ## 4. Monitoring and Logs
180
+
181
+ ### Build Logs (SSE)
182
+ ```bash
183
+ curl -N \
184
+ -H "Authorization: Bearer $HF_TOKEN" \
185
+ "https://huggingface.co/api/spaces/AUXteam/WitNote/logs/build"
186
+ ```
187
+
188
+ ### Run Logs (SSE)
189
+ ```bash
190
+ curl -N \
191
+ -H "Authorization: Bearer $HF_TOKEN" \
192
+ "https://huggingface.co/api/spaces/AUXteam/WitNote/logs/run"
193
+ ```
194
+
195
+ ### Notes
196
+ - If the Space stays in *starting* for too long, `/health` is usually failing.
197
+ - If the Space times out after ~30 minutes, check logs immediately.
198
+ - Fix issues, commit changes, redeploy.
199
+
200
+ ---
201
+
202
+ ## 5. Test Run Cases (Mandatory After Every Deployment)
203
+
204
+ These tests ensure the agentic system can verify the deployment automatically.
205
+
206
+ ### 1. Health Check
207
+ ```
208
+ GET https://HF_PROFILE-WitNote.hf.space/health
209
+ Expected: HTTP 200, body: {"status": "ok"} or similar
210
+ ```
211
+
212
+ ### 2. API Docs Check
213
+ ```
214
+ GET https://HF_PROFILE-WitNote.hf.space/api-docs
215
+ Expected: HTTP 200, valid documentation UI or JSON spec
216
+ ```
217
+
218
+ ### 3. Functional Endpoint Tests
219
+ For each endpoint documented above, define:
220
+
221
+ - Example request
222
+ - Expected response structure
223
+ - Validation criteria (e.g., non-empty output, valid JSON)
224
+
225
+ Example:
226
+
227
+ ```
228
+ POST https://HF_PROFILE-WitNote.hf.space/predict
229
+ Payload:
230
+ {
231
+ "text": "test"
232
+ }
233
+ Expected:
234
+ - HTTP 200
235
+ - JSON with key "prediction"
236
+ - No error fields
237
+ ```
238
+
239
+ ### 4. End-to-End Behaviour
240
+ - Confirm the UI loads (if applicable)
241
+ - Confirm API endpoints respond within reasonable time
242
+ - Confirm no errors appear in run logs
243
+
244
+ ---
245
+
246
+ ## 6. Maintenance Rules
247
+
248
+ - `Agent.md` must always reflect the **current** deployment configuration, API surface, and test cases.
249
+ - Any change to:
250
+ - API routes
251
+ - Dockerfile
252
+ - Dependencies
253
+ - App logic
254
+ - Deployment method
255
+ requires updating this file.
256
+ - This file must be committed **before** every deployment.
257
+ - This file is the operational contract for autonomous agents interacting with the project.
CODE_OF_CONDUCT.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6
+
7
+ ## Our Standards
8
+
9
+ Examples of behavior that contributes to creating a positive environment include:
10
+
11
+ * Using welcoming and inclusive language
12
+ * Being respectful of differing viewpoints and experiences
13
+ * Gracefully accepting constructive criticism
14
+ * Focusing on what is best for the community
15
+ * Showing empathy towards other community members
16
+
17
+ Examples of unacceptable behavior by participants include:
18
+
19
+ * The use of sexualized language or imagery and unwelcome sexual attention or advances
20
+ * Trolling, insulting/derogatory comments, and personal or political attacks
21
+ * Public or private harassment
22
+ * Publishing others' private information, such as a physical or electronic address, without explicit permission
23
+ * Other conduct which could reasonably be considered inappropriate in a professional setting
24
+
25
+ ## Our Responsibilities
26
+
27
+ Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
28
+
29
+ Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
30
+
31
+ ## Scope
32
+
33
+ This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
34
+
35
+ ## Enforcement
36
+
37
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at admin@pinchtab.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
38
+
39
+ Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
40
+
41
+ ## Attribution
42
+
43
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
44
+
45
+ [homepage]: http://contributor-covenant.org
46
+ [version]: http://contributor-covenant.org/version/1/4
CONTRIBUTING.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing to PinchTab
2
+
3
+ This repository keeps one detailed contributor guide:
4
+
5
+ - [docs/guides/contributing.md](docs/guides/contributing.md)
6
+
7
+ Use that guide for setup, build commands, tests, hooks, and the day-to-day development workflow.
8
+
9
+ ## Quick Start
10
+
11
+ ```bash
12
+ git clone https://github.com/pinchtab/pinchtab.git
13
+ cd pinchtab
14
+ ./dev doctor
15
+ ./dev check
16
+ ./dev test unit
17
+ ```
18
+
19
+ ## Pull Requests
20
+
21
+ Please keep GitHub's "Allow edits from maintainers" option enabled on your PRs. It makes small fixes, rebases, and merge-conflict resolution much faster.
DEFINITION_OF_DONE.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Definition of Done
2
+
3
+ Canonical file: [`.github/DEFINITION_OF_DONE.md`](./.github/DEFINITION_OF_DONE.md)
4
+
5
+ Use the `.github` copy as the source of truth for PR review, templates, and agent instructions.
DEVELOPMENT.md ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Development Setup
2
+
3
+ This document has been consolidated into the main contributor guide:
4
+
5
+ - [docs/guides/contributing.md](docs/guides/contributing.md)
6
+
7
+ Use that page as the source of truth for environment setup, building, testing, hooks, and contributor workflow.
Dockerfile ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build the React dashboard with Bun.
2
+ # The compiled assets are copied into the Go embed directory in stage 2.
3
+ FROM oven/bun:1 AS dashboard
4
+ WORKDIR /build
5
+ COPY dashboard/package.json dashboard/bun.lock ./
6
+ RUN bun install --frozen-lockfile
7
+ COPY dashboard/ .
8
+ RUN bun run build
9
+
10
+ # Stage 2: Compile the Go binary.
11
+ # Dashboard dist is embedded via Go's embed package.
12
+ # Vite always outputs index.html; rename to dashboard.html so it doesn't
13
+ # collide with http.FileServer's automatic index.html handling at /dashboard/.
14
+ FROM golang:1.26-alpine AS builder
15
+ RUN apk add --no-cache git
16
+ WORKDIR /build
17
+ COPY go.mod go.sum ./
18
+ RUN go mod download
19
+ COPY . .
20
+ COPY --from=dashboard /build/dist/ internal/dashboard/dashboard/
21
+ RUN mv internal/dashboard/dashboard/index.html internal/dashboard/dashboard/dashboard.html
22
+ RUN go build -ldflags="-s -w" -o pinchtab ./cmd/pinchtab
23
+
24
+ # Stage 3: Minimal runtime image with Chromium.
25
+ # Only the compiled binary and entrypoint script are copied in.
26
+ #
27
+ # Security model:
28
+ # - Chrome runs with --no-sandbox (set by entrypoint) because containers don't
29
+ # have user namespaces for sandboxing
30
+ # - Container provides isolation via cgroups, seccomp, dropped capabilities,
31
+ # read-only filesystem, and non-root user
32
+ # - This matches best practices for headless Chrome in containerized environments
33
+ FROM alpine:3.21
34
+
35
+ LABEL org.opencontainers.image.source="https://github.com/pinchtab/pinchtab"
36
+ LABEL org.opencontainers.image.description="High-performance browser automation bridge"
37
+
38
+ # Chromium and its runtime dependencies for headless operation
39
+ RUN apk add --no-cache \
40
+ chromium \
41
+ nss \
42
+ freetype \
43
+ harfbuzz \
44
+ ca-certificates \
45
+ ttf-freefont \
46
+ dumb-init
47
+
48
+ # Non-root user; /data is the persistent volume mount point
49
+ RUN adduser -D -h /data -g '' pinchtab && \
50
+ mkdir -p /data && \
51
+ chown pinchtab:pinchtab /data
52
+
53
+ COPY --from=builder /build/pinchtab /usr/local/bin/pinchtab
54
+ COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
55
+
56
+ USER pinchtab
57
+ WORKDIR /data
58
+
59
+ # HOME and XDG_CONFIG_HOME point into the persistent volume so config
60
+ # and Chrome profiles survive container restarts.
61
+ ENV HOME=/data \
62
+ XDG_CONFIG_HOME=/data/.config
63
+
64
+ EXPOSE 7860
65
+
66
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
67
+ CMD wget -q -O /dev/null http://localhost:7860/health || exit 1
68
+
69
+ ENTRYPOINT ["/usr/bin/dumb-init", "--"]
70
+ CMD ["/usr/local/bin/docker-entrypoint.sh", "pinchtab"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luigi Agosti
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,12 +1,374 @@
1
  ---
2
- title: WitNote
3
- emoji: 👁
4
- colorFrom: green
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.9.0
8
- app_file: app.py
9
- pinned: false
10
  ---
 
 
 
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: PinchTab
3
+ sdk: docker
4
+ app_port: 7860
 
 
 
 
 
5
  ---
6
+ <p align="center">
7
+ <img src="assets/pinchtab-headless.png" alt="PinchTab" width="200"/>
8
+ </p>
9
 
10
+ <p align="center">
11
+ <strong>PinchTab</strong><br/>
12
+ <strong>Browser control for AI agents</strong><br/>
13
+ 12MB Go binary • HTTP API • Token-efficient
14
+ </p>
15
+
16
+
17
+ <table align="center">
18
+ <tr>
19
+ <td align="center" valign="middle">
20
+ <a href="https://pinchtab.com/docs"><img src="assets/docs-no-background-256.png" alt="Full Documentation" width="92"/></a>
21
+ </td>
22
+ <td align="left" valign="middle">
23
+ <a href="https://github.com/pinchtab/pinchtab/releases/latest"><img src="https://img.shields.io/github/v/release/pinchtab/pinchtab?style=flat-square&color=FFD700" alt="Release"/></a><br/>
24
+ <a href="https://github.com/pinchtab/pinchtab/actions/workflows/go-verify.yml"><img src="https://img.shields.io/github/actions/workflow/status/pinchtab/pinchtab/go-verify.yml?branch=main&style=flat-square&label=Build" alt="Build"/></a><br/>
25
+ <img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.25+"/><br/>
26
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue?style=flat-square" alt="License"/></a>
27
+ </td>
28
+ </tr>
29
+ </table>
30
+
31
+ ---
32
+
33
+ ## What is PinchTab?
34
+
35
+ PinchTab is a **standalone HTTP server** that gives AI agents direct control over Chrome.
36
+
37
+ For day-to-day local use, the server is typically installed as a user-level daemon, allowing agent tools to reuse the same browser control plane running in the background.
38
+
39
+ ```bash
40
+ curl -fsSL https://pinchtab.com/install.sh | bash
41
+ # or
42
+ pinchtab daemon install
43
+ ```
44
+
45
+ This installs the control-plane server and starts a default headless Chrome instance, ready to accept requests from agents or manual API calls.
46
+
47
+
48
+ If you prefer not to run a daemon, or if you're on Windows, you can instead run:
49
+
50
+ `pinchtab server` — runs the control-plane server directly
51
+ `pinchtab bridge` — runs a single browser instance as a lightweight runtime
52
+
53
+ PinchTab also provides a CLI with an interactive entry point for local setup and common tasks:
54
+
55
+ `pinchtab`
56
+
57
+ ## Security
58
+
59
+ PinchTab defaults to a **local-first security posture**:
60
+
61
+ - `server.bind = 127.0.0.1`
62
+ - sensitive endpoint families are disabled by default
63
+ - `attach` is disabled by default
64
+ - IDPI is enabled with a **local-only website allowlist**
65
+
66
+ > [!CAUTION]
67
+ > By default, IDPI restricts browsing to **locally hosted websites only**.
68
+ > This prevents agents from navigating the public internet until you explicitly allow it.
69
+ > The restriction exists to make the security implications of browser automation clear before enabling wider access.
70
+
71
+ See the full guide: [docs/guides/security.md](docs/guides/security.md)
72
+
73
+ ## What can you use it for
74
+
75
+ ### Headless navigation
76
+
77
+ With the daemon installed and an agent skill configured, an agent can execute tasks like:
78
+
79
+ ```
80
+ "What are the main news about aliens on news.com?"
81
+ ```
82
+
83
+ PinchTab exposes browser tools that allow agents to navigate pages, extract structured content, and interact with the DOM without wasting tokens on raw HTML or images.
84
+
85
+ ### Headed navigation
86
+
87
+ In addition to headless automation, PinchTab supports headed Chrome profiles.
88
+
89
+ You can create profiles configured with authentication, cookies, extensions, or specific environments. Each profile can have a name and description.
90
+
91
+ For example, an agent request like:
92
+
93
+ ```
94
+ "Log into my work profile and download the weekly report"
95
+ ```
96
+
97
+ can automatically select the appropriate profile and perform the action.
98
+
99
+ ### Local container isolation
100
+
101
+ If you prefer stronger isolation, PinchTab can run inside Docker.
102
+
103
+ This allows agents to control browsers in a sandboxed environment, reducing risk when running automation tasks locally.
104
+
105
+ ### Distributed automation
106
+
107
+ PinchTab can manage multiple Chrome instances (headless or headed) across containers or remote machines.
108
+
109
+ Typical use cases include:
110
+
111
+ - QA automation
112
+ - testing environments
113
+ - distributed browsing tasks
114
+ - development tooling
115
+
116
+ You can connect to multiple PinchTab servers, or attach to Chrome instances running in remote debug mode.
117
+
118
+ ## Process Model
119
+
120
+ PinchTab is server-first:
121
+ 1. install the daemon or run `pinchtab server` for the full control plane
122
+ 2. let the server manage profiles and instances
123
+ 3. let each managed instance run behind a lightweight `pinchtab bridge` runtime
124
+
125
+ In practice:
126
+ - Server — the main product entry point and control plane
127
+ - Bridge — the runtime that manages a single browser instance
128
+ - Attach — an advanced mode for registering externally managed Chrome instances
129
+
130
+ ### Primary Usage
131
+
132
+ The primary user journey is:
133
+
134
+ 1. install Pinchtab
135
+ 2. install and start the daemon with `pinchtab daemon install`
136
+ 3. point your agent or tool at `http://localhost:9867`
137
+ 4. let PinchTab act as your local browser service
138
+
139
+ That is the default “replace the browser runtime” scenario.
140
+ Most users should not need to think about `pinchtab bridge` directly, and only need `pinchtab` when they want the local interactive menu.
141
+
142
+ ### Key Features
143
+
144
+ - **CLI or Curl** — Control via command-line or HTTP API
145
+ - **Token-efficient** — 800 tokens/page with text extraction (5-13x cheaper than screenshots)
146
+ - **Headless or Headed** — Run without a window or with visible Chrome
147
+ - **Multi-instance** — Run multiple parallel Chrome processes with isolated profiles
148
+ - **Self-contained** — ~15MB binary, no external dependencies
149
+ - **Accessibility-first** — Stable element refs instead of fragile coordinates
150
+ - **ARM64-optimized** — First-class Raspberry Pi support with automatic Chromium detection
151
+
152
+ ---
153
+
154
+ ## Quick Start
155
+
156
+ ### Installation
157
+
158
+ **macOS / Linux:**
159
+ ```bash
160
+ curl -fsSL https://pinchtab.com/install.sh | bash
161
+ ```
162
+
163
+ **Homebrew (macOS / Linux):**
164
+ ```bash
165
+ brew install pinchtab/tap/pinchtab
166
+ ```
167
+
168
+ **npm:**
169
+ ```bash
170
+ npm install -g pinchtab
171
+ ```
172
+
173
+ ### Shell Completion
174
+
175
+ Generate and install shell completions after `pinchtab` is on your `PATH`:
176
+
177
+ ```bash
178
+ # Generate and install zsh completions
179
+ pinchtab completion zsh > "${fpath[1]}/_pinchtab"
180
+
181
+ # Generate bash completions
182
+ pinchtab completion bash > /etc/bash_completion.d/pinchtab
183
+
184
+ # Generate fish completions
185
+ pinchtab completion fish > ~/.config/fish/completions/pinchtab.fish
186
+ ```
187
+
188
+ **Docker:**
189
+ ```bash
190
+ docker run -d \
191
+ --name pinchtab \
192
+ -p 127.0.0.1:9867:9867 \
193
+ -v pinchtab-data:/data \
194
+ --shm-size=2g \
195
+ pinchtab/pinchtab
196
+ ```
197
+
198
+ The bundled container persists its managed config at `/data/.config/pinchtab/config.json`.
199
+ If you want to supply your own config file instead, mount it and point `PINCHTAB_CONFIG` at it:
200
+
201
+ ```bash
202
+ docker run -d \
203
+ --name pinchtab \
204
+ -p 127.0.0.1:9867:9867 \
205
+ -e PINCHTAB_CONFIG=/config/config.json \
206
+ -v "$PWD/config.json:/config/config.json:ro" \
207
+ -v pinchtab-data:/data \
208
+ --shm-size=2g \
209
+ pinchtab/pinchtab
210
+ ```
211
+
212
+ ### Use It
213
+
214
+ **Terminal 1 — Start the server:**
215
+ ```bash
216
+ pinchtab server
217
+ ```
218
+
219
+ **Recommended for daily local use — install the daemon once:**
220
+ ```bash
221
+ pinchtab daemon install
222
+ pinchtab daemon
223
+ ```
224
+
225
+ That keeps PinchTab running in the background so your agent tools can reuse it without an open terminal.
226
+
227
+ **Terminal 2 — Control the browser:**
228
+ ```bash
229
+ # Navigate
230
+ pinchtab nav https://pinchtab.com
231
+
232
+ # Get page structure
233
+ pinchtab snap -i -c
234
+
235
+ # Click an element
236
+ pinchtab click e5
237
+
238
+ # Extract text
239
+ pinchtab text
240
+ ```
241
+
242
+ Or use the HTTP API directly:
243
+ ```bash
244
+ # Create an instance (returns instance id)
245
+ INST=$(curl -s -X POST http://localhost:9867/instances/launch \
246
+ -H "Content-Type: application/json" \
247
+ -d '{"name":"work","mode":"headless"}' | jq -r '.id')
248
+
249
+ # Open a tab in that instance
250
+ TAB=$(curl -s -X POST http://localhost:9867/instances/$INST/tabs/open \
251
+ -H "Content-Type: application/json" \
252
+ -d '{"url":"https://pinchtab.com"}' | jq -r '.tabId')
253
+
254
+ # Get snapshot
255
+ curl "http://localhost:9867/tabs/$TAB/snapshot?filter=interactive"
256
+
257
+ # Click element
258
+ curl -X POST "http://localhost:9867/tabs/$TAB/action" \
259
+ -H "Content-Type: application/json" \
260
+ -d '{"kind":"click","ref":"e5"}'
261
+ ```
262
+
263
+ ---
264
+
265
+ ## Core Concepts
266
+
267
+ **Server** — The main PinchTab process. It manages profiles, instances, routing, and the dashboard.
268
+
269
+ **Instance** — A running Chrome process. Each instance can have one profile.
270
+
271
+ **Profile** — Browser state (cookies, history, local storage). Log in once, stay logged in across restarts.
272
+
273
+ **Tab** — A single webpage. Each instance can have multiple tabs.
274
+
275
+ **Bridge** — The single-instance runtime behind a managed instance. Usually spawned by the server, not started manually.
276
+
277
+ Read more in the [Core Concepts](https://pinchtab.com/docs/core-concepts) guide.
278
+
279
+ ---
280
+
281
+ ## Why PinchTab?
282
+
283
+ | Aspect | PinchTab |
284
+ |--------|----------|
285
+ | **Tokens performance** | ✅ |
286
+ | **Headless and Headed** | ✅ |
287
+ | **Profile** | ✅ |
288
+ | **Advanced CDP control** | ✅ |
289
+ | **Persistent sessions** | ✅ |
290
+ | **Binary size** | ✅ |
291
+ | **Multi-instance** | ✅ |
292
+ | **External Chrome attach** | ✅ |
293
+
294
+ ---
295
+
296
+ ## Privacy
297
+
298
+ PinchTab is a fully open-source, local-only tool. No telemetry, no analytics, no outbound connections. The binary binds to `127.0.0.1` by default. Persistent profiles store browser sessions locally on your machine — similar to how a human reuses their browser. The single Go binary (~16 MB) is fully verifiable: build from source at [github.com/pinchtab/pinchtab](https://github.com/pinchtab/pinchtab).
299
+
300
+ ---
301
+
302
+ ## Documentation
303
+
304
+ Full docs at **[pinchtab.com/docs](https://pinchtab.com/docs)**
305
+
306
+ ### MCP (SMCP) integration
307
+
308
+ An **SMCP plugin** in this repo lets AI agents control PinchTab via the [Model Context Protocol](https://github.com/sanctumos/smcp) (SMCP). One plugin exposes 15 tools (e.g. `pinchtab__navigate`, `pinchtab__snapshot`, `pinchtab__action`). No extra runtime deps (stdlib only). See **[plugins/README.md](plugins/README.md)** for setup (env vars and paths).
309
+
310
+ ---
311
+
312
+ ## Examples
313
+
314
+ ### AI Agent Automation
315
+
316
+ ```bash
317
+ # Your AI agent can:
318
+ pinchtab nav https://pinchtab.com
319
+ pinchtab snap -i # Get clickable elements
320
+ pinchtab click e5 # Click by ref
321
+ pinchtab fill e3 "user@pinchtab.com" # Fill input
322
+ pinchtab press e7 Enter # Submit form
323
+ ```
324
+
325
+ ### Data Extraction
326
+
327
+ ```bash
328
+ # Extract text (token-efficient)
329
+ pinchtab nav https://pinchtab.com/article
330
+ pinchtab text # ~800 tokens instead of 10,000
331
+ ```
332
+
333
+ ### Multi-Instance Workflows
334
+
335
+ ```bash
336
+ # Run multiple instances in parallel
337
+ curl -s -X POST http://localhost:9867/instances/start \
338
+ -H "Content-Type: application/json" \
339
+ -d '{"profileId":"alice","mode":"headless"}'
340
+
341
+ curl -s -X POST http://localhost:9867/instances/start \
342
+ -H "Content-Type: application/json" \
343
+ -d '{"profileId":"bob","mode":"headless"}'
344
+
345
+ # Each instance is isolated
346
+ curl http://localhost:9867/instances
347
+ ```
348
+
349
+ See [chrome-files.md](chrome-files.md) for technical details on how PinchTab manages Chrome user data directories and ensures isolation between parallel instances.
350
+
351
+ ---
352
+
353
+ ## Development
354
+
355
+ Want to contribute? Start with [CONTRIBUTING.md](CONTRIBUTING.md).
356
+ The full setup and workflow guide lives at [docs/guides/contributing.md](docs/guides/contributing.md).
357
+
358
+ **Quick start:**
359
+ ```bash
360
+ git clone https://github.com/pinchtab/pinchtab.git
361
+ cd pinchtab
362
+ ./dev doctor # Verifies environment, offers hooks/deps setup
363
+ go build ./cmd/pinchtab # Build pinchtab binary
364
+ ```
365
+
366
+ ---
367
+
368
+ ## License
369
+
370
+ MIT — Free and open source.
371
+
372
+ ---
373
+
374
+ **Get started:** [pinchtab.com/docs](https://pinchtab.com/docs)
RELEASE.md ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Release Process
2
+
3
+ **⚠️ CRITICAL:** The npm package depends on Go binaries being in the GitHub release. The release pipeline will fail hard if goreleaser doesn't upload binaries — npm users won't get a working binary.
4
+
5
+ Pinchtab uses an automated CI/CD pipeline triggered by Git tags. When you push a tag like `v0.7.0`, GitHub Actions:
6
+
7
+ 1. **Builds Go binaries** — darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64
8
+ 2. **Creates GitHub release** — with checksums.txt for integrity verification
9
+ 3. **Publishes to npm** — TypeScript SDK with auto-download postinstall script
10
+ 4. **Builds Docker images** — linux/amd64, linux/arm64
11
+
12
+ ## Prerequisites
13
+
14
+ ### Secrets (configure once in GitHub)
15
+
16
+ Go to **Settings → Secrets and variables → Actions** and add:
17
+
18
+ - **NPM_TOKEN** — npm authentication token
19
+ - Create at https://npmjs.com/settings/~/tokens
20
+ - Scope: `automation` (publish + read)
21
+
22
+ - **DOCKERHUB_USER** — Docker Hub username (if using Docker Hub)
23
+ - **DOCKERHUB_TOKEN** — Docker Hub personal access token
24
+
25
+ ### Local setup
26
+
27
+ ```bash
28
+ # 1. Ensure main branch is up to date
29
+ git checkout main && git pull origin main
30
+
31
+ # 2. Merge feature branches
32
+ # (all features should be on main before tagging)
33
+
34
+ # 3. Verify version consistency
35
+ cat package.json | jq .version # npm package
36
+ cat go.mod | grep "module" # Go module
37
+ git describe --tags # latest tag
38
+ ```
39
+
40
+ ## Pre-Release Checklist
41
+
42
+ Goreleaser is already configured correctly. Verify before tagging:
43
+
44
+ ```bash
45
+ # 1. Check builds section (compiles for all platforms)
46
+ grep -A 8 "^builds:" .goreleaser.yml
47
+ # Should output: linux/darwin/windows + amd64/arm64
48
+
49
+ # 2. Check archives use binary format (required for npm)
50
+ grep -A 5 "^archives:" .goreleaser.yml
51
+ # Should show: format: binary (not tar/zip)
52
+
53
+ # 3. Verify checksums enabled
54
+ grep "checksum:" .goreleaser.yml
55
+ # Should output: name_template: checksums.txt
56
+
57
+ # 4. Check release config
58
+ grep -A 2 "^release:" .goreleaser.yml
59
+ # Should output: GitHub owner/repo
60
+ ```
61
+
62
+ **Configuration status:**
63
+ - ✅ `builds:` section compiles all platforms (darwin-arm64, darwin-amd64, linux-arm64, linux-amd64, windows-amd64, windows-arm64)
64
+ - ✅ `archives:` outputs binaries directly: `pinchtab-darwin-arm64`, `pinchtab-linux-x64`, etc.
65
+ - ✅ `checksum:` generates `checksums.txt` (used by npm postinstall verification)
66
+ - ✅ `release:` points to GitHub (uploads everything automatically)
67
+
68
+ **Ready to release.** Just tag and push.
69
+
70
+ ## Releasing
71
+
72
+ ### For patch/minor versions (recommended)
73
+
74
+ ```bash
75
+ # 1. Bump version in all places
76
+ npm version patch # or minor, major
77
+ git push origin main
78
+
79
+ # 2. Create tag
80
+ git tag v0.7.1
81
+ git push origin v0.7.1
82
+ ```
83
+
84
+ ### For manual releases
85
+
86
+ ```bash
87
+ # 1. Tag directly
88
+ git tag -a v0.7.1 -m "Release v0.7.1"
89
+ git push origin v0.7.1
90
+
91
+ # 2. Or via GitHub UI: Releases → Create from tag
92
+ # (workflow will auto-create if not present)
93
+ ```
94
+
95
+ ### Using workflow_dispatch (manual trigger)
96
+
97
+ If you need to re-release an existing tag:
98
+
99
+ 1. Go to **Actions → Release**
100
+ 2. **Run workflow**
101
+ 3. Enter tag (e.g. `v0.7.1`)
102
+
103
+ For a no-side-effects dry run from `main` or any other ref:
104
+
105
+ 1. Go to **Actions → Release**
106
+ 2. **Run workflow**
107
+ 3. Leave `tag` empty
108
+ 4. Set `ref` to the branch, tag, or commit you want to test
109
+ 5. Set `dry_run` to `true`
110
+
111
+ Dry-run behavior:
112
+ - GoReleaser runs in snapshot mode, so artifacts are built but not published
113
+ - npm runs `npm publish --dry-run`
114
+ - Docker runs a multi-arch `buildx` build with `push=false`
115
+ - ClawHub skill publishing is not part of this workflow and is therefore skipped
116
+
117
+ ## Pipeline details
118
+
119
+ ### 1. Goreleaser (Go binary) — CRITICAL for npm
120
+
121
+ Triggered on `v*` tag push. Builds binaries and creates GitHub release.
122
+
123
+ **What it does:**
124
+ - ✅ Compiles for all platforms (darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64)
125
+ - ✅ Generates `checksums.txt` (SHA256)
126
+ - ✅ **Uploads binaries to GitHub Releases** ← Required by npm postinstall!
127
+ - Also: Docker images, changelog, etc.
128
+
129
+ **⚠️ CRITICAL:** npm postinstall script downloads binaries from GitHub Releases. If the release doesn't have the binaries (e.g., only Docker images), `npm install pinchtab` will fail silently and the binary won't be available.
130
+
131
+ **Configured in:** `.goreleaser.yml`
132
+
133
+ **Verify release has binaries:**
134
+ ```bash
135
+ curl -s https://api.github.com/repos/pinchtab/pinchtab/releases/v0.7.0 | jq '.assets[].name'
136
+ # Should output: pinchtab-darwin-arm64, pinchtab-darwin-x64, etc.
137
+ ```
138
+
139
+ ### 2. npm publish
140
+
141
+ Depends on: `release` job (waits for goreleaser to finish)
142
+
143
+ **What it does:**
144
+ - Syncs version from tag (v0.7.0 → 0.7.0)
145
+ - Builds TypeScript (`npm run build`)
146
+ - Publishes to npm registry
147
+ - Users get postinstall script that downloads binaries from GitHub Releases
148
+
149
+ **User flow on `npm install pinchtab`:**
150
+ ```
151
+ 1. npm downloads @pinchtab/cli package
152
+ 2. Runs postinstall script
153
+ 3. Script detects OS/arch (darwin-arm64, linux-x64, etc.)
154
+ 4. Downloads binary from GitHub release
155
+ 5. Verifies SHA256 checksum
156
+ 6. Stores in ~/.pinchtab/bin/0.7.0/pinchtab-<os>-<arch>
157
+ 7. Makes executable
158
+ 8. If ANY STEP fails → npm install fails (exit 1)
159
+ ```
160
+
161
+ **⚠️ REQUIRES:** Goreleaser must have successfully uploaded binaries to GitHub release. The npm postinstall will verify this and fail hard if binaries are missing.
162
+
163
+ ### 3. Docker
164
+
165
+ GitHub Actions builds the release image directly from the tagged source with `docker buildx`.
166
+ The workflow pushes the same multi-arch build to both GHCR and Docker Hub, so Docker no longer depends on GoReleaser's temporary build context.
167
+
168
+ ### 4. ClawHub skill
169
+
170
+ The main `Release` workflow publishes the Pinchtab skill to ClawHub only after both npm and Docker complete successfully.
171
+ The standalone `Publish Skill` workflow is manual-only and is intended for retries or one-off recovery publishes.
172
+
173
+ ## Troubleshooting
174
+
175
+ ### npm publish fails (403)
176
+
177
+ - Check **NPM_TOKEN** is set in secrets
178
+ - Verify token has `automation` scope
179
+ - Check you're not already published (can't overwrite existing version)
180
+
181
+ ### Binary checksum mismatch
182
+
183
+ - goreleaser must generate `checksums.txt`
184
+ - Verify `.goreleaser.yml` has `checksum:` section
185
+ - Check GitHub release includes `checksums.txt`
186
+
187
+ ### Docker push fails
188
+
189
+ - Verify DOCKERHUB_USER and DOCKERHUB_TOKEN
190
+ - Check token has permission to push
191
+
192
+ ## Rolling back
193
+
194
+ If something goes wrong:
195
+
196
+ ```bash
197
+ # Delete the tag locally and on GitHub
198
+ git tag -d v0.7.1
199
+ git push origin :refs/tags/v0.7.1
200
+
201
+ # Delete npm version (requires owner permission)
202
+ npm unpublish pinchtab@0.7.1
203
+
204
+ # Revert any commits
205
+ git revert <commit>
206
+ git push origin main
207
+
208
+ # Retag when ready
209
+ git tag v0.7.1
210
+ git push origin v0.7.1
211
+ ```
212
+
213
+ ## Version strategy
214
+
215
+ - Use **semantic versioning**: v0.7.0 (major.minor.patch)
216
+ - Tag on main branch only
217
+ - One tag = one release (all artifacts)
218
+ - npm version must match Go binary tag
219
+
220
+ ## See also
221
+
222
+ - `.github/workflows/release.yml` — GitHub Actions workflow
223
+ - `.goreleaser.yml` — Go binary release config
224
+ - `npm/package.json` — npm package metadata
SECURITY.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ We currently support the following versions of Pinchtab with security updates:
6
+
7
+ | Version | Supported |
8
+ | ------- | ------------------ |
9
+ | v0.7.x | :white_check_mark: |
10
+ | v0.6.x | :white_check_mark: |
11
+ | < v0.6 | :x: |
12
+
13
+ ## Reporting a Vulnerability
14
+
15
+ We take the security of our browser automation bridge seriously. If you believe you have found a security vulnerability, please do not report it via a public GitHub issue.
16
+
17
+ Instead, please report vulnerabilities privately by:
18
+
19
+ 1. Opening a **Private Vulnerability Report** on GitHub (if available for this repo).
20
+ 2. Or emailing the maintainer directly at [INSERT EMAIL ADDRESS].
21
+
22
+ Please include:
23
+ - A description of the vulnerability.
24
+ - Steps to reproduce (proof of concept).
25
+ - Potential impact.
26
+
27
+ We will acknowledge receipt of your report within 48 hours and provide a timeline for a fix if necessary.
TESTING.md ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Testing
2
+
3
+ ## Quick Start with dev
4
+
5
+ The `dev` developer toolkit is the easiest way to run checks and tests:
6
+
7
+ ```bash
8
+ ./dev # Interactive picker
9
+ ./dev test # All tests (unit + E2E)
10
+ ./dev test unit # Unit tests only
11
+ ./dev e2e # E2E tests (both curl and CLI)
12
+ ./dev e2e orchestrator # Orchestrator-heavy E2E tests only
13
+ ./dev e2e curl # E2E curl tests only
14
+ ./dev e2e cli # E2E CLI tests only
15
+ ./dev check # All checks (format, vet, build, lint)
16
+ ./dev check go # Go checks only
17
+ ./dev check security # Gosec security scan
18
+ ./dev format dashboard # Run Prettier on dashboard sources
19
+ ./dev doctor # Setup dev environment
20
+ ```
21
+
22
+ ## Unit Tests
23
+
24
+ ```bash
25
+ go test ./...
26
+ # or
27
+ ./dev test unit
28
+ ```
29
+
30
+ Unit tests are standard Go tests that validate individual packages and functions without launching a full server.
31
+
32
+ ## E2E Tests
33
+
34
+ End-to-end tests launch a real pinchtab server with Chrome and run e2e-level tests against it.
35
+
36
+ ### Curl Tests (HTTP API)
37
+
38
+ ```bash
39
+ ./dev e2e curl
40
+ ```
41
+
42
+ Runs 183 HTTP-level tests using curl against the server. Tests the REST API, navigation, snapshots, and other HTTP endpoints.
43
+
44
+ ### CLI Tests
45
+
46
+ ```bash
47
+ ./dev e2e cli
48
+ ```
49
+
50
+ Runs CLI e2e tests. Tests the command-line interface directly.
51
+
52
+ ### Both E2E Test Suites
53
+
54
+ ```bash
55
+ ./dev e2e
56
+ ```
57
+
58
+ Runs all E2E tests (curl + CLI, 224 tests total).
59
+
60
+ ### Orchestrator E2E Suite
61
+
62
+ ```bash
63
+ ./dev e2e orchestrator
64
+ ```
65
+
66
+ Runs the orchestration-focused curl scenarios, including multi-instance flows and remote bridge attachment against the dedicated `pinchtab-bridge` Compose service.
67
+
68
+ ## Environment Variables
69
+
70
+ | Variable | Default | Description |
71
+ |---|---|---|
72
+ | `CI` | _(unset)_ | Set to `true` for longer health check timeouts (60s vs 30s) |
73
+
74
+ ### Temp Directory Layout
75
+
76
+ Each E2E test run creates a single temp directory under `/tmp/pinchtab-test-*/`:
77
+
78
+ ```
79
+ /tmp/pinchtab-test-123456789/
80
+ ├── pinchtab # Compiled test binary
81
+ ├── state/ # Dashboard state (profiles, instances)
82
+ └── profiles/ # Chrome user-data directories
83
+ ```
84
+
85
+ Everything is cleaned up automatically when tests finish.
86
+
87
+ ## Test File Structure
88
+
89
+ E2E tests are organized in two directories:
90
+
91
+ - **`tests/e2e/scenarios/*.sh`** — HTTP curl-based tests (183 tests)
92
+ - Test the REST API directly
93
+ - Use Docker Compose: `tests/e2e/docker-compose.yml`
94
+
95
+ - **`tests/e2e/scenarios-orchestrator/*.sh`** — orchestration-heavy curl tests
96
+ - Test multi-instance flows and remote bridge attachment
97
+ - Use Docker Compose: `tests/e2e/docker-compose-orchestrator.yml`
98
+
99
+ - **`tests/e2e/scenarios-cli/*.sh`** — CLI e2e tests (41 tests)
100
+ - Test the command-line interface
101
+ - Use Docker Compose: `tests/e2e/docker-compose.cli.yml`
102
+
103
+ Each test is a standalone bash script that:
104
+ 1. Starts the test server (or uses existing)
105
+ 2. Runs curl or CLI commands
106
+ 3. Asserts expected output or exit codes
107
+ 4. Cleans up
108
+
109
+ ## Writing New E2E Tests
110
+
111
+ Create a new bash script in `tests/e2e/scenarios/` (for curl tests) or `tests/e2e/scenarios-cli/` (for CLI tests):
112
+
113
+ ### Example: Simple Curl Test
114
+
115
+ ```bash
116
+ #!/bin/bash
117
+
118
+ # tests/e2e/scenarios/test-my-feature.sh
119
+
120
+ set -e # Exit on error
121
+
122
+ # Source helpers
123
+ . "$(dirname "$0")/../helpers.sh"
124
+
125
+ # Test setup
126
+ SERVER_URL="http://localhost:9867"
127
+
128
+ # Start server if needed
129
+ start_test_server
130
+
131
+ # Run test
132
+ echo "Testing my feature..."
133
+ RESPONSE=$(curl -s "$SERVER_URL/health")
134
+
135
+ if [ "$(echo "$RESPONSE" | jq -r '.status')" != "ok" ]; then
136
+ echo "❌ Health check failed"
137
+ exit 1
138
+ fi
139
+
140
+ echo "✅ Test passed"
141
+ ```
142
+
143
+ ### Example: CLI Test
144
+
145
+ ```bash
146
+ #!/bin/bash
147
+
148
+ # tests/e2e/scenarios-cli/test-my-cli.sh
149
+
150
+ set -e
151
+
152
+ # Source helpers
153
+ . "$(dirname "$0")/../helpers.sh"
154
+
155
+ # Test the CLI
156
+ echo "Testing pinchtab CLI..."
157
+ OUTPUT=$($PINCHTAB_BIN --version)
158
+
159
+ if [[ ! "$OUTPUT" =~ pinchtab ]]; then
160
+ echo "❌ Version output incorrect"
161
+ exit 1
162
+ fi
163
+
164
+ echo "✅ CLI test passed"
165
+ ```
166
+
167
+ ## Coverage
168
+
169
+ Generate coverage for unit tests:
170
+
171
+ ```bash
172
+ go test ./... -coverprofile=coverage.out
173
+ go tool cover -html=coverage.out
174
+ ```
175
+
176
+ Note: E2E tests are black-box tests and don't contribute to code coverage metrics directly.
THIRD_PARTY_LICENSES.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Third-Party Licenses
2
+
3
+ Pinchtab depends on the following open-source packages. All are compatible with MIT licensing.
4
+
5
+ ## Direct Dependencies
6
+
7
+ ### chromedp/chromedp
8
+ - **License:** MIT
9
+ - **Copyright:** (c) 2016-2025 Kenneth Shaw
10
+ - **URL:** https://github.com/chromedp/chromedp
11
+ - **Purpose:** Chrome DevTools Protocol driver — launches and controls Chrome
12
+
13
+ ### chromedp/cdproto
14
+ - **License:** MIT
15
+ - **Copyright:** (c) 2016-2025 Kenneth Shaw
16
+ - **URL:** https://github.com/chromedp/cdproto
17
+ - **Purpose:** Generated Go types for the Chrome DevTools Protocol
18
+
19
+ ## Transitive Dependencies
20
+
21
+ ### chromedp/sysutil
22
+ - **License:** MIT
23
+ - **Copyright:** (c) 2016-2017 Kenneth Shaw
24
+ - **URL:** https://github.com/chromedp/sysutil
25
+ - **Purpose:** System utilities for chromedp (finding Chrome binary)
26
+
27
+ ### go-json-experiment/json
28
+ - **License:** BSD 3-Clause
29
+ - **Copyright:** (c) 2020 The Go Authors
30
+ - **URL:** https://github.com/go-json-experiment/json
31
+ - **Purpose:** Experimental JSON library used by cdproto
32
+
33
+ ### gobwas/ws
34
+ - **License:** MIT
35
+ - **Copyright:** (c) 2017-2021 Sergey Kamardin
36
+ - **URL:** https://github.com/gobwas/ws
37
+ - **Purpose:** WebSocket implementation for CDP communication
38
+
39
+ ### gobwas/httphead
40
+ - **License:** MIT
41
+ - **Copyright:** (c) 2017 Sergey Kamardin
42
+ - **URL:** https://github.com/gobwas/httphead
43
+ - **Purpose:** HTTP header parsing (ws dependency)
44
+
45
+ ### gobwas/pool
46
+ - **License:** MIT
47
+ - **Copyright:** (c) 2017-2019 Sergey Kamardin
48
+ - **URL:** https://github.com/gobwas/pool
49
+ - **Purpose:** Pool utilities (ws dependency)
50
+
51
+ ### golang.org/x/sys
52
+ - **License:** BSD 3-Clause
53
+ - **Copyright:** (c) 2009 The Go Authors
54
+ - **URL:** https://github.com/golang/sys
55
+ - **Purpose:** Go system call wrappers
56
+
57
+ ### github.com/ledongthuc/pdf
58
+ - **License:** BSD 3-Clause
59
+ - **Copyright:** (c) 2009 The Go Authors
60
+ - **URL:** https://github.com/ledongthuc/pdf
61
+ - **Purpose:** Transitive dependency in module graph (test tooling chain)
62
+
63
+ ### github.com/orisano/pixelmatch
64
+ - **License:** MIT
65
+ - **Copyright:** (c) 2022 orisano
66
+ - **URL:** https://github.com/orisano/pixelmatch
67
+ - **Purpose:** Transitive dependency in module graph (test tooling chain)
68
+
69
+ ### gopkg.in/check.v1
70
+ - **License:** BSD-style
71
+ - **Copyright:** (c) 2010-2013 Gustavo Niemeyer
72
+ - **URL:** https://gopkg.in/check.v1
73
+ - **Purpose:** Transitive dependency in module graph (test tooling chain)
74
+
75
+ ### gopkg.in/yaml.v3
76
+ - **License:** Apache 2.0 / MIT
77
+ - **Copyright:** (c) 2006-2011 Kirill Simonov, (c) 2011-2019 Canonical Ltd
78
+ - **URL:** https://github.com/go-yaml/yaml
79
+ - **Purpose:** YAML output format for snapshots
80
+
81
+ ## Summary
82
+
83
+ | Package | License | Compatible |
84
+ |---------|---------|------------|
85
+ | chromedp/chromedp | MIT | ✅ |
86
+ | chromedp/cdproto | MIT | ✅ |
87
+ | chromedp/sysutil | MIT | ✅ |
88
+ | go-json-experiment/json | BSD 3-Clause | ✅ |
89
+ | gobwas/ws | MIT | ✅ |
90
+ | gobwas/httphead | MIT | ✅ |
91
+ | gobwas/pool | MIT | ✅ |
92
+ | golang.org/x/sys | BSD 3-Clause | ✅ |
93
+ | github.com/ledongthuc/pdf | BSD 3-Clause | ✅ |
94
+ | github.com/orisano/pixelmatch | MIT | ✅ |
95
+ | gopkg.in/check.v1 | BSD-style | ✅ |
96
+ | gopkg.in/yaml.v3 | Apache 2.0 / MIT | ✅ |
97
+
98
+ All dependencies are MIT, BSD-style, or Apache 2.0 licensed, compatible with Pinchtab's MIT license.
assets/docs-no-background-256.png ADDED
assets/favicon.png ADDED
assets/pinchtab-headless.png ADDED

Git LFS Details

  • SHA256: ce6460c357b720a8febef68203ab064750a29de094b2c9b7e173d67dfef7aa61
  • Pointer size: 131 Bytes
  • Size of remote file: 563 kB
cmd/pinchtab/build_test.go ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+
8
+ "gopkg.in/yaml.v3"
9
+ )
10
+
11
+ // GoReleaserConfig represents the minimal goreleaser config we care about
12
+ type GoReleaserConfig struct {
13
+ Builds []struct {
14
+ GOOS []string `yaml:"goos"`
15
+ GOARCH []string `yaml:"goarch"`
16
+ } `yaml:"builds"`
17
+ }
18
+
19
+ // TestBinaryPermutations verifies all expected binary permutations are configured in goreleaser
20
+ func TestBinaryPermutations(t *testing.T) {
21
+ // Find .goreleaser.yml in repo root (2 levels up from cmd/pinchtab/)
22
+ repoRoot := filepath.Join("..", "..", ".goreleaser.yml")
23
+ data, err := os.ReadFile(repoRoot)
24
+ if err != nil {
25
+ t.Fatalf("failed to read .goreleaser.yml at %s: %v", repoRoot, err)
26
+ }
27
+
28
+ var cfg GoReleaserConfig
29
+ if err := yaml.Unmarshal(data, &cfg); err != nil {
30
+ t.Fatalf("failed to parse .goreleaser.yml: %v", err)
31
+ }
32
+
33
+ if len(cfg.Builds) == 0 {
34
+ t.Fatal("no builds configured in .goreleaser.yml")
35
+ }
36
+
37
+ build := cfg.Builds[0]
38
+
39
+ // Expected OS/arch combinations
40
+ expectedOS := map[string]bool{
41
+ "linux": true,
42
+ "darwin": true,
43
+ "windows": true,
44
+ }
45
+
46
+ expectedArch := map[string]bool{
47
+ "amd64": true,
48
+ "arm64": true,
49
+ }
50
+
51
+ // Verify all expected OS are configured
52
+ for os := range expectedOS {
53
+ found := false
54
+ for _, configOS := range build.GOOS {
55
+ if configOS == os {
56
+ found = true
57
+ break
58
+ }
59
+ }
60
+ if !found {
61
+ t.Errorf("OS %q not found in goreleaser config", os)
62
+ }
63
+ }
64
+
65
+ // Verify all expected architectures are configured
66
+ for arch := range expectedArch {
67
+ found := false
68
+ for _, configArch := range build.GOARCH {
69
+ if configArch == arch {
70
+ found = true
71
+ break
72
+ }
73
+ }
74
+ if !found {
75
+ t.Errorf("Architecture %q not found in goreleaser config", arch)
76
+ }
77
+ }
78
+
79
+ // Calculate expected binary count
80
+ totalExpected := len(expectedOS) * len(expectedArch)
81
+ totalConfigured := len(build.GOOS) * len(build.GOARCH)
82
+
83
+ if totalConfigured != totalExpected {
84
+ t.Errorf("expected %d binaries (3 OS × 2 arch), but config produces %d",
85
+ totalExpected, totalConfigured)
86
+ }
87
+
88
+ t.Logf("✓ Binary matrix verified: %d OS × %d arch = %d total binaries",
89
+ len(build.GOOS), len(build.GOARCH), totalConfigured)
90
+ }
91
+
92
+ // TestExpectedBinaryNames verifies correct naming for all permutations
93
+ func TestExpectedBinaryNames(t *testing.T) {
94
+ expectedBinaries := []string{
95
+ "pinchtab-linux-amd64",
96
+ "pinchtab-linux-arm64",
97
+ "pinchtab-darwin-amd64",
98
+ "pinchtab-darwin-arm64",
99
+ "pinchtab-windows-amd64.exe",
100
+ "pinchtab-windows-arm64.exe",
101
+ }
102
+
103
+ if len(expectedBinaries) != 6 {
104
+ t.Errorf("expected 6 binaries, got %d", len(expectedBinaries))
105
+ }
106
+
107
+ t.Logf("Expected binary names (%d total):", len(expectedBinaries))
108
+ for _, bin := range expectedBinaries {
109
+ t.Logf(" ✓ %s", bin)
110
+ }
111
+ }
cmd/pinchtab/cmd_bridge.go ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+
7
+ "github.com/pinchtab/pinchtab/internal/config"
8
+ "github.com/pinchtab/pinchtab/internal/server"
9
+ "github.com/spf13/cobra"
10
+ )
11
+
12
+ var bridgeEngine string
13
+
14
+ var bridgeCmd = &cobra.Command{
15
+ Use: "bridge",
16
+ Short: "Start single-instance bridge-only server",
17
+ RunE: func(cmd *cobra.Command, args []string) error {
18
+ cfg := config.Load()
19
+ engineMode, err := resolveBridgeEngine(bridgeEngine, cfg.Engine)
20
+ if err != nil {
21
+ return err
22
+ }
23
+ cfg.Engine = engineMode
24
+ server.RunBridgeServer(cfg)
25
+ return nil
26
+ },
27
+ }
28
+
29
+ func resolveBridgeEngine(flagValue, configValue string) (string, error) {
30
+ engineMode := strings.ToLower(strings.TrimSpace(configValue))
31
+ if strings.TrimSpace(flagValue) != "" {
32
+ engineMode = strings.ToLower(strings.TrimSpace(flagValue))
33
+ }
34
+ if engineMode == "" {
35
+ engineMode = "chrome"
36
+ }
37
+ if engineMode != "chrome" && engineMode != "lite" && engineMode != "auto" {
38
+ return "", fmt.Errorf("invalid --engine %q (expected chrome, lite, or auto)", engineMode)
39
+ }
40
+ return engineMode, nil
41
+ }
42
+
43
+ func init() {
44
+ bridgeCmd.GroupID = "primary"
45
+ bridgeCmd.Flags().StringVar(&bridgeEngine, "engine", "", "Bridge engine: chrome, lite, or auto (overrides config)")
46
+ rootCmd.AddCommand(bridgeCmd)
47
+ }
cmd/pinchtab/cmd_bridge_test.go ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import "testing"
4
+
5
+ func TestResolveBridgeEngine(t *testing.T) {
6
+ tests := []struct {
7
+ name string
8
+ flagValue string
9
+ cfgValue string
10
+ want string
11
+ wantErr bool
12
+ }{
13
+ {name: "config default", cfgValue: "lite", want: "lite"},
14
+ {name: "flag overrides config", flagValue: "auto", cfgValue: "chrome", want: "auto"},
15
+ {name: "empty falls back to chrome", want: "chrome"},
16
+ {name: "invalid", flagValue: "bogus", wantErr: true},
17
+ }
18
+
19
+ for _, tt := range tests {
20
+ t.Run(tt.name, func(t *testing.T) {
21
+ got, err := resolveBridgeEngine(tt.flagValue, tt.cfgValue)
22
+ if tt.wantErr {
23
+ if err == nil {
24
+ t.Fatal("expected error")
25
+ }
26
+ return
27
+ }
28
+ if err != nil {
29
+ t.Fatalf("unexpected error: %v", err)
30
+ }
31
+ if got != tt.want {
32
+ t.Fatalf("got %q want %q", got, tt.want)
33
+ }
34
+ })
35
+ }
36
+ }
cmd/pinchtab/cmd_cli.go ADDED
@@ -0,0 +1,593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "fmt"
5
+ "net/http"
6
+ "os"
7
+ "strings"
8
+ "time"
9
+
10
+ browseractions "github.com/pinchtab/pinchtab/internal/cli/actions"
11
+ "github.com/pinchtab/pinchtab/internal/config"
12
+ "github.com/pinchtab/pinchtab/internal/urlutil"
13
+ "github.com/spf13/cobra"
14
+ )
15
+
16
+ var quickCmd = &cobra.Command{
17
+ Use: "quick <url>",
18
+ Short: "Navigate + analyze page",
19
+ Args: cobra.ExactArgs(1),
20
+ Run: func(cmd *cobra.Command, args []string) {
21
+ args[0] = urlutil.Normalize(args[0])
22
+ cfg := config.Load()
23
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
24
+ browseractions.Quick(client, base, token, args)
25
+ })
26
+ },
27
+ }
28
+
29
+ var navCmd = &cobra.Command{
30
+ Use: "nav <url>",
31
+ Aliases: []string{"goto", "navigate", "open"},
32
+ Short: "Navigate to URL",
33
+ Args: cobra.ExactArgs(1),
34
+ Run: func(cmd *cobra.Command, args []string) {
35
+ url := urlutil.Normalize(args[0])
36
+ cfg := config.Load()
37
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
38
+ browseractions.Navigate(client, base, token, url, cmd)
39
+ })
40
+ },
41
+ }
42
+
43
+ var backCmd = &cobra.Command{
44
+ Use: "back",
45
+ Short: "Go back in browser history",
46
+ Run: func(cmd *cobra.Command, args []string) {
47
+ cfg := config.Load()
48
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
49
+ browseractions.Back(client, base, token, cmd)
50
+ })
51
+ },
52
+ }
53
+
54
+ var forwardCmd = &cobra.Command{
55
+ Use: "forward",
56
+ Short: "Go forward in browser history",
57
+ Run: func(cmd *cobra.Command, args []string) {
58
+ cfg := config.Load()
59
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
60
+ browseractions.Forward(client, base, token, cmd)
61
+ })
62
+ },
63
+ }
64
+
65
+ var reloadCmd = &cobra.Command{
66
+ Use: "reload",
67
+ Short: "Reload current page",
68
+ Run: func(cmd *cobra.Command, args []string) {
69
+ cfg := config.Load()
70
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
71
+ browseractions.Reload(client, base, token, cmd)
72
+ })
73
+ },
74
+ }
75
+
76
+ var snapCmd = &cobra.Command{
77
+ Use: "snap",
78
+ Short: "Snapshot accessibility tree",
79
+ Run: func(cmd *cobra.Command, args []string) {
80
+ cfg := config.Load()
81
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
82
+ browseractions.Snapshot(client, base, token, cmd)
83
+ })
84
+ },
85
+ }
86
+
87
+ var clickCmd = &cobra.Command{
88
+ Use: "click <ref>",
89
+ Short: "Click element",
90
+ Args: cobra.MaximumNArgs(1),
91
+ Run: func(cmd *cobra.Command, args []string) {
92
+ ref := ""
93
+ if len(args) > 0 {
94
+ ref = args[0]
95
+ }
96
+ cfg := config.Load()
97
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
98
+ browseractions.Action(client, base, token, "click", ref, cmd)
99
+ })
100
+ },
101
+ }
102
+
103
+ var dblclickCmd = &cobra.Command{
104
+ Use: "dblclick <ref>",
105
+ Short: "Double-click element",
106
+ Args: cobra.MaximumNArgs(1),
107
+ Run: func(cmd *cobra.Command, args []string) {
108
+ ref := ""
109
+ if len(args) > 0 {
110
+ ref = args[0]
111
+ }
112
+ cfg := config.Load()
113
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
114
+ browseractions.Action(client, base, token, "dblclick", ref, cmd)
115
+ })
116
+ },
117
+ }
118
+
119
+ var typeCmd = &cobra.Command{
120
+ Use: "type <ref> <text>",
121
+ Short: "Type into element",
122
+ Args: cobra.MinimumNArgs(2),
123
+ Run: func(cmd *cobra.Command, args []string) {
124
+ cfg := config.Load()
125
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
126
+ browseractions.ActionSimple(client, base, token, "type", args, cmd)
127
+ })
128
+ },
129
+ }
130
+
131
+ var screenshotCmd = &cobra.Command{
132
+ Use: "screenshot",
133
+ Short: "Take a screenshot",
134
+ Run: func(cmd *cobra.Command, args []string) {
135
+ cfg := config.Load()
136
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
137
+ browseractions.Screenshot(client, base, token, cmd)
138
+ })
139
+ },
140
+ }
141
+
142
+ var tabsCmd = &cobra.Command{
143
+ Use: "tab [id]",
144
+ Aliases: []string{"tabs"},
145
+ Short: "List tabs, or focus a tab by ID",
146
+ Args: cobra.MaximumNArgs(1),
147
+ Run: func(cmd *cobra.Command, args []string) {
148
+ cfg := config.Load()
149
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
150
+ if len(args) == 0 {
151
+ browseractions.TabList(client, base, token)
152
+ } else {
153
+ browseractions.TabFocus(client, base, token, args[0])
154
+ }
155
+ })
156
+ },
157
+ }
158
+
159
+ var instancesCmd = &cobra.Command{
160
+ Use: "instances",
161
+ Short: "List or manage instances",
162
+ Run: func(cmd *cobra.Command, args []string) {
163
+ cfg := config.Load()
164
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
165
+ browseractions.Instances(client, base, token)
166
+ })
167
+ },
168
+ }
169
+
170
+ var healthCmd = &cobra.Command{
171
+ Use: "health",
172
+ Short: "Check server health",
173
+ Run: func(cmd *cobra.Command, args []string) {
174
+ cfg := config.Load()
175
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
176
+ browseractions.Health(client, base, token)
177
+ })
178
+ },
179
+ }
180
+
181
+ var pressCmd = &cobra.Command{
182
+ Use: "press <key>",
183
+ Short: "Press key (Enter, Tab, Escape...)",
184
+ Args: cobra.MinimumNArgs(1),
185
+ Run: func(cmd *cobra.Command, args []string) {
186
+ cfg := config.Load()
187
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
188
+ browseractions.ActionSimple(client, base, token, "press", args, cmd)
189
+ })
190
+ },
191
+ }
192
+
193
+ var fillCmd = &cobra.Command{
194
+ Use: "fill <ref|selector> <text>",
195
+ Short: "Fill input directly",
196
+ Args: cobra.MinimumNArgs(2),
197
+ Run: func(cmd *cobra.Command, args []string) {
198
+ cfg := config.Load()
199
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
200
+ browseractions.ActionSimple(client, base, token, "fill", args, cmd)
201
+ })
202
+ },
203
+ }
204
+
205
+ var hoverCmd = &cobra.Command{
206
+ Use: "hover <ref>",
207
+ Short: "Hover element",
208
+ Args: cobra.MaximumNArgs(1),
209
+ Run: func(cmd *cobra.Command, args []string) {
210
+ ref := ""
211
+ if len(args) > 0 {
212
+ ref = args[0]
213
+ }
214
+ cfg := config.Load()
215
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
216
+ browseractions.Action(client, base, token, "hover", ref, cmd)
217
+ })
218
+ },
219
+ }
220
+
221
+ var scrollCmd = &cobra.Command{
222
+ Use: "scroll <ref|pixels>",
223
+ Short: "Scroll to element or by pixels",
224
+ Args: cobra.MinimumNArgs(1),
225
+ Run: func(cmd *cobra.Command, args []string) {
226
+ cfg := config.Load()
227
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
228
+ browseractions.ActionSimple(client, base, token, "scroll", args, cmd)
229
+ })
230
+ },
231
+ }
232
+
233
+ var evalCmd = &cobra.Command{
234
+ Use: "eval <expression>",
235
+ Short: "Evaluate JavaScript",
236
+ Args: cobra.MinimumNArgs(1),
237
+ Run: func(cmd *cobra.Command, args []string) {
238
+ cfg := config.Load()
239
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
240
+ browseractions.Evaluate(client, base, token, args, cmd)
241
+ })
242
+ },
243
+ }
244
+
245
+ var pdfCmd = &cobra.Command{
246
+ Use: "pdf",
247
+ Short: "Export the current page as PDF",
248
+ Run: func(cmd *cobra.Command, args []string) {
249
+ cfg := config.Load()
250
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
251
+ browseractions.PDF(client, base, token, cmd)
252
+ })
253
+ },
254
+ }
255
+
256
+ var textCmd = &cobra.Command{
257
+ Use: "text",
258
+ Short: "Extract page text",
259
+ Run: func(cmd *cobra.Command, args []string) {
260
+ cfg := config.Load()
261
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
262
+ browseractions.Text(client, base, token, cmd)
263
+ })
264
+ },
265
+ }
266
+
267
+ var downloadCmd = &cobra.Command{
268
+ Use: "download <url>",
269
+ Short: "Download a file via browser session",
270
+ Args: cobra.ExactArgs(1),
271
+ Run: func(cmd *cobra.Command, args []string) {
272
+ args[0] = urlutil.Normalize(args[0])
273
+ output, _ := cmd.Flags().GetString("output")
274
+ cfg := config.Load()
275
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
276
+ browseractions.Download(client, base, token, args, output)
277
+ })
278
+ },
279
+ }
280
+
281
+ var uploadCmd = &cobra.Command{
282
+ Use: "upload <file-path>",
283
+ Short: "Upload a file to a file input element",
284
+ Args: cobra.MinimumNArgs(1),
285
+ Run: func(cmd *cobra.Command, args []string) {
286
+ selector, _ := cmd.Flags().GetString("selector")
287
+ cfg := config.Load()
288
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
289
+ browseractions.Upload(client, base, token, args, selector)
290
+ })
291
+ },
292
+ }
293
+
294
+ var profilesCmd = &cobra.Command{
295
+ Use: "profiles",
296
+ Short: "List browser profiles",
297
+ Run: func(cmd *cobra.Command, args []string) {
298
+ cfg := config.Load()
299
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
300
+ browseractions.Profiles(client, base, token)
301
+ })
302
+ },
303
+ }
304
+
305
+ var instanceCmd = &cobra.Command{
306
+ Use: "instance",
307
+ Short: "Manage browser instances",
308
+ }
309
+
310
+ var findCmd = &cobra.Command{
311
+ Use: "find <query>",
312
+ Short: "Find elements by natural language query",
313
+ Args: cobra.ExactArgs(1),
314
+ Run: func(cmd *cobra.Command, args []string) {
315
+ cfg := config.Load()
316
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
317
+ browseractions.Find(client, base, token, args[0], cmd)
318
+ })
319
+ },
320
+ }
321
+
322
+ var selectCmd = &cobra.Command{
323
+ Use: "select <ref> <value>",
324
+ Short: "Select option in dropdown",
325
+ Args: cobra.MinimumNArgs(2),
326
+ Run: func(cmd *cobra.Command, args []string) {
327
+ cfg := config.Load()
328
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
329
+ browseractions.ActionSimple(client, base, token, "select", args, cmd)
330
+ })
331
+ },
332
+ }
333
+
334
+ var checkCmd = &cobra.Command{
335
+ Use: "check <selector>",
336
+ Short: "Check a checkbox or radio",
337
+ Args: cobra.ExactArgs(1),
338
+ Run: func(cmd *cobra.Command, args []string) {
339
+ cfg := config.Load()
340
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
341
+ browseractions.Action(client, base, token, "check", args[0], cmd)
342
+ })
343
+ },
344
+ }
345
+
346
+ var uncheckCmd = &cobra.Command{
347
+ Use: "uncheck <selector>",
348
+ Short: "Uncheck a checkbox or radio",
349
+ Args: cobra.ExactArgs(1),
350
+ Run: func(cmd *cobra.Command, args []string) {
351
+ cfg := config.Load()
352
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
353
+ browseractions.Action(client, base, token, "uncheck", args[0], cmd)
354
+ })
355
+ },
356
+ }
357
+
358
+ func init() {
359
+ quickCmd.GroupID = "browser"
360
+ navCmd.GroupID = "browser"
361
+ backCmd.GroupID = "browser"
362
+ forwardCmd.GroupID = "browser"
363
+ reloadCmd.GroupID = "browser"
364
+ snapCmd.GroupID = "browser"
365
+ clickCmd.GroupID = "browser"
366
+ typeCmd.GroupID = "browser"
367
+ screenshotCmd.GroupID = "browser"
368
+ tabsCmd.GroupID = "browser"
369
+ instancesCmd.GroupID = "management"
370
+ healthCmd.GroupID = "management"
371
+ pressCmd.GroupID = "browser"
372
+ fillCmd.GroupID = "browser"
373
+ hoverCmd.GroupID = "browser"
374
+ scrollCmd.GroupID = "browser"
375
+ evalCmd.GroupID = "browser"
376
+ pdfCmd.GroupID = "browser"
377
+ textCmd.GroupID = "browser"
378
+ profilesCmd.GroupID = "management"
379
+ downloadCmd.GroupID = "browser"
380
+ uploadCmd.GroupID = "browser"
381
+ findCmd.GroupID = "browser"
382
+ selectCmd.GroupID = "browser"
383
+ checkCmd.GroupID = "browser"
384
+ uncheckCmd.GroupID = "browser"
385
+
386
+ tabsCmd.AddCommand(&cobra.Command{
387
+ Use: "new [url]",
388
+ Short: "Open a new tab",
389
+ Args: cobra.MaximumNArgs(1),
390
+ Run: func(cmd *cobra.Command, args []string) {
391
+ cfg := config.Load()
392
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
393
+ body := map[string]any{"action": "new"}
394
+ if len(args) > 0 {
395
+ body["url"] = urlutil.Normalize(args[0])
396
+ }
397
+ browseractions.TabNew(client, base, token, body)
398
+ })
399
+ },
400
+ })
401
+ tabsCmd.AddCommand(&cobra.Command{
402
+ Use: "close <id>",
403
+ Short: "Close a tab by ID",
404
+ Args: cobra.ExactArgs(1),
405
+ Run: func(cmd *cobra.Command, args []string) {
406
+ cfg := config.Load()
407
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
408
+ browseractions.TabClose(client, base, token, args[0])
409
+ })
410
+ },
411
+ })
412
+
413
+ uploadCmd.Flags().StringP("selector", "s", "", "CSS selector for file input")
414
+ downloadCmd.Flags().StringP("output", "o", "", "Save downloaded file to path")
415
+
416
+ clickCmd.Flags().String("css", "", "CSS selector instead of ref")
417
+ clickCmd.Flags().Float64("x", 0, "X coordinate for click")
418
+ clickCmd.Flags().Float64("y", 0, "Y coordinate for click")
419
+ clickCmd.Flags().Bool("wait-nav", false, "Wait for navigation after click")
420
+ dblclickCmd.Flags().String("css", "", "CSS selector instead of ref")
421
+ dblclickCmd.Flags().Float64("x", 0, "X coordinate for dblclick")
422
+ dblclickCmd.Flags().Float64("y", 0, "Y coordinate for dblclick")
423
+ hoverCmd.Flags().String("css", "", "CSS selector instead of ref")
424
+ hoverCmd.Flags().Float64("x", 0, "X coordinate for hover")
425
+ hoverCmd.Flags().Float64("y", 0, "Y coordinate for hover")
426
+
427
+ snapCmd.Flags().BoolP("interactive", "i", false, "Filter interactive elements only")
428
+ snapCmd.Flags().BoolP("compact", "c", false, "Compact output format")
429
+ snapCmd.Flags().Bool("text", false, "Text output format")
430
+ snapCmd.Flags().BoolP("diff", "d", false, "Show diff from previous snapshot")
431
+ snapCmd.Flags().StringP("selector", "s", "", "CSS selector to scope snapshot")
432
+ snapCmd.Flags().String("max-tokens", "", "Maximum token budget")
433
+ snapCmd.Flags().String("depth", "", "Tree depth limit")
434
+ snapCmd.Flags().String("tab", "", "Tab ID")
435
+
436
+ screenshotCmd.Flags().StringP("output", "o", "", "Save screenshot to file path")
437
+ screenshotCmd.Flags().StringP("quality", "q", "", "JPEG quality (0-100)")
438
+ screenshotCmd.Flags().String("tab", "", "Tab ID")
439
+
440
+ pdfCmd.Flags().StringP("output", "o", "", "Save PDF to file path")
441
+ pdfCmd.Flags().String("tab", "", "Tab ID")
442
+ pdfCmd.Flags().Bool("landscape", false, "Landscape orientation")
443
+ pdfCmd.Flags().String("scale", "", "Page scale (e.g. 0.5)")
444
+ pdfCmd.Flags().String("paper-width", "", "Paper width (inches)")
445
+ pdfCmd.Flags().String("paper-height", "", "Paper height (inches)")
446
+ pdfCmd.Flags().String("margin-top", "", "Top margin")
447
+ pdfCmd.Flags().String("margin-bottom", "", "Bottom margin")
448
+ pdfCmd.Flags().String("margin-left", "", "Left margin")
449
+ pdfCmd.Flags().String("margin-right", "", "Right margin")
450
+ pdfCmd.Flags().String("page-ranges", "", "Page ranges (e.g. 1-3)")
451
+ pdfCmd.Flags().Bool("prefer-css-page-size", false, "Use CSS page size")
452
+ pdfCmd.Flags().Bool("display-header-footer", false, "Show header/footer")
453
+ pdfCmd.Flags().String("header-template", "", "Header HTML template")
454
+ pdfCmd.Flags().String("footer-template", "", "Footer HTML template")
455
+ pdfCmd.Flags().Bool("generate-tagged-pdf", false, "Generate tagged PDF")
456
+ pdfCmd.Flags().Bool("generate-document-outline", false, "Generate document outline")
457
+ pdfCmd.Flags().Bool("file-output", false, "Use server-side file output")
458
+ pdfCmd.Flags().String("path", "", "Server-side output path")
459
+
460
+ findCmd.Flags().String("tab", "", "Tab ID")
461
+ findCmd.Flags().String("threshold", "", "Minimum similarity score (0-1)")
462
+ findCmd.Flags().Bool("explain", false, "Show score breakdown")
463
+ findCmd.Flags().Bool("ref-only", false, "Output just the element ref")
464
+
465
+ textCmd.Flags().Bool("raw", false, "Raw extraction mode")
466
+ textCmd.Flags().String("tab", "", "Tab ID")
467
+
468
+ navCmd.Flags().Bool("new-tab", false, "Open in new tab")
469
+ navCmd.Flags().Bool("block-images", false, "Block image loading")
470
+ navCmd.Flags().Bool("block-ads", false, "Block ads")
471
+ navCmd.Flags().String("tab", "", "Tab ID")
472
+ backCmd.Flags().String("tab", "", "Tab ID")
473
+ forwardCmd.Flags().String("tab", "", "Tab ID")
474
+ reloadCmd.Flags().String("tab", "", "Tab ID")
475
+
476
+ clickCmd.Flags().String("tab", "", "Tab ID")
477
+ dblclickCmd.Flags().String("tab", "", "Tab ID")
478
+ hoverCmd.Flags().String("tab", "", "Tab ID")
479
+ typeCmd.Flags().String("tab", "", "Tab ID")
480
+ pressCmd.Flags().String("tab", "", "Tab ID")
481
+ fillCmd.Flags().String("tab", "", "Tab ID")
482
+ scrollCmd.Flags().String("tab", "", "Tab ID")
483
+ selectCmd.Flags().String("tab", "", "Tab ID")
484
+ evalCmd.Flags().String("tab", "", "Tab ID")
485
+ checkCmd.Flags().String("tab", "", "Tab ID")
486
+ uncheckCmd.Flags().String("tab", "", "Tab ID")
487
+
488
+ rootCmd.AddCommand(quickCmd)
489
+ rootCmd.AddCommand(navCmd)
490
+ rootCmd.AddCommand(backCmd)
491
+ rootCmd.AddCommand(forwardCmd)
492
+ rootCmd.AddCommand(reloadCmd)
493
+ rootCmd.AddCommand(snapCmd)
494
+ rootCmd.AddCommand(clickCmd)
495
+ rootCmd.AddCommand(dblclickCmd)
496
+ rootCmd.AddCommand(typeCmd)
497
+ rootCmd.AddCommand(screenshotCmd)
498
+ rootCmd.AddCommand(tabsCmd)
499
+ rootCmd.AddCommand(instancesCmd)
500
+ rootCmd.AddCommand(healthCmd)
501
+ rootCmd.AddCommand(pressCmd)
502
+ rootCmd.AddCommand(fillCmd)
503
+ rootCmd.AddCommand(hoverCmd)
504
+ rootCmd.AddCommand(scrollCmd)
505
+ rootCmd.AddCommand(evalCmd)
506
+ rootCmd.AddCommand(pdfCmd)
507
+ rootCmd.AddCommand(textCmd)
508
+ rootCmd.AddCommand(profilesCmd)
509
+ rootCmd.AddCommand(downloadCmd)
510
+ rootCmd.AddCommand(uploadCmd)
511
+ rootCmd.AddCommand(findCmd)
512
+ rootCmd.AddCommand(selectCmd)
513
+ rootCmd.AddCommand(checkCmd)
514
+ rootCmd.AddCommand(uncheckCmd)
515
+
516
+ instanceCmd.GroupID = "management"
517
+
518
+ startInstanceCmd := &cobra.Command{
519
+ Use: "start",
520
+ Short: "Start a browser instance",
521
+ Run: func(cmd *cobra.Command, args []string) {
522
+ cfg := config.Load()
523
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
524
+ browseractions.InstanceStart(client, base, token, cmd)
525
+ })
526
+ },
527
+ }
528
+ startInstanceCmd.Flags().String("profile", "", "Profile to use")
529
+ startInstanceCmd.Flags().String("mode", "", "Instance mode")
530
+ startInstanceCmd.Flags().String("port", "", "Port number")
531
+ startInstanceCmd.Flags().StringArray("extension", nil, "Load browser extension (repeatable)")
532
+ instanceCmd.AddCommand(startInstanceCmd)
533
+
534
+ instanceCmd.AddCommand(&cobra.Command{
535
+ Use: "navigate <id> <url>",
536
+ Short: "Navigate an instance to a URL",
537
+ Args: cobra.ExactArgs(2),
538
+ Run: func(cmd *cobra.Command, args []string) {
539
+ args[1] = urlutil.Normalize(args[1])
540
+ cfg := config.Load()
541
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
542
+ browseractions.InstanceNavigate(client, base, token, args)
543
+ })
544
+ },
545
+ })
546
+ instanceCmd.AddCommand(&cobra.Command{
547
+ Use: "stop <id>",
548
+ Short: "Stop a browser instance",
549
+ Args: cobra.ExactArgs(1),
550
+ Run: func(cmd *cobra.Command, args []string) {
551
+ cfg := config.Load()
552
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
553
+ browseractions.InstanceStop(client, base, token, args)
554
+ })
555
+ },
556
+ })
557
+ instanceCmd.AddCommand(&cobra.Command{
558
+ Use: "logs <id>",
559
+ Short: "Get instance logs",
560
+ Args: cobra.ExactArgs(1),
561
+ Run: func(cmd *cobra.Command, args []string) {
562
+ cfg := config.Load()
563
+ runCLIWith(cfg, func(client *http.Client, base, token string) {
564
+ browseractions.InstanceLogs(client, base, token, args)
565
+ })
566
+ },
567
+ })
568
+ rootCmd.AddCommand(instanceCmd)
569
+ }
570
+
571
+ func runCLIWith(cfg *config.RuntimeConfig, fn func(client *http.Client, base, token string)) {
572
+ client := &http.Client{Timeout: 60 * time.Second}
573
+
574
+ // Default: http://127.0.0.1:{port}
575
+ port := cfg.Port
576
+ if port == "" {
577
+ port = "9867"
578
+ }
579
+ base := fmt.Sprintf("http://127.0.0.1:%s", port)
580
+
581
+ // --server flag overrides
582
+ if serverURL != "" {
583
+ base = strings.TrimRight(serverURL, "/")
584
+ }
585
+
586
+ // Token from config, env var overrides
587
+ token := cfg.Token
588
+ if envToken := os.Getenv("PINCHTAB_TOKEN"); envToken != "" {
589
+ token = envToken
590
+ }
591
+
592
+ fn(client, base, token)
593
+ }
cmd/pinchtab/cmd_completion.go ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "os"
5
+
6
+ "github.com/spf13/cobra"
7
+ )
8
+
9
+ var completionCmd = &cobra.Command{
10
+ Use: "completion [bash|zsh|fish|powershell]",
11
+ Short: "Generate shell completion script",
12
+ Long: `Generate a shell completion script for pinchtab.
13
+
14
+ To load completions:
15
+
16
+ Bash:
17
+ $ source <(pinchtab completion bash)
18
+
19
+ # To load completions for each session, execute once:
20
+ # Linux:
21
+ $ pinchtab completion bash > /etc/bash_completion.d/pinchtab
22
+ # macOS:
23
+ $ pinchtab completion bash > $(brew --prefix)/etc/bash_completion.d/pinchtab
24
+
25
+ Zsh:
26
+ # If shell completion is not already enabled in your environment,
27
+ # you will need to enable it. You can execute the following once:
28
+ $ echo "autoload -U compinit; compinit" >> ~/.zshrc
29
+
30
+ # To load completions for each session, execute once:
31
+ $ pinchtab completion zsh > "${fpath[1]}/_pinchtab"
32
+
33
+ # You will need to start a new shell for this setup to take effect.
34
+
35
+ Fish:
36
+ $ pinchtab completion fish | source
37
+
38
+ # To load completions for each session, execute once:
39
+ $ pinchtab completion fish > ~/.config/fish/completions/pinchtab.fish
40
+
41
+ PowerShell:
42
+ PS> pinchtab completion powershell | Out-String | Invoke-Expression
43
+
44
+ # To load completions for every new session, add the output to your profile:
45
+ PS> pinchtab completion powershell > pinchtab.ps1
46
+ # and source this file from your PowerShell profile.
47
+ `,
48
+ DisableFlagsInUseLine: true,
49
+ ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
50
+ Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
51
+ RunE: func(cmd *cobra.Command, args []string) error {
52
+ switch args[0] {
53
+ case "bash":
54
+ return rootCmd.GenBashCompletion(os.Stdout)
55
+ case "zsh":
56
+ return rootCmd.GenZshCompletion(os.Stdout)
57
+ case "fish":
58
+ return rootCmd.GenFishCompletion(os.Stdout, true)
59
+ case "powershell":
60
+ return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
61
+ }
62
+ return nil
63
+ },
64
+ }
65
+
66
+ func init() {
67
+ completionCmd.GroupID = "config"
68
+ rootCmd.AddCommand(completionCmd)
69
+ }
cmd/pinchtab/cmd_config.go ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "runtime"
10
+ "strings"
11
+
12
+ "github.com/pinchtab/pinchtab/internal/cli"
13
+ "github.com/pinchtab/pinchtab/internal/config"
14
+ "github.com/pinchtab/pinchtab/internal/server"
15
+ "github.com/spf13/cobra"
16
+ )
17
+
18
+ var clipboardExecCommand = exec.Command
19
+
20
+ var configCmd = &cobra.Command{
21
+ Use: "config",
22
+ Short: "Manage configuration",
23
+ Run: func(cmd *cobra.Command, args []string) {
24
+ handleConfigOverview(loadConfig())
25
+ },
26
+ }
27
+
28
+ func init() {
29
+ configCmd.GroupID = "config"
30
+ configCmd.AddCommand(&cobra.Command{
31
+ Use: "show",
32
+ Short: "Display current configuration",
33
+ Run: func(cmd *cobra.Command, args []string) {
34
+ cfg := config.Load()
35
+ cli.HandleConfigShow(cfg)
36
+ },
37
+ })
38
+ configCmd.AddCommand(&cobra.Command{
39
+ Use: "init",
40
+ Short: "Initialize a new config file",
41
+ Run: func(cmd *cobra.Command, args []string) {
42
+ handleConfigInit()
43
+ },
44
+ })
45
+ configCmd.AddCommand(&cobra.Command{
46
+ Use: "path",
47
+ Short: "Show config file path",
48
+ Run: func(cmd *cobra.Command, args []string) {
49
+ handleConfigPath()
50
+ },
51
+ })
52
+ configCmd.AddCommand(&cobra.Command{
53
+ Use: "validate",
54
+ Short: "Validate config file",
55
+ Run: func(cmd *cobra.Command, args []string) {
56
+ handleConfigValidate()
57
+ },
58
+ })
59
+ configCmd.AddCommand(&cobra.Command{
60
+ Use: "get <path>",
61
+ Short: "Get a config value (e.g., server.port)",
62
+ Args: cobra.ExactArgs(1),
63
+ Run: func(cmd *cobra.Command, args []string) {
64
+ handleConfigGet(args[0])
65
+ },
66
+ })
67
+ configCmd.AddCommand(&cobra.Command{
68
+ Use: "set <path> <val>",
69
+ Short: "Set a config value (e.g., server.port 8080)",
70
+ Args: cobra.ExactArgs(2),
71
+ Run: func(cmd *cobra.Command, args []string) {
72
+ handleConfigSet(args[0], args[1])
73
+ },
74
+ })
75
+ configCmd.AddCommand(&cobra.Command{
76
+ Use: "patch <json>",
77
+ Short: "Merge JSON into config",
78
+ Args: cobra.ExactArgs(1),
79
+ Run: func(cmd *cobra.Command, args []string) {
80
+ handleConfigPatch(args[0])
81
+ },
82
+ })
83
+
84
+ rootCmd.AddCommand(configCmd)
85
+ }
86
+
87
+ func handleConfigOverview(cfg *config.RuntimeConfig) {
88
+ _, configPath, err := config.LoadFileConfig()
89
+ if err != nil {
90
+ fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, fmt.Sprintf("Error loading config path: %v", err)))
91
+ os.Exit(1)
92
+ }
93
+
94
+ dashPort := cfg.Port
95
+ if dashPort == "" {
96
+ dashPort = "9870"
97
+ }
98
+ dashboardURL := fmt.Sprintf("http://localhost:%s", dashPort)
99
+ running := server.CheckPinchTabRunning(dashPort, cfg.Token)
100
+
101
+ for {
102
+ fmt.Print(renderConfigOverview(cfg, configPath, dashboardURL, running))
103
+
104
+ if !isInteractiveTerminal() {
105
+ return
106
+ }
107
+
108
+ nextCfg, changed, done, err := promptConfigEdit(cfg)
109
+ if err != nil {
110
+ fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
111
+ fmt.Println()
112
+ continue
113
+ }
114
+ if done {
115
+ return
116
+ }
117
+ if !changed {
118
+ fmt.Println()
119
+ continue
120
+ }
121
+
122
+ cfg = nextCfg
123
+ dashPort = cfg.Port
124
+ if dashPort == "" {
125
+ dashPort = "9870"
126
+ }
127
+ dashboardURL = fmt.Sprintf("http://localhost:%s", dashPort)
128
+ running = server.CheckPinchTabRunning(dashPort, cfg.Token)
129
+ fmt.Println()
130
+ }
131
+ }
132
+
133
+ func renderConfigOverview(cfg *config.RuntimeConfig, configPath, dashboardURL string, running bool) string {
134
+ out := ""
135
+ out += cli.StyleStdout(cli.HeadingStyle, "Config") + "\n\n"
136
+ out += fmt.Sprintf(" 1. %-18s %s\n", "Strategy", cli.StyleStdout(cli.ValueStyle, cfg.Strategy))
137
+ out += fmt.Sprintf(" 2. %-18s %s\n", "Allocation policy", cli.StyleStdout(cli.ValueStyle, cfg.AllocationPolicy))
138
+ out += fmt.Sprintf(" 3. %-18s %s\n", "Stealth level", cli.StyleStdout(cli.ValueStyle, cfg.StealthLevel))
139
+ out += fmt.Sprintf(" 4. %-18s %s\n", "Tab eviction", cli.StyleStdout(cli.ValueStyle, cfg.TabEvictionPolicy))
140
+ out += fmt.Sprintf(" 5. %-18s %s\n", "Copy token", cli.StyleStdout(cli.MutedStyle, "clipboard"))
141
+ out += "\n"
142
+ out += cli.StyleStdout(cli.HeadingStyle, "More") + "\n\n"
143
+ out += fmt.Sprintf(" %s %s\n", cli.StyleStdout(cli.MutedStyle, "File:"), cli.StyleStdout(cli.ValueStyle, configPath))
144
+ out += fmt.Sprintf(" %s %s\n", cli.StyleStdout(cli.MutedStyle, "Token:"), cli.StyleStdout(cli.ValueStyle, config.MaskToken(cfg.Token)))
145
+ if running {
146
+ out += fmt.Sprintf(" %s %s\n", cli.StyleStdout(cli.MutedStyle, "Dashboard:"), cli.StyleStdout(cli.ValueStyle, dashboardURL))
147
+ } else {
148
+ out += fmt.Sprintf(" %s %s\n", cli.StyleStdout(cli.MutedStyle, "Dashboard:"), cli.StyleStdout(cli.MutedStyle, "not running"))
149
+ }
150
+ if isInteractiveTerminal() {
151
+ out += "\n"
152
+ out += cli.StyleStdout(cli.MutedStyle, "Edit item (1-5, blank to exit):") + " "
153
+ }
154
+ out += "\n"
155
+ return out
156
+ }
157
+
158
+ func promptConfigEdit(cfg *config.RuntimeConfig) (*config.RuntimeConfig, bool, bool, error) {
159
+ choice, err := promptInput("", "")
160
+ if err != nil {
161
+ return nil, false, false, err
162
+ }
163
+ choice = strings.TrimSpace(choice)
164
+ if choice == "" {
165
+ return nil, false, true, nil
166
+ }
167
+
168
+ switch choice {
169
+ case "1":
170
+ nextCfg, changed, err := editConfigSelection("Instance strategy", "multiInstance.strategy", cfg.Strategy, config.ValidStrategies())
171
+ return nextCfg, changed, false, err
172
+ case "2":
173
+ nextCfg, changed, err := editConfigSelection("Allocation policy", "multiInstance.allocationPolicy", cfg.AllocationPolicy, config.ValidAllocationPolicies())
174
+ return nextCfg, changed, false, err
175
+ case "3":
176
+ nextCfg, changed, err := editConfigSelection("Default stealth level", "instanceDefaults.stealthLevel", cfg.StealthLevel, config.ValidStealthLevels())
177
+ return nextCfg, changed, false, err
178
+ case "4":
179
+ nextCfg, changed, err := editConfigSelection("Default tab eviction", "instanceDefaults.tabEvictionPolicy", cfg.TabEvictionPolicy, config.ValidEvictionPolicies())
180
+ return nextCfg, changed, false, err
181
+ case "5":
182
+ if err := copyConfigToken(cfg.Token); err != nil {
183
+ return nil, false, false, err
184
+ }
185
+ return nil, false, false, nil
186
+ default:
187
+ return nil, false, false, fmt.Errorf("invalid selection %q", choice)
188
+ }
189
+ }
190
+
191
+ func editConfigSelection(title, path, current string, values []string) (*config.RuntimeConfig, bool, error) {
192
+ options := make([]menuOption, 0, len(values)+1)
193
+ for _, value := range values {
194
+ label := value
195
+ if value == current {
196
+ label += " (current)"
197
+ }
198
+ options = append(options, menuOption{label: label, value: value})
199
+ }
200
+ options = append(options, menuOption{label: "Cancel", value: "cancel"})
201
+
202
+ picked, err := promptSelect(title, options)
203
+ if err != nil {
204
+ return nil, false, err
205
+ }
206
+ if picked == "" || picked == "cancel" {
207
+ return nil, false, nil
208
+ }
209
+
210
+ nextCfg, changed, err := updateConfigValue(path, picked)
211
+ if err != nil {
212
+ return nil, false, err
213
+ }
214
+ if changed {
215
+ fmt.Println(cli.StyleStdout(cli.SuccessStyle, fmt.Sprintf("Updated %s to %s", path, picked)))
216
+ fmt.Println(cli.StyleStdout(cli.MutedStyle, "Restart PinchTab to apply file-based changes."))
217
+ }
218
+ return nextCfg, changed, nil
219
+ }
220
+
221
+ func copyConfigToken(token string) error {
222
+ if strings.TrimSpace(token) == "" {
223
+ return fmt.Errorf("server token is empty")
224
+ }
225
+
226
+ if err := copyToClipboard(token); err == nil {
227
+ fmt.Println(cli.StyleStdout(cli.SuccessStyle, "Token copied to clipboard."))
228
+ return nil
229
+ }
230
+
231
+ fmt.Println(cli.StyleStdout(cli.WarningStyle, "Clipboard unavailable; copy the token manually:"))
232
+ fmt.Println(cli.StyleStdout(cli.ValueStyle, token))
233
+ return nil
234
+ }
235
+
236
+ func copyToClipboard(text string) error {
237
+ candidates := clipboardCommands()
238
+ var lastErr error
239
+
240
+ for _, candidate := range candidates {
241
+ if _, err := exec.LookPath(candidate.name); err != nil {
242
+ lastErr = err
243
+ continue
244
+ }
245
+ cmd := clipboardExecCommand(candidate.name, candidate.args...)
246
+ cmd.Stdin = strings.NewReader(text)
247
+ if output, err := cmd.CombinedOutput(); err != nil {
248
+ if len(strings.TrimSpace(string(output))) > 0 {
249
+ lastErr = fmt.Errorf("%s: %s", err, strings.TrimSpace(string(output)))
250
+ } else {
251
+ lastErr = err
252
+ }
253
+ continue
254
+ }
255
+ return nil
256
+ }
257
+
258
+ if lastErr == nil {
259
+ return fmt.Errorf("no clipboard command available")
260
+ }
261
+ return lastErr
262
+ }
263
+
264
+ type clipboardCommand struct {
265
+ name string
266
+ args []string
267
+ }
268
+
269
+ func clipboardCommands() []clipboardCommand {
270
+ switch runtime.GOOS {
271
+ case "darwin":
272
+ return []clipboardCommand{{name: "pbcopy"}}
273
+ case "windows":
274
+ return []clipboardCommand{{name: "clip"}}
275
+ default:
276
+ return []clipboardCommand{
277
+ {name: "wl-copy"},
278
+ {name: "xclip", args: []string{"-selection", "clipboard"}},
279
+ {name: "xsel", args: []string{"--clipboard", "--input"}},
280
+ }
281
+ }
282
+ }
283
+
284
+ func handleConfigInit() {
285
+ configPath := os.Getenv("PINCHTAB_CONFIG")
286
+ if configPath == "" {
287
+ configPath = config.DefaultConfigPath()
288
+ }
289
+
290
+ if _, err := os.Stat(configPath); err == nil {
291
+ fmt.Printf("Config file already exists at %s\n", configPath)
292
+ fmt.Print("Overwrite? (y/N): ")
293
+ var response string
294
+ _, _ = fmt.Scanln(&response)
295
+ if response != "y" && response != "Y" {
296
+ return
297
+ }
298
+ }
299
+
300
+ if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
301
+ fmt.Printf("Error creating directory: %v\n", err)
302
+ os.Exit(1)
303
+ }
304
+
305
+ fc := config.DefaultFileConfig()
306
+ token, err := config.GenerateAuthToken()
307
+ if err != nil {
308
+ fmt.Printf("Error generating auth token: %v\n", err)
309
+ os.Exit(1)
310
+ }
311
+ fc.Server.Token = token
312
+
313
+ if err := config.SaveFileConfig(&fc, configPath); err != nil {
314
+ fmt.Printf("Error writing config: %v\n", err)
315
+ os.Exit(1)
316
+ }
317
+
318
+ fmt.Printf("Config file created at %s\n", configPath)
319
+ }
320
+
321
+ func handleConfigPath() {
322
+ configPath := os.Getenv("PINCHTAB_CONFIG")
323
+ if configPath == "" {
324
+ configPath = config.DefaultConfigPath()
325
+ }
326
+ fmt.Println(configPath)
327
+ }
328
+
329
+ func handleConfigGet(path string) {
330
+ fc, _, err := config.LoadFileConfig()
331
+ if err != nil {
332
+ fmt.Printf("Error loading config: %v\n", err)
333
+ os.Exit(1)
334
+ }
335
+
336
+ value, err := config.GetConfigValue(fc, path)
337
+ if err != nil {
338
+ fmt.Printf("Error: %v\n", err)
339
+ os.Exit(1)
340
+ }
341
+
342
+ fmt.Println(value)
343
+ }
344
+
345
+ func handleConfigSet(path, value string) {
346
+ fc, configPath, err := config.LoadFileConfig()
347
+ if err != nil {
348
+ fmt.Printf("Error loading config: %v\n", err)
349
+ os.Exit(1)
350
+ }
351
+
352
+ if err := config.SetConfigValue(fc, path, value); err != nil {
353
+ fmt.Printf("Error: %v\n", err)
354
+ os.Exit(1)
355
+ }
356
+
357
+ if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
358
+ fmt.Printf("Warning: new value causes validation error(s):\n")
359
+ for _, err := range errs {
360
+ fmt.Printf(" - %v\n", err)
361
+ }
362
+ fmt.Print("Save anyway? (y/N): ")
363
+ var response string
364
+ _, _ = fmt.Scanln(&response)
365
+ if response != "y" && response != "Y" {
366
+ return
367
+ }
368
+ }
369
+
370
+ if err := config.SaveFileConfig(fc, configPath); err != nil {
371
+ fmt.Printf("Error saving config: %v\n", err)
372
+ os.Exit(1)
373
+ }
374
+
375
+ fmt.Printf("Set %s = %s\n", path, value)
376
+ }
377
+
378
+ func handleConfigPatch(jsonPatch string) {
379
+ fc, configPath, err := config.LoadFileConfig()
380
+ if err != nil {
381
+ fmt.Printf("Error loading config: %v\n", err)
382
+ os.Exit(1)
383
+ }
384
+
385
+ if err := config.PatchConfigJSON(fc, jsonPatch); err != nil {
386
+ fmt.Printf("Error: %v\n", err)
387
+ os.Exit(1)
388
+ }
389
+
390
+ if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
391
+ fmt.Printf("Warning: patch causes validation error(s):\n")
392
+ for _, err := range errs {
393
+ fmt.Printf(" - %v\n", err)
394
+ }
395
+ fmt.Print("Save anyway? (y/N): ")
396
+ var response string
397
+ _, _ = fmt.Scanln(&response)
398
+ if response != "y" && response != "Y" {
399
+ return
400
+ }
401
+ }
402
+
403
+ if err := config.SaveFileConfig(fc, configPath); err != nil {
404
+ fmt.Printf("Error saving config: %v\n", err)
405
+ os.Exit(1)
406
+ }
407
+
408
+ fmt.Println("Config patched successfully")
409
+ }
410
+
411
+ func handleConfigValidate() {
412
+ configPath := os.Getenv("PINCHTAB_CONFIG")
413
+ if configPath == "" {
414
+ configPath = config.DefaultConfigPath()
415
+ }
416
+ data, err := os.ReadFile(configPath)
417
+ if err != nil {
418
+ fmt.Printf("Error reading config file: %v\n", err)
419
+ os.Exit(1)
420
+ }
421
+
422
+ fc := &config.FileConfig{}
423
+ if err := json.Unmarshal(data, fc); err != nil {
424
+ fmt.Printf("Error parsing config: %v\n", err)
425
+ os.Exit(1)
426
+ }
427
+
428
+ if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
429
+ fmt.Printf("Config file has %d error(s):\n", len(errs))
430
+ for _, err := range errs {
431
+ fmt.Printf(" - %v\n", err)
432
+ }
433
+ os.Exit(1)
434
+ }
435
+
436
+ fmt.Printf("Config file is valid: %s\n", configPath)
437
+ }
cmd/pinchtab/cmd_config_test.go ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/pinchtab/pinchtab/internal/config"
8
+ )
9
+
10
+ func TestRenderConfigOverview(t *testing.T) {
11
+ cfg := &config.RuntimeConfig{
12
+ Port: "9867",
13
+ Strategy: "simple",
14
+ AllocationPolicy: "fcfs",
15
+ StealthLevel: "light",
16
+ TabEvictionPolicy: "close_lru",
17
+ Token: "very-long-token-secret",
18
+ }
19
+ output := renderConfigOverview(cfg, "/tmp/pinchtab/config.json", "http://localhost:9867", false)
20
+
21
+ required := []string{
22
+ "Config",
23
+ "Strategy",
24
+ "Allocation policy",
25
+ "Stealth level",
26
+ "Tab eviction",
27
+ "Copy token",
28
+ "More",
29
+ "/tmp/pinchtab/config.json",
30
+ "very...cret",
31
+ "Dashboard:",
32
+ }
33
+ for _, needle := range required {
34
+ if !strings.Contains(output, needle) {
35
+ t.Fatalf("expected config overview to contain %q\n%s", needle, output)
36
+ }
37
+ }
38
+ }
39
+
40
+ func TestClipboardCommands(t *testing.T) {
41
+ commands := clipboardCommands()
42
+ if len(commands) == 0 {
43
+ t.Fatal("expected clipboard commands")
44
+ }
45
+ for _, command := range commands {
46
+ if command.name == "" {
47
+ t.Fatalf("clipboard command missing name: %+v", command)
48
+ }
49
+ }
50
+ }
cmd/pinchtab/cmd_daemon.go ADDED
@@ -0,0 +1,700 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "os"
7
+ "os/exec"
8
+ "os/user"
9
+ "path/filepath"
10
+ "runtime"
11
+ "strings"
12
+
13
+ "github.com/pinchtab/pinchtab/internal/cli"
14
+ "github.com/pinchtab/pinchtab/internal/config"
15
+ "github.com/spf13/cobra"
16
+ )
17
+
18
+ const (
19
+ pinchtabDaemonUnitName = "pinchtab.service"
20
+ pinchtabLaunchdLabel = "com.pinchtab.pinchtab"
21
+ )
22
+
23
+ var daemonCmd = &cobra.Command{
24
+ Use: "daemon [action]",
25
+ Short: "Manage the background service",
26
+ Long: "Start, stop, install, or check the status of the PinchTab background service.",
27
+ Run: func(cmd *cobra.Command, args []string) {
28
+ cfg := config.Load()
29
+ sub := ""
30
+ if len(args) > 0 {
31
+ sub = args[0]
32
+ }
33
+ handleDaemonCommand(cfg, sub)
34
+ },
35
+ }
36
+
37
+ func init() {
38
+ daemonCmd.GroupID = "primary"
39
+ rootCmd.AddCommand(daemonCmd)
40
+ }
41
+
42
+ func handleDaemonCommand(_ *config.RuntimeConfig, subcommand string) {
43
+ if subcommand == "" || subcommand == "help" || subcommand == "--help" || subcommand == "-h" {
44
+ printDaemonStatusSummary()
45
+
46
+ if subcommand == "" && isInteractiveTerminal() {
47
+ picked, err := promptSelect("Daemon Actions", daemonMenuOptions(cli.IsDaemonInstalled(), cli.IsDaemonRunning()))
48
+ if err != nil || picked == "exit" || picked == "" {
49
+ os.Exit(0)
50
+ }
51
+ subcommand = picked
52
+ } else {
53
+ daemonUsage()
54
+ if subcommand == "" {
55
+ os.Exit(0)
56
+ }
57
+ return
58
+ }
59
+ }
60
+
61
+ manager, err := currentDaemonManager()
62
+ if err != nil {
63
+ fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
64
+ os.Exit(1)
65
+ }
66
+
67
+ switch subcommand {
68
+ case "install":
69
+ configPath, fileCfg, _, err := ensureDaemonConfig(false)
70
+ if err != nil {
71
+ fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, fmt.Sprintf("daemon install failed: %v", err)))
72
+ os.Exit(1)
73
+ }
74
+ // Run wizard if needed (first install or version upgrade)
75
+ if config.NeedsWizard(fileCfg) {
76
+ isNew := config.IsFirstRun(fileCfg)
77
+ runSecurityWizard(fileCfg, configPath, isNew)
78
+ }
79
+ if err := manager.Preflight(); err != nil {
80
+ fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, fmt.Sprintf("daemon install unavailable: %v", err)))
81
+ os.Exit(1)
82
+ }
83
+ message, err := manager.Install(managerEnvironment(manager).execPath, configPath)
84
+ if err != nil {
85
+ fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, fmt.Sprintf("daemon install failed: %v", err)))
86
+ fmt.Println()
87
+ fmt.Println(manager.ManualInstructions())
88
+ os.Exit(1)
89
+ }
90
+ fmt.Println(cli.StyleStdout(cli.SuccessStyle, " [ok] ") + message)
91
+ printDaemonFollowUp()
92
+ case "start":
93
+ printDaemonManagerResult(manager.Start())
94
+ case "restart":
95
+ printDaemonManagerResult(manager.Restart())
96
+ case "stop":
97
+ printDaemonManagerResult(manager.Stop())
98
+ case "uninstall":
99
+ message, err := manager.Uninstall()
100
+ if err != nil {
101
+ fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
102
+ fmt.Println()
103
+ fmt.Println(manager.ManualInstructions())
104
+ os.Exit(1)
105
+ }
106
+ fmt.Println(cli.StyleStdout(cli.SuccessStyle, " [ok] ") + message)
107
+ default:
108
+ fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, fmt.Sprintf("unknown daemon command: %s", subcommand)))
109
+ daemonUsage()
110
+ os.Exit(2)
111
+ }
112
+ }
113
+
114
+ func daemonUsage() {
115
+ fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Usage:") + " " + cli.StyleStdout(cli.CommandStyle, "pinchtab daemon <install|start|restart|stop|uninstall>"))
116
+ fmt.Println()
117
+ fmt.Println(cli.StyleStdout(cli.MutedStyle, "Manage the PinchTab user-level background service."))
118
+ fmt.Println()
119
+ }
120
+
121
+ func daemonMenuOptions(installed, running bool) []menuOption {
122
+ options := make([]menuOption, 0, 4)
123
+ switch {
124
+ case !installed:
125
+ options = append(options, menuOption{label: "Install service", value: "install"})
126
+ case running:
127
+ options = append(options,
128
+ menuOption{label: "Stop service", value: "stop"},
129
+ menuOption{label: "Restart service", value: "restart"},
130
+ menuOption{label: "Uninstall service", value: "uninstall"},
131
+ )
132
+ default:
133
+ options = append(options,
134
+ menuOption{label: "Start service", value: "start"},
135
+ menuOption{label: "Uninstall service", value: "uninstall"},
136
+ )
137
+ }
138
+ options = append(options, menuOption{label: "Exit", value: "exit"})
139
+ return options
140
+ }
141
+
142
+ func printDaemonStatusSummary() {
143
+ manager, err := currentDaemonManager()
144
+ if err != nil {
145
+ fmt.Println(cli.StyleStdout(cli.ErrorStyle, " Error: ") + err.Error())
146
+ return
147
+ }
148
+
149
+ installed := cli.IsDaemonInstalled()
150
+ running := cli.IsDaemonRunning()
151
+
152
+ fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Daemon status:"))
153
+
154
+ status := cli.StyleStdout(cli.WarningStyle, "not installed")
155
+ if installed {
156
+ status = cli.StyleStdout(cli.SuccessStyle, "installed")
157
+ }
158
+ fmt.Printf(" %-12s %s\n", cli.StyleStdout(cli.MutedStyle, "Service:"), status)
159
+
160
+ state := cli.StyleStdout(cli.MutedStyle, "stopped")
161
+ if running {
162
+ state = cli.StyleStdout(cli.SuccessStyle, "active (running)")
163
+ }
164
+ fmt.Printf(" %-12s %s\n", cli.StyleStdout(cli.MutedStyle, "State:"), state)
165
+
166
+ if running {
167
+ pid, _ := manager.Pid()
168
+ if pid != "" {
169
+ fmt.Printf(" %-12s %s\n", cli.StyleStdout(cli.MutedStyle, "PID:"), cli.StyleStdout(cli.ValueStyle, pid))
170
+ }
171
+ }
172
+
173
+ if installed {
174
+ fmt.Printf(" %-12s %s\n", cli.StyleStdout(cli.MutedStyle, "Path:"), cli.StyleStdout(cli.ValueStyle, manager.ServicePath()))
175
+ }
176
+ if err := manager.Preflight(); err != nil {
177
+ fmt.Printf(" %-12s %s\n", cli.StyleStdout(cli.MutedStyle, "Environment:"), cli.StyleStdout(cli.WarningStyle, err.Error()))
178
+ }
179
+
180
+ if installed {
181
+ logs, err := manager.Logs(5)
182
+ if err == nil && strings.TrimSpace(logs) != "" {
183
+ fmt.Println()
184
+ fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Recent logs:"))
185
+ lines := strings.Split(logs, "\n")
186
+ for _, line := range lines {
187
+ if strings.TrimSpace(line) != "" {
188
+ fmt.Printf(" %s\n", cli.StyleStdout(cli.MutedStyle, line))
189
+ }
190
+ }
191
+ }
192
+ }
193
+ fmt.Println()
194
+ }
195
+
196
+ func printDaemonManagerResult(message string, err error) {
197
+ if err != nil {
198
+ fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
199
+ os.Exit(1)
200
+ }
201
+ if strings.HasPrefix(message, "Installed") || strings.HasPrefix(message, "Pinchtab daemon") {
202
+ fmt.Println(cli.StyleStdout(cli.SuccessStyle, " [ok] ") + message)
203
+ } else {
204
+ // For status, it might be a block of text
205
+ fmt.Println(message)
206
+ }
207
+ }
208
+
209
+ func ensureDaemonConfig(force bool) (string, *config.FileConfig, configBootstrapStatus, error) {
210
+ _, configPath, err := config.LoadFileConfig()
211
+ if err != nil {
212
+ return "", nil, "", err
213
+ }
214
+
215
+ exists := fileExists(configPath)
216
+ if !exists || force {
217
+ defaults := config.DefaultFileConfig()
218
+ defaults.ConfigVersion = "" // Leave empty so wizard triggers on first install
219
+ token, err := config.GenerateAuthToken()
220
+ if err != nil {
221
+ return "", nil, "", err
222
+ }
223
+ defaults.Server.Token = token
224
+ if err := config.SaveFileConfig(&defaults, configPath); err != nil {
225
+ return "", nil, "", err
226
+ }
227
+ status := configCreated
228
+ if exists {
229
+ status = configRecovered
230
+ }
231
+ return configPath, &defaults, status, nil
232
+ }
233
+
234
+ // File exists — load it as-is, don't overwrite security settings.
235
+ // Security recovery is now handled by the wizard or `pinchtab security up`.
236
+ fileCfg, _, _ := config.LoadFileConfig()
237
+ if fileCfg == nil {
238
+ return configPath, nil, "", fmt.Errorf("failed to load existing config at %s", configPath)
239
+ }
240
+
241
+ // Only generate a token if one is missing (security essential)
242
+ if strings.TrimSpace(fileCfg.Server.Token) == "" {
243
+ token, err := config.GenerateAuthToken()
244
+ if err == nil {
245
+ fileCfg.Server.Token = token
246
+ _ = config.SaveFileConfig(fileCfg, configPath)
247
+ return configPath, fileCfg, configRecovered, nil
248
+ }
249
+ }
250
+
251
+ return configPath, fileCfg, configVerified, nil
252
+ }
253
+
254
+ type configBootstrapStatus string
255
+
256
+ const (
257
+ configCreated configBootstrapStatus = "created"
258
+ configRecovered configBootstrapStatus = "recovered"
259
+ configVerified configBootstrapStatus = "verified"
260
+ )
261
+
262
+ type commandRunner interface {
263
+ CombinedOutput(name string, arg ...string) ([]byte, error)
264
+ }
265
+
266
+ type osCommandRunner struct{}
267
+
268
+ func (r osCommandRunner) CombinedOutput(name string, arg ...string) ([]byte, error) {
269
+ return exec.Command(name, arg...).CombinedOutput() // #nosec G204 -- args are daemon manager controlled, not user input
270
+ }
271
+
272
+ type daemonEnvironment struct {
273
+ execPath string
274
+ homeDir string
275
+ osName string
276
+ userID string
277
+ xdgConfigHome string
278
+ }
279
+
280
+ type daemonManager interface {
281
+ Preflight() error
282
+ Install(execPath, configPath string) (string, error)
283
+ ServicePath() string
284
+ Start() (string, error)
285
+ Restart() (string, error)
286
+ Status() (string, error)
287
+ Stop() (string, error)
288
+ Uninstall() (string, error)
289
+ ManualInstructions() string
290
+ Pid() (string, error)
291
+ Logs(n int) (string, error)
292
+ }
293
+
294
+ type systemdUserManager struct {
295
+ env daemonEnvironment
296
+ runner commandRunner
297
+ }
298
+
299
+ type launchdManager struct {
300
+ env daemonEnvironment
301
+ runner commandRunner
302
+ }
303
+
304
+ func printDaemonFollowUp() {
305
+ fmt.Println()
306
+ fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Follow-up commands:"))
307
+ fmt.Printf(" %s %s\n", cli.StyleStdout(cli.CommandStyle, "pinchtab daemon"), cli.StyleStdout(cli.MutedStyle, "# Check service health and logs"))
308
+ fmt.Printf(" %s %s\n", cli.StyleStdout(cli.CommandStyle, "pinchtab daemon restart"), cli.StyleStdout(cli.MutedStyle, "# Apply config changes"))
309
+ fmt.Printf(" %s %s\n", cli.StyleStdout(cli.CommandStyle, "pinchtab daemon stop"), cli.StyleStdout(cli.MutedStyle, "# Stop background service"))
310
+ fmt.Printf(" %s %s\n", cli.StyleStdout(cli.CommandStyle, "pinchtab daemon uninstall"), cli.StyleStdout(cli.MutedStyle, "# Remove service file"))
311
+ }
312
+
313
+ func currentDaemonManager() (daemonManager, error) {
314
+ env, err := currentDaemonEnvironment()
315
+ if err != nil {
316
+ return nil, err
317
+ }
318
+ return newDaemonManager(env, osCommandRunner{})
319
+ }
320
+
321
+ func currentDaemonEnvironment() (daemonEnvironment, error) {
322
+ execPath, err := os.Executable()
323
+ if err != nil {
324
+ return daemonEnvironment{}, fmt.Errorf("resolve executable path: %w", err)
325
+ }
326
+ homeDir, err := os.UserHomeDir()
327
+ if err != nil {
328
+ return daemonEnvironment{}, fmt.Errorf("resolve home directory: %w", err)
329
+ }
330
+ currentUser, err := user.Current()
331
+ if err != nil {
332
+ return daemonEnvironment{}, fmt.Errorf("resolve current user: %w", err)
333
+ }
334
+
335
+ return daemonEnvironment{
336
+ execPath: execPath,
337
+ homeDir: homeDir,
338
+ osName: runtime.GOOS,
339
+ userID: currentUser.Uid,
340
+ xdgConfigHome: os.Getenv("XDG_CONFIG_HOME"),
341
+ }, nil
342
+ }
343
+
344
+ func newDaemonManager(env daemonEnvironment, runner commandRunner) (daemonManager, error) {
345
+ switch env.osName {
346
+ case "linux":
347
+ return &systemdUserManager{env: env, runner: runner}, nil
348
+ case "darwin":
349
+ return &launchdManager{env: env, runner: runner}, nil
350
+ default:
351
+ return nil, fmt.Errorf("pinchtab daemon is supported on macOS and Linux; current OS is %s", env.osName)
352
+ }
353
+ }
354
+
355
+ func managerEnvironment(manager daemonManager) daemonEnvironment {
356
+ switch m := manager.(type) {
357
+ case *systemdUserManager:
358
+ return m.env
359
+ case *launchdManager:
360
+ return m.env
361
+ default:
362
+ return daemonEnvironment{}
363
+ }
364
+ }
365
+
366
+ func (m *systemdUserManager) ServicePath() string {
367
+ return filepath.Join(systemdUserConfigHome(m.env), "systemd", "user", pinchtabDaemonUnitName)
368
+ }
369
+
370
+ func (m *systemdUserManager) Preflight() error {
371
+ if _, err := runCommand(m.runner, "systemctl", "--user", "show-environment"); err != nil {
372
+ return fmt.Errorf("linux daemon install requires a working user systemd session (`systemctl --user`): %w", err)
373
+ }
374
+ return nil
375
+ }
376
+
377
+ func (m *systemdUserManager) Install(execPath, configPath string) (string, error) {
378
+ if err := os.MkdirAll(filepath.Dir(m.ServicePath()), 0755); err != nil {
379
+ return "", fmt.Errorf("create systemd user directory: %w", err)
380
+ }
381
+ if err := os.WriteFile(m.ServicePath(), []byte(renderSystemdUnit(execPath, configPath)), 0644); err != nil {
382
+ return "", fmt.Errorf("write systemd unit: %w", err)
383
+ }
384
+ if _, err := runCommand(m.runner, "systemctl", "--user", "daemon-reload"); err != nil {
385
+ return "", err
386
+ }
387
+ if _, err := runCommand(m.runner, "systemctl", "--user", "enable", "--now", pinchtabDaemonUnitName); err != nil {
388
+ return "", err
389
+ }
390
+ return fmt.Sprintf("Installed systemd user service at %s", m.ServicePath()), nil
391
+ }
392
+
393
+ func (m *systemdUserManager) Start() (string, error) {
394
+ if _, err := runCommand(m.runner, "systemctl", "--user", "start", pinchtabDaemonUnitName); err != nil {
395
+ return "", err
396
+ }
397
+ return "Pinchtab daemon started.", nil
398
+ }
399
+
400
+ func (m *systemdUserManager) Restart() (string, error) {
401
+ if _, err := runCommand(m.runner, "systemctl", "--user", "restart", pinchtabDaemonUnitName); err != nil {
402
+ return "", err
403
+ }
404
+ return "Pinchtab daemon restarted.", nil
405
+ }
406
+
407
+ func (m *systemdUserManager) Stop() (string, error) {
408
+ if _, err := runCommand(m.runner, "systemctl", "--user", "stop", pinchtabDaemonUnitName); err != nil {
409
+ return "", err
410
+ }
411
+ return "Pinchtab daemon stopped.", nil
412
+ }
413
+
414
+ func (m *systemdUserManager) Status() (string, error) {
415
+ output, err := runCommand(m.runner, "systemctl", "--user", "status", pinchtabDaemonUnitName, "--no-pager")
416
+ if err != nil {
417
+ return "", err
418
+ }
419
+ if strings.TrimSpace(output) == "" {
420
+ return "Pinchtab daemon status returned no output.", nil
421
+ }
422
+ return output, nil
423
+ }
424
+
425
+ func (m *systemdUserManager) Uninstall() (string, error) {
426
+ var errs []error
427
+ if _, err := runCommand(m.runner, "systemctl", "--user", "disable", "--now", pinchtabDaemonUnitName); err != nil {
428
+ errs = append(errs, err)
429
+ }
430
+ if err := os.Remove(m.ServicePath()); err != nil && !errors.Is(err, os.ErrNotExist) {
431
+ errs = append(errs, fmt.Errorf("remove unit file: %w", err))
432
+ }
433
+ if _, err := runCommand(m.runner, "systemctl", "--user", "daemon-reload"); err != nil {
434
+ errs = append(errs, err)
435
+ }
436
+ if len(errs) > 0 {
437
+ return "", errors.Join(errs...)
438
+ }
439
+ return "Pinchtab daemon uninstalled.", nil
440
+ }
441
+
442
+ func (m *systemdUserManager) Pid() (string, error) {
443
+ output, err := runCommand(m.runner, "systemctl", "--user", "show", pinchtabDaemonUnitName, "--property", "MainPID")
444
+ if err != nil {
445
+ return "", err
446
+ }
447
+ // Output is typically MainPID=1234
448
+ if parts := strings.Split(output, "="); len(parts) == 2 {
449
+ pid := strings.TrimSpace(parts[1])
450
+ if pid == "0" {
451
+ return "", nil // Not running
452
+ }
453
+ return pid, nil
454
+ }
455
+ return "", nil
456
+ }
457
+
458
+ func (m *systemdUserManager) Logs(n int) (string, error) {
459
+ return runCommand(m.runner, "journalctl", "--user", "-u", pinchtabDaemonUnitName, "-n", fmt.Sprintf("%d", n), "--no-pager")
460
+ }
461
+
462
+ func (m *systemdUserManager) ManualInstructions() string {
463
+ path := m.ServicePath()
464
+ var b strings.Builder
465
+ fmt.Fprintln(&b, cli.StyleStdout(cli.HeadingStyle, "Manual instructions (Linux/systemd):"))
466
+ fmt.Fprintln(&b, cli.StyleStdout(cli.MutedStyle, "To install manually:"))
467
+ fmt.Fprintf(&b, " 1. Create %s\n", cli.StyleStdout(cli.ValueStyle, path))
468
+ fmt.Fprintln(&b, " 2. Run: "+cli.StyleStdout(cli.CommandStyle, "systemctl --user daemon-reload"))
469
+ fmt.Fprintln(&b, " 3. Run: "+cli.StyleStdout(cli.CommandStyle, "systemctl --user enable --now pinchtab.service"))
470
+ fmt.Fprintln(&b)
471
+ fmt.Fprintln(&b, cli.StyleStdout(cli.MutedStyle, "To uninstall manually:"))
472
+ fmt.Fprintln(&b, " 1. Run: "+cli.StyleStdout(cli.CommandStyle, "systemctl --user disable --now pinchtab.service"))
473
+ fmt.Fprintf(&b, " 2. Remove: %s\n", cli.StyleStdout(cli.ValueStyle, path))
474
+ fmt.Fprintln(&b, " 3. Run: "+cli.StyleStdout(cli.CommandStyle, "systemctl --user daemon-reload"))
475
+ return b.String()
476
+ }
477
+
478
+ func renderSystemdUnit(execPath, configPath string) string {
479
+ return fmt.Sprintf(`[Unit]
480
+ Description=Pinchtab Browser Service
481
+ After=network.target
482
+
483
+ [Service]
484
+ Type=simple
485
+ ExecStart="%s" server
486
+ Environment="PINCHTAB_CONFIG=%s"
487
+ Restart=always
488
+ RestartSec=5
489
+
490
+ [Install]
491
+ WantedBy=default.target
492
+ `, execPath, configPath)
493
+ }
494
+
495
+ func (m *launchdManager) ServicePath() string {
496
+ return filepath.Join(m.env.homeDir, "Library", "LaunchAgents", pinchtabLaunchdLabel+".plist")
497
+ }
498
+
499
+ func (m *launchdManager) Preflight() error {
500
+ if strings.TrimSpace(m.env.userID) == "" {
501
+ return fmt.Errorf("macOS daemon install requires a logged-in user session with a launchd GUI domain")
502
+ }
503
+ if _, err := runCommand(m.runner, "launchctl", "print", launchdDomainTarget(m.env)); err != nil {
504
+ return fmt.Errorf("macOS daemon install requires an active launchd GUI session: %w", err)
505
+ }
506
+ return nil
507
+ }
508
+
509
+ func (m *launchdManager) Install(execPath, configPath string) (string, error) {
510
+ if err := os.MkdirAll(filepath.Dir(m.ServicePath()), 0755); err != nil {
511
+ return "", fmt.Errorf("create LaunchAgents directory: %w", err)
512
+ }
513
+ if err := os.WriteFile(m.ServicePath(), []byte(renderLaunchdPlist(execPath, configPath)), 0644); err != nil {
514
+ return "", fmt.Errorf("write launchd plist: %w", err)
515
+ }
516
+ _, _ = runCommand(m.runner, "launchctl", "bootout", launchdDomainTarget(m.env), m.ServicePath())
517
+ if _, err := runCommand(m.runner, "launchctl", "bootstrap", launchdDomainTarget(m.env), m.ServicePath()); err != nil {
518
+ return "", err
519
+ }
520
+ if _, err := runCommand(m.runner, "launchctl", "kickstart", "-k", launchdDomainTarget(m.env)+"/"+pinchtabLaunchdLabel); err != nil {
521
+ return "", err
522
+ }
523
+ return fmt.Sprintf("Installed launchd agent at %s", m.ServicePath()), nil
524
+ }
525
+
526
+ func (m *launchdManager) Start() (string, error) {
527
+ if _, err := runCommand(m.runner, "launchctl", "bootstrap", launchdDomainTarget(m.env), m.ServicePath()); err != nil && !strings.Contains(err.Error(), "already bootstrapped") {
528
+ return "", err
529
+ }
530
+ if _, err := runCommand(m.runner, "launchctl", "kickstart", launchdDomainTarget(m.env)+"/"+pinchtabLaunchdLabel); err != nil {
531
+ return "", err
532
+ }
533
+ return "Pinchtab daemon started.", nil
534
+ }
535
+
536
+ func (m *launchdManager) Restart() (string, error) {
537
+ if _, err := runCommand(m.runner, "launchctl", "kickstart", "-k", launchdDomainTarget(m.env)+"/"+pinchtabLaunchdLabel); err != nil {
538
+ return "", err
539
+ }
540
+ return "Pinchtab daemon restarted.", nil
541
+ }
542
+
543
+ func (m *launchdManager) Stop() (string, error) {
544
+ _, err := runCommand(m.runner, "launchctl", "bootout", launchdDomainTarget(m.env), m.ServicePath())
545
+ if err != nil && !isLaunchdIgnorableError(err) {
546
+ return "", err
547
+ }
548
+ return "Pinchtab daemon stopped.", nil
549
+ }
550
+
551
+ func (m *launchdManager) Status() (string, error) {
552
+ output, err := runCommand(m.runner, "launchctl", "print", launchdDomainTarget(m.env)+"/"+pinchtabLaunchdLabel)
553
+ if err != nil {
554
+ return "", err
555
+ }
556
+ if strings.TrimSpace(output) == "" {
557
+ return "Pinchtab daemon status returned no output.", nil
558
+ }
559
+ return output, nil
560
+ }
561
+
562
+ func (m *launchdManager) Uninstall() (string, error) {
563
+ var errs []error
564
+ _, err := runCommand(m.runner, "launchctl", "bootout", launchdDomainTarget(m.env), m.ServicePath())
565
+ if err != nil && !isLaunchdIgnorableError(err) {
566
+ errs = append(errs, err)
567
+ }
568
+ if err := os.Remove(m.ServicePath()); err != nil && !errors.Is(err, os.ErrNotExist) {
569
+ errs = append(errs, fmt.Errorf("remove launchd plist: %w", err))
570
+ }
571
+ if len(errs) > 0 {
572
+ return "", errors.Join(errs...)
573
+ }
574
+ return "Pinchtab daemon uninstalled.", nil
575
+ }
576
+
577
+ func (m *launchdManager) Pid() (string, error) {
578
+ output, err := runCommand(m.runner, "launchctl", "print", launchdDomainTarget(m.env)+"/"+pinchtabLaunchdLabel)
579
+ if err != nil {
580
+ return "", err
581
+ }
582
+ // Try to find pid = 1234
583
+ lines := strings.Split(output, "\n")
584
+ for _, line := range lines {
585
+ trimmed := strings.TrimSpace(line)
586
+ if strings.HasPrefix(trimmed, "pid = ") {
587
+ return strings.TrimPrefix(trimmed, "pid = "), nil
588
+ }
589
+ }
590
+ return "", nil
591
+ }
592
+
593
+ func (m *launchdManager) Logs(n int) (string, error) {
594
+ // macOS log paths we added to plist
595
+ logPath := "/tmp/pinchtab.err.log"
596
+ if _, err := os.Stat(logPath); err != nil {
597
+ return "No logs found at " + logPath, nil
598
+ }
599
+ return runCommand(m.runner, "tail", "-n", fmt.Sprintf("%d", n), logPath)
600
+ }
601
+
602
+ func (m *launchdManager) ManualInstructions() string {
603
+ path := m.ServicePath()
604
+ target := launchdDomainTarget(m.env)
605
+ var b strings.Builder
606
+ fmt.Fprintln(&b, cli.StyleStdout(cli.HeadingStyle, "Manual instructions (macOS/launchd):"))
607
+ fmt.Fprintln(&b, cli.StyleStdout(cli.MutedStyle, "To install manually:"))
608
+ fmt.Fprintf(&b, " 1. Create %s\n", cli.StyleStdout(cli.ValueStyle, path))
609
+ fmt.Fprintln(&b, " 2. Run: "+cli.StyleStdout(cli.CommandStyle, fmt.Sprintf("launchctl bootstrap %s %s", target, path)))
610
+ fmt.Fprintln(&b)
611
+ fmt.Fprintln(&b, cli.StyleStdout(cli.MutedStyle, "To uninstall manually:"))
612
+ fmt.Fprintln(&b, " 1. Run: "+cli.StyleStdout(cli.CommandStyle, fmt.Sprintf("launchctl bootout %s %s", target, path)))
613
+ fmt.Fprintf(&b, " 2. Remove: %s\n", cli.StyleStdout(cli.ValueStyle, path))
614
+ return b.String()
615
+ }
616
+
617
+ func isLaunchdIgnorableError(err error) bool {
618
+ if err == nil {
619
+ return true
620
+ }
621
+ msg := err.Error()
622
+ // Exit status 5 often means already booted out or path not found in domain
623
+ // "No such process" (status 3) or "No such file or directory" (status 2) or "Operation not permitted" (sometimes)
624
+ return strings.Contains(msg, "exit status 5") ||
625
+ strings.Contains(msg, "No such process") ||
626
+ strings.Contains(msg, "not found") ||
627
+ strings.Contains(msg, "already bootstrapped")
628
+ }
629
+
630
+ func renderLaunchdPlist(execPath, configPath string) string {
631
+ return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
632
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
633
+ <plist version="1.0">
634
+ <dict>
635
+ <key>Label</key>
636
+ <string>%s</string>
637
+ <key>ProgramArguments</key>
638
+ <array>
639
+ <string>%s</string>
640
+ <string>server</string>
641
+ </array>
642
+ <key>RunAtLoad</key>
643
+ <true/>
644
+ <key>KeepAlive</key>
645
+ <true/>
646
+ <key>ExitTimeOut</key>
647
+ <integer>10</integer>
648
+ <key>EnvironmentVariables</key>
649
+ <dict>
650
+ <key>PINCHTAB_CONFIG</key>
651
+ <string>%s</string>
652
+ </dict>
653
+ <key>StandardOutPath</key>
654
+ <string>/tmp/pinchtab.out.log</string>
655
+ <key>StandardErrorPath</key>
656
+ <string>/tmp/pinchtab.err.log</string>
657
+ </dict>
658
+ </plist>
659
+ `, pinchtabLaunchdLabel, execPath, configPath)
660
+ }
661
+
662
+ func runCommand(runner commandRunner, name string, args ...string) (string, error) {
663
+ output, err := runner.CombinedOutput(name, args...)
664
+ trimmed := strings.TrimSpace(string(output))
665
+ if err == nil {
666
+ return trimmed, nil
667
+ }
668
+ if trimmed == "" {
669
+ return "", fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
670
+ }
671
+ return "", fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, trimmed)
672
+ }
673
+
674
+ func launchdDomainTarget(env daemonEnvironment) string {
675
+ return "gui/" + env.userID
676
+ }
677
+
678
+ func systemdUserConfigHome(env daemonEnvironment) string {
679
+ if strings.TrimSpace(env.xdgConfigHome) != "" {
680
+ return env.xdgConfigHome
681
+ }
682
+ return filepath.Join(env.homeDir, ".config")
683
+ }
684
+
685
+ func fileExists(path string) bool {
686
+ _, err := os.Stat(path)
687
+ return err == nil
688
+ }
689
+
690
+ func isInteractiveTerminal() bool {
691
+ in, err := os.Stdin.Stat()
692
+ if err != nil || (in.Mode()&os.ModeCharDevice) == 0 {
693
+ return false
694
+ }
695
+ out, err := os.Stdout.Stat()
696
+ if err != nil || (out.Mode()&os.ModeCharDevice) == 0 {
697
+ return false
698
+ }
699
+ return true
700
+ }
cmd/pinchtab/cmd_daemon_test.go ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "errors"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ type fakeCommandRunner struct {
12
+ calls []string
13
+ outputs map[string]string
14
+ errors map[string]error
15
+ }
16
+
17
+ func (f *fakeCommandRunner) CombinedOutput(name string, args ...string) ([]byte, error) {
18
+ call := name + " " + strings.Join(args, " ")
19
+ f.calls = append(f.calls, call)
20
+ if out, ok := f.outputs[call]; ok {
21
+ return []byte(out), f.errors[call]
22
+ }
23
+ return nil, f.errors[call]
24
+ }
25
+
26
+ func TestEnsureOnboardConfigCreatesDefaultConfig(t *testing.T) {
27
+ configPath := filepath.Join(t.TempDir(), "pinchtab", "config.json")
28
+ t.Setenv("PINCHTAB_CONFIG", configPath)
29
+ t.Setenv("PINCHTAB_TOKEN", "")
30
+ t.Setenv("PINCHTAB_BIND", "")
31
+
32
+ gotPath, cfg, status, err := ensureDaemonConfig(false)
33
+ if err != nil {
34
+ t.Fatalf("ensureDaemonConfig returned error: %v", err)
35
+ }
36
+ if status != configCreated {
37
+ t.Fatalf("status = %q, want %q", status, configCreated)
38
+ }
39
+ if gotPath != configPath {
40
+ t.Fatalf("config path = %q, want %q", gotPath, configPath)
41
+ }
42
+ if cfg.Server.Bind != "127.0.0.1" {
43
+ t.Fatalf("bind = %q, want 127.0.0.1", cfg.Server.Bind)
44
+ }
45
+ if strings.TrimSpace(cfg.Server.Token) == "" {
46
+ t.Fatal("expected generated token to be set")
47
+ }
48
+ data, err := os.ReadFile(configPath)
49
+ if err != nil {
50
+ t.Fatalf("reading config file: %v", err)
51
+ }
52
+ content := string(data)
53
+ if !strings.Contains(content, `"bind": "127.0.0.1"`) {
54
+ t.Fatalf("expected config file to include bind, got %s", content)
55
+ }
56
+ if !strings.Contains(content, `"token": "`) {
57
+ t.Fatalf("expected config file to include token, got %s", content)
58
+ }
59
+ }
60
+
61
+ func TestEnsureOnboardConfigRecoversExistingSecuritySettings(t *testing.T) {
62
+ configPath := filepath.Join(t.TempDir(), "pinchtab", "config.json")
63
+ t.Setenv("PINCHTAB_CONFIG", configPath)
64
+ input := `{
65
+ "server": {
66
+ "bind": "0.0.0.0",
67
+ "port": "9999",
68
+ "token": ""
69
+ },
70
+ "browser": {
71
+ "binary": "/custom/chrome"
72
+ },
73
+ "security": {
74
+ "allowEvaluate": true
75
+ }
76
+ }
77
+ `
78
+ if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
79
+ t.Fatalf("creating config dir: %v", err)
80
+ }
81
+ if err := os.WriteFile(configPath, []byte(input), 0644); err != nil {
82
+ t.Fatalf("writing config file: %v", err)
83
+ }
84
+
85
+ _, cfg, status, err := ensureDaemonConfig(false)
86
+ if err != nil {
87
+ t.Fatalf("ensureDaemonConfig returned error: %v", err)
88
+ }
89
+ // Only token recovery happens now — security settings are preserved for wizard
90
+ if status != configRecovered {
91
+ t.Fatalf("status = %q, want %q", status, configRecovered)
92
+ }
93
+ // Bind is preserved as-is (wizard handles security changes, not daemon config)
94
+ if cfg.Server.Bind != "0.0.0.0" {
95
+ t.Fatalf("bind = %q, want 0.0.0.0 (preserved)", cfg.Server.Bind)
96
+ }
97
+ if cfg.Server.Port != "9999" {
98
+ t.Fatalf("port = %q, want 9999", cfg.Server.Port)
99
+ }
100
+ if cfg.Browser.ChromeBinary != "/custom/chrome" {
101
+ t.Fatalf("chrome binary = %q, want /custom/chrome", cfg.Browser.ChromeBinary)
102
+ }
103
+ // Security settings preserved — not overwritten
104
+ if !boolPtrValue(cfg.Security.AllowEvaluate) {
105
+ t.Fatal("expected allowEvaluate to be preserved as true")
106
+ }
107
+ // Token should be generated (was empty)
108
+ if strings.TrimSpace(cfg.Server.Token) == "" {
109
+ t.Fatal("expected recovery to generate a token")
110
+ }
111
+ }
112
+
113
+ func TestSystemdUserManagerInstallWritesUnitAndEnablesService(t *testing.T) {
114
+ root := t.TempDir()
115
+ runner := &fakeCommandRunner{}
116
+ manager := &systemdUserManager{
117
+ env: daemonEnvironment{
118
+ homeDir: root,
119
+ osName: "linux",
120
+ execPath: "/usr/local/bin/pinchtab",
121
+ xdgConfigHome: filepath.Join(root, ".config"),
122
+ },
123
+ runner: runner,
124
+ }
125
+
126
+ message, err := manager.Install("/usr/local/bin/pinchtab", "/tmp/pinchtab/config.json")
127
+ if err != nil {
128
+ t.Fatalf("Install returned error: %v", err)
129
+ }
130
+ if !strings.Contains(message, manager.ServicePath()) {
131
+ t.Fatalf("install message = %q, want path %q", message, manager.ServicePath())
132
+ }
133
+
134
+ data, err := os.ReadFile(manager.ServicePath())
135
+ if err != nil {
136
+ t.Fatalf("reading service file: %v", err)
137
+ }
138
+ content := string(data)
139
+ if !strings.Contains(content, `ExecStart="/usr/local/bin/pinchtab" server`) {
140
+ t.Fatalf("unexpected unit content: %s", content)
141
+ }
142
+ if !strings.Contains(content, `Environment="PINCHTAB_CONFIG=/tmp/pinchtab/config.json"`) {
143
+ t.Fatalf("expected config env in unit content: %s", content)
144
+ }
145
+
146
+ expectedCalls := []string{
147
+ "systemctl --user daemon-reload",
148
+ "systemctl --user enable --now pinchtab.service",
149
+ }
150
+ if strings.Join(runner.calls, "\n") != strings.Join(expectedCalls, "\n") {
151
+ t.Fatalf("systemd calls = %v, want %v", runner.calls, expectedCalls)
152
+ }
153
+ }
154
+
155
+ func TestLaunchdManagerInstallWritesPlistAndBootstrapsAgent(t *testing.T) {
156
+ root := t.TempDir()
157
+ runner := &fakeCommandRunner{}
158
+ manager := &launchdManager{
159
+ env: daemonEnvironment{
160
+ homeDir: root,
161
+ osName: "darwin",
162
+ execPath: "/Applications/Pinchtab.app/Contents/MacOS/pinchtab",
163
+ userID: "501",
164
+ },
165
+ runner: runner,
166
+ }
167
+
168
+ message, err := manager.Install("/Applications/Pinchtab.app/Contents/MacOS/pinchtab", "/tmp/pinchtab/config.json")
169
+ if err != nil {
170
+ t.Fatalf("Install returned error: %v", err)
171
+ }
172
+ if !strings.Contains(message, manager.ServicePath()) {
173
+ t.Fatalf("install message = %q, want path %q", message, manager.ServicePath())
174
+ }
175
+
176
+ data, err := os.ReadFile(manager.ServicePath())
177
+ if err != nil {
178
+ t.Fatalf("reading launchd plist: %v", err)
179
+ }
180
+ content := string(data)
181
+ if !strings.Contains(content, "<string>com.pinchtab.pinchtab</string>") {
182
+ t.Fatalf("expected launchd label in plist: %s", content)
183
+ }
184
+ if !strings.Contains(content, "<string>/Applications/Pinchtab.app/Contents/MacOS/pinchtab</string>") {
185
+ t.Fatalf("expected executable path in plist: %s", content)
186
+ }
187
+ if !strings.Contains(content, "<string>/tmp/pinchtab/config.json</string>") {
188
+ t.Fatalf("expected config path in plist: %s", content)
189
+ }
190
+
191
+ expectedCalls := []string{
192
+ "launchctl bootout gui/501 " + manager.ServicePath(),
193
+ "launchctl bootstrap gui/501 " + manager.ServicePath(),
194
+ "launchctl kickstart -k gui/501/com.pinchtab.pinchtab",
195
+ }
196
+ if strings.Join(runner.calls, "\n") != strings.Join(expectedCalls, "\n") {
197
+ t.Fatalf("launchctl calls = %v, want %v", runner.calls, expectedCalls)
198
+ }
199
+ }
200
+
201
+ func TestSystemdUserManagerPreflightRequiresUserSession(t *testing.T) {
202
+ runner := &fakeCommandRunner{
203
+ errors: map[string]error{
204
+ "systemctl --user show-environment": errors.New("exit status 1"),
205
+ },
206
+ }
207
+ manager := &systemdUserManager{
208
+ env: daemonEnvironment{osName: "linux"},
209
+ runner: runner,
210
+ }
211
+
212
+ err := manager.Preflight()
213
+ if err == nil {
214
+ t.Fatal("expected preflight error")
215
+ }
216
+ if !strings.Contains(err.Error(), "working user systemd session") {
217
+ t.Fatalf("unexpected error: %v", err)
218
+ }
219
+ }
220
+
221
+ func TestLaunchdManagerPreflightRequiresGUIDomain(t *testing.T) {
222
+ runner := &fakeCommandRunner{
223
+ errors: map[string]error{
224
+ "launchctl print gui/501": errors.New("exit status 113"),
225
+ },
226
+ }
227
+ manager := &launchdManager{
228
+ env: daemonEnvironment{osName: "darwin", userID: "501"},
229
+ runner: runner,
230
+ }
231
+
232
+ err := manager.Preflight()
233
+ if err == nil {
234
+ t.Fatal("expected preflight error")
235
+ }
236
+ if !strings.Contains(err.Error(), "active launchd GUI session") {
237
+ t.Fatalf("unexpected error: %v", err)
238
+ }
239
+ }
240
+
241
+ func TestNewDaemonManagerRejectsUnsupportedOS(t *testing.T) {
242
+ _, err := newDaemonManager(daemonEnvironment{osName: "windows"}, &fakeCommandRunner{})
243
+ if err == nil {
244
+ t.Fatal("expected unsupported OS error")
245
+ }
246
+ }
247
+
248
+ func TestDaemonMenuOptions(t *testing.T) {
249
+ tests := []struct {
250
+ name string
251
+ installed bool
252
+ running bool
253
+ want []string
254
+ }{
255
+ {
256
+ name: "not installed",
257
+ installed: false,
258
+ running: false,
259
+ want: []string{"install", "exit"},
260
+ },
261
+ {
262
+ name: "installed stopped",
263
+ installed: true,
264
+ running: false,
265
+ want: []string{"start", "uninstall", "exit"},
266
+ },
267
+ {
268
+ name: "installed running",
269
+ installed: true,
270
+ running: true,
271
+ want: []string{"stop", "restart", "uninstall", "exit"},
272
+ },
273
+ }
274
+
275
+ for _, tt := range tests {
276
+ t.Run(tt.name, func(t *testing.T) {
277
+ got := daemonMenuOptions(tt.installed, tt.running)
278
+ if len(got) != len(tt.want) {
279
+ t.Fatalf("len(daemonMenuOptions()) = %d, want %d", len(got), len(tt.want))
280
+ }
281
+ for i, want := range tt.want {
282
+ if got[i].value != want {
283
+ t.Fatalf("daemonMenuOptions()[%d] = %q, want %q", i, got[i].value, want)
284
+ }
285
+ }
286
+ })
287
+ }
288
+ }
cmd/pinchtab/cmd_mcp.go ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "strings"
7
+
8
+ "github.com/pinchtab/pinchtab/internal/config"
9
+ "github.com/pinchtab/pinchtab/internal/mcp"
10
+ "github.com/spf13/cobra"
11
+ )
12
+
13
+ var mcpCmd = &cobra.Command{
14
+ Use: "mcp",
15
+ Short: "Start the MCP stdio server",
16
+ Long: "Start the Model Context Protocol stdio server and proxy browser actions to a running PinchTab instance.",
17
+ Run: func(cmd *cobra.Command, args []string) {
18
+ cfg := config.Load()
19
+ runMCP(cfg)
20
+ },
21
+ }
22
+
23
+ func init() {
24
+ mcpCmd.GroupID = "primary"
25
+ rootCmd.AddCommand(mcpCmd)
26
+ }
27
+
28
+ func runMCP(cfg *config.RuntimeConfig) {
29
+ // Default: http://127.0.0.1:{port}
30
+ port := cfg.Port
31
+ if port == "" {
32
+ port = "9867"
33
+ }
34
+ baseURL := "http://127.0.0.1:" + port
35
+
36
+ // --server flag overrides
37
+ if serverURL != "" {
38
+ baseURL = strings.TrimRight(serverURL, "/")
39
+ }
40
+
41
+ // Token from config, env var overrides
42
+ token := cfg.Token
43
+ if envToken := os.Getenv("PINCHTAB_TOKEN"); envToken != "" {
44
+ token = envToken
45
+ }
46
+
47
+ mcp.Version = version
48
+
49
+ if err := mcp.Serve(baseURL, token); err != nil {
50
+ fmt.Fprintf(os.Stderr, "mcp server error: %v\n", err)
51
+ os.Exit(1)
52
+ }
53
+ }
cmd/pinchtab/cmd_security.go ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "slices"
8
+ "strings"
9
+
10
+ "github.com/pinchtab/pinchtab/internal/cli"
11
+ "github.com/pinchtab/pinchtab/internal/config"
12
+ "github.com/spf13/cobra"
13
+ )
14
+
15
+ var securityCmd = &cobra.Command{
16
+ Use: "security",
17
+ Short: "Review runtime security posture",
18
+ Long: "Shows runtime security posture and offers to restore recommended security defaults.",
19
+ Run: func(cmd *cobra.Command, args []string) {
20
+ cfg := loadConfig()
21
+ handleSecurityCommand(cfg)
22
+ },
23
+ }
24
+
25
+ func init() {
26
+ securityCmd.GroupID = "config"
27
+ securityCmd.AddCommand(&cobra.Command{
28
+ Use: "up",
29
+ Short: "Apply recommended security defaults",
30
+ Run: func(cmd *cobra.Command, args []string) {
31
+ handleSecurityUpCommand()
32
+ },
33
+ })
34
+ securityCmd.AddCommand(&cobra.Command{
35
+ Use: "down",
36
+ Short: "Lower guards while keeping loopback bind and API auth enabled",
37
+ Run: func(cmd *cobra.Command, args []string) {
38
+ handleSecurityDownCommand()
39
+ },
40
+ })
41
+ rootCmd.AddCommand(securityCmd)
42
+ }
43
+
44
+ func handleSecurityCommand(cfg *config.RuntimeConfig) {
45
+ interactive := isInteractiveTerminal()
46
+
47
+ for {
48
+ posture := cli.AssessSecurityPosture(cfg)
49
+ warnings := cli.AssessSecurityWarnings(cfg)
50
+ recommended := cli.RecommendedSecurityDefaultLines(cfg)
51
+
52
+ printSecuritySummary(posture, interactive)
53
+
54
+ if len(warnings) > 0 {
55
+ fmt.Println()
56
+ fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Warnings"))
57
+ fmt.Println()
58
+ for _, warning := range warnings {
59
+ fmt.Printf(" - %s\n", cli.StyleStdout(cli.WarningStyle, warning.Message))
60
+ for i := 0; i+1 < len(warning.Attrs); i += 2 {
61
+ key, ok := warning.Attrs[i].(string)
62
+ if !ok || key == "hint" {
63
+ continue
64
+ }
65
+ fmt.Printf(" %s: %s\n", cli.StyleStdout(cli.MutedStyle, key), cli.StyleStdout(cli.ValueStyle, formatSecurityValue(warning.Attrs[i+1])))
66
+ }
67
+ for i := 0; i+1 < len(warning.Attrs); i += 2 {
68
+ key, ok := warning.Attrs[i].(string)
69
+ if ok && key == "hint" {
70
+ fmt.Printf(" %s: %s\n", cli.StyleStdout(cli.MutedStyle, "hint"), cli.StyleStdout(cli.ValueStyle, formatSecurityValue(warning.Attrs[i+1])))
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ if len(recommended) == 0 && len(warnings) == 0 {
77
+ fmt.Println()
78
+ fmt.Println(" " + cli.StyleStdout(cli.SuccessStyle, "All recommended security defaults are active."))
79
+ } else if len(recommended) > 0 {
80
+ fmt.Println()
81
+ fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Recommended defaults"))
82
+ fmt.Println()
83
+ printRecommendedSecurityDefaults(recommended)
84
+ }
85
+
86
+ if !interactive {
87
+ if len(recommended) > 0 {
88
+ fmt.Println()
89
+ fmt.Println(cli.StyleStdout(cli.MutedStyle, "Interactive editing skipped because stdin/stdout is not a terminal."))
90
+ }
91
+ return
92
+ }
93
+
94
+ nextCfg, changed, done, err := promptSecurityEdit(cfg, posture, len(recommended) > 0)
95
+ if err != nil {
96
+ fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
97
+ os.Exit(1)
98
+ }
99
+ if done {
100
+ return
101
+ }
102
+ if !changed {
103
+ fmt.Println()
104
+ fmt.Println(cli.StyleStdout(cli.MutedStyle, "No changes made."))
105
+ return
106
+ }
107
+ cfg = nextCfg
108
+ fmt.Println()
109
+ }
110
+ }
111
+
112
+ func formatSecurityValue(value any) string {
113
+ switch v := value.(type) {
114
+ case []string:
115
+ return strings.Join(v, ", ")
116
+ default:
117
+ return fmt.Sprint(v)
118
+ }
119
+ }
120
+
121
+ func printRecommendedSecurityDefaults(lines []string) {
122
+ for _, line := range lines {
123
+ fmt.Printf(" - %s\n", cli.StyleStdout(cli.ValueStyle, line))
124
+ }
125
+ }
126
+
127
+ func printSecuritySummary(posture cli.SecurityPosture, interactive bool) {
128
+ fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Security"))
129
+ fmt.Println()
130
+ fmt.Printf(" %s %s\n", posture.Bar, cli.StyleStdout(cli.ValueStyle, posture.Level))
131
+ for i, check := range posture.Checks {
132
+ indicator := cli.StyleStdout(cli.WarningStyle, "!!")
133
+ if check.Passed {
134
+ indicator = cli.StyleStdout(cli.SuccessStyle, "ok")
135
+ }
136
+ if interactive {
137
+ fmt.Printf(" %d. %s %-20s %s\n", i+1, indicator, check.Label, check.Detail)
138
+ continue
139
+ }
140
+ fmt.Printf(" %s %-20s %s\n", indicator, check.Label, check.Detail)
141
+ }
142
+ }
143
+
144
+ func promptSecurityEdit(cfg *config.RuntimeConfig, posture cli.SecurityPosture, canRestoreDefaults bool) (*config.RuntimeConfig, bool, bool, error) {
145
+ fmt.Println()
146
+ prompt := "Edit item (1-8"
147
+ if canRestoreDefaults {
148
+ prompt += ", u = security up"
149
+ }
150
+ prompt += ", d = security down, blank to exit):"
151
+
152
+ choice, err := promptInput(cli.StyleStdout(cli.HeadingStyle, prompt), "")
153
+ if err != nil {
154
+ return nil, false, false, err
155
+ }
156
+ choice = strings.ToLower(strings.TrimSpace(choice))
157
+ if choice == "" {
158
+ return nil, false, true, nil
159
+ }
160
+
161
+ if (choice == "u" || choice == "up") && canRestoreDefaults {
162
+ nextCfg, changed, err := applySecurityUp()
163
+ return nextCfg, changed, false, err
164
+ }
165
+
166
+ if choice == "d" || choice == "down" {
167
+ nextCfg, changed, err := applySecurityDown()
168
+ return nextCfg, changed, false, err
169
+ }
170
+
171
+ index := strings.TrimSpace(choice)
172
+ for i, check := range posture.Checks {
173
+ if index == fmt.Sprint(i+1) {
174
+ nextCfg, changed, err := editSecurityCheck(cfg, check)
175
+ return nextCfg, changed, false, err
176
+ }
177
+ }
178
+
179
+ return nil, false, false, fmt.Errorf("invalid selection %q", choice)
180
+ }
181
+
182
+ func editSecurityCheck(cfg *config.RuntimeConfig, check cli.SecurityPostureCheck) (*config.RuntimeConfig, bool, error) {
183
+ switch check.ID {
184
+ case "bind_loopback":
185
+ value, err := promptInput("Set server.bind:", cfg.Bind)
186
+ if err != nil {
187
+ return nil, false, err
188
+ }
189
+ return updateConfigValue("server.bind", value)
190
+ case "api_auth_enabled":
191
+ picked, err := promptSelect("API authentication", []menuOption{
192
+ {label: "Generate new token (Recommended)", value: "generate"},
193
+ {label: "Set custom token", value: "custom"},
194
+ {label: "Disable token", value: "disable"},
195
+ {label: "Cancel", value: "cancel"},
196
+ })
197
+ if err != nil || picked == "" || picked == "cancel" {
198
+ return cfg, false, nil
199
+ }
200
+ switch picked {
201
+ case "generate":
202
+ token, err := config.GenerateAuthToken()
203
+ if err != nil {
204
+ return nil, false, err
205
+ }
206
+ return updateConfigValue("server.token", token)
207
+ case "custom":
208
+ token, err := promptInput("Set server.token:", cfg.Token)
209
+ if err != nil {
210
+ return nil, false, err
211
+ }
212
+ return updateConfigValue("server.token", token)
213
+ case "disable":
214
+ return updateConfigValue("server.token", "")
215
+ }
216
+ case "sensitive_endpoints_disabled":
217
+ current := strings.Join(cfg.EnabledSensitiveEndpoints(), ",")
218
+ value, err := promptInput("Enable sensitive endpoints (evaluate,macro,screencast,download,upload; blank = disable all):", current)
219
+ if err != nil {
220
+ return nil, false, err
221
+ }
222
+ return updateSensitiveEndpoints(value)
223
+ case "attach_disabled":
224
+ picked, err := promptSelect("Attach endpoint", []menuOption{
225
+ {label: "Disable (Recommended)", value: "disable"},
226
+ {label: "Enable", value: "enable"},
227
+ {label: "Cancel", value: "cancel"},
228
+ })
229
+ if err != nil || picked == "" || picked == "cancel" {
230
+ return cfg, false, nil
231
+ }
232
+ return updateConfigValue("security.attach.enabled", fmt.Sprintf("%t", picked == "enable"))
233
+ case "attach_local_only":
234
+ value, err := promptInput("Set security.attach.allowHosts (comma-separated):", strings.Join(cfg.AttachAllowHosts, ","))
235
+ if err != nil {
236
+ return nil, false, err
237
+ }
238
+ return updateConfigValue("security.attach.allowHosts", value)
239
+ case "idpi_whitelist_scoped":
240
+ value, err := promptInput("Set security.idpi.allowedDomains (comma-separated):", strings.Join(cfg.IDPI.AllowedDomains, ","))
241
+ if err != nil {
242
+ return nil, false, err
243
+ }
244
+ return updateConfigValue("security.idpi.allowedDomains", value)
245
+ case "idpi_strict_mode":
246
+ picked, err := promptSelect("IDPI strict mode", []menuOption{
247
+ {label: "Enforce (Recommended)", value: "true"},
248
+ {label: "Warn only", value: "false"},
249
+ {label: "Cancel", value: "cancel"},
250
+ })
251
+ if err != nil || picked == "" || picked == "cancel" {
252
+ return cfg, false, nil
253
+ }
254
+ return updateConfigValue("security.idpi.strictMode", picked)
255
+ case "idpi_content_protection":
256
+ picked, err := promptSelect("IDPI content guard", []menuOption{
257
+ {label: "Active: scan + wrap (Recommended)", value: "both"},
258
+ {label: "Scan only", value: "scan"},
259
+ {label: "Wrap only", value: "wrap"},
260
+ {label: "Disable", value: "off"},
261
+ {label: "Cancel", value: "cancel"},
262
+ })
263
+ if err != nil || picked == "" || picked == "cancel" {
264
+ return cfg, false, nil
265
+ }
266
+ return updateContentGuard(picked)
267
+ }
268
+ return cfg, false, nil
269
+ }
270
+
271
+ func updateConfigValue(path, value string) (*config.RuntimeConfig, bool, error) {
272
+ fc, configPath, err := config.LoadFileConfig()
273
+ if err != nil {
274
+ return nil, false, fmt.Errorf("load config: %w", err)
275
+ }
276
+ if err := config.SetConfigValue(fc, path, value); err != nil {
277
+ return nil, false, fmt.Errorf("set %s: %w", path, err)
278
+ }
279
+ if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
280
+ return nil, false, errs[0]
281
+ }
282
+ if err := config.SaveFileConfig(fc, configPath); err != nil {
283
+ return nil, false, fmt.Errorf("save config: %w", err)
284
+ }
285
+ return config.Load(), true, nil
286
+ }
287
+
288
+ func updateSensitiveEndpoints(value string) (*config.RuntimeConfig, bool, error) {
289
+ fc, configPath, err := config.LoadFileConfig()
290
+ if err != nil {
291
+ return nil, false, fmt.Errorf("load config: %w", err)
292
+ }
293
+
294
+ selected := map[string]bool{}
295
+ for _, item := range splitCommaList(value) {
296
+ selected[item] = true
297
+ }
298
+ for endpoint, path := range map[string]string{
299
+ "evaluate": "security.allowEvaluate",
300
+ "macro": "security.allowMacro",
301
+ "screencast": "security.allowScreencast",
302
+ "download": "security.allowDownload",
303
+ "upload": "security.allowUpload",
304
+ } {
305
+ enabled := selected[endpoint]
306
+ if err := config.SetConfigValue(fc, path, fmt.Sprintf("%t", enabled)); err != nil {
307
+ return nil, false, fmt.Errorf("set %s: %w", endpoint, err)
308
+ }
309
+ }
310
+ if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
311
+ return nil, false, errs[0]
312
+ }
313
+ if err := config.SaveFileConfig(fc, configPath); err != nil {
314
+ return nil, false, fmt.Errorf("save config: %w", err)
315
+ }
316
+ return config.Load(), true, nil
317
+ }
318
+
319
+ func updateContentGuard(mode string) (*config.RuntimeConfig, bool, error) {
320
+ fc, configPath, err := config.LoadFileConfig()
321
+ if err != nil {
322
+ return nil, false, fmt.Errorf("load config: %w", err)
323
+ }
324
+
325
+ scan := mode == "both" || mode == "scan"
326
+ wrap := mode == "both" || mode == "wrap"
327
+ for _, item := range []struct {
328
+ path string
329
+ value bool
330
+ }{
331
+ {path: "security.idpi.scanContent", value: scan},
332
+ {path: "security.idpi.wrapContent", value: wrap},
333
+ } {
334
+ if err := config.SetConfigValue(fc, item.path, fmt.Sprintf("%t", item.value)); err != nil {
335
+ return nil, false, fmt.Errorf("set %s: %w", item.path, err)
336
+ }
337
+ }
338
+ if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
339
+ return nil, false, errs[0]
340
+ }
341
+ if err := config.SaveFileConfig(fc, configPath); err != nil {
342
+ return nil, false, fmt.Errorf("save config: %w", err)
343
+ }
344
+ return config.Load(), true, nil
345
+ }
346
+
347
+ func applyGuardsDownPreset() (*config.RuntimeConfig, string, bool, error) {
348
+ fc, configPath, err := config.LoadFileConfig()
349
+ if err != nil {
350
+ return nil, "", false, fmt.Errorf("load config: %w", err)
351
+ }
352
+ originalJSON, err := formatFileConfigJSON(fc)
353
+ if err != nil {
354
+ return nil, "", false, err
355
+ }
356
+
357
+ original, err := config.GetConfigValue(fc, "server.token")
358
+ if err != nil {
359
+ return nil, "", false, fmt.Errorf("read server.token: %w", err)
360
+ }
361
+ if strings.TrimSpace(original) == "" {
362
+ token, err := config.GenerateAuthToken()
363
+ if err != nil {
364
+ return nil, "", false, fmt.Errorf("generate token: %w", err)
365
+ }
366
+ if err := config.SetConfigValue(fc, "server.token", token); err != nil {
367
+ return nil, "", false, fmt.Errorf("set server.token: %w", err)
368
+ }
369
+ }
370
+
371
+ for _, item := range []struct {
372
+ path string
373
+ value string
374
+ }{
375
+ {path: "server.bind", value: "127.0.0.1"},
376
+ {path: "security.allowEvaluate", value: "true"},
377
+ {path: "security.allowMacro", value: "true"},
378
+ {path: "security.allowScreencast", value: "true"},
379
+ {path: "security.allowDownload", value: "true"},
380
+ {path: "security.allowUpload", value: "true"},
381
+ {path: "security.attach.enabled", value: "true"},
382
+ {path: "security.attach.allowHosts", value: "127.0.0.1,localhost,::1"},
383
+ {path: "security.attach.allowSchemes", value: "ws,wss"},
384
+ {path: "security.idpi.enabled", value: "false"},
385
+ {path: "security.idpi.strictMode", value: "false"},
386
+ {path: "security.idpi.scanContent", value: "false"},
387
+ {path: "security.idpi.wrapContent", value: "false"},
388
+ } {
389
+ if err := config.SetConfigValue(fc, item.path, item.value); err != nil {
390
+ return nil, "", false, fmt.Errorf("set %s: %w", item.path, err)
391
+ }
392
+ }
393
+
394
+ if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
395
+ return nil, "", false, errs[0]
396
+ }
397
+
398
+ nextJSON, err := formatFileConfigJSON(fc)
399
+ if err != nil {
400
+ return nil, "", false, err
401
+ }
402
+ changed := originalJSON != nextJSON
403
+ if !changed {
404
+ return config.Load(), configPath, false, nil
405
+ }
406
+
407
+ if err := config.SaveFileConfig(fc, configPath); err != nil {
408
+ return nil, "", false, fmt.Errorf("save config: %w", err)
409
+ }
410
+ return config.Load(), configPath, true, nil
411
+ }
412
+
413
+ func applySecurityUp() (*config.RuntimeConfig, bool, error) {
414
+ configPath, changed, err := cli.RestoreSecurityDefaults()
415
+ if err != nil {
416
+ return nil, false, fmt.Errorf("restore defaults: %w", err)
417
+ }
418
+ if !changed {
419
+ fmt.Println(cli.StyleStdout(cli.MutedStyle, fmt.Sprintf("Security defaults already match %s", configPath)))
420
+ return config.Load(), false, nil
421
+ }
422
+ fmt.Println(cli.StyleStdout(cli.SuccessStyle, fmt.Sprintf("Security defaults restored in %s", configPath)))
423
+ fmt.Println(cli.StyleStdout(cli.MutedStyle, "Restart PinchTab to apply file-based changes."))
424
+ return config.Load(), true, nil
425
+ }
426
+
427
+ func applySecurityDown() (*config.RuntimeConfig, bool, error) {
428
+ nextCfg, configPath, changed, err := applyGuardsDownPreset()
429
+ if err != nil {
430
+ return nil, false, fmt.Errorf("guards down: %w", err)
431
+ }
432
+ if !changed {
433
+ fmt.Println(cli.StyleStdout(cli.MutedStyle, fmt.Sprintf("Guards down preset already matches %s", configPath)))
434
+ return nextCfg, false, nil
435
+ }
436
+ fmt.Println(cli.StyleStdout(cli.WarningStyle, fmt.Sprintf("Guards down preset applied in %s", configPath)))
437
+ fmt.Println(cli.StyleStdout(cli.MutedStyle, "Loopback bind and API auth remain enabled; sensitive endpoints and attach are enabled, IDPI is disabled."))
438
+ return nextCfg, true, nil
439
+ }
440
+
441
+ func handleSecurityUpCommand() {
442
+ if _, _, err := applySecurityUp(); err != nil {
443
+ fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
444
+ os.Exit(1)
445
+ }
446
+ }
447
+
448
+ func handleSecurityDownCommand() {
449
+ if _, _, err := applySecurityDown(); err != nil {
450
+ fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
451
+ os.Exit(1)
452
+ }
453
+ }
454
+
455
+ func formatFileConfigJSON(fc *config.FileConfig) (string, error) {
456
+ data, err := json.Marshal(fc)
457
+ if err != nil {
458
+ return "", fmt.Errorf("marshal config: %w", err)
459
+ }
460
+ return string(data), nil
461
+ }
462
+
463
+ func splitCommaList(value string) []string {
464
+ parts := strings.Split(value, ",")
465
+ items := make([]string, 0, len(parts))
466
+ for _, part := range parts {
467
+ trimmed := strings.TrimSpace(strings.ToLower(part))
468
+ if trimmed != "" {
469
+ items = append(items, trimmed)
470
+ }
471
+ }
472
+ slices.Sort(items)
473
+ return slices.Compact(items)
474
+ }