opencode-pilot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.devcontainer/devcontainer.json +16 -0
  2. package/.github/workflows/ci.yml +67 -0
  3. package/.releaserc.cjs +28 -0
  4. package/AGENTS.md +71 -0
  5. package/CONTRIBUTING.md +102 -0
  6. package/LICENSE +21 -0
  7. package/README.md +72 -0
  8. package/bin/opencode-pilot +809 -0
  9. package/dist/opencode-ntfy.tar.gz +0 -0
  10. package/examples/config.yaml +73 -0
  11. package/examples/templates/default.md +7 -0
  12. package/examples/templates/devcontainer.md +7 -0
  13. package/examples/templates/review-feedback.md +7 -0
  14. package/examples/templates/review.md +15 -0
  15. package/install.sh +246 -0
  16. package/package.json +40 -0
  17. package/plugin/config.js +76 -0
  18. package/plugin/index.js +260 -0
  19. package/plugin/logger.js +125 -0
  20. package/plugin/notifier.js +110 -0
  21. package/service/actions.js +334 -0
  22. package/service/io.opencode.ntfy.plist +29 -0
  23. package/service/logger.js +82 -0
  24. package/service/poll-service.js +246 -0
  25. package/service/poller.js +339 -0
  26. package/service/readiness.js +234 -0
  27. package/service/repo-config.js +222 -0
  28. package/service/server.js +1523 -0
  29. package/service/utils.js +21 -0
  30. package/test/run_tests.bash +34 -0
  31. package/test/test_actions.bash +263 -0
  32. package/test/test_cli.bash +161 -0
  33. package/test/test_config.bash +438 -0
  34. package/test/test_helper.bash +140 -0
  35. package/test/test_logger.bash +401 -0
  36. package/test/test_notifier.bash +310 -0
  37. package/test/test_plist.bash +125 -0
  38. package/test/test_plugin.bash +952 -0
  39. package/test/test_poll_service.bash +179 -0
  40. package/test/test_poller.bash +120 -0
  41. package/test/test_readiness.bash +313 -0
  42. package/test/test_repo_config.bash +406 -0
  43. package/test/test_service.bash +1342 -0
  44. package/test/unit/actions.test.js +235 -0
  45. package/test/unit/config.test.js +86 -0
  46. package/test/unit/paths.test.js +77 -0
  47. package/test/unit/poll-service.test.js +142 -0
  48. package/test/unit/poller.test.js +347 -0
  49. package/test/unit/repo-config.test.js +441 -0
  50. package/test/unit/utils.test.js +53 -0
