Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,8 @@ logs/
# GHA credentials
gha-creds-*.json

QWEN.md
QWEN.md

# Ensure the CLI command `spec` folder is not ignored so its files can be committed
!packages/cli/src/ui/commands/spec/
!packages/cli/src/ui/commands/spec/**
1 change: 1 addition & 0 deletions packages/cli/src/services/BuiltinCommandLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ vi.mock('../ui/commands/modelCommand.js', () => ({
kind: 'BUILT_IN',
},
}));
vi.mock('../ui/commands/addSpecCommand.js', () => ({ addSpecCommand: {} }));

describe('BuiltinCommandLoader', () => {
let mockConfig: Config;
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ICommandLoader } from './types.js';
import type { SlashCommand } from '../ui/commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { addSpecCommand } from '../ui/commands/addSpecCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
Expand Down Expand Up @@ -87,6 +88,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
vimCommand,
setupGithubCommand,
terminalSetupCommand,
addSpecCommand,
];

return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
Expand Down
119 changes: 119 additions & 0 deletions packages/cli/src/ui/commands/addSpecCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import os from 'node:os';
import path from 'node:path';
import * as fs from 'node:fs/promises';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { addSpecCommand } from './addSpecCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { CommandContext, MessageActionReturn } from './types.js';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SPEC_TEMPLATE_DIR = path.resolve(__dirname, 'spec');

async function listTemplateFiles(): Promise<string[]> {
const files: string[] = [];
const walk = async (dir: string) => {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
files.push(path.relative(SPEC_TEMPLATE_DIR, fullPath));
}
}
};
await walk(SPEC_TEMPLATE_DIR);
return files.sort();
}

describe('addSpecCommand', () => {
let scratchDir: string;
let mockContext: CommandContext;

beforeEach(async () => {
scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'add-spec-command-'));
mockContext = createMockCommandContext({
services: {
config: {
getTargetDir: () => scratchDir,
},
},
});
});

afterEach(async () => {
await fs.rm(scratchDir, { recursive: true, force: true });
});

it('returns error when configuration is missing', async () => {
const context = createMockCommandContext();
if (context.services) {
context.services.config = null;
}

const result = await addSpecCommand.action?.(context, '');

expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
});
});

it('copies all spec templates into the target directory', async () => {
const result = (await addSpecCommand.action?.(
mockContext,
'',
)) as MessageActionReturn;

expect(result).toBeDefined();
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');

const expectedFiles = await listTemplateFiles();
for (const relativePath of expectedFiles) {
const targetPath = path.join(scratchDir, relativePath);
await expect(fs.readFile(targetPath, 'utf8')).resolves.toBeDefined();
}

const readme = await fs.readFile(path.join(scratchDir, 'QWEN.md'), 'utf8');
expect(readme).toContain('Spec-Driven Development');
});

it('skips existing files and preserves their content', async () => {
const targetQwen = path.join(scratchDir, 'QWEN.md');
await fs.mkdir(path.dirname(targetQwen), { recursive: true });
await fs.writeFile(targetQwen, 'custom content', 'utf8');

const result = (await addSpecCommand.action?.(
mockContext,
'',
)) as MessageActionReturn;

expect(result).toBeDefined();
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('Skipped existing files');

const finalContent = await fs.readFile(targetQwen, 'utf8');
expect(finalContent).toBe('custom content');

const commandFile = path.join(
scratchDir,
'.qwen',
'commands',
'spec-init.toml',
);
await expect(fs.readFile(commandFile, 'utf8')).resolves.toContain(
'Initialize a new specification',
);
});
});
140 changes: 140 additions & 0 deletions packages/cli/src/ui/commands/addSpecCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import * as fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SPEC_TEMPLATE_DIR = path.resolve(__dirname, 'spec');
const fsp = fs.promises;

async function pathExists(targetPath: string): Promise<boolean> {
try {
await fsp.access(targetPath);
return true;
} catch {
return false;
}
}

async function copySpecDirectory(
sourceDir: string,
destinationDir: string,
targetRoot: string,
copied: string[],
skipped: string[],
): Promise<void> {
const entries = await fsp.readdir(sourceDir, { withFileTypes: true });

for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const destinationPath = path.join(destinationDir, entry.name);

if (entry.isDirectory()) {
await fsp.mkdir(destinationPath, { recursive: true });
await copySpecDirectory(
sourcePath,
destinationPath,
targetRoot,
copied,
skipped,
);
} else if (entry.isFile()) {
await fsp.mkdir(path.dirname(destinationPath), { recursive: true });

if (await pathExists(destinationPath)) {
skipped.push(path.relative(targetRoot, destinationPath));
continue;
}

await fsp.copyFile(sourcePath, destinationPath);
copied.push(path.relative(targetRoot, destinationPath));
}
}
}

export const addSpecCommand: SlashCommand = {
name: 'add-spec',
description: 'Copy spec scaffolding into the current project.',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
const config = context.services.config;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}

const targetDir = config.getTargetDir();
try {
if (!(await pathExists(SPEC_TEMPLATE_DIR))) {
return {
type: 'message',
messageType: 'error',
content: 'Spec templates were not found in the CLI bundle.',
};
}

const copied: string[] = [];
const skipped: string[] = [];

await copySpecDirectory(
SPEC_TEMPLATE_DIR,
targetDir,
targetDir,
copied,
skipped,
);

copied.sort();
skipped.sort();

let content = `Spec scaffolding prepared in ${targetDir}.`;

if (copied.length > 0) {
const copiedList = copied
.map((relative) => `- ${relative}`)
.join('\n');
content += `\n\nCreated:\n${copiedList}`;
}

if (skipped.length > 0) {
const skippedList = skipped
.map((relative) => `- ${relative}`)
.join('\n');
content += `\n\nSkipped existing files:\n${skippedList}`;
}

if (copied.length === 0 && skipped.length === 0) {
content += '\n\nNo files were copied. Templates may be empty.';
}

return {
type: 'message',
messageType: 'info',
content,
};
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
return {
type: 'message',
messageType: 'error',
content: `Failed to add spec scaffolding: ${message}`,
};
}
},
};

Loading