@lythos/skill-deck 0.7.2 → 0.9.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/src/remove.ts ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * deck-remove.ts — Remove a skill from the declaration layer
4
+ *
5
+ * Deletes the entry from skill-deck.toml and removes the working-set symlink.
6
+ * Does NOT touch the cold pool (use `deck prune` for material-layer GC).
7
+ */
8
+
9
+ import { parse as parseToml, stringify as stringifyToml } from "@iarna/toml";
10
+ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
11
+ import { resolve, dirname, join } from "node:path";
12
+ import { findDeckToml, expandHome } from "./link.js";
13
+ import { parseDeck } from "./parse-deck.js";
14
+
15
+ export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: string): void {
16
+ const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
17
+ const DECK_PATH = cliDeck
18
+ ? resolve(cliDeck)
19
+ : findDeckToml(process.cwd()) || resolve("skill-deck.toml");
20
+
21
+ if (!existsSync(DECK_PATH)) {
22
+ console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
23
+ console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`);
24
+ process.exit(1);
25
+ }
26
+
27
+ const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
28
+ const deckRaw = readFileSync(DECK_PATH, "utf-8");
29
+ const deck = parseToml(deckRaw) as any;
30
+
31
+ const WORKING_SET = expandHome(deck.deck?.working_set || ".claude/skills", PROJECT_DIR);
32
+
33
+ // ── 定位目标 ────────────────────────────────────────────────
34
+
35
+ const { entries: parsedEntries } = parseDeck(deckRaw);
36
+
37
+ // Match by alias first, then by path
38
+ const match = parsedEntries.find(e => e.alias === target || e.path === target);
39
+
40
+ if (!match) {
41
+ console.error(`❌ Skill not found in deck: ${target}`);
42
+ const aliases = parsedEntries.map(e => e.alias);
43
+ if (aliases.length > 0) {
44
+ console.error(` Declared aliases: ${aliases.join(", ")}`);
45
+ }
46
+ process.exit(1);
47
+ }
48
+
49
+ // ── 删 deck.toml 条目 ───────────────────────────────────────
50
+
51
+ const section = match.type;
52
+ const alias = match.alias;
53
+
54
+ if (deck[section]?.skills) {
55
+ if (Array.isArray(deck[section].skills)) {
56
+ // Legacy string-array format
57
+ deck[section].skills = deck[section].skills.filter((name: string) => {
58
+ const a = name.split("/").pop() || name;
59
+ return a !== alias;
60
+ });
61
+ if (deck[section].skills.length === 0) {
62
+ delete deck[section].skills;
63
+ }
64
+ } else if (typeof deck[section].skills === "object") {
65
+ // Dict format
66
+ delete deck[section].skills[alias];
67
+ if (Object.keys(deck[section].skills).length === 0) {
68
+ delete deck[section].skills;
69
+ }
70
+ }
71
+ // Clean up empty section
72
+ if (Object.keys(deck[section] || {}).length === 0) {
73
+ delete deck[section];
74
+ }
75
+ }
76
+
77
+ writeFileSync(DECK_PATH, stringifyToml(deck));
78
+ console.log(`📝 Removed "${alias}" from [${section}.skills] in ${DECK_PATH}`);
79
+
80
+ // ── 删 working set symlink ──────────────────────────────────
81
+
82
+ const symlinkPath = join(WORKING_SET, alias);
83
+ if (existsSync(symlinkPath)) {
84
+ rmSync(symlinkPath, { recursive: true, force: true });
85
+ console.log(` 🗑️ Removed symlink: ${symlinkPath}`);
86
+ } else {
87
+ console.log(` ⚠️ Symlink not found: ${symlinkPath}`);
88
+ }
89
+
90
+ console.log(`\n💡 Cold pool untouched. Run 'bunx @lythos/skill-deck prune' to GC unreferenced repos.`);
91
+ }
package/src/schema.ts CHANGED
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  // ── 单个已链接 Skill ────────────────────────────────────────
4
4
  export const LinkedSkillSchema = z.object({
5
5
  name: z.string(),
6
+ alias: z.string(),
6
7
  deck_niche: z.string(),
7
8
  type: z.enum(["innate", "tool", "combo", "transient"]),
8
9
  source: z.string(),
@@ -49,6 +50,15 @@ export const SkillDeckLockSchema = z.object({
49
50
  constraints: ConstraintReportSchema,
50
51
  });
51
52
 
53
+ // ── Skill entry (alias-as-key dict body) ──────────────────────
54
+ export const SkillEntrySchema = z.object({
55
+ path: z.string().min(1),
56
+ role: z.string().optional(),
57
+ why_in_deck: z.string().optional(),
58
+ }).passthrough();
59
+
60
+ export type SkillEntry = z.infer<typeof SkillEntrySchema>;
61
+
52
62
  export type LinkedSkill = z.infer<typeof LinkedSkillSchema>;
53
63
  export type ConstraintReport = z.infer<typeof ConstraintReportSchema>;
54
64
  export type SkillDeckLock = z.infer<typeof SkillDeckLockSchema>;
package/src/update.ts CHANGED
@@ -1,154 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * deck-update.ts — Update declared skills from their upstream sources
3
+ * deck-update.ts — Deprecated alias for refresh
4
4
  *
5
- * 读取 skill-deck.toml 遍历声明的 skill git 来源执行 pull。
6
- * 职责:让冷池跟上上游版本。
7
- * 不做:下载新 skill(那是 add 的职责)、修改 deck.toml、同步 working set。
5
+ * Kept for backward compatibility. Will be removed in v1.0.0.
8
6
  */
9
7
 
10
- import { parse as parseToml } from "@iarna/toml";
11
- import { existsSync, readdirSync, readFileSync } from "node:fs";
12
- import { execSync } from "node:child_process";
13
- import { resolve, dirname, join, relative } from "node:path";
14
- import { findDeckToml, expandHome, findSource } from "./link.js";
8
+ import { refreshDeck } from "./refresh.js";
15
9
 
16
- interface UpdateResult {
17
- name: string;
18
- path: string;
19
- status: "updated" | "up-to-date" | "skipped" | "failed" | "not-git";
20
- message?: string;
10
+ export function updateDeck(cliDeckPath?: string, cliWorkdir?: string, target?: string): void {
11
+ console.warn("⚠️ `deck update` is deprecated. Use `deck refresh` instead. (Removed in v1.0.0)");
12
+ refreshDeck(cliDeckPath, cliWorkdir, target);
21
13
  }
22
-
23
- function isGitRepo(dir: string): boolean {
24
- return existsSync(join(dir, ".git"));
25
- }
26
-
27
- function gitPull(dir: string): { status: "updated" | "up-to-date" | "failed"; message: string } {
28
- try {
29
- const output = execSync("git pull", {
30
- cwd: dir,
31
- encoding: "utf-8",
32
- stdio: ["pipe", "pipe", "pipe"],
33
- timeout: 30000,
34
- }).trim();
35
-
36
- if (output.includes("Already up to date") || output.includes("Already up-to-date")) {
37
- return { status: "up-to-date", message: output };
38
- }
39
- return { status: "updated", message: output };
40
- } catch (err: any) {
41
- const stderr = err.stderr?.toString() || err.message || "";
42
- return { status: "failed", message: stderr.trim() };
43
- }
44
- }
45
-
46
- export function updateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
47
- const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
48
- const DECK_PATH = cliDeck
49
- ? resolve(cliDeck)
50
- : findDeckToml(process.cwd()) || resolve("skill-deck.toml");
51
-
52
- if (!existsSync(DECK_PATH)) {
53
- console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
54
- console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`);
55
- process.exit(1);
56
- }
57
-
58
- const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
59
- const deckRaw = readFileSync(DECK_PATH, "utf-8");
60
- const deck = parseToml(deckRaw) as any;
61
-
62
- const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
63
-
64
- // ── 收集声明 ────────────────────────────────────────────────
65
-
66
- const declared: { name: string; type: string }[] = [];
67
-
68
- for (const section of ["innate", "tool", "combo"] as const) {
69
- for (const name of (deck[section]?.skills || [])) {
70
- if (!name || typeof name !== "string") continue;
71
- declared.push({ name, type: section });
72
- }
73
- }
74
-
75
- if (declared.length === 0) {
76
- console.log("📭 No skills declared in deck. Nothing to update.");
77
- process.exit(0);
78
- }
79
-
80
- // ── 执行更新 ────────────────────────────────────────────────
81
-
82
- const results: UpdateResult[] = [];
83
- let updated = 0;
84
- let upToDate = 0;
85
- let skipped = 0;
86
- let failed = 0;
87
-
88
- for (const { name, type } of declared) {
89
- const result = findSource(name, COLD_POOL, PROJECT_DIR);
90
-
91
- if (result.error || !result.path) {
92
- results.push({ name, path: "", status: "failed", message: result.error || "Skill not found" });
93
- failed++;
94
- continue;
95
- }
96
-
97
- const path = result.path;
98
-
99
- // localhost skills are user-managed; skip
100
- const relativePath = relative(COLD_POOL, path);
101
- if (relativePath.startsWith("localhost")) {
102
- results.push({ name, path: relativePath, status: "skipped", message: "localhost skill — user-managed" });
103
- skipped++;
104
- continue;
105
- }
106
-
107
- if (!isGitRepo(path)) {
108
- results.push({ name, path: relativePath, status: "not-git", message: "Not a git repository" });
109
- skipped++;
110
- continue;
111
- }
112
-
113
- const pullResult = gitPull(path);
114
- results.push({ name, path: relativePath, status: pullResult.status, message: pullResult.message });
115
-
116
- if (pullResult.status === "updated") updated++;
117
- else if (pullResult.status === "up-to-date") upToDate++;
118
- else failed++;
119
- }
120
-
121
- // ── 报告 ────────────────────────────────────────────────────
122
-
123
- console.log(`\n📦 Skill Update Report — ${declared.length} skill(s) checked`);
124
- console.log(` Updated: ${updated} | Up-to-date: ${upToDate} | Skipped: ${skipped} | Failed: ${failed}`);
125
- console.log();
126
-
127
- for (const r of results) {
128
- const icon =
129
- r.status === "updated" ? "🔄" :
130
- r.status === "up-to-date" ? "✅" :
131
- r.status === "skipped" ? "⏭️" :
132
- r.status === "not-git" ? "📁" :
133
- "❌";
134
- console.log(`${icon} ${r.name}`);
135
- if (r.message && r.status !== "up-to-date") {
136
- const lines = r.message.split("\n").filter(l => l.trim());
137
- for (const line of lines.slice(0, 3)) {
138
- console.log(` ${line.trim()}`);
139
- }
140
- if (lines.length > 3) {
141
- console.log(` ... (${lines.length - 3} more lines)`);
142
- }
143
- }
144
- }
145
-
146
- if (updated > 0) {
147
- console.log(`\n💡 Run 'bunx @lythos/skill-deck link' to sync updated skills to working set.`);
148
- }
149
-
150
- if (failed > 0) {
151
- process.exit(1);
152
- }
153
- }
154
-
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * validate.test.ts — unit tests for validate.ts
4
+ *
5
+ * Run: bun test packages/lythoskill-deck/src/validate.test.ts
6
+ */
7
+
8
+ import { describe, it, expect, afterEach } from 'bun:test'
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { tmpdir } from 'node:os'
12
+ import { spawnSync } from 'node:child_process'
13
+
14
+ let cleanup: string[] = []
15
+
16
+ afterEach(() => {
17
+ for (const dir of cleanup) {
18
+ rmSync(dir, { recursive: true, force: true })
19
+ }
20
+ cleanup = []
21
+ })
22
+
23
+ function makeTmp(): string {
24
+ const dir = mkdtempSync(join(tmpdir(), 'deck-validate-'))
25
+ cleanup.push(dir)
26
+ return dir
27
+ }
28
+
29
+ function placeSkill(coldPool: string, relPath: string): string {
30
+ const skillDir = join(coldPool, relPath)
31
+ mkdirSync(skillDir, { recursive: true })
32
+ writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: fixture\n---\n')
33
+ return skillDir
34
+ }
35
+
36
+ function runValidate(deckPath: string, workdir: string) {
37
+ return spawnSync('bun', [join(import.meta.dir, 'cli.ts'), 'validate', '--deck', deckPath, '--workdir', workdir], {
38
+ cwd: workdir,
39
+ encoding: 'utf-8',
40
+ })
41
+ }
42
+
43
+ describe('validateDeck', () => {
44
+ it('C1: valid deck passes validation', () => {
45
+ const projectDir = makeTmp()
46
+ const coldPoolRel = 'cold-pool'
47
+ const coldPool = join(projectDir, coldPoolRel)
48
+
49
+ placeSkill(coldPool, 'github.com/owner/repo/skill')
50
+
51
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.my-alias]\npath = "github.com/owner/repo/skill"\n`
52
+ const deckPath = join(projectDir, 'skill-deck.toml')
53
+ writeFileSync(deckPath, deckContent)
54
+
55
+ const result = runValidate(deckPath, projectDir)
56
+
57
+ expect(result.status).toBe(0)
58
+ expect(result.stdout).toContain('Validation passed')
59
+ })
60
+
61
+ it('C2: missing [deck] section errors', () => {
62
+ const projectDir = makeTmp()
63
+ const deckContent = `[tool.skills.foo]\npath = "github.com/owner/repo/skill"\n`
64
+ const deckPath = join(projectDir, 'skill-deck.toml')
65
+ writeFileSync(deckPath, deckContent)
66
+
67
+ const result = runValidate(deckPath, projectDir)
68
+
69
+ expect(result.status).toBe(1)
70
+ expect(result.stderr).toContain('[deck] section is required')
71
+ })
72
+
73
+ it('C3: invalid max_cards errors', () => {
74
+ const projectDir = makeTmp()
75
+ const coldPoolRel = 'cold-pool'
76
+ const coldPool = join(projectDir, coldPoolRel)
77
+ placeSkill(coldPool, 'github.com/owner/repo/skill')
78
+
79
+ const deckContent = `[deck]\nmax_cards = -1\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.foo]\npath = "github.com/owner/repo/skill"\n`
80
+ const deckPath = join(projectDir, 'skill-deck.toml')
81
+ writeFileSync(deckPath, deckContent)
82
+
83
+ const result = runValidate(deckPath, projectDir)
84
+
85
+ expect(result.status).toBe(1)
86
+ expect(result.stderr).toContain('deck.max_cards must be a positive integer')
87
+ })
88
+
89
+ it('C4: skill not found in cold pool errors', () => {
90
+ const projectDir = makeTmp()
91
+ const coldPoolRel = 'cold-pool'
92
+ const coldPool = join(projectDir, coldPoolRel)
93
+ mkdirSync(coldPool, { recursive: true })
94
+ // do NOT place the skill
95
+
96
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.foo]\npath = "github.com/owner/repo/nonexistent"\n`
97
+ const deckPath = join(projectDir, 'skill-deck.toml')
98
+ writeFileSync(deckPath, deckContent)
99
+
100
+ const result = runValidate(deckPath, projectDir)
101
+
102
+ expect(result.status).toBe(1)
103
+ expect(result.stderr).toContain('Skill not found')
104
+ })
105
+
106
+ it('C5: budget exceeded errors', () => {
107
+ const projectDir = makeTmp()
108
+ const coldPoolRel = 'cold-pool'
109
+ const coldPool = join(projectDir, coldPoolRel)
110
+ placeSkill(coldPool, 'github.com/owner/repo/skill-a')
111
+ placeSkill(coldPool, 'github.com/owner/repo/skill-b')
112
+
113
+ const deckContent = `[deck]\nmax_cards = 1\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo/skill-a"\n\n[tool.skills.skill-b]\npath = "github.com/owner/repo/skill-b"\n`
114
+ const deckPath = join(projectDir, 'skill-deck.toml')
115
+ writeFileSync(deckPath, deckContent)
116
+
117
+ const result = runValidate(deckPath, projectDir)
118
+
119
+ expect(result.status).toBe(1)
120
+ expect(result.stderr).toContain('Budget exceeded')
121
+ })
122
+
123
+ it('C6: toml parse error exits', () => {
124
+ const projectDir = makeTmp()
125
+ const deckPath = join(projectDir, 'skill-deck.toml')
126
+ writeFileSync(deckPath, '[invalid toml\n')
127
+
128
+ const result = runValidate(deckPath, projectDir)
129
+
130
+ expect(result.status).toBe(1)
131
+ expect(result.stderr).toContain('TOML parse error')
132
+ })
133
+
134
+ it('C7: deprecated string-array format warns', () => {
135
+ const projectDir = makeTmp()
136
+ const coldPoolRel = 'cold-pool'
137
+ const coldPool = join(projectDir, coldPoolRel)
138
+ placeSkill(coldPool, 'github.com/owner/repo/skill')
139
+
140
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool]\nskills = ["github.com/owner/repo/skill"]\n`
141
+ const deckPath = join(projectDir, 'skill-deck.toml')
142
+ writeFileSync(deckPath, deckContent)
143
+
144
+ const result = runValidate(deckPath, projectDir)
145
+
146
+ expect(result.status).toBe(0)
147
+ expect(result.stderr).toContain('deprecated')
148
+ })
149
+
150
+ it('C8: invalid transient expires errors', () => {
151
+ const projectDir = makeTmp()
152
+ const deckPath = join(projectDir, 'skill-deck.toml')
153
+ writeFileSync(deckPath, `[deck]\nmax_cards = 10\n\n[transient.foo]\npath = "./nonexistent"\nexpires = "not-a-date"\n`)
154
+
155
+ const result = runValidate(deckPath, projectDir)
156
+
157
+ expect(result.status).toBe(1)
158
+ expect(result.stderr).toContain('invalid expires')
159
+ })
160
+ })
package/src/validate.ts CHANGED
@@ -10,6 +10,7 @@ import { parse as parseToml } from "@iarna/toml";
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { resolve } from "node:path";
12
12
  import { findDeckToml, expandHome, findSource } from "./link.js";
13
+ import { parseDeck } from "./parse-deck.js";
13
14
 
14
15
  export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
15
16
  const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : process.cwd();
@@ -52,33 +53,27 @@ export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
52
53
 
53
54
  // ── Validate skill declarations ────────────────────────────
54
55
 
56
+ const { entries: parsedEntries, deprecated: isDeprecated, errors: parseErrors } = parseDeck(deckRaw);
57
+ if (isDeprecated) {
58
+ warnings.push("string-array skill entries are deprecated. Run `deck migrate-schema` to upgrade.");
59
+ }
60
+ errors.push(...parseErrors);
61
+
55
62
  const declaredNames = new Set<string>();
56
63
  let declaredCount = 0;
57
64
 
58
- for (const section of ["innate", "tool", "combo"] as const) {
59
- const skills = deck[section]?.skills;
60
- if (skills === undefined) continue;
61
- if (!Array.isArray(skills)) {
62
- errors.push(`[${section}].skills must be an array`);
63
- continue;
65
+ for (const entry of parsedEntries) {
66
+ declaredCount++;
67
+ if (declaredNames.has(entry.path)) {
68
+ warnings.push(`Skill "${entry.path}" is declared in multiple sections`);
64
69
  }
65
- for (const name of skills) {
66
- if (!name || typeof name !== "string") {
67
- errors.push(`[${section}] contains invalid skill name`);
68
- continue;
69
- }
70
- declaredCount++;
71
- if (declaredNames.has(name)) {
72
- warnings.push(`Skill "${name}" is declared in multiple sections`);
73
- }
74
- declaredNames.add(name);
70
+ declaredNames.add(entry.path);
75
71
 
76
- const result = findSource(name, COLD_POOL, PROJECT_DIR);
77
- if (result.error) {
78
- errors.push(result.error);
79
- } else if (!result.path) {
80
- errors.push(`Skill not found: ${name} (${section})`);
81
- }
72
+ const result = findSource(entry.path, COLD_POOL, PROJECT_DIR);
73
+ if (result.error) {
74
+ errors.push(result.error);
75
+ } else if (!result.path) {
76
+ errors.push(`Skill not found: ${entry.path} (${entry.type})`);
82
77
  }
83
78
  }
84
79