@retrotech71/appleii-agent 1.0.8 → 1.0.9
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 +122 -7
- package/package.json +1 -1
- package/src/mcp-server.js +16 -0
- package/src/path-resolver.js +200 -0
- package/src/tools/get-screenshot.js +59 -0
- package/src/tools/index.js +6 -6
- package/src/tools/load-disk-image.js +5 -13
- package/src/tools/load-file.js +5 -8
- package/src/tools/load-smartport-image.js +5 -13
- package/src/tools/reload-sandbox.js +36 -0
- package/src/tools/save-asm-file.js +5 -16
- package/src/tools/save-basic-file.js +5 -16
- package/src/tools/save-disk-file.js +27 -10
- package/src/tools/save-screenshot.js +112 -0
- package/src/tools/save-to.js +263 -0
package/README.md
CHANGED
|
@@ -36,11 +36,70 @@ npm install
|
|
|
36
36
|
|
|
37
37
|
## Configuration
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
### For Claude Desktop
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%/Claude/claude_desktop_config.json` (Windows):
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
**Using npx (Recommended):**
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
"appleii-agent": {
|
|
49
|
+
"command": "npx",
|
|
50
|
+
"args": ["-y", "@retrotech71/appleii-agent"]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Using bunx:**
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"mcpServers": {
|
|
61
|
+
"appleii-agent": {
|
|
62
|
+
"command": "bunx",
|
|
63
|
+
"args": ["-y", "@retrotech71/appleii-agent"]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**From source:**
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"appleii-agent": {
|
|
75
|
+
"command": "node",
|
|
76
|
+
"args": ["/absolute/path/to/appleii-agent/src/index.js"]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**With custom sandbox config location:**
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"mcpServers": {
|
|
87
|
+
"appleii-agent": {
|
|
88
|
+
"command": "npx",
|
|
89
|
+
"args": ["-y", "@retrotech71/appleii-agent"],
|
|
90
|
+
"env": {
|
|
91
|
+
"APPLEII_AGENT_SANDBOX": "/path/to/custom/sandbox.config"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### For Claude Code
|
|
99
|
+
|
|
100
|
+
Edit `~/.claude/mcp.json`:
|
|
101
|
+
|
|
102
|
+
**Using bunx (Recommended):**
|
|
44
103
|
|
|
45
104
|
```json
|
|
46
105
|
{
|
|
@@ -54,7 +113,7 @@ Runs the published package directly with Bun:
|
|
|
54
113
|
}
|
|
55
114
|
```
|
|
56
115
|
|
|
57
|
-
|
|
116
|
+
**From source:**
|
|
58
117
|
|
|
59
118
|
```json
|
|
60
119
|
{
|
|
@@ -78,12 +137,19 @@ Runs the published package directly with Bun:
|
|
|
78
137
|
|
|
79
138
|
```
|
|
80
139
|
Show the CPU debugger window
|
|
81
|
-
Load
|
|
140
|
+
Load [disks]/ProDOS_2_4_2.dsk into drive 1
|
|
141
|
+
Load [zork]/zork1.dsk into drive 2
|
|
82
142
|
Write a BASIC program that draws a sine wave
|
|
83
143
|
Install the SmartPort card in slot 7
|
|
84
|
-
Load
|
|
144
|
+
Load [games]/Total_Replay.hdv into SmartPort device 1
|
|
85
145
|
Turn on the emulator and boot from disk
|
|
86
|
-
Save
|
|
146
|
+
Save the BASIC program to [basic]/sinewave.bas
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
You can also use full paths:
|
|
150
|
+
```
|
|
151
|
+
Load ~/Documents/ProDOS.dsk into drive 1
|
|
152
|
+
Save assembly code to ~/code/program.s
|
|
87
153
|
```
|
|
88
154
|
|
|
89
155
|
## Available Tools
|
|
@@ -117,6 +183,55 @@ Save 256 bytes from memory address $0800 to ~/output.bin
|
|
|
117
183
|
| `shutdown_remote_server` | Shutdown another MCP server instance on the same port |
|
|
118
184
|
| `disconnect_clients` | Gracefully disconnect all connected emulator clients |
|
|
119
185
|
|
|
186
|
+
## Sandbox Paths
|
|
187
|
+
|
|
188
|
+
Sandbox paths provide convenient shortcuts for frequently accessed directories. Instead of typing full paths, you can define sandboxes in a configuration file and use them across all file operations.
|
|
189
|
+
|
|
190
|
+
### Configuration
|
|
191
|
+
|
|
192
|
+
1. **Create a sandbox config file** anywhere you like (e.g., `~/sandbox.config`):
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
# Apple //e Agent - Sandbox Paths Configuration
|
|
196
|
+
# Format: [key]@/path/to/directory
|
|
197
|
+
|
|
198
|
+
[disks]@~/Documents/Apple2/Disks
|
|
199
|
+
[games]@~/Documents/Apple2/Games
|
|
200
|
+
[zork]@~/Games/Zork
|
|
201
|
+
[basic]@~/Documents/Apple2/BASIC
|
|
202
|
+
[files]@~/Documents/Apple2/Files
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
2. **Set the `APPLEII_AGENT_SANDBOX` environment variable** to point to your config file in your MCP client configuration (see Configuration section above for examples).
|
|
206
|
+
|
|
207
|
+
### Usage
|
|
208
|
+
|
|
209
|
+
All file loading/saving tools support both sandbox syntax and full paths:
|
|
210
|
+
|
|
211
|
+
**With sandbox paths:**
|
|
212
|
+
```
|
|
213
|
+
Load [disks]/ProDOS.dsk into drive 1
|
|
214
|
+
Load [zork]/zork1.dsk into drive 2
|
|
215
|
+
Save BASIC program to [basic]/hello.bas
|
|
216
|
+
Load [games]/total-replay.hdv into SmartPort device 1
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**With full paths:**
|
|
220
|
+
```
|
|
221
|
+
Load ~/Documents/game.dsk into drive 1
|
|
222
|
+
Save assembly to ~/code/program.s
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Supported Tools
|
|
226
|
+
|
|
227
|
+
Sandbox paths work with:
|
|
228
|
+
- `load_disk_image` - `[disks]/game.dsk`
|
|
229
|
+
- `load_smartport_image` - `[games]/hd.hdv`
|
|
230
|
+
- `load_file` - `[files]/data.bin`
|
|
231
|
+
- `save_basic_file` - `[basic]/program.bas`
|
|
232
|
+
- `save_asm_file` - `[asm]/code.s`
|
|
233
|
+
- `save_disk_file` - `[files]/output.bin`
|
|
234
|
+
|
|
120
235
|
## Environment Variables
|
|
121
236
|
|
|
122
237
|
| Variable | Default | Description |
|
package/package.json
CHANGED
package/src/mcp-server.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
14
14
|
import { tools } from "./tools/index.js";
|
|
15
15
|
import { VERSION } from "./version.js";
|
|
16
|
+
import { pathResolver } from "./path-resolver.js";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* MCP Server for Apple //e emulator agent
|
|
@@ -60,6 +61,11 @@ export class McpServer {
|
|
|
60
61
|
// Call the handler
|
|
61
62
|
const result = await toolModule.handler(args, this.httpServer);
|
|
62
63
|
|
|
64
|
+
// If the handler returns _mcpContent, pass it through directly (e.g. image content)
|
|
65
|
+
if (result?._mcpContent) {
|
|
66
|
+
return { content: result._mcpContent };
|
|
67
|
+
}
|
|
68
|
+
|
|
63
69
|
return {
|
|
64
70
|
content: [
|
|
65
71
|
{
|
|
@@ -88,5 +94,15 @@ export class McpServer {
|
|
|
88
94
|
async start() {
|
|
89
95
|
const transport = new StdioServerTransport();
|
|
90
96
|
await this.server.connect(transport);
|
|
97
|
+
|
|
98
|
+
// Alert user if no sandbox config is configured
|
|
99
|
+
if (!pathResolver.configPath) {
|
|
100
|
+
await this.server.sendLoggingMessage({
|
|
101
|
+
level: "warning",
|
|
102
|
+
data: "⚠️ Apple //e Agent: No sandbox config set. File operations are disabled. " +
|
|
103
|
+
"Set the APPLEII_AGENT_SANDBOX environment variable to point to your sandbox.config file. " +
|
|
104
|
+
"See https://github.com/mikedaley/appleii-agent#sandbox-paths for setup instructions.",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
91
107
|
}
|
|
92
108
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* path-resolver.js - Sandbox path resolution for convenient file access
|
|
3
|
+
*
|
|
4
|
+
* Written by
|
|
5
|
+
* Shawn Bullock <shawn@agenticexpert.ai>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import { logger } from "./logger.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* PathResolver - Manages sandbox paths for convenient file access
|
|
16
|
+
*
|
|
17
|
+
* Format: [key]@/path/to/directory
|
|
18
|
+
* Usage: [key]/relative/path/to/file.dsk or /full/path/to/file.dsk
|
|
19
|
+
*/
|
|
20
|
+
export class PathResolver {
|
|
21
|
+
constructor(configPath = null) {
|
|
22
|
+
// User must specify config location via constructor or APPLEII_AGENT_SANDBOX env var
|
|
23
|
+
const expandTilde = (p) => p.startsWith("~") ? p.replace("~", os.homedir()) : p;
|
|
24
|
+
|
|
25
|
+
if (configPath) {
|
|
26
|
+
this.configPath = expandTilde(configPath);
|
|
27
|
+
} else if (process.env.APPLEII_AGENT_SANDBOX) {
|
|
28
|
+
this.configPath = expandTilde(process.env.APPLEII_AGENT_SANDBOX);
|
|
29
|
+
} else {
|
|
30
|
+
// No config specified - sandbox paths will not be available
|
|
31
|
+
this.configPath = null;
|
|
32
|
+
}
|
|
33
|
+
this.sandboxes = new Map();
|
|
34
|
+
this.loadConfig();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load sandbox paths from config file
|
|
39
|
+
*/
|
|
40
|
+
loadConfig() {
|
|
41
|
+
// No config path specified - all file access will be blocked
|
|
42
|
+
if (!this.configPath) {
|
|
43
|
+
logger.log("[PathResolver] No sandbox config specified. All file operations are blocked. Set APPLEII_AGENT_SANDBOX to enable file access.");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// Check if config file exists
|
|
49
|
+
if (!fs.existsSync(this.configPath)) {
|
|
50
|
+
logger.log(`[PathResolver] Sandbox config not found: ${this.configPath}`);
|
|
51
|
+
logger.log(`[PathResolver] Create the file or update APPLEII_AGENT_SANDBOX to point to a valid config.`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Read and parse config
|
|
56
|
+
const content = fs.readFileSync(this.configPath, "utf8");
|
|
57
|
+
const lines = content.split("\n");
|
|
58
|
+
|
|
59
|
+
this.sandboxes.clear();
|
|
60
|
+
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
|
|
64
|
+
// Skip empty lines and comments
|
|
65
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Parse [key]@path format
|
|
70
|
+
const match = trimmed.match(/^\[([a-zA-Z0-9_-]+)\]@(.+)$/);
|
|
71
|
+
if (match) {
|
|
72
|
+
const [, key, pathValue] = match;
|
|
73
|
+
|
|
74
|
+
// Expand ~ to home directory
|
|
75
|
+
let resolvedPath = pathValue;
|
|
76
|
+
if (resolvedPath.startsWith("~")) {
|
|
77
|
+
resolvedPath = resolvedPath.replace("~", os.homedir());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Convert to absolute path
|
|
81
|
+
if (!path.isAbsolute(resolvedPath)) {
|
|
82
|
+
resolvedPath = path.resolve(resolvedPath);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.sandboxes.set(key, resolvedPath);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (this.sandboxes.size > 0) {
|
|
90
|
+
logger.log(`[PathResolver] Loaded ${this.sandboxes.size} sandbox paths from ${this.configPath}`);
|
|
91
|
+
} else {
|
|
92
|
+
logger.log(`[PathResolver] No sandbox paths found in ${this.configPath}`);
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
logger.log(`[PathResolver] Error loading config: ${error.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a path with sandbox syntax [key]/path or regular path.
|
|
101
|
+
* If sandboxes are configured, full paths must fall within a trusted directory.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} pathString - Path to resolve (e.g., "[disks]/game.dsk" or "~/file.txt")
|
|
104
|
+
* @returns {string} Resolved absolute path
|
|
105
|
+
* @throws {Error} If sandbox is unknown, path traversal detected, or path outside trusted directories
|
|
106
|
+
*/
|
|
107
|
+
resolve(pathString) {
|
|
108
|
+
if (!pathString) {
|
|
109
|
+
return pathString;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if path uses sandbox syntax [key]/path
|
|
113
|
+
const sandboxMatch = pathString.match(/^\[([a-zA-Z0-9_-]+)\](.*)$/);
|
|
114
|
+
|
|
115
|
+
if (sandboxMatch) {
|
|
116
|
+
const [, key, relativePath] = sandboxMatch;
|
|
117
|
+
|
|
118
|
+
// Check if key exists
|
|
119
|
+
if (!this.sandboxes.has(key)) {
|
|
120
|
+
if (this.sandboxes.size === 0) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
this.configPath
|
|
123
|
+
? `No sandboxes loaded from ${this.configPath}. Check the file has valid [key]@/path entries.`
|
|
124
|
+
: `No sandbox config set. Set APPLEII_AGENT_SANDBOX environment variable and restart the MCP server.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const available = [...this.sandboxes.keys()].map(k => `[${k}]`).join(", ");
|
|
128
|
+
throw new Error(`Unknown sandbox path: [${key}]. Available sandboxes: ${available}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const basePath = this.sandboxes.get(key);
|
|
132
|
+
|
|
133
|
+
// Remove leading slash from relative path if present
|
|
134
|
+
const cleanRelativePath = relativePath.startsWith("/")
|
|
135
|
+
? relativePath.slice(1)
|
|
136
|
+
: relativePath;
|
|
137
|
+
|
|
138
|
+
// Resolve and normalize the full path
|
|
139
|
+
const resolved = path.normalize(path.join(basePath, cleanRelativePath));
|
|
140
|
+
|
|
141
|
+
// Prevent path traversal escaping the sandbox directory
|
|
142
|
+
if (!resolved.startsWith(path.normalize(basePath))) {
|
|
143
|
+
throw new Error(`Path traversal detected: [${key}]${relativePath} escapes its trusted directory.`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return resolved;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// No sandbox syntax - treat as regular full path
|
|
150
|
+
let expandedPath = pathString;
|
|
151
|
+
if (pathString.startsWith("~")) {
|
|
152
|
+
expandedPath = pathString.replace("~", os.homedir());
|
|
153
|
+
}
|
|
154
|
+
const resolved = path.normalize(path.resolve(expandedPath));
|
|
155
|
+
|
|
156
|
+
// Full paths must always be within a trusted sandbox directory
|
|
157
|
+
if (!this._isWithinTrustedPath(resolved)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Access denied: "${resolved}" is outside all trusted directories. ` +
|
|
160
|
+
`Use a sandbox path like [key]/file or add a trusted path to ${this.configPath}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return resolved;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Check if a resolved absolute path falls within any configured sandbox directory
|
|
169
|
+
*/
|
|
170
|
+
_isWithinTrustedPath(absolutePath) {
|
|
171
|
+
for (const trustedPath of this.sandboxes.values()) {
|
|
172
|
+
const normalizedTrusted = path.normalize(trustedPath);
|
|
173
|
+
if (absolutePath === normalizedTrusted || absolutePath.startsWith(normalizedTrusted + path.sep)) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get list of available sandbox paths
|
|
182
|
+
*/
|
|
183
|
+
getSandboxes() {
|
|
184
|
+
const result = {};
|
|
185
|
+
for (const [key, value] of this.sandboxes.entries()) {
|
|
186
|
+
result[key] = value;
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Reload config from disk
|
|
193
|
+
*/
|
|
194
|
+
reload() {
|
|
195
|
+
this.loadConfig();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Singleton instance
|
|
200
|
+
export const pathResolver = new PathResolver();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* get-screenshot.js - Capture and return emulator screenshot as an image
|
|
3
|
+
*
|
|
4
|
+
* Written by
|
|
5
|
+
* Shawn Bullock <shawn@agenticexpert.ai>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const tool = {
|
|
9
|
+
name: "get_screenshot",
|
|
10
|
+
description: "Capture the current Apple //e screen and return it as a viewable image.",
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {},
|
|
14
|
+
required: []
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function handler(args, httpServer) {
|
|
19
|
+
const toolCallId = `tc_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
20
|
+
|
|
21
|
+
await httpServer.sendEvent({
|
|
22
|
+
type: "TOOL_CALL_START",
|
|
23
|
+
tool_call_id: toolCallId,
|
|
24
|
+
tool_call_name: "emma_command",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
await httpServer.sendEvent({
|
|
28
|
+
type: "TOOL_CALL_ARGS",
|
|
29
|
+
tool_call_id: toolCallId,
|
|
30
|
+
delta: JSON.stringify({ command: "captureScreenshot", params: {} }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await httpServer.sendEvent({
|
|
34
|
+
type: "TOOL_CALL_END",
|
|
35
|
+
tool_call_id: toolCallId,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const raw = await httpServer.waitForToolResult(toolCallId, 10000);
|
|
39
|
+
const captureResult = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
40
|
+
|
|
41
|
+
if (!captureResult?.success) {
|
|
42
|
+
return { success: false, error: captureResult?.error || "Screenshot capture failed" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { imageBase64 } = captureResult;
|
|
46
|
+
if (!imageBase64) {
|
|
47
|
+
return { success: false, error: "No image data returned from emulator" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Strip data URL prefix to get raw base64
|
|
51
|
+
const data = imageBase64.replace(/^data:[^;]+;base64,\s*/, '').trim();
|
|
52
|
+
|
|
53
|
+
// Return as MCP image content so the LLM can view it directly
|
|
54
|
+
return {
|
|
55
|
+
_mcpContent: [
|
|
56
|
+
{ type: "image", data, mimeType: "image/png" }
|
|
57
|
+
]
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/tools/index.js
CHANGED
|
@@ -18,10 +18,10 @@ import * as hideWindow from "./hide-window.js";
|
|
|
18
18
|
import * as focusWindow from "./focus-window.js";
|
|
19
19
|
import * as loadDiskImage from "./load-disk-image.js";
|
|
20
20
|
import * as loadFile from "./load-file.js";
|
|
21
|
-
import * as saveBasicFile from "./save-basic-file.js";
|
|
22
|
-
import * as saveAsmFile from "./save-asm-file.js";
|
|
23
|
-
import * as saveDiskFile from "./save-disk-file.js";
|
|
24
21
|
import * as loadSmartportImage from "./load-smartport-image.js";
|
|
22
|
+
import * as reloadSandbox from "./reload-sandbox.js";
|
|
23
|
+
import * as getScreenshot from "./get-screenshot.js";
|
|
24
|
+
import * as saveTo from "./save-to.js";
|
|
25
25
|
|
|
26
26
|
export const tools = [
|
|
27
27
|
serverControl,
|
|
@@ -29,6 +29,7 @@ export const tools = [
|
|
|
29
29
|
setDebug,
|
|
30
30
|
getState,
|
|
31
31
|
getVersion,
|
|
32
|
+
reloadSandbox,
|
|
32
33
|
disconnectClients,
|
|
33
34
|
shutdownRemoteServer,
|
|
34
35
|
showWindow,
|
|
@@ -38,7 +39,6 @@ export const tools = [
|
|
|
38
39
|
loadDiskImage,
|
|
39
40
|
loadSmartportImage,
|
|
40
41
|
loadFile,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
saveDiskFile,
|
|
42
|
+
getScreenshot,
|
|
43
|
+
saveTo,
|
|
44
44
|
];
|
|
@@ -7,17 +7,17 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import {
|
|
10
|
+
import { pathResolver } from '../path-resolver.js';
|
|
11
11
|
|
|
12
12
|
export const tool = {
|
|
13
13
|
name: "load_disk_image",
|
|
14
|
-
description: "Load a disk image file from the local filesystem and return as base64",
|
|
14
|
+
description: "Load a disk image file from the local filesystem and return as base64. Supports sandbox paths like [disks]/game.dsk or full paths like ~/Documents/game.dsk",
|
|
15
15
|
inputSchema: {
|
|
16
16
|
type: "object",
|
|
17
17
|
properties: {
|
|
18
18
|
path: {
|
|
19
19
|
type: "string",
|
|
20
|
-
description: "Path to disk image file
|
|
20
|
+
description: "Path to disk image file. Use [sandbox]/path syntax or full path with ~ for home directory",
|
|
21
21
|
},
|
|
22
22
|
},
|
|
23
23
|
required: ["path"],
|
|
@@ -35,16 +35,8 @@ export function handler(args) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
try {
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
if (filePath.startsWith('~/')) {
|
|
41
|
-
expandedPath = path.join(homedir(), filePath.slice(2));
|
|
42
|
-
} else if (filePath === '~') {
|
|
43
|
-
expandedPath = homedir();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Resolve to absolute path
|
|
47
|
-
const absolutePath = path.resolve(expandedPath);
|
|
38
|
+
// Resolve path (handles sandbox paths and ~ expansion)
|
|
39
|
+
const absolutePath = pathResolver.resolve(filePath);
|
|
48
40
|
|
|
49
41
|
// Check if file exists
|
|
50
42
|
if (!fs.existsSync(absolutePath)) {
|
package/src/tools/load-file.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import
|
|
3
|
+
import { pathResolver } from '../path-resolver.js';
|
|
4
4
|
|
|
5
5
|
export const tool = {
|
|
6
6
|
name: "load_file",
|
|
7
|
-
description: "Read a file from the local filesystem. Returns base64 for binary files or plain text for text files.",
|
|
7
|
+
description: "Read a file from the local filesystem. Returns base64 for binary files or plain text for text files. Supports sandbox paths like [files]/program.bas or full paths like ~/Documents/file.txt",
|
|
8
8
|
inputSchema: {
|
|
9
9
|
type: "object",
|
|
10
10
|
properties: {
|
|
11
11
|
path: {
|
|
12
12
|
type: "string",
|
|
13
|
-
description: "Path to the file
|
|
13
|
+
description: "Path to the file. Use [sandbox]/path syntax or full path with ~ for home directory"
|
|
14
14
|
},
|
|
15
15
|
binary: {
|
|
16
16
|
type: "boolean",
|
|
@@ -33,11 +33,8 @@ export function handler(args) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
try {
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
if (filePath.startsWith('~')) {
|
|
39
|
-
expandedPath = path.join(os.homedir(), filePath.slice(1));
|
|
40
|
-
}
|
|
36
|
+
// Resolve path (handles sandbox paths and ~ expansion)
|
|
37
|
+
const expandedPath = pathResolver.resolve(filePath);
|
|
41
38
|
|
|
42
39
|
// Check if file exists
|
|
43
40
|
if (!fs.existsSync(expandedPath)) {
|
|
@@ -7,17 +7,17 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import {
|
|
10
|
+
import { pathResolver } from '../path-resolver.js';
|
|
11
11
|
|
|
12
12
|
export const tool = {
|
|
13
13
|
name: "load_smartport_image",
|
|
14
|
-
description: "Load a SmartPort hard drive image file from the local filesystem and return as base64",
|
|
14
|
+
description: "Load a SmartPort hard drive image file from the local filesystem and return as base64. Supports sandbox paths like [games]/total-replay.hdv or full paths like ~/Documents/disk.hdv",
|
|
15
15
|
inputSchema: {
|
|
16
16
|
type: "object",
|
|
17
17
|
properties: {
|
|
18
18
|
path: {
|
|
19
19
|
type: "string",
|
|
20
|
-
description: "Path to SmartPort image file
|
|
20
|
+
description: "Path to SmartPort image file. Use [sandbox]/path syntax or full path with ~ for home directory",
|
|
21
21
|
},
|
|
22
22
|
},
|
|
23
23
|
required: ["path"],
|
|
@@ -35,16 +35,8 @@ export function handler(args) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
try {
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
if (filePath.startsWith('~/')) {
|
|
41
|
-
expandedPath = path.join(homedir(), filePath.slice(2));
|
|
42
|
-
} else if (filePath === '~') {
|
|
43
|
-
expandedPath = homedir();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Resolve to absolute path
|
|
47
|
-
const absolutePath = path.resolve(expandedPath);
|
|
38
|
+
// Resolve path (handles sandbox paths and ~ expansion)
|
|
39
|
+
const absolutePath = pathResolver.resolve(filePath);
|
|
48
40
|
|
|
49
41
|
// Check if file exists
|
|
50
42
|
if (!fs.existsSync(absolutePath)) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* reload-sandbox.js - Reload sandbox paths config without restarting MCP server
|
|
3
|
+
*
|
|
4
|
+
* Written by
|
|
5
|
+
* Shawn Bullock <shawn@agenticexpert.ai>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { pathResolver } from "../path-resolver.js";
|
|
9
|
+
|
|
10
|
+
export const tool = {
|
|
11
|
+
name: "reload_sandbox",
|
|
12
|
+
description: "Reload the sandbox paths configuration from disk without restarting the MCP server. Use this after editing your sandbox.config file to pick up new paths.",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {},
|
|
16
|
+
required: [],
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function handler() {
|
|
21
|
+
pathResolver.reload();
|
|
22
|
+
const sandboxes = pathResolver.getSandboxes();
|
|
23
|
+
const keys = Object.keys(sandboxes);
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
success: true,
|
|
27
|
+
config: pathResolver.configPath,
|
|
28
|
+
loaded: keys.length,
|
|
29
|
+
sandboxes: keys.length > 0
|
|
30
|
+
? sandboxes
|
|
31
|
+
: null,
|
|
32
|
+
message: keys.length > 0
|
|
33
|
+
? `Loaded ${keys.length} sandbox path(s): ${keys.map(k => `[${k}]`).join(", ")}`
|
|
34
|
+
: "No sandbox paths found in config.",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -7,17 +7,17 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import {
|
|
10
|
+
import { pathResolver } from '../path-resolver.js';
|
|
11
11
|
|
|
12
12
|
export const tool = {
|
|
13
13
|
name: "save_asm_file",
|
|
14
|
-
description: "Save assembly source code to a file on the local filesystem",
|
|
14
|
+
description: "Save assembly source code to a file on the local filesystem. Supports sandbox paths like [asm]/program.s or full paths like ~/Documents/program.asm",
|
|
15
15
|
inputSchema: {
|
|
16
16
|
type: "object",
|
|
17
17
|
properties: {
|
|
18
18
|
path: {
|
|
19
19
|
type: "string",
|
|
20
|
-
description: "Path to save file
|
|
20
|
+
description: "Path to save file. Use [sandbox]/path syntax or full path with ~ for home directory (.s or .asm extension recommended)",
|
|
21
21
|
},
|
|
22
22
|
content: {
|
|
23
23
|
type: "string",
|
|
@@ -51,19 +51,8 @@ export function handler(args) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
try {
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
if (filePath.startsWith('~/')) {
|
|
57
|
-
expandedPath = path.join(homedir(), filePath.slice(2));
|
|
58
|
-
} else if (filePath === '~') {
|
|
59
|
-
return {
|
|
60
|
-
success: false,
|
|
61
|
-
error: "Cannot save to home directory root, please specify a filename",
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Resolve to absolute path
|
|
66
|
-
const absolutePath = path.resolve(expandedPath);
|
|
54
|
+
// Resolve path (handles sandbox paths and ~ expansion)
|
|
55
|
+
const absolutePath = pathResolver.resolve(filePath);
|
|
67
56
|
|
|
68
57
|
// Ensure parent directory exists
|
|
69
58
|
const dir = path.dirname(absolutePath);
|
|
@@ -7,17 +7,17 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import {
|
|
10
|
+
import { pathResolver } from '../path-resolver.js';
|
|
11
11
|
|
|
12
12
|
export const tool = {
|
|
13
13
|
name: "save_basic_file",
|
|
14
|
-
description: "Save BASIC program text to a file on the local filesystem",
|
|
14
|
+
description: "Save BASIC program text to a file on the local filesystem. Supports sandbox paths like [basic]/program.bas or full paths like ~/Documents/program.bas",
|
|
15
15
|
inputSchema: {
|
|
16
16
|
type: "object",
|
|
17
17
|
properties: {
|
|
18
18
|
path: {
|
|
19
19
|
type: "string",
|
|
20
|
-
description: "Path to save file
|
|
20
|
+
description: "Path to save file. Use [sandbox]/path syntax or full path with ~ for home directory (.bas extension recommended)",
|
|
21
21
|
},
|
|
22
22
|
content: {
|
|
23
23
|
type: "string",
|
|
@@ -51,19 +51,8 @@ export function handler(args) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
try {
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
if (filePath.startsWith('~/')) {
|
|
57
|
-
expandedPath = path.join(homedir(), filePath.slice(2));
|
|
58
|
-
} else if (filePath === '~') {
|
|
59
|
-
return {
|
|
60
|
-
success: false,
|
|
61
|
-
error: "Cannot save to home directory root, please specify a filename",
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Resolve to absolute path
|
|
66
|
-
const absolutePath = path.resolve(expandedPath);
|
|
54
|
+
// Resolve path (handles sandbox paths and ~ expansion)
|
|
55
|
+
const absolutePath = pathResolver.resolve(filePath);
|
|
67
56
|
|
|
68
57
|
// Ensure parent directory exists
|
|
69
58
|
const dir = path.dirname(absolutePath);
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import
|
|
3
|
+
import { pathResolver } from '../path-resolver.js';
|
|
4
4
|
|
|
5
5
|
export const tool = {
|
|
6
6
|
name: "save_disk_file",
|
|
7
|
-
description: "Save disk file content to the local filesystem. Content should be base64 encoded binary data.",
|
|
7
|
+
description: "Save disk file content to the local filesystem. Content should be base64 encoded binary data. Supports sandbox paths like [files]/data.bin or full paths like ~/Documents/file.bin",
|
|
8
8
|
inputSchema: {
|
|
9
9
|
type: "object",
|
|
10
10
|
properties: {
|
|
11
11
|
path: {
|
|
12
12
|
type: "string",
|
|
13
|
-
description: "Path to save the file
|
|
13
|
+
description: "Path to save the file. Use [sandbox]/path syntax or full path with ~ for home directory"
|
|
14
14
|
},
|
|
15
15
|
contentBase64: {
|
|
16
16
|
type: "string",
|
|
@@ -20,6 +20,11 @@ export const tool = {
|
|
|
20
20
|
type: "boolean",
|
|
21
21
|
description: "Allow overwriting existing files (default: false)",
|
|
22
22
|
default: false
|
|
23
|
+
},
|
|
24
|
+
direct: {
|
|
25
|
+
type: "boolean",
|
|
26
|
+
description: "When true (default), decode and save the file, returning only metadata. When false, return the base64 content to the LLM without saving.",
|
|
27
|
+
default: true
|
|
23
28
|
}
|
|
24
29
|
},
|
|
25
30
|
required: ["path", "contentBase64"]
|
|
@@ -27,7 +32,7 @@ export const tool = {
|
|
|
27
32
|
};
|
|
28
33
|
|
|
29
34
|
export function handler(args) {
|
|
30
|
-
const { path: filePath, contentBase64, overwrite = false } = args;
|
|
35
|
+
const { path: filePath, contentBase64, overwrite = false, direct = true } = args;
|
|
31
36
|
|
|
32
37
|
if (!filePath) {
|
|
33
38
|
return {
|
|
@@ -43,12 +48,20 @@ export function handler(args) {
|
|
|
43
48
|
};
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
// direct=false: return content to LLM without saving
|
|
52
|
+
if (!direct) {
|
|
53
|
+
const raw = contentBase64.replace(/^data:[^;]+;base64,\s*/, '').trim();
|
|
54
|
+
const buffer = Buffer.from(raw, 'base64');
|
|
55
|
+
return {
|
|
56
|
+
success: true,
|
|
57
|
+
contentBase64: raw,
|
|
58
|
+
size: buffer.length
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
try {
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
if (filePath.startsWith('~')) {
|
|
50
|
-
expandedPath = path.join(os.homedir(), filePath.slice(1));
|
|
51
|
-
}
|
|
63
|
+
// Resolve path (handles sandbox paths and ~ expansion)
|
|
64
|
+
const expandedPath = pathResolver.resolve(filePath);
|
|
52
65
|
|
|
53
66
|
// Check if file exists and overwrite is false
|
|
54
67
|
if (!overwrite && fs.existsSync(expandedPath)) {
|
|
@@ -58,8 +71,12 @@ export function handler(args) {
|
|
|
58
71
|
};
|
|
59
72
|
}
|
|
60
73
|
|
|
74
|
+
// Strip data URL prefix if present (e.g. "data:image/png;base64, iVBOR...")
|
|
75
|
+
// The regex handles any media type and optional whitespace after the comma
|
|
76
|
+
const raw = contentBase64.replace(/^data:[^;]+;base64,\s*/, '').trim();
|
|
77
|
+
|
|
61
78
|
// Decode base64 content
|
|
62
|
-
const buffer = Buffer.from(
|
|
79
|
+
const buffer = Buffer.from(raw, 'base64');
|
|
63
80
|
|
|
64
81
|
// Ensure directory exists
|
|
65
82
|
const dir = path.dirname(expandedPath);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* save-screenshot.js - Capture and save emulator screenshot in one step
|
|
3
|
+
*
|
|
4
|
+
* Written by
|
|
5
|
+
* Shawn Bullock <shawn@agenticexpert.ai>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { pathResolver } from '../path-resolver.js';
|
|
11
|
+
|
|
12
|
+
export const tool = {
|
|
13
|
+
name: "save_screenshot",
|
|
14
|
+
description: "Capture the current Apple //e screen and save it as a PNG file. Handles the full capture-decode-save pipeline without returning the image data to the LLM.",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
path: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Sandbox path to save the PNG (e.g. \"[t]/cap.png\" or \"[files]/screen.png\")"
|
|
21
|
+
},
|
|
22
|
+
overwrite: {
|
|
23
|
+
type: "boolean",
|
|
24
|
+
description: "Allow overwriting an existing file (default: false)",
|
|
25
|
+
default: false
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
required: ["path"]
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function handler(args, httpServer) {
|
|
33
|
+
const { path: filePath, overwrite = false } = args;
|
|
34
|
+
|
|
35
|
+
if (!filePath) {
|
|
36
|
+
return { success: false, error: "path parameter is required" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Resolve sandbox path
|
|
40
|
+
let expandedPath;
|
|
41
|
+
try {
|
|
42
|
+
expandedPath = pathResolver.resolve(filePath);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return { success: false, error: err.message };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check overwrite
|
|
48
|
+
if (!overwrite && fs.existsSync(expandedPath)) {
|
|
49
|
+
return {
|
|
50
|
+
success: false,
|
|
51
|
+
error: `File already exists: ${expandedPath}. Set overwrite: true to replace it.`
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Call the captureScreenshot app tool via AG-UI
|
|
56
|
+
const toolCallId = `tc_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
57
|
+
|
|
58
|
+
await httpServer.sendEvent({
|
|
59
|
+
type: "TOOL_CALL_START",
|
|
60
|
+
tool_call_id: toolCallId,
|
|
61
|
+
tool_call_name: "emma_command",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await httpServer.sendEvent({
|
|
65
|
+
type: "TOOL_CALL_ARGS",
|
|
66
|
+
tool_call_id: toolCallId,
|
|
67
|
+
delta: JSON.stringify({ command: "captureScreenshot", params: {} }),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await httpServer.sendEvent({
|
|
71
|
+
type: "TOOL_CALL_END",
|
|
72
|
+
tool_call_id: toolCallId,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const toolResult = await httpServer.waitForToolResult(toolCallId, 10000);
|
|
76
|
+
const captureResult = typeof toolResult === "string" ? JSON.parse(toolResult) : toolResult;
|
|
77
|
+
|
|
78
|
+
if (!captureResult?.success) {
|
|
79
|
+
return { success: false, error: captureResult?.error || "Screenshot capture failed" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { imageBase64 } = captureResult;
|
|
83
|
+
if (!imageBase64) {
|
|
84
|
+
return { success: false, error: "No image data returned from emulator" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Strip data URL prefix (e.g. "data:image/png;base64, ") to get raw base64
|
|
88
|
+
const raw = imageBase64.replace(/^data:[^;]+;base64,\s*/, '').trim();
|
|
89
|
+
|
|
90
|
+
// Decode and save
|
|
91
|
+
try {
|
|
92
|
+
const buffer = Buffer.from(raw, 'base64');
|
|
93
|
+
|
|
94
|
+
const dir = path.dirname(expandedPath);
|
|
95
|
+
if (!fs.existsSync(dir)) {
|
|
96
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fs.writeFileSync(expandedPath, buffer);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
success: true,
|
|
103
|
+
path: expandedPath,
|
|
104
|
+
size: buffer.length,
|
|
105
|
+
width: 560,
|
|
106
|
+
height: 384,
|
|
107
|
+
message: `Screenshot saved to ${expandedPath} (${buffer.length} bytes)`
|
|
108
|
+
};
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return { success: false, error: err.message };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* save-to.js - Unified load-and-save tool for all emulator content sources
|
|
3
|
+
*
|
|
4
|
+
* Fetches content from an emulator source and saves it directly to a file
|
|
5
|
+
* without exposing the raw data to the LLM (when direct=true).
|
|
6
|
+
*
|
|
7
|
+
* Written by
|
|
8
|
+
* Shawn Bullock <shawn@agenticexpert.ai>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { pathResolver } from '../path-resolver.js';
|
|
14
|
+
|
|
15
|
+
export const tool = {
|
|
16
|
+
name: "save_to",
|
|
17
|
+
description: "Load content from an emulator source and save it to a file in one step. When direct=true (default), saves silently and returns only metadata — the base64 is never sent to the LLM. When direct=false, returns the content to the LLM without saving.",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
from: {
|
|
22
|
+
type: "string",
|
|
23
|
+
enum: ["basic-editor", "asm-editor", "basic-memory", "file-explorer", "memory-range", "screen", "raw"],
|
|
24
|
+
description: "Content source: basic-editor (BASIC editor text), asm-editor (ASM editor source), basic-memory (BASIC program from emulator memory), file-explorer (file from disk/SmartPort), memory-range (raw memory bytes), screen (screen capture), raw (LLM-provided content)"
|
|
25
|
+
},
|
|
26
|
+
whereTo: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "Sandbox path to save the file (e.g. \"[files]/prog.bas\", \"[t]/dump.bin\")"
|
|
29
|
+
},
|
|
30
|
+
direct: {
|
|
31
|
+
type: "boolean",
|
|
32
|
+
description: "When true (default), save file and return only metadata. When false, return content to LLM without saving.",
|
|
33
|
+
default: true
|
|
34
|
+
},
|
|
35
|
+
overwrite: {
|
|
36
|
+
type: "boolean",
|
|
37
|
+
description: "Allow overwriting an existing file (default: false)",
|
|
38
|
+
default: false
|
|
39
|
+
},
|
|
40
|
+
// raw source
|
|
41
|
+
content: {
|
|
42
|
+
type: "object",
|
|
43
|
+
description: "For from=raw: content provided by the LLM",
|
|
44
|
+
properties: {
|
|
45
|
+
data: {
|
|
46
|
+
type: "string",
|
|
47
|
+
description: "The content to save — UTF-8 text or base64-encoded binary depending on type"
|
|
48
|
+
},
|
|
49
|
+
type: {
|
|
50
|
+
type: "string",
|
|
51
|
+
enum: ["text", "binary"],
|
|
52
|
+
description: "text: save data as UTF-8. binary: decode data from base64 and save as binary."
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
required: ["data", "type"]
|
|
56
|
+
},
|
|
57
|
+
// file-explorer source
|
|
58
|
+
filename: {
|
|
59
|
+
type: "string",
|
|
60
|
+
description: "For from=file-explorer: filename on disk to load"
|
|
61
|
+
},
|
|
62
|
+
drive: {
|
|
63
|
+
type: "number",
|
|
64
|
+
description: "For from=file-explorer: drive number 0 or 1 (default 0)",
|
|
65
|
+
default: 0
|
|
66
|
+
},
|
|
67
|
+
// memory-range source
|
|
68
|
+
address: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "For from=memory-range: start address — $hex (e.g. \"$0300\") or decimal"
|
|
71
|
+
},
|
|
72
|
+
length: {
|
|
73
|
+
type: "string",
|
|
74
|
+
description: "For from=memory-range: byte count — $hex (e.g. \"$0800\") or decimal"
|
|
75
|
+
},
|
|
76
|
+
// screen source
|
|
77
|
+
screenMode: {
|
|
78
|
+
type: "string",
|
|
79
|
+
enum: ["auto", "text", "graphics"],
|
|
80
|
+
description: "For from=screen: 'graphics' captures as PNG, 'text' captures screen text, 'auto' defaults to graphics (default: auto)",
|
|
81
|
+
default: "auto"
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
required: ["from", "whereTo"]
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Call a frontend app tool via AG-UI and return parsed result
|
|
90
|
+
*/
|
|
91
|
+
async function callAppTool(httpServer, command, params = {}) {
|
|
92
|
+
const toolCallId = `tc_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
93
|
+
|
|
94
|
+
await httpServer.sendEvent({
|
|
95
|
+
type: "TOOL_CALL_START",
|
|
96
|
+
tool_call_id: toolCallId,
|
|
97
|
+
tool_call_name: "emma_command",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await httpServer.sendEvent({
|
|
101
|
+
type: "TOOL_CALL_ARGS",
|
|
102
|
+
tool_call_id: toolCallId,
|
|
103
|
+
delta: JSON.stringify({ command, params }),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await httpServer.sendEvent({
|
|
107
|
+
type: "TOOL_CALL_END",
|
|
108
|
+
tool_call_id: toolCallId,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const raw = await httpServer.waitForToolResult(toolCallId, 15000);
|
|
112
|
+
return typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Fetch content from the requested source.
|
|
117
|
+
* Returns { content, isBinary, isDataUrl }
|
|
118
|
+
* content — string: raw base64 (isBinary=true) or UTF-8 text (isBinary=false)
|
|
119
|
+
* isBinary — true if content is base64-encoded binary
|
|
120
|
+
* isDataUrl — true if the base64 has a data URL prefix that must be stripped
|
|
121
|
+
*/
|
|
122
|
+
async function fetchContent(httpServer, args) {
|
|
123
|
+
const { from, content, filename, drive = 0, address, length, screenMode = "auto" } = args;
|
|
124
|
+
|
|
125
|
+
switch (from) {
|
|
126
|
+
|
|
127
|
+
case "raw": {
|
|
128
|
+
if (!content?.data) throw new Error("content.data is required for from=raw");
|
|
129
|
+
if (!content?.type) throw new Error("content.type is required for from=raw");
|
|
130
|
+
return {
|
|
131
|
+
content: content.data,
|
|
132
|
+
isBinary: content.type === "binary",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case "basic-editor": {
|
|
137
|
+
const result = await callAppTool(httpServer, "basicProgramGet");
|
|
138
|
+
if (!result?.success) throw new Error(result?.error || "Failed to get BASIC program from editor");
|
|
139
|
+
return { content: result.program, isBinary: false };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
case "asm-editor": {
|
|
143
|
+
const result = await callAppTool(httpServer, "asmGet");
|
|
144
|
+
if (!result?.success) throw new Error(result?.error || "Failed to get ASM source from editor");
|
|
145
|
+
return { content: result.source, isBinary: false };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case "basic-memory": {
|
|
149
|
+
const result = await callAppTool(httpServer, "directReadBasic");
|
|
150
|
+
if (!result?.success) throw new Error(result?.error || "Failed to read BASIC from memory");
|
|
151
|
+
return { content: result.program, isBinary: false };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case "file-explorer": {
|
|
155
|
+
if (!filename) throw new Error("filename is required for from=file-explorer");
|
|
156
|
+
const result = await callAppTool(httpServer, "getDiskFileContent", { filename, drive });
|
|
157
|
+
if (!result?.success) throw new Error(result?.error || "Failed to read file from disk");
|
|
158
|
+
if (result.isBinary) {
|
|
159
|
+
return { content: result.contentBase64, isBinary: true };
|
|
160
|
+
}
|
|
161
|
+
return { content: result.content ?? result.text ?? result.contentBase64, isBinary: false };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case "memory-range": {
|
|
165
|
+
if (!address) throw new Error("address is required for from=memory-range");
|
|
166
|
+
if (!length) throw new Error("length is required for from=memory-range");
|
|
167
|
+
const result = await callAppTool(httpServer, "directSaveBinaryRangeTo", { address, length });
|
|
168
|
+
if (!result?.success) throw new Error(result?.error || "Failed to read memory range");
|
|
169
|
+
return { content: result.contentBase64, isBinary: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case "screen": {
|
|
173
|
+
if (screenMode === "text") {
|
|
174
|
+
const result = await callAppTool(httpServer, "captureScreenText");
|
|
175
|
+
if (!result?.success) throw new Error(result?.error || "Failed to capture screen text");
|
|
176
|
+
return { content: result.text, isBinary: false };
|
|
177
|
+
}
|
|
178
|
+
// "auto" and "graphics" → PNG
|
|
179
|
+
const result = await callAppTool(httpServer, "captureScreenshot");
|
|
180
|
+
if (!result?.success) throw new Error(result?.error || "Failed to capture screenshot");
|
|
181
|
+
return { content: result.imageBase64, isBinary: true, isDataUrl: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
default:
|
|
185
|
+
throw new Error(`Unknown source: ${from}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function handler(args, httpServer) {
|
|
190
|
+
const { whereTo, direct = true, overwrite = false } = args;
|
|
191
|
+
|
|
192
|
+
if (!whereTo) {
|
|
193
|
+
return { success: false, error: "whereTo parameter is required" };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Fetch content from the source
|
|
197
|
+
let fetched;
|
|
198
|
+
try {
|
|
199
|
+
fetched = await fetchContent(httpServer, args);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
return { success: false, error: err.message };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { isBinary, isDataUrl } = fetched;
|
|
205
|
+
let content = fetched.content;
|
|
206
|
+
|
|
207
|
+
// Strip data URL prefix if present (e.g. "data:image/png;base64, ")
|
|
208
|
+
if (isDataUrl && isBinary) {
|
|
209
|
+
content = content.replace(/^data:[^;]+;base64,\s*/, '').trim();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// direct=false: return content to LLM without saving
|
|
213
|
+
if (!direct) {
|
|
214
|
+
return {
|
|
215
|
+
success: true,
|
|
216
|
+
isBinary,
|
|
217
|
+
...(isBinary ? { contentBase64: content } : { content }),
|
|
218
|
+
message: "Content loaded — not saved (direct=false)"
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// direct=true: save to file, scrub content from response
|
|
223
|
+
let expandedPath;
|
|
224
|
+
try {
|
|
225
|
+
expandedPath = pathResolver.resolve(whereTo);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return { success: false, error: err.message };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!overwrite && fs.existsSync(expandedPath)) {
|
|
231
|
+
return {
|
|
232
|
+
success: false,
|
|
233
|
+
error: `File already exists: ${expandedPath}. Set overwrite: true to replace it.`
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const dir = path.dirname(expandedPath);
|
|
239
|
+
if (!fs.existsSync(dir)) {
|
|
240
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let size;
|
|
244
|
+
if (isBinary) {
|
|
245
|
+
const buffer = Buffer.from(content, 'base64');
|
|
246
|
+
fs.writeFileSync(expandedPath, buffer);
|
|
247
|
+
size = buffer.length;
|
|
248
|
+
} else {
|
|
249
|
+
fs.writeFileSync(expandedPath, content, 'utf8');
|
|
250
|
+
size = Buffer.byteLength(content, 'utf8');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
success: true,
|
|
255
|
+
path: expandedPath,
|
|
256
|
+
size,
|
|
257
|
+
from: args.from,
|
|
258
|
+
message: `Saved ${size} bytes to ${expandedPath}`
|
|
259
|
+
};
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return { success: false, error: err.message };
|
|
262
|
+
}
|
|
263
|
+
}
|