cdspec 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -33
- package/dist/cli.js +21 -74
- package/dist/skill-core/manifest-loader.js +0 -3
- package/dist/skill-core/service.js +1 -48
- package/package.json +2 -1
- package/src/cli.ts +4 -65
- package/src/skill-core/manifest-loader.ts +0 -7
- package/src/skill-core/service.ts +1 -63
- package/tests/init.test.ts +63 -0
- package/AGENTS.md +0 -14
- package/CLAUDE.md +0 -10
- package/src/task-core/parser.ts +0 -89
- package/src/task-core/service.ts +0 -49
- package/src/task-core/storage.ts +0 -177
- package/src/task-core/types.ts +0 -15
- package/tests/skill.test.ts +0 -191
- package/tests/task.test.ts +0 -55
package/README.md
CHANGED
|
@@ -1,55 +1,108 @@
|
|
|
1
|
-
#
|
|
1
|
+
# CDSpec
|
|
2
2
|
|
|
3
|
-
CLI
|
|
3
|
+
`cdspec` 是一个 Skill 编排 CLI,用来把项目里的模板/技能一键安装到不同智能体目录(Codex / ClaudeCode / iFlow),并自动生成可调用命令。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- Installing OpenSpec-style interaction commands (`opsx-*`).
|
|
7
|
-
- Splitting markdown into task cards and archiving completed tasks.
|
|
8
|
-
- Config-driven command-skill bindings via `cdspec.config.yaml`.
|
|
5
|
+
## 功能
|
|
9
6
|
|
|
10
|
-
|
|
7
|
+
- 从 `templates/` 批量生成并安装 skills
|
|
8
|
+
- 自动为每个 skill 生成命令文件
|
|
9
|
+
- 生成命令与 skill 的绑定说明(AGENTS/CLAUDE/IFLOW)
|
|
10
|
+
- 支持任务拆分与归档(`task split/status/archive`)
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
|
|
14
|
+
全局安装(已发布到 npm):
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm i -g cdspec
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
验证安装:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cdspec --help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
本地开发方式:
|
|
11
27
|
|
|
12
28
|
```bash
|
|
13
29
|
npm install
|
|
14
30
|
npm run build
|
|
15
|
-
|
|
31
|
+
npm link
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 快速开始
|
|
35
|
+
|
|
36
|
+
在项目根目录准备 `templates/`,每个技能一个目录,目录内至少有 `SKILL.md`。
|
|
37
|
+
|
|
38
|
+
示例:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
templates/
|
|
42
|
+
design-doc/
|
|
43
|
+
SKILL.md
|
|
44
|
+
agents/openai.yaml
|
|
45
|
+
references/*
|
|
46
|
+
frontend-standards/
|
|
47
|
+
SKILL.md
|
|
16
48
|
```
|
|
17
49
|
|
|
18
|
-
|
|
50
|
+
执行初始化:
|
|
19
51
|
|
|
20
52
|
```bash
|
|
21
|
-
cdspec init
|
|
53
|
+
cdspec init
|
|
54
|
+
```
|
|
22
55
|
|
|
23
|
-
|
|
24
|
-
cdspec skill add <name> --target all
|
|
25
|
-
cdspec skill sync --target all
|
|
56
|
+
或指定智能体:
|
|
26
57
|
|
|
27
|
-
|
|
28
|
-
cdspec
|
|
29
|
-
cdspec task status --id <task-id> --to done
|
|
30
|
-
cdspec task archive --id <task-id>
|
|
58
|
+
```bash
|
|
59
|
+
cdspec init --agents codex,claudecode,iflow
|
|
31
60
|
```
|
|
32
61
|
|
|
33
|
-
|
|
62
|
+
## 生成规则(当前版本)
|
|
63
|
+
|
|
64
|
+
1. `templates/*` 下每个 skill 目录会被安装到目标智能体的 `skills/`。
|
|
65
|
+
2. 每个 skill 自动生成一个命令文件,命令名规则为:
|
|
66
|
+
`cd-<skill-name>`
|
|
67
|
+
3. `init` 默认覆盖旧文件(不需要 `--force`)。
|
|
68
|
+
|
|
69
|
+
示例:
|
|
70
|
+
|
|
71
|
+
- skill: `frontend-develop-standard`
|
|
72
|
+
- 命令文件:`cd-frontend-develop-standard.md`
|
|
73
|
+
|
|
74
|
+
## 输出目录
|
|
75
|
+
|
|
76
|
+
默认配置下:
|
|
77
|
+
|
|
78
|
+
- Codex: `~/.codex/skills/*` 和 `~/.codex/prompts/*`
|
|
79
|
+
- ClaudeCode: `.claude/skills/*` 和 `.claude/commands/opsx/*`
|
|
80
|
+
- iFlow: `.iflow/skills/*` 和 `.iflow/commands/*`
|
|
81
|
+
|
|
82
|
+
附加说明文件:
|
|
83
|
+
|
|
84
|
+
- `~/.codex/AGENTS.md`
|
|
85
|
+
- 项目根 `AGENTS.md`
|
|
86
|
+
- 项目根 `CLAUDE.md`(当启用 claudecode)
|
|
87
|
+
- `.iflow/IFLOW.md`(当启用 iflow)
|
|
88
|
+
|
|
89
|
+
## 配置
|
|
34
90
|
|
|
35
|
-
|
|
36
|
-
- `.claude/skills/*`, `.claude/commands/opsx/*.md`, `CLAUDE.md`
|
|
37
|
-
- `.iflow/skills/*`, `.iflow/commands/opsx-*.md`, `.iflow/IFLOW.md`
|
|
91
|
+
项目根配置文件:`cdspec.config.yaml`
|
|
38
92
|
|
|
39
|
-
|
|
40
|
-
It does not create extra source folders in your project tree.
|
|
41
|
-
When `templates/*.md` exists, the exported skill name is derived from template filename, and
|
|
42
|
-
command bindings are auto-pointed to that generated skill.
|
|
93
|
+
可配置项包括:
|
|
43
94
|
|
|
44
|
-
|
|
95
|
+
- 各智能体根目录(如 `~/.codex`)
|
|
96
|
+
- 命令目录位置(`prompts` / `commands`)
|
|
97
|
+
- 命令文件命名规则与 slash 展示规则
|
|
45
98
|
|
|
46
|
-
|
|
47
|
-
- Task workspace: `.cdspec/tasks` and `.cdspec/archive`
|
|
99
|
+
## 常见问题
|
|
48
100
|
|
|
49
|
-
|
|
101
|
+
1. `init` 后看不到新命令
|
|
102
|
+
重启 Codex 会话;部分客户端启动时才扫描 prompt 列表。
|
|
50
103
|
|
|
51
|
-
|
|
104
|
+
2. 命令重复(Team/个人各一份)
|
|
105
|
+
这是不同来源同时存在,清理个人或团队同名 skill 即可。
|
|
52
106
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
- slash command display pattern
|
|
107
|
+
3. 发布 npm 报 401/404
|
|
108
|
+
先 `npm login`,再检查包名与权限后发布。
|
package/dist/cli.js
CHANGED
|
@@ -1,74 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import checkbox from
|
|
3
|
-
import { Command } from
|
|
4
|
-
import path from
|
|
5
|
-
import process from
|
|
6
|
-
import {
|
|
7
|
-
import { archiveTaskById, splitTasks, updateTask } from "./task-core/service.js";
|
|
2
|
+
import checkbox from '@inquirer/checkbox';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { initSkills } from './skill-core/service.js';
|
|
8
7
|
const program = new Command();
|
|
8
|
+
program.name('cdspec').description('Skill init CLI').version('0.1.0');
|
|
9
9
|
program
|
|
10
|
-
.
|
|
11
|
-
.description(
|
|
12
|
-
.
|
|
13
|
-
program
|
|
14
|
-
.command("init")
|
|
15
|
-
.description("Initialize skills for selected coding agents")
|
|
16
|
-
.option("--agents <agents>", "codex|claudecode|iflow|all or comma-separated")
|
|
10
|
+
.command('init')
|
|
11
|
+
.description('Initialize skills for selected coding agents')
|
|
12
|
+
.option('--agents <agents>', 'codex|claudecode|iflow|all or comma-separated')
|
|
17
13
|
.action(async (options) => {
|
|
18
14
|
const selected = options.agents ?? (await askAgentsSelection());
|
|
19
15
|
const files = await initSkills(process.cwd(), selected, true);
|
|
20
|
-
console.log(`Initialized
|
|
21
|
-
console.log(`Generated files: ${files.map(
|
|
16
|
+
console.log(`Initialized setup for "${selected}".`);
|
|
17
|
+
console.log(`Generated files: ${files.map(x => path.relative(process.cwd(), x)).join(', ')}`);
|
|
22
18
|
});
|
|
23
|
-
program
|
|
24
|
-
.command("skill")
|
|
25
|
-
.description("Skill operations")
|
|
26
|
-
.addCommand(new Command("list").action(async () => {
|
|
27
|
-
const names = await listSkills(process.cwd());
|
|
28
|
-
if (names.length === 0) {
|
|
29
|
-
console.log("No skill found under .codex/.");
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
names.forEach((name) => console.log(name));
|
|
33
|
-
}))
|
|
34
|
-
.addCommand(new Command("add")
|
|
35
|
-
.argument("<name>", "skill folder name under .codex")
|
|
36
|
-
.option("--target <target>", "codex|claudecode|iflow|all or comma-separated", "all")
|
|
37
|
-
.option("--force", "overwrite existing output", false)
|
|
38
|
-
.action(async (name, options) => {
|
|
39
|
-
await addSkill(process.cwd(), name, options.target, options.force);
|
|
40
|
-
console.log(`Skill "${name}" exported to tool folders for target "${options.target}".`);
|
|
41
|
-
}))
|
|
42
|
-
.addCommand(new Command("sync")
|
|
43
|
-
.option("--target <target>", "codex|claudecode|iflow|all or comma-separated", "all")
|
|
44
|
-
.option("--force", "overwrite existing output", false)
|
|
45
|
-
.action(async (options) => {
|
|
46
|
-
await syncSkills(process.cwd(), options.target, options.force);
|
|
47
|
-
console.log(`All skills synced to tool folders for target "${options.target}".`);
|
|
48
|
-
}));
|
|
49
|
-
program
|
|
50
|
-
.command("task")
|
|
51
|
-
.description("Task split/archive operations")
|
|
52
|
-
.addCommand(new Command("split")
|
|
53
|
-
.requiredOption("--from <file>", "source markdown file")
|
|
54
|
-
.requiredOption("--title <title>", "task group title")
|
|
55
|
-
.action(async (options) => {
|
|
56
|
-
const count = await splitTasks(process.cwd(), options.from, options.title);
|
|
57
|
-
console.log(`Generated ${count} tasks from "${options.from}".`);
|
|
58
|
-
}))
|
|
59
|
-
.addCommand(new Command("status")
|
|
60
|
-
.requiredOption("--id <id>", "task id")
|
|
61
|
-
.requiredOption("--to <status>", "todo|in_progress|done")
|
|
62
|
-
.action(async (options) => {
|
|
63
|
-
await updateTask(process.cwd(), options.id, options.to);
|
|
64
|
-
console.log(`Task "${options.id}" updated to "${options.to}".`);
|
|
65
|
-
}))
|
|
66
|
-
.addCommand(new Command("archive")
|
|
67
|
-
.requiredOption("--id <id>", "task id")
|
|
68
|
-
.action(async (options) => {
|
|
69
|
-
await archiveTaskById(process.cwd(), options.id);
|
|
70
|
-
console.log(`Task "${options.id}" archived.`);
|
|
71
|
-
}));
|
|
72
19
|
program.parseAsync(process.argv).catch((error) => {
|
|
73
20
|
const message = error instanceof Error ? error.message : String(error);
|
|
74
21
|
console.error(message);
|
|
@@ -76,19 +23,19 @@ program.parseAsync(process.argv).catch((error) => {
|
|
|
76
23
|
});
|
|
77
24
|
async function askAgentsSelection() {
|
|
78
25
|
if (!process.stdin.isTTY)
|
|
79
|
-
return
|
|
26
|
+
return 'all';
|
|
80
27
|
const values = await checkbox({
|
|
81
|
-
message:
|
|
28
|
+
message: 'Select coding agents',
|
|
82
29
|
choices: [
|
|
83
|
-
{ name:
|
|
84
|
-
{ name:
|
|
85
|
-
{ name:
|
|
86
|
-
{ name:
|
|
30
|
+
{ name: 'codex', value: 'codex', checked: true },
|
|
31
|
+
{ name: 'claudecode', value: 'claudecode' },
|
|
32
|
+
{ name: 'iflow', value: 'iflow' },
|
|
33
|
+
{ name: 'all', value: 'all' }
|
|
87
34
|
]
|
|
88
35
|
});
|
|
89
36
|
if (values.length === 0)
|
|
90
|
-
return
|
|
91
|
-
if (values.includes(
|
|
92
|
-
return
|
|
93
|
-
return values.join(
|
|
37
|
+
return 'all';
|
|
38
|
+
if (values.includes('all'))
|
|
39
|
+
return 'all';
|
|
40
|
+
return values.join(',');
|
|
94
41
|
}
|
|
@@ -15,9 +15,6 @@ export async function loadAllSkillManifests(cwd) {
|
|
|
15
15
|
}
|
|
16
16
|
return manifests;
|
|
17
17
|
}
|
|
18
|
-
export async function loadSkillManifestByName(cwd, name) {
|
|
19
|
-
return loadSkillManifest(path.join(cwd, CODEX_DIR, name));
|
|
20
|
-
}
|
|
21
18
|
async function loadSkillManifest(skillDir) {
|
|
22
19
|
const skillMdPath = path.join(skillDir, "SKILL.md");
|
|
23
20
|
if (!(await pathExists(skillMdPath)))
|
|
@@ -2,58 +2,11 @@ import path from "node:path";
|
|
|
2
2
|
import { rm } from "node:fs/promises";
|
|
3
3
|
import { loadConfig } from "../config/loader.js";
|
|
4
4
|
import { resolveAgentRoot } from "../config/path.js";
|
|
5
|
-
import { loadAllSkillManifests
|
|
5
|
+
import { loadAllSkillManifests } from "./manifest-loader.js";
|
|
6
6
|
import { expandTargets, getAdapter } from "./adapters/index.js";
|
|
7
7
|
import { installToolInteractionTemplates, writeSharedAgentsStub } from "./tool-interactions.js";
|
|
8
8
|
import { cleanupLegacyDefaultSkillDir, loadDefaultSkillManifest, loadProjectTemplateManifests } from "./scaffold.js";
|
|
9
9
|
import { validateManifest } from "./validator.js";
|
|
10
|
-
export async function listSkills(cwd) {
|
|
11
|
-
const manifests = await loadAllSkillManifests(cwd);
|
|
12
|
-
return manifests.map((manifest) => manifest.name).sort();
|
|
13
|
-
}
|
|
14
|
-
export async function addSkill(cwd, name, targetRaw, force) {
|
|
15
|
-
const config = await loadConfig(cwd);
|
|
16
|
-
let manifest = await loadSkillManifestByName(cwd, name);
|
|
17
|
-
if (!manifest) {
|
|
18
|
-
const all = await loadAllSkillManifests(cwd);
|
|
19
|
-
manifest = all.find((item) => item.name === name) ?? null;
|
|
20
|
-
}
|
|
21
|
-
if (!manifest) {
|
|
22
|
-
throw new Error(`Skill "${name}" not found under .codex/.`);
|
|
23
|
-
}
|
|
24
|
-
const diagnostics = [...validateManifest(manifest)];
|
|
25
|
-
const targets = expandTargets(targetRaw);
|
|
26
|
-
for (const target of targets) {
|
|
27
|
-
diagnostics.push(...getAdapter(target).validate(manifest));
|
|
28
|
-
}
|
|
29
|
-
failIfErrors(diagnostics);
|
|
30
|
-
for (const target of targets) {
|
|
31
|
-
const adapter = getAdapter(target);
|
|
32
|
-
const outDir = path.join(resolveAgentRoot(cwd, config.agents[target].rootDir), "skills", manifest.name);
|
|
33
|
-
await adapter.emit(manifest, outDir, force);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
export async function syncSkills(cwd, targetRaw, force) {
|
|
37
|
-
const config = await loadConfig(cwd);
|
|
38
|
-
const manifests = await loadAllSkillManifests(cwd);
|
|
39
|
-
if (manifests.length === 0) {
|
|
40
|
-
throw new Error("No skills found under .codex/.");
|
|
41
|
-
}
|
|
42
|
-
const targets = expandTargets(targetRaw);
|
|
43
|
-
for (const manifest of manifests) {
|
|
44
|
-
const diagnostics = [...validateManifest(manifest)];
|
|
45
|
-
for (const target of targets) {
|
|
46
|
-
diagnostics.push(...getAdapter(target).validate(manifest));
|
|
47
|
-
}
|
|
48
|
-
failIfErrors(diagnostics);
|
|
49
|
-
}
|
|
50
|
-
for (const manifest of manifests) {
|
|
51
|
-
for (const target of targets) {
|
|
52
|
-
const outDir = path.join(resolveAgentRoot(cwd, config.agents[target].rootDir), "skills", manifest.name);
|
|
53
|
-
await getAdapter(target).emit(manifest, outDir, force);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
10
|
export async function initSkills(cwd, agentsRaw, force) {
|
|
58
11
|
await cleanupLegacyDefaultSkillDir(cwd);
|
|
59
12
|
const baseConfig = await loadConfig(cwd);
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -3,12 +3,11 @@ import checkbox from '@inquirer/checkbox'
|
|
|
3
3
|
import { Command } from 'commander'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import process from 'node:process'
|
|
6
|
-
import {
|
|
7
|
-
import { archiveTaskById, splitTasks, updateTask } from './task-core/service.js'
|
|
6
|
+
import { initSkills } from './skill-core/service.js'
|
|
8
7
|
|
|
9
8
|
const program = new Command()
|
|
10
9
|
|
|
11
|
-
program.name('cdspec').description('Skill
|
|
10
|
+
program.name('cdspec').description('Skill init CLI').version('0.1.0')
|
|
12
11
|
|
|
13
12
|
program
|
|
14
13
|
.command('init')
|
|
@@ -17,71 +16,10 @@ program
|
|
|
17
16
|
.action(async (options: { agents?: string }) => {
|
|
18
17
|
const selected = options.agents ?? (await askAgentsSelection())
|
|
19
18
|
const files = await initSkills(process.cwd(), selected, true)
|
|
20
|
-
console.log(`Initialized
|
|
19
|
+
console.log(`Initialized setup for "${selected}".`)
|
|
21
20
|
console.log(`Generated files: ${files.map(x => path.relative(process.cwd(), x)).join(', ')}`)
|
|
22
21
|
})
|
|
23
22
|
|
|
24
|
-
program
|
|
25
|
-
.command('skill')
|
|
26
|
-
.description('Skill operations')
|
|
27
|
-
.addCommand(
|
|
28
|
-
new Command('list').action(async () => {
|
|
29
|
-
const names = await listSkills(process.cwd())
|
|
30
|
-
if (names.length === 0) {
|
|
31
|
-
console.log('No skill found under .codex/.')
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
names.forEach(name => console.log(name))
|
|
35
|
-
})
|
|
36
|
-
)
|
|
37
|
-
.addCommand(
|
|
38
|
-
new Command('add')
|
|
39
|
-
.argument('<name>', 'skill folder name under .codex')
|
|
40
|
-
.option('--target <target>', 'codex|claudecode|iflow|all or comma-separated', 'all')
|
|
41
|
-
.option('--force', 'overwrite existing output', false)
|
|
42
|
-
.action(async (name: string, options: { target: string; force: boolean }) => {
|
|
43
|
-
await addSkill(process.cwd(), name, options.target, options.force)
|
|
44
|
-
console.log(`Skill "${name}" exported to tool folders for target "${options.target}".`)
|
|
45
|
-
})
|
|
46
|
-
)
|
|
47
|
-
.addCommand(
|
|
48
|
-
new Command('sync')
|
|
49
|
-
.option('--target <target>', 'codex|claudecode|iflow|all or comma-separated', 'all')
|
|
50
|
-
.option('--force', 'overwrite existing output', false)
|
|
51
|
-
.action(async (options: { target: string; force: boolean }) => {
|
|
52
|
-
await syncSkills(process.cwd(), options.target, options.force)
|
|
53
|
-
console.log(`All skills synced to tool folders for target "${options.target}".`)
|
|
54
|
-
})
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
program
|
|
58
|
-
.command('task')
|
|
59
|
-
.description('Task split/archive operations')
|
|
60
|
-
.addCommand(
|
|
61
|
-
new Command('split')
|
|
62
|
-
.requiredOption('--from <file>', 'source markdown file')
|
|
63
|
-
.requiredOption('--title <title>', 'task group title')
|
|
64
|
-
.action(async (options: { from: string; title: string }) => {
|
|
65
|
-
const count = await splitTasks(process.cwd(), options.from, options.title)
|
|
66
|
-
console.log(`Generated ${count} tasks from "${options.from}".`)
|
|
67
|
-
})
|
|
68
|
-
)
|
|
69
|
-
.addCommand(
|
|
70
|
-
new Command('status')
|
|
71
|
-
.requiredOption('--id <id>', 'task id')
|
|
72
|
-
.requiredOption('--to <status>', 'todo|in_progress|done')
|
|
73
|
-
.action(async (options: { id: string; to: 'todo' | 'in_progress' | 'done' }) => {
|
|
74
|
-
await updateTask(process.cwd(), options.id, options.to)
|
|
75
|
-
console.log(`Task "${options.id}" updated to "${options.to}".`)
|
|
76
|
-
})
|
|
77
|
-
)
|
|
78
|
-
.addCommand(
|
|
79
|
-
new Command('archive').requiredOption('--id <id>', 'task id').action(async (options: { id: string }) => {
|
|
80
|
-
await archiveTaskById(process.cwd(), options.id)
|
|
81
|
-
console.log(`Task "${options.id}" archived.`)
|
|
82
|
-
})
|
|
83
|
-
)
|
|
84
|
-
|
|
85
23
|
program.parseAsync(process.argv).catch((error: unknown) => {
|
|
86
24
|
const message = error instanceof Error ? error.message : String(error)
|
|
87
25
|
console.error(message)
|
|
@@ -103,3 +41,4 @@ async function askAgentsSelection(): Promise<string> {
|
|
|
103
41
|
if (values.includes('all')) return 'all'
|
|
104
42
|
return values.join(',')
|
|
105
43
|
}
|
|
44
|
+
|
|
@@ -18,13 +18,6 @@ export async function loadAllSkillManifests(cwd: string): Promise<SkillManifest[
|
|
|
18
18
|
return manifests;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export async function loadSkillManifestByName(
|
|
22
|
-
cwd: string,
|
|
23
|
-
name: string
|
|
24
|
-
): Promise<SkillManifest | null> {
|
|
25
|
-
return loadSkillManifest(path.join(cwd, CODEX_DIR, name));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
21
|
async function loadSkillManifest(skillDir: string): Promise<SkillManifest | null> {
|
|
29
22
|
const skillMdPath = path.join(skillDir, "SKILL.md");
|
|
30
23
|
if (!(await pathExists(skillMdPath))) return null;
|
|
@@ -3,7 +3,7 @@ import { rm } from "node:fs/promises";
|
|
|
3
3
|
import { loadConfig } from "../config/loader.js";
|
|
4
4
|
import { resolveAgentRoot } from "../config/path.js";
|
|
5
5
|
import { CDSpecConfig } from "../config/types.js";
|
|
6
|
-
import { loadAllSkillManifests
|
|
6
|
+
import { loadAllSkillManifests } from "./manifest-loader.js";
|
|
7
7
|
import { expandTargets, getAdapter } from "./adapters/index.js";
|
|
8
8
|
import {
|
|
9
9
|
installToolInteractionTemplates,
|
|
@@ -17,68 +17,6 @@ import {
|
|
|
17
17
|
import { SkillManifest } from "./types.js";
|
|
18
18
|
import { validateManifest } from "./validator.js";
|
|
19
19
|
|
|
20
|
-
export async function listSkills(cwd: string): Promise<string[]> {
|
|
21
|
-
const manifests = await loadAllSkillManifests(cwd);
|
|
22
|
-
return manifests.map((manifest) => manifest.name).sort();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export async function addSkill(
|
|
26
|
-
cwd: string,
|
|
27
|
-
name: string,
|
|
28
|
-
targetRaw: string,
|
|
29
|
-
force: boolean
|
|
30
|
-
): Promise<void> {
|
|
31
|
-
const config = await loadConfig(cwd);
|
|
32
|
-
let manifest = await loadSkillManifestByName(cwd, name);
|
|
33
|
-
if (!manifest) {
|
|
34
|
-
const all = await loadAllSkillManifests(cwd);
|
|
35
|
-
manifest = all.find((item) => item.name === name) ?? null;
|
|
36
|
-
}
|
|
37
|
-
if (!manifest) {
|
|
38
|
-
throw new Error(`Skill "${name}" not found under .codex/.`);
|
|
39
|
-
}
|
|
40
|
-
const diagnostics = [...validateManifest(manifest)];
|
|
41
|
-
const targets = expandTargets(targetRaw);
|
|
42
|
-
for (const target of targets) {
|
|
43
|
-
diagnostics.push(...getAdapter(target).validate(manifest));
|
|
44
|
-
}
|
|
45
|
-
failIfErrors(diagnostics);
|
|
46
|
-
|
|
47
|
-
for (const target of targets) {
|
|
48
|
-
const adapter = getAdapter(target);
|
|
49
|
-
const outDir = path.join(resolveAgentRoot(cwd, config.agents[target].rootDir), "skills", manifest.name);
|
|
50
|
-
await adapter.emit(manifest, outDir, force);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export async function syncSkills(
|
|
55
|
-
cwd: string,
|
|
56
|
-
targetRaw: string,
|
|
57
|
-
force: boolean
|
|
58
|
-
): Promise<void> {
|
|
59
|
-
const config = await loadConfig(cwd);
|
|
60
|
-
const manifests = await loadAllSkillManifests(cwd);
|
|
61
|
-
if (manifests.length === 0) {
|
|
62
|
-
throw new Error("No skills found under .codex/.");
|
|
63
|
-
}
|
|
64
|
-
const targets = expandTargets(targetRaw);
|
|
65
|
-
|
|
66
|
-
for (const manifest of manifests) {
|
|
67
|
-
const diagnostics = [...validateManifest(manifest)];
|
|
68
|
-
for (const target of targets) {
|
|
69
|
-
diagnostics.push(...getAdapter(target).validate(manifest));
|
|
70
|
-
}
|
|
71
|
-
failIfErrors(diagnostics);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
for (const manifest of manifests) {
|
|
75
|
-
for (const target of targets) {
|
|
76
|
-
const outDir = path.join(resolveAgentRoot(cwd, config.agents[target].rootDir), "skills", manifest.name);
|
|
77
|
-
await getAdapter(target).emit(manifest, outDir, force);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
20
|
export async function initSkills(
|
|
83
21
|
cwd: string,
|
|
84
22
|
agentsRaw: string,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { initSkills } from "../src/skill-core/service.js";
|
|
6
|
+
|
|
7
|
+
describe("init command core flow", () => {
|
|
8
|
+
it("exports all template skills and creates cd-full-name prompts", async () => {
|
|
9
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-init-"));
|
|
10
|
+
await seedTemplateSkill(root, "design-doc");
|
|
11
|
+
await seedTemplateSkill(root, "frontend-standards");
|
|
12
|
+
await initSkills(root, "codex", true);
|
|
13
|
+
|
|
14
|
+
const skills = await readDirNames(path.join(root, ".codex", "skills"));
|
|
15
|
+
expect(skills).toEqual(["design-doc", "frontend-standards"]);
|
|
16
|
+
|
|
17
|
+
const prompts = await readDirNames(path.join(root, ".codex", "prompts"));
|
|
18
|
+
expect(prompts).toEqual(["cd-design-doc.md", "cd-frontend-standards.md"]);
|
|
19
|
+
|
|
20
|
+
const promptContent = await readFile(
|
|
21
|
+
path.join(root, ".codex", "prompts", "cd-frontend-standards.md"),
|
|
22
|
+
"utf8"
|
|
23
|
+
);
|
|
24
|
+
expect(promptContent).toContain("skill: frontend-standards");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("overwrites old prompts/skills when force=true", async () => {
|
|
28
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-force-"));
|
|
29
|
+
await seedTemplateSkill(root, "design-doc");
|
|
30
|
+
await initSkills(root, "codex", true);
|
|
31
|
+
|
|
32
|
+
await writeFile(path.join(root, ".codex", "prompts", "old.md"), "old");
|
|
33
|
+
await writeFile(path.join(root, ".codex", "skills", "old", "SKILL.md"), "x", "utf8").catch(
|
|
34
|
+
async () => {
|
|
35
|
+
await mkdir(path.join(root, ".codex", "skills", "old"), { recursive: true });
|
|
36
|
+
await writeFile(path.join(root, ".codex", "skills", "old", "SKILL.md"), "x", "utf8");
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
await initSkills(root, "codex", true);
|
|
41
|
+
const prompts = await readDirNames(path.join(root, ".codex", "prompts"));
|
|
42
|
+
const skills = await readDirNames(path.join(root, ".codex", "skills"));
|
|
43
|
+
expect(prompts).toEqual(["cd-design-doc.md"]);
|
|
44
|
+
expect(skills).toEqual(["design-doc"]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
async function seedTemplateSkill(root: string, name: string): Promise<void> {
|
|
49
|
+
const dir = path.join(root, "templates", name);
|
|
50
|
+
await mkdir(path.join(dir, "agents"), { recursive: true });
|
|
51
|
+
await writeFile(
|
|
52
|
+
path.join(dir, "SKILL.md"),
|
|
53
|
+
["---", `name: ${name}`, "description: desc", "---", "", `# ${name}`].join("\n"),
|
|
54
|
+
"utf8"
|
|
55
|
+
);
|
|
56
|
+
await writeFile(path.join(dir, "agents", "openai.yaml"), "interface:\n display_name: x\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readDirNames(dir: string): Promise<string[]> {
|
|
60
|
+
const entries = await (await import("node:fs/promises")).readdir(dir, { withFileTypes: true });
|
|
61
|
+
return entries.map((e) => e.name).sort();
|
|
62
|
+
}
|
|
63
|
+
|
package/AGENTS.md
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# AGENTS instructions
|
|
2
|
-
|
|
3
|
-
<INSTRUCTIONS>
|
|
4
|
-
## OpenSpec Setup
|
|
5
|
-
Enabled targets: codex
|
|
6
|
-
### Skills
|
|
7
|
-
- design-doc: 管理业务详细设计文档的全生命周期:首次基线、增量 feature 文档、归档合并。适用于首次开发与迭代改造,避免小改动频繁新建基线版本文件,同时保留 feature 变更历史。
|
|
8
|
-
- frontend-standards: 部门级前端开发规范执行技能,覆盖 Vue3 编码规范、工程配置规范、Git 提交规范与前端最佳实践。用于前端需求开发、重构、代码评审、工程初始化与联调前自检;当任务涉及 Vue3/TypeScript 页面与组件实现、样式规范、ESLint/Prettier/Husky/Commitlint 配置时触发。
|
|
9
|
-
- standards-backend: 部门级后端开发规范执行技能,覆盖后端编码规范、数据库设计规范、传统三层架构约束、DDD架构约束、常用设计模式落地与JUC并发编排规范,并内置数据表基线字段与关联表例外规则。用于需求开发、重构、代码评审、建表SQL设计、接口设计、并发优化与联调前自检等场景;当任务涉及 Java 后端实现、SQL/表结构、分层落位、架构选型(三层或DDD)、可扩展性设计(策略/工厂/责任链/适配器/模板/单例)或线程池/CompletableFuture/CountDownLatch/Semaphore/ReentrantLock/Atomic* 等并发处理时触发。
|
|
10
|
-
### Command Bindings
|
|
11
|
-
- cd-design-doc -> design-doc
|
|
12
|
-
- cd-frontend-standards -> frontend-standards
|
|
13
|
-
- cd-standards-backend -> standards-backend
|
|
14
|
-
</INSTRUCTIONS>
|
package/CLAUDE.md
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# claudecode OpenSpec-style setup
|
|
2
|
-
|
|
3
|
-
## Installed skills
|
|
4
|
-
- frontend-develop-standard: Generated from templates/frontend_develop_standard.md
|
|
5
|
-
|
|
6
|
-
## Commands
|
|
7
|
-
- /opsx:propose -> frontend-develop-standard
|
|
8
|
-
- /opsx:explore -> frontend-develop-standard
|
|
9
|
-
- /opsx:apply -> frontend-develop-standard
|
|
10
|
-
- /opsx:archive -> frontend-develop-standard
|
package/src/task-core/parser.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { TaskItem } from "./types.js";
|
|
4
|
-
|
|
5
|
-
export function splitMarkdownToTasks(
|
|
6
|
-
markdown: string,
|
|
7
|
-
sourcePath: string,
|
|
8
|
-
rootTitle: string
|
|
9
|
-
): TaskItem[] {
|
|
10
|
-
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
11
|
-
const lines = normalized.split("\n");
|
|
12
|
-
const headingStack: string[] = [];
|
|
13
|
-
const extracted: string[] = [];
|
|
14
|
-
|
|
15
|
-
for (const line of lines) {
|
|
16
|
-
const headingMatch = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
17
|
-
if (headingMatch) {
|
|
18
|
-
const level = headingMatch[1].length;
|
|
19
|
-
const title = headingMatch[2].trim();
|
|
20
|
-
headingStack.splice(level - 1);
|
|
21
|
-
headingStack[level - 1] = title;
|
|
22
|
-
continue;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const listMatch = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.+?)\s*$/);
|
|
26
|
-
if (!listMatch) continue;
|
|
27
|
-
const item = listMatch[1].trim();
|
|
28
|
-
const prefix = headingStack.filter(Boolean).join(" / ");
|
|
29
|
-
extracted.push(prefix ? `${prefix} - ${item}` : item);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (extracted.length === 0) {
|
|
33
|
-
for (const line of lines) {
|
|
34
|
-
const headingMatch = line.match(/^#{2,6}\s+(.+?)\s*$/);
|
|
35
|
-
if (headingMatch) extracted.push(headingMatch[1].trim());
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (extracted.length === 0) {
|
|
40
|
-
extracted.push(rootTitle);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return dedupe(extracted).map((title, idx) =>
|
|
44
|
-
createTaskItem({
|
|
45
|
-
sourcePath,
|
|
46
|
-
title,
|
|
47
|
-
order: idx + 1
|
|
48
|
-
})
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function createTaskItem(input: {
|
|
53
|
-
sourcePath: string;
|
|
54
|
-
title: string;
|
|
55
|
-
order: number;
|
|
56
|
-
}): TaskItem {
|
|
57
|
-
const now = new Date().toISOString();
|
|
58
|
-
return {
|
|
59
|
-
id: buildStableId(input.sourcePath, input.title, input.order),
|
|
60
|
-
title: input.title,
|
|
61
|
-
status: "todo",
|
|
62
|
-
source: normalizePath(input.sourcePath),
|
|
63
|
-
createdAt: now,
|
|
64
|
-
updatedAt: now
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function buildStableId(sourcePath: string, title: string, order: number): string {
|
|
69
|
-
const digest = createHash("sha1")
|
|
70
|
-
.update(`${normalizePath(sourcePath)}::${title}::${order}`)
|
|
71
|
-
.digest("hex");
|
|
72
|
-
return digest.slice(0, 10);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function normalizePath(input: string): string {
|
|
76
|
-
return input.replaceAll("\\", "/").replaceAll(path.sep, "/");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function dedupe(items: string[]): string[] {
|
|
80
|
-
const seen = new Set<string>();
|
|
81
|
-
const result: string[] = [];
|
|
82
|
-
for (const item of items) {
|
|
83
|
-
if (seen.has(item)) continue;
|
|
84
|
-
seen.add(item);
|
|
85
|
-
result.push(item);
|
|
86
|
-
}
|
|
87
|
-
return result;
|
|
88
|
-
}
|
|
89
|
-
|
package/src/task-core/service.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { pathExists } from "../utils/fs.js";
|
|
4
|
-
import { splitMarkdownToTasks } from "./parser.js";
|
|
5
|
-
import {
|
|
6
|
-
archiveTask,
|
|
7
|
-
initTaskWorkspace,
|
|
8
|
-
refreshArchiveIndex,
|
|
9
|
-
refreshTaskIndex,
|
|
10
|
-
saveTask,
|
|
11
|
-
updateTaskStatus
|
|
12
|
-
} from "./storage.js";
|
|
13
|
-
import { TaskStatus } from "./types.js";
|
|
14
|
-
|
|
15
|
-
export async function splitTasks(
|
|
16
|
-
cwd: string,
|
|
17
|
-
fromFile: string,
|
|
18
|
-
title: string
|
|
19
|
-
): Promise<number> {
|
|
20
|
-
const absoluteInput = path.resolve(cwd, fromFile);
|
|
21
|
-
if (!(await pathExists(absoluteInput))) {
|
|
22
|
-
throw new Error(`Input file not found: ${fromFile}`);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const markdown = await readFile(absoluteInput, "utf8");
|
|
26
|
-
const tasks = splitMarkdownToTasks(markdown, fromFile, title);
|
|
27
|
-
await initTaskWorkspace(cwd);
|
|
28
|
-
for (const task of tasks) {
|
|
29
|
-
await saveTask(cwd, task);
|
|
30
|
-
}
|
|
31
|
-
await refreshTaskIndex(cwd);
|
|
32
|
-
return tasks.length;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function archiveTaskById(cwd: string, id: string): Promise<void> {
|
|
36
|
-
await archiveTask(cwd, id);
|
|
37
|
-
await refreshTaskIndex(cwd);
|
|
38
|
-
await refreshArchiveIndex(cwd);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export async function updateTask(
|
|
42
|
-
cwd: string,
|
|
43
|
-
id: string,
|
|
44
|
-
status: TaskStatus
|
|
45
|
-
): Promise<void> {
|
|
46
|
-
await updateTaskStatus(cwd, id, status);
|
|
47
|
-
await refreshTaskIndex(cwd);
|
|
48
|
-
}
|
|
49
|
-
|
package/src/task-core/storage.ts
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { readFile, rm, writeFile, readdir } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { ensureDir, pathExists } from "../utils/fs.js";
|
|
4
|
-
import { parseFrontmatter, stringifyFrontmatter } from "../utils/frontmatter.js";
|
|
5
|
-
import { ArchiveRecord, TaskItem, TaskStatus } from "./types.js";
|
|
6
|
-
|
|
7
|
-
const ROOT_DIR = ".cdspec";
|
|
8
|
-
const TASKS_DIR = "tasks";
|
|
9
|
-
const ARCHIVE_DIR = "archive";
|
|
10
|
-
|
|
11
|
-
export function taskDir(cwd: string): string {
|
|
12
|
-
return path.join(cwd, ROOT_DIR, TASKS_DIR);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function archiveDir(cwd: string): string {
|
|
16
|
-
return path.join(cwd, ROOT_DIR, ARCHIVE_DIR);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export async function initTaskWorkspace(cwd: string): Promise<void> {
|
|
20
|
-
await ensureDir(taskDir(cwd));
|
|
21
|
-
await ensureDir(archiveDir(cwd));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export async function saveTask(cwd: string, task: TaskItem): Promise<void> {
|
|
25
|
-
await initTaskWorkspace(cwd);
|
|
26
|
-
const targetFile = path.join(taskDir(cwd), `${task.id}.md`);
|
|
27
|
-
const body = `# ${task.title}\n\n- status: ${task.status}\n- source: ${task.source}\n`;
|
|
28
|
-
const content = stringifyFrontmatter(
|
|
29
|
-
{
|
|
30
|
-
id: task.id,
|
|
31
|
-
title: task.title,
|
|
32
|
-
status: task.status,
|
|
33
|
-
source: task.source,
|
|
34
|
-
created_at: task.createdAt,
|
|
35
|
-
updated_at: task.updatedAt
|
|
36
|
-
},
|
|
37
|
-
body
|
|
38
|
-
);
|
|
39
|
-
await writeFile(targetFile, content, "utf8");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export async function loadTask(cwd: string, id: string): Promise<TaskItem | null> {
|
|
43
|
-
const file = path.join(taskDir(cwd), `${id}.md`);
|
|
44
|
-
if (!(await pathExists(file))) return null;
|
|
45
|
-
const raw = await readFile(file, "utf8");
|
|
46
|
-
const parsed = parseFrontmatter(raw);
|
|
47
|
-
return {
|
|
48
|
-
id: parsed.attributes.id,
|
|
49
|
-
title: parsed.attributes.title,
|
|
50
|
-
status: parseStatus(parsed.attributes.status),
|
|
51
|
-
source: parsed.attributes.source,
|
|
52
|
-
createdAt: parsed.attributes.created_at,
|
|
53
|
-
updatedAt: parsed.attributes.updated_at
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export async function updateTaskStatus(
|
|
58
|
-
cwd: string,
|
|
59
|
-
id: string,
|
|
60
|
-
next: TaskStatus
|
|
61
|
-
): Promise<TaskItem> {
|
|
62
|
-
const current = await loadTask(cwd, id);
|
|
63
|
-
if (!current) {
|
|
64
|
-
throw new Error(`Task "${id}" not found.`);
|
|
65
|
-
}
|
|
66
|
-
if (!canTransition(current.status, next)) {
|
|
67
|
-
throw new Error(`Invalid status transition: ${current.status} -> ${next}.`);
|
|
68
|
-
}
|
|
69
|
-
const updated: TaskItem = {
|
|
70
|
-
...current,
|
|
71
|
-
status: next,
|
|
72
|
-
updatedAt: new Date().toISOString()
|
|
73
|
-
};
|
|
74
|
-
await saveTask(cwd, updated);
|
|
75
|
-
return updated;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export async function archiveTask(cwd: string, id: string): Promise<ArchiveRecord> {
|
|
79
|
-
const current = await loadTask(cwd, id);
|
|
80
|
-
if (!current) {
|
|
81
|
-
throw new Error(`Task "${id}" not found.`);
|
|
82
|
-
}
|
|
83
|
-
if (current.status !== "done") {
|
|
84
|
-
throw new Error(`Task "${id}" must be done before archive.`);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const archived: ArchiveRecord = {
|
|
88
|
-
...current,
|
|
89
|
-
archivedAt: new Date().toISOString()
|
|
90
|
-
};
|
|
91
|
-
const sourceFile = path.join(taskDir(cwd), `${id}.md`);
|
|
92
|
-
const archiveFile = path.join(archiveDir(cwd), `${id}.md`);
|
|
93
|
-
const body = `# ${archived.title}\n\nArchived from ${sourceFile.replaceAll("\\", "/")}.\n`;
|
|
94
|
-
const content = stringifyFrontmatter(
|
|
95
|
-
{
|
|
96
|
-
id: archived.id,
|
|
97
|
-
title: archived.title,
|
|
98
|
-
status: archived.status,
|
|
99
|
-
source: archived.source,
|
|
100
|
-
created_at: archived.createdAt,
|
|
101
|
-
updated_at: archived.updatedAt,
|
|
102
|
-
archived_at: archived.archivedAt
|
|
103
|
-
},
|
|
104
|
-
body
|
|
105
|
-
);
|
|
106
|
-
await writeFile(archiveFile, content, "utf8");
|
|
107
|
-
await rm(sourceFile, { force: true });
|
|
108
|
-
return archived;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export async function refreshTaskIndex(cwd: string): Promise<void> {
|
|
112
|
-
await initTaskWorkspace(cwd);
|
|
113
|
-
const tasks = await loadByDir(taskDir(cwd));
|
|
114
|
-
const grouped = {
|
|
115
|
-
todo: tasks.filter((task) => task.status === "todo"),
|
|
116
|
-
in_progress: tasks.filter((task) => task.status === "in_progress"),
|
|
117
|
-
done: tasks.filter((task) => task.status === "done")
|
|
118
|
-
};
|
|
119
|
-
const lines = [
|
|
120
|
-
"# Task Index",
|
|
121
|
-
"",
|
|
122
|
-
"## todo",
|
|
123
|
-
...grouped.todo.map((task) => `- [${task.id}] ${task.title}`),
|
|
124
|
-
"",
|
|
125
|
-
"## in_progress",
|
|
126
|
-
...grouped.in_progress.map((task) => `- [${task.id}] ${task.title}`),
|
|
127
|
-
"",
|
|
128
|
-
"## done",
|
|
129
|
-
...grouped.done.map((task) => `- [${task.id}] ${task.title}`),
|
|
130
|
-
""
|
|
131
|
-
];
|
|
132
|
-
await writeFile(path.join(taskDir(cwd), "index.md"), lines.join("\n"), "utf8");
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export async function refreshArchiveIndex(cwd: string): Promise<void> {
|
|
136
|
-
await initTaskWorkspace(cwd);
|
|
137
|
-
const records = await loadByDir(archiveDir(cwd));
|
|
138
|
-
const lines = [
|
|
139
|
-
"# Archive Index",
|
|
140
|
-
"",
|
|
141
|
-
...records.map((record) => `- [${record.id}] ${record.title}`),
|
|
142
|
-
""
|
|
143
|
-
];
|
|
144
|
-
await writeFile(path.join(archiveDir(cwd), "index.md"), lines.join("\n"), "utf8");
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async function loadByDir(dirPath: string): Promise<TaskItem[]> {
|
|
148
|
-
if (!(await pathExists(dirPath))) return [];
|
|
149
|
-
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
150
|
-
const result: TaskItem[] = [];
|
|
151
|
-
for (const entry of entries) {
|
|
152
|
-
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "index.md") continue;
|
|
153
|
-
const raw = await readFile(path.join(dirPath, entry.name), "utf8");
|
|
154
|
-
const parsed = parseFrontmatter(raw);
|
|
155
|
-
result.push({
|
|
156
|
-
id: parsed.attributes.id,
|
|
157
|
-
title: parsed.attributes.title,
|
|
158
|
-
status: parseStatus(parsed.attributes.status),
|
|
159
|
-
source: parsed.attributes.source,
|
|
160
|
-
createdAt: parsed.attributes.created_at,
|
|
161
|
-
updatedAt: parsed.attributes.updated_at
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
return result;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function parseStatus(input: string): TaskStatus {
|
|
168
|
-
if (input === "todo" || input === "in_progress" || input === "done") return input;
|
|
169
|
-
return "todo";
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function canTransition(current: TaskStatus, next: TaskStatus): boolean {
|
|
173
|
-
if (current === next) return true;
|
|
174
|
-
if (current === "todo" && next === "in_progress") return true;
|
|
175
|
-
if (current === "in_progress" && next === "done") return true;
|
|
176
|
-
return false;
|
|
177
|
-
}
|
package/src/task-core/types.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export type TaskStatus = "todo" | "in_progress" | "done";
|
|
2
|
-
|
|
3
|
-
export interface TaskItem {
|
|
4
|
-
id: string;
|
|
5
|
-
title: string;
|
|
6
|
-
status: TaskStatus;
|
|
7
|
-
source: string;
|
|
8
|
-
createdAt: string;
|
|
9
|
-
updatedAt: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface ArchiveRecord extends TaskItem {
|
|
13
|
-
archivedAt: string;
|
|
14
|
-
}
|
|
15
|
-
|
package/tests/skill.test.ts
DELETED
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { addSkill, initSkills, listSkills } from "../src/skill-core/service.js";
|
|
6
|
-
|
|
7
|
-
async function seedSkillRepo(): Promise<string> {
|
|
8
|
-
const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-skill-"));
|
|
9
|
-
const skillDir = path.join(root, ".codex", "demo-skill");
|
|
10
|
-
await mkdir(path.join(skillDir, "agents"), { recursive: true });
|
|
11
|
-
await mkdir(path.join(skillDir, "references"), { recursive: true });
|
|
12
|
-
|
|
13
|
-
await writeFile(
|
|
14
|
-
path.join(skillDir, "SKILL.md"),
|
|
15
|
-
[
|
|
16
|
-
"---",
|
|
17
|
-
"name: demo-skill",
|
|
18
|
-
"description: demo description",
|
|
19
|
-
"---",
|
|
20
|
-
"",
|
|
21
|
-
"# Demo Skill",
|
|
22
|
-
"",
|
|
23
|
-
"body"
|
|
24
|
-
].join("\n"),
|
|
25
|
-
"utf8"
|
|
26
|
-
);
|
|
27
|
-
await writeFile(path.join(skillDir, "agents", "openai.yaml"), "x: 1\n", "utf8");
|
|
28
|
-
await writeFile(path.join(skillDir, "references", "readme.md"), "ref\n", "utf8");
|
|
29
|
-
return root;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe("skill service", () => {
|
|
33
|
-
it("lists valid skills", async () => {
|
|
34
|
-
const root = await seedSkillRepo();
|
|
35
|
-
const names = await listSkills(root);
|
|
36
|
-
expect(names).toEqual(["demo-skill"]);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("exports skill to all targets", async () => {
|
|
40
|
-
const root = await seedSkillRepo();
|
|
41
|
-
await addSkill(root, "demo-skill", "all", false);
|
|
42
|
-
|
|
43
|
-
const codexSkill = await readFile(
|
|
44
|
-
path.join(root, ".codex", "skills", "demo-skill", "SKILL.md"),
|
|
45
|
-
"utf8"
|
|
46
|
-
);
|
|
47
|
-
const claudecodeMeta = await readFile(
|
|
48
|
-
path.join(root, ".claude", "skills", "demo-skill", "claudecode.skill.json"),
|
|
49
|
-
"utf8"
|
|
50
|
-
);
|
|
51
|
-
const iflowMeta = await readFile(
|
|
52
|
-
path.join(root, ".iflow", "skills", "demo-skill", "iflow.skill.yaml"),
|
|
53
|
-
"utf8"
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
expect(codexSkill).toContain("name: demo-skill");
|
|
57
|
-
expect(claudecodeMeta).toContain('"name": "demo-skill"');
|
|
58
|
-
expect(iflowMeta).toContain('name: "demo-skill"');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("rejects conflict without force", async () => {
|
|
62
|
-
const root = await seedSkillRepo();
|
|
63
|
-
await addSkill(root, "demo-skill", "codex", false);
|
|
64
|
-
await expect(addSkill(root, "demo-skill", "codex", false)).rejects.toThrow(
|
|
65
|
-
/Use --force/
|
|
66
|
-
);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("supports ifow alias", async () => {
|
|
70
|
-
const root = await seedSkillRepo();
|
|
71
|
-
await addSkill(root, "demo-skill", "ifow", false);
|
|
72
|
-
const iflowMeta = await readFile(
|
|
73
|
-
path.join(root, ".iflow", "skills", "demo-skill", "iflow.skill.yaml"),
|
|
74
|
-
"utf8"
|
|
75
|
-
);
|
|
76
|
-
expect(iflowMeta).toContain('entry: "prompt.md"');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("generates agent config files on init", async () => {
|
|
80
|
-
const root = await seedSkillRepo();
|
|
81
|
-
const files = await initSkills(root, "codex,ifow", false);
|
|
82
|
-
expect(files.some((file) => file.endsWith(path.join(".codex", "AGENTS.md")))).toBe(true);
|
|
83
|
-
expect(files.some((file) => file.endsWith(path.join(".iflow", "IFLOW.md")))).toBe(true);
|
|
84
|
-
expect(files.some((file) => file.endsWith(path.join("AGENTS.md")))).toBe(true);
|
|
85
|
-
|
|
86
|
-
const agents = await readFile(path.join(root, ".codex", "AGENTS.md"), "utf8");
|
|
87
|
-
const iflow = await readFile(path.join(root, ".iflow", "IFLOW.md"), "utf8");
|
|
88
|
-
expect(agents).toContain("demo-skill");
|
|
89
|
-
expect(iflow).toContain("/cd-demo-skill -> demo-skill");
|
|
90
|
-
const command = await readFile(path.join(root, ".codex", "prompts", "cd-demo-skill.md"), "utf8");
|
|
91
|
-
expect(command).toContain("skill: demo-skill");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("creates default skill when .codex is empty", async () => {
|
|
95
|
-
const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-empty-"));
|
|
96
|
-
const files = await initSkills(root, "codex", false);
|
|
97
|
-
expect(files.some((file) => file.endsWith(path.join(".codex", "AGENTS.md")))).toBe(true);
|
|
98
|
-
expect(files.some((file) => file.endsWith(path.join("AGENTS.md")))).toBe(true);
|
|
99
|
-
const exported = await readFile(
|
|
100
|
-
path.join(root, ".codex", "skills", "openspec-core", "SKILL.md"),
|
|
101
|
-
"utf8"
|
|
102
|
-
);
|
|
103
|
-
await expect(readFile(path.join(root, ".cdspec", "seed-skills", "openspec-core", "SKILL.md"), "utf8")).rejects.toThrow();
|
|
104
|
-
await expect(readFile(path.join(root, ".codex", "openspec-core", "SKILL.md"), "utf8")).rejects.toThrow();
|
|
105
|
-
expect(exported).toContain("OpenSpec Core Skill");
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("supports configurable agent output paths", async () => {
|
|
109
|
-
const root = await seedSkillRepo();
|
|
110
|
-
await writeFile(
|
|
111
|
-
path.join(root, "cdspec.config.yaml"),
|
|
112
|
-
[
|
|
113
|
-
"commandBindings:",
|
|
114
|
-
" - id: plan",
|
|
115
|
-
" skill: demo-skill",
|
|
116
|
-
" description: Plan workflow",
|
|
117
|
-
"agents:",
|
|
118
|
-
" codex:",
|
|
119
|
-
" rootDir: .codex",
|
|
120
|
-
" commandsDir: prompts",
|
|
121
|
-
" commandFilePattern: custom-{id}.md",
|
|
122
|
-
" slashPattern: /custom-{id}",
|
|
123
|
-
" guideFile: AGENTS.md",
|
|
124
|
-
" claudecode:",
|
|
125
|
-
" rootDir: .claude",
|
|
126
|
-
" commandsDir: commands/opsx",
|
|
127
|
-
" commandFilePattern: \"{id}.md\"",
|
|
128
|
-
" slashPattern: /opsx:{id}",
|
|
129
|
-
" guideFile: CLAUDE.md",
|
|
130
|
-
" guideAtProjectRoot: true",
|
|
131
|
-
" iflow:",
|
|
132
|
-
" rootDir: .iflow",
|
|
133
|
-
" commandsDir: commands",
|
|
134
|
-
" commandFilePattern: opsx-{id}.md",
|
|
135
|
-
" slashPattern: /opsx-{id}",
|
|
136
|
-
" guideFile: IFLOW.md"
|
|
137
|
-
].join("\n"),
|
|
138
|
-
"utf8"
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
await addSkill(root, "demo-skill", "codex", false);
|
|
142
|
-
const exported = await readFile(path.join(root, ".codex", "skills", "demo-skill", "SKILL.md"), "utf8");
|
|
143
|
-
expect(exported).toContain("name: demo-skill");
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("binds commands to template-derived skill when no .codex skills exist", async () => {
|
|
147
|
-
const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-template-bind-"));
|
|
148
|
-
await mkdir(path.join(root, "templates"), { recursive: true });
|
|
149
|
-
await writeFile(
|
|
150
|
-
path.join(root, "templates", "frontend_develop_standard.md"),
|
|
151
|
-
"# Frontend Develop Standard\n\nTemplate body",
|
|
152
|
-
"utf8"
|
|
153
|
-
);
|
|
154
|
-
await initSkills(root, "codex", false);
|
|
155
|
-
|
|
156
|
-
const exported = await readFile(
|
|
157
|
-
path.join(root, ".codex", "skills", "frontend-develop-standard", "SKILL.md"),
|
|
158
|
-
"utf8"
|
|
159
|
-
);
|
|
160
|
-
expect(exported).toContain("name: frontend-develop-standard");
|
|
161
|
-
|
|
162
|
-
const prompt = await readFile(path.join(root, ".codex", "prompts", "cd-frontend-develop-standard.md"), "utf8");
|
|
163
|
-
expect(prompt).toContain("skill: frontend-develop-standard");
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("prioritizes templates over existing .codex skills for init binding", async () => {
|
|
167
|
-
const root = await seedSkillRepo();
|
|
168
|
-
await mkdir(path.join(root, "templates"), { recursive: true });
|
|
169
|
-
await writeFile(path.join(root, "templates", "my_template.md"), "# My Template\n", "utf8");
|
|
170
|
-
|
|
171
|
-
await initSkills(root, "codex", true);
|
|
172
|
-
const prompt = await readFile(path.join(root, ".codex", "prompts", "cd-my-template.md"), "utf8");
|
|
173
|
-
expect(prompt).toContain("skill: my-template");
|
|
174
|
-
const exported = await readFile(path.join(root, ".codex", "skills", "my-template", "SKILL.md"), "utf8");
|
|
175
|
-
expect(exported).toContain("name: my-template");
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("uses full skill name for command ids", async () => {
|
|
179
|
-
const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-fullname-"));
|
|
180
|
-
await mkdir(path.join(root, "templates", "design-doc", "agents"), { recursive: true });
|
|
181
|
-
await writeFile(
|
|
182
|
-
path.join(root, "templates", "design-doc", "SKILL.md"),
|
|
183
|
-
["---", "name: design-doc", "description: d", "---", "", "# d"].join("\n"),
|
|
184
|
-
"utf8"
|
|
185
|
-
);
|
|
186
|
-
await writeFile(path.join(root, "templates", "design-doc", "agents", "openai.yaml"), "x: 1\n");
|
|
187
|
-
await initSkills(root, "codex", false);
|
|
188
|
-
const prompt = await readFile(path.join(root, ".codex", "prompts", "cd-design-doc.md"), "utf8");
|
|
189
|
-
expect(prompt).toContain("skill: design-doc");
|
|
190
|
-
});
|
|
191
|
-
});
|
package/tests/task.test.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { archiveTaskById, splitTasks, updateTask } from "../src/task-core/service.js";
|
|
6
|
-
import { loadTask } from "../src/task-core/storage.js";
|
|
7
|
-
|
|
8
|
-
describe("task workflow", () => {
|
|
9
|
-
it("splits markdown into tasks and creates index", async () => {
|
|
10
|
-
const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-task-"));
|
|
11
|
-
const source = path.join(root, "input.md");
|
|
12
|
-
await writeFile(
|
|
13
|
-
source,
|
|
14
|
-
["# Feature", "", "## Backend", "- API schema", "## Frontend", "- Build page"].join(
|
|
15
|
-
"\n"
|
|
16
|
-
),
|
|
17
|
-
"utf8"
|
|
18
|
-
);
|
|
19
|
-
|
|
20
|
-
const count = await splitTasks(root, "input.md", "Root Task");
|
|
21
|
-
expect(count).toBe(2);
|
|
22
|
-
|
|
23
|
-
const index = await readFile(path.join(root, ".cdspec", "tasks", "index.md"), "utf8");
|
|
24
|
-
expect(index).toContain("## todo");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("enforces status transitions and archive done tasks", async () => {
|
|
28
|
-
const root = await mkdtemp(path.join(os.tmpdir(), "cdspec-task-"));
|
|
29
|
-
const source = path.join(root, "plan.md");
|
|
30
|
-
await writeFile(source, "- one\n", "utf8");
|
|
31
|
-
await splitTasks(root, "plan.md", "Task Plan");
|
|
32
|
-
|
|
33
|
-
const ids = await extractTaskIds(root);
|
|
34
|
-
const id = ids[0];
|
|
35
|
-
|
|
36
|
-
await expect(archiveTaskById(root, id)).rejects.toThrow(/must be done/);
|
|
37
|
-
|
|
38
|
-
await expect(updateTask(root, id, "done")).rejects.toThrow(/Invalid status transition/);
|
|
39
|
-
await updateTask(root, id, "in_progress");
|
|
40
|
-
await updateTask(root, id, "done");
|
|
41
|
-
await archiveTaskById(root, id);
|
|
42
|
-
|
|
43
|
-
const task = await loadTask(root, id);
|
|
44
|
-
expect(task).toBeNull();
|
|
45
|
-
const archive = await readFile(path.join(root, ".cdspec", "archive", `${id}.md`), "utf8");
|
|
46
|
-
expect(archive).toContain("archived_at");
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
async function extractTaskIds(root: string): Promise<string[]> {
|
|
51
|
-
const index = await readFile(path.join(root, ".cdspec", "tasks", "index.md"), "utf8");
|
|
52
|
-
const ids = [...index.matchAll(/\[([a-f0-9]{10})\]/g)].map((match) => match[1]);
|
|
53
|
-
return ids;
|
|
54
|
-
}
|
|
55
|
-
|