mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
feat(skills): add secure terminal opener (#2650)
* test(skills): define terminal opener contract * feat(skills): add secure terminal opener * fix(skills): report detached terminal errors * docs: sync terminal opener skill count * fix(security): require explicit terminal launch * test(skills): cover terminal opener review findings * fix(skills): bound terminal launch waits * test(skills): cover terminal fallback output * fix(skills): report terminal mux fallback --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b3c679684b
commit
52a3babd5d
@@ -11,7 +11,7 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"source": "./",
|
||||
"description": "Harness-native ECC operator layer - 67 agents, 281 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
|
||||
"description": "Harness-native ECC operator layer - 67 agents, 282 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
|
||||
"version": "2.2.0",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "2.2.0",
|
||||
"description": "Harness-native ECC plugin for engineering teams - 67 agents, 281 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
|
||||
"description": "Harness-native ECC plugin for engineering teams - 67 agents, 282 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
"url": "https://x.com/affaanmustafa"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — Agent Instructions
|
||||
|
||||
This is a **production-ready AI coding plugin** providing 67 specialized agents, 281 skills, 94 commands, and automated hook workflows for software development.
|
||||
This is a **production-ready AI coding plugin** providing 67 specialized agents, 282 skills, 94 commands, and automated hook workflows for software development.
|
||||
|
||||
**Version:** 2.2.0
|
||||
|
||||
@@ -152,7 +152,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
||||
|
||||
```
|
||||
agents/ — 67 specialized subagents
|
||||
skills/ — 281 workflow skills and domain knowledge
|
||||
skills/ — 282 workflow skills and domain knowledge
|
||||
commands/ — 94 slash commands
|
||||
hooks/ — Trigger-based automations
|
||||
rules/ — Always-follow guidelines (common + per-language)
|
||||
|
||||
@@ -116,12 +116,12 @@ Instead of rebuilding that process in every prompt, you install it once and make
|
||||
|
||||
ECC is MIT-licensed open source. It works best with Claude Code today, has a supported Codex sync path, and provides capability-limited adapters for Cursor, OpenCode, Gemini, Zed, GitHub Copilot, Antigravity, Qwen, and other harnesses. See the [support status matrix](#platform-support) before assuming feature parity.
|
||||
|
||||
Access to 67 agents, 281 skills, and 94 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work.
|
||||
Access to 67 agents, 282 skills, and 94 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work.
|
||||
|
||||
| Included | Count | What it gives you |
|
||||
| ---------------- | ----------: | ------------------------------------------------------------------------------------ |
|
||||
| Agents | 67 agents | Planning, review, build repair, security, architecture, and domain work |
|
||||
| Skills | 281 skills | TDD, research, security, docs, frontend, data, ML, operations, and more |
|
||||
| Skills | 282 skills | TDD, research, security, docs, frontend, data, ML, operations, and more |
|
||||
| Commands | 94 commands | Convenient entry points while ECC moves to a skills-first surface |
|
||||
| Hooks and memory | Runtime | Enforcement, session summaries, continuous learning, instincts, and context controls |
|
||||
| Rules | Selective | Always-loaded standards you choose by language or project |
|
||||
@@ -967,7 +967,7 @@ This repo is the raw code. The guides explain everything.
|
||||
```text
|
||||
ECC/
|
||||
|-- agents/ # 67 specialized subagents for delegation
|
||||
|-- skills/ # 281 reusable workflows loaded on demand
|
||||
|-- skills/ # 282 reusable workflows loaded on demand
|
||||
|-- commands/ # 94 maintained slash-command shims
|
||||
|-- rules/ # opt-in common and language standards
|
||||
|-- hooks/ # runtime automation and enforcement
|
||||
|
||||
+1
-1
@@ -196,7 +196,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**完成!** 你现在可以使用 67 个代理、281 个技能和 94 个命令。
|
||||
**完成!** 你现在可以使用 67 个代理、282 个技能和 94 个命令。
|
||||
|
||||
### multi-* 命令需要额外配置
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — Agent Talimatları
|
||||
|
||||
Bu, yazılım geliştirme için 67 özel agent, 281 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
|
||||
Bu, yazılım geliştirme için 67 özel agent, 282 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
|
||||
|
||||
**Sürüm:** 2.2.0
|
||||
|
||||
@@ -142,7 +142,7 @@ Başarısızlık sorunlarını giderin: test izolasyonunu kontrol edin → mockl
|
||||
|
||||
```
|
||||
agents/ — 67 özel subagent
|
||||
skills/ — 281 iş akışı skillleri ve alan bilgisi
|
||||
skills/ — 282 iş akışı skillleri ve alan bilgisi
|
||||
commands/ — 94 slash command
|
||||
hooks/ — Tetikleyici tabanlı otomasyonlar
|
||||
rules/ — Her zaman uyulması gereken kurallar (ortak + dile özel)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — 智能体指令
|
||||
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、281 项技能、94 条命令以及自动化钩子工作流,用于软件开发。
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、282 项技能、94 条命令以及自动化钩子工作流,用于软件开发。
|
||||
|
||||
**版本:** 2.2.0
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
|
||||
```
|
||||
agents/ — 67 个专业子代理
|
||||
skills/ — 281 个工作流技能和领域知识
|
||||
skills/ — 282 个工作流技能和领域知识
|
||||
commands/ — 94 个斜杠命令
|
||||
hooks/ — 基于触发的自动化
|
||||
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
||||
|
||||
@@ -260,7 +260,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**搞定!** 你现在可以使用 67 个智能体、281 项技能和 94 个命令了。
|
||||
**搞定!** 你现在可以使用 67 个智能体、282 项技能和 94 个命令了。
|
||||
|
||||
***
|
||||
|
||||
@@ -1174,7 +1174,7 @@ opencode
|
||||
|---------|---------------|----------|--------|
|
||||
| 智能体 | PASS: 67 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||
| 命令 | PASS: 94 个 | PASS: 35 个 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 281 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 282 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
||||
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
||||
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
||||
@@ -1282,7 +1282,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
||||
|---------|-----------------------|------------|-----------|----------|
|
||||
| **智能体** | 67 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||
| **命令** | 94 | 共享 | 基于指令 | 35 |
|
||||
| **技能** | 281 | 共享 | 10 (原生格式) | 37 |
|
||||
| **技能** | 282 | 共享 | 10 (原生格式) | 37 |
|
||||
| **钩子事件** | 8 种类型 | 15 种类型 | SessionStart(1 种类型) | 11 种类型 |
|
||||
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | 1 个 SessionStart 引导脚本 | 插件钩子 |
|
||||
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
|
||||
|
||||
@@ -819,6 +819,7 @@
|
||||
"skills/cisco-ios-patterns",
|
||||
"skills/deployment-patterns",
|
||||
"skills/docker-patterns",
|
||||
"skills/terminal-opener",
|
||||
"skills/homelab-network-readiness",
|
||||
"skills/homelab-network-setup",
|
||||
"skills/netmiko-ssh-automation",
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"skills/tdd-workflow/",
|
||||
"skills/team-agent-orchestration/",
|
||||
"skills/team-builder/",
|
||||
"skills/terminal-opener/",
|
||||
"skills/terminal-ops/",
|
||||
"skills/token-budget-advisor/",
|
||||
"skills/ui-demo/",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: terminal-opener
|
||||
description: Open an executable and its argument array in a visible terminal window through a reusable, shell-free launch plan with dry-run, JSON, capability detection, detached fallback, and standalone recovery modes. Use when Codex needs to open an interactive CLI, SSH session, local development process, sandbox, or other argv-based command in a new host terminal; diagnose whether a supported terminal is available; or provide an actionable plan when the requested terminal is unsupported.
|
||||
---
|
||||
|
||||
# Terminal Opener
|
||||
|
||||
Use `scripts/open-terminal.js` to preserve an executable and every argument as
|
||||
separate process entries. Never interpolate a shell command string. Keep every
|
||||
spawn on `shell: false`. Default to a non-launching plan. Use `--launch` only
|
||||
after the user explicitly requests a real window and the argv has been reviewed.
|
||||
The launched process inherits the full environment of the calling process,
|
||||
including secret-bearing variables. The launcher does not filter the
|
||||
environment. Run it from a shell whose environment is safe to expose to the
|
||||
target command.
|
||||
|
||||
## Launch a command
|
||||
|
||||
Pass launcher options before `--`, then pass exactly one executable followed by
|
||||
its argument array:
|
||||
|
||||
```bash
|
||||
node skills/terminal-opener/scripts/open-terminal.js \
|
||||
--launch \
|
||||
--cwd /absolute/host/path \
|
||||
-- ssh -t example.test command-with-arguments
|
||||
```
|
||||
|
||||
Run normal mode first. Let WezTerm try its mux with a new window, then let the
|
||||
launcher fall back to a detached `wezterm start` process if the mux is not
|
||||
available. When fallback is used, read `muxFailure` from JSON output (or the
|
||||
human-readable failure line) to diagnose why the mux path failed.
|
||||
|
||||
## Recover from terminal configuration
|
||||
|
||||
Add `--recover` or `--standalone` when user configuration or mux state may
|
||||
interfere with the requested command. Start a detached WezTerm process with:
|
||||
|
||||
```text
|
||||
--skip-config start --always-new-process
|
||||
```
|
||||
|
||||
Expect recovery mode to skip all user terminal configuration intentionally.
|
||||
|
||||
## Inspect before launch
|
||||
|
||||
Omit `--launch` (or add `--dry-run`) and add `--json` to inspect the exact
|
||||
executable, argv, working directory, terminal adapter, primary launch, and
|
||||
fallback without opening a window. Treat the JSON plan as the composition
|
||||
boundary for callers.
|
||||
|
||||
Run `--detect --json` without a command to probe terminal availability. Follow
|
||||
the returned `action` when the adapter is missing or unsupported. Use WezTerm
|
||||
for the current adapter; treat other requested terminals as unsupported plans,
|
||||
not as commands to execute.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Terminal Opener"
|
||||
short_description: "Open commands safely in visible terminals"
|
||||
default_prompt: "Use $terminal-opener to open an executable and its argument array in a visible terminal."
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const childProcess = require('child_process');
|
||||
|
||||
const DEFAULT_TERMINAL = 'wezterm';
|
||||
const SPAWN_KILL_SIGNAL = 'SIGTERM';
|
||||
const SYNC_TIMEOUT_MS = 10_000;
|
||||
const SUPPORTED_TERMINALS = new Set([DEFAULT_TERMINAL]);
|
||||
|
||||
function usage() {
|
||||
return `Open an executable and its argument array in a visible terminal.
|
||||
|
||||
Usage:
|
||||
node skills/terminal-opener/scripts/open-terminal.js [options] -- <executable> [args...]
|
||||
node skills/terminal-opener/scripts/open-terminal.js --detect [--terminal <name>] [--json]
|
||||
|
||||
Options:
|
||||
--terminal <name> Terminal adapter (default: ECC_TERMINAL or wezterm).
|
||||
--cwd <path> Initial host directory (default: current directory).
|
||||
--recover Start a standalone terminal with stock configuration.
|
||||
--standalone Alias for --recover.
|
||||
--detect Check whether the selected terminal can be launched.
|
||||
--launch Explicitly open the terminal (the default only prints a plan).
|
||||
--dry-run Explicitly print the launch plan without opening a terminal.
|
||||
--json Emit the plan, capability, or launch result as JSON.
|
||||
--help, -h Show this help.
|
||||
|
||||
Always pass the executable and arguments as separate entries after --.
|
||||
Shell command strings are not accepted.
|
||||
`;
|
||||
}
|
||||
|
||||
function isAbsolutePath(value) {
|
||||
return path.isAbsolute(value) || path.win32.isAbsolute(value);
|
||||
}
|
||||
|
||||
function validateTerminalName(value) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(value)) {
|
||||
throw new Error('Invalid terminal name; use a simple adapter name such as wezterm.');
|
||||
}
|
||||
}
|
||||
|
||||
function validateCwd(value) {
|
||||
if (value.includes('\0')) throw new Error('--cwd must not contain a NUL byte.');
|
||||
if (!isAbsolutePath(value)) throw new Error('--cwd must be an absolute path.');
|
||||
}
|
||||
|
||||
function validateExecutable(value) {
|
||||
if (!value || /[\0\r\n]/.test(value)) {
|
||||
throw new Error('Executable must be a non-empty argv entry without control bytes.');
|
||||
}
|
||||
|
||||
const whitespaceIndex = value.search(/\s/);
|
||||
const separatorIndexes = [value.indexOf('/'), value.indexOf('\\')].filter(index => index >= 0);
|
||||
const firstSeparatorIndex = separatorIndexes.length > 0 ? Math.min(...separatorIndexes) : -1;
|
||||
const resemblesExecutablePath = isAbsolutePath(value)
|
||||
|| (firstSeparatorIndex >= 0 && (whitespaceIndex < 0 || firstSeparatorIndex < whitespaceIndex));
|
||||
|
||||
if (whitespaceIndex >= 0 && !resemblesExecutablePath) {
|
||||
throw new Error(
|
||||
'Executable must be one argv entry, not an interpolated shell command string.'
|
||||
);
|
||||
}
|
||||
if (!resemblesExecutablePath && /[;&|<>`$]/.test(value)) {
|
||||
throw new Error(
|
||||
'Executable must be one argv entry, not an interpolated shell command string.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateArgv(argv) {
|
||||
for (const argument of argv) {
|
||||
if (argument.includes('\0')) throw new Error('Arguments must not contain NUL bytes.');
|
||||
}
|
||||
}
|
||||
|
||||
function readValue(argv, index, option) {
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined || value.startsWith('--')) {
|
||||
throw new Error(`Missing value for ${option}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseArgs(argv, context = {}) {
|
||||
const env = context.env || process.env;
|
||||
const initialTerminal = env.ECC_TERMINAL || DEFAULT_TERMINAL;
|
||||
const initialCwd = context.cwd || process.cwd();
|
||||
const options = {
|
||||
argv: [],
|
||||
cwd: initialCwd,
|
||||
detect: false,
|
||||
dryRun: true,
|
||||
executable: undefined,
|
||||
help: false,
|
||||
json: false,
|
||||
mode: 'normal',
|
||||
terminal: initialTerminal,
|
||||
};
|
||||
let dryRunRequested = false;
|
||||
let launchRequested = false;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === '--') {
|
||||
options.executable = argv[index + 1];
|
||||
options.argv = argv.slice(index + 2);
|
||||
break;
|
||||
}
|
||||
if (argument === '--terminal' || argument === '--cwd') {
|
||||
const value = readValue(argv, index, argument);
|
||||
options[argument.slice(2)] = value;
|
||||
index += 1;
|
||||
} else if (argument === '--recover' || argument === '--standalone') {
|
||||
options.mode = 'recover';
|
||||
} else if (argument === '--detect') {
|
||||
options.detect = true;
|
||||
} else if (argument === '--launch') {
|
||||
launchRequested = true;
|
||||
options.dryRun = false;
|
||||
} else if (argument === '--dry-run') {
|
||||
dryRunRequested = true;
|
||||
options.dryRun = true;
|
||||
} else if (argument === '--json') {
|
||||
options.json = true;
|
||||
} else if (argument === '--help' || argument === '-h') {
|
||||
options.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown option "${argument}"; put the executable after --.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (launchRequested && dryRunRequested) {
|
||||
throw new Error('--launch and --dry-run are mutually exclusive.');
|
||||
}
|
||||
|
||||
validateTerminalName(options.terminal);
|
||||
validateCwd(options.cwd);
|
||||
if (!options.help && !options.detect && !options.executable) {
|
||||
throw new Error('An executable is required after --.');
|
||||
}
|
||||
if (options.executable) validateExecutable(options.executable);
|
||||
validateArgv(options.argv);
|
||||
return options;
|
||||
}
|
||||
|
||||
function unsupportedPlan(options) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'unsupported-terminal',
|
||||
action: `Terminal "${options.terminal}" is not supported. Install WezTerm, then rerun with --terminal wezterm.`,
|
||||
terminal: options.terminal,
|
||||
executable: options.executable,
|
||||
argv: [...options.argv],
|
||||
cwd: options.cwd,
|
||||
dryRun: options.dryRun,
|
||||
launchMode: options.mode === 'recover' ? 'recover' : 'mux',
|
||||
command: null,
|
||||
args: null,
|
||||
fallback: null,
|
||||
probe: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLaunchPlan(options) {
|
||||
if (!SUPPORTED_TERMINALS.has(options.terminal)) return unsupportedPlan(options);
|
||||
|
||||
const commandArgs = options.executable ? [options.executable, ...options.argv] : [];
|
||||
const recover = options.mode === 'recover';
|
||||
return {
|
||||
ok: true,
|
||||
reason: null,
|
||||
action: null,
|
||||
terminal: options.terminal,
|
||||
executable: options.executable,
|
||||
argv: [...options.argv],
|
||||
cwd: options.cwd,
|
||||
dryRun: options.dryRun,
|
||||
launchMode: recover ? 'recover' : 'mux',
|
||||
command: DEFAULT_TERMINAL,
|
||||
args: recover
|
||||
? ['--skip-config', 'start', '--always-new-process', '--cwd', options.cwd, '--', ...commandArgs]
|
||||
: ['cli', 'spawn', '--new-window', '--cwd', options.cwd, '--', ...commandArgs],
|
||||
fallback: recover
|
||||
? null
|
||||
: {
|
||||
command: DEFAULT_TERMINAL,
|
||||
args: ['start', '--cwd', options.cwd, '--', ...commandArgs],
|
||||
},
|
||||
probe: { command: DEFAULT_TERMINAL, args: ['--version'] },
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableCapability(plan, reason, detail) {
|
||||
return {
|
||||
terminal: plan.terminal,
|
||||
supported: true,
|
||||
available: false,
|
||||
reason,
|
||||
detail,
|
||||
action: 'Install WezTerm and ensure wezterm is on PATH, then rerun with --detect.',
|
||||
};
|
||||
}
|
||||
|
||||
function detectTerminalCapability(plan, spawnSyncImpl = childProcess.spawnSync) {
|
||||
if (!plan.ok) {
|
||||
return {
|
||||
terminal: plan.terminal,
|
||||
supported: false,
|
||||
available: false,
|
||||
reason: plan.reason,
|
||||
detail: null,
|
||||
action: plan.action,
|
||||
};
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = spawnSyncImpl(plan.probe.command, plan.probe.args, {
|
||||
encoding: 'utf8',
|
||||
killSignal: SPAWN_KILL_SIGNAL,
|
||||
shell: false,
|
||||
timeout: SYNC_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
return unavailableCapability(plan, 'probe-failed', error.message);
|
||||
}
|
||||
if (result.error) {
|
||||
const reason = result.error.code === 'ETIMEDOUT' ? 'probe-failed' : 'not-installed';
|
||||
return unavailableCapability(plan, reason, result.error.message);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
return unavailableCapability(
|
||||
plan,
|
||||
'probe-failed',
|
||||
`Terminal version probe exited with status ${result.status}.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
terminal: plan.terminal,
|
||||
supported: true,
|
||||
available: true,
|
||||
reason: null,
|
||||
detail: null,
|
||||
action: null,
|
||||
version: String(result.stdout || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function reportDetachedError(error) {
|
||||
process.stderr.write(`Error: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function launchDetached(command, args, cwd, spawnImpl, onDetachedError) {
|
||||
let child;
|
||||
try {
|
||||
child = spawnImpl(command, args, {
|
||||
cwd,
|
||||
detached: true,
|
||||
shell: false,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to start ${command}: ${error.message}`, { cause: error });
|
||||
}
|
||||
if (!child || typeof child.unref !== 'function') {
|
||||
throw new Error('Terminal process did not start correctly.');
|
||||
}
|
||||
if (typeof child.once === 'function') {
|
||||
child.once('error', error => {
|
||||
onDetachedError(
|
||||
new Error(`Unable to start ${command}: ${error.message}`, { cause: error })
|
||||
);
|
||||
});
|
||||
}
|
||||
child.unref();
|
||||
}
|
||||
|
||||
function launch(plan, dependencies = {}) {
|
||||
const spawnSyncImpl = dependencies.spawnSync || childProcess.spawnSync;
|
||||
const spawnImpl = dependencies.spawn || childProcess.spawn;
|
||||
const onDetachedError = dependencies.onDetachedError || reportDetachedError;
|
||||
const capability = detectTerminalCapability(plan, spawnSyncImpl);
|
||||
if (!capability.available) {
|
||||
throw new Error(`${capability.reason}: ${capability.action}`);
|
||||
}
|
||||
|
||||
if (plan.launchMode === 'recover') {
|
||||
launchDetached(plan.command, plan.args, plan.cwd, spawnImpl, onDetachedError);
|
||||
return { strategy: 'detached-recover', capability };
|
||||
}
|
||||
|
||||
const muxResult = spawnSyncImpl(plan.command, plan.args, {
|
||||
cwd: plan.cwd,
|
||||
encoding: 'utf8',
|
||||
killSignal: SPAWN_KILL_SIGNAL,
|
||||
shell: false,
|
||||
timeout: SYNC_TIMEOUT_MS,
|
||||
});
|
||||
if (!muxResult.error && muxResult.status === 0) {
|
||||
return { strategy: 'mux', capability };
|
||||
}
|
||||
|
||||
const muxFailure = muxResult.error
|
||||
? muxResult.error.message
|
||||
: `${plan.command} cli spawn exited with status ${muxResult.status}: ${String(
|
||||
muxResult.stderr || ''
|
||||
).trim()}`;
|
||||
|
||||
launchDetached(
|
||||
plan.fallback.command,
|
||||
plan.fallback.args,
|
||||
plan.cwd,
|
||||
spawnImpl,
|
||||
onDetachedError
|
||||
);
|
||||
return { strategy: 'detached-fallback', capability, muxFailure };
|
||||
}
|
||||
|
||||
function printJson(value) {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function formatLaunchResult(plan, result, json) {
|
||||
if (json) {
|
||||
return `${JSON.stringify({ ...plan, ...result }, null, 2)}\n`;
|
||||
}
|
||||
|
||||
const summary =
|
||||
`Open ${plan.executable} in ${plan.terminal} using ${plan.launchMode} mode.\n`;
|
||||
if (result.strategy !== 'detached-fallback') return summary;
|
||||
return `${summary}Mux launch failed: ${result.muxFailure}\n`;
|
||||
}
|
||||
|
||||
function printPlan(plan, json) {
|
||||
if (json) return printJson(plan);
|
||||
if (!plan.ok) {
|
||||
process.stdout.write(`${plan.action}\n`);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`Open ${plan.executable} in ${plan.terminal} using ${plan.launchMode} mode.\n`
|
||||
);
|
||||
}
|
||||
|
||||
function printCapability(capability, json) {
|
||||
if (json) return printJson(capability);
|
||||
if (capability.available) {
|
||||
process.stdout.write(`${capability.terminal} is available (${capability.version}).\n`);
|
||||
} else {
|
||||
process.stdout.write(`${capability.terminal} is unavailable. ${capability.action}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = buildLaunchPlan(options);
|
||||
if (options.detect) {
|
||||
const capability = detectTerminalCapability(plan);
|
||||
printCapability(capability, options.json);
|
||||
if (!capability.available) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
printPlan(plan, options.json);
|
||||
return;
|
||||
}
|
||||
const result = launch(plan);
|
||||
process.stdout.write(formatLaunchResult(plan, result, options.json));
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = {
|
||||
buildLaunchPlan,
|
||||
detectTerminalCapability,
|
||||
formatLaunchResult,
|
||||
launch,
|
||||
parseArgs,
|
||||
usage,
|
||||
};
|
||||
@@ -0,0 +1,463 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const SKILL_ROOT = path.join(REPO_ROOT, 'skills', 'terminal-opener');
|
||||
const SCRIPT = path.join(SKILL_ROOT, 'scripts', 'open-terminal.js');
|
||||
|
||||
const {
|
||||
buildLaunchPlan,
|
||||
detectTerminalCapability,
|
||||
formatLaunchResult,
|
||||
launch,
|
||||
parseArgs,
|
||||
} = require(SCRIPT);
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCli(args, env = {}) {
|
||||
return spawnSync(process.execPath, [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ECC_TERMINAL: '', ...env },
|
||||
});
|
||||
}
|
||||
|
||||
function baseOptions(overrides = {}) {
|
||||
return {
|
||||
argv: ['hello world'],
|
||||
cwd: '/tmp/example workspace',
|
||||
dryRun: false,
|
||||
executable: 'printf',
|
||||
help: false,
|
||||
json: false,
|
||||
mode: 'normal',
|
||||
terminal: 'wezterm',
|
||||
detect: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing terminal-opener skill ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const check = (name, fn) => {
|
||||
if (test(name, fn)) passed += 1;
|
||||
else failed += 1;
|
||||
};
|
||||
|
||||
check('parses an executable and exact argv entries after --', () => {
|
||||
const options = parseArgs(
|
||||
['--terminal', 'wezterm', '--cwd', '/tmp/demo', '--', 'docker', 'exec', '-it', 'demo', 'bash'],
|
||||
{ cwd: '/fallback', env: {} }
|
||||
);
|
||||
assert.strictEqual(options.executable, 'docker');
|
||||
assert.deepStrictEqual(options.argv, ['exec', '-it', 'demo', 'bash']);
|
||||
assert.strictEqual(options.cwd, '/tmp/demo');
|
||||
});
|
||||
|
||||
check('rejects an interpolated shell command string', () => {
|
||||
assert.throws(
|
||||
() => parseArgs(['--', 'printf hello; touch /tmp/pwned'], { cwd: '/tmp', env: {} }),
|
||||
/executable.*argv entry.*shell command string/i
|
||||
);
|
||||
});
|
||||
|
||||
check('preserves shell metacharacters as inert argument entries', () => {
|
||||
const options = parseArgs(
|
||||
['--', 'printf', '%s', '$(touch /tmp/never)', '; rm -rf /'],
|
||||
{ cwd: '/tmp', env: {} }
|
||||
);
|
||||
assert.deepStrictEqual(options.argv, ['%s', '$(touch /tmp/never)', '; rm -rf /']);
|
||||
});
|
||||
|
||||
check('accepts literal executable paths with spaces and metacharacters', () => {
|
||||
const spaced = parseArgs(
|
||||
['--', '/Applications/My App/bin/tool', '--flag'],
|
||||
{ cwd: '/tmp', env: {} }
|
||||
);
|
||||
assert.strictEqual(spaced.executable, '/Applications/My App/bin/tool');
|
||||
assert.deepStrictEqual(spaced.argv, ['--flag']);
|
||||
|
||||
const metacharacter = parseArgs(
|
||||
['--', '/tmp/tool;$name', '--flag'],
|
||||
{ cwd: '/tmp', env: {} }
|
||||
);
|
||||
assert.strictEqual(metacharacter.executable, '/tmp/tool;$name');
|
||||
});
|
||||
|
||||
check('requires the -- argv boundary and an executable', () => {
|
||||
assert.throws(() => parseArgs(['echo', 'hello'], { cwd: '/tmp', env: {} }), /Unknown option.*--/);
|
||||
assert.throws(() => parseArgs(['--'], { cwd: '/tmp', env: {} }), /executable is required/i);
|
||||
});
|
||||
|
||||
check('defaults to a non-launching plan and requires an explicit launch gate', () => {
|
||||
const planned = parseArgs(['--', 'echo', 'hello'], { cwd: '/tmp', env: {} });
|
||||
assert.strictEqual(planned.dryRun, true);
|
||||
|
||||
const launched = parseArgs(['--launch', '--', 'echo', 'hello'], {
|
||||
cwd: '/tmp',
|
||||
env: {},
|
||||
});
|
||||
assert.strictEqual(launched.dryRun, false);
|
||||
|
||||
assert.throws(
|
||||
() => parseArgs(['--launch', '--dry-run', '--', 'echo'], {
|
||||
cwd: '/tmp',
|
||||
env: {},
|
||||
}),
|
||||
/mutually exclusive/i
|
||||
);
|
||||
});
|
||||
|
||||
check('rejects unsafe values at input boundaries', () => {
|
||||
assert.throws(() => parseArgs(['--cwd', 'relative', '--', 'echo'], { cwd: '/tmp', env: {} }), /absolute/);
|
||||
assert.throws(() => parseArgs(['--terminal', '../wezterm', '--', 'echo'], { cwd: '/tmp', env: {} }), /terminal name/);
|
||||
assert.throws(() => parseArgs(['--', 'echo', 'bad\0arg'], { cwd: '/tmp', env: {} }), /NUL/);
|
||||
});
|
||||
|
||||
check('builds the mux-first WezTerm launch plan without a shell', () => {
|
||||
const plan = buildLaunchPlan(baseOptions());
|
||||
assert.strictEqual(plan.ok, true);
|
||||
assert.strictEqual(plan.launchMode, 'mux');
|
||||
assert.strictEqual(plan.command, 'wezterm');
|
||||
assert.deepStrictEqual(plan.args, [
|
||||
'cli', 'spawn', '--new-window', '--cwd', '/tmp/example workspace', '--', 'printf', 'hello world',
|
||||
]);
|
||||
assert.deepStrictEqual(plan.fallback.args, [
|
||||
'start', '--cwd', '/tmp/example workspace', '--', 'printf', 'hello world',
|
||||
]);
|
||||
assert.deepStrictEqual(plan.probe, { command: 'wezterm', args: ['--version'] });
|
||||
});
|
||||
|
||||
check('builds standalone recovery with stock config and a new process', () => {
|
||||
const plan = buildLaunchPlan(baseOptions({ mode: 'recover' }));
|
||||
assert.strictEqual(plan.launchMode, 'recover');
|
||||
assert.deepStrictEqual(plan.args, [
|
||||
'--skip-config', 'start', '--always-new-process', '--cwd', '/tmp/example workspace', '--',
|
||||
'printf', 'hello world',
|
||||
]);
|
||||
assert.strictEqual(plan.fallback, null);
|
||||
});
|
||||
|
||||
check('returns an actionable plan for an unsupported terminal', () => {
|
||||
const plan = buildLaunchPlan(baseOptions({ terminal: 'alacritty' }));
|
||||
assert.strictEqual(plan.ok, false);
|
||||
assert.strictEqual(plan.reason, 'unsupported-terminal');
|
||||
assert.match(plan.action, /--terminal wezterm/);
|
||||
assert.match(plan.action, /Install WezTerm/);
|
||||
assert.strictEqual(plan.command, null);
|
||||
});
|
||||
|
||||
check('detects an available terminal with shell disabled', () => {
|
||||
const calls = [];
|
||||
const capability = detectTerminalCapability(buildLaunchPlan(baseOptions()), (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return { status: 0, stdout: 'wezterm 20260101\n', stderr: '' };
|
||||
});
|
||||
assert.deepStrictEqual(calls.map(({ command, args }) => ({ command, args })), [
|
||||
{ command: 'wezterm', args: ['--version'] },
|
||||
]);
|
||||
assert.strictEqual(calls[0].options.shell, false);
|
||||
assert.strictEqual(calls[0].options.timeout, 10_000);
|
||||
assert.strictEqual(calls[0].options.killSignal, 'SIGTERM');
|
||||
assert.strictEqual(capability.available, true);
|
||||
assert.strictEqual(capability.version, 'wezterm 20260101');
|
||||
});
|
||||
|
||||
check('reports actionable missing and unsupported capabilities', () => {
|
||||
const missing = detectTerminalCapability(buildLaunchPlan(baseOptions()), () => ({
|
||||
error: Object.assign(new Error('spawn wezterm ENOENT'), { code: 'ENOENT' }),
|
||||
status: null,
|
||||
}));
|
||||
assert.strictEqual(missing.supported, true);
|
||||
assert.strictEqual(missing.available, false);
|
||||
assert.match(missing.action, /Install WezTerm/);
|
||||
|
||||
const unsupported = detectTerminalCapability(
|
||||
buildLaunchPlan(baseOptions({ terminal: 'kitty' })),
|
||||
() => { throw new Error('must not probe unsupported adapters'); }
|
||||
);
|
||||
assert.strictEqual(unsupported.supported, false);
|
||||
assert.match(unsupported.action, /--terminal wezterm/);
|
||||
});
|
||||
|
||||
check('classifies probe timeouts and non-zero exits as probe failures', () => {
|
||||
const timedOut = detectTerminalCapability(buildLaunchPlan(baseOptions()), () => ({
|
||||
error: Object.assign(new Error('spawnSync wezterm ETIMEDOUT'), { code: 'ETIMEDOUT' }),
|
||||
status: null,
|
||||
}));
|
||||
assert.strictEqual(timedOut.available, false);
|
||||
assert.strictEqual(timedOut.reason, 'probe-failed');
|
||||
|
||||
const nonZero = detectTerminalCapability(
|
||||
buildLaunchPlan(baseOptions()),
|
||||
() => ({ status: 3, stdout: '', stderr: 'broken' })
|
||||
);
|
||||
assert.strictEqual(nonZero.available, false);
|
||||
assert.strictEqual(nonZero.reason, 'probe-failed');
|
||||
assert.match(nonZero.detail, /status 3/);
|
||||
});
|
||||
|
||||
check('refuses to launch when the terminal is unavailable', () => {
|
||||
let spawned = false;
|
||||
assert.throws(
|
||||
() => launch(buildLaunchPlan(baseOptions()), {
|
||||
spawnSync() {
|
||||
return { error: new Error('spawn wezterm ENOENT'), status: null };
|
||||
},
|
||||
spawn() {
|
||||
spawned = true;
|
||||
return { unref() {} };
|
||||
},
|
||||
}),
|
||||
/not-installed/
|
||||
);
|
||||
assert.strictEqual(spawned, false);
|
||||
});
|
||||
|
||||
check('uses the WezTerm mux when available', () => {
|
||||
const syncCalls = [];
|
||||
const asyncCalls = [];
|
||||
const result = launch(buildLaunchPlan(baseOptions()), {
|
||||
spawnSync(command, args, options) {
|
||||
syncCalls.push({ command, args, options });
|
||||
return syncCalls.length === 1
|
||||
? { status: 0, stdout: 'wezterm 1\n', stderr: '' }
|
||||
: { status: 0, stdout: '42\n', stderr: '' };
|
||||
},
|
||||
spawn(...args) { asyncCalls.push(args); },
|
||||
});
|
||||
assert.strictEqual(result.strategy, 'mux');
|
||||
assert.strictEqual(syncCalls.length, 2);
|
||||
assert.strictEqual(syncCalls[1].options.shell, false);
|
||||
assert.strictEqual(asyncCalls.length, 0);
|
||||
});
|
||||
|
||||
check('falls back to a detached process and unreferences it', () => {
|
||||
const spawnCalls = [];
|
||||
const syncCalls = [];
|
||||
let unrefCount = 0;
|
||||
const result = launch(buildLaunchPlan(baseOptions()), {
|
||||
spawnSync(command, args, options) {
|
||||
syncCalls.push({ command, args, options });
|
||||
if (args[0] === '--version') return { status: 0, stdout: 'wezterm 1\n', stderr: '' };
|
||||
return { status: 1, stdout: '', stderr: 'mux unavailable' };
|
||||
},
|
||||
spawn(command, args, options) {
|
||||
spawnCalls.push({ command, args, options });
|
||||
return { unref() { unrefCount += 1; } };
|
||||
},
|
||||
});
|
||||
assert.strictEqual(result.strategy, 'detached-fallback');
|
||||
assert.strictEqual(spawnCalls[0].options.detached, true);
|
||||
assert.strictEqual(spawnCalls[0].options.shell, false);
|
||||
assert.strictEqual(spawnCalls[0].options.stdio, 'ignore');
|
||||
assert.strictEqual(unrefCount, 1);
|
||||
assert.strictEqual(syncCalls[1].options.timeout, 10_000);
|
||||
assert.strictEqual(syncCalls[1].options.killSignal, 'SIGTERM');
|
||||
assert.match(result.muxFailure, /status 1.*mux unavailable/);
|
||||
});
|
||||
|
||||
check('surfaces mux fallback failures in human and JSON launch output', () => {
|
||||
const plan = buildLaunchPlan(baseOptions());
|
||||
const result = {
|
||||
strategy: 'detached-fallback',
|
||||
capability: { available: true, terminal: 'wezterm', version: 'wezterm 1' },
|
||||
muxFailure: 'wezterm cli spawn exited with status 1: mux unavailable',
|
||||
};
|
||||
|
||||
const human = formatLaunchResult(plan, result, false);
|
||||
assert.match(human, /Open printf in wezterm using mux mode\./);
|
||||
assert.match(human, /Mux launch failed: .*status 1.*mux unavailable/);
|
||||
|
||||
const json = JSON.parse(formatLaunchResult(plan, result, true));
|
||||
assert.strictEqual(json.executable, 'printf');
|
||||
assert.strictEqual(json.strategy, 'detached-fallback');
|
||||
assert.strictEqual(json.muxFailure, result.muxFailure);
|
||||
});
|
||||
|
||||
check('preserves existing human launch output for non-fallback strategies', () => {
|
||||
const plan = buildLaunchPlan(baseOptions());
|
||||
const result = {
|
||||
strategy: 'mux',
|
||||
capability: { available: true, terminal: 'wezterm', version: 'wezterm 1' },
|
||||
};
|
||||
|
||||
assert.strictEqual(
|
||||
formatLaunchResult(plan, result, false),
|
||||
'Open printf in wezterm using mux mode.\n'
|
||||
);
|
||||
});
|
||||
|
||||
check('launches recovery directly as a detached process', () => {
|
||||
const syncArgs = [];
|
||||
const spawnCalls = [];
|
||||
const result = launch(buildLaunchPlan(baseOptions({ mode: 'recover' })), {
|
||||
spawnSync(command, args) {
|
||||
syncArgs.push(args);
|
||||
return { status: 0, stdout: 'wezterm 1\n', stderr: '' };
|
||||
},
|
||||
spawn(command, args, options) {
|
||||
spawnCalls.push({ command, args, options });
|
||||
return { unref() {} };
|
||||
},
|
||||
});
|
||||
assert.strictEqual(result.strategy, 'detached-recover');
|
||||
assert.deepStrictEqual(syncArgs, [['--version']]);
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
assert.ok(spawnCalls[0].args.includes('--always-new-process'));
|
||||
});
|
||||
|
||||
check('reports synchronous detached spawn failures actionably', () => {
|
||||
assert.throws(
|
||||
() => launch(buildLaunchPlan(baseOptions({ mode: 'recover' })), {
|
||||
spawnSync() {
|
||||
return { status: 0, stdout: 'wezterm 1\n', stderr: '' };
|
||||
},
|
||||
spawn() {
|
||||
throw new Error('EACCES');
|
||||
},
|
||||
}),
|
||||
/Unable to start wezterm: EACCES/
|
||||
);
|
||||
});
|
||||
|
||||
check('routes asynchronous detached spawn errors to the caller', () => {
|
||||
let errorHandler;
|
||||
let reportedError;
|
||||
launch(buildLaunchPlan(baseOptions({ mode: 'recover' })), {
|
||||
spawnSync() {
|
||||
return { status: 0, stdout: 'wezterm 1\n', stderr: '' };
|
||||
},
|
||||
spawn() {
|
||||
return {
|
||||
once(event, handler) {
|
||||
if (event === 'error') errorHandler = handler;
|
||||
},
|
||||
unref() {},
|
||||
};
|
||||
},
|
||||
onDetachedError(error) {
|
||||
reportedError = error;
|
||||
},
|
||||
});
|
||||
assert.strictEqual(typeof errorHandler, 'function');
|
||||
errorHandler(new Error('terminal disappeared'));
|
||||
assert.match(reportedError.message, /Unable to start wezterm: terminal disappeared/);
|
||||
});
|
||||
|
||||
check('sets a failing exit code for an unhandled asynchronous spawn error', () => {
|
||||
let errorHandler;
|
||||
let stderr = '';
|
||||
const originalExitCode = process.exitCode;
|
||||
const originalWrite = process.stderr.write;
|
||||
try {
|
||||
process.exitCode = undefined;
|
||||
process.stderr.write = chunk => {
|
||||
stderr += chunk;
|
||||
return true;
|
||||
};
|
||||
launch(buildLaunchPlan(baseOptions({ mode: 'recover' })), {
|
||||
spawnSync() {
|
||||
return { status: 0, stdout: 'wezterm 1\n', stderr: '' };
|
||||
},
|
||||
spawn() {
|
||||
return {
|
||||
once(event, handler) {
|
||||
if (event === 'error') errorHandler = handler;
|
||||
},
|
||||
unref() {},
|
||||
};
|
||||
},
|
||||
});
|
||||
errorHandler(new Error('terminal disappeared'));
|
||||
assert.strictEqual(process.exitCode, 1);
|
||||
assert.match(stderr, /Unable to start wezterm: terminal disappeared/);
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
process.exitCode = originalExitCode;
|
||||
}
|
||||
});
|
||||
|
||||
check('emits a machine-readable dry-run without launching', () => {
|
||||
const result = runCli([
|
||||
'--dry-run', '--json', '--cwd', '/tmp/demo', '--', 'ssh', '-t', 'example.test', 'echo $HOME; id',
|
||||
]);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const plan = JSON.parse(result.stdout);
|
||||
assert.strictEqual(plan.executable, 'ssh');
|
||||
assert.deepStrictEqual(plan.argv, ['-t', 'example.test', 'echo $HOME; id']);
|
||||
assert.strictEqual(plan.dryRun, true);
|
||||
assert.strictEqual(result.stderr, '');
|
||||
});
|
||||
|
||||
check('keeps the CLI non-launching unless --launch is explicit', () => {
|
||||
const result = runCli(['--json', '--', 'printf', 'safe']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(JSON.parse(result.stdout).dryRun, true);
|
||||
});
|
||||
|
||||
check('supports terminal capability detection without a command', () => {
|
||||
const result = runCli(['--detect', '--terminal', 'unsupported', '--json']);
|
||||
assert.strictEqual(result.status, 1);
|
||||
const capability = JSON.parse(result.stdout);
|
||||
assert.strictEqual(capability.supported, false);
|
||||
assert.match(capability.action, /--terminal wezterm/);
|
||||
});
|
||||
|
||||
check('documents the safe reusable workflow in concise skill metadata', () => {
|
||||
const skill = fs.readFileSync(path.join(SKILL_ROOT, 'SKILL.md'), 'utf8');
|
||||
const frontmatterMatch = skill.match(/^---\n([\s\S]*?)\n---/);
|
||||
assert.ok(frontmatterMatch, 'SKILL.md must start with a YAML frontmatter block');
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const frontmatterKeys = frontmatter
|
||||
.split('\n')
|
||||
.filter(line => /^[a-z][a-z-]*:/.test(line))
|
||||
.map(line => line.split(':')[0]);
|
||||
assert.deepStrictEqual(frontmatterKeys, ['name', 'description']);
|
||||
assert.match(frontmatter, /executable.*argument array/i);
|
||||
assert.match(frontmatter, /visible terminal/i);
|
||||
assert.match(skill, /shell:\s*false/);
|
||||
assert.match(skill, /--skip-config start --always-new-process/);
|
||||
assert.match(skill, /--launch/);
|
||||
assert.match(skill, /inherits the full environment[\s\S]*does not filter/i);
|
||||
assert.ok(!skill.includes('[TODO'));
|
||||
assert.ok(!fs.existsSync(path.join(SKILL_ROOT, 'README.md')));
|
||||
});
|
||||
|
||||
check('keeps generated OpenAI metadata minimal and valid', () => {
|
||||
const yaml = fs.readFileSync(path.join(SKILL_ROOT, 'agents', 'openai.yaml'), 'utf8');
|
||||
const keys = [...yaml.matchAll(/^\s{2}([a-z_]+):/gm)].map(match => match[1]);
|
||||
const shortDescriptionMatch = yaml.match(/short_description:\s*"([^"]+)"/);
|
||||
assert.ok(shortDescriptionMatch, 'openai.yaml must define a quoted short_description');
|
||||
const shortDescription = shortDescriptionMatch[1];
|
||||
assert.deepStrictEqual(keys, ['display_name', 'short_description', 'default_prompt']);
|
||||
assert.ok(shortDescription.length >= 25 && shortDescription.length <= 64);
|
||||
assert.match(yaml, /default_prompt:.*\$terminal-opener/);
|
||||
});
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
runTests();
|
||||
Reference in New Issue
Block a user