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
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Tests for actions.js - session creation with new config format
3
+ */
4
+
5
+ import { test, describe, beforeEach, afterEach } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { tmpdir, homedir } from 'os';
10
+
11
+ describe('actions.js', () => {
12
+ let tempDir;
13
+ let templatesDir;
14
+
15
+ beforeEach(() => {
16
+ tempDir = mkdtempSync(join(tmpdir(), 'opencode-pilot-actions-test-'));
17
+ templatesDir = join(tempDir, 'templates');
18
+ mkdirSync(templatesDir);
19
+ });
20
+
21
+ afterEach(() => {
22
+ rmSync(tempDir, { recursive: true, force: true });
23
+ });
24
+
25
+ describe('expandTemplate', () => {
26
+ test('expands simple placeholders', async () => {
27
+ const { expandTemplate } = await import('../../service/actions.js');
28
+
29
+ const result = expandTemplate('{title}\n\n{body}', {
30
+ title: 'Fix bug',
31
+ body: 'The bug is bad'
32
+ });
33
+
34
+ assert.strictEqual(result, 'Fix bug\n\nThe bug is bad');
35
+ });
36
+
37
+ test('preserves unmatched placeholders', async () => {
38
+ const { expandTemplate } = await import('../../service/actions.js');
39
+
40
+ const result = expandTemplate('{title}\n\n{missing}', {
41
+ title: 'Fix bug'
42
+ });
43
+
44
+ assert.strictEqual(result, 'Fix bug\n\n{missing}');
45
+ });
46
+
47
+ test('expands nested field references', async () => {
48
+ const { expandTemplate } = await import('../../service/actions.js');
49
+
50
+ const result = expandTemplate('Repo: {repository.full_name}', {
51
+ repository: { full_name: 'athal7/opencode-pilot' }
52
+ });
53
+
54
+ assert.strictEqual(result, 'Repo: athal7/opencode-pilot');
55
+ });
56
+
57
+ test('preserves unmatched nested placeholders', async () => {
58
+ const { expandTemplate } = await import('../../service/actions.js');
59
+
60
+ const result = expandTemplate('{team.name}', {
61
+ team: {}
62
+ });
63
+
64
+ assert.strictEqual(result, '{team.name}');
65
+ });
66
+ });
67
+
68
+ describe('buildPromptFromTemplate', () => {
69
+ test('loads template from file and expands', async () => {
70
+ writeFileSync(join(templatesDir, 'default.md'), '{title}\n\n{body}');
71
+
72
+ const { buildPromptFromTemplate } = await import('../../service/actions.js');
73
+
74
+ const item = { title: 'Fix bug', body: 'Details here' };
75
+ const prompt = buildPromptFromTemplate('default', item, templatesDir);
76
+
77
+ assert.strictEqual(prompt, 'Fix bug\n\nDetails here');
78
+ });
79
+
80
+ test('returns fallback when template not found', async () => {
81
+ const { buildPromptFromTemplate } = await import('../../service/actions.js');
82
+
83
+ const item = { title: 'Fix bug', body: 'Details here' };
84
+ const prompt = buildPromptFromTemplate('nonexistent', item, templatesDir);
85
+
86
+ // Should fall back to title + body
87
+ assert.strictEqual(prompt, 'Fix bug\n\nDetails here');
88
+ });
89
+
90
+ test('handles item with only title', async () => {
91
+ const { buildPromptFromTemplate } = await import('../../service/actions.js');
92
+
93
+ const item = { title: 'Fix bug' };
94
+ const prompt = buildPromptFromTemplate('nonexistent', item, templatesDir);
95
+
96
+ assert.strictEqual(prompt, 'Fix bug');
97
+ });
98
+ });
99
+
100
+ describe('getActionConfig', () => {
101
+ test('merges source, repo, and defaults', async () => {
102
+ const { getActionConfig } = await import('../../service/actions.js');
103
+
104
+ const source = {
105
+ name: 'my-issues',
106
+ prompt: 'custom',
107
+ agent: 'reviewer'
108
+ };
109
+ const repoConfig = {
110
+ path: '~/code/backend',
111
+ session: { name: 'issue-{number}' }
112
+ };
113
+ const defaults = {
114
+ prompt: 'default',
115
+ working_dir: '~'
116
+ };
117
+
118
+ const config = getActionConfig(source, repoConfig, defaults);
119
+
120
+ // Source overrides
121
+ assert.strictEqual(config.prompt, 'custom');
122
+ assert.strictEqual(config.agent, 'reviewer');
123
+ // Repo config
124
+ assert.strictEqual(config.path, '~/code/backend');
125
+ assert.strictEqual(config.session.name, 'issue-{number}');
126
+ });
127
+
128
+ test('falls back to defaults when no source/repo overrides', async () => {
129
+ const { getActionConfig } = await import('../../service/actions.js');
130
+
131
+ const source = { name: 'my-issues' };
132
+ const repoConfig = {};
133
+ const defaults = {
134
+ prompt: 'default',
135
+ working_dir: '~/scratch'
136
+ };
137
+
138
+ const config = getActionConfig(source, repoConfig, defaults);
139
+
140
+ assert.strictEqual(config.prompt, 'default');
141
+ assert.strictEqual(config.working_dir, '~/scratch');
142
+ });
143
+
144
+ test('source working_dir overrides repo path', async () => {
145
+ const { getActionConfig } = await import('../../service/actions.js');
146
+
147
+ const source = {
148
+ name: 'cross-repo',
149
+ working_dir: '~/workspaces'
150
+ };
151
+ const repoConfig = {
152
+ path: '~/code/backend'
153
+ };
154
+ const defaults = {};
155
+
156
+ const config = getActionConfig(source, repoConfig, defaults);
157
+
158
+ assert.strictEqual(config.working_dir, '~/workspaces');
159
+ });
160
+
161
+ });
162
+
163
+ describe('getCommandInfoNew', () => {
164
+ test('builds command with all options', async () => {
165
+ writeFileSync(join(templatesDir, 'default.md'), '{title}\n\n{body}');
166
+
167
+ const { getCommandInfoNew } = await import('../../service/actions.js');
168
+
169
+ const item = { number: 123, title: 'Fix bug', body: 'Details' };
170
+ const config = {
171
+ path: '~/code/backend',
172
+ prompt: 'default',
173
+ agent: 'coder',
174
+ session: { name: 'issue-{number}' }
175
+ };
176
+
177
+ const cmdInfo = getCommandInfoNew(item, config, templatesDir);
178
+
179
+ assert.strictEqual(cmdInfo.cwd, join(homedir(), 'code/backend'));
180
+ assert.ok(cmdInfo.args.includes('opencode'));
181
+ assert.ok(cmdInfo.args.includes('run'));
182
+ assert.ok(cmdInfo.args.includes('--title'));
183
+ assert.ok(cmdInfo.args.includes('issue-123'));
184
+ assert.ok(cmdInfo.args.includes('--agent'));
185
+ assert.ok(cmdInfo.args.includes('coder'));
186
+ });
187
+
188
+ test('uses working_dir when no path', async () => {
189
+ const { getCommandInfoNew } = await import('../../service/actions.js');
190
+
191
+ const item = { id: 'reminder-1', title: 'Do something' };
192
+ const config = {
193
+ working_dir: '~/scratch',
194
+ prompt: 'default'
195
+ };
196
+
197
+ const cmdInfo = getCommandInfoNew(item, config, templatesDir);
198
+
199
+ assert.strictEqual(cmdInfo.cwd, join(homedir(), 'scratch'));
200
+ });
201
+
202
+ test('defaults to home dir when no path or working_dir', async () => {
203
+ const { getCommandInfoNew } = await import('../../service/actions.js');
204
+
205
+ const item = { title: 'Do something' };
206
+ const config = { prompt: 'default' };
207
+
208
+ const cmdInfo = getCommandInfoNew(item, config, templatesDir);
209
+
210
+ assert.strictEqual(cmdInfo.cwd, homedir());
211
+ });
212
+
213
+ test('includes prompt from template as message', async () => {
214
+ writeFileSync(join(templatesDir, 'devcontainer.md'), '/devcontainer issue-{number}\n\n{title}\n\n{body}');
215
+
216
+ const { getCommandInfoNew } = await import('../../service/actions.js');
217
+
218
+ const item = { number: 66, title: 'Fix bug', body: 'Details' };
219
+ const config = {
220
+ path: '~/code/backend',
221
+ prompt: 'devcontainer'
222
+ };
223
+
224
+ const cmdInfo = getCommandInfoNew(item, config, templatesDir);
225
+
226
+ // Should NOT have --command flag (slash command is in template)
227
+ assert.ok(!cmdInfo.args.includes('--command'), 'Should not include --command flag');
228
+
229
+ // Prompt should include the /devcontainer command
230
+ const lastArg = cmdInfo.args[cmdInfo.args.length - 1];
231
+ assert.ok(lastArg.includes('/devcontainer issue-66'), 'Prompt should include /devcontainer command');
232
+ assert.ok(lastArg.includes('Fix bug'), 'Prompt should include title');
233
+ });
234
+ });
235
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Tests for config.js - unified YAML configuration
3
+ */
4
+
5
+ import { test, describe, beforeEach, afterEach } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { tmpdir } from 'os';
10
+
11
+ // We'll test the module by creating temp config files
12
+
13
+ describe('config.js', () => {
14
+ let tempDir;
15
+ let configPath;
16
+ let templatesDir;
17
+
18
+ beforeEach(() => {
19
+ tempDir = mkdtempSync(join(tmpdir(), 'opencode-pilot-test-'));
20
+ configPath = join(tempDir, 'config.yaml');
21
+ templatesDir = join(tempDir, 'templates');
22
+ mkdirSync(templatesDir);
23
+ });
24
+
25
+ afterEach(() => {
26
+ rmSync(tempDir, { recursive: true, force: true });
27
+ });
28
+
29
+ describe('loadConfig', () => {
30
+ test('reads notifications from config.yaml', async () => {
31
+ writeFileSync(configPath, `
32
+ notifications:
33
+ topic: test-topic
34
+ server: https://custom.ntfy.sh
35
+ idle_delay_ms: 600000
36
+ `);
37
+
38
+ const { loadConfig } = await import('../../plugin/config.js');
39
+ const config = loadConfig(configPath);
40
+
41
+ assert.strictEqual(config.topic, 'test-topic');
42
+ assert.strictEqual(config.server, 'https://custom.ntfy.sh');
43
+ assert.strictEqual(config.idleDelayMs, 600000);
44
+ });
45
+
46
+ test('returns defaults when config file missing', async () => {
47
+ const { loadConfig } = await import('../../plugin/config.js');
48
+ const config = loadConfig('/nonexistent/path/config.yaml');
49
+
50
+ assert.strictEqual(config.server, 'https://ntfy.sh');
51
+ assert.strictEqual(config.idleDelayMs, 300000);
52
+ });
53
+
54
+ test('handles all config options', async () => {
55
+ writeFileSync(configPath, `
56
+ notifications:
57
+ topic: my-topic
58
+ server: https://ntfy.example.com
59
+ token: tk_xxx
60
+ idle_delay_ms: 600000
61
+ idle_notify: false
62
+ error_notify: false
63
+ error_debounce_ms: 120000
64
+ retry_notify_first: false
65
+ retry_notify_after: 5
66
+ debug: true
67
+ debug_path: /custom/debug.log
68
+ `);
69
+
70
+ const { loadConfig } = await import('../../plugin/config.js');
71
+ const config = loadConfig(configPath);
72
+
73
+ assert.strictEqual(config.topic, 'my-topic');
74
+ assert.strictEqual(config.server, 'https://ntfy.example.com');
75
+ assert.strictEqual(config.authToken, 'tk_xxx');
76
+ assert.strictEqual(config.idleDelayMs, 600000);
77
+ assert.strictEqual(config.idleNotify, false);
78
+ assert.strictEqual(config.errorNotify, false);
79
+ assert.strictEqual(config.errorDebounceMs, 120000);
80
+ assert.strictEqual(config.retryNotifyFirst, false);
81
+ assert.strictEqual(config.retryNotifyAfter, 5);
82
+ assert.strictEqual(config.debug, true);
83
+ assert.strictEqual(config.debugPath, '/custom/debug.log');
84
+ });
85
+ });
86
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Tests for consistent path naming across the codebase.
3
+ *
4
+ * These tests ensure all config/socket paths use "opencode-pilot"
5
+ * and not the old "opencode-ntfy" name.
6
+ */
7
+
8
+ import { test, describe } from 'node:test';
9
+ import assert from 'node:assert';
10
+ import { readFileSync } from 'fs';
11
+ import { join, dirname } from 'path';
12
+ import { fileURLToPath } from 'url';
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+ const ROOT_DIR = join(__dirname, '..', '..');
17
+ const PLUGIN_DIR = join(ROOT_DIR, 'plugin');
18
+ const SERVICE_DIR = join(ROOT_DIR, 'service');
19
+
20
+ describe('Path naming consistency', () => {
21
+
22
+ describe('config.js', () => {
23
+ const configPath = join(PLUGIN_DIR, 'config.js');
24
+ const content = readFileSync(configPath, 'utf8');
25
+
26
+ test('uses opencode-pilot config path', () => {
27
+ assert.match(content, /opencode-pilot.*config\.yaml/,
28
+ 'config.js should reference opencode-pilot config path');
29
+ });
30
+
31
+ test('does not reference old opencode-ntfy path', () => {
32
+ assert.doesNotMatch(content, /opencode-ntfy/,
33
+ 'config.js should not reference old opencode-ntfy name');
34
+ });
35
+ });
36
+
37
+ describe('logger.js', () => {
38
+ const loggerPath = join(PLUGIN_DIR, 'logger.js');
39
+ const content = readFileSync(loggerPath, 'utf8');
40
+
41
+ test('uses opencode-pilot debug log path', () => {
42
+ assert.match(content, /opencode-pilot.*debug\.log/,
43
+ 'logger.js should reference opencode-pilot debug log path');
44
+ });
45
+
46
+ test('does not reference old opencode-ntfy path', () => {
47
+ assert.doesNotMatch(content, /opencode-ntfy/,
48
+ 'logger.js should not reference old opencode-ntfy name');
49
+ });
50
+ });
51
+
52
+ describe('server.js', () => {
53
+ const serverPath = join(SERVICE_DIR, 'server.js');
54
+ const content = readFileSync(serverPath, 'utf8');
55
+
56
+ test('uses opencode-pilot socket path', () => {
57
+ assert.match(content, /opencode-pilot\.sock/,
58
+ 'server.js should reference opencode-pilot socket path');
59
+ });
60
+
61
+ test('uses opencode-pilot config path', () => {
62
+ assert.match(content, /opencode-pilot.*config\.json|opencode-pilot/,
63
+ 'server.js should reference opencode-pilot paths');
64
+ });
65
+ });
66
+
67
+ describe('index.js (plugin entry)', () => {
68
+ const indexPath = join(PLUGIN_DIR, 'index.js');
69
+ const content = readFileSync(indexPath, 'utf8');
70
+
71
+ test('does not reference old opencode-ntfy name in comments', () => {
72
+ // Allow "ntfy" alone (the service name) but not "opencode-ntfy"
73
+ assert.doesNotMatch(content, /opencode-ntfy/,
74
+ 'index.js should not reference old opencode-ntfy name');
75
+ });
76
+ });
77
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Tests for poll-service.js
3
+ */
4
+
5
+ import { test, describe, beforeEach, afterEach, mock } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { tmpdir } from 'os';
10
+
11
+ describe('poll-service.js', () => {
12
+ let tempDir;
13
+ let configPath;
14
+
15
+ beforeEach(() => {
16
+ tempDir = mkdtempSync(join(tmpdir(), 'opencode-pilot-poll-service-test-'));
17
+ configPath = join(tempDir, 'config.yaml');
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(tempDir, { recursive: true, force: true });
22
+ });
23
+
24
+ describe('source configuration', () => {
25
+ test('parses sources with tool.mcp and tool.name', async () => {
26
+ const config = `
27
+ sources:
28
+ - name: my-issues
29
+ tool:
30
+ mcp: github
31
+ name: search_issues
32
+ args:
33
+ q: "is:issue assignee:@me"
34
+ item:
35
+ id: "{html_url}"
36
+ `;
37
+ writeFileSync(configPath, config);
38
+
39
+ const { loadRepoConfig, getAllSources } = await import('../../service/repo-config.js');
40
+ loadRepoConfig(configPath);
41
+ const sources = getAllSources();
42
+
43
+ assert.strictEqual(sources.length, 1);
44
+ assert.strictEqual(sources[0].name, 'my-issues');
45
+ assert.ok(sources[0].tool, 'Source should have tool config');
46
+ assert.strictEqual(sources[0].tool.mcp, 'github');
47
+ assert.strictEqual(sources[0].tool.name, 'search_issues');
48
+ });
49
+
50
+ test('hasToolConfig validates source configuration', async () => {
51
+ const { hasToolConfig } = await import('../../service/poll-service.js');
52
+
53
+ // Valid config
54
+ const valid = {
55
+ name: 'test',
56
+ tool: { mcp: 'github', name: 'search_issues' },
57
+ args: {}
58
+ };
59
+ assert.strictEqual(hasToolConfig(valid), true);
60
+
61
+ // Missing tool
62
+ const missingTool = { name: 'test' };
63
+ assert.strictEqual(hasToolConfig(missingTool), false);
64
+
65
+ // Missing mcp
66
+ const missingMcp = { name: 'test', tool: { name: 'search_issues' } };
67
+ assert.strictEqual(hasToolConfig(missingMcp), false);
68
+
69
+ // Missing tool.name
70
+ const missingName = { name: 'test', tool: { mcp: 'github' } };
71
+ assert.strictEqual(hasToolConfig(missingName), false);
72
+ });
73
+ });
74
+
75
+ describe('buildActionConfigFromSource', () => {
76
+ test('includes source-level agent, model, prompt, and working_dir', async () => {
77
+ const { buildActionConfigFromSource } = await import('../../service/poll-service.js');
78
+
79
+ const source = {
80
+ name: 'test-source',
81
+ agent: 'plan',
82
+ model: 'claude-opus',
83
+ prompt: 'devcontainer',
84
+ working_dir: '~/code/project',
85
+ session: { name: 'issue-{number}' }
86
+ };
87
+ const repoConfig = {
88
+ path: '~/code/default',
89
+ prompt: 'default'
90
+ };
91
+
92
+ const config = buildActionConfigFromSource(source, repoConfig);
93
+
94
+ assert.strictEqual(config.agent, 'plan');
95
+ assert.strictEqual(config.model, 'claude-opus');
96
+ assert.strictEqual(config.prompt, 'devcontainer');
97
+ assert.strictEqual(config.working_dir, '~/code/project');
98
+ assert.deepStrictEqual(config.session, { name: 'issue-{number}' });
99
+ });
100
+
101
+ test('falls back to repoConfig when source fields missing', async () => {
102
+ const { buildActionConfigFromSource } = await import('../../service/poll-service.js');
103
+
104
+ const source = {
105
+ name: 'test-source'
106
+ // No agent, model, prompt, working_dir
107
+ };
108
+ const repoConfig = {
109
+ path: '~/code/default',
110
+ prompt: 'default',
111
+ session: { name: 'default-{number}' }
112
+ };
113
+
114
+ const config = buildActionConfigFromSource(source, repoConfig);
115
+
116
+ assert.strictEqual(config.prompt, 'default');
117
+ assert.strictEqual(config.repo_path, '~/code/default');
118
+ assert.deepStrictEqual(config.session, { name: 'default-{number}' });
119
+ });
120
+
121
+ test('source fields override repoConfig fields', async () => {
122
+ const { buildActionConfigFromSource } = await import('../../service/poll-service.js');
123
+
124
+ const source = {
125
+ name: 'test-source',
126
+ prompt: 'review',
127
+ agent: 'code'
128
+ };
129
+ const repoConfig = {
130
+ path: '~/code/default',
131
+ prompt: 'default',
132
+ agent: 'plan' // Should be overridden
133
+ };
134
+
135
+ const config = buildActionConfigFromSource(source, repoConfig);
136
+
137
+ assert.strictEqual(config.prompt, 'review');
138
+ assert.strictEqual(config.agent, 'code');
139
+ });
140
+
141
+ });
142
+ });