Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

All notable changes to @rpamis/comet will be documented in this file.

## What's Changed [0.2.7] - 2026-05-24

### Fixed

- **OpenSpec global init**: `comet init` global scope now passes the home directory as OpenSpec's init target instead of using the unsupported `openspec init --global` flag
- **Cross-platform path quoting**: OpenSpec init targets are shell-quoted for Windows, macOS, and Linux paths, including home directories with spaces
- **Installer argument quoting**: OpenSpec `--tools` values and Superpowers `--agent` values are now shell-quoted, and Windows OpenSpec paths preserve trailing backslashes before the closing quote
- **Superpowers multi-platform install**: Superpowers installation now passes repeated `--agent` flags instead of a comma-separated agent list, matching the `skills` CLI behavior
- **Superpowers agent mappings**: Updated Comet platform mappings to valid `skills` CLI agent IDs, with unsupported platform-specific IDs falling back to `universal`

### Tests

- Added regression coverage for OpenSpec global init command construction across Windows, macOS, and Linux
- Added regression coverage for OpenSpec Windows trailing-backslash quoting and quoted installer arguments
- Added Superpowers coverage for valid `skills` CLI agent mappings and multi-agent argument formatting
- Smoke-tested project and global initialization outputs for all 28 supported platforms in isolated temporary directories

