@junwu168/openshell 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/.claude/settings.local.json +10 -0
- package/README.md +63 -0
- package/bun.lock +368 -0
- package/dist/cli/openshell.d.ts +13 -0
- package/dist/cli/openshell.js +41 -0
- package/dist/cli/server-registry.d.ts +22 -0
- package/dist/cli/server-registry.js +349 -0
- package/dist/core/audit/git-audit-repo.d.ts +10 -0
- package/dist/core/audit/git-audit-repo.js +38 -0
- package/dist/core/audit/log-store.d.ts +4 -0
- package/dist/core/audit/log-store.js +17 -0
- package/dist/core/audit/redact.d.ts +1 -0
- package/dist/core/audit/redact.js +3 -0
- package/dist/core/contracts.d.ts +28 -0
- package/dist/core/contracts.js +1 -0
- package/dist/core/orchestrator.d.ts +110 -0
- package/dist/core/orchestrator.js +825 -0
- package/dist/core/patch.d.ts +1 -0
- package/dist/core/patch.js +8 -0
- package/dist/core/paths.d.ts +26 -0
- package/dist/core/paths.js +26 -0
- package/dist/core/policy.d.ts +16 -0
- package/dist/core/policy.js +29 -0
- package/dist/core/registry/server-registry.d.ts +59 -0
- package/dist/core/registry/server-registry.js +350 -0
- package/dist/core/result.d.ts +4 -0
- package/dist/core/result.js +12 -0
- package/dist/core/ssh/ssh-runtime.d.ts +31 -0
- package/dist/core/ssh/ssh-runtime.js +240 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/opencode/plugin.d.ts +10 -0
- package/dist/opencode/plugin.js +183 -0
- package/dist/product/install.d.ts +12 -0
- package/dist/product/install.js +25 -0
- package/dist/product/opencode-config.d.ts +15 -0
- package/dist/product/opencode-config.js +93 -0
- package/dist/product/uninstall.d.ts +12 -0
- package/dist/product/uninstall.js +27 -0
- package/dist/product/workspace-tracker.d.ts +11 -0
- package/dist/product/workspace-tracker.js +48 -0
- package/docs/superpowers/notes/2026-03-25-opencode-remote-tools-handoff.md +81 -0
- package/docs/superpowers/notes/2026-03-26-openshell-pre-release-review.md +174 -0
- package/docs/superpowers/plans/2026-03-25-opencode-remote-tools.md +1656 -0
- package/docs/superpowers/plans/2026-03-25-server-registry-cli.md +54 -0
- package/docs/superpowers/plans/2026-03-26-config-backed-credential-registry.md +494 -0
- package/docs/superpowers/plans/2026-03-26-openshell-release-prep.md +639 -0
- package/docs/superpowers/specs/2026-03-25-opencode-remote-tools-design.md +378 -0
- package/docs/superpowers/specs/2026-03-26-config-backed-credential-registry-design.md +272 -0
- package/docs/superpowers/specs/2026-03-26-openshell-release-prep-design.md +197 -0
- package/examples/opencode-local/opencode.json +19 -0
- package/package.json +33 -0
- package/scripts/openshell.ts +3 -0
- package/scripts/server-registry.ts +3 -0
- package/src/cli/openshell.ts +59 -0
- package/src/cli/server-registry.ts +470 -0
- package/src/core/audit/git-audit-repo.ts +42 -0
- package/src/core/audit/log-store.ts +20 -0
- package/src/core/audit/redact.ts +4 -0
- package/src/core/contracts.ts +51 -0
- package/src/core/orchestrator.ts +1082 -0
- package/src/core/patch.ts +11 -0
- package/src/core/paths.ts +32 -0
- package/src/core/policy.ts +30 -0
- package/src/core/registry/server-registry.ts +505 -0
- package/src/core/result.ts +16 -0
- package/src/core/ssh/ssh-runtime.ts +355 -0
- package/src/index.ts +3 -0
- package/src/opencode/plugin.ts +242 -0
- package/src/product/install.ts +43 -0
- package/src/product/opencode-config.ts +118 -0
- package/src/product/uninstall.ts +47 -0
- package/src/product/workspace-tracker.ts +69 -0
- package/tests/integration/fake-ssh-server.ts +97 -0
- package/tests/integration/install-lifecycle.test.ts +85 -0
- package/tests/integration/orchestrator.test.ts +767 -0
- package/tests/integration/ssh-runtime.test.ts +122 -0
- package/tests/unit/audit.test.ts +221 -0
- package/tests/unit/build-layout.test.ts +28 -0
- package/tests/unit/opencode-config.test.ts +100 -0
- package/tests/unit/opencode-plugin.test.ts +358 -0
- package/tests/unit/openshell-cli.test.ts +60 -0
- package/tests/unit/paths.test.ts +64 -0
- package/tests/unit/plugin-export.test.ts +10 -0
- package/tests/unit/policy.test.ts +53 -0
- package/tests/unit/release-docs.test.ts +31 -0
- package/tests/unit/result.test.ts +28 -0
- package/tests/unit/server-registry-cli.test.ts +673 -0
- package/tests/unit/server-registry.test.ts +452 -0
- package/tests/unit/workspace-tracker.test.ts +57 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { constants as osConstants } from "node:os";
|
|
2
|
+
import { Shescape } from "shescape";
|
|
3
|
+
import { Client } from "ssh2";
|
|
4
|
+
const shell = new Shescape({ shell: "zsh" });
|
|
5
|
+
const DEFAULT_OPERATION_TIMEOUT_MS = 30_000;
|
|
6
|
+
const normalizeShellPath = (path) => (path.startsWith("-") ? `./${path}` : path);
|
|
7
|
+
const quoteShellPath = (path) => shell.quote(normalizeShellPath(path));
|
|
8
|
+
const clampLimit = (limit) => Math.max(0, Math.trunc(limit));
|
|
9
|
+
const joinRemotePath = (parent, name) => (parent.endsWith("/") ? `${parent}${name}` : `${parent}/${name}`);
|
|
10
|
+
const signalToExitCode = (signal) => {
|
|
11
|
+
if (!signal) {
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
const normalized = signal.startsWith("SIG") ? signal : `SIG${signal}`;
|
|
15
|
+
const signalNumber = osConstants.signals[normalized];
|
|
16
|
+
return signalNumber === undefined ? 1 : 128 + signalNumber;
|
|
17
|
+
};
|
|
18
|
+
const withClient = (connection, action, timeoutMs = null) => new Promise((resolve, reject) => {
|
|
19
|
+
const client = new Client();
|
|
20
|
+
let settled = false;
|
|
21
|
+
let timer = null;
|
|
22
|
+
const finish = (handler, close = () => client.end()) => {
|
|
23
|
+
if (settled) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
settled = true;
|
|
27
|
+
if (timer) {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
}
|
|
30
|
+
handler();
|
|
31
|
+
close();
|
|
32
|
+
};
|
|
33
|
+
if (timeoutMs !== null) {
|
|
34
|
+
timer = setTimeout(() => {
|
|
35
|
+
finish(() => reject(new Error(`ssh operation timed out after ${timeoutMs}ms`)), () => client.destroy());
|
|
36
|
+
}, timeoutMs);
|
|
37
|
+
}
|
|
38
|
+
client
|
|
39
|
+
.on("ready", () => {
|
|
40
|
+
action(client).then((result) => finish(() => resolve(result)), (error) => finish(() => reject(error)));
|
|
41
|
+
})
|
|
42
|
+
.on("error", (error) => finish(() => reject(error)))
|
|
43
|
+
.connect(timeoutMs === null ? connection : { ...connection, readyTimeout: connection.readyTimeout ?? timeoutMs });
|
|
44
|
+
});
|
|
45
|
+
export const createSshRuntime = (options = {}) => {
|
|
46
|
+
const operationTimeoutMs = options.operationTimeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS;
|
|
47
|
+
const exec = (connection, command, options = {}) => withClient(connection, (client) => new Promise((resolve, reject) => {
|
|
48
|
+
const effective = options.cwd ? `cd -- ${quoteShellPath(options.cwd)} && ${command}` : command;
|
|
49
|
+
let settled = false;
|
|
50
|
+
let streamRef = null;
|
|
51
|
+
const finish = (handler) => {
|
|
52
|
+
if (settled) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
settled = true;
|
|
56
|
+
if (timer) {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
handler();
|
|
60
|
+
};
|
|
61
|
+
const timer = options.timeout === undefined
|
|
62
|
+
? null
|
|
63
|
+
: setTimeout(() => {
|
|
64
|
+
if (streamRef) {
|
|
65
|
+
try {
|
|
66
|
+
streamRef.signal("SIGKILL");
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
69
|
+
try {
|
|
70
|
+
streamRef.close();
|
|
71
|
+
}
|
|
72
|
+
catch { }
|
|
73
|
+
}
|
|
74
|
+
finish(() => reject(new Error(`command timed out after ${options.timeout}ms`)));
|
|
75
|
+
}, options.timeout);
|
|
76
|
+
client.exec(effective, (error, stream) => {
|
|
77
|
+
if (error) {
|
|
78
|
+
finish(() => reject(error));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
streamRef = stream;
|
|
82
|
+
let stdout = "";
|
|
83
|
+
let stderr = "";
|
|
84
|
+
let exitCode = 0;
|
|
85
|
+
stream.on("data", (chunk) => {
|
|
86
|
+
stdout += chunk.toString();
|
|
87
|
+
});
|
|
88
|
+
stream.stderr.on("data", (chunk) => {
|
|
89
|
+
stderr += chunk.toString();
|
|
90
|
+
});
|
|
91
|
+
stream.on("exit", (code, signal) => {
|
|
92
|
+
exitCode = code ?? signalToExitCode(signal);
|
|
93
|
+
});
|
|
94
|
+
stream.on("close", () => {
|
|
95
|
+
finish(() => resolve({ stdout, stderr, exitCode }));
|
|
96
|
+
});
|
|
97
|
+
stream.on("error", (streamError) => {
|
|
98
|
+
finish(() => reject(streamError));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}), null);
|
|
102
|
+
const readFile = (connection, path) => withClient(connection, (client) => new Promise((resolve, reject) => {
|
|
103
|
+
client.sftp((error, sftp) => {
|
|
104
|
+
if (error) {
|
|
105
|
+
reject(error);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const chunks = [];
|
|
109
|
+
const stream = sftp.createReadStream(path);
|
|
110
|
+
stream.on("data", (chunk) => {
|
|
111
|
+
chunks.push(Buffer.from(chunk));
|
|
112
|
+
});
|
|
113
|
+
stream.on("error", reject);
|
|
114
|
+
stream.on("close", () => {
|
|
115
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}), operationTimeoutMs);
|
|
119
|
+
const writeFile = (connection, path, content, mode) => withClient(connection, (client) => new Promise((resolve, reject) => {
|
|
120
|
+
client.sftp((error, sftp) => {
|
|
121
|
+
if (error) {
|
|
122
|
+
reject(error);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const stream = sftp.createWriteStream(path, mode === undefined ? undefined : { mode });
|
|
126
|
+
stream.on("error", reject);
|
|
127
|
+
stream.on("close", () => resolve());
|
|
128
|
+
stream.end(content);
|
|
129
|
+
});
|
|
130
|
+
}), operationTimeoutMs);
|
|
131
|
+
const listDir = async (connection, path, recursive = false, limit = 200) => {
|
|
132
|
+
const boundedLimit = clampLimit(limit);
|
|
133
|
+
if (boundedLimit === 0) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
if (recursive) {
|
|
137
|
+
return withClient(connection, (client) => new Promise((resolve, reject) => {
|
|
138
|
+
client.sftp((error, sftp) => {
|
|
139
|
+
if (error) {
|
|
140
|
+
reject(error);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const statPath = (target) => new Promise((resolvePath, rejectPath) => {
|
|
144
|
+
sftp.stat(target, (statError, stats) => {
|
|
145
|
+
if (statError) {
|
|
146
|
+
rejectPath(statError);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
resolvePath(stats);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
const readDir = (target) => new Promise((resolvePath, rejectPath) => {
|
|
153
|
+
sftp.readdir(target, (readError, entries) => {
|
|
154
|
+
if (readError) {
|
|
155
|
+
rejectPath(readError);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
resolvePath(entries);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
const visit = async (target, output) => {
|
|
162
|
+
if (output.length >= boundedLimit) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
output.push(target);
|
|
166
|
+
if (output.length >= boundedLimit) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const targetStats = await statPath(target);
|
|
170
|
+
if (!targetStats.isDirectory()) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const entries = await readDir(target);
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
if (entry.filename === "." || entry.filename === "..") {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const fullPath = joinRemotePath(target, entry.filename);
|
|
179
|
+
if (entry.attrs.isDirectory()) {
|
|
180
|
+
await visit(fullPath, output);
|
|
181
|
+
}
|
|
182
|
+
else if (output.length < boundedLimit) {
|
|
183
|
+
output.push(fullPath);
|
|
184
|
+
}
|
|
185
|
+
if (output.length >= boundedLimit) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
void (async () => {
|
|
191
|
+
try {
|
|
192
|
+
const output = [];
|
|
193
|
+
await visit(path, output);
|
|
194
|
+
resolve(output);
|
|
195
|
+
}
|
|
196
|
+
catch (visitError) {
|
|
197
|
+
reject(visitError);
|
|
198
|
+
}
|
|
199
|
+
})();
|
|
200
|
+
});
|
|
201
|
+
}), operationTimeoutMs);
|
|
202
|
+
}
|
|
203
|
+
return withClient(connection, (client) => new Promise((resolve, reject) => {
|
|
204
|
+
client.sftp((error, sftp) => {
|
|
205
|
+
if (error) {
|
|
206
|
+
reject(error);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
sftp.readdir(path, (readError, entries) => {
|
|
210
|
+
if (readError) {
|
|
211
|
+
reject(readError);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
resolve(entries.slice(0, boundedLimit).map((entry) => ({ name: entry.filename, longname: entry.longname })));
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}), operationTimeoutMs);
|
|
218
|
+
};
|
|
219
|
+
const stat = (connection, path) => withClient(connection, (client) => new Promise((resolve, reject) => {
|
|
220
|
+
client.sftp((error, sftp) => {
|
|
221
|
+
if (error) {
|
|
222
|
+
reject(error);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
sftp.stat(path, (statError, stats) => {
|
|
226
|
+
if (statError) {
|
|
227
|
+
reject(statError);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
resolve({
|
|
231
|
+
size: stats.size,
|
|
232
|
+
mode: stats.mode,
|
|
233
|
+
isFile: stats.isFile(),
|
|
234
|
+
isDirectory: stats.isDirectory(),
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}), operationTimeoutMs);
|
|
239
|
+
return { exec, readFile, writeFile, listDir, stat };
|
|
240
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { createOrchestrator } from "../core/orchestrator";
|
|
3
|
+
type RuntimeDependencies = Parameters<typeof createOrchestrator>[0];
|
|
4
|
+
type OpenCodePluginOptions = {
|
|
5
|
+
ensureRuntimeDirs?: () => Promise<void>;
|
|
6
|
+
createRuntimeDependencies?: (workspaceRoot?: string) => RuntimeDependencies;
|
|
7
|
+
};
|
|
8
|
+
export declare const createOpenCodePlugin: (options?: OpenCodePluginOptions) => Plugin;
|
|
9
|
+
export declare const OpenCodePlugin: Plugin;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { createAuditLogStore } from "../core/audit/log-store";
|
|
3
|
+
import { createGitAuditRepo } from "../core/audit/git-audit-repo";
|
|
4
|
+
import { createOrchestrator } from "../core/orchestrator";
|
|
5
|
+
import { classifyRemoteExec } from "../core/policy";
|
|
6
|
+
import { createRuntimePaths, ensureRuntimeDirs } from "../core/paths";
|
|
7
|
+
import { createServerRegistry } from "../core/registry/server-registry";
|
|
8
|
+
import { errorResult } from "../core/result";
|
|
9
|
+
import { createSshRuntime } from "../core/ssh/ssh-runtime";
|
|
10
|
+
const serialize = async (result) => JSON.stringify(await result);
|
|
11
|
+
const approvalRejected = (toolId, server, error) => JSON.stringify(errorResult({
|
|
12
|
+
tool: toolId,
|
|
13
|
+
server,
|
|
14
|
+
code: "APPROVAL_REJECTED",
|
|
15
|
+
message: error instanceof Error ? error.message : "approval rejected",
|
|
16
|
+
execution: { attempted: false, completed: false },
|
|
17
|
+
audit: { logWritten: false, snapshotStatus: "not-applicable" },
|
|
18
|
+
}));
|
|
19
|
+
const requestApproval = async (context, toolId, server, request) => {
|
|
20
|
+
if (!request) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
await context.ask(request);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return approvalRejected(toolId, server, error);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const createEditApproval = (toolId, input) => ({
|
|
32
|
+
permission: "edit",
|
|
33
|
+
patterns: [input.path],
|
|
34
|
+
always: [],
|
|
35
|
+
metadata: {
|
|
36
|
+
tool: toolId,
|
|
37
|
+
server: input.server,
|
|
38
|
+
path: input.path,
|
|
39
|
+
mode: input.mode,
|
|
40
|
+
contentBytes: input.content ? Buffer.byteLength(input.content) : undefined,
|
|
41
|
+
patchBytes: input.patch ? Buffer.byteLength(input.patch) : undefined,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const createRemoteExecApproval = (input) => {
|
|
45
|
+
const classification = classifyRemoteExec(input.command);
|
|
46
|
+
if (classification.decision !== "approval-required") {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
permission: "bash",
|
|
51
|
+
patterns: [input.command],
|
|
52
|
+
always: [],
|
|
53
|
+
metadata: {
|
|
54
|
+
tool: "remote_exec",
|
|
55
|
+
server: input.server,
|
|
56
|
+
command: input.command,
|
|
57
|
+
cwd: input.cwd,
|
|
58
|
+
timeout: input.timeout,
|
|
59
|
+
reason: classification.reason,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const createTools = (orchestrator) => ({
|
|
64
|
+
list_servers: tool({
|
|
65
|
+
description: "List configured remote servers.",
|
|
66
|
+
args: {},
|
|
67
|
+
execute: async () => serialize(orchestrator.listServers()),
|
|
68
|
+
}),
|
|
69
|
+
remote_exec: tool({
|
|
70
|
+
description: "Execute a shell command on a remote server.",
|
|
71
|
+
args: {
|
|
72
|
+
server: tool.schema.string(),
|
|
73
|
+
command: tool.schema.string(),
|
|
74
|
+
cwd: tool.schema.string().optional(),
|
|
75
|
+
timeout: tool.schema.number().int().positive().optional(),
|
|
76
|
+
},
|
|
77
|
+
execute: async ({ server, command, cwd, timeout }, context) => {
|
|
78
|
+
const rejected = await requestApproval(context, "remote_exec", server, createRemoteExecApproval({ server, command, cwd, timeout }));
|
|
79
|
+
if (rejected) {
|
|
80
|
+
return rejected;
|
|
81
|
+
}
|
|
82
|
+
return serialize(orchestrator.remoteExec({ server, command, cwd, timeout }));
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
remote_read_file: tool({
|
|
86
|
+
description: "Read a remote file.",
|
|
87
|
+
args: {
|
|
88
|
+
server: tool.schema.string(),
|
|
89
|
+
path: tool.schema.string(),
|
|
90
|
+
offset: tool.schema.number().int().nonnegative().optional(),
|
|
91
|
+
length: tool.schema.number().int().positive().optional(),
|
|
92
|
+
},
|
|
93
|
+
execute: async ({ server, path, offset, length }) => serialize(orchestrator.remoteReadFile({ server, path, offset, length })),
|
|
94
|
+
}),
|
|
95
|
+
remote_write_file: tool({
|
|
96
|
+
description: "Write content to a remote file.",
|
|
97
|
+
args: {
|
|
98
|
+
server: tool.schema.string(),
|
|
99
|
+
path: tool.schema.string(),
|
|
100
|
+
content: tool.schema.string(),
|
|
101
|
+
mode: tool.schema.number().int().positive().optional(),
|
|
102
|
+
},
|
|
103
|
+
execute: async ({ server, path, content, mode }, context) => {
|
|
104
|
+
const rejected = await requestApproval(context, "remote_write_file", server, createEditApproval("remote_write_file", { server, path, content, mode }));
|
|
105
|
+
if (rejected) {
|
|
106
|
+
return rejected;
|
|
107
|
+
}
|
|
108
|
+
return serialize(orchestrator.remoteWriteFile({ server, path, content, mode }));
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
remote_patch_file: tool({
|
|
112
|
+
description: "Apply a unified diff to a remote file.",
|
|
113
|
+
args: {
|
|
114
|
+
server: tool.schema.string(),
|
|
115
|
+
path: tool.schema.string(),
|
|
116
|
+
patch: tool.schema.string(),
|
|
117
|
+
},
|
|
118
|
+
execute: async ({ server, path, patch }, context) => {
|
|
119
|
+
const rejected = await requestApproval(context, "remote_patch_file", server, createEditApproval("remote_patch_file", { server, path, patch }));
|
|
120
|
+
if (rejected) {
|
|
121
|
+
return rejected;
|
|
122
|
+
}
|
|
123
|
+
return serialize(orchestrator.remotePatchFile({ server, path, patch }));
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
remote_list_dir: tool({
|
|
127
|
+
description: "List a remote directory.",
|
|
128
|
+
args: {
|
|
129
|
+
server: tool.schema.string(),
|
|
130
|
+
path: tool.schema.string(),
|
|
131
|
+
recursive: tool.schema.boolean().optional(),
|
|
132
|
+
limit: tool.schema.number().int().positive().optional(),
|
|
133
|
+
},
|
|
134
|
+
execute: async ({ server, path, recursive, limit }) => serialize(orchestrator.remoteListDir({ server, path, recursive, limit })),
|
|
135
|
+
}),
|
|
136
|
+
remote_stat: tool({
|
|
137
|
+
description: "Stat a remote path.",
|
|
138
|
+
args: {
|
|
139
|
+
server: tool.schema.string(),
|
|
140
|
+
path: tool.schema.string(),
|
|
141
|
+
},
|
|
142
|
+
execute: async ({ server, path }) => serialize(orchestrator.remoteStat({ server, path })),
|
|
143
|
+
}),
|
|
144
|
+
remote_find: tool({
|
|
145
|
+
description: "Search a remote directory for matching files or content.",
|
|
146
|
+
args: {
|
|
147
|
+
server: tool.schema.string(),
|
|
148
|
+
path: tool.schema.string(),
|
|
149
|
+
pattern: tool.schema.string(),
|
|
150
|
+
glob: tool.schema.string().optional(),
|
|
151
|
+
limit: tool.schema.number().int().positive().optional(),
|
|
152
|
+
},
|
|
153
|
+
execute: async ({ server, path, pattern, glob, limit }) => serialize(orchestrator.remoteFind({ server, path, pattern, glob, limit })),
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
const buildRuntimeDependencies = (workspaceRoot) => {
|
|
157
|
+
const paths = createRuntimePaths(workspaceRoot);
|
|
158
|
+
const registry = createServerRegistry({
|
|
159
|
+
globalRegistryFile: paths.globalRegistryFile,
|
|
160
|
+
workspaceRegistryFile: paths.workspaceRegistryFile,
|
|
161
|
+
workspaceRoot,
|
|
162
|
+
});
|
|
163
|
+
const auditLog = createAuditLogStore(paths.auditLogFile);
|
|
164
|
+
const auditRepo = createGitAuditRepo(paths.auditRepoDir);
|
|
165
|
+
return {
|
|
166
|
+
registry,
|
|
167
|
+
ssh: createSshRuntime(),
|
|
168
|
+
audit: {
|
|
169
|
+
preflightLog: () => auditLog.preflight(),
|
|
170
|
+
appendLog: (entry) => auditLog.append(entry),
|
|
171
|
+
preflightSnapshots: () => auditRepo.preflight(),
|
|
172
|
+
captureSnapshots: (input) => auditRepo.captureChange(input),
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
export const createOpenCodePlugin = (options = {}) => async (input) => {
|
|
177
|
+
await (options.ensureRuntimeDirs ?? ensureRuntimeDirs)();
|
|
178
|
+
const orchestrator = createOrchestrator((options.createRuntimeDependencies ?? buildRuntimeDependencies)(input.worktree));
|
|
179
|
+
return {
|
|
180
|
+
tool: createTools(orchestrator),
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
export const OpenCodePlugin = createOpenCodePlugin();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createRuntimePaths } from "../core/paths.js";
|
|
2
|
+
type WritableLike = {
|
|
3
|
+
write(chunk: string): void;
|
|
4
|
+
};
|
|
5
|
+
type RuntimePaths = ReturnType<typeof createRuntimePaths>;
|
|
6
|
+
type InstallOptions = {
|
|
7
|
+
runtimePaths: RuntimePaths;
|
|
8
|
+
stdout: WritableLike;
|
|
9
|
+
};
|
|
10
|
+
export declare const installOpenShell: ({ runtimePaths, stdout }: InstallOptions) => Promise<void>;
|
|
11
|
+
export declare const runInstallCli: (_argv?: string[], stream?: WritableLike) => Promise<number>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { cwd, stdout } from "node:process";
|
|
3
|
+
import { createRuntimePaths } from "../core/paths.js";
|
|
4
|
+
import { installIntoOpenCodeConfig } from "./opencode-config.js";
|
|
5
|
+
import { createWorkspaceTracker } from "./workspace-tracker.js";
|
|
6
|
+
export const installOpenShell = async ({ runtimePaths, stdout }) => {
|
|
7
|
+
await mkdir(runtimePaths.configDir, { recursive: true });
|
|
8
|
+
await mkdir(runtimePaths.dataDir, { recursive: true });
|
|
9
|
+
await mkdir(runtimePaths.opencodeConfigDir, { recursive: true });
|
|
10
|
+
await installIntoOpenCodeConfig(runtimePaths.opencodeConfigFile);
|
|
11
|
+
await createWorkspaceTracker(runtimePaths.workspaceTrackerFile).clear();
|
|
12
|
+
stdout.write([
|
|
13
|
+
"Installed openshell.",
|
|
14
|
+
`OpenShell config: ${runtimePaths.configDir}`,
|
|
15
|
+
`OpenShell data: ${runtimePaths.dataDir}`,
|
|
16
|
+
`OpenCode config: ${runtimePaths.opencodeConfigFile}`,
|
|
17
|
+
].join("\n") + "\n");
|
|
18
|
+
};
|
|
19
|
+
export const runInstallCli = async (_argv = [], stream = stdout) => {
|
|
20
|
+
await installOpenShell({
|
|
21
|
+
runtimePaths: createRuntimePaths(cwd()),
|
|
22
|
+
stdout: stream,
|
|
23
|
+
});
|
|
24
|
+
return 0;
|
|
25
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const defaultBashPermissions: {
|
|
2
|
+
readonly "*": "ask";
|
|
3
|
+
readonly "cat *": "allow";
|
|
4
|
+
readonly "grep *": "allow";
|
|
5
|
+
readonly "find *": "allow";
|
|
6
|
+
readonly "ls *": "allow";
|
|
7
|
+
readonly pwd: "allow";
|
|
8
|
+
readonly "uname *": "allow";
|
|
9
|
+
readonly "df *": "allow";
|
|
10
|
+
readonly "free *": "allow";
|
|
11
|
+
readonly "ps *": "allow";
|
|
12
|
+
readonly "systemctl status *": "allow";
|
|
13
|
+
};
|
|
14
|
+
export declare const installIntoOpenCodeConfig: (opencodeConfigFile: string) => Promise<void>;
|
|
15
|
+
export declare const uninstallFromOpenCodeConfig: (opencodeConfigFile: string) => Promise<void>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
export const defaultBashPermissions = {
|
|
4
|
+
"*": "ask",
|
|
5
|
+
"cat *": "allow",
|
|
6
|
+
"grep *": "allow",
|
|
7
|
+
"find *": "allow",
|
|
8
|
+
"ls *": "allow",
|
|
9
|
+
pwd: "allow",
|
|
10
|
+
"uname *": "allow",
|
|
11
|
+
"df *": "allow",
|
|
12
|
+
"free *": "allow",
|
|
13
|
+
"ps *": "allow",
|
|
14
|
+
"systemctl status *": "allow",
|
|
15
|
+
};
|
|
16
|
+
const readConfig = async (opencodeConfigFile) => {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(await readFile(opencodeConfigFile, "utf8"));
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code === "ENOENT") {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const writeConfig = async (opencodeConfigFile, config) => {
|
|
28
|
+
await mkdir(dirname(opencodeConfigFile), { recursive: true });
|
|
29
|
+
await writeFile(opencodeConfigFile, JSON.stringify(config, null, 2) + "\n");
|
|
30
|
+
};
|
|
31
|
+
export const installIntoOpenCodeConfig = async (opencodeConfigFile) => {
|
|
32
|
+
const current = await readConfig(opencodeConfigFile);
|
|
33
|
+
const plugins = Array.isArray(current.plugin) ? [...current.plugin] : [];
|
|
34
|
+
if (!plugins.includes("@junwu168/openshell")) {
|
|
35
|
+
plugins.push("@junwu168/openshell");
|
|
36
|
+
}
|
|
37
|
+
const currentPermissions = typeof current.permission === "object" && current.permission !== null ? current.permission : {};
|
|
38
|
+
const currentBash = typeof currentPermissions.bash === "object" && currentPermissions.bash !== null
|
|
39
|
+
? currentPermissions.bash
|
|
40
|
+
: {};
|
|
41
|
+
await writeConfig(opencodeConfigFile, {
|
|
42
|
+
...current,
|
|
43
|
+
plugin: plugins,
|
|
44
|
+
permission: {
|
|
45
|
+
...currentPermissions,
|
|
46
|
+
edit: currentPermissions.edit ?? "ask",
|
|
47
|
+
bash: {
|
|
48
|
+
...defaultBashPermissions,
|
|
49
|
+
...currentBash,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
export const uninstallFromOpenCodeConfig = async (opencodeConfigFile) => {
|
|
55
|
+
const current = await readConfig(opencodeConfigFile);
|
|
56
|
+
const plugins = Array.isArray(current.plugin)
|
|
57
|
+
? current.plugin.filter((plugin) => plugin !== "@junwu168/openshell")
|
|
58
|
+
: [];
|
|
59
|
+
const currentPermissions = typeof current.permission === "object" && current.permission !== null ? { ...current.permission } : {};
|
|
60
|
+
const currentBash = typeof currentPermissions.bash === "object" && currentPermissions.bash !== null
|
|
61
|
+
? { ...currentPermissions.bash }
|
|
62
|
+
: null;
|
|
63
|
+
if (currentBash) {
|
|
64
|
+
for (const [pattern, permission] of Object.entries(defaultBashPermissions)) {
|
|
65
|
+
if (currentBash[pattern] === permission) {
|
|
66
|
+
delete currentBash[pattern];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (Object.keys(currentBash).length === 0) {
|
|
70
|
+
delete currentPermissions.bash;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
currentPermissions.bash = currentBash;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (currentPermissions.edit === "ask") {
|
|
77
|
+
delete currentPermissions.edit;
|
|
78
|
+
}
|
|
79
|
+
const nextConfig = { ...current };
|
|
80
|
+
if (plugins.length > 0) {
|
|
81
|
+
nextConfig.plugin = plugins;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
delete nextConfig.plugin;
|
|
85
|
+
}
|
|
86
|
+
if (Object.keys(currentPermissions).length > 0) {
|
|
87
|
+
nextConfig.permission = currentPermissions;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
delete nextConfig.permission;
|
|
91
|
+
}
|
|
92
|
+
await writeConfig(opencodeConfigFile, nextConfig);
|
|
93
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createRuntimePaths } from "../core/paths.js";
|
|
2
|
+
type WritableLike = {
|
|
3
|
+
write(chunk: string): void;
|
|
4
|
+
};
|
|
5
|
+
type RuntimePaths = ReturnType<typeof createRuntimePaths>;
|
|
6
|
+
type UninstallOptions = {
|
|
7
|
+
runtimePaths: RuntimePaths;
|
|
8
|
+
stdout: WritableLike;
|
|
9
|
+
};
|
|
10
|
+
export declare const uninstallOpenShell: ({ runtimePaths, stdout }: UninstallOptions) => Promise<void>;
|
|
11
|
+
export declare const runUninstallCli: (_argv?: string[], stream?: WritableLike) => Promise<number>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { cwd, stdout } from "node:process";
|
|
3
|
+
import { createRuntimePaths } from "../core/paths.js";
|
|
4
|
+
import { uninstallFromOpenCodeConfig } from "./opencode-config.js";
|
|
5
|
+
import { createWorkspaceTracker } from "./workspace-tracker.js";
|
|
6
|
+
export const uninstallOpenShell = async ({ runtimePaths, stdout }) => {
|
|
7
|
+
const tracker = createWorkspaceTracker(runtimePaths.workspaceTrackerFile);
|
|
8
|
+
const trackedWorkspaces = await tracker.list();
|
|
9
|
+
await uninstallFromOpenCodeConfig(runtimePaths.opencodeConfigFile);
|
|
10
|
+
for (const entry of trackedWorkspaces) {
|
|
11
|
+
await rm(entry.managedPath, { recursive: true, force: true });
|
|
12
|
+
}
|
|
13
|
+
await rm(runtimePaths.configDir, { recursive: true, force: true });
|
|
14
|
+
await rm(runtimePaths.dataDir, { recursive: true, force: true });
|
|
15
|
+
stdout.write([
|
|
16
|
+
"Removed openshell.",
|
|
17
|
+
`OpenShell config: ${runtimePaths.configDir}`,
|
|
18
|
+
`OpenShell data: ${runtimePaths.dataDir}`,
|
|
19
|
+
].join("\n") + "\n");
|
|
20
|
+
};
|
|
21
|
+
export const runUninstallCli = async (_argv = [], stream = stdout) => {
|
|
22
|
+
await uninstallOpenShell({
|
|
23
|
+
runtimePaths: createRuntimePaths(cwd()),
|
|
24
|
+
stdout: stream,
|
|
25
|
+
});
|
|
26
|
+
return 0;
|
|
27
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type WorkspaceTrackerEntry = {
|
|
2
|
+
workspaceRoot: string;
|
|
3
|
+
managedPath: string;
|
|
4
|
+
};
|
|
5
|
+
export type WorkspaceTracker = {
|
|
6
|
+
list(): Promise<WorkspaceTrackerEntry[]>;
|
|
7
|
+
record(entry: WorkspaceTrackerEntry): Promise<void>;
|
|
8
|
+
remove(workspaceRoot: string): Promise<void>;
|
|
9
|
+
clear(): Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export declare const createWorkspaceTracker: (trackerFile: string) => WorkspaceTracker;
|