@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 CHANGED
@@ -36,11 +36,70 @@ npm install
36
36
 
37
37
  ## Configuration
38
38
 
39
- Add to your MCP client configuration. For Claude Code, edit `~/.claude/mcp.json`:
39
+ ### For Claude Desktop
40
40
 
41
- ### Option 1: Using bunx (Recommended)
41
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%/Claude/claude_desktop_config.json` (Windows):
42
42
 
43
- Runs the published package directly with Bun:
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
- ### Option 2: Local development from source
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 ~/Documents/ProDOS_2_4_2.dsk into drive 1
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 ~/Images/Total_Replay.hdv into SmartPort device 1
144
+ Load [games]/Total_Replay.hdv into SmartPort device 1
85
145
  Turn on the emulator and boot from disk
86
- Save 256 bytes from memory address $0800 to ~/output.bin
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retrotech71/appleii-agent",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "MCP server for the Apple //e browser emulator — control the emulator and integrate with AI agents",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
+ }
@@ -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
- saveBasicFile,
42
- saveAsmFile,
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 { homedir } from 'os';
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 (supports ~ for home directory)",
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
- // Expand ~ to home directory
39
- let expandedPath = filePath;
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)) {
@@ -1,16 +1,16 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import os from 'os';
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 (supports ~ for home directory)"
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
- // Expand ~ to home directory
37
- let expandedPath = filePath;
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 { homedir } from 'os';
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 (supports ~ for home directory)",
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
- // Expand ~ to home directory
39
- let expandedPath = filePath;
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 { homedir } from 'os';
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 (supports ~ for home directory, .s or .asm extension recommended)",
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
- // Expand ~ to home directory
55
- let expandedPath = filePath;
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 { homedir } from 'os';
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 (supports ~ for home directory, .bas extension recommended)",
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
- // Expand ~ to home directory
55
- let expandedPath = filePath;
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 os from 'os';
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 (supports ~ for home directory)"
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
- // Expand ~ to home directory
48
- let expandedPath = filePath;
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(contentBase64, 'base64');
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
+ }