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 CHANGED
@@ -5,7 +5,7 @@
5
5
  ![npm](https://img.shields.io/npm/dt/remote-opencode) 📦 Used by developers worldwide — **1000+ weekly downloads** on npm
6
6
 
7
7
  <div align="center">
8
- <img width="1024" alt="Gemini_Generated_Image_47d5gq47d5gq47d5" src="https://github.com/user-attachments/assets/1defa11d-6195-4a9c-956b-4f87470f6393" />
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 | Description |
136
- | ------------------------------------------ | ---------------------------------------------------- |
137
- | `remote-opencode` | Start the bot (shows setup guide if not configured) |
138
- | `remote-opencode setup` | Interactive setup wizard — configures bot token, IDs |
139
- | `remote-opencode start` | Start the Discord bot |
140
- | `remote-opencode deploy` | Deploy/update slash commands to Discord |
141
- | `remote-opencode config` | Display current configuration info |
142
- | `remote-opencode allow add <userId>` | Add a Discord user ID to the allowlist |
143
- | `remote-opencode allow remove <userId>` | Remove a Discord user ID from the allowlist |
144
- | `remote-opencode allow list` | List all user IDs in the allowlist |
145
- | `remote-opencode allow reset` | Clear the entire allowlist (removes access control) |
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 { exec } from 'node:child_process';
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 execAsync = promisify(exec);
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
- execAsync(`git branch --list ${branchName}`, { cwd: projectPath }).catch(() => ({ stdout: '' })),
17
- execAsync(`git branch -r --list origin/${branchName}`, { cwd: projectPath }).catch(() => ({ stdout: '' }))
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 execAsync(`git worktree add ./worktrees/${sanitizedBranch} ${sanitizedBranch}`, {
36
+ await execFileAsync('git', ['worktree', 'add', `./worktrees/${sanitizedBranch}`, sanitizedBranch], {
37
37
  cwd: projectPath
38
38
  });
39
39
  }
40
40
  else {
41
- await execAsync(`git worktree add ./worktrees/${sanitizedBranch} -b ${sanitizedBranch}`, {
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 execAsync('git branch --show-current', { cwd: worktreePath });
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 execAsync(`git worktree remove "${worktreePath}" --force`, { cwd: projectPath });
60
+ await execFileAsync('git', ['worktree', 'remove', worktreePath, '--force'], { cwd: projectPath });
61
61
  if (deleteBranch && branchName) {
62
- await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
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 execAsync('git rev-parse --abbrev-ref HEAD', { cwd });
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.2.0",
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
  }