mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
三平台 Computer Use (macOS + Windows + Linux),Windows 专项增强。
- MCP server: toolCalls/tools/executor/mcpServer 等 12 文件完整实现
- 平台抽象层: platforms/{win32,darwin,linux}.ts
- 跨平台 executor: executorCrossPlatform.ts
- CHICAGO_MCP + VOICE_MODE feature flags 启用
- windowMessage.ts: SendMessageW (WM_CHAR Unicode + 剪贴板粘贴)
- windowBorder.ts: 4 叠加窗口边框 (30fps 跟踪)
- uiAutomation.ts: UI Automation 元素树/点击/写值
- accessibilitySnapshot.ts: 无障碍快照 → 模型感知 GUI
- bridge.py + bridgeClient.ts: Python 长驻进程 (替代 per-call PS)
- window_management: min/max/restore/close/focus (Win32 API)
- click_element / type_into_element: 按名称操作 (无需坐标)
- 截图自动附带 Accessibility Snapshot
- 17 种方法, stdin/stdout JSON 通信
- 窗口枚举 1.5ms vs PS 500ms, 截图 360ms vs PS 800ms
- 依赖: mss + Pillow + pywinauto
451 lines
13 KiB
TypeScript
451 lines
13 KiB
TypeScript
/**
|
|
* Word COM automation module for Windows.
|
|
* Uses PowerShell to drive Word.Application COM object — fully headless (Visible=false).
|
|
* Each function builds a PowerShell script, runs it via Bun.spawnSync, and parses JSON output.
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface WordParagraph {
|
|
text: string
|
|
bold?: boolean
|
|
italic?: boolean
|
|
fontSize?: number
|
|
}
|
|
|
|
export interface WordTable {
|
|
rows: number
|
|
cols: number
|
|
data: string[][]
|
|
}
|
|
|
|
export interface WordDocInfo {
|
|
text: string
|
|
paragraphs: WordParagraph[]
|
|
tables: WordTable[]
|
|
wordCount: number
|
|
pageCount: number
|
|
}
|
|
|
|
export interface AppendTextOptions {
|
|
bold?: boolean
|
|
italic?: boolean
|
|
fontSize?: number
|
|
fontName?: string
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PowerShell runner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function runPs(script: string): string {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
return new TextDecoder().decode(result.stdout).trim()
|
|
}
|
|
|
|
function parseJsonOutput<T>(raw: string, fallback: T): T {
|
|
if (!raw) return fallback
|
|
try {
|
|
return JSON.parse(raw) as T
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
/** Escape a string for safe embedding inside a PowerShell single-quoted string. */
|
|
function psEscape(s: string): string {
|
|
return s.replace(/'/g, "''")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Word COM wrapper template
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Wraps a Word COM script body with standard open/cleanup boilerplate.
|
|
* The body receives $word and $doc variables.
|
|
* If `openPath` is provided the document is opened; otherwise a new doc is created.
|
|
*/
|
|
function wrapWordScript(body: string, openPath?: string): string {
|
|
const openCmd = openPath
|
|
? `$doc = $word.Documents.Open('${psEscape(openPath)}')`
|
|
: '$doc = $word.Documents.Add()'
|
|
|
|
return `
|
|
$word = New-Object -ComObject Word.Application
|
|
$word.Visible = $false
|
|
$word.DisplayAlerts = 0
|
|
try {
|
|
${openCmd}
|
|
${body}
|
|
} finally {
|
|
if ($doc -ne $null) { $doc.Close($false); }
|
|
if ($word -ne $null) { $word.Quit(); }
|
|
if ($word -ne $null) { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null }
|
|
}
|
|
`
|
|
}
|
|
|
|
/**
|
|
* Same as wrapWordScript but the body is responsible for saving before close.
|
|
* After body runs, $doc.Save() is called automatically.
|
|
*/
|
|
function wrapWordScriptWithSave(body: string, openPath: string): string {
|
|
return `
|
|
$word = New-Object -ComObject Word.Application
|
|
$word.Visible = $false
|
|
$word.DisplayAlerts = 0
|
|
try {
|
|
$doc = $word.Documents.Open('${psEscape(openPath)}')
|
|
${body}
|
|
$doc.Save()
|
|
Write-Output '{"ok":true}'
|
|
} catch {
|
|
Write-Output ('{"ok":false,"error":"' + ($_.Exception.Message -replace '"','\\"') + '"}')
|
|
} finally {
|
|
if ($doc -ne $null) { $doc.Close($false); }
|
|
if ($word -ne $null) { $word.Quit(); }
|
|
if ($word -ne $null) { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null }
|
|
}
|
|
`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. openWord
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function openWord(filePath: string): Promise<WordDocInfo> {
|
|
const script = wrapWordScript(
|
|
`
|
|
# Paragraphs (limit 500)
|
|
$paras = @()
|
|
$paraCount = $doc.Paragraphs.Count
|
|
$limit = [Math]::Min($paraCount, 500)
|
|
for ($i = 1; $i -le $limit; $i++) {
|
|
$p = $doc.Paragraphs.Item($i)
|
|
$r = $p.Range
|
|
$paras += @{
|
|
text = $r.Text -replace '\\r$',''
|
|
bold = [bool]($r.Font.Bold -eq -1)
|
|
italic = [bool]($r.Font.Italic -eq -1)
|
|
fontSize = $r.Font.Size
|
|
}
|
|
}
|
|
|
|
# Tables
|
|
$tables = @()
|
|
foreach ($table in $doc.Tables) {
|
|
$rows = $table.Rows.Count
|
|
$cols = $table.Columns.Count
|
|
$data = @()
|
|
for ($r = 1; $r -le $rows; $r++) {
|
|
$row = @()
|
|
for ($c = 1; $c -le $cols; $c++) {
|
|
try {
|
|
$cellText = $table.Cell($r, $c).Range.Text
|
|
# Trim trailing \\r\\a that Word adds to cell text
|
|
$cellText = $cellText -replace '[\\r\\n\\a]+$',''
|
|
$row += $cellText
|
|
} catch {
|
|
$row += ''
|
|
}
|
|
}
|
|
$data += ,@($row)
|
|
}
|
|
$tables += @{ rows = $rows; cols = $cols; data = $data }
|
|
}
|
|
|
|
# Counts: wdStatisticWords=0, wdStatisticPages=2
|
|
$wordCount = $doc.ComputeStatistics(0)
|
|
$pageCount = $doc.ComputeStatistics(2)
|
|
|
|
$result = @{
|
|
text = $doc.Content.Text
|
|
paragraphs = $paras
|
|
tables = $tables
|
|
wordCount = $wordCount
|
|
pageCount = $pageCount
|
|
}
|
|
Write-Output (ConvertTo-Json $result -Depth 5 -Compress)
|
|
`,
|
|
filePath,
|
|
)
|
|
|
|
const raw = runPs(script)
|
|
return parseJsonOutput<WordDocInfo>(raw, {
|
|
text: '',
|
|
paragraphs: [],
|
|
tables: [],
|
|
wordCount: 0,
|
|
pageCount: 0,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. readText
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function readText(filePath: string): Promise<string> {
|
|
const script = wrapWordScript(
|
|
`Write-Output $doc.Content.Text`,
|
|
filePath,
|
|
)
|
|
return runPs(script)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. appendText
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function appendText(
|
|
filePath: string,
|
|
text: string,
|
|
opts?: AppendTextOptions,
|
|
): Promise<boolean> {
|
|
const fontSetup = opts
|
|
? [
|
|
opts.bold !== undefined ? `$sel.Font.Bold = ${opts.bold ? '-1' : '0'}` : '',
|
|
opts.italic !== undefined ? `$sel.Font.Italic = ${opts.italic ? '-1' : '0'}` : '',
|
|
opts.fontSize !== undefined ? `$sel.Font.Size = ${opts.fontSize}` : '',
|
|
opts.fontName ? `$sel.Font.Name = '${psEscape(opts.fontName)}'` : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n ')
|
|
: ''
|
|
|
|
const body = `
|
|
$sel = $word.Selection
|
|
$sel.EndKey(6) | Out-Null
|
|
${fontSetup}
|
|
$sel.TypeText('${psEscape(text)}')
|
|
`
|
|
|
|
const script = wrapWordScriptWithSave(body, filePath)
|
|
const raw = runPs(script)
|
|
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. insertText
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function insertText(
|
|
filePath: string,
|
|
paraIndex: number,
|
|
text: string,
|
|
): Promise<boolean> {
|
|
const body = `
|
|
$doc.Paragraphs.Item(${paraIndex}).Range.InsertBefore('${psEscape(text)}')
|
|
`
|
|
const script = wrapWordScriptWithSave(body, filePath)
|
|
const raw = runPs(script)
|
|
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. findReplace
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function findReplace(
|
|
filePath: string,
|
|
find: string,
|
|
replace: string,
|
|
replaceAll?: boolean,
|
|
): Promise<number> {
|
|
// wdReplaceAll=2, wdReplaceOne=1
|
|
const replaceConst = replaceAll !== false ? 2 : 1
|
|
|
|
const body = `
|
|
$content = $doc.Content
|
|
$findObj = $content.Find
|
|
$findObj.ClearFormatting()
|
|
$findObj.Replacement.ClearFormatting()
|
|
|
|
# Count replacements by iterating
|
|
$count = 0
|
|
$findObj.Text = '${psEscape(find)}'
|
|
$findObj.Replacement.Text = '${psEscape(replace)}'
|
|
$findObj.Forward = $true
|
|
$findObj.Wrap = 0
|
|
$findObj.Format = $false
|
|
$findObj.MatchCase = $false
|
|
$findObj.MatchWholeWord = $false
|
|
$findObj.MatchWildcards = $false
|
|
|
|
if (${replaceConst} -eq 2) {
|
|
# Count occurrences first using a clone of content
|
|
$range2 = $doc.Content.Duplicate
|
|
while ($range2.Find.Execute('${psEscape(find)}')) { $count++ }
|
|
# Now do the actual replace
|
|
$findObj.Execute('${psEscape(find)}', $false, $false, $false, $false, $false, $true, 0, $false, '${psEscape(replace)}', 2)
|
|
} else {
|
|
$found = $findObj.Execute('${psEscape(find)}', $false, $false, $false, $false, $false, $true, 0, $false, '${psEscape(replace)}', 1)
|
|
if ($found) { $count = 1 }
|
|
}
|
|
`
|
|
|
|
const script = `
|
|
$word = New-Object -ComObject Word.Application
|
|
$word.Visible = $false
|
|
$word.DisplayAlerts = 0
|
|
try {
|
|
$doc = $word.Documents.Open('${psEscape(filePath)}')
|
|
${body}
|
|
$doc.Save()
|
|
Write-Output ('{"count":' + $count + '}')
|
|
} catch {
|
|
Write-Output '{"count":0}'
|
|
} finally {
|
|
if ($doc -ne $null) { $doc.Close($false); }
|
|
if ($word -ne $null) { $word.Quit(); }
|
|
if ($word -ne $null) { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null }
|
|
}
|
|
`
|
|
|
|
const raw = runPs(script)
|
|
return parseJsonOutput<{ count: number }>(raw, { count: 0 }).count
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 6. insertTable
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function insertTable(
|
|
filePath: string,
|
|
rows: number,
|
|
cols: number,
|
|
data: string[][],
|
|
): Promise<boolean> {
|
|
// Build PowerShell array literal for the data
|
|
const psData = data
|
|
.map(
|
|
(row) =>
|
|
',@(' + row.map((cell) => `'${psEscape(cell)}'`).join(',') + ')',
|
|
)
|
|
.join('\n ')
|
|
|
|
const body = `
|
|
$sel = $word.Selection
|
|
$sel.EndKey(6) | Out-Null
|
|
$table = $doc.Tables.Add($sel.Range, ${rows}, ${cols})
|
|
$data = @(${psData})
|
|
for ($r = 0; $r -lt $data.Count; $r++) {
|
|
for ($c = 0; $c -lt $data[$r].Count; $c++) {
|
|
$table.Cell($r + 1, $c + 1).Range.Text = $data[$r][$c]
|
|
}
|
|
}
|
|
`
|
|
|
|
const script = wrapWordScriptWithSave(body, filePath)
|
|
const raw = runPs(script)
|
|
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 7. saveWord
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function saveWord(
|
|
filePath: string,
|
|
savePath?: string,
|
|
): Promise<boolean> {
|
|
if (!savePath || savePath === filePath) {
|
|
const script = wrapWordScriptWithSave('', filePath)
|
|
const raw = runPs(script)
|
|
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
|
|
}
|
|
|
|
const body = `$doc.SaveAs('${psEscape(savePath)}')`
|
|
const script = `
|
|
$word = New-Object -ComObject Word.Application
|
|
$word.Visible = $false
|
|
$word.DisplayAlerts = 0
|
|
try {
|
|
$doc = $word.Documents.Open('${psEscape(filePath)}')
|
|
${body}
|
|
Write-Output '{"ok":true}'
|
|
} catch {
|
|
Write-Output ('{"ok":false,"error":"' + ($_.Exception.Message -replace '"','\\"') + '"}')
|
|
} finally {
|
|
if ($doc -ne $null) { $doc.Close($false); }
|
|
if ($word -ne $null) { $word.Quit(); }
|
|
if ($word -ne $null) { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null }
|
|
}
|
|
`
|
|
const raw = runPs(script)
|
|
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 8. saveAsPdf
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function saveAsPdf(
|
|
filePath: string,
|
|
pdfPath: string,
|
|
): Promise<boolean> {
|
|
// wdFormatPDF = 17
|
|
const body = `$doc.SaveAs2('${psEscape(pdfPath)}', 17)`
|
|
|
|
const script = `
|
|
$word = New-Object -ComObject Word.Application
|
|
$word.Visible = $false
|
|
$word.DisplayAlerts = 0
|
|
try {
|
|
$doc = $word.Documents.Open('${psEscape(filePath)}')
|
|
${body}
|
|
Write-Output '{"ok":true}'
|
|
} catch {
|
|
Write-Output ('{"ok":false,"error":"' + ($_.Exception.Message -replace '"','\\"') + '"}')
|
|
} finally {
|
|
if ($doc -ne $null) { $doc.Close($false); }
|
|
if ($word -ne $null) { $word.Quit(); }
|
|
if ($word -ne $null) { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null }
|
|
}
|
|
`
|
|
const raw = runPs(script)
|
|
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 9. createWord
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function createWord(savePath: string): Promise<boolean> {
|
|
const script = `
|
|
$word = New-Object -ComObject Word.Application
|
|
$word.Visible = $false
|
|
$word.DisplayAlerts = 0
|
|
try {
|
|
$doc = $word.Documents.Add()
|
|
$doc.SaveAs('${psEscape(savePath)}')
|
|
Write-Output '{"ok":true}'
|
|
} catch {
|
|
Write-Output ('{"ok":false,"error":"' + ($_.Exception.Message -replace '"','\\"') + '"}')
|
|
} finally {
|
|
if ($doc -ne $null) { $doc.Close($false); }
|
|
if ($word -ne $null) { $word.Quit(); }
|
|
if ($word -ne $null) { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null }
|
|
}
|
|
`
|
|
const raw = runPs(script)
|
|
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 10. closeWord (no-op)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* closeWord is a no-op since each operation opens and closes its own COM instance.
|
|
*/
|
|
export function closeWord(_filePath: string): void {
|
|
// No-op: each function manages its own Word lifecycle
|
|
}
|