@vicoa/opencode 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.
- package/LICENSE +201 -0
- package/README.md +104 -0
- package/dist/commands.d.ts +24 -0
- package/dist/commands.js +228 -0
- package/dist/credentials.d.ts +27 -0
- package/dist/credentials.js +56 -0
- package/dist/format-utils.d.ts +10 -0
- package/dist/format-utils.js +335 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +538 -0
- package/dist/message-poller.d.ts +16 -0
- package/dist/message-poller.js +45 -0
- package/dist/plugin/commands.d.ts +24 -0
- package/dist/plugin/commands.js +228 -0
- package/dist/plugin/control.d.ts +15 -0
- package/dist/plugin/control.js +140 -0
- package/dist/plugin/credentials.d.ts +27 -0
- package/dist/plugin/credentials.js +56 -0
- package/dist/plugin/file-sync.d.ts +5 -0
- package/dist/plugin/file-sync.js +187 -0
- package/dist/plugin/format-utils.d.ts +10 -0
- package/dist/plugin/format-utils.js +339 -0
- package/dist/plugin/message-poller.d.ts +16 -0
- package/dist/plugin/message-poller.js +45 -0
- package/dist/plugin/path-utils.d.ts +45 -0
- package/dist/plugin/path-utils.js +62 -0
- package/dist/plugin/permission.d.ts +30 -0
- package/dist/plugin/permission.js +125 -0
- package/dist/plugin/utils.d.ts +7 -0
- package/dist/plugin/utils.js +15 -0
- package/dist/plugin/vicoa-client.d.ts +67 -0
- package/dist/plugin/vicoa-client.js +259 -0
- package/dist/vicoa-client.d.ts +67 -0
- package/dist/vicoa-client.js +259 -0
- package/package.json +51 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import { log } from './utils.js';
|
|
4
|
+
export const OPENCODE_SLASH_AGENT_TYPE = 'opencode';
|
|
5
|
+
export const OPENCODE_SLASH_COMMAND_ACTIONS = {
|
|
6
|
+
sessions: 'session.list',
|
|
7
|
+
resume: 'session.list',
|
|
8
|
+
continue: 'session.list',
|
|
9
|
+
new: 'session.new',
|
|
10
|
+
clear: 'session.new',
|
|
11
|
+
models: 'model.list',
|
|
12
|
+
agents: 'agent.list',
|
|
13
|
+
mcps: 'mcp.list',
|
|
14
|
+
connect: 'provider.connect',
|
|
15
|
+
status: 'opencode.status',
|
|
16
|
+
themes: 'theme.switch',
|
|
17
|
+
help: 'help.show',
|
|
18
|
+
exit: 'app.exit',
|
|
19
|
+
quit: 'app.exit',
|
|
20
|
+
q: 'app.exit',
|
|
21
|
+
editor: 'prompt.editor',
|
|
22
|
+
share: 'session.share',
|
|
23
|
+
rename: 'session.rename',
|
|
24
|
+
timeline: 'session.timeline',
|
|
25
|
+
fork: 'session.fork',
|
|
26
|
+
compact: 'session.compact',
|
|
27
|
+
summarize: 'session.compact',
|
|
28
|
+
unshare: 'session.unshare',
|
|
29
|
+
undo: 'session.undo',
|
|
30
|
+
redo: 'session.redo',
|
|
31
|
+
timestamps: 'session.toggle.timestamps',
|
|
32
|
+
'toggle-timestamps': 'session.toggle.timestamps',
|
|
33
|
+
thinking: 'session.toggle.thinking',
|
|
34
|
+
'toggle-thinking': 'session.toggle.thinking',
|
|
35
|
+
copy: 'session.copy',
|
|
36
|
+
export: 'session.export',
|
|
37
|
+
};
|
|
38
|
+
export const OPENCODE_EXECUTE_COMMAND_KEYS = {
|
|
39
|
+
'session.new': 'session_new',
|
|
40
|
+
'session.share': 'session_share',
|
|
41
|
+
'session.interrupt': 'session_interrupt',
|
|
42
|
+
'session.compact': 'session_compact',
|
|
43
|
+
'session.page.up': 'messages_page_up',
|
|
44
|
+
'session.page.down': 'messages_page_down',
|
|
45
|
+
'session.line.up': 'messages_line_up',
|
|
46
|
+
'session.line.down': 'messages_line_down',
|
|
47
|
+
'session.half.page.up': 'messages_half_page_up',
|
|
48
|
+
'session.half.page.down': 'messages_half_page_down',
|
|
49
|
+
'session.first': 'messages_first',
|
|
50
|
+
'session.last': 'messages_last',
|
|
51
|
+
'agent.cycle': 'agent_cycle',
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Execute a TUI command via the OpenCode client.
|
|
55
|
+
*/
|
|
56
|
+
export async function executeTuiCommand(client, command) {
|
|
57
|
+
const executeKey = OPENCODE_EXECUTE_COMMAND_KEYS[command];
|
|
58
|
+
if (executeKey) {
|
|
59
|
+
await client.tui.executeCommand({ body: { command: executeKey } });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await client.tui.publish({
|
|
63
|
+
body: {
|
|
64
|
+
type: 'tui.command.execute',
|
|
65
|
+
properties: { command },
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export function parseSlashCommand(input) {
|
|
70
|
+
const trimmed = input.trim();
|
|
71
|
+
if (!trimmed.startsWith('/')) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const body = trimmed.slice(1).trim();
|
|
75
|
+
if (!body) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const [rawName, ...rest] = body.split(/\s+/);
|
|
79
|
+
if (!rawName) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
rawName,
|
|
84
|
+
name: rawName.toLowerCase(),
|
|
85
|
+
arguments: rest.join(' ').trim(),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async function pathExists(target) {
|
|
89
|
+
try {
|
|
90
|
+
await fs.stat(target);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function walkMarkdownFiles(root) {
|
|
98
|
+
const results = [];
|
|
99
|
+
if (!(await pathExists(root))) {
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const fullPath = path.join(root, entry.name);
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
results.push(...(await walkMarkdownFiles(fullPath)));
|
|
107
|
+
}
|
|
108
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
|
109
|
+
results.push(fullPath);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
function getOpencodeCommandRoots(projectDir, homeDir) {
|
|
115
|
+
const roots = new Set();
|
|
116
|
+
if (process.env.OPENCODE_CONFIG_DIR) {
|
|
117
|
+
roots.add(process.env.OPENCODE_CONFIG_DIR);
|
|
118
|
+
}
|
|
119
|
+
if (process.env.XDG_CONFIG_HOME) {
|
|
120
|
+
roots.add(path.join(process.env.XDG_CONFIG_HOME, 'opencode'));
|
|
121
|
+
}
|
|
122
|
+
roots.add(path.join(homeDir, '.config', 'opencode'));
|
|
123
|
+
if (process.platform === 'win32') {
|
|
124
|
+
const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
|
|
125
|
+
roots.add(path.join(appData, 'opencode'));
|
|
126
|
+
}
|
|
127
|
+
// ~/.opencode fallback (all platforms).
|
|
128
|
+
roots.add(path.join(homeDir, '.opencode'));
|
|
129
|
+
// Per-project override.
|
|
130
|
+
if (projectDir) {
|
|
131
|
+
roots.add(path.join(projectDir, '.opencode'));
|
|
132
|
+
}
|
|
133
|
+
return Array.from(roots);
|
|
134
|
+
}
|
|
135
|
+
function parseCommandDescription(content, fallbackName) {
|
|
136
|
+
const lines = content.split('\n');
|
|
137
|
+
let description = '';
|
|
138
|
+
if (lines.length > 0 && lines[0].trim() === '---') {
|
|
139
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
140
|
+
const line = lines[i].trim();
|
|
141
|
+
if (line === '---') {
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
const [key, value] = line.split(':', 2).map((item) => item?.trim());
|
|
145
|
+
if (key?.toLowerCase() === 'description' && value) {
|
|
146
|
+
description = value.replace(/^['"]|['"]$/g, '').trim();
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!description) {
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
const stripped = line.trim();
|
|
154
|
+
if (!stripped) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
description = stripped.startsWith('#') ? stripped.replace(/^#+/, '').trim() : stripped;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return description || `Custom command: ${fallbackName}`;
|
|
162
|
+
}
|
|
163
|
+
export async function scanOpencodeCommands(projectDir, homeDir) {
|
|
164
|
+
const commands = {};
|
|
165
|
+
const roots = getOpencodeCommandRoots(projectDir, homeDir);
|
|
166
|
+
for (const root of roots) {
|
|
167
|
+
const commandRoot = path.join(root, 'commands');
|
|
168
|
+
const files = await walkMarkdownFiles(commandRoot);
|
|
169
|
+
for (const filePath of files) {
|
|
170
|
+
const relativePath = path.relative(commandRoot, filePath);
|
|
171
|
+
const normalized = relativePath.split(path.sep).join('/');
|
|
172
|
+
const commandName = normalized.replace(/\.md$/i, '');
|
|
173
|
+
if (!commandName) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
178
|
+
const description = parseCommandDescription(content, commandName);
|
|
179
|
+
commands[commandName] = { description };
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return commands;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Handle slash command execution. Returns true if the command was executed
|
|
190
|
+
* directly (built-in with no arguments), false if it should be submitted as
|
|
191
|
+
* a prompt (built-in with args, custom command, or unknown command).
|
|
192
|
+
*/
|
|
193
|
+
export async function handleSlashCommand(userMessage, client, currentSessionId, vicoaClient) {
|
|
194
|
+
const parsed = parseSlashCommand(userMessage);
|
|
195
|
+
if (!parsed) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
const action = OPENCODE_SLASH_COMMAND_ACTIONS[parsed.name];
|
|
199
|
+
// Built-in command with no arguments — use the direct TUI action shortcut.
|
|
200
|
+
if (action && !parsed.arguments) {
|
|
201
|
+
// Special handling for /share command: call the API directly to get the URL
|
|
202
|
+
if (parsed.name === 'share' && currentSessionId && vicoaClient) {
|
|
203
|
+
try {
|
|
204
|
+
const { data: session } = await client.session.share({
|
|
205
|
+
path: { id: currentSessionId },
|
|
206
|
+
});
|
|
207
|
+
if (session?.share?.url) {
|
|
208
|
+
await vicoaClient.sendMessage(`Share url: ${session.share.url}`);
|
|
209
|
+
log(client, 'info', `[Vicoa] Shared session and sent URL to UI: ${session.share.url}`);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
log(client, 'warn', '[Vicoa] Session shared but no URL in response');
|
|
213
|
+
}
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
log(client, 'warn', `[Vicoa] Failed to share session: ${error}`);
|
|
218
|
+
// Fall back to TUI command if API call fails
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
await executeTuiCommand(client, action);
|
|
222
|
+
log(client, 'info', `[Vicoa] Executed slash command: /${parsed.rawName}`);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
// Built-in with arguments, or a custom command — fall through so the raw
|
|
226
|
+
// slash command text is submitted as a prompt (OpenCode parses args natively).
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { VicoaClient } from './vicoa-client.js';
|
|
2
|
+
type ControlCommandContext = {
|
|
3
|
+
client: any;
|
|
4
|
+
vicoaClient: VicoaClient;
|
|
5
|
+
currentSessionId?: string;
|
|
6
|
+
getTuiCurrentAgent: () => string | undefined;
|
|
7
|
+
setTuiCurrentAgent: (agent: string | undefined) => void;
|
|
8
|
+
setPreferredAgent: (agent: string | undefined) => void;
|
|
9
|
+
};
|
|
10
|
+
export declare function cycleTuiToAgent(client: any, targetAgent: string, currentAgent: string | undefined): Promise<boolean>;
|
|
11
|
+
/**
|
|
12
|
+
* Handle control commands from Vicoa (matching Claude wrapper pattern)
|
|
13
|
+
*/
|
|
14
|
+
export declare function handleControlCommand(content: string, context: ControlCommandContext): Promise<boolean>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { executeTuiCommand } from './commands.js';
|
|
2
|
+
import { log } from './utils.js';
|
|
3
|
+
// Cycle the TUI's agent indicator to `targetAgent` by firing agent.cycle
|
|
4
|
+
// the right number of times. The TUI wraps around at the end of the list,
|
|
5
|
+
// so we only need (targetIndex - currentIndex + len) % len steps.
|
|
6
|
+
// Returns true if the cycle was attempted, false if we couldn't determine
|
|
7
|
+
// the current position (e.g. no user message seen yet).
|
|
8
|
+
export async function cycleTuiToAgent(client, targetAgent, currentAgent) {
|
|
9
|
+
try {
|
|
10
|
+
// Fetch the same filtered list the TUI uses: primary agents only, not hidden.
|
|
11
|
+
// hey-api returns { data, error, … } when throwOnError is false (the default).
|
|
12
|
+
const { data: allAgents } = await client.app.agents();
|
|
13
|
+
const primaryAgents = allAgents.filter((a) => a.mode !== 'subagent' && !a.hidden);
|
|
14
|
+
const targetIdx = primaryAgents.findIndex((a) => a.name === targetAgent);
|
|
15
|
+
if (targetIdx === -1)
|
|
16
|
+
return false; // shouldn't happen — already validated
|
|
17
|
+
// If we haven't seen a user message yet we don't know where the TUI is.
|
|
18
|
+
// Optimistic fallback: assume it's on index 0 (the default agent).
|
|
19
|
+
const currentName = currentAgent ?? primaryAgents[0]?.name;
|
|
20
|
+
const currentIdx = primaryAgents.findIndex((a) => a.name === currentName);
|
|
21
|
+
if (currentIdx === -1)
|
|
22
|
+
return false;
|
|
23
|
+
const steps = (targetIdx - currentIdx + primaryAgents.length) % primaryAgents.length;
|
|
24
|
+
for (let i = 0; i < steps; i++) {
|
|
25
|
+
await executeTuiCommand(client, 'agent.cycle');
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function extractControlPayload(content) {
|
|
34
|
+
const trimmed = content.trim();
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(trimmed);
|
|
37
|
+
const payload = parsed.control_command ?? (parsed.type === 'control' ? parsed : null);
|
|
38
|
+
if (payload) {
|
|
39
|
+
return payload;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Ignore and try to scan embedded JSON
|
|
44
|
+
}
|
|
45
|
+
const candidates = trimmed.match(/\{[^{}]*\}/g) ?? [];
|
|
46
|
+
for (const candidate of candidates) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(candidate);
|
|
49
|
+
const payload = parsed.control_command ?? (parsed.type === 'control' ? parsed : null);
|
|
50
|
+
if (payload) {
|
|
51
|
+
return payload;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Handle control commands from Vicoa (matching Claude wrapper pattern)
|
|
62
|
+
*/
|
|
63
|
+
export async function handleControlCommand(content, context) {
|
|
64
|
+
const { client, vicoaClient, currentSessionId, getTuiCurrentAgent, setPreferredAgent, setTuiCurrentAgent } = context;
|
|
65
|
+
// Try to parse as JSON control command
|
|
66
|
+
try {
|
|
67
|
+
const controlPayload = extractControlPayload(content);
|
|
68
|
+
if (controlPayload) {
|
|
69
|
+
const { setting, value } = controlPayload;
|
|
70
|
+
if (setting === 'interrupt') {
|
|
71
|
+
log(client, 'info', '[Vicoa] Interrupt command received');
|
|
72
|
+
if (!currentSessionId) {
|
|
73
|
+
await vicoaClient.sendMessage('Failed to interrupt');
|
|
74
|
+
log(client, 'warn', '[Vicoa] Interrupt failed: no active session');
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await executeTuiCommand(client, 'session.interrupt');
|
|
79
|
+
await executeTuiCommand(client, 'session.interrupt');
|
|
80
|
+
await vicoaClient.sendMessage('Interrupted');
|
|
81
|
+
log(client, 'info', '[Vicoa] Interrupted');
|
|
82
|
+
}
|
|
83
|
+
catch (tuiError) {
|
|
84
|
+
await vicoaClient.sendMessage('Failed to interrupt.');
|
|
85
|
+
log(client, 'error', `[Vicoa] TUI interrupt failed: ${tuiError}`);
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
if (setting === 'agent_type') {
|
|
90
|
+
const nextAgent = typeof value === 'string' ? value.toLowerCase() : '';
|
|
91
|
+
if (!nextAgent) {
|
|
92
|
+
log(client, 'warn', '[Vicoa] Agent type control command missing value');
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
let selectedAgent = nextAgent;
|
|
96
|
+
// Validate against the agents OpenCode actually knows about.
|
|
97
|
+
// Filter to primary (non-subagent, non-hidden) — same set the TUI cycles through.
|
|
98
|
+
try {
|
|
99
|
+
const { data: agents } = await client.app.agents();
|
|
100
|
+
const primaryAgents = agents.filter((a) => a.mode !== 'subagent' && !a.hidden);
|
|
101
|
+
const validNames = primaryAgents.map((a) => a.name);
|
|
102
|
+
const match = validNames.find((n) => n.toLowerCase() === nextAgent);
|
|
103
|
+
if (!match) {
|
|
104
|
+
log(client, 'warn', `[Vicoa] Unknown agent "${nextAgent}". Available: ${validNames.join(', ')}`);
|
|
105
|
+
await vicoaClient.sendMessage(`Unknown agent "${nextAgent}". Available agents: ${validNames.join(', ')}`);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
// Use the canonical casing returned by OpenCode
|
|
109
|
+
selectedAgent = match;
|
|
110
|
+
setPreferredAgent(match);
|
|
111
|
+
// Cycle the TUI indicator so the terminal also shows the new agent.
|
|
112
|
+
// session.prompt alone only affects a single message server-side;
|
|
113
|
+
// the TUI's displayed agent is purely client-side state mutated by
|
|
114
|
+
// agent.cycle commands.
|
|
115
|
+
const cycled = await cycleTuiToAgent(client, match, getTuiCurrentAgent());
|
|
116
|
+
if (cycled) {
|
|
117
|
+
setTuiCurrentAgent(match);
|
|
118
|
+
log(client, 'info', `[Vicoa] TUI agent indicator cycled to ${match}`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
log(client, 'warn', `[Vicoa] Could not cycle TUI to ${match}, will still route messages via session.prompt`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
// If agent listing fails, accept the value optimistically
|
|
126
|
+
log(client, 'warn', `[Vicoa] Could not list agents for validation: ${error}`);
|
|
127
|
+
setPreferredAgent(nextAgent);
|
|
128
|
+
}
|
|
129
|
+
await vicoaClient.sendMessage(`Agent changed to ${selectedAgent}.`);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
log(client, 'warn', `[Vicoa] Unknown control command: ${setting}`);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Not a JSON control command, treat as regular message
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials loader for Vicoa
|
|
3
|
+
*
|
|
4
|
+
* Reads API key from ~/.vicoa/credentials.json (same as Vicoa CLI)
|
|
5
|
+
*/
|
|
6
|
+
export interface Credentials {
|
|
7
|
+
write_key?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Get path to Vicoa credentials file
|
|
11
|
+
*/
|
|
12
|
+
export declare function getCredentialsPath(): string;
|
|
13
|
+
/**
|
|
14
|
+
* Load Vicoa API key from credentials file
|
|
15
|
+
*
|
|
16
|
+
* Returns the API key from ~/.vicoa/credentials.json if it exists,
|
|
17
|
+
* otherwise returns null.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadApiKey(): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Get Vicoa API key from environment or credentials file
|
|
22
|
+
*
|
|
23
|
+
* Priority:
|
|
24
|
+
* 1. VICOA_API_KEY environment variable
|
|
25
|
+
* 2. ~/.vicoa/credentials.json (write_key)
|
|
26
|
+
*/
|
|
27
|
+
export declare function getApiKey(): string | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials loader for Vicoa
|
|
3
|
+
*
|
|
4
|
+
* Reads API key from ~/.vicoa/credentials.json (same as Vicoa CLI)
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
/**
|
|
10
|
+
* Get path to Vicoa credentials file
|
|
11
|
+
*/
|
|
12
|
+
export function getCredentialsPath() {
|
|
13
|
+
return path.join(os.homedir(), '.vicoa', 'credentials.json');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Load Vicoa API key from credentials file
|
|
17
|
+
*
|
|
18
|
+
* Returns the API key from ~/.vicoa/credentials.json if it exists,
|
|
19
|
+
* otherwise returns null.
|
|
20
|
+
*/
|
|
21
|
+
export function loadApiKey() {
|
|
22
|
+
const credentialsPath = getCredentialsPath();
|
|
23
|
+
// Check if file exists
|
|
24
|
+
if (!fs.existsSync(credentialsPath)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const data = fs.readFileSync(credentialsPath, 'utf-8');
|
|
29
|
+
const credentials = JSON.parse(data);
|
|
30
|
+
const apiKey = credentials.write_key;
|
|
31
|
+
if (apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0) {
|
|
32
|
+
return apiKey.trim();
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
console.error(`[Vicoa] Error reading credentials file: ${error}`);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Get Vicoa API key from environment or credentials file
|
|
43
|
+
*
|
|
44
|
+
* Priority:
|
|
45
|
+
* 1. VICOA_API_KEY environment variable
|
|
46
|
+
* 2. ~/.vicoa/credentials.json (write_key)
|
|
47
|
+
*/
|
|
48
|
+
export function getApiKey() {
|
|
49
|
+
// Check environment variable first
|
|
50
|
+
const envKey = process.env.VICOA_API_KEY;
|
|
51
|
+
if (envKey && envKey.trim().length > 0) {
|
|
52
|
+
return envKey.trim();
|
|
53
|
+
}
|
|
54
|
+
// Fall back to credentials file
|
|
55
|
+
return loadApiKey();
|
|
56
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { formatProjectPath } from './path-utils.js';
|
|
4
|
+
// Common patterns to always exclude (matching Python version)
|
|
5
|
+
const DEFAULT_EXCLUDE_PATTERNS = [
|
|
6
|
+
'.git/',
|
|
7
|
+
'.pytest_cache/',
|
|
8
|
+
'.dart_tool/',
|
|
9
|
+
'.ruff_cache/',
|
|
10
|
+
'.mypy_cache/',
|
|
11
|
+
'__pycache__/',
|
|
12
|
+
'node_modules/',
|
|
13
|
+
'.venv/',
|
|
14
|
+
'venv/',
|
|
15
|
+
'.tox/',
|
|
16
|
+
'.eggs/',
|
|
17
|
+
'*.egg-info/',
|
|
18
|
+
'dist/',
|
|
19
|
+
'build/',
|
|
20
|
+
'target/', // Rust build output
|
|
21
|
+
'.next/', // Next.js
|
|
22
|
+
'.nuxt/', // Nuxt
|
|
23
|
+
'coverage/',
|
|
24
|
+
'.coverage',
|
|
25
|
+
'.nyc_output/',
|
|
26
|
+
'*.pyc',
|
|
27
|
+
'*.pyo',
|
|
28
|
+
'*.pyd',
|
|
29
|
+
];
|
|
30
|
+
// Extract directory names from exclude patterns
|
|
31
|
+
function getExcludedDirNames() {
|
|
32
|
+
const excluded = new Set();
|
|
33
|
+
for (const pattern of DEFAULT_EXCLUDE_PATTERNS) {
|
|
34
|
+
// Skip file patterns (contain wildcards)
|
|
35
|
+
if (pattern.includes('*')) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
// Strip trailing slashes and add to set
|
|
39
|
+
const dirName = pattern.replace(/\/$/, '');
|
|
40
|
+
if (dirName) {
|
|
41
|
+
excluded.add(dirName);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return excluded;
|
|
45
|
+
}
|
|
46
|
+
// Load .gitignore patterns
|
|
47
|
+
async function loadGitignorePatterns(projectPath) {
|
|
48
|
+
const gitignorePath = path.join(projectPath, '.gitignore');
|
|
49
|
+
try {
|
|
50
|
+
const content = await fs.readFile(gitignorePath, 'utf-8');
|
|
51
|
+
return content
|
|
52
|
+
.split('\n')
|
|
53
|
+
.map(line => line.trim())
|
|
54
|
+
.filter(line => line && !line.startsWith('#'));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Simple gitignore pattern matcher (basic implementation)
|
|
61
|
+
function matchesGitignorePattern(filePath, patterns) {
|
|
62
|
+
for (const pattern of patterns) {
|
|
63
|
+
const normalizedPattern = pattern.trim();
|
|
64
|
+
if (!normalizedPattern)
|
|
65
|
+
continue;
|
|
66
|
+
// Convert to regex for simple matching
|
|
67
|
+
const regexPattern = normalizedPattern
|
|
68
|
+
.replace(/\./g, '\\.')
|
|
69
|
+
.replace(/\*/g, '.*')
|
|
70
|
+
.replace(/\?/g, '.');
|
|
71
|
+
const regex = new RegExp(`^${regexPattern}`);
|
|
72
|
+
// Check both relative path and basename
|
|
73
|
+
if (regex.test(filePath) || regex.test(path.basename(filePath))) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
// Scan project files recursively
|
|
80
|
+
async function scanProjectFiles(projectPath, vicoaClient, maxFiles = 100000) {
|
|
81
|
+
const base = path.resolve(projectPath);
|
|
82
|
+
try {
|
|
83
|
+
const stats = await fs.stat(base);
|
|
84
|
+
if (!stats.isDirectory()) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
const excludePatterns = [...DEFAULT_EXCLUDE_PATTERNS];
|
|
92
|
+
const gitignorePatterns = await loadGitignorePatterns(base);
|
|
93
|
+
excludePatterns.push(...gitignorePatterns);
|
|
94
|
+
const skipDirs = getExcludedDirNames();
|
|
95
|
+
const files = [];
|
|
96
|
+
const folders = new Set();
|
|
97
|
+
async function walkDirectory(dirPath, relativePath = '') {
|
|
98
|
+
try {
|
|
99
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
// Skip excluded directories early
|
|
102
|
+
if (entry.isDirectory() && skipDirs.has(entry.name)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const entryRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
|
106
|
+
const entryFullPath = path.join(dirPath, entry.name);
|
|
107
|
+
if (entry.isDirectory()) {
|
|
108
|
+
await walkDirectory(entryFullPath, entryRelativePath);
|
|
109
|
+
// Add folder to results (with trailing slash)
|
|
110
|
+
const folderPath = entryRelativePath.replace(/\\/g, '/') + '/';
|
|
111
|
+
folders.add(folderPath);
|
|
112
|
+
}
|
|
113
|
+
else if (entry.isFile()) {
|
|
114
|
+
// Check if file should be excluded
|
|
115
|
+
if (matchesGitignorePattern(entryRelativePath, excludePatterns)) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
files.push(entryRelativePath.replace(/\\/g, '/'));
|
|
119
|
+
// Extract parent folders
|
|
120
|
+
const parts = entryRelativePath.split(path.sep);
|
|
121
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
122
|
+
const folderPath = parts.slice(0, i + 1).join('/') + '/';
|
|
123
|
+
folders.add(folderPath);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Stop at max_files limit
|
|
127
|
+
if (files.length >= maxFiles) {
|
|
128
|
+
vicoaClient.log('warn', `Large project detected: ${maxFiles}+ files found`);
|
|
129
|
+
vicoaClient.log('warn', `Only syncing first ${maxFiles} files for performance`);
|
|
130
|
+
vicoaClient.log('warn', 'File mentions may be incomplete');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
vicoaClient.log('warn', `Error scanning directory ${dirPath}: ${error}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
await walkDirectory(base);
|
|
140
|
+
// Combine folders and files
|
|
141
|
+
return Array.from(folders).concat(files);
|
|
142
|
+
}
|
|
143
|
+
// Sync project files to Vicoa backend
|
|
144
|
+
export async function syncProjectFiles(vicoaClient, projectPath) {
|
|
145
|
+
try {
|
|
146
|
+
// Use absolute path for file scanning (needed for fs operations)
|
|
147
|
+
const absolutePath = path.resolve(projectPath);
|
|
148
|
+
vicoaClient.log('info', 'Preparing fuzzy file search with @ ...');
|
|
149
|
+
const files = await scanProjectFiles(absolutePath, vicoaClient);
|
|
150
|
+
if (files.length === 0) {
|
|
151
|
+
return; // No files to sync
|
|
152
|
+
}
|
|
153
|
+
// Send tilde path to backend (consistent with Claude wrapper)
|
|
154
|
+
const formattedPath = formatProjectPath(absolutePath);
|
|
155
|
+
await vicoaClient.syncFiles(formattedPath, files);
|
|
156
|
+
vicoaClient.log('info', `Synced ${files.length} files for fuzzy search`);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
vicoaClient.log('warn', `Failed to sync project files: ${error}`);
|
|
160
|
+
// Silently fail - don't block plugin startup
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Backup vicoa client api call here
|
|
164
|
+
/**
|
|
165
|
+
* Sync project files to Vicoa backend for @ mentions
|
|
166
|
+
*/
|
|
167
|
+
// async syncFiles(projectPath: string, files: string[]): Promise<void> {
|
|
168
|
+
// try {
|
|
169
|
+
// const response = await fetch(`${this.config.baseUrl}/api/v1/files/sync`, {
|
|
170
|
+
// method: 'POST',
|
|
171
|
+
// headers: {
|
|
172
|
+
// 'Authorization': `Bearer ${this.config.apiKey}`,
|
|
173
|
+
// 'Content-Type': 'application/json',
|
|
174
|
+
// },
|
|
175
|
+
// body: JSON.stringify({
|
|
176
|
+
// project_path: projectPath,
|
|
177
|
+
// files,
|
|
178
|
+
// }),
|
|
179
|
+
// });
|
|
180
|
+
// if (!response.ok) {
|
|
181
|
+
// const error = await response.text();
|
|
182
|
+
// this.log('warn', `Failed to sync project files: ${response.statusText} - ${error}`);
|
|
183
|
+
// }
|
|
184
|
+
// } catch (error) {
|
|
185
|
+
// this.log('warn', `Error syncing project files: ${error}`);
|
|
186
|
+
// }
|
|
187
|
+
// }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FilePart, PatchPart, ReasoningPart, ToolPart } from '@opencode-ai/sdk';
|
|
2
|
+
type ToolInput = Record<string, unknown>;
|
|
3
|
+
export declare function formatToolUsage(toolName: string, inputData: ToolInput): string;
|
|
4
|
+
export declare function formatToolResult(output: string, toolName?: string): string;
|
|
5
|
+
export declare function shouldSuppressToolOutput(toolName: string): boolean;
|
|
6
|
+
export declare function formatToolPart(toolPart: ToolPart): string;
|
|
7
|
+
export declare function formatReasoningPart(part: ReasoningPart): string;
|
|
8
|
+
export declare function formatFilePart(part: FilePart): string;
|
|
9
|
+
export declare function formatPatchPart(part: PatchPart): string;
|
|
10
|
+
export {};
|