remote-opencode 1.2.0 → 1.3.1
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.
- package/README.md +55 -15
- package/dist/src/commands/diff.js +98 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/services/worktreeManager.js +10 -10
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
 📦 Used by developers worldwide — **1000+ weekly downloads** on npm
|
|
6
6
|
|
|
7
7
|
<div align="center">
|
|
8
|
-
<img width="1024" alt="
|
|
8
|
+
<img width="1024" alt="remote-opencode logo" src="./asset/remo-code-logo.png" />
|
|
9
9
|
</div>
|
|
10
10
|
|
|
11
11
|
**remote-opencode** is a Discord bot that bridges your local [OpenCode CLI](https://github.com/sst/opencode) to Discord, enabling you to interact with your AI coding assistant remotely. Perfect for developers who want to:
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
|
|
39
39
|
The bot runs on your development machine alongside OpenCode. When you send a command via Discord, it's forwarded to OpenCode, and the output streams back to you in real-time.
|
|
40
40
|
|
|
41
|
+
## Demo
|
|
42
|
+
|
|
43
|
+
https://github.com/user-attachments/assets/b6239cb6-234e-41e2-a4d1-d4dd3e86c7b9
|
|
44
|
+
|
|
41
45
|
---
|
|
42
46
|
|
|
43
47
|
## Table of Contents
|
|
@@ -132,17 +136,17 @@ If you prefer manual setup or need to troubleshoot:
|
|
|
132
136
|
|
|
133
137
|
## CLI Commands
|
|
134
138
|
|
|
135
|
-
| Command
|
|
136
|
-
|
|
|
137
|
-
| `remote-opencode`
|
|
138
|
-
| `remote-opencode setup`
|
|
139
|
-
| `remote-opencode start`
|
|
140
|
-
| `remote-opencode deploy`
|
|
141
|
-
| `remote-opencode config`
|
|
142
|
-
| `remote-opencode allow add <userId>`
|
|
143
|
-
| `remote-opencode allow remove <userId>`
|
|
144
|
-
| `remote-opencode allow list`
|
|
145
|
-
| `remote-opencode allow reset`
|
|
139
|
+
| Command | Description |
|
|
140
|
+
| --------------------------------------- | ---------------------------------------------------- |
|
|
141
|
+
| `remote-opencode` | Start the bot (shows setup guide if not configured) |
|
|
142
|
+
| `remote-opencode setup` | Interactive setup wizard — configures bot token, IDs |
|
|
143
|
+
| `remote-opencode start` | Start the Discord bot |
|
|
144
|
+
| `remote-opencode deploy` | Deploy/update slash commands to Discord |
|
|
145
|
+
| `remote-opencode config` | Display current configuration info |
|
|
146
|
+
| `remote-opencode allow add <userId>` | Add a Discord user ID to the allowlist |
|
|
147
|
+
| `remote-opencode allow remove <userId>` | Remove a Discord user ID from the allowlist |
|
|
148
|
+
| `remote-opencode allow list` | List all user IDs in the allowlist |
|
|
149
|
+
| `remote-opencode allow reset` | Clear the entire allowlist (removes access control) |
|
|
146
150
|
|
|
147
151
|
---
|
|
148
152
|
|
|
@@ -303,6 +307,41 @@ Control the automated job queue for the current thread.
|
|
|
303
307
|
- `continue_on_failure`: If `True`, the bot moves to the next task even if the current one fails.
|
|
304
308
|
- `fresh_context`: If `True` (default), the AI forgets previous chat history for each new queued task to improve performance, while maintaining the same code state.
|
|
305
309
|
|
|
310
|
+
### `/diff` — View Git Diff
|
|
311
|
+
|
|
312
|
+
Show git diffs for the current project directly in Discord — perfect for reviewing AI-made changes from your phone.
|
|
313
|
+
|
|
314
|
+
```
|
|
315
|
+
/diff
|
|
316
|
+
/diff target:staged
|
|
317
|
+
/diff target:branch base:develop
|
|
318
|
+
/diff stat:true
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
| Parameter | Description |
|
|
322
|
+
| --------- | ------------------------------------------------------------------ |
|
|
323
|
+
| `target` | `unstaged` (default), `staged`, or `branch` |
|
|
324
|
+
| `stat` | Show `--stat` summary only instead of full diff (default: `false`) |
|
|
325
|
+
| `base` | Base branch for `target:branch` diff (default: `main`) |
|
|
326
|
+
|
|
327
|
+
**How it works:**
|
|
328
|
+
|
|
329
|
+
- Inside a **worktree thread** → diffs the worktree path for that branch
|
|
330
|
+
- In a **regular channel** → diffs the channel-bound project path
|
|
331
|
+
- Output is formatted in a `diff` code block (truncated if over Discord's 2000-char limit)
|
|
332
|
+
|
|
333
|
+
**Examples:**
|
|
334
|
+
|
|
335
|
+
```
|
|
336
|
+
/diff → unstaged changes (git diff)
|
|
337
|
+
/diff target:staged → staged changes (git diff --cached)
|
|
338
|
+
/diff target:branch → changes vs main (git diff main...HEAD)
|
|
339
|
+
/diff target:branch base:dev → changes vs dev branch
|
|
340
|
+
/diff stat:true → summary only (git diff --stat)
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
306
345
|
### `/allow` — Manage Allowlist
|
|
307
346
|
|
|
308
347
|
Manage the user allowlist directly from Discord. This command is only available when the allowlist has already been initialized (at least one user exists).
|
|
@@ -313,9 +352,9 @@ Manage the user allowlist directly from Discord. This command is only available
|
|
|
313
352
|
/allow action:list
|
|
314
353
|
```
|
|
315
354
|
|
|
316
|
-
| Parameter | Description
|
|
317
|
-
| --------- |
|
|
318
|
-
| `action` | `add`, `remove`, or `list`
|
|
355
|
+
| Parameter | Description |
|
|
356
|
+
| --------- | --------------------------------------------- |
|
|
357
|
+
| `action` | `add`, `remove`, or `list` |
|
|
319
358
|
| `user` | Target user (required for `add` and `remove`) |
|
|
320
359
|
|
|
321
360
|
**Behavior:**
|
|
@@ -597,6 +636,7 @@ src/
|
|
|
597
636
|
│ ├── opencode.ts # Main AI interaction command
|
|
598
637
|
│ ├── code.ts # Passthrough mode toggle
|
|
599
638
|
│ ├── work.ts # Worktree management
|
|
639
|
+
│ ├── diff.ts # Git diff viewer
|
|
600
640
|
│ ├── allow.ts # Allowlist management
|
|
601
641
|
│ ├── setpath.ts # Project registration
|
|
602
642
|
│ ├── projects.ts # List projects
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import * as dataStore from '../services/dataStore.js';
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const MAX_LENGTH = 1900;
|
|
7
|
+
const CODE_BLOCK_OVERHEAD = 8; // ```diff\n...\n```
|
|
8
|
+
const GIT_REF_PATTERN = /^[a-zA-Z0-9._\/-]+$/;
|
|
9
|
+
function formatDiff(raw) {
|
|
10
|
+
const maxContent = MAX_LENGTH - CODE_BLOCK_OVERHEAD;
|
|
11
|
+
if (raw.length <= maxContent) {
|
|
12
|
+
return '```diff\n' + raw + '\n```';
|
|
13
|
+
}
|
|
14
|
+
const truncated = '...(truncated)...\n\n' + raw.slice(-maxContent + 20);
|
|
15
|
+
return '```diff\n' + truncated + '\n```';
|
|
16
|
+
}
|
|
17
|
+
export const diff = {
|
|
18
|
+
data: new SlashCommandBuilder()
|
|
19
|
+
.setName('diff')
|
|
20
|
+
.setDescription('Show git diff for the current project')
|
|
21
|
+
.addStringOption(option => option.setName('target')
|
|
22
|
+
.setDescription('What to diff: unstaged (default), staged, or branch')
|
|
23
|
+
.setRequired(false)
|
|
24
|
+
.addChoices({ name: 'unstaged', value: 'unstaged' }, { name: 'staged', value: 'staged' }, { name: 'branch', value: 'branch' }))
|
|
25
|
+
.addBooleanOption(option => option.setName('stat')
|
|
26
|
+
.setDescription('Show summary stats only (--stat)')
|
|
27
|
+
.setRequired(false))
|
|
28
|
+
.addStringOption(option => option.setName('base')
|
|
29
|
+
.setDescription('Base branch for branch diff (default: main)')
|
|
30
|
+
.setRequired(false)),
|
|
31
|
+
execute: async (interaction) => {
|
|
32
|
+
const i = interaction;
|
|
33
|
+
const target = i.options.getString('target') ?? 'unstaged';
|
|
34
|
+
const stat = i.options.getBoolean('stat') ?? false;
|
|
35
|
+
const base = i.options.getString('base') ?? 'main';
|
|
36
|
+
const channel = i.channel;
|
|
37
|
+
if (!channel) {
|
|
38
|
+
await i.reply({ content: '❌ Unknown channel.', flags: MessageFlags.Ephemeral });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// Resolve project path: worktree thread takes priority
|
|
42
|
+
let projectPath;
|
|
43
|
+
if (channel.isThread()) {
|
|
44
|
+
const mapping = dataStore.getWorktreeMapping(i.channelId);
|
|
45
|
+
if (mapping) {
|
|
46
|
+
projectPath = mapping.worktreePath;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const parentId = channel.parentId;
|
|
50
|
+
if (parentId) {
|
|
51
|
+
projectPath = dataStore.getChannelProjectPath(parentId);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
projectPath = dataStore.getChannelProjectPath(i.channelId);
|
|
57
|
+
}
|
|
58
|
+
if (!projectPath) {
|
|
59
|
+
await i.reply({
|
|
60
|
+
content: '❌ No project bound to this channel. Use `/setpath` and `/use` first.',
|
|
61
|
+
flags: MessageFlags.Ephemeral
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (target === 'branch' && !GIT_REF_PATTERN.test(base)) {
|
|
66
|
+
await i.reply({ content: '❌ Invalid base branch name.', flags: MessageFlags.Ephemeral });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await i.deferReply();
|
|
70
|
+
try {
|
|
71
|
+
let gitArgs;
|
|
72
|
+
switch (target) {
|
|
73
|
+
case 'staged':
|
|
74
|
+
gitArgs = ['diff', '--cached'];
|
|
75
|
+
break;
|
|
76
|
+
case 'branch':
|
|
77
|
+
gitArgs = ['diff', `${base}...HEAD`];
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
gitArgs = ['diff'];
|
|
81
|
+
}
|
|
82
|
+
if (stat) {
|
|
83
|
+
gitArgs.push('--stat');
|
|
84
|
+
}
|
|
85
|
+
const { stdout } = await execFileAsync('git', gitArgs, { cwd: projectPath });
|
|
86
|
+
const output = stdout.trim();
|
|
87
|
+
if (!output) {
|
|
88
|
+
const targetLabel = target === 'branch' ? `branch (base: ${base})` : target;
|
|
89
|
+
await i.editReply(`✅ No ${targetLabel} changes.`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await i.editReply(formatDiff(output));
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
await i.editReply(`❌ Failed to get diff: ${error.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
@@ -10,6 +10,7 @@ import { model } from './model.js';
|
|
|
10
10
|
import { setports } from './setports.js';
|
|
11
11
|
import { queue } from './queue.js';
|
|
12
12
|
import { allow } from './allow.js';
|
|
13
|
+
import { diff } from './diff.js';
|
|
13
14
|
export const commands = new Collection();
|
|
14
15
|
commands.set(setpath.data.name, setpath);
|
|
15
16
|
commands.set(projects.data.name, projects);
|
|
@@ -22,3 +23,4 @@ commands.set(model.data.name, model);
|
|
|
22
23
|
commands.set(setports.data.name, setports);
|
|
23
24
|
commands.set(queue.data.name, queue);
|
|
24
25
|
commands.set(allow.data.name, allow);
|
|
26
|
+
commands.set(diff.data.name, diff);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
|
-
const
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
6
|
export function sanitizeBranchName(name) {
|
|
7
7
|
return name
|
|
8
8
|
.trim()
|
|
@@ -13,8 +13,8 @@ export function sanitizeBranchName(name) {
|
|
|
13
13
|
export async function branchExists(projectPath, branchName) {
|
|
14
14
|
try {
|
|
15
15
|
const [localRes, remoteRes] = await Promise.all([
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
execFileAsync('git', ['branch', '--list', branchName], { cwd: projectPath }).catch(() => ({ stdout: '' })),
|
|
17
|
+
execFileAsync('git', ['branch', '-r', '--list', `origin/${branchName}`], { cwd: projectPath }).catch(() => ({ stdout: '' }))
|
|
18
18
|
]);
|
|
19
19
|
return {
|
|
20
20
|
local: localRes.stdout.trim().length > 0,
|
|
@@ -33,12 +33,12 @@ export async function createWorktree(projectPath, branchName) {
|
|
|
33
33
|
const { local, remote } = await branchExists(projectPath, sanitizedBranch);
|
|
34
34
|
try {
|
|
35
35
|
if (local || remote) {
|
|
36
|
-
await
|
|
36
|
+
await execFileAsync('git', ['worktree', 'add', `./worktrees/${sanitizedBranch}`, sanitizedBranch], {
|
|
37
37
|
cwd: projectPath
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
else {
|
|
41
|
-
await
|
|
41
|
+
await execFileAsync('git', ['worktree', 'add', `./worktrees/${sanitizedBranch}`, '-b', sanitizedBranch], {
|
|
42
42
|
cwd: projectPath
|
|
43
43
|
});
|
|
44
44
|
}
|
|
@@ -53,13 +53,13 @@ export async function removeWorktree(worktreePath, deleteBranch) {
|
|
|
53
53
|
try {
|
|
54
54
|
let branchName;
|
|
55
55
|
if (deleteBranch) {
|
|
56
|
-
const { stdout } = await
|
|
56
|
+
const { stdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd: worktreePath });
|
|
57
57
|
branchName = stdout.trim();
|
|
58
58
|
}
|
|
59
59
|
const projectPath = resolve(worktreePath, '..', '..');
|
|
60
|
-
await
|
|
60
|
+
await execFileAsync('git', ['worktree', 'remove', worktreePath, '--force'], { cwd: projectPath });
|
|
61
61
|
if (deleteBranch && branchName) {
|
|
62
|
-
await
|
|
62
|
+
await execFileAsync('git', ['branch', '-D', branchName], { cwd: projectPath });
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
catch (error) {
|
|
@@ -69,7 +69,7 @@ export async function removeWorktree(worktreePath, deleteBranch) {
|
|
|
69
69
|
}
|
|
70
70
|
export async function getCurrentBranch(cwd) {
|
|
71
71
|
try {
|
|
72
|
-
const { stdout } = await
|
|
72
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
|
|
73
73
|
return stdout.trim() || null;
|
|
74
74
|
}
|
|
75
75
|
catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remote-opencode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Discord bot for remote OpenCode CLI access",
|
|
5
5
|
"main": "dist/src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -52,5 +52,8 @@
|
|
|
52
52
|
"ts-node": "^10.9.2",
|
|
53
53
|
"typescript": "^5.9.3",
|
|
54
54
|
"vitest": "^4.0.18"
|
|
55
|
+
},
|
|
56
|
+
"overrides": {
|
|
57
|
+
"undici": "^6.23.0"
|
|
55
58
|
}
|
|
56
59
|
}
|