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:
redknox
2026-09-29 20:24:09 +00:00
committed by GitHub
co-authored by Trae Clay Good Claude Opus 5.5
parent a7f08b8a46
commit d1642cb58c
15 changed files with 242 additions and 67 deletions
+5
View File
@@ -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>`.
+4 -3
View File
@@ -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`;
},
};
+1 -37
View File
@@ -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';
+2
View File
@@ -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);
+41
View File
@@ -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')}`);
}
+1
View File
@@ -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 },
+8 -5
View File
@@ -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 () => {
+44 -13
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -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) => {
+61
View File
@@ -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) => {