Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ export { createPdfPlugin, type PdfPlugin } from './plugins/pdf'
export { createDesktopPlugin, type DesktopPlugin } from './plugins/desktop'
export { createMetaPlugin } from './plugins/meta'

// Foundations Pack — OS-basics plugin (app launcher, clipboard, allowlisted
// filesystem, durable state K/V). Opt-in.
export {
createFoundationsPlugin,
FilesGuard,
StateStore,
FOUNDATIONS_TOOLS,
runApp,
readClipboardText,
writeClipboardText,
} from '../plugins/foundations'
export type { FoundationsPluginConfig } from '../plugins/foundations'

// Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the
// default plugin set. Pass it explicitly via `createMcpServer({ plugins })`.
export {
Expand Down
132 changes: 132 additions & 0 deletions src/plugins/foundations/app-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Cross-platform application launcher.
*
* Handles three invocation shapes:
* 1. App name — "Excel", "Calculator"; resolved via OS file
* associations / Launch Services / Start Menu.
* 2. Absolute path — "C:\\path\\to\\app.exe", "/Applications/Excel.app"
* 3. File path — "report.xlsx" → opened in the default associated app.
*
* The shape is detected automatically. On macOS we shell out to `open`
* (handles all three transparently); on Windows we use `start` for the
* same effect; on Linux we use `xdg-open` for files and direct spawn
* for binaries.
*/
import { spawn } from 'node:child_process'
import { access, constants } from 'node:fs/promises'
import * as path from 'node:path'

export interface RunAppOptions {
/** App name, absolute path to a binary/bundle, or file path. */
command: string
/** Extra arguments forwarded to the launched app. */
args?: string[]
/** Working directory for the spawned process. Default: process.cwd(). */
cwd?: string
/** Detach so the spawned process survives the MCP server's death. Default: true. */
detached?: boolean
}

export interface RunAppResult {
/** OS process id of the launcher invocation. The launched app may run
* as a child of this (Mac `open`, Windows `start`) — agents should
* use `agentmark_desktop_list_targets` to find the actual window. */
pid: number
/** What was actually invoked (resolved command + args), for diagnostics. */
command: string
args: string[]
platform: NodeJS.Platform
}

export async function runApp(opts: RunAppOptions): Promise<RunAppResult> {
const { command, args = [], cwd, detached = true } = opts
if (!command) throw new Error('runApp: command is required.')

const platform = process.platform
const resolved = await resolveLaunch(command, args, platform)

const child = spawn(resolved.command, resolved.args, {
cwd,
detached,
stdio: 'ignore',
windowsHide: false,
})

if (detached) child.unref()

if (!child.pid) {
throw new Error(
`runApp: failed to spawn ${resolved.command}. The OS rejected the launch.`,
)
}

return {
pid: child.pid,
command: resolved.command,
args: resolved.args,
platform,
}
}

interface ResolvedLaunch {
command: string
args: string[]
}

async function resolveLaunch(
command: string,
args: string[],
platform: NodeJS.Platform,
): Promise<ResolvedLaunch> {
const isAbsolute = path.isAbsolute(command)
let existsAsFile = false
if (isAbsolute) {
try {
await access(command, constants.F_OK)
existsAsFile = true
} catch {
existsAsFile = false
}
}

if (platform === 'darwin') {
// `open` handles app bundles, file paths, and app names equally.
// Use -a only when we have a bare app name; let `open` infer
// otherwise so file associations work.
if (existsAsFile || isAbsolute) {
return { command: 'open', args: [command, ...maybeArgs(args)] }
}
return { command: 'open', args: ['-a', command, ...maybeArgs(args)] }
}

if (platform === 'win32') {
// `cmd /c start "" "<command>" args...` makes Windows resolve via
// file associations / Start Menu when `<command>` isn't an absolute
// exe. The empty title arg is required syntax for `start`.
return {
command: 'cmd',
args: ['/c', 'start', '""', command, ...args],
}
}

// Linux / other Unix
if (existsAsFile || !looksLikeFile(command)) {
return { command, args }
}
return { command: 'xdg-open', args: [command, ...args] }
}

function maybeArgs(args: string[]): string[] {
if (args.length === 0) return []
// macOS `open` passes args via `--args` (everything after is forwarded
// to the launched app).
return ['--args', ...args]
}

function looksLikeFile(command: string): boolean {
// Heuristic: if the command has an extension and a path separator OR
// ends in a known document extension, treat it as a file path.
const ext = path.extname(command).toLowerCase()
if (!ext) return false
return /\.(xlsx|xls|docx|doc|pptx|ppt|pdf|txt|csv|md|html|jpg|jpeg|png|gif)$/.test(ext)
}
110 changes: 110 additions & 0 deletions src/plugins/foundations/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Cross-platform clipboard access.
*
* No external dependencies — shells out to the OS-provided clipboard
* utility on each platform:
* - macOS: pbcopy / pbpaste
* - Windows: PowerShell Get-Clipboard / Set-Clipboard
* - Linux: xclip (preferred) or xsel as fallback; wl-paste on Wayland
*
* Only text is supported in this first pass. HTML and image clipboards
* are valuable but each requires platform-specific incantations (CF_HTML
* with envelope on Windows, NSPasteboardItem on Mac); shipping later.
*/
import { spawn } from 'node:child_process'

export async function readClipboardText(): Promise<string> {
if (process.platform === 'darwin') {
return await runForOutput('pbpaste', [])
}
if (process.platform === 'win32') {
// -Raw avoids PowerShell appending a trailing CRLF on the final line.
// -OutputBuffer suppresses Get-Clipboard's deprecation warning.
const out = await runForOutput('powershell', [
'-NoProfile',
'-Command',
'Get-Clipboard -Raw',
])
// PowerShell ends with `\r\n` of its own; strip exactly one trailing
// newline to match pbpaste / xclip behavior.
return out.replace(/\r?\n$/, '')
}
// Linux: try wl-paste (Wayland) → xclip → xsel.
for (const [cmd, args] of [
['wl-paste', ['--no-newline']],
['xclip', ['-selection', 'clipboard', '-out']],
['xsel', ['--clipboard', '--output']],
] as const) {
try {
return await runForOutput(cmd, [...args])
} catch {
continue
}
}
throw new Error(
'Clipboard read on Linux requires wl-paste, xclip, or xsel. '
+ 'Install one: `apt install xclip` (X11) or `apt install wl-clipboard` (Wayland).',
)
}

export async function writeClipboardText(text: string): Promise<void> {
if (process.platform === 'darwin') {
await runWithInput('pbcopy', [], text)
return
}
if (process.platform === 'win32') {
// Set-Clipboard reads stdin when -Value isn't supplied; the
// simpler form avoids escaping nightmares for arbitrary text.
await runWithInput('powershell', [
'-NoProfile',
'-Command',
'$input | Set-Clipboard',
], text)
return
}
for (const [cmd, args] of [
['wl-copy', []],
['xclip', ['-selection', 'clipboard', '-in']],
['xsel', ['--clipboard', '--input']],
] as const) {
try {
await runWithInput(cmd, [...args], text)
return
} catch {
continue
}
}
throw new Error(
'Clipboard write on Linux requires wl-copy, xclip, or xsel.',
)
}

function runForOutput(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (b: Buffer) => { stdout += b.toString('utf8') })
child.stderr.on('data', (b: Buffer) => { stderr += b.toString('utf8') })
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) resolve(stdout)
else reject(new Error(`${command} exited ${code}: ${stderr.trim()}`))
})
})
}

function runWithInput(command: string, args: string[], input: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'pipe'] })
let stderr = ''
child.stderr.on('data', (b: Buffer) => { stderr += b.toString('utf8') })
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`${command} exited ${code}: ${stderr.trim()}`))
})
child.stdin.write(input)
child.stdin.end()
})
}
Loading
Loading