mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-02 05:24:34 +08:00
feat(easycode): add project skills and commands (#1352)
* feat: add Easy Code as a supported AI tool - Register 'easycode' in AI_TOOLS (config.ts) with skillsDir '.easycode' - Add EasycodeAdapter (easycode.ts): generates TOML commands at .easycode/commands/opsx/<id>.toml, matching Easy Code's native format - Export easycodeAdapter from adapters/index.ts - Register easycodeAdapter in CommandAdapterRegistry Easy Code (https://easycode.ai) is a terminal-based AI coding assistant. Its commands use TOML with a description field and a prompt multiline literal string, distinct from the Markdown/YAML frontmatter format used by most other tools. Tested locally: `openspec init --tools easycode` generates 5 SKILL.md files and 5 .toml command files in the expected directory structure. * fix: robust TOML serialization for Easy Code adapter Per code review: the original formatFile had unsafe manual escaping that would corrupt output for descriptions containing backslashes or control characters, and prompt bodies containing triple-single-quotes. Changes: - Add src/core/command-generation/toml.ts with two helpers: escapeTOMLBasicString — escapes \, ", \n, \r, \t for TOML basic strings (double-quoted) escapeTOMLMultilineString — escapes \ and \r, and breaks any run of 3+ consecutive " (lookahead match) for TOML basic multiline strings - Switch prompt block from triple-single-quote literal string (''') to triple-double-quote basic multiline string ("""), which allows full escape sequence support and handles arbitrary body content - Update easycode.ts to use both helpers * fix: escape disallowed control characters in TOML helpers Per code review: TOML basic strings forbid U+0000-U+0008, U+000B-U+000C, U+000E-U+001F, and U+007F. Add escapeControlChars() helper that replaces these with \uXXXX sequences, and apply it in both escapeTOMLBasicString and escapeTOMLMultilineString after their named-escape passes. * feat(easycode): harden project skills and command integration --------- Co-authored-by: Trae <konghaifeng@cmcm.com> Co-authored-by: Clay Good <hi@claygood.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Trae
Clay Good
Claude Opus 5.5
parent
a7f08b8a46
commit
d1642cb58c
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@fission-ai/openspec": minor
|
||||
---
|
||||
|
||||
Add EasyCode support to init and update, with project-local skills and TOML commands invoked as `/opsx:<id>`.
|
||||
@@ -31,6 +31,7 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).
|
||||
| Cursor | `cursor` | `.cursor/skills/` | `/openspec-apply-change` | `.cursor/commands/` | `/opsx-apply` |
|
||||
| DeepSeek Harness | `dsh` | `.dsh/skills/` | `/openspec-apply-change` | none | none |
|
||||
| Devin Desktop (formerly Windsurf) | `devin` | `.devin/skills/` | `/openspec-apply-change` | `.devin/workflows/` | `/opsx-apply` |
|
||||
| EasyCode | `easycode` | `.easycode/skills/` | `/openspec-apply-change` | `.easycode/commands/opsx/` | `/opsx:apply` |
|
||||
| Factory Droid | `factory` | `.factory/skills/` | `/openspec-apply-change` | `.factory/commands/` | `/opsx-apply` |
|
||||
| ForgeCode | `forgecode` | `.forge/skills/` | `/openspec-apply-change` | none | none |
|
||||
| Gemini CLI | `gemini` | `.gemini/skills/` | `/openspec-apply-change` | `.gemini/commands/opsx/` | `/opsx:apply` |
|
||||
@@ -60,9 +61,9 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).
|
||||
- **Skill invocation**: whether a tool registers skills as typed entries is the tool's
|
||||
own behavior. The column shows the spelling OpenSpec uses in generated files and in
|
||||
the hint init prints. Check your tool's docs if typing it does nothing.
|
||||
- **Command file formats**: most tools take `.md` command files. Gemini CLI takes
|
||||
`.toml`, Continue `.prompt`, Kiro and GitHub Copilot `.prompt.md`. The spelling you
|
||||
type is the same either way.
|
||||
- **Command file formats**: most tools take `.md` command files. EasyCode and Gemini
|
||||
CLI take `.toml`, Continue `.prompt`, Kiro and GitHub Copilot `.prompt.md`. The
|
||||
spelling you type is the same either way.
|
||||
|
||||
## Per-tool notes
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* EasyCode Command Adapter
|
||||
*
|
||||
* Formats commands for OrionStarAI/EasyCode using its TOML command format.
|
||||
* https://github.com/OrionStarAI/EasyCode
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { CommandContent, ToolCommandAdapter } from '../types.js';
|
||||
import { escapeTomlBasicString, escapeTomlMultilineBasicString } from '../toml.js';
|
||||
|
||||
/**
|
||||
* EasyCode adapter for command generation.
|
||||
* File path: .easycode/commands/opsx/<id>.toml
|
||||
*
|
||||
* Format:
|
||||
* description = "<basic-string>" single-line, backslash/quote-safe
|
||||
* prompt = """<multiline-string>""" multiline, backslash/triple-quote-safe
|
||||
*/
|
||||
export const easycodeAdapter: ToolCommandAdapter = {
|
||||
toolId: 'easycode',
|
||||
|
||||
getFilePath(commandId: string): string {
|
||||
return path.join('.easycode', 'commands', 'opsx', `${commandId}.toml`);
|
||||
},
|
||||
|
||||
formatFile(content: CommandContent): string {
|
||||
const safeDesc = escapeTomlBasicString(content.description);
|
||||
const safeBody = escapeTomlMultilineBasicString(content.body);
|
||||
return `description = "${safeDesc}"\n\nprompt = """\n${safeBody}\n"""\n`;
|
||||
},
|
||||
};
|
||||
@@ -7,43 +7,7 @@
|
||||
import path from 'path';
|
||||
import type { CommandContent, ToolCommandAdapter } from '../types.js';
|
||||
|
||||
/**
|
||||
* Control characters (C0 except tab/newline/carriage return, plus DEL) are
|
||||
* invalid inside TOML strings and must be written as escapes.
|
||||
*/
|
||||
const TOML_CONTROL_CHARS = new RegExp('[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]', 'g');
|
||||
|
||||
/**
|
||||
* TOML basic strings are escape-active: a backslash or double quote in the
|
||||
* value breaks the file if written raw. Newlines cannot appear in a
|
||||
* single-line basic string at all, so they are escaped too.
|
||||
*/
|
||||
function escapeTomlBasicString(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t')
|
||||
.replace(TOML_CONTROL_CHARS, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiline basic strings keep raw newlines and tabs, but backslashes are
|
||||
* still escape-active, any run of three quotes would end the string, and the
|
||||
* same control characters are invalid as in single-line basic strings — a
|
||||
* lone carriage return included (only LF and CRLF may appear raw; CRLF is
|
||||
* normalized away so the emitted file is single-convention). Escapes are
|
||||
* introduced after backslash-doubling so they are not re-doubled.
|
||||
*/
|
||||
function escapeTomlMultilineBasicString(value: string): string {
|
||||
return value
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"""/g, '""\\"')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(TOML_CONTROL_CHARS, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
||||
}
|
||||
import { escapeTomlBasicString, escapeTomlMultilineBasicString } from '../toml.js';
|
||||
|
||||
/**
|
||||
* Gemini adapter for command generation.
|
||||
|
||||
@@ -18,6 +18,7 @@ export { costrictAdapter } from './costrict.js';
|
||||
export { crushAdapter } from './crush.js';
|
||||
export { cursorAdapter } from './cursor.js';
|
||||
export { devinAdapter } from './devin.js';
|
||||
export { easycodeAdapter } from './easycode.js';
|
||||
export { factoryAdapter } from './factory.js';
|
||||
export { geminiAdapter } from './gemini.js';
|
||||
export { githubCopilotAdapter } from './github-copilot.js';
|
||||
|
||||
@@ -20,6 +20,7 @@ import { continueAdapter } from './adapters/continue.js';
|
||||
import { costrictAdapter } from './adapters/costrict.js';
|
||||
import { crushAdapter } from './adapters/crush.js';
|
||||
import { cursorAdapter } from './adapters/cursor.js';
|
||||
import { easycodeAdapter } from './adapters/easycode.js';
|
||||
import { factoryAdapter } from './adapters/factory.js';
|
||||
import { geminiAdapter } from './adapters/gemini.js';
|
||||
import { githubCopilotAdapter } from './adapters/github-copilot.js';
|
||||
@@ -61,6 +62,7 @@ export class CommandAdapterRegistry {
|
||||
CommandAdapterRegistry.register(costrictAdapter);
|
||||
CommandAdapterRegistry.register(crushAdapter);
|
||||
CommandAdapterRegistry.register(cursorAdapter);
|
||||
CommandAdapterRegistry.register(easycodeAdapter);
|
||||
CommandAdapterRegistry.register(factoryAdapter);
|
||||
CommandAdapterRegistry.register(geminiAdapter);
|
||||
CommandAdapterRegistry.register(githubCopilotAdapter);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Shared TOML string escaping for command adapters.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Control characters (C0 except tab/newline/carriage return, plus DEL) are
|
||||
* invalid inside TOML strings and must be written as escapes.
|
||||
*/
|
||||
const TOML_CONTROL_CHARS = new RegExp('[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]', 'g');
|
||||
|
||||
/**
|
||||
* TOML basic strings are escape-active: a backslash or double quote in the
|
||||
* value breaks the file if written raw. Newlines cannot appear in a
|
||||
* single-line basic string at all, so they are escaped too.
|
||||
*/
|
||||
export function escapeTomlBasicString(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t')
|
||||
.replace(TOML_CONTROL_CHARS, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiline basic strings keep raw newlines and tabs, but backslashes are
|
||||
* still escape-active, any run of three quotes would end the string, and the
|
||||
* same control characters are invalid as in single-line basic strings — a
|
||||
* lone carriage return included (only LF and CRLF may appear raw; CRLF is
|
||||
* normalized away so the emitted file is single-convention). Escapes are
|
||||
* introduced after backslash-doubling so they are not re-doubled.
|
||||
*/
|
||||
export function escapeTomlMultilineBasicString(value: string): string {
|
||||
return value
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"""/g, '""\\"')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(TOML_CONTROL_CHARS, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export const AI_TOOLS: AIToolOption[] = [
|
||||
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec', requiresIdeRestart: true },
|
||||
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush', skillsDir: '.crush' },
|
||||
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor', requiresIdeRestart: true },
|
||||
{ name: 'EasyCode', value: 'easycode', available: true, successLabel: 'EasyCode', skillsDir: '.easycode' },
|
||||
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },
|
||||
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
|
||||
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'], requiresIdeRestart: true },
|
||||
|
||||
@@ -24,14 +24,17 @@ describe('available-tools', () => {
|
||||
expect(tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect a single tool directory', async () => {
|
||||
await fs.mkdir(path.join(testDir, '.claude'), { recursive: true });
|
||||
it.each([
|
||||
['claude', 'Claude Code'],
|
||||
['easycode', 'EasyCode'],
|
||||
])('should detect a single %s tool directory', async (toolId, name) => {
|
||||
await fs.mkdir(path.join(testDir, `.${toolId}`), { recursive: true });
|
||||
|
||||
const tools = getAvailableTools(testDir);
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].value).toBe('claude');
|
||||
expect(tools[0].name).toBe('Claude Code');
|
||||
expect(tools[0].skillsDir).toBe('.claude');
|
||||
expect(tools[0].value).toBe(toolId);
|
||||
expect(tools[0].name).toBe(name);
|
||||
expect(tools[0].skillsDir).toBe(`.${toolId}`);
|
||||
});
|
||||
|
||||
it('should identify the Bob integration by its product name', async () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { costrictAdapter } from '../../../src/core/command-generation/adapters/c
|
||||
import { crushAdapter } from '../../../src/core/command-generation/adapters/crush.js';
|
||||
import { cursorAdapter } from '../../../src/core/command-generation/adapters/cursor.js';
|
||||
import { devinAdapter } from '../../../src/core/command-generation/adapters/devin.js';
|
||||
import { easycodeAdapter } from '../../../src/core/command-generation/adapters/easycode.js';
|
||||
import { factoryAdapter } from '../../../src/core/command-generation/adapters/factory.js';
|
||||
import { geminiAdapter } from '../../../src/core/command-generation/adapters/gemini.js';
|
||||
import { githubCopilotAdapter } from '../../../src/core/command-generation/adapters/github-copilot.js';
|
||||
@@ -557,18 +558,14 @@ describe('command-generation/adapters', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('geminiAdapter', () => {
|
||||
it('should have correct toolId', () => {
|
||||
expect(geminiAdapter.toolId).toBe('gemini');
|
||||
});
|
||||
|
||||
describe.each([geminiAdapter, easycodeAdapter])('$toolId TOML adapter', (adapter) => {
|
||||
it('should generate correct file path with .toml extension', () => {
|
||||
const filePath = geminiAdapter.getFilePath('explore');
|
||||
expect(filePath).toBe(path.join('.gemini', 'commands', 'opsx', 'explore.toml'));
|
||||
const filePath = adapter.getFilePath('explore');
|
||||
expect(filePath).toBe(path.join(`.${adapter.toolId}`, 'commands', 'opsx', 'explore.toml'));
|
||||
});
|
||||
|
||||
it('should format file in TOML format', () => {
|
||||
const output = geminiAdapter.formatFile(sampleContent);
|
||||
const output = adapter.formatFile(sampleContent);
|
||||
expect(output).toContain('description = "Enter explore mode for thinking"');
|
||||
expect(output).toContain('prompt = """');
|
||||
expect(output).toContain('This is the command body.');
|
||||
@@ -576,7 +573,7 @@ describe('command-generation/adapters', () => {
|
||||
});
|
||||
|
||||
it('escapes TOML-active characters in the description', () => {
|
||||
const output = geminiAdapter.formatFile({
|
||||
const output = adapter.formatFile({
|
||||
...sampleContent,
|
||||
description: 'Say "hi" to C:\\Users and\nmore',
|
||||
});
|
||||
@@ -590,7 +587,7 @@ describe('command-generation/adapters', () => {
|
||||
|
||||
it('keeps the prompt a single multiline string when the body carries fences and backslashes', () => {
|
||||
const body = 'Windows path C:\\temp and a quote run: """ done';
|
||||
const output = geminiAdapter.formatFile({ ...sampleContent, body });
|
||||
const output = adapter.formatFile({ ...sampleContent, body });
|
||||
// Backslashes must be escaped and no unescaped quote-triple may remain,
|
||||
// or the """ delimiter ends the prompt early.
|
||||
expect(output).toContain('C:\\\\temp');
|
||||
@@ -604,6 +601,9 @@ describe('command-generation/adapters', () => {
|
||||
// must yield a file smol-toml accepts, and the parsed prompt must
|
||||
// round-trip to the original (modulo CRLF normalization).
|
||||
const HOSTILE_BODIES: Array<[string, string, string]> = [
|
||||
['empty content', '', ''],
|
||||
['leading newlines', '\n\nbody', '\n\nbody'],
|
||||
['literal triple quotes', "literal ''' body", "literal ''' body"],
|
||||
['control characters', 'null:\u0000 vt:\u000b ff:\u000c end', 'null:\u0000 vt:\u000b ff:\u000c end'],
|
||||
// A lone CR is illegal raw in a multiline basic string (only LF and
|
||||
// CRLF may appear); Python tomllib rejects it — so must never be
|
||||
@@ -617,12 +617,43 @@ describe('command-generation/adapters', () => {
|
||||
|
||||
for (const [label, body, expected] of HOSTILE_BODIES) {
|
||||
it(`emits parseable TOML for a body with ${label}`, () => {
|
||||
const output = geminiAdapter.formatFile({ ...sampleContent, body });
|
||||
const output = adapter.formatFile({ ...sampleContent, body });
|
||||
const parsed = parseToml(output) as { description: string; prompt: string };
|
||||
expect(parsed.prompt).toBe(`${expected}\n`);
|
||||
expect(parsed.description).toBe(sampleContent.description);
|
||||
});
|
||||
}
|
||||
|
||||
it('round-trips all C0 controls and DEL in descriptions and prompts', () => {
|
||||
const controls = Array.from({ length: 32 }, (_, i) => String.fromCharCode(i)).join('') + '\u007f';
|
||||
const parsed = parseToml(adapter.formatFile({
|
||||
...sampleContent,
|
||||
description: controls,
|
||||
body: controls,
|
||||
}));
|
||||
expect(parsed.description).toBe(controls);
|
||||
expect(parsed.prompt).toBe(`${controls}\n`);
|
||||
});
|
||||
|
||||
it('round-trips quote runs beside backslashes and string boundaries', () => {
|
||||
for (let length = 1; length <= 12; length++) {
|
||||
const quotes = '"'.repeat(length);
|
||||
const body = `${quotes}\\${quotes}\n${quotes}`;
|
||||
const parsed = parseToml(adapter.formatFile({ ...sampleContent, description: body, body }));
|
||||
expect(parsed.description).toBe(body);
|
||||
expect(parsed.prompt).toBe(`${body}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates parseable commands for every workflow', () => {
|
||||
for (const content of getCommandContents()) {
|
||||
const generated = generateCommand(content, adapter);
|
||||
expect(generated.path).toBe(adapter.getFilePath(content.id));
|
||||
const parsed = parseToml(generated.fileContent);
|
||||
expect(parsed.description).toBe(content.description);
|
||||
expect(parsed.prompt).toBe(`${content.body.replace(/\r\n/g, '\n')}\n`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('githubCopilotAdapter', () => {
|
||||
@@ -1268,7 +1299,7 @@ describe('command-generation/adapters', () => {
|
||||
const adapters = [
|
||||
amazonQAdapter, antigravityAdapter, atomcodeAdapter, auggieAdapter, bobAdapter, clineAdapter,
|
||||
codebuddyAdapter, continueAdapter, costrictAdapter,
|
||||
crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter, gigacodeAdapter,
|
||||
crushAdapter, easycodeAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter, gigacodeAdapter,
|
||||
iflowAdapter, kilocodeAdapter, kiroAdapter, lingmaAdapter, ohMyPiAdapter,
|
||||
opencodeAdapter, piAdapter, qoderAdapter, qwenAdapter, roocodeAdapter,
|
||||
traeAdapter, zcodeAdapter
|
||||
@@ -1326,7 +1357,7 @@ describe('command-generation/adapters', () => {
|
||||
// Derived from the registry, not hand-listed: a newly registered adapter
|
||||
// must be covered by default. Adding one that emits no YAML frontmatter is
|
||||
// then a deliberate act of adding it here.
|
||||
const NON_YAML_ADAPTERS = ['cline', 'command-code', 'kilocode', 'roocode', 'gemini'];
|
||||
const NON_YAML_ADAPTERS = ['cline', 'command-code', 'kilocode', 'roocode', 'gemini', 'easycode'];
|
||||
const yamlAdapters = CommandAdapterRegistry.getAll().filter(
|
||||
(adapter) => !NON_YAML_ADAPTERS.includes(adapter.toolId)
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ALL_WORKFLOWS } from '../../../src/core/profiles.js';
|
||||
* tool from its own `getFilePath`. A new adapter that lands on the wrong side
|
||||
* of the split fails here, which is the point.
|
||||
*/
|
||||
const NAMESPACED_TOOLS = ['claude', 'codebuddy', 'crush', 'gemini', 'lingma', 'qoder', 'zcode'];
|
||||
const NAMESPACED_TOOLS = ['claude', 'codebuddy', 'crush', 'easycode', 'gemini', 'lingma', 'qoder', 'zcode'];
|
||||
|
||||
/**
|
||||
* Tools whose command name is wrapped in something other than a slash. The
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('command-generation/registry', () => {
|
||||
};
|
||||
|
||||
// Tools that don't use YAML frontmatter (markdown headers or TOML or plain)
|
||||
const noYamlFrontmatter = ['cline', 'command-code', 'kilocode', 'roocode', 'gemini'];
|
||||
const noYamlFrontmatter = ['cline', 'command-code', 'kilocode', 'roocode', 'gemini', 'easycode'];
|
||||
|
||||
const adapters = CommandAdapterRegistry.getAll();
|
||||
for (const adapter of adapters) {
|
||||
|
||||
+39
-7
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { InitCommand } from '../../src/core/init.js';
|
||||
import { saveGlobalConfig, getGlobalConfig } from '../../src/core/global-config.js';
|
||||
@@ -1711,18 +1712,49 @@ describe('InitCommand', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('should generate Gemini CLI commands as TOML files', async () => {
|
||||
const initCommand = new InitCommand({ tools: 'gemini', force: true });
|
||||
it.each(['gemini', 'easycode'])('should generate %s core skills and parseable TOML commands', async (toolId) => {
|
||||
const initCommand = new InitCommand({ tools: toolId, force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const cmdFile = path.join(testDir, '.gemini', 'commands', 'opsx', 'explore.toml');
|
||||
expect(await fileExists(cmdFile)).toBe(true);
|
||||
const commandsDir = path.join(testDir, `.${toolId}`, 'commands', 'opsx');
|
||||
const commandIds = ['apply', 'archive', 'explore', 'propose', 'sync', 'update'];
|
||||
expect((await fs.readdir(commandsDir)).sort()).toEqual(commandIds.map((id) => `${id}.toml`));
|
||||
for (const commandId of commandIds) {
|
||||
const content = parseToml(await fs.readFile(path.join(commandsDir, `${commandId}.toml`), 'utf-8'));
|
||||
expect(content.description).toEqual(expect.any(String));
|
||||
expect(content.prompt).toContain('openspec');
|
||||
}
|
||||
|
||||
const content = await fs.readFile(cmdFile, 'utf-8');
|
||||
expect(content).toContain('description =');
|
||||
expect(content).toContain('prompt =');
|
||||
const skillFile = path.join(testDir, `.${toolId}`, 'skills', 'openspec-explore', 'SKILL.md');
|
||||
expect(await fs.readFile(skillFile, 'utf-8')).toContain('name: openspec-explore');
|
||||
});
|
||||
|
||||
it.each(['both', 'skills', 'commands'] as const)(
|
||||
'should honor EasyCode custom workflows with delivery=%s',
|
||||
async (delivery) => {
|
||||
saveGlobalConfig({ featureFlags: {}, profile: 'custom', delivery, workflows: ['explore', 'new'] });
|
||||
await new InitCommand({ tools: 'easycode', force: true }).execute(testDir);
|
||||
|
||||
const skillsDir = path.join(testDir, '.easycode', 'skills');
|
||||
for (const skillName of ['openspec-explore', 'openspec-new-change']) {
|
||||
expect(await fileExists(path.join(skillsDir, skillName, 'SKILL.md'))).toBe(delivery !== 'commands');
|
||||
}
|
||||
expect(await fileExists(path.join(skillsDir, 'openspec-propose', 'SKILL.md'))).toBe(false);
|
||||
|
||||
const commandsDir = path.join(testDir, '.easycode', 'commands', 'opsx');
|
||||
if (delivery === 'skills') {
|
||||
expect(await directoryExists(commandsDir)).toBe(false);
|
||||
} else {
|
||||
expect((await fs.readdir(commandsDir)).sort()).toEqual(['explore.toml', 'new.toml']);
|
||||
for (const filename of ['explore.toml', 'new.toml']) {
|
||||
const content = parseToml(await fs.readFile(path.join(commandsDir, filename), 'utf-8'));
|
||||
expect(content.description).toEqual(expect.any(String));
|
||||
expect(content.prompt).toContain('openspec');
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('should generate Devin workflows for the retired windsurf id', async () => {
|
||||
const initCommand = new InitCommand({ tools: 'windsurf', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
@@ -398,6 +398,7 @@ Content here
|
||||
// cline — a directory that is not the tool's skillsDir at all.
|
||||
it.each([
|
||||
['gemini', path.join('.gemini', 'commands', 'opsx', 'explore.toml')],
|
||||
['easycode', path.join('.easycode', 'commands', 'opsx', 'explore.toml')],
|
||||
['cursor', path.join('.cursor', 'commands', 'opsx-explore.md')],
|
||||
['cline', path.join('.clinerules', 'workflows', 'opsx-explore.md')],
|
||||
])('should fingerprint commands-only %s installs', async (toolId, explorePath) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { generateCopilotSetupSteps, persistCopilotCloudOptIn } from '../../src/c
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
|
||||
const { confirmMock, searchableMultiSelectMock, interactiveState } = vi.hoisted(() => ({
|
||||
confirmMock: vi.fn(),
|
||||
@@ -1608,6 +1609,35 @@ metadata:
|
||||
expect(content).toContain('**Provided arguments**: $ARGUMENTS');
|
||||
});
|
||||
|
||||
it.each(['both', 'commands'] as const)(
|
||||
'should repair EasyCode commands with delivery=%s and then be up to date',
|
||||
async (delivery) => {
|
||||
setMockConfig({ featureFlags: {}, profile: 'core', delivery });
|
||||
await new InitCommand({ tools: 'easycode', force: true }).execute(testDir);
|
||||
|
||||
const commandsDir = path.join(testDir, '.easycode', 'commands', 'opsx');
|
||||
const exploreFile = path.join(commandsDir, 'explore.toml');
|
||||
const originalExplore = await fs.readFile(exploreFile, 'utf-8');
|
||||
await fs.writeFile(exploreFile, 'stale command');
|
||||
await fs.unlink(path.join(commandsDir, 'apply.toml'));
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
expect(await fs.readFile(exploreFile, 'utf-8')).toBe(originalExplore);
|
||||
const apply = parseToml(await fs.readFile(path.join(commandsDir, 'apply.toml'), 'utf-8'));
|
||||
expect(apply.prompt).toContain('openspec');
|
||||
expect(await FileSystemUtils.fileExists(
|
||||
path.join(testDir, '.easycode', 'skills', 'openspec-explore', 'SKILL.md')
|
||||
)).toBe(delivery === 'both');
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
await updateCommand.execute(testDir);
|
||||
const logCalls = consoleSpy.mock.calls.flat().map(String);
|
||||
expect(logCalls.some((entry) => entry.includes('up to date'))).toBe(true);
|
||||
expect(logCalls.some((entry) => entry.includes('Updating 1 tool(s)'))).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('should repair stale OpenCode commands-only installs once', async () => {
|
||||
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' });
|
||||
const commandsDir = path.join(testDir, '.opencode', 'commands');
|
||||
@@ -3546,6 +3576,37 @@ More user content after markers.
|
||||
});
|
||||
|
||||
describe('profile-aware updates', () => {
|
||||
it('should prune EasyCode profile and delivery changes while preserving user files', async () => {
|
||||
await new InitCommand({ tools: 'easycode', force: true }).execute(testDir);
|
||||
|
||||
const commandsDir = path.join(testDir, '.easycode', 'commands', 'opsx');
|
||||
const skillsDir = path.join(testDir, '.easycode', 'skills');
|
||||
const userCommand = path.join(commandsDir, 'personal.toml');
|
||||
const userSkill = path.join(skillsDir, 'personal', 'SKILL.md');
|
||||
await fs.writeFile(userCommand, 'prompt = "Keep my command"\n');
|
||||
await fs.mkdir(path.dirname(userSkill), { recursive: true });
|
||||
await fs.writeFile(userSkill, 'Keep my skill');
|
||||
|
||||
setMockConfig({ featureFlags: {}, profile: 'custom', delivery: 'commands', workflows: ['explore', 'new'] });
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
expect((await fs.readdir(commandsDir)).sort()).toEqual(['explore.toml', 'new.toml', 'personal.toml']);
|
||||
const newCommand = parseToml(await fs.readFile(path.join(commandsDir, 'new.toml'), 'utf-8'));
|
||||
expect(newCommand.prompt).toContain('openspec');
|
||||
expect(await FileSystemUtils.fileExists(path.join(skillsDir, 'openspec-explore', 'SKILL.md'))).toBe(false);
|
||||
expect(await FileSystemUtils.fileExists(path.join(skillsDir, 'openspec-propose', 'SKILL.md'))).toBe(false);
|
||||
|
||||
setMockConfig({ featureFlags: {}, profile: 'custom', delivery: 'skills', workflows: ['explore', 'new'] });
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
expect(await fs.readdir(commandsDir)).toEqual(['personal.toml']);
|
||||
for (const skillName of ['openspec-explore', 'openspec-new-change']) {
|
||||
expect(await fs.readFile(path.join(skillsDir, skillName, 'SKILL.md'), 'utf-8')).toContain(`name: ${skillName}`);
|
||||
}
|
||||
expect(await fs.readFile(userCommand, 'utf-8')).toBe('prompt = "Keep my command"\n');
|
||||
expect(await fs.readFile(userSkill, 'utf-8')).toBe('Keep my skill');
|
||||
});
|
||||
|
||||
it.each(['both', 'skills', 'commands'] as const)(
|
||||
'should sync AtomCode profile and %s delivery while preserving custom files',
|
||||
async (delivery) => {
|
||||
|
||||
Reference in New Issue
Block a user