Binary file
@@ -0,0 +1,73 @@
1
+ # Example config.yaml for opencode-pilot
2
+ #
3
+ # Copy to ~/.config/opencode-pilot/config.yaml and customize
4
+ # Also create templates/ directory with prompt template files
5
+
6
+ # =============================================================================
7
+ # NOTIFICATIONS - ntfy settings for mobile notifications
8
+ # =============================================================================
9
+ notifications:
10
+ topic: your-secret-topic # Required: ntfy topic name
11
+ server: https://ntfy.sh # Optional: ntfy server URL
12
+ idle_delay_ms: 300000 # Optional: idle notification delay (5 min)
13
+ idle_notify: true # Optional: enable idle notifications
14
+ error_notify: true # Optional: enable error notifications
15
+ error_debounce_ms: 60000 # Optional: error debounce window (1 min)
16
+ debug: false # Optional: enable debug logging
17
+
18
+ # =============================================================================
19
+ # TOOLS - Field mappings for MCP servers (normalize different APIs)
20
+ # =============================================================================
21
+ tools:
22
+ github:
23
+ mappings: {} # GitHub already uses standard field names
24
+
25
+ linear:
26
+ mappings:
27
+ body: title # Use title as body
28
+ number: "url:/([A-Z0-9]+-[0-9]+)/" # Extract PROJ-123 from URL
29
+
30
+ # =============================================================================
31
+ # SOURCES - What to poll (generic MCP tool references)
32
+ # =============================================================================
33
+ sources:
34
+ # GitHub issues assigned to me - work in devcontainer
35
+ - name: my-issues
36
+ tool:
37
+ mcp: github
38
+ name: search_issues
39
+ args:
40
+ q: "is:issue assignee:@me state:open"
41
+ item:
42
+ id: "{html_url}"
43
+ prompt: devcontainer
44
+ agent: plan
45
+
46
+ # GitHub PRs needing review
47
+ - name: review-requests
48
+ tool:
49
+ mcp: github
50
+ name: search_issues
51
+ args:
52
+ q: "is:pr review-requested:@me state:open"
53
+ item:
54
+ id: "{html_url}"
55
+ prompt: review
56
+ agent: plan
57
+
58
+ # Linear issues - work in devcontainer
59
+ # NOTE: Replace teamId and assigneeId with your actual Linear UUIDs
60
+ # Find these via: linear_list_teams and check user IDs in team members
61
+ - name: linear-work
62
+ tool:
63
+ mcp: linear
64
+ name: list_issues
65
+ args:
66
+ teamId: "your-team-uuid" # Replace with actual team UUID
67
+ assigneeId: "your-user-uuid" # Replace with actual user UUID
68
+ status: "Todo"
69
+ item:
70
+ id: "linear:{id}"
71
+ working_dir: ~/code/myproject
72
+ prompt: devcontainer
73
+ agent: plan
@@ -0,0 +1,7 @@
1
+ Work on this issue:
2
+
3
+ {title}
4
+
5
+ {body}
6
+
7
+ Follow the project's coding standards and test your changes. Create a PR when the implementation is complete.
@@ -0,0 +1,7 @@
1
+ Start a devcontainer for branch issue-{number} and work on this issue:
2
+
3
+ {title}
4
+
5
+ {body}
6
+
7
+ Follow TDD: write failing tests first, then implement. Create a PR when complete.
@@ -0,0 +1,7 @@
1
+ Address the review feedback on this PR:
2
+
3
+ {title}
4
+
5
+ {html_url}
6
+
7
+ Focus only on unresolved review comments. Read each comment, make the requested changes, and respond to the reviewer. Skip any conversations that are already resolved.
@@ -0,0 +1,15 @@
1
+ /review {html_url}
2
+
3
+ Review this pull request:
4
+
5
+ {title}
6
+
7
+ {body}
8
+
9
+ Check for:
10
+ - Correctness and edge cases
11
+ - Test coverage
12
+ - Code quality and maintainability
13
+ - Security considerations
14
+
15
+ Provide actionable feedback. Approve if ready, or request changes with specific suggestions.
package/install.sh ADDED
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Install opencode-ntfy plugin and callback service
4
+ #
5
+ # Usage:
6
+ # curl -fsSL https://raw.githubusercontent.com/athal7/opencode-ntfy/main/install.sh | bash
7
+ #
8
+ # Or from a local clone:
9
+ # ./install.sh
10
+ #
11
+
12
+ set -euo pipefail
13
+
14
+ REPO="athal7/opencode-ntfy"
15
+ PLUGIN_NAME="opencode-ntfy"
16
+ PLUGIN_DIR="$HOME/.config/opencode/plugins/$PLUGIN_NAME"
17
+ SERVICE_DIR="$HOME/.local/share/opencode-ntfy"
18
+ CONFIG_FILE="$HOME/.config/opencode/opencode.json"
19
+ PLIST_DIR="$HOME/Library/LaunchAgents"
20
+ PLIST_NAME="io.opencode.ntfy.plist"
21
+ PLUGIN_FILES="index.js notifier.js callback.js hostname.js nonces.js config.js service-client.js"
22
+ SERVICE_FILES="server.js"
23
+
24
+ echo "Installing $PLUGIN_NAME..."
25
+ echo ""
26
+
27
+ # Create directories
28
+ mkdir -p "$PLUGIN_DIR"
29
+ mkdir -p "$SERVICE_DIR"
30
+
31
+ # Check if we're running from a local clone or need to download
32
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" 2>/dev/null)" && pwd 2>/dev/null)" || SCRIPT_DIR=""
33
+
34
+ if [[ -n "$SCRIPT_DIR" ]] && [[ -f "$SCRIPT_DIR/plugin/index.js" ]]; then
35
+ # Local install from clone
36
+ echo "Installing from local directory..."
37
+
38
+ echo ""
39
+ echo "Plugin files:"
40
+ for file in $PLUGIN_FILES; do
41
+ if [[ -f "$SCRIPT_DIR/plugin/$file" ]]; then
42
+ cp "$SCRIPT_DIR/plugin/$file" "$PLUGIN_DIR/$file"
43
+ echo " Installed: plugin/$file -> $PLUGIN_DIR/$file"
44
+ fi
45
+ done
46
+
47
+ echo ""
48
+ echo "Service files:"
49
+ for file in $SERVICE_FILES; do
50
+ if [[ -f "$SCRIPT_DIR/service/$file" ]]; then
51
+ cp "$SCRIPT_DIR/service/$file" "$SERVICE_DIR/$file"
52
+ echo " Installed: service/$file -> $SERVICE_DIR/$file"
53
+ fi
54
+ done
55
+ else
56
+ # Remote install - download from GitHub
57
+ echo "Downloading plugin files from GitHub..."
58
+
59
+ for file in $PLUGIN_FILES; do
60
+ echo " Downloading: plugin/$file"
61
+ if curl -fsSL "https://raw.githubusercontent.com/$REPO/main/plugin/$file" -o "$PLUGIN_DIR/$file"; then
62
+ echo " Installed: $file"
63
+ else
64
+ echo " ERROR: Failed to download $file"
65
+ exit 1
66
+ fi
67
+ done
68
+
69
+ echo ""
70
+ echo "Downloading service files from GitHub..."
71
+
72
+ for file in $SERVICE_FILES; do
73
+ echo " Downloading: service/$file"
74
+ if curl -fsSL "https://raw.githubusercontent.com/$REPO/main/service/$file" -o "$SERVICE_DIR/$file"; then
75
+ echo " Installed: $file"
76
+ else
77
+ echo " ERROR: Failed to download $file"
78
+ exit 1
79
+ fi
80
+ done
81
+ fi
82
+
83
+ echo ""
84
+ echo "Plugin files installed to: $PLUGIN_DIR"
85
+ echo "Service files installed to: $SERVICE_DIR"
86
+
87
+ # Install LaunchAgent plist (macOS only)
88
+ if [[ "$(uname)" == "Darwin" ]]; then
89
+ echo ""
90
+ echo "Installing LaunchAgent for callback service..."
91
+
92
+ mkdir -p "$PLIST_DIR"
93
+
94
+ # Find node path (handle both Intel and Apple Silicon Macs)
95
+ NODE_PATH=$(command -v node 2>/dev/null)
96
+ if [[ -z "$NODE_PATH" ]]; then
97
+ # Try Homebrew paths
98
+ if [[ -x "/opt/homebrew/bin/node" ]]; then
99
+ NODE_PATH="/opt/homebrew/bin/node"
100
+ elif [[ -x "/usr/local/bin/node" ]]; then
101
+ NODE_PATH="/usr/local/bin/node"
102
+ else
103
+ echo " WARNING: node not found, please install Node.js"
104
+ NODE_PATH="/usr/local/bin/node"
105
+ fi
106
+ fi
107
+
108
+ # Generate plist with correct paths
109
+ cat > "$PLIST_DIR/$PLIST_NAME" << EOF
110
+ <?xml version="1.0" encoding="UTF-8"?>
111
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
112
+ <plist version="1.0">
113
+ <dict>
114
+ <key>Label</key>
115
+ <string>io.opencode.ntfy</string>
116
+
117
+ <key>ProgramArguments</key>
118
+ <array>
119
+ <string>$NODE_PATH</string>
120
+ <string>$SERVICE_DIR/server.js</string>
121
+ </array>
122
+
123
+ <key>RunAtLoad</key>
124
+ <true/>
125
+
126
+ <key>KeepAlive</key>
127
+ <true/>
128
+
129
+ <key>StandardOutPath</key>
130
+ <string>$HOME/.local/share/opencode-ntfy/opencode-ntfy.log</string>
131
+
132
+ <key>StandardErrorPath</key>
133
+ <string>$HOME/.local/share/opencode-ntfy/opencode-ntfy.log</string>
134
+
135
+ <key>WorkingDirectory</key>
136
+ <string>$SERVICE_DIR</string>
137
+ </dict>
138
+ </plist>
139
+ EOF
140
+
141
+ echo " LaunchAgent installed to: $PLIST_DIR/$PLIST_NAME"
142
+ echo ""
143
+ echo " To start the callback service:"
144
+ echo " launchctl load $PLIST_DIR/$PLIST_NAME"
145
+ echo ""
146
+ echo " To stop the callback service:"
147
+ echo " launchctl unload $PLIST_DIR/$PLIST_NAME"
148
+ fi
149
+
150
+ # Configure opencode.json
151
+ echo ""
152
+ echo "Configuring OpenCode..."
153
+
154
+ if [[ -f "$CONFIG_FILE" ]]; then
155
+ # Check if plugin already configured
156
+ if grep -q "$PLUGIN_DIR" "$CONFIG_FILE" 2>/dev/null; then
157
+ echo " Plugin already configured in opencode.json"
158
+ else
159
+ echo ""
160
+ echo " Would you like to add the plugin to opencode.json? [Y/n]"
161
+ read -r response </dev/tty || response="y"
162
+ if [[ "$response" != "n" && "$response" != "N" ]]; then
163
+ # Use node to update JSON safely
164
+ if command -v node >/dev/null 2>&1; then
165
+ node -e "
166
+ const fs = require('fs');
167
+ const configPath = '$CONFIG_FILE';
168
+ const pluginDir = '$PLUGIN_DIR';
169
+
170
+ let config;
171
+ try {
172
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
173
+ } catch {
174
+ config = {};
175
+ }
176
+
177
+ config.plugin = config.plugin || [];
178
+ if (!config.plugin.includes(pluginDir)) {
179
+ config.plugin.push(pluginDir);
180
+ }
181
+
182
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
183
+ "
184
+ echo " Plugin added to opencode.json"
185
+ else
186
+ echo " WARNING: node not found, please manually add to opencode.json:"
187
+ echo ""
188
+ echo " \"plugin\": [\"$PLUGIN_DIR\"]"
189
+ fi
190
+ else
191
+ echo " Skipped. You can manually add the plugin path to opencode.json later."
192
+ fi
193
+ fi
194
+ else
195
+ echo ""
196
+ echo " No opencode.json found. Create one with the plugin configured? [Y/n]"
197
+ read -r response </dev/tty || response="y"
198
+ if [[ "$response" != "n" && "$response" != "N" ]]; then
199
+ mkdir -p "$(dirname "$CONFIG_FILE")"
200
+ cat > "$CONFIG_FILE" << EOF
201
+ {
202
+ "plugin": ["$PLUGIN_DIR"]
203
+ }
204
+ EOF
205
+ echo " Created $CONFIG_FILE"
206
+ else
207
+ echo " Skipped. You can create opencode.json later with:"
208
+ echo ""
209
+ echo " {\"plugin\": [\"$PLUGIN_DIR\"]}"
210
+ fi
211
+ fi
212
+
213
+ # Environment variable check and guidance
214
+ echo ""
215
+ echo "========================================"
216
+ echo " Installation complete!"
217
+ echo "========================================"
218
+ echo ""
219
+
220
+ # Check if NTFY_TOPIC is set
221
+ if [[ -n "${NTFY_TOPIC:-}" ]]; then
222
+ echo "NTFY_TOPIC is set: $NTFY_TOPIC"
223
+ echo ""
224
+ echo "The plugin is ready to use!"
225
+ else
226
+ echo "REQUIRED: Set NTFY_TOPIC in your environment."
227
+ echo ""
228
+ echo "Add to ~/.env (if using direnv) or your shell profile:"
229
+ echo ""
230
+ echo " export NTFY_TOPIC=your-secret-topic"
231
+ fi
232
+
233
+ echo ""
234
+ echo "Optional configuration:"
235
+ echo " NTFY_SERVER=https://ntfy.sh # ntfy server (default: ntfy.sh)"
236
+ echo " NTFY_TOKEN=tk_xxx # ntfy access token for protected topics"
237
+ echo " NTFY_CALLBACK_HOST=host.ts.net # Callback host for interactive notifications"
238
+ echo " NTFY_CALLBACK_PORT=4097 # Callback server port"
239
+ echo " NTFY_IDLE_DELAY_MS=300000 # Idle notification delay (5 min)"
240
+
241
+ echo ""
242
+ echo "For interactive permissions:"
243
+ echo " 1. Set NTFY_CALLBACK_HOST to your machine's hostname (e.g., via Tailscale)"
244
+ echo " 2. Start the callback service: launchctl load ~/Library/LaunchAgents/$PLIST_NAME"
245
+ echo " 3. Ensure your phone can reach the callback URL"
246
+ echo ""
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "opencode-pilot",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Automation layer for OpenCode - notifications, mobile UI, and workflow orchestration",
6
+ "main": "plugin/index.js",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/athal7/opencode-pilot.git"
10
+ },
11
+ "publishConfig": {
12
+ "provenance": true,
13
+ "access": "public"
14
+ },
15
+ "keywords": [
16
+ "opencode",
17
+ "plugin",
18
+ "notifications",
19
+ "automation",
20
+ "ntfy"
21
+ ],
22
+ "author": "Andrew Thal <467872+athal7@users.noreply.github.com>",
23
+ "license": "MIT",
24
+ "bin": {
25
+ "opencode-pilot": "./bin/opencode-pilot"
26
+ },
27
+ "scripts": {
28
+ "test": "node --test test/unit/*.test.js"
29
+ },
30
+ "devDependencies": {
31
+ "@semantic-release/git": "^10.0.1",
32
+ "@semantic-release/github": "^9.2.6",
33
+ "@semantic-release/npm": "^12.0.0",
34
+ "semantic-release": "^25.0.2"
35
+ },
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.25.1",
38
+ "yaml": "^2.8.2"
39
+ }
40
+ }
@@ -0,0 +1,76 @@
1
+ // Configuration management for opencode-pilot
2
+ // Reads from ~/.config/opencode-pilot/config.yaml
3
+ //
4
+ // Example config file (~/.config/opencode-pilot/config.yaml):
5
+ // notifications:
6
+ // topic: my-secret-topic
7
+ // server: https://ntfy.sh
8
+ // idle_delay_ms: 300000
9
+ // debug: true
10
+
11
+ import { readFileSync, existsSync } from 'fs'
12
+ import { join } from 'path'
13
+ import { homedir } from 'os'
14
+ import YAML from 'yaml'
15
+
16
+ const DEFAULT_CONFIG_PATH = join(homedir(), '.config', 'opencode-pilot', 'config.yaml')
17
+
18
+ /**
19
+ * Load configuration from config file
20
+ * @param {string} [configPath] - Optional path to config file (for testing)
21
+ */
22
+ export function loadConfig(configPath) {
23
+ const actualPath = configPath || DEFAULT_CONFIG_PATH
24
+
25
+ // Load config.yaml if it exists
26
+ let fileConfig = {}
27
+ if (existsSync(actualPath)) {
28
+ try {
29
+ const content = readFileSync(actualPath, 'utf8')
30
+ const parsed = YAML.parse(content)
31
+ // Extract notifications section
32
+ fileConfig = parsed?.notifications || {}
33
+ } catch (err) {
34
+ // Silently ignore parse errors
35
+ }
36
+ }
37
+
38
+ // Helper to get value with default
39
+ const get = (key, defaultValue) => {
40
+ if (fileConfig[key] !== undefined && fileConfig[key] !== '') {
41
+ return fileConfig[key]
42
+ }
43
+ return defaultValue
44
+ }
45
+
46
+ // Helper to parse boolean
47
+ const getBool = (key, defaultValue) => {
48
+ const value = get(key, undefined)
49
+ if (value === undefined) return defaultValue
50
+ if (typeof value === 'boolean') return value
51
+ return String(value).toLowerCase() !== 'false' && String(value) !== '0'
52
+ }
53
+
54
+ // Helper to parse int
55
+ const getInt = (key, defaultValue) => {
56
+ const value = get(key, undefined)
57
+ if (value === undefined) return defaultValue
58
+ if (typeof value === 'number') return value
59
+ const parsed = parseInt(String(value), 10)
60
+ return isNaN(parsed) ? defaultValue : parsed
61
+ }
62
+
63
+ return {
64
+ topic: get('topic', null),
65
+ server: get('server', 'https://ntfy.sh'),
66
+ authToken: get('token', null),
67
+ idleDelayMs: getInt('idle_delay_ms', 300000),
68
+ errorNotify: getBool('error_notify', true),
69
+ errorDebounceMs: getInt('error_debounce_ms', 60000),
70
+ retryNotifyFirst: getBool('retry_notify_first', true),
71
+ retryNotifyAfter: getInt('retry_notify_after', 3),
72
+ idleNotify: getBool('idle_notify', true),
73
+ debug: getBool('debug', false),
74
+ debugPath: get('debug_path', null),
75
+ }
76
+ }