diff --git a/README.md b/README.md index 0ca77a4ff..78b74339d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ OpenCLI gives you one surface for three different kinds of automation: - **Let AI Agents operate any website** — install the `opencli-browser` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type/fill, extract, and inspect any page through your logged-in browser via `opencli browser` primitives. - **Write new adapters** end-to-end with `opencli browser` + the `opencli-adapter-author` skill, which guides from first recon through field decoding, code, and `opencli browser verify`. -It also works as a **CLI hub** for local tools such as `gh`, `docker`, `longbridge`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, and ChatGPT. +It also works as a **CLI hub** for local tools such as `gh`, `docker`, `longbridge`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, ChatGPT, and Trae SOLO. ## Quick Start @@ -194,7 +194,7 @@ Unified passthrough for your existing command-line tools. Run `opencli .. Register your own with `opencli external register `; list everything with `opencli external list`. -**Desktop app adapters** (Electron, via CDP): Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao — see [`docs/adapters/desktop/`](./docs/adapters/desktop/). +**Desktop app adapters** (Electron, via CDP): Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — see [`docs/adapters/desktop/`](./docs/adapters/desktop/). ## Download Support diff --git a/README.zh-CN.md b/README.zh-CN.md index 3ca25d3d7..dae734446 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -15,7 +15,7 @@ OpenCLI 可以用同一套 CLI 做三类事情: - **让 AI Agent 操作任意网站**:在你的 AI Agent(Claude Code、Cursor 等)中安装 `opencli-browser` skill,Agent 就能用你的已登录浏览器导航、点击、输入/填充、提取任意网页内容。 - **把新网站写成 CLI**:用 `opencli browser` 原语 + `opencli-adapter-author` skill,从站点侦察、API 发现、字段解码到 `opencli browser verify` 一条龙。 -除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh`、`docker`、`longbridge`、`tg`、`discord`、`wx`、`ntn`(Notion)等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT 等 Electron 应用。 +除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh`、`docker`、`longbridge`、`tg`、`discord`、`wx`、`ntn`(Notion)等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT、Trae SOLO 等 Electron 应用。 ## 快速开始 @@ -182,7 +182,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 注册自定义本地 CLI:`opencli external register `;查看所有:`opencli external list`。 -**桌面应用适配器**(Electron,通过 CDP):Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。 +**桌面应用适配器**(Electron,通过 CDP):Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。 ## 下载支持 diff --git a/cli-manifest.json b/cli-manifest.json index 99a39cc12..c89d0755d 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -27592,6 +27592,763 @@ "modulePath": "toutiao/hot.js", "sourceFile": "toutiao/hot.js" }, + { + "site": "trae-solo", + "name": "automation-list", + "description": "List Trae SOLO Automation tab content. Default tab is \"Configured\"; pass --tab to switch.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "tab", + "type": "str", + "default": "configured", + "required": false, + "help": "Tab to view: configured / run-history / task-template" + }, + { + "name": "limit", + "type": "int", + "default": 50, + "required": false, + "help": "" + } + ], + "columns": [ + "Index", + "Title", + "Summary" + ], + "type": "js", + "modulePath": "trae-solo/automation.js", + "sourceFile": "trae-solo/automation.js", + "navigateBefore": true + }, + { + "site": "trae-solo", + "name": "cookies", + "description": "List cookies on the Trae SOLO renderer (JS-visible via document.cookie; httpOnly cookies not shown).", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Index", + "Key", + "Bytes", + "Name", + "Preview", + "Database", + "Version" + ], + "type": "js", + "modulePath": "trae-solo/renderer-storage.js", + "sourceFile": "trae-solo/renderer-storage.js", + "navigateBefore": true + }, + { + "site": "trae-solo", + "name": "extensions-list", + "description": "List VSCode extensions installed in Trae SOLO (~/.trae/extensions/extensions.json). Works while Trae is closed.", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [], + "columns": [ + "Index", + "Workspace Id", + "Kind", + "Target", + "Modified", + "Id", + "Version", + "Source", + "Installed" + ], + "type": "js", + "modulePath": "trae-solo/workspaces-fs.js", + "sourceFile": "trae-solo/workspaces-fs.js" + }, + { + "site": "trae-solo", + "name": "history", + "description": "List Trae SOLO projects and the tasks within each (from the project-list view sidebar).", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "project", + "type": "str", + "required": false, + "help": "Filter by project name (substring, case-insensitive)" + }, + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "help": "Max tasks per project" + } + ], + "columns": [ + "Project", + "Task Index", + "Task" + ], + "type": "js", + "modulePath": "trae-solo/history.js", + "sourceFile": "trae-solo/history.js", + "navigateBefore": true + }, + { + "site": "trae-solo", + "name": "idb-list", + "description": "List IndexedDB databases on the Trae SOLO renderer. Trae ships an @byted/ve-rtc DB used by the Volcengine RTC voice/video infrastructure.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Index", + "Key", + "Bytes", + "Name", + "Preview", + "Database", + "Version" + ], + "type": "js", + "modulePath": "trae-solo/renderer-storage.js", + "sourceFile": "trae-solo/renderer-storage.js", + "navigateBefore": true + }, + { + "site": "trae-solo", + "name": "mode", + "description": "Read or switch TRAE SOLO between Code mode and Work mode.", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "target", + "type": "str", + "required": false, + "positional": true, + "help": "Target mode: code or work. Omit to read current." + } + ], + "columns": [ + "Status", + "Mode" + ], + "type": "js", + "modulePath": "trae-solo/mode.js", + "sourceFile": "trae-solo/mode.js", + "navigateBefore": true + }, + { + "site": "trae-solo", + "name": "model", + "description": "Read or switch the current AI model in TRAE SOLO. Without arguments, reports the current model. With argument (substring, case-insensitive), switches to a matching model. Pass --list to enumerate available models.", + "access": "write", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "name", + "type": "str", + "required": false, + "positional": true, + "help": "Target model name (substring match, case-insensitive). Omit to read current." + }, + { + "name": "list", + "type": "boolean", + "default": false, + "required": false, + "help": "List all available models (does not switch)" + } + ], + "columns": [ + "Status", + "Model" + ], + "type": "js", + "modulePath": "trae-solo/model.js", + "sourceFile": "trae-solo/model.js", + "navigateBefore": true + }, + { + "site": "trae-solo", + "name": "recent-workspaces", + "description": "Show Trae SOLO's recently-opened workspaces (the File → Open Recent menu, stored under key \"history.recentlyOpenedPathsList\" in state.vscdb).", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "" + } + ], + "columns": [ + "Index", + "Key", + "Kind", + "Path" + ], + "type": "js", + "modulePath": "trae-solo/state-fs.js", + "sourceFile": "trae-solo/state-fs.js" + }, + { + "site": "trae-solo", + "name": "settings-read", + "description": "Parse and pretty-print Trae SOLO user settings.json (~/Library/Application Support/TRAE SOLO/User/settings.json). Handles VSCode JSONC syntax (line comments + trailing commas).", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [], + "columns": [ + "Field", + "Value" + ], + "type": "js", + "modulePath": "trae-solo/settings.js", + "sourceFile": "trae-solo/settings.js" + }, + { + "site": "trae-solo", + "name": "skill-category", + "description": "Filter Skills Marketplace by category. Pass --list to see categories.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "name", + "type": "str", + "required": false, + "positional": true, + "help": "Category name (substring; case-insensitive). Common: All / Developer Tools / Data Analysis / UI Design / Content Creation / Productivity" + }, + { + "name": "list", + "type": "boolean", + "default": false, + "required": false, + "help": "List available categories" + }, + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "help": "" + } + ], + "columns": [ + "Index", + "Name", + "Description" + ], + "type": "js", + "modulePath": "trae-solo/skill.js", + "sourceFile": "trae-solo/skill.js", + "navigateBefore": true + }, + { + "site": "trae-solo", + "name": "skill-fs-installed", + "description": "List INSTALLED Trae SOLO skills (managedSkills entry in ~/.trae/skill-config.json).", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [], + "columns": [ + "Index", + "Name", + "Description", + "Source" + ], + "type": "js", + "modulePath": "trae-solo/skill-fs.js", + "sourceFile": "trae-solo/skill-fs.js" + }, + { + "site": "trae-solo", + "name": "skill-fs-list", + "description": "List all Trae SOLO skills present on disk under ~/.trae/skills/. Reads SKILL.md front-matter for descriptions. Works while Trae is closed.", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 200, + "required": false, + "help": "Max rows" + } + ], + "columns": [ + "Index", + "Name", + "Description", + "Source" + ], + "type": "js", + "modulePath": "trae-solo/skill-fs.js", + "sourceFile": "trae-solo/skill-fs.js" + }, + { + "site": "trae-solo", + "name": "skill-fs-show", + "description": "Print a skill's SKILL.md content + on-disk path.", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [ + { + "name": "name", + "type": "str", + "required": true, + "positional": true, + "help": "Skill name (folder under ~/.trae/skills/)" + } + ], + "columns": [ + "Field", + "Value" + ], + "type": "js", + "modulePath": "trae-solo/skill-fs.js", + "sourceFile": "trae-solo/skill-fs.js" + }, + { + "site": "trae-solo", + "name": "skill-list", + "description": "List Trae SOLO Skills — by default the Marketplace; pass --installed to list installed ones.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "installed", + "type": "boolean", + "default": false, + "required": false, + "help": "List installed skills instead of the marketplace" + }, + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "help": "Max rows to return" + } + ], + "columns": [ + "Index", + "Name", + "Description" + ], + "type": "js", + "modulePath": "trae-solo/skill.js", + "sourceFile": "trae-solo/skill.js", + "navigateBefore": true + }, + { + "site": "trae-solo", + "name": "skill-search", + "description": "Filter Skills Marketplace by keyword.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "keyword", + "type": "str", + "required": true, + "positional": true, + "help": "Search keyword (substring)" + }, + { + "name": "limit", + "type": "int", + "default": 50, + "required": false, + "help": "Max rows" + } + ], + "columns": [ + "Index", + "Name", + "Description" + ], + "type": "js", + "modulePath": "trae-solo/skill.js", + "sourceFile": "trae-solo/skill.js", + "navigateBefore": true + }, + { + "site": "trae-solo", + "name": "state-get", + "description": "Read a single key from Trae SOLO's globalStorage state.vscdb. Pass --workspace to query a per-workspace DB instead. Returns parsed JSON if the value is JSON.", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [ + { + "name": "key", + "type": "str", + "required": true, + "positional": true, + "help": "State key (use state-keys to discover)" + }, + { + "name": "workspace", + "type": "str", + "required": false, + "help": "Workspace id (from workspaces-list) to query a per-workspace DB" + }, + { + "name": "max-bytes", + "type": "int", + "default": 8000, + "required": false, + "help": "Truncate value to this many bytes" + } + ], + "columns": [ + "Field", + "Value" + ], + "type": "js", + "modulePath": "trae-solo/state-fs.js", + "sourceFile": "trae-solo/state-fs.js" + }, + { + "site": "trae-solo", + "name": "state-keys", + "description": "List all keys present in Trae SOLO's globalStorage state.vscdb (VSCode-style UI/agent state). Pass --workspace to query a per-workspace DB instead. Use state-get to read a specific value. (See renderer storage-keys for browser-side LS/SS.)", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [ + { + "name": "filter", + "type": "str", + "required": false, + "help": "Case-insensitive substring filter over keys" + }, + { + "name": "workspace", + "type": "str", + "required": false, + "help": "Workspace id (from workspaces-list) to query a per-workspace DB" + }, + { + "name": "limit", + "type": "int", + "default": 200, + "required": false, + "help": "" + } + ], + "columns": [ + "Index", + "Key", + "Kind", + "Path" + ], + "type": "js", + "modulePath": "trae-solo/state-fs.js", + "sourceFile": "trae-solo/state-fs.js" + }, + { + "site": "trae-solo", + "name": "status", + "description": "Check active CDP connection to Trae SOLO Desktop", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status", + "Url", + "Title" + ], + "type": "js", + "modulePath": "trae-solo/status.js", + "sourceFile": "trae-solo/status.js", + "navigateBefore": true + }, + { + "site": "trae-solo", + "name": "storage-get", + "description": "Read a single localStorage / sessionStorage value on the Trae SOLO renderer.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "key", + "type": "str", + "required": true, + "positional": true, + "help": "Storage key (use storage-keys to discover)" + }, + { + "name": "storage", + "type": "str", + "default": "local", + "required": false, + "help": "\"local\" or \"session\"" + }, + { + "name": "max-bytes", + "type": "int", + "default": 4000, + "required": false, + "help": "Truncate value to this many chars" + } + ], + "columns": [ + "Field", + "Value" + ], + "type": "js", + "modulePath": "trae-solo/renderer-storage.js", + "sourceFile": "trae-solo/renderer-storage.js", + "navigateBefore": true + }, + { + "site": "trae-solo", + "name": "storage-keys", + "description": "List localStorage / sessionStorage keys on the Trae SOLO renderer (CDP). For the on-disk VSCode state.vscdb, see state-keys.", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "storage", + "type": "str", + "default": "local", + "required": false, + "help": "\"local\" or \"session\"" + }, + { + "name": "filter", + "type": "str", + "required": false, + "help": "Case-insensitive substring filter" + }, + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "help": "Max rows to return" + } + ], + "columns": [ + "Index", + "Key", + "Bytes", + "Name", + "Preview", + "Database", + "Version" + ], + "type": "js", + "modulePath": "trae-solo/renderer-storage.js", + "sourceFile": "trae-solo/renderer-storage.js", + "navigateBefore": true + }, + { + "site": "trae-solo", + "name": "task-fs-list", + "description": "List Trae SOLO task ids from disk (snapshot/ + agentconfig/.json). Works while Trae is closed.", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "help": "" + } + ], + "columns": [ + "Index", + "Task Id", + "Has Snapshot", + "Has Config", + "Modified", + "Phase", + "Turn Id", + "Commit" + ], + "type": "js", + "modulePath": "trae-solo/task-fs.js", + "sourceFile": "trae-solo/task-fs.js" + }, + { + "site": "trae-solo", + "name": "task-fs-show", + "description": "Show the workspace tree at a given chat-turn ref (via git ls-tree). Pass --turn to pick a turn; otherwise the latest after-chat-turn ref.", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [ + { + "name": "task-id", + "type": "str", + "required": true, + "positional": true, + "help": "Task UUID" + }, + { + "name": "turn", + "type": "str", + "required": false, + "help": "Specific turn id (omit for latest after-chat-turn)" + }, + { + "name": "limit", + "type": "int", + "default": 50, + "required": false, + "help": "" + } + ], + "columns": [ + "Mode", + "Path", + "Size" + ], + "type": "js", + "modulePath": "trae-solo/task-fs.js", + "sourceFile": "trae-solo/task-fs.js" + }, + { + "site": "trae-solo", + "name": "task-fs-turns", + "description": "Show the chat-turn timeline for a Trae SOLO task as git tags (before-chat-turn-* / after-chat-turn-*).", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [ + { + "name": "task-id", + "type": "str", + "required": true, + "positional": true, + "help": "Task UUID (folder name under snapshot/)" + }, + { + "name": "limit", + "type": "int", + "default": 50, + "required": false, + "help": "" + } + ], + "columns": [ + "Index", + "Task Id", + "Has Snapshot", + "Has Config", + "Modified", + "Phase", + "Turn Id", + "Commit" + ], + "type": "js", + "modulePath": "trae-solo/task-fs.js", + "sourceFile": "trae-solo/task-fs.js" + }, + { + "site": "trae-solo", + "name": "user-rules", + "description": "Print Trae SOLO user rules (~/.trae/user_rules.md).", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [], + "columns": [ + "Field", + "Value" + ], + "type": "js", + "modulePath": "trae-solo/user-rules.js", + "sourceFile": "trae-solo/user-rules.js" + }, + { + "site": "trae-solo", + "name": "workspaces-list", + "description": "List Trae SOLO workspaceStorage entries (~/Library/.../TRAE SOLO/User/workspaceStorage//), resolving each workspace.json to its single-folder path or multi-folder workspace target. Works while Trae is closed.", + "access": "read", + "domain": "localhost", + "strategy": "local", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "help": "" + } + ], + "columns": [ + "Index", + "Workspace Id", + "Kind", + "Target", + "Modified", + "Id", + "Version", + "Source", + "Installed" + ], + "type": "js", + "modulePath": "trae-solo/workspaces-fs.js", + "sourceFile": "trae-solo/workspaces-fs.js" + }, { "site": "tvmaze", "name": "search", diff --git a/clis/trae-solo/_actions.js b/clis/trae-solo/_actions.js new file mode 100644 index 000000000..ebc2eb4da --- /dev/null +++ b/clis/trae-solo/_actions.js @@ -0,0 +1,60 @@ +// Shared helpers for trae-solo adapter — panel navigation, pointer chain, etc. + +import { CommandExecutionError } from '@jackwener/opencli/errors'; + +// The 3 sidebar panel entries: 'New task' / 'Skills' / 'Automation'. +// Each renders as a clickable DIV with class .task-list-new-task-item. +export async function switchToPanel(page, panelName) { + const nameJson = JSON.stringify(panelName); + const result = await page.evaluate(`(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const target = ${nameJson}; + // Check if already active. + const active = Array.from(document.querySelectorAll('.task-list-new-task-item')) + .find((el) => el.offsetParent && /\\b(task-list-skills-item|task-list-new-task-item-in)\\b/.test(el.className) && el.className.includes('active') && el.textContent.trim() === target); + if (active) return { ok: true, already: true }; + const entry = Array.from(document.querySelectorAll('.task-list-new-task-item')) + .find((el) => el.offsetParent && el.textContent.trim() === target); + if (!entry) { + return { ok: false, reason: 'Panel entry not found.', detail: 'wanted=' + target }; + } + const r = entry.getBoundingClientRect(); + const init = { + bubbles: true, cancelable: true, button: 0, buttons: 1, + clientX: Math.round(r.left + Math.min(30, r.width / 2)), + clientY: Math.round(r.top + Math.min(10, r.height / 2)), + }; + entry.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' })); + entry.dispatchEvent(new MouseEvent('mousedown', init)); + entry.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' })); + entry.dispatchEvent(new MouseEvent('mouseup', init)); + entry.dispatchEvent(new MouseEvent('click', init)); + await wait(900); + return { ok: true }; + })()`); + if (!result?.ok) { + throw new CommandExecutionError(result?.reason || `Could not switch to ${panelName} panel.`, result?.detail || ''); + } + return result; +} + +// Click a target element using the full pointer-event chain. Useful for +// React + radix components that ignore plain .click(). +export async function pointerClickByEval(page, selectorJsExpr) { + return await page.evaluate(`(async () => { + const target = ${selectorJsExpr}; + if (!target) return { ok: false, reason: 'target not found' }; + const r = target.getBoundingClientRect(); + const init = { + bubbles: true, cancelable: true, button: 0, buttons: 1, + clientX: Math.round(r.left + r.width / 2), + clientY: Math.round(r.top + r.height / 2), + }; + target.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' })); + target.dispatchEvent(new MouseEvent('mousedown', init)); + target.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' })); + target.dispatchEvent(new MouseEvent('mouseup', init)); + target.dispatchEvent(new MouseEvent('click', init)); + return { ok: true }; + })()`); +} diff --git a/clis/trae-solo/_fs.js b/clis/trae-solo/_fs.js new file mode 100644 index 000000000..5918520e9 --- /dev/null +++ b/clis/trae-solo/_fs.js @@ -0,0 +1,118 @@ +// File-system access helpers for Trae SOLO local state. +// +// TRAE SOLO stores its conversation / skill / task state on disk under: +// - ~/.trae/ — user-scope (skills, extensions, rules) +// - ~/Library/Application Support/TRAE SOLO/ModularData/ai-agent/ +// — per-task storage (snapshots, configs) +// All file accesses here are local-only and read-only by default. Write +// helpers (used by skill-fs-install / task-fs-delete / etc.) require the +// caller's explicit --yes flag, mirroring the safety pattern used by the +// other adapters (grok delete, antigravity delete, etc.). + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; + +export const TRAE_USER_DIR = path.join(os.homedir(), '.trae'); +export const TRAE_SKILLS_DIR = path.join(TRAE_USER_DIR, 'skills'); +export const TRAE_SKILL_CONFIG = path.join(TRAE_USER_DIR, 'skill-config.json'); +export const TRAE_USER_RULES = path.join(TRAE_USER_DIR, 'user_rules.md'); + +export const TRAE_APP_SUPPORT = path.join( + os.homedir(), + 'Library/Application Support/TRAE SOLO', +); +export const TRAE_AI_AGENT_DIR = path.join( + TRAE_APP_SUPPORT, + 'ModularData/ai-agent', +); +export const TRAE_SNAPSHOT_DIR = path.join(TRAE_AI_AGENT_DIR, 'snapshot'); +export const TRAE_AGENTCONFIG_DIR = path.join(TRAE_AI_AGENT_DIR, 'agentconfig'); +export const TRAE_WORK_MODE_PROJECTS = path.join( + TRAE_AI_AGENT_DIR, + 'work-mode-projects', +); +export const TRAE_DB_WAL = path.join(TRAE_AI_AGENT_DIR, 'database.db-wal'); + +// Quick existence + readable check. +export function assertReadable(p, label) { + if (!fs.existsSync(p)) { + throw new CommandExecutionError( + `${label} not found: ${p}`, + 'Is Trae SOLO installed and run at least once?', + ); + } +} + +// Parse a Markdown SKILL.md and return its YAML-style front-matter as a +// plain object, plus a one-line description (first non-frontmatter +// non-heading paragraph). Handles missing/malformed front-matter +// gracefully — returns whatever it can. +export function parseSkillMd(skillDir) { + const skillMdPath = path.join(skillDir, 'SKILL.md'); + if (!fs.existsSync(skillMdPath)) { + return { name: path.basename(skillDir), description: '', tags: [] }; + } + const content = fs.readFileSync(skillMdPath, 'utf-8'); + const fm = {}; + let body = content; + if (content.startsWith('---\n')) { + const end = content.indexOf('\n---\n', 4); + if (end > 0) { + const fmText = content.slice(4, end); + for (const line of fmText.split('\n')) { + const m = line.match(/^([\w_-]+):\s*(.*)$/); + if (m) fm[m[1]] = m[2].replace(/^["']|["']$/g, '').trim(); + } + body = content.slice(end + 5); + } + } + // First non-empty, non-heading line of body = description fallback. + if (!fm.description) { + for (const line of body.split('\n')) { + const t = line.trim(); + if (!t) continue; + if (t.startsWith('#')) continue; + fm.description = t.slice(0, 200); + break; + } + } + return { + name: fm.name || path.basename(skillDir), + description: fm.description || '', + tags: fm.tags ? fm.tags.split(/\s+/) : [], + version: fm.version || '', + author: fm.author || '', + path: skillDir, + }; +} + +// Load skill-config.json (Trae's installed-skills registry). +export function readSkillConfig() { + assertReadable(TRAE_SKILL_CONFIG, 'skill-config.json'); + return JSON.parse(fs.readFileSync(TRAE_SKILL_CONFIG, 'utf-8')); +} + +// Atomically update skill-config.json — read, mutate via callback, write +// via tmp + rename to avoid Trae seeing a half-written file. +export function updateSkillConfig(mutate) { + const conf = readSkillConfig(); + mutate(conf); + const tmp = TRAE_SKILL_CONFIG + '.tmp-' + process.pid; + fs.writeFileSync(tmp, JSON.stringify(conf, null, 4)); + fs.renameSync(tmp, TRAE_SKILL_CONFIG); +} + +// Refuse to mutate ai-agent on-disk state if database.db-wal was touched +// in the last `windowSec` seconds — Trae is probably writing. +export function checkAgentDbQuiet(windowSec = 5) { + if (!fs.existsSync(TRAE_DB_WAL)) return; // first run + const ageMs = Date.now() - fs.statSync(TRAE_DB_WAL).mtimeMs; + if (ageMs < windowSec * 1000) { + throw new CommandExecutionError( + `Trae is actively writing (database.db-wal touched ${(ageMs / 1000).toFixed(1)}s ago).`, + `Wait ${windowSec}+ s for Trae to settle, or quit Trae SOLO before mutating state.`, + ); + } +} diff --git a/clis/trae-solo/_state.js b/clis/trae-solo/_state.js new file mode 100644 index 000000000..2a487fd37 --- /dev/null +++ b/clis/trae-solo/_state.js @@ -0,0 +1,111 @@ +// Helpers for reading Trae SOLO's VSCode-style state.vscdb files. +// +// Trae SOLO stores UI/agent state in the same on-disk layout VSCode uses: +// ~/Library/Application Support/TRAE SOLO/User/globalStorage/state.vscdb +// ~/Library/Application Support/TRAE SOLO/User/workspaceStorage//state.vscdb +// +// Each is a SQLite database with a single table: +// CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB); +// +// We shell out to /usr/bin/sqlite3 (macOS ships it) so we avoid pulling a +// native sqlite dep into OpenCLI. Reads only — writing would race with +// Trae's own writer and corrupt the DB. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { TRAE_APP_SUPPORT } from './_fs.js'; + +export const TRAE_USER_DIR_APP = path.join(TRAE_APP_SUPPORT, 'User'); +export const TRAE_GLOBAL_STATE_DB = path.join( + TRAE_USER_DIR_APP, + 'globalStorage/state.vscdb', +); +export const TRAE_WORKSPACE_STORAGE = path.join( + TRAE_USER_DIR_APP, + 'workspaceStorage', +); +export const TRAE_EXTENSIONS_JSON = path.join( + process.env.HOME || '', + '.trae/extensions/extensions.json', +); + +// Run `sqlite3 ""` and return stdout as a string. Throws +// CommandExecutionError on sqlite failure. +export function sqliteQuery(db, sql) { + if (!fs.existsSync(db)) { + throw new CommandExecutionError( + `state.vscdb not found: ${db}`, + 'Has Trae SOLO been run at least once?', + ); + } + try { + return execFileSync('/usr/bin/sqlite3', [db, sql], { + encoding: 'utf-8', + maxBuffer: 64 * 1024 * 1024, + }); + } catch (e) { + throw new CommandExecutionError( + `sqlite3 failed on ${path.basename(db)}: ${e.message}`, + 'The DB may be locked by a running Trae SOLO instance. Try closing it or wait a few seconds.', + ); + } +} + +// List all keys in an ItemTable. +export function listKeys(db) { + const out = sqliteQuery(db, 'SELECT key FROM ItemTable ORDER BY key;'); + return out.split('\n').map((s) => s.trim()).filter(Boolean); +} + +// Get a single value by key. Returns null if absent. Auto-parses JSON when +// possible. +export function getValue(db, key) { + // Escape single quotes for sqlite literal. + const esc = key.replace(/'/g, "''"); + const raw = sqliteQuery( + db, + `SELECT value FROM ItemTable WHERE key = '${esc}';`, + ).trim(); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +// Resolve a workspaceStorage workspace.json into a human-readable target. +// Trae writes two shapes: +// { "folder": "file:///path/to/single-folder" } +// { "workspace": "file:///.../Workspaces//workspace.json" } (multi-folder) +// For the multi-folder case we read the inner workspace.json and stringify +// its `folders` list. +export function resolveWorkspaceJson(wj) { + if (!fs.existsSync(wj)) return { kind: 'missing', target: '(no workspace.json)' }; + let outer; + try { + outer = JSON.parse(fs.readFileSync(wj, 'utf-8')); + } catch { + return { kind: 'invalid', target: '(invalid JSON)' }; + } + if (outer.folder) { + return { kind: 'folder', target: decodeURI(outer.folder.replace(/^file:\/\//, '')) }; + } + if (outer.workspace) { + const inner = outer.workspace.replace(/^file:\/\//, ''); + const innerPath = decodeURI(inner); + if (!fs.existsSync(innerPath)) { + return { kind: 'workspace', target: '(missing) ' + innerPath }; + } + try { + const inn = JSON.parse(fs.readFileSync(innerPath, 'utf-8')); + const folders = (inn.folders || []).map((f) => f.path || f.uri || JSON.stringify(f)); + return { kind: 'workspace', target: folders.join('; ') || '(empty workspace)' }; + } catch { + return { kind: 'workspace', target: '(invalid inner) ' + innerPath }; + } + } + return { kind: 'unknown', target: JSON.stringify(outer).slice(0, 100) }; +} diff --git a/clis/trae-solo/automation.js b/clis/trae-solo/automation.js new file mode 100644 index 000000000..fe948c4d3 --- /dev/null +++ b/clis/trae-solo/automation.js @@ -0,0 +1,97 @@ +// TRAE SOLO Automation panel — workflows / scheduled tasks. +// +// The panel has 3 tabs: +// .tab-u_Qz20 'Configured' — currently configured automations (empty by default) +// .tab-u_Qz20 'Run History' — past runs +// .tab-u_Qz20 'Task Template' — pre-built templates (e.g. "Daily AI News Briefing") +// +// And 2 top-level create buttons: +// .button-eTMLAq.secondary 'Create manually' +// .button-eTMLAq.primary 'Create in chat' + +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors'; +import { switchToPanel } from './_actions.js'; + +async function switchToAutomationTab(page, tabName) { + const nameJson = JSON.stringify(tabName); + await page.evaluate(`(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const target = ${nameJson}; + const tab = Array.from(document.querySelectorAll('.tab-u_Qz20')) + .find((t) => t.offsetParent && (t.textContent || '').trim() === target); + if (!tab || tab.className.includes('tabActive')) return; + const r = tab.getBoundingClientRect(); + const init = { + bubbles: true, cancelable: true, button: 0, buttons: 1, + clientX: Math.round(r.left + r.width / 2), + clientY: Math.round(r.top + r.height / 2), + }; + tab.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' })); + tab.dispatchEvent(new MouseEvent('mousedown', init)); + tab.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' })); + tab.dispatchEvent(new MouseEvent('mouseup', init)); + tab.dispatchEvent(new MouseEvent('click', init)); + await wait(700); + })()`); + await page.wait(0.3); +} + +// -------- automation-list -------- +cli({ + site: 'trae-solo', + name: 'automation-list', + access: 'read', + description: 'List Trae SOLO Automation tab content. Default tab is "Configured"; pass --tab to switch.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'tab', required: false, default: 'configured', help: 'Tab to view: configured / run-history / task-template' }, + { name: 'limit', type: 'int', required: false, default: 50 }, + ], + columns: ['Index', 'Title', 'Summary'], + func: async (page, kwargs) => { + await switchToPanel(page, 'Automation'); + const tab = String(kwargs.tab || 'configured').trim().toLowerCase(); + const tabLabel = { + 'configured': 'Configured', + 'run-history': 'Run History', + 'task-template': 'Task Template', + }[tab]; + if (!tabLabel) throw new ArgumentError('tab must be configured / run-history / task-template'); + await switchToAutomationTab(page, tabLabel); + + const items = await page.evaluate(`(function() { + // Each tab renders its content in a different container; pull all + // direct text rows from the main panel area. + const main = document.querySelector('.task-list-base-content') || document.querySelector('main') || document.body; + // Templates use .templateCard-... or similar. Configured items have a different shape. + const candidates = Array.from(main.querySelectorAll('[class*="templateCard"], [class*="taskCard"], [class*="card"], li, [role="listitem"]')) + .filter((el) => el.offsetParent); + const out = []; + const seen = new Set(); + for (const el of candidates) { + const t = (el.innerText || '').replace(/\\s+/g, ' ').trim(); + if (!t || t.length < 3 || seen.has(t)) continue; + // Filter UI chrome (tabs / heading / 'Create manually' / etc.) + if (/^(Configured|Run History|Task Template|Create manually|Create in chat|Automation|Create from a template)$/.test(t)) continue; + seen.add(t); + const lines = t.split('\\n'); + out.push({ title: lines[0].slice(0, 60), summary: (lines.slice(1).join(' ') || '').slice(0, 120) }); + } + // If nothing matched, fall back to a quick text dump of the main panel. + if (!out.length) { + const fallback = (main.innerText || '').trim(); + if (fallback) out.push({ title: '(empty)', summary: fallback.slice(0, 300) }); + } + return out; + })()`); + const limit = Number.isInteger(kwargs.limit) && kwargs.limit > 0 ? kwargs.limit : 50; + const rows = (items || []).slice(0, limit); + if (!rows.length) { + throw new EmptyResultError('trae-solo automation-list', `No items in tab '${tabLabel}'.`); + } + return rows.map((r, i) => ({ Index: i + 1, Title: r.title, Summary: r.summary })); + }, +}); diff --git a/clis/trae-solo/history.js b/clis/trae-solo/history.js new file mode 100644 index 000000000..7d08b7a41 --- /dev/null +++ b/clis/trae-solo/history.js @@ -0,0 +1,54 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; + +cli({ + site: 'trae-solo', + name: 'history', + access: 'read', + description: 'List Trae SOLO projects and the tasks within each (from the project-list view sidebar).', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'project', required: false, help: 'Filter by project name (substring, case-insensitive)' }, + { name: 'limit', type: 'int', required: false, default: 100, help: 'Max tasks per project' }, + ], + columns: ['Project', 'Task Index', 'Task'], + func: async (page, kwargs) => { + const projects = await page.evaluate(`(function() { + const out = []; + const groups = Array.from(document.querySelectorAll('.task-list-group')).filter((g) => g.offsetParent); + for (const group of groups) { + const headerText = (group.querySelector('.task-list-group-header-wrapper')?.innerText || '').trim().split('\\n')[0] || ''; + const inner = group.querySelector('.task-list-group-collapsible-inner'); + const list = inner ? inner.querySelector('.task-list-group-list') : null; + const taskRows = list ? Array.from(list.querySelectorAll('.task-list-row-wrapper')).filter((r) => r.offsetParent) : []; + out.push({ + project: headerText, + tasks: taskRows.map((row) => (row.innerText || '').trim().split('\\n')[0] || '(untitled)'), + }); + } + return out; + })()`); + + const filter = (kwargs.project || '').toLowerCase(); + const limit = Number.isInteger(kwargs.limit) && kwargs.limit > 0 ? kwargs.limit : 100; + const rows = []; + for (const p of projects || []) { + if (filter && !p.project.toLowerCase().includes(filter)) continue; + const tasks = p.tasks.slice(0, limit); + for (let i = 0; i < tasks.length; i++) { + rows.push({ Project: p.project, 'Task Index': i + 1, Task: tasks[i] }); + } + } + if (!rows.length) { + throw new EmptyResultError( + 'trae-solo history', + filter + ? `No projects matched "${kwargs.project}". Try without --project.` + : 'No projects visible. Make sure TRAE SOLO is on the project-list view and the sidebar is expanded.', + ); + } + return rows; + }, +}); diff --git a/clis/trae-solo/mode.js b/clis/trae-solo/mode.js new file mode 100644 index 000000000..21c5776b3 --- /dev/null +++ b/clis/trae-solo/mode.js @@ -0,0 +1,81 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError, selectorError } from '@jackwener/opencli/errors'; + +// TRAE SOLO has two top-level modes: "Code" and "Work". The mode indicator +// is a capsule at the top-left (.index-module__capsule___...) whose +// aria-label is "Switch to Work mode" when on Code, and "Switch to Code +// mode" when on Work. Clicking it toggles between the two. + +cli({ + site: 'trae-solo', + name: 'mode', + access: 'write', + description: 'Read or switch TRAE SOLO between Code mode and Work mode.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'target', positional: true, required: false, help: 'Target mode: code or work. Omit to read current.' }, + ], + columns: ['Status', 'Mode'], + func: async (page, kwargs) => { + const want = String(kwargs.target || '').trim().toLowerCase(); + if (want && !['code', 'work'].includes(want)) { + throw new ArgumentError('target must be "code" or "work"'); + } + + const current = await page.evaluate(`(function() { + const cap = document.querySelector('[class*="capsule"]'); + if (!cap) return ''; + const aria = cap.getAttribute('aria-label') || ''; + // aria says "Switch to mode" — current is the OTHER one. + const m = aria.match(/Switch to (Code|Work) mode/i); + if (m) return m[1].toLowerCase() === 'work' ? 'code' : 'work'; + return ''; + })()`); + if (!current) { + throw selectorError('TRAE SOLO mode capsule (.index-module__capsule__ ...).'); + } + + if (!want) { + return [{ Status: 'Active', Mode: current }]; + } + if (current === want) { + return [{ Status: 'no-op (already in target mode)', Mode: current }]; + } + + await page.evaluate(`(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const cap = document.querySelector('[class*="capsule"]'); + if (!cap) return; + const r = cap.getBoundingClientRect(); + const init = { + bubbles: true, cancelable: true, button: 0, buttons: 1, + clientX: Math.round(r.left + r.width / 2), + clientY: Math.round(r.top + r.height / 2), + }; + cap.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' })); + cap.dispatchEvent(new MouseEvent('mousedown', init)); + cap.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' })); + cap.dispatchEvent(new MouseEvent('mouseup', init)); + cap.dispatchEvent(new MouseEvent('click', init)); + await wait(500); + })()`); + await page.wait(0.4); + + const after = await page.evaluate(`(function() { + const cap = document.querySelector('[class*="capsule"]'); + if (!cap) return ''; + const aria = cap.getAttribute('aria-label') || ''; + const m = aria.match(/Switch to (Code|Work) mode/i); + return m ? (m[1].toLowerCase() === 'work' ? 'code' : 'work') : ''; + })()`); + if (after !== want) { + throw new CommandExecutionError( + `Mode toggle did not reach requested state "${want}".`, + after ? `current=${after}` : 'Mode capsule state could not be read after click.', + ); + } + return [{ Status: 'switched', Mode: after }]; + }, +}); diff --git a/clis/trae-solo/model.js b/clis/trae-solo/model.js new file mode 100644 index 000000000..d532b8f7d --- /dev/null +++ b/clis/trae-solo/model.js @@ -0,0 +1,134 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError, selectorError } from '@jackwener/opencli/errors'; + +async function readCurrentModel(page) { + const current = await page.evaluate(`(function() { + const trigger = document.querySelector('.core-model-select-trigger'); + return trigger ? (trigger.textContent || '').trim() : ''; + })()`); + return typeof current === 'string' ? current.trim() : ''; +} + +cli({ + site: 'trae-solo', + name: 'model', + access: 'write', + description: 'Read or switch the current AI model in TRAE SOLO. Without arguments, reports the current model. With argument (substring, case-insensitive), switches to a matching model. Pass --list to enumerate available models.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'name', required: false, positional: true, help: 'Target model name (substring match, case-insensitive). Omit to read current.' }, + { name: 'list', type: 'boolean', default: false, help: 'List all available models (does not switch)' }, + ], + columns: ['Status', 'Model'], + func: async (page, kwargs) => { + const name = String(kwargs.name || '').trim().toLowerCase(); + const listOnly = kwargs.list === true || kwargs.list === 'true'; + + // Read current model from the composer trigger + const current = await readCurrentModel(page); + if (!current) { + throw selectorError('TRAE SOLO model trigger (.core-model-select-trigger). Make sure a chat task is open (not the project-list view).'); + } + + // List or switch — both require opening the menu + if (listOnly || name) { + const namejson = JSON.stringify(name); + + // Stage 1 (eval): open the trigger + enumerate option labels. + const stage1 = await page.evaluate(`(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const trigger = document.querySelector('.core-model-select-trigger'); + if (!trigger) return { ok: false, reason: 'model trigger gone' }; + const r = trigger.getBoundingClientRect(); + const init = { + bubbles: true, cancelable: true, button: 0, buttons: 1, + clientX: Math.round(r.left + Math.min(r.width / 2, 20)), + clientY: Math.round(r.top + Math.min(r.height / 2, 10)), + }; + trigger.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' })); + trigger.dispatchEvent(new MouseEvent('mousedown', init)); + trigger.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' })); + trigger.dispatchEvent(new MouseEvent('mouseup', init)); + trigger.dispatchEvent(new MouseEvent('click', init)); + + let opts = []; + for (let attempt = 0; attempt < 16; attempt += 1) { + await wait(80); + opts = Array.from(document.querySelectorAll('.core-model-select-model-item[role="option"]')) + .filter((el) => el instanceof HTMLElement && el.offsetParent); + if (opts.length) break; + } + if (!opts.length) { + return { ok: false, reason: 'Model menu did not open.' }; + } + const labels = opts.map((o) => { + const nameEl = o.querySelector('.core-model-select-model-item-name'); + return ((nameEl ? nameEl.textContent : o.textContent) || '').trim(); + }); + return { ok: true, labels }; + })()`); + + if (!stage1.ok) { + throw new CommandExecutionError(stage1.reason, stage1.detail || ''); + } + + if (listOnly) { + try { await page.evaluate('document.body.click()'); } catch {} + return stage1.labels.map((m) => ({ Status: m === current ? 'Active' : 'Available', Model: m })); + } + + // Stage 2 (page.click): use real CDP Input.dispatchMouseEvent — + // Trae's model menu items don't fire React onSelect from synthetic + // pointer events alone. Match by nth-of-type derived from labels. + const idx = stage1.labels.findIndex((l) => l.toLowerCase().includes(name)); + if (idx < 0) { + try { await page.evaluate('document.body.click()'); } catch {} + throw new CommandExecutionError( + `No model matched: '${name}'`, + 'available=' + JSON.stringify(stage1.labels), + ); + } + const chosenLabel = stage1.labels[idx]; + const clickSelector = `.core-model-select-model-item[role="option"]:nth-of-type(${idx + 1})`; + try { + await page.click(clickSelector); + } catch (err) { + // Some Trae model rows wrap option in another element; fall back + // to JS dispatch on the nth visible option. + const fallbackClicked = await page.evaluate(`(function(i) { + const opts = Array.from(document.querySelectorAll('.core-model-select-model-item[role="option"]')) + .filter((el) => el.offsetParent); + const target = opts[i]; + if (!target) return false; + const r = target.getBoundingClientRect(); + const init = { bubbles: true, cancelable: true, button: 0, buttons: 1, + clientX: Math.round(r.left + r.width / 2), + clientY: Math.round(r.top + r.height / 2) }; + target.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' })); + target.dispatchEvent(new MouseEvent('mousedown', init)); + target.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' })); + target.dispatchEvent(new MouseEvent('mouseup', init)); + target.dispatchEvent(new MouseEvent('click', init)); + return true; + })(${idx})`); + if (!fallbackClicked) { + throw new CommandExecutionError('Click on model option failed.', `model=${chosenLabel}`); + } + } + await page.wait(0.6); + const after = await readCurrentModel(page); + if (!after || !after.toLowerCase().includes(name)) { + throw new CommandExecutionError( + `Model click did not verify selected model "${chosenLabel}".`, + after ? `current=${after}` : 'model trigger was unreadable after click', + ); + } + return [{ Status: 'switched', Model: after }]; + } + + // Just read the current. + return [{ Status: 'Active', Model: current }]; + }, +}); diff --git a/clis/trae-solo/renderer-storage.js b/clis/trae-solo/renderer-storage.js new file mode 100644 index 000000000..6a577024b --- /dev/null +++ b/clis/trae-solo/renderer-storage.js @@ -0,0 +1,170 @@ +// Renderer-side storage commands for Trae SOLO. +// +// These query the Electron renderer's localStorage / sessionStorage / +// cookies / IndexedDB via CDP (port 9235), NOT the on-disk state.vscdb. +// For the FS-side equivalents see state-fs.js (state-keys / state-get). +// +// storage-keys [--storage local|session] [--filter] [--limit] +// storage-get [--storage] [--max-bytes] +// cookies — list JS-visible cookies on the renderer +// idb-list — list IndexedDB databases the renderer can see +// (Trae SOLO ships an @byted/ve-rtc DB for the +// Volcengine RTC voice/video infra) + +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + ArgumentError, + CommandExecutionError, + EmptyResultError, +} from '@jackwener/opencli/errors'; + +function pickStore(args) { + const s = String(args?.storage || 'local').trim().toLowerCase(); + if (s !== 'local' && s !== 'session') { + throw new ArgumentError('storage', 'must be "local" or "session"'); + } + return s === 'session' ? 'sessionStorage' : 'localStorage'; +} + +// -------- storage-keys -------- +cli({ + site: 'trae-solo', + name: 'storage-keys', + access: 'read', + description: 'List localStorage / sessionStorage keys on the Trae SOLO renderer (CDP). For the on-disk VSCode state.vscdb, see state-keys.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'storage', required: false, default: 'local', help: '"local" or "session"' }, + { name: 'filter', required: false, help: 'Case-insensitive substring filter' }, + { name: 'limit', type: 'int', required: false, default: 100, help: 'Max rows to return' }, + ], + columns: ['Index', 'Key', 'Bytes', 'Name', 'Preview', 'Database', 'Version'], + func: async (page, kwargs) => { + const store = pickStore(kwargs); + const raw = await page.evaluate(`(() => { + const s = ${store}; + const out = []; + for (let i = 0; i < s.length; i++) { + const k = s.key(i); + const v = s.getItem(k) || ''; + out.push({ k, bytes: v.length }); + } + return out; + })()`); + const flt = kwargs?.filter ? String(kwargs.filter).toLowerCase() : null; + const filtered = flt ? raw.filter((r) => r.k.toLowerCase().includes(flt)) : raw; + if (!filtered.length) { + throw new EmptyResultError('trae-solo storage-keys', flt ? `No keys match "${flt}".` : `${store} is empty.`); + } + filtered.sort((a, b) => a.k.localeCompare(b.k)); + const limit = Number.isInteger(kwargs?.limit) && kwargs.limit > 0 ? kwargs.limit : 100; + return filtered.slice(0, limit).map((r, i) => ({ + Index: i + 1, + Key: r.k, + Bytes: r.bytes, + Name: '', + Preview: '', + Database: '', + Version: '', + })); + }, +}); + +// -------- storage-get -------- +cli({ + site: 'trae-solo', + name: 'storage-get', + access: 'read', + description: 'Read a single localStorage / sessionStorage value on the Trae SOLO renderer.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'key', positional: true, required: true, help: 'Storage key (use storage-keys to discover)' }, + { name: 'storage', required: false, default: 'local', help: '"local" or "session"' }, + { name: 'max-bytes', type: 'int', required: false, default: 4000, help: 'Truncate value to this many chars' }, + ], + columns: ['Field', 'Value'], + func: async (page, kwargs) => { + const key = String(kwargs?.key || '').trim(); + if (!key) throw new ArgumentError('key', 'is required'); + const store = pickStore(kwargs); + const raw = await page.evaluate(`${store}.getItem(${JSON.stringify(key)})`); + if (raw === null) throw new CommandExecutionError(`Key not found in ${store}: ${key}`, ''); + const max = Number.isInteger(kwargs['max-bytes']) && kwargs['max-bytes'] > 0 ? kwargs['max-bytes'] : 4000; + let parsed = raw, kind = 'string'; + try { parsed = JSON.parse(raw); kind = Array.isArray(parsed) ? 'array' : typeof parsed; } catch {} + const text = kind === 'string' ? parsed : JSON.stringify(parsed, null, 2); + const truncated = text.length > max; + return [ + { Field: 'Key', Value: key }, + { Field: 'Store', Value: store }, + { Field: 'Type', Value: kind }, + { Field: 'Size', Value: `${text.length} chars${truncated ? ' (truncated)' : ''}` }, + { Field: 'Value', Value: truncated ? text.slice(0, max) + '\n...(truncated)' : text }, + ]; + }, +}); + +// -------- cookies -------- +cli({ + site: 'trae-solo', + name: 'cookies', + access: 'read', + description: 'List cookies on the Trae SOLO renderer (JS-visible via document.cookie; httpOnly cookies not shown).', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Index', 'Key', 'Bytes', 'Name', 'Preview', 'Database', 'Version'], + func: async (page) => { + const raw = await page.evaluate('document.cookie'); + if (!raw) { + throw new EmptyResultError('trae-solo cookies', 'document.cookie is empty (Trae uses Electron session cookies, mostly httpOnly).'); + } + const cookies = raw.split('; ').map((pair) => { + const idx = pair.indexOf('='); + if (idx < 0) return { name: pair, value: '' }; + return { name: pair.slice(0, idx), value: pair.slice(idx + 1) }; + }); + return cookies.map((c, i) => ({ + Index: i + 1, + Key: '', + Name: c.name, + Bytes: c.value.length, + Preview: c.value.slice(0, 40) + (c.value.length > 40 ? '…' : ''), + Database: '', + Version: '', + })); + }, +}); + +// -------- idb-list -------- +cli({ + site: 'trae-solo', + name: 'idb-list', + access: 'read', + description: 'List IndexedDB databases on the Trae SOLO renderer. Trae ships an @byted/ve-rtc DB used by the Volcengine RTC voice/video infrastructure.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Index', 'Key', 'Bytes', 'Name', 'Preview', 'Database', 'Version'], + func: async (page) => { + const dbs = await page.evaluate(`(async () => indexedDB.databases ? await indexedDB.databases() : [])()`); + if (!Array.isArray(dbs) || !dbs.length) { + throw new EmptyResultError('trae-solo idb-list', 'No IndexedDB databases.'); + } + return dbs.map((d, i) => ({ + Index: i + 1, + Key: '', + Bytes: '', + Name: '', + Preview: '', + Database: d.name || '(unnamed)', + Version: String(d.version || ''), + })); + }, +}); diff --git a/clis/trae-solo/settings.js b/clis/trae-solo/settings.js new file mode 100644 index 000000000..27f8898e4 --- /dev/null +++ b/clis/trae-solo/settings.js @@ -0,0 +1,54 @@ +// VSCode-style user settings.json reader for Trae SOLO. +// +// settings-read — parse and pretty-print +// ~/Library/Application Support/TRAE SOLO/User/settings.json +// +// Trae SOLO follows VSCode's JSONC settings convention (line/block comments +// + trailing commas allowed). + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + CommandExecutionError, + EmptyResultError, +} from '@jackwener/opencli/errors'; +import { TRAE_APP_SUPPORT } from './_fs.js'; + +const TRAE_SETTINGS_JSON = path.join(TRAE_APP_SUPPORT, 'User/settings.json'); + +cli({ + site: 'trae-solo', + name: 'settings-read', + access: 'read', + description: 'Parse and pretty-print Trae SOLO user settings.json (~/Library/Application Support/TRAE SOLO/User/settings.json). Handles VSCode JSONC syntax (line comments + trailing commas).', + domain: 'localhost', + strategy: Strategy.LOCAL, + browser: false, + args: [], + columns: ['Field', 'Value'], + func: async () => { + if (!fs.existsSync(TRAE_SETTINGS_JSON)) { + throw new EmptyResultError('trae-solo settings-read', `settings.json not found: ${TRAE_SETTINGS_JSON}`); + } + const raw = fs.readFileSync(TRAE_SETTINGS_JSON, 'utf-8'); + // Strip JSONC: line comments + block comments + trailing commas. + const stripped = raw + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, '') + .replace(/([^:"])\/\/.*$/gm, '$1') + .replace(/,(\s*[}\]])/g, '$1'); + let obj; + try { obj = JSON.parse(stripped); } catch (e) { + throw new CommandExecutionError(`Failed to parse settings.json: ${e.message}`, ''); + } + const rows = []; + for (const [k, v] of Object.entries(obj)) { + rows.push({ Field: k, Value: typeof v === 'object' ? JSON.stringify(v) : String(v) }); + } + if (!rows.length) { + throw new EmptyResultError('trae-solo settings-read', 'settings.json is empty (or contains only defaults).'); + } + return rows; + }, +}); diff --git a/clis/trae-solo/skill-fs.js b/clis/trae-solo/skill-fs.js new file mode 100644 index 000000000..fc28b7db0 --- /dev/null +++ b/clis/trae-solo/skill-fs.js @@ -0,0 +1,123 @@ +// File-system based skill commands — work without TRAE SOLO being focused +// or even running, because Trae stores skill state on disk under ~/.trae/. +// +// Commands: +// skill-fs-list — list ALL skills in ~/.trae/skills/ +// skill-fs-installed — list installed skills (managedSkills in skill-config.json) +// skill-fs-show — print a skill's SKILL.md +// +// Trade-off vs the UI-driven `skill-*` commands: +// + Don't require Trae to be in foreground (works while window minimized) +// + Read commands are instant (no CDP roundtrip) +// + Can inspect SKILL.md content directly + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + ArgumentError, + CommandExecutionError, + EmptyResultError, +} from '@jackwener/opencli/errors'; +import { + TRAE_SKILLS_DIR, + assertReadable, + parseSkillMd, + readSkillConfig, +} from './_fs.js'; + +// -------- skill-fs-list -------- +cli({ + site: 'trae-solo', + name: 'skill-fs-list', + access: 'read', + description: 'List all Trae SOLO skills present on disk under ~/.trae/skills/. Reads SKILL.md front-matter for descriptions. Works while Trae is closed.', + domain: 'localhost', + browser: false, + strategy: Strategy.LOCAL, + args: [ + { name: 'limit', type: 'int', required: false, default: 200, help: 'Max rows' }, + ], + columns: ['Index', 'Name', 'Description', 'Source'], + func: async (args) => { + assertReadable(TRAE_SKILLS_DIR, '~/.trae/skills'); + const dirs = fs.readdirSync(TRAE_SKILLS_DIR).filter((n) => { + const full = path.join(TRAE_SKILLS_DIR, n); + return fs.statSync(full).isDirectory() && !n.startsWith('_'); + }); + const rows = dirs.map((d) => parseSkillMd(path.join(TRAE_SKILLS_DIR, d))); + const limit = Number.isInteger(args.limit) && args.limit > 0 ? args.limit : 200; + if (!rows.length) { + throw new EmptyResultError('trae-solo skill-fs-list', 'No skills found under ~/.trae/skills/.'); + } + return rows.slice(0, limit).map((r, i) => ({ + Index: i + 1, + Name: r.name, + Description: (r.description || '').slice(0, 120), + Source: '', + })); + }, +}); + +// -------- skill-fs-installed -------- +cli({ + site: 'trae-solo', + name: 'skill-fs-installed', + access: 'read', + description: 'List INSTALLED Trae SOLO skills (managedSkills entry in ~/.trae/skill-config.json).', + domain: 'localhost', + browser: false, + strategy: Strategy.LOCAL, + args: [], + columns: ['Index', 'Name', 'Description', 'Source'], + func: async () => { + const cfg = readSkillConfig(); + const managed = cfg.managedSkills || {}; + const rows = Object.entries(managed); + if (!rows.length) { + throw new EmptyResultError('trae-solo skill-fs-installed', 'No installed skills.'); + } + return rows.map(([name, source], i) => ({ + Index: i + 1, + Name: name, + Description: '', + Source: source, + })); + }, +}); + +// -------- skill-fs-show -------- +cli({ + site: 'trae-solo', + name: 'skill-fs-show', + access: 'read', + description: 'Print a skill\'s SKILL.md content + on-disk path.', + domain: 'localhost', + browser: false, + strategy: Strategy.LOCAL, + args: [ + { name: 'name', positional: true, required: true, help: 'Skill name (folder under ~/.trae/skills/)' }, + ], + columns: ['Field', 'Value'], + func: async (args) => { + const name = String(args.name || '').trim(); + if (!name) throw new ArgumentError('name required'); + const dir = path.join(TRAE_SKILLS_DIR, name); + if (!fs.existsSync(dir)) { + throw new CommandExecutionError(`Skill "${name}" not found.`, `Tried: ${dir}`); + } + const meta = parseSkillMd(dir); + const skillMd = path.join(dir, 'SKILL.md'); + const content = fs.existsSync(skillMd) ? fs.readFileSync(skillMd, 'utf-8') : '(no SKILL.md)'; + return [ + { Field: 'Name', Value: meta.name }, + { Field: 'Path', Value: dir }, + { Field: 'Description', Value: (meta.description || '').slice(0, 200) }, + { Field: 'Tags', Value: (meta.tags || []).join(', ') }, + { Field: 'Author', Value: meta.author }, + { Field: 'Version', Value: meta.version }, + { Field: 'Files', Value: fs.readdirSync(dir).join(', ').slice(0, 200) }, + { Field: 'SKILL.md (head)', Value: content.slice(0, 1200) }, + ]; + }, +}); diff --git a/clis/trae-solo/skill.js b/clis/trae-solo/skill.js new file mode 100644 index 000000000..f573b5e29 --- /dev/null +++ b/clis/trae-solo/skill.js @@ -0,0 +1,220 @@ +// Read-only commands for the TRAE SOLO Skills Marketplace panel: +// skill-list / skill-search / skill-category +// +// All commands switch to the Skills panel first (sidebar entry +// '.task-list-new-task-item.task-list-skills-item' with text 'Skills'). +// +// Skills UI structure: +// .marketplace-tab 'Skills Marketplace' / 'Installed ' +// .marketplace-tag 6 categories: All / Developer Tools / +// Data Analysis / UI Design / Content Creation / Productivity +// input[placeholder="Search"] text filter input +// .marketplace-card-v2 × ~51 marketplace card view (browse all) +// .installed-card × ~49 installed view +// Write-side marketplace operations are intentionally not exposed here until +// they can prove install/uninstall/run/toggle postconditions instead of only +// proving a button click. + +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + ArgumentError, + CommandExecutionError, + EmptyResultError, +} from '@jackwener/opencli/errors'; +import { switchToPanel } from './_actions.js'; + +const SESSION_HINT = 'Make sure TRAE SOLO is running and the Skills panel is reachable.'; + +async function ensureSkillsTab(page, tabName) { + const nameJson = JSON.stringify(tabName); + await page.evaluate(`(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const target = ${nameJson}; + const tab = Array.from(document.querySelectorAll('.marketplace-tab')) + .find((t) => (t.textContent || '').trim().startsWith(target)); + if (!tab) return; + if (tab.className.includes('active')) return; + const r = tab.getBoundingClientRect(); + const init = { + bubbles: true, cancelable: true, button: 0, buttons: 1, + clientX: Math.round(r.left + r.width / 2), + clientY: Math.round(r.top + r.height / 2), + }; + tab.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' })); + tab.dispatchEvent(new MouseEvent('mousedown', init)); + tab.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' })); + tab.dispatchEvent(new MouseEvent('mouseup', init)); + tab.dispatchEvent(new MouseEvent('click', init)); + await wait(700); + })()`); + await page.wait(0.3); +} + +// -------- skill-list -------- +cli({ + site: 'trae-solo', + name: 'skill-list', + access: 'read', + description: 'List Trae SOLO Skills — by default the Marketplace; pass --installed to list installed ones.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'installed', type: 'boolean', default: false, help: 'List installed skills instead of the marketplace' }, + { name: 'limit', type: 'int', required: false, default: 100, help: 'Max rows to return' }, + ], + columns: ['Index', 'Name', 'Description'], + func: async (page, kwargs) => { + await switchToPanel(page, 'Skills'); + const installed = kwargs.installed === true || kwargs.installed === 'true' || kwargs.installed === '1'; + await ensureSkillsTab(page, installed ? 'Installed' : 'Skills Marketplace'); + + const items = await page.evaluate(`(function() { + const sel = ${installed ? "'.installed-card'" : "'.marketplace-card-v2'"}; + const cards = Array.from(document.querySelectorAll(sel)).filter((c) => c.offsetParent); + return cards.map((c, i) => { + const logo = c.querySelector('.skill-logo-svg'); + const name = (logo && logo.getAttribute('aria-label')) || ''; + // Card text starts with the name; trim that off to get description. + const full = (c.innerText || '').replace(/\\s+/g, ' ').trim(); + let desc = full; + if (name && desc.startsWith(name)) desc = desc.slice(name.length).trim(); + return { index: i + 1, name: name || full.split(' ')[0], description: desc.slice(0, 200) }; + }); + })()`); + const limit = Number.isInteger(kwargs.limit) && kwargs.limit > 0 ? kwargs.limit : 100; + const rows = (items || []).slice(0, limit); + if (!rows.length) { + throw new EmptyResultError( + 'trae-solo skill-list', + installed ? 'No installed skills visible.' : 'No marketplace skills visible.', + ); + } + return rows.map((r) => ({ Index: r.index, Name: r.name, Description: r.description })); + }, +}); + +// -------- skill-search -------- +cli({ + site: 'trae-solo', + name: 'skill-search', + access: 'read', + description: 'Filter Skills Marketplace by keyword.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'keyword', positional: true, required: true, help: 'Search keyword (substring)' }, + { name: 'limit', type: 'int', required: false, default: 50, help: 'Max rows' }, + ], + columns: ['Index', 'Name', 'Description'], + func: async (page, kwargs) => { + await switchToPanel(page, 'Skills'); + await ensureSkillsTab(page, 'Skills Marketplace'); + const keyword = String(kwargs.keyword || '').trim(); + if (!keyword) throw new ArgumentError('keyword cannot be empty'); + + const kwJson = JSON.stringify(keyword); + await page.evaluate(`(function() { + const inp = document.querySelector('input[placeholder="Search"]'); + if (!inp) return; + inp.focus(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(inp, ${kwJson}); + inp.dispatchEvent(new Event('input', { bubbles: true })); + })()`); + await page.wait(0.7); + + const items = await page.evaluate(`(function() { + const cards = Array.from(document.querySelectorAll('.marketplace-card-v2')).filter((c) => c.offsetParent); + return cards.map((c, i) => { + const logo = c.querySelector('.skill-logo-svg'); + const name = (logo && logo.getAttribute('aria-label')) || ''; + const full = (c.innerText || '').replace(/\\s+/g, ' ').trim(); + let desc = full; + if (name && desc.startsWith(name)) desc = desc.slice(name.length).trim(); + return { index: i + 1, name: name || full.split(' ')[0], description: desc.slice(0, 200) }; + }); + })()`); + const limit = Number.isInteger(kwargs.limit) && kwargs.limit > 0 ? kwargs.limit : 50; + const rows = (items || []).slice(0, limit); + if (!rows.length) { + throw new EmptyResultError('trae-solo skill-search', `No skills matched "${keyword}".`); + } + return rows.map((r) => ({ Index: r.index, Name: r.name, Description: r.description })); + }, +}); + +// -------- skill-category -------- +cli({ + site: 'trae-solo', + name: 'skill-category', + access: 'read', + description: 'Filter Skills Marketplace by category. Pass --list to see categories.', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'name', positional: true, required: false, help: 'Category name (substring; case-insensitive). Common: All / Developer Tools / Data Analysis / UI Design / Content Creation / Productivity' }, + { name: 'list', type: 'boolean', default: false, help: 'List available categories' }, + { name: 'limit', type: 'int', required: false, default: 100 }, + ], + columns: ['Index', 'Name', 'Description'], + func: async (page, kwargs) => { + await switchToPanel(page, 'Skills'); + await ensureSkillsTab(page, 'Skills Marketplace'); + const listOnly = kwargs.list === true || kwargs.list === 'true'; + const name = String(kwargs.name || '').trim().toLowerCase(); + + const cats = await page.evaluate(`(function() { + return Array.from(document.querySelectorAll('.marketplace-tag')) + .filter((c) => c.offsetParent) + .map((c) => ({ text: (c.textContent || '').trim(), active: c.className.includes('active') })); + })()`); + + if (listOnly) { + return (cats || []).map((c) => ({ Index: '-', Name: c.text + (c.active ? ' (active)' : ''), Description: '' })); + } + if (!name) { + throw new ArgumentError('name required (or pass --list)'); + } + + const nameJson = JSON.stringify(name); + const switchRes = await page.evaluate(`(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const tag = Array.from(document.querySelectorAll('.marketplace-tag')) + .find((t) => t.offsetParent && (t.textContent || '').trim().toLowerCase().includes(${nameJson})); + if (!tag) return { ok: false, reason: 'Category not found.' }; + const r = tag.getBoundingClientRect(); + const init = { + bubbles: true, cancelable: true, button: 0, buttons: 1, + clientX: Math.round(r.left + r.width / 2), + clientY: Math.round(r.top + r.height / 2), + }; + tag.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' })); + tag.dispatchEvent(new MouseEvent('mousedown', init)); + tag.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' })); + tag.dispatchEvent(new MouseEvent('mouseup', init)); + tag.dispatchEvent(new MouseEvent('click', init)); + await wait(700); + return { ok: true, chosen: (tag.textContent || '').trim() }; + })()`); + if (!switchRes?.ok) { + throw new CommandExecutionError(switchRes?.reason || 'Category click failed.', SESSION_HINT); + } + + const items = await page.evaluate(`(function() { + const cards = Array.from(document.querySelectorAll('.marketplace-card-v2')).filter((c) => c.offsetParent); + return cards.map((c, i) => { + const logo = c.querySelector('.skill-logo-svg'); + const name = (logo && logo.getAttribute('aria-label')) || ''; + const full = (c.innerText || '').replace(/\\s+/g, ' ').trim(); + let desc = full; + if (name && desc.startsWith(name)) desc = desc.slice(name.length).trim(); + return { index: i + 1, name: name || full.split(' ')[0], description: desc.slice(0, 200) }; + }); + })()`); + const limit = Number.isInteger(kwargs.limit) && kwargs.limit > 0 ? kwargs.limit : 100; + return ((items || []).slice(0, limit)).map((r) => ({ Index: r.index, Name: r.name, Description: r.description })); + }, +}); diff --git a/clis/trae-solo/state-fs.js b/clis/trae-solo/state-fs.js new file mode 100644 index 000000000..72bdbdb9a --- /dev/null +++ b/clis/trae-solo/state-fs.js @@ -0,0 +1,156 @@ +// VSCode-style state.vscdb read commands for Trae SOLO. +// +// state-keys — list all keys in globalStorage state.vscdb +// state-get — get a single value (auto-decode JSON) +// recent-workspaces — pretty-print history.recentlyOpenedPathsList +// +// Naming note: these were originally named `storage-keys` / `storage-get`, +// but `storage-*` was repurposed for renderer-side localStorage in PR #1798 +// (Grok) and PR #1799/#1800 (Codex/Antigravity). Renamed here for cross- +// surface consistency: +// state-* → on-disk VSCode state.vscdb (this file) +// storage-* → renderer LS/SS (see renderer-storage.js) +// +// All commands are READ-ONLY. Writing to state.vscdb while Trae is +// running would race with Trae's own writer and may corrupt the DB. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + ArgumentError, + CommandExecutionError, + EmptyResultError, +} from '@jackwener/opencli/errors'; +import { + TRAE_GLOBAL_STATE_DB, + TRAE_WORKSPACE_STORAGE, + listKeys, + getValue, +} from './_state.js'; + +// Resolve the actual state.vscdb to read. With no --workspace, uses the +// global state DB. With --workspace , uses that workspaceStorage DB. +function resolveStateDb(args) { + const ws = args.workspace ? String(args.workspace).trim() : ''; + if (!ws) return TRAE_GLOBAL_STATE_DB; + const db = path.join(TRAE_WORKSPACE_STORAGE, ws, 'state.vscdb'); + if (!fs.existsSync(db)) { + throw new CommandExecutionError( + `Workspace state.vscdb not found: ${db}`, + 'List valid workspace ids with `opencli trae-solo workspaces-list`.', + ); + } + return db; +} + +// -------- state-keys -------- +cli({ + site: 'trae-solo', + name: 'state-keys', + access: 'read', + description: 'List all keys present in Trae SOLO\'s globalStorage state.vscdb (VSCode-style UI/agent state). Pass --workspace to query a per-workspace DB instead. Use state-get to read a specific value. (See renderer storage-keys for browser-side LS/SS.)', + domain: 'localhost', + browser: false, + strategy: Strategy.LOCAL, + args: [ + { name: 'filter', required: false, help: 'Case-insensitive substring filter over keys' }, + { name: 'workspace', required: false, help: 'Workspace id (from workspaces-list) to query a per-workspace DB' }, + { name: 'limit', type: 'int', required: false, default: 200 }, + ], + columns: ['Index', 'Key', 'Kind', 'Path'], + func: async (args) => { + const db = resolveStateDb(args); + const keys = listKeys(db); + const flt = args.filter ? String(args.filter).toLowerCase() : null; + const filtered = flt ? keys.filter((k) => k.toLowerCase().includes(flt)) : keys; + if (!filtered.length) { + throw new EmptyResultError('trae-solo storage-keys', flt ? `No keys match "${flt}".` : 'No keys.'); + } + const limit = Number.isInteger(args.limit) && args.limit > 0 ? args.limit : 200; + return filtered.slice(0, limit).map((k, i) => ({ + Index: i + 1, + Key: k, + Kind: '', + Path: '', + })); + }, +}); + +// -------- state-get -------- +cli({ + site: 'trae-solo', + name: 'state-get', + access: 'read', + description: 'Read a single key from Trae SOLO\'s globalStorage state.vscdb. Pass --workspace to query a per-workspace DB instead. Returns parsed JSON if the value is JSON.', + domain: 'localhost', + browser: false, + strategy: Strategy.LOCAL, + args: [ + { name: 'key', positional: true, required: true, help: 'State key (use state-keys to discover)' }, + { name: 'workspace', required: false, help: 'Workspace id (from workspaces-list) to query a per-workspace DB' }, + { name: 'max-bytes', type: 'int', required: false, default: 8000, help: 'Truncate value to this many bytes' }, + ], + columns: ['Field', 'Value'], + func: async (args) => { + const key = String(args.key || '').trim(); + if (!key) throw new ArgumentError('key required'); + const db = resolveStateDb(args); + const val = getValue(db, key); + if (val === null) { + throw new CommandExecutionError(`Key not found: ${key}`, 'List available keys with `opencli trae-solo state-keys`.'); + } + const max = Number.isInteger(args['max-bytes']) && args['max-bytes'] > 0 ? args['max-bytes'] : 8000; + const valStr = typeof val === 'string' ? val : JSON.stringify(val, null, 2); + const truncated = valStr.length > max; + return [ + { Field: 'Key', Value: key }, + { Field: 'Type', Value: typeof val === 'string' ? 'string' : (Array.isArray(val) ? 'array' : typeof val) }, + { Field: 'Size', Value: `${valStr.length} chars${truncated ? ' (truncated)' : ''}` }, + { Field: 'Value', Value: truncated ? valStr.slice(0, max) + '\n...(truncated, use --max-bytes to read more)' : valStr }, + ]; + }, +}); + +// -------- recent-workspaces -------- +cli({ + site: 'trae-solo', + name: 'recent-workspaces', + access: 'read', + description: 'Show Trae SOLO\'s recently-opened workspaces (the File → Open Recent menu, stored under key "history.recentlyOpenedPathsList" in state.vscdb).', + domain: 'localhost', + browser: false, + strategy: Strategy.LOCAL, + args: [ + { name: 'limit', type: 'int', required: false, default: 20 }, + ], + columns: ['Index', 'Key', 'Kind', 'Path'], + func: async (args) => { + if (!fs.existsSync(TRAE_GLOBAL_STATE_DB)) { + throw new CommandExecutionError(`state.vscdb not found: ${TRAE_GLOBAL_STATE_DB}`, ''); + } + const val = getValue(TRAE_GLOBAL_STATE_DB, 'history.recentlyOpenedPathsList'); + if (!val) { + throw new EmptyResultError('trae-solo recent-workspaces', 'No recent workspaces recorded.'); + } + const entries = val.entries || []; + if (!entries.length) { + throw new EmptyResultError('trae-solo recent-workspaces', 'history.recentlyOpenedPathsList has no entries.'); + } + const limit = Number.isInteger(args.limit) && args.limit > 0 ? args.limit : 20; + return entries.slice(0, limit).map((e, i) => { + let kind = 'other', target = JSON.stringify(e).slice(0, 200); + if (e.folderUri) { + kind = 'folder'; + target = decodeURI(String(e.folderUri).replace(/^file:\/\//, '')); + } else if (e.workspace && e.workspace.configPath) { + kind = 'workspace'; + target = decodeURI(String(e.workspace.configPath).replace(/^file:\/\//, '')); + } else if (e.fileUri) { + kind = 'file'; + target = decodeURI(String(e.fileUri).replace(/^file:\/\//, '')); + } + return { Index: i + 1, Key: '', Kind: kind, Path: target }; + }); + }, +}); diff --git a/clis/trae-solo/status.js b/clis/trae-solo/status.js new file mode 100644 index 000000000..de8df7160 --- /dev/null +++ b/clis/trae-solo/status.js @@ -0,0 +1,2 @@ +import { makeStatusCommand } from '../_shared/desktop-commands.js'; +export const statusCommand = makeStatusCommand('trae-solo', 'Trae SOLO Desktop'); diff --git a/clis/trae-solo/task-fs.js b/clis/trae-solo/task-fs.js new file mode 100644 index 000000000..3c34d5397 --- /dev/null +++ b/clis/trae-solo/task-fs.js @@ -0,0 +1,181 @@ +// File-system based task commands. Each TRAE SOLO conversation is stored +// as: +// 1. snapshot//v2/.git — a real git repo with chat-turn refs +// (tag pattern: before-chat-turn- / after-chat-turn-) +// 2. agentconfig/.json + -hooks.json — per-task config +// 3. work-mode-projects//... — workspace files +// +// Commands: +// task-fs-list — list snapshot/ dirs + agentconfig presence +// task-fs-turns — git log on the snapshot repo (chat-turn timeline) +// task-fs-show [--turn N] — extract a turn snapshot via git show + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + ArgumentError, + CommandExecutionError, + EmptyResultError, +} from '@jackwener/opencli/errors'; +import { + TRAE_SNAPSHOT_DIR, + TRAE_AGENTCONFIG_DIR, + assertReadable, +} from './_fs.js'; + +function snapshotRepoFor(taskId) { + return path.join(TRAE_SNAPSHOT_DIR, taskId, 'v2'); +} + +function gitInRepo(repoPath, args) { + return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trim(); +} + +// -------- task-fs-list -------- +cli({ + site: 'trae-solo', + name: 'task-fs-list', + access: 'read', + description: 'List Trae SOLO task ids from disk (snapshot/ + agentconfig/.json). Works while Trae is closed.', + domain: 'localhost', + browser: false, + strategy: Strategy.LOCAL, + args: [ + { name: 'limit', type: 'int', required: false, default: 100 }, + ], + columns: ['Index', 'Task Id', 'Has Snapshot', 'Has Config', 'Modified', 'Phase', 'Turn Id', 'Commit'], + func: async (args) => { + assertReadable(TRAE_SNAPSHOT_DIR, 'ai-agent/snapshot'); + const snapshotIds = new Set( + fs.readdirSync(TRAE_SNAPSHOT_DIR).filter((n) => fs.statSync(path.join(TRAE_SNAPSHOT_DIR, n)).isDirectory()), + ); + const configIds = fs.existsSync(TRAE_AGENTCONFIG_DIR) + ? new Set(fs.readdirSync(TRAE_AGENTCONFIG_DIR) + .filter((n) => n.endsWith('.json') && !n.endsWith('-hooks.json') && !n.startsWith('boot') && !n.startsWith('ide_')) + .map((n) => n.replace(/\.json$/, ''))) + : new Set(); + const all = [...new Set([...snapshotIds, ...configIds])]; + const rows = all + .map((id) => { + const snapPath = path.join(TRAE_SNAPSHOT_DIR, id); + const configPath = path.join(TRAE_AGENTCONFIG_DIR, id + '.json'); + const hasSnap = fs.existsSync(snapPath); + const hasCfg = fs.existsSync(configPath); + let mtime = 0; + if (hasSnap) mtime = Math.max(mtime, fs.statSync(snapPath).mtimeMs); + if (hasCfg) mtime = Math.max(mtime, fs.statSync(configPath).mtimeMs); + return { id, hasSnap, hasCfg, mtime }; + }) + .sort((a, b) => b.mtime - a.mtime); + const limit = Number.isInteger(args.limit) && args.limit > 0 ? args.limit : 100; + if (!rows.length) { + throw new EmptyResultError('trae-solo task-fs-list', 'No tasks on disk.'); + } + return rows.slice(0, limit).map((r, i) => ({ + Index: i + 1, + 'Task Id': r.id, + 'Has Snapshot': r.hasSnap ? 'yes' : 'no', + 'Has Config': r.hasCfg ? 'yes' : 'no', + Modified: new Date(r.mtime).toISOString().replace('T', ' ').slice(0, 19), + Phase: '', + 'Turn Id': '', + Commit: '', + })); + }, +}); + +// -------- task-fs-turns -------- +cli({ + site: 'trae-solo', + name: 'task-fs-turns', + access: 'read', + description: 'Show the chat-turn timeline for a Trae SOLO task as git tags (before-chat-turn-* / after-chat-turn-*).', + domain: 'localhost', + browser: false, + strategy: Strategy.LOCAL, + args: [ + { name: 'task-id', positional: true, required: true, help: 'Task UUID (folder name under snapshot/)' }, + { name: 'limit', type: 'int', required: false, default: 50 }, + ], + columns: ['Index', 'Task Id', 'Has Snapshot', 'Has Config', 'Modified', 'Phase', 'Turn Id', 'Commit'], + func: async (args) => { + const tid = String(args['task-id'] || '').trim(); + if (!tid) throw new ArgumentError('task-id required'); + const repo = snapshotRepoFor(tid); + if (!fs.existsSync(path.join(repo, '.git'))) { + throw new CommandExecutionError(`No snapshot repo for task ${tid}.`, `Tried: ${repo}`); + } + // List tags + their commits, sort by commit date. + const raw = gitInRepo(repo, ['for-each-ref', '--format=%(refname:short)|%(*objectname:short)|%(objectname:short)|%(committerdate:iso8601)', 'refs/tags']); + const rows = raw.split('\n').filter(Boolean).map((line) => { + const [tag, _ptr, oid, date] = line.split('|'); + const m = tag.match(/^(before|after)-chat-turn-([0-9a-f]+)(?:-(refresh))?$/); + const phase = m ? (m[1] + (m[3] ? '/refresh' : '')) : 'misc'; + const turnId = m ? m[2] : tag; + return { phase, turnId, oid, date }; + }).sort((a, b) => a.date.localeCompare(b.date)); + if (!rows.length) { + throw new EmptyResultError('trae-solo task-fs-turns', `No chat-turn refs in ${repo}.`); + } + const limit = Number.isInteger(args.limit) && args.limit > 0 ? args.limit : 50; + return rows.slice(0, limit).map((r, i) => ({ + Index: i + 1, + 'Task Id': '', + 'Has Snapshot': '', + 'Has Config': '', + Modified: '', + Phase: r.phase, + 'Turn Id': r.turnId, + Commit: r.oid, + })); + }, +}); + +// -------- task-fs-show -------- +cli({ + site: 'trae-solo', + name: 'task-fs-show', + access: 'read', + description: 'Show the workspace tree at a given chat-turn ref (via git ls-tree). Pass --turn to pick a turn; otherwise the latest after-chat-turn ref.', + domain: 'localhost', + browser: false, + strategy: Strategy.LOCAL, + args: [ + { name: 'task-id', positional: true, required: true, help: 'Task UUID' }, + { name: 'turn', required: false, help: 'Specific turn id (omit for latest after-chat-turn)' }, + { name: 'limit', type: 'int', required: false, default: 50 }, + ], + columns: ['Mode', 'Path', 'Size'], + func: async (args) => { + const tid = String(args['task-id'] || '').trim(); + if (!tid) throw new ArgumentError('task-id required'); + const repo = snapshotRepoFor(tid); + if (!fs.existsSync(path.join(repo, '.git'))) { + throw new CommandExecutionError(`No snapshot repo for task ${tid}.`, `Tried: ${repo}`); + } + let ref; + if (args.turn) { + const t = String(args.turn).trim(); + // Prefer after-* if present; else before-*. + const tagListRaw = gitInRepo(repo, ['for-each-ref', '--format=%(refname:short)', 'refs/tags']); + const tags = tagListRaw.split('\n').filter((x) => x.includes(t)); + ref = tags.find((x) => x.startsWith('after-chat-turn-')) || tags[0]; + if (!ref) throw new CommandExecutionError(`No tag matched turn "${t}".`, ''); + } else { + // Pick the latest after-chat-turn ref. + const raw = gitInRepo(repo, ['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)', 'refs/tags']); + ref = raw.split('\n').find((t) => t.startsWith('after-chat-turn-')); + if (!ref) throw new CommandExecutionError('No after-chat-turn tags found.', ''); + } + const tree = gitInRepo(repo, ['ls-tree', '-r', '-l', ref]); + const rows = tree.split('\n').filter(Boolean).map((line) => { + const parts = line.split(/\s+/); + // mode type oid size path + return { mode: parts[0], oid: parts[2], size: parts[3], pth: parts.slice(4).join(' ') }; + }); + const limit = Number.isInteger(args.limit) && args.limit > 0 ? args.limit : 50; + return rows.slice(0, limit).map((r) => ({ Mode: r.mode, Path: r.pth, Size: r.size })); + }, +}); diff --git a/clis/trae-solo/trae-solo.test.js b/clis/trae-solo/trae-solo.test.js new file mode 100644 index 000000000..f147d80ac --- /dev/null +++ b/clis/trae-solo/trae-solo.test.js @@ -0,0 +1,67 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parseSkillMd } from './_fs.js'; +import { resolveWorkspaceJson } from './_state.js'; + +describe('trae-solo filesystem helpers', () => { + it('parses skill front matter without requiring the Trae app', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'trae-skill-')); + writeFileSync(path.join(dir, 'SKILL.md'), [ + '---', + 'name: Code Reviewer', + 'description: Review code changes', + 'tags: review qa', + 'version: 1.2.3', + 'author: test', + '---', + '', + '# Skill', + ].join('\n')); + + expect(parseSkillMd(dir)).toMatchObject({ + name: 'Code Reviewer', + description: 'Review code changes', + tags: ['review', 'qa'], + version: '1.2.3', + author: 'test', + }); + }); + + it('falls back to the first body paragraph when SKILL.md lacks front matter', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'trae-skill-')); + writeFileSync(path.join(dir, 'SKILL.md'), '# Heading\n\nFirst body paragraph.\n'); + + expect(parseSkillMd(dir)).toMatchObject({ + name: path.basename(dir), + description: 'First body paragraph.', + }); + }); + + it('resolves a folder workspace.json file URL to a local path', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'trae-workspace-')); + const workspaceJson = path.join(dir, 'workspace.json'); + writeFileSync(workspaceJson, JSON.stringify({ folder: 'file:///tmp/example%20project' })); + + expect(resolveWorkspaceJson(workspaceJson)).toEqual({ + kind: 'folder', + target: '/tmp/example project', + }); + }); + + it('resolves a multi-folder workspace indirection', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'trae-workspace-')); + const innerDir = path.join(dir, 'inner'); + mkdirSync(innerDir); + const inner = path.join(innerDir, 'workspace.code-workspace'); + const outer = path.join(dir, 'workspace.json'); + writeFileSync(inner, JSON.stringify({ folders: [{ path: '/repo/a' }, { uri: 'file:///repo/b' }] })); + writeFileSync(outer, JSON.stringify({ workspace: `file://${inner}` })); + + expect(resolveWorkspaceJson(outer)).toEqual({ + kind: 'workspace', + target: '/repo/a; file:///repo/b', + }); + }); +}); diff --git a/clis/trae-solo/user-rules.js b/clis/trae-solo/user-rules.js new file mode 100644 index 000000000..1f2fc60e3 --- /dev/null +++ b/clis/trae-solo/user-rules.js @@ -0,0 +1,29 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { TRAE_USER_RULES } from './_fs.js'; + +// -------- user-rules -------- +cli({ + site: 'trae-solo', + name: 'user-rules', + access: 'read', + description: 'Print Trae SOLO user rules (~/.trae/user_rules.md).', + domain: 'localhost', + browser: false, + strategy: Strategy.LOCAL, + args: [], + columns: ['Field', 'Value'], + func: async () => { + if (!fs.existsSync(TRAE_USER_RULES)) { + return [{ Field: 'path', Value: TRAE_USER_RULES }, { Field: 'content', Value: '(file does not exist yet)' }]; + } + const content = fs.readFileSync(TRAE_USER_RULES, 'utf-8'); + const stat = fs.statSync(TRAE_USER_RULES); + return [ + { Field: 'path', Value: TRAE_USER_RULES }, + { Field: 'size', Value: String(stat.size) + ' bytes' }, + { Field: 'modified', Value: stat.mtime.toISOString().replace('T', ' ').slice(0, 19) }, + { Field: 'content', Value: content }, + ]; + }, +}); diff --git a/clis/trae-solo/workspaces-fs.js b/clis/trae-solo/workspaces-fs.js new file mode 100644 index 000000000..59889f89d --- /dev/null +++ b/clis/trae-solo/workspaces-fs.js @@ -0,0 +1,113 @@ +// Workspace + extension enumeration commands for Trae SOLO. +// +// workspaces-list — list workspaceStorage uuids + resolved folder paths +// extensions-list — list installed VSCode extensions from extensions.json +// +// READ-ONLY. Works while Trae SOLO is closed. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + CommandExecutionError, + EmptyResultError, +} from '@jackwener/opencli/errors'; +import { + TRAE_WORKSPACE_STORAGE, + TRAE_EXTENSIONS_JSON, + resolveWorkspaceJson, +} from './_state.js'; + +// -------- workspaces-list -------- +cli({ + site: 'trae-solo', + name: 'workspaces-list', + access: 'read', + description: 'List Trae SOLO workspaceStorage entries (~/Library/.../TRAE SOLO/User/workspaceStorage//), resolving each workspace.json to its single-folder path or multi-folder workspace target. Works while Trae is closed.', + domain: 'localhost', + browser: false, + strategy: Strategy.LOCAL, + args: [ + { name: 'limit', type: 'int', required: false, default: 100 }, + ], + columns: ['Index', 'Workspace Id', 'Kind', 'Target', 'Modified', 'Id', 'Version', 'Source', 'Installed'], + func: async (args) => { + if (!fs.existsSync(TRAE_WORKSPACE_STORAGE)) { + throw new CommandExecutionError( + `workspaceStorage not found: ${TRAE_WORKSPACE_STORAGE}`, + '', + ); + } + const dirs = fs.readdirSync(TRAE_WORKSPACE_STORAGE).filter((n) => { + const full = path.join(TRAE_WORKSPACE_STORAGE, n); + return fs.statSync(full).isDirectory(); + }); + if (!dirs.length) { + throw new EmptyResultError('trae-solo workspaces-list', 'No workspace storage entries.'); + } + const rows = dirs.map((id) => { + const dir = path.join(TRAE_WORKSPACE_STORAGE, id); + const wj = path.join(dir, 'workspace.json'); + const resolved = resolveWorkspaceJson(wj); + const mtime = fs.statSync(dir).mtimeMs; + return { id, kind: resolved.kind, target: resolved.target, mtime }; + }).sort((a, b) => b.mtime - a.mtime); + const limit = Number.isInteger(args.limit) && args.limit > 0 ? args.limit : 100; + return rows.slice(0, limit).map((r, i) => ({ + Index: i + 1, + 'Workspace Id': r.id, + Kind: r.kind, + Target: (r.target || '').slice(0, 120), + Modified: new Date(r.mtime).toISOString().replace('T', ' ').slice(0, 19), + Id: '', + Version: '', + Source: '', + Installed: '', + })); + }, +}); + +// -------- extensions-list -------- +cli({ + site: 'trae-solo', + name: 'extensions-list', + access: 'read', + description: 'List VSCode extensions installed in Trae SOLO (~/.trae/extensions/extensions.json). Works while Trae is closed.', + domain: 'localhost', + browser: false, + strategy: Strategy.LOCAL, + args: [], + columns: ['Index', 'Workspace Id', 'Kind', 'Target', 'Modified', 'Id', 'Version', 'Source', 'Installed'], + func: async () => { + if (!fs.existsSync(TRAE_EXTENSIONS_JSON)) { + throw new CommandExecutionError( + `extensions.json not found: ${TRAE_EXTENSIONS_JSON}`, + 'Trae SOLO has not installed any VSCode extensions yet.', + ); + } + let arr; + try { + arr = JSON.parse(fs.readFileSync(TRAE_EXTENSIONS_JSON, 'utf-8')); + } catch (e) { + throw new CommandExecutionError(`Failed to parse extensions.json: ${e.message}`, ''); + } + if (!Array.isArray(arr) || !arr.length) { + throw new EmptyResultError('trae-solo extensions-list', 'No extensions installed.'); + } + return arr.map((e, i) => { + const ts = e?.metadata?.installedTimestamp; + const installed = ts ? new Date(ts).toISOString().replace('T', ' ').slice(0, 19) : ''; + return { + Index: i + 1, + 'Workspace Id': '', + Kind: '', + Target: '', + Modified: '', + Id: e?.identifier?.id || '?', + Version: e?.version || '', + Source: e?.metadata?.source || '', + Installed: installed, + }; + }); + }, +}); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 2390d88f8..7d7ff7370 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -157,6 +157,7 @@ export default defineConfig({ { text: 'ChatWise', link: '/adapters/desktop/chatwise' }, { text: 'Discord', link: '/adapters/desktop/discord' }, { text: 'Doubao App', link: '/adapters/desktop/doubao-app' }, + { text: 'Trae SOLO', link: '/adapters/desktop/trae-solo' }, ], }, ], diff --git a/docs/adapters/desktop/trae-solo.md b/docs/adapters/desktop/trae-solo.md new file mode 100644 index 000000000..fa4625ed8 --- /dev/null +++ b/docs/adapters/desktop/trae-solo.md @@ -0,0 +1,61 @@ +# Trae SOLO + +Control **Trae SOLO** from OpenCLI through the Electron debug port and read its local VSCode-style state files. + +**Mode**: Desktop app / local filesystem · **App**: `TRAE SOLO` + +## Commands + +| Command | Description | Access | +|---------|-------------|--------| +| `opencli trae-solo status` | Check whether the Trae SOLO desktop app is reachable | read | +| `opencli trae-solo history` | List visible projects and tasks from the Trae SOLO sidebar | read | +| `opencli trae-solo model [name]` | Read, list, or switch the active model in an open task | write when switching | +| `opencli trae-solo mode [code\|work]` | Read or switch between Code and Work mode | write when switching | +| `opencli trae-solo automation-list` | Read visible Automation tab entries | read | +| `opencli trae-solo skill-list` | List marketplace or installed skills from the Skills panel | read | +| `opencli trae-solo skill-search ` | Search visible marketplace skills | read | +| `opencli trae-solo skill-category [name]` | List or filter marketplace skill categories | read | +| `opencli trae-solo storage-keys` | List renderer `localStorage` / `sessionStorage` keys | read | +| `opencli trae-solo storage-get ` | Read a renderer storage value | read | +| `opencli trae-solo cookies` | List JavaScript-visible renderer cookies with truncated previews | read | +| `opencli trae-solo idb-list` | List renderer IndexedDB database names | read | +| `opencli trae-solo state-keys` | List keys in Trae SOLO `state.vscdb` | read | +| `opencli trae-solo state-get ` | Read a key from `state.vscdb` | read | +| `opencli trae-solo recent-workspaces` | Show recently opened workspaces from local state | read | +| `opencli trae-solo workspaces-list` | List workspaceStorage entries and resolved workspace targets | read | +| `opencli trae-solo extensions-list` | List installed VSCode-compatible extensions | read | +| `opencli trae-solo task-fs-list` | List on-disk Trae SOLO task ids | read | +| `opencli trae-solo task-fs-turns ` | List chat-turn git tags for a task snapshot | read | +| `opencli trae-solo task-fs-show ` | Show the workspace tree at a chat-turn ref | read | +| `opencli trae-solo skill-fs-list` | List local skill directories under `~/.trae/skills` | read | +| `opencli trae-solo skill-fs-installed` | List skills registered in `skill-config.json` | read | +| `opencli trae-solo skill-fs-show ` | Show a local skill's `SKILL.md` head and metadata | read | +| `opencli trae-solo settings-read` | Read user `settings.json` with JSONC comments/trailing commas | read | +| `opencli trae-solo user-rules` | Read `~/.trae/user_rules.md` | read | + +Write-side UI commands that only proved button clicks were intentionally left out. New task creation, task open/navigation, message actions, skill install/uninstall/run/toggle, automation creation, and filesystem deletion need explicit postconditions before they can be exposed safely. + +## Examples + +```bash +opencli trae-solo status +opencli trae-solo history --limit 20 +opencli trae-solo model --list true +opencli trae-solo model "Claude" +opencli trae-solo mode work + +opencli trae-solo skill-search "python" +opencli trae-solo automation-list --tab task-template + +opencli trae-solo state-keys --filter workbench +opencli trae-solo recent-workspaces +opencli trae-solo task-fs-list --limit 20 +``` + +## Notes + +- Electron UI commands require Trae SOLO to be running with the configured CDP port. OpenCLI launches registered Electron apps with the app-specific debug port when needed. +- Renderer storage reads come from the current Electron renderer and may be empty if the app has not loaded the relevant workspace. +- Filesystem reads are local-only and read Trae SOLO state under `~/.trae` and `~/Library/Application Support/TRAE SOLO`. +- `model` and `mode` verify the visible post-action state before returning success. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 1898a7360..d28524132 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -173,3 +173,4 @@ Run `opencli list` for the live registry. | **[Qoder](./desktop/qoder.md)** | Control Qoder IDE | `status` `new` `history` `send` `ask` `read` `search` `settings` `knowledge` `marketplace` `credits` `view-all` `add-workspace` `account` `more-actions` `prompt-enhance` `open-editor` `sidebar-toggle` `open-panel` | | **[Discord](./desktop/discord.md)** | Desktop messages & channels | `status` `send` `read` `channels` `servers` `search` `members` | | **[Doubao App](./desktop/doubao-app.md)** | Doubao AI desktop app via CDP | `status` `new` `send` `read` `ask` `screenshot` `dump` | +| **[Trae SOLO](./desktop/trae-solo.md)** | Trae SOLO desktop state | `status` `history` `model` `mode` `automation-list` `skill-*` `state-*` `task-fs-*` | diff --git a/src/electron-apps.test.ts b/src/electron-apps.test.ts index f8674293c..0fa102126 100644 --- a/src/electron-apps.test.ts +++ b/src/electron-apps.test.ts @@ -32,6 +32,7 @@ describe('electron-apps registry', () => { expect(isElectronApp('codex')).toBe(true); expect(isElectronApp('chatwise')).toBe(true); expect(isElectronApp('qoder')).toBe(true); + expect(isElectronApp('trae-solo')).toBe(true); }); it('registers Qoder on its own CDP port', () => { @@ -44,6 +45,15 @@ describe('electron-apps registry', () => { }); }); + it('registers Trae SOLO with its own CDP port and process metadata', () => { + const app = getElectronApp('trae-solo'); + + expect(app).toBeDefined(); + expect(app!.port).toBe(9235); + expect(app!.processName).toBe('TRAE SOLO'); + expect(app!.bundleId).toBe('com.trae.solo.app'); + }); + it('isElectronApp returns false for non-Electron sites', () => { expect(isElectronApp('bilibili')).toBe(false); expect(isElectronApp('notion')).toBe(false); diff --git a/src/electron-apps.ts b/src/electron-apps.ts index 8b2f3b535..de9a6ba76 100644 --- a/src/electron-apps.ts +++ b/src/electron-apps.ts @@ -46,6 +46,13 @@ export const builtinApps: Record = { bundleId: 'com.qoder.ide', displayName: 'Qoder', }, + 'trae-solo': { + port: 9235, + processName: 'TRAE SOLO', + executableNames: ['Electron', 'TRAE SOLO'], + bundleId: 'com.trae.solo.app', + displayName: 'Trae SOLO', + }, }; /** Merge builtin + user-defined apps. User entries are additive only. */