## What's Changed [0.2.6] - 2026-05-23

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
```

> 中文版:[README-zh.md](README-zh.md)
> [B站视频介绍 ](https://www.bilibili.com/video/BV1y4Gi6CEo1/?spm_id_from=333.1387.homepage.video_card.click&vd_source=d22726fe6b108647dbebf1c5d8817377)
> [B站视频介绍](https://www.bilibili.com/video/BV1y4Gi6CEo1/?spm_id_from=333.1387.homepage.video_card.click&vd_source=d22726fe6b108647dbebf1c5d8817377)

**OpenSpec + Superpowers dual-star development workflow** — one command from idea to archive.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rpamis/comet",
"version": "0.2.6",
"version": "0.2.7",
"description": "OpenSpec + Superpowers dual-star development workflow",
"keywords": [
"comet",
Expand Down
27 changes: 21 additions & 6 deletions src/core/openspec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
import { execSync } from 'child_process';
import os from 'os';
import { PLATFORMS } from './platforms.js';

import type { InstallScope } from './types.js';

const VALID_TOOL_IDS = new Set(PLATFORMS.map((p) => p.openspecToolId));

function quoteShellArg(value: string, platform: NodeJS.Platform = process.platform): string {
if (platform === 'win32') {
return `"${value.replace(/"/g, '\\"').replace(/\\+$/, (match) => match + match)}"`;
}
return `'${value.replace(/'/g, `'\\''`)}'`;
}

function buildOpenSpecInitCommand(
projectPath: string,
toolIds: string[],
scope: InstallScope,
homeDir = os.homedir(),
platform: NodeJS.Platform = process.platform,
): string {
const targetPath = scope === 'global' ? homeDir : projectPath;
return `openspec init ${quoteShellArg(targetPath, platform)} --tools ${quoteShellArg(toolIds.join(','), platform)}`;
}

function isCommandAvailable(command: string): boolean {
try {
const checkCmd = process.platform === 'win32' ? `where ${command}` : `which ${command}`;
Expand Down Expand Up @@ -53,11 +72,7 @@ async function installOpenSpec(
}

try {
const flags = ['--tools', toolIds.join(','), scope === 'global' ? '--global' : '']
.filter(Boolean)
.join(' ');

execSync(`openspec init ${flags}`, {
execSync(buildOpenSpecInitCommand(projectPath, toolIds, scope), {
cwd: projectPath,
stdio: 'pipe',
timeout: 120_000,
Expand All @@ -69,4 +84,4 @@ async function installOpenSpec(
}
}

export { installOpenSpec, isCommandAvailable };
export { installOpenSpec, isCommandAvailable, buildOpenSpecInitCommand, quoteShellArg };
55 changes: 32 additions & 23 deletions src/core/superpowers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execSync } from 'child_process';

import { quoteShellArg } from './openspec.js';
import type { InstallScope } from './types.js';

const SKILLS_AGENT_MAP: Record<string, string> = {
Expand All @@ -9,55 +10,63 @@ const SKILLS_AGENT_MAP: Record<string, string> = {
opencode: 'opencode',
windsurf: 'windsurf',
cline: 'cline',
roocode: 'roo-code',
roocode: 'roo',
continue: 'continue',
'github-copilot': 'github-copilot',
gemini: 'gemini',
'amazon-q': 'amazon-q',
qwen: 'qwen',
kilocode: 'kilo-code',
gemini: 'gemini-cli',
'amazon-q': 'universal',
qwen: 'qwen-code',
kilocode: 'kilo',
auggie: 'augment',
kiro: 'kiro',
lingma: 'lingma',
kiro: 'kiro-cli',
lingma: 'universal',
junie: 'junie',
codebuddy: 'codebuddy',
costrict: 'costrict',
costrict: 'universal',
crush: 'crush',
factory: 'factory',
iflow: 'iflow',
factory: 'droid',
iflow: 'iflow-cli',
pi: 'pi',
qoder: 'qoder',
antigravity: 'antigravity',
bob: 'bob',
forgecode: 'forge',
forgecode: 'forgecode',
trae: 'trae',
};

const VALID_PLATFORM_IDS = new Set(Object.keys(SKILLS_AGENT_MAP));

async function installSuperpowersForPlatforms(
projectPath: string,
function buildSuperpowersInstallCommand(
_projectPath: string,
scope: InstallScope,
platformIds: string[],
): Promise<'installed' | 'failed' | 'skipped'> {
platform: NodeJS.Platform = process.platform,
): string {
const unknownIds = platformIds.filter((id) => !VALID_PLATFORM_IDS.has(id));
if (unknownIds.length > 0) {
throw new Error(`Unknown platform IDs: ${unknownIds.join(', ')}`);
}

const agentNames = platformIds.map((id) => SKILLS_AGENT_MAP[id]).filter(Boolean);
const agentNames = [...new Set(platformIds.map((id) => SKILLS_AGENT_MAP[id]).filter(Boolean))];

if (agentNames.length === 0) {
console.error(` No valid agent names resolved for platforms: ${platformIds.join(', ')}`);
return 'failed';
throw new Error(`No valid agent names resolved for platforms: ${platformIds.join(', ')}`);
}

try {
const flags = ['-y', scope === 'global' ? '-g' : '', `--agent ${agentNames.join(',')}`]
.filter(Boolean)
.join(' ');
const agentFlags = agentNames.map((name) => `--agent ${quoteShellArg(name, platform)}`).join(' ');
const flags = ['-y', scope === 'global' ? '-g' : '', agentFlags].filter(Boolean).join(' ');
return `npx skills add obra/superpowers ${flags}`;
}

execSync(`npx skills add obra/superpowers ${flags}`, {
async function installSuperpowersForPlatforms(
projectPath: string,
scope: InstallScope,
platformIds: string[],
): Promise<'installed' | 'failed' | 'skipped'> {
const command = buildSuperpowersInstallCommand(projectPath, scope, platformIds);

try {
execSync(command, {
cwd: projectPath,
stdio: 'pipe',
timeout: 120_000,
Expand All @@ -69,4 +78,4 @@ async function installSuperpowersForPlatforms(
}
}

export { installSuperpowersForPlatforms, SKILLS_AGENT_MAP };
export { installSuperpowersForPlatforms, buildSuperpowersInstallCommand, SKILLS_AGENT_MAP };
50 changes: 46 additions & 4 deletions test/ts/openspec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ describe('openspec', () => {
});
});

describe('quoteShellArg', () => {
it('doubles trailing backslashes before the closing quote on Windows', async () => {
const { quoteShellArg } = await import('../../src/core/openspec.js');

expect(quoteShellArg('C:\\Users\\', 'win32')).toBe('"C:\\Users\\\\"');
});
});

describe('installOpenSpec', () => {
it('installs openspec when CLI is available', async () => {
mockedExecSync.mockReturnValueOnce(Buffer.from('/usr/bin/openspec'));
Expand Down Expand Up @@ -61,16 +69,50 @@ describe('openspec', () => {
expect(result).toBe('failed');
});

it('passes --global flag for global scope', async () => {
it('does not pass unsupported --global flag for global scope', async () => {
mockedExecSync.mockReturnValueOnce(Buffer.from('/usr/bin/openspec'));
mockedExecSync.mockReturnValueOnce(Buffer.from('ok'));

const { installOpenSpec } = await import('../../src/core/openspec.js');
const { installOpenSpec, quoteShellArg } = await import('../../src/core/openspec.js');
await installOpenSpec('/tmp/test', ['claude'], 'global');

// Second call should have --global flag
const initCall = mockedExecSync.mock.calls[1][0] as string;
expect(initCall).toContain('--global');
expect(initCall).not.toContain('--global');
expect(initCall).toContain(`--tools ${quoteShellArg('claude')}`);
});

it('uses the home directory as the OpenSpec init target for global scope', async () => {
const { buildOpenSpecInitCommand } = await import('../../src/core/openspec.js');

expect(
buildOpenSpecInitCommand('/tmp/project', ['codex'], 'global', '/Users/Test User', 'darwin'),
).toBe("openspec init '/Users/Test User' --tools 'codex'");
expect(
buildOpenSpecInitCommand('/tmp/project', ['codex'], 'global', '/home/test user', 'linux'),
).toBe("openspec init '/home/test user' --tools 'codex'");
expect(
buildOpenSpecInitCommand(
'D:\\Project\\Comet',
['codex'],
'global',
'C:\\Users\\Test User',
'win32',
),
).toBe('openspec init "C:\\Users\\Test User" --tools "codex"');
});

it('quotes the joined OpenSpec tools argument', async () => {
const { buildOpenSpecInitCommand } = await import('../../src/core/openspec.js');

expect(
buildOpenSpecInitCommand(
'/tmp/project',
['future tool', 'codex'],
'project',
'/home/user',
'linux',
),
).toBe("openspec init '/tmp/project' --tools 'future tool,codex'");
});

it('installs openspec CLI when not on PATH', async () => {
Expand Down
Loading
Loading