@lythos/skill-deck 0.6.2 → 0.7.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 +8 -5
- package/package.json +2 -2
- package/src/add.ts +37 -9
- package/src/cli.ts +8 -1
- package/src/link.ts +127 -28
- package/src/update.ts +154 -0
- package/src/validate.ts +6 -4
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ No installation required. `bunx` auto-downloads the package.
|
|
|
19
19
|
max_cards = 10
|
|
20
20
|
|
|
21
21
|
[tool]
|
|
22
|
-
skills = ["github.com/lythos-labs/lythoskill/lythoskill-deck"]
|
|
22
|
+
skills = ["github.com/lythos-labs/lythoskill/skills/lythoskill-deck"]
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
### skill-deck.toml (full reference)
|
|
@@ -31,13 +31,16 @@ working_set = ".claude/skills" # Where symlinks are created
|
|
|
31
31
|
cold_pool = "~/.agents/skill-repos" # Where skills are downloaded
|
|
32
32
|
|
|
33
33
|
[innate] # Always-loaded skills
|
|
34
|
-
skills = ["github.com/lythos-labs/lythoskill/lythoskill-deck"]
|
|
34
|
+
skills = ["github.com/lythos-labs/lythoskill/skills/lythoskill-deck"]
|
|
35
35
|
|
|
36
36
|
[tool] # Auto-triggered skills
|
|
37
|
-
skills = [
|
|
37
|
+
skills = [
|
|
38
|
+
"github.com/mattpocock/skills/skills/engineering/tdd",
|
|
39
|
+
"github.com/garrytan/gstack",
|
|
40
|
+
]
|
|
38
41
|
|
|
39
42
|
[combo] # Multi-skill bundles
|
|
40
|
-
skills = ["
|
|
43
|
+
skills = ["github.com/anthropics/skills/skills/pdf"]
|
|
41
44
|
|
|
42
45
|
[transient] # Temporary skills with expiry
|
|
43
46
|
[transient.handoff]
|
|
@@ -100,7 +103,7 @@ cat > skill-deck.toml << 'EOF'
|
|
|
100
103
|
max_cards = 10
|
|
101
104
|
|
|
102
105
|
[tool]
|
|
103
|
-
skills = ["github.com/lythos-labs/lythoskill/lythoskill-deck"]
|
|
106
|
+
skills = ["github.com/lythos-labs/lythoskill/skills/lythoskill-deck"]
|
|
104
107
|
EOF
|
|
105
108
|
|
|
106
109
|
# 2. Link — creates symlinks in .claude/skills/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lythos/skill-deck",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Declarative skill deck governance
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
7
7
|
"skill",
|
package/src/add.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { execSync } from 'node:child_process'
|
|
|
15
15
|
import { parse as parseToml, stringify as stringifyToml } from '@iarna/toml'
|
|
16
16
|
import { findDeckToml, expandHome } from './link.js'
|
|
17
17
|
|
|
18
|
+
const CLAUDE_SKILLS_DIR = join(homedir(), '.claude', 'skills')
|
|
19
|
+
|
|
18
20
|
interface ParsedLocator {
|
|
19
21
|
host: string
|
|
20
22
|
owner: string
|
|
@@ -114,29 +116,55 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
|
|
|
114
116
|
const tmpRepo = join(tmpDir, 'repo')
|
|
115
117
|
|
|
116
118
|
try {
|
|
119
|
+
let skillSourceDir: string
|
|
120
|
+
|
|
117
121
|
if (backend === 'skills.sh' || backend === 'vercel') {
|
|
118
122
|
const skillsShLocator = `${parsed.owner}/${parsed.repo}`
|
|
119
123
|
console.log(`📦 Downloading via skills.sh: ${skillsShLocator}`)
|
|
124
|
+
|
|
125
|
+
// Snapshot existing directories in ~/.claude/skills/
|
|
126
|
+
const beforeDirs = existsSync(CLAUDE_SKILLS_DIR)
|
|
127
|
+
? new Set(readdirSync(CLAUDE_SKILLS_DIR, { withFileTypes: true })
|
|
128
|
+
.filter(e => e.isDirectory())
|
|
129
|
+
.map(e => e.name))
|
|
130
|
+
: new Set<string>()
|
|
131
|
+
|
|
120
132
|
execSync(`npx skills add ${skillsShLocator} -g`, { cwd: tmpDir, stdio: 'inherit' })
|
|
133
|
+
|
|
134
|
+
// Detect the newly installed directory
|
|
135
|
+
const afterDirs = existsSync(CLAUDE_SKILLS_DIR)
|
|
136
|
+
? readdirSync(CLAUDE_SKILLS_DIR, { withFileTypes: true })
|
|
137
|
+
.filter(e => e.isDirectory())
|
|
138
|
+
.map(e => e.name)
|
|
139
|
+
: []
|
|
140
|
+
const newDirs = afterDirs.filter(d => !beforeDirs.has(d))
|
|
141
|
+
|
|
142
|
+
if (newDirs.length === 0) {
|
|
143
|
+
console.error(`❌ skills.sh installed nothing new to ~/.claude/skills/`)
|
|
144
|
+
console.error(` The skill may already be installed, or the install failed.`)
|
|
145
|
+
process.exit(1)
|
|
146
|
+
}
|
|
147
|
+
if (newDirs.length > 1) {
|
|
148
|
+
console.warn(`⚠️ Multiple new directories detected in ~/.claude/skills/`)
|
|
149
|
+
console.warn(` Using the first one: ${newDirs[0]}`)
|
|
150
|
+
}
|
|
151
|
+
const installedName = newDirs[0]
|
|
152
|
+
skillSourceDir = join(CLAUDE_SKILLS_DIR, installedName)
|
|
153
|
+
console.log(` Detected install: ${installedName}`)
|
|
121
154
|
} else {
|
|
122
155
|
const gitUrl = `https://${parsed.host}/${parsed.owner}/${parsed.repo}.git`
|
|
123
156
|
console.log(`📦 Cloning: ${gitUrl}`)
|
|
124
157
|
execSync(`git clone --depth 1 ${gitUrl} ${tmpRepo}`, { stdio: 'inherit' })
|
|
158
|
+
skillSourceDir = tmpRepo
|
|
125
159
|
}
|
|
126
160
|
|
|
127
|
-
if (!existsSync(
|
|
128
|
-
|
|
129
|
-
console.error(`❌ skills.sh backend installs globally, not to cold pool.`)
|
|
130
|
-
console.error(` Please manually place the skill at: ${targetDir}`)
|
|
131
|
-
console.error(` Or use: deck add ${locator} --via git`)
|
|
132
|
-
process.exit(1)
|
|
133
|
-
}
|
|
134
|
-
console.error(`❌ Download failed: expected output not found at ${tmpRepo}`)
|
|
161
|
+
if (!existsSync(skillSourceDir)) {
|
|
162
|
+
console.error(`❌ Download failed: expected output not found at ${skillSourceDir}`)
|
|
135
163
|
process.exit(1)
|
|
136
164
|
}
|
|
137
165
|
|
|
138
166
|
mkdirSync(dirname(targetDir), { recursive: true })
|
|
139
|
-
renameSync(
|
|
167
|
+
renameSync(skillSourceDir, targetDir)
|
|
140
168
|
|
|
141
169
|
const skillDir = findSkillDir(targetDir, parsed.skill)
|
|
142
170
|
if (!skillDir) {
|
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { linkDeck } from './link.js'
|
|
3
3
|
import { validateDeck } from './validate.js'
|
|
4
4
|
import { addSkill } from './add.js'
|
|
5
|
+
import { updateDeck } from './update.js'
|
|
5
6
|
import { formatHelp } from './help.js'
|
|
6
7
|
|
|
7
8
|
const HELP_CONFIG = {
|
|
@@ -10,11 +11,13 @@ const HELP_CONFIG = {
|
|
|
10
11
|
commands: [
|
|
11
12
|
{ name: 'link', description: 'Sync working set with skill-deck.toml' },
|
|
12
13
|
{ name: 'add', description: 'Download skill to cold pool and add to deck', args: '<locator>' },
|
|
14
|
+
{ name: 'update', description: 'Pull latest versions of declared skills from upstream' },
|
|
13
15
|
{ name: 'validate', description: 'Validate deck configuration', args: '[deck.toml]' },
|
|
14
16
|
],
|
|
15
17
|
options: [
|
|
16
18
|
{ flag: '--deck <path>', description: 'Specify skill-deck.toml path (default: find upward from cwd)' },
|
|
17
19
|
{ flag: '--workdir <dir>', description: 'Specify working directory (default: cwd)' },
|
|
20
|
+
{ flag: '--no-backup', description: 'Skip tar backup when removing non-symlink entries' },
|
|
18
21
|
{ flag: '--via <backend>', description: 'Download backend: git (default) | skills.sh' },
|
|
19
22
|
],
|
|
20
23
|
}
|
|
@@ -29,6 +32,7 @@ const viaFlagIdx = args.indexOf('--via')
|
|
|
29
32
|
const deckPath = deckFlagIdx >= 0 ? args[deckFlagIdx + 1] : undefined
|
|
30
33
|
const workdir = workdirFlagIdx >= 0 ? args[workdirFlagIdx + 1] : undefined
|
|
31
34
|
const via = viaFlagIdx >= 0 ? args[viaFlagIdx + 1] : undefined
|
|
35
|
+
const noBackup = args.includes('--no-backup')
|
|
32
36
|
|
|
33
37
|
switch (command) {
|
|
34
38
|
case '--help':
|
|
@@ -36,7 +40,7 @@ switch (command) {
|
|
|
36
40
|
console.log(formatHelp(HELP_CONFIG))
|
|
37
41
|
process.exit(0)
|
|
38
42
|
case 'link':
|
|
39
|
-
linkDeck(deckPath, workdir)
|
|
43
|
+
linkDeck(deckPath, workdir, noBackup)
|
|
40
44
|
break
|
|
41
45
|
case 'add': {
|
|
42
46
|
const locator = args[1]
|
|
@@ -47,6 +51,9 @@ switch (command) {
|
|
|
47
51
|
await addSkill(locator, { via, deck: deckPath, workdir })
|
|
48
52
|
break
|
|
49
53
|
}
|
|
54
|
+
case 'update':
|
|
55
|
+
updateDeck(deckPath, workdir)
|
|
56
|
+
break
|
|
50
57
|
case 'validate':
|
|
51
58
|
validateDeck(deckPath, workdir)
|
|
52
59
|
break
|
package/src/link.ts
CHANGED
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
|
|
10
10
|
import { parse as parseToml } from "@iarna/toml";
|
|
11
11
|
import YAML from "yaml";
|
|
12
|
-
import { createHash } from "crypto";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
13
|
import {
|
|
14
14
|
existsSync, mkdirSync, readFileSync, readdirSync,
|
|
15
|
-
symlinkSync, lstatSync, rmSync, writeFileSync,
|
|
16
|
-
} from "fs";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
15
|
+
symlinkSync, lstatSync, rmSync, statSync, writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { execSync } from "node:child_process";
|
|
18
|
+
import { resolve, dirname, join, basename, relative } from "node:path";
|
|
19
|
+
import { homedir } from "node:os";
|
|
19
20
|
import {
|
|
20
21
|
SkillDeckLockSchema,
|
|
21
22
|
type SkillDeckLock, type LinkedSkill, type ConstraintReport,
|
|
@@ -51,7 +52,12 @@ function parseSkillFrontmatter(skillMdPath: string): Record<string, any> {
|
|
|
51
52
|
|
|
52
53
|
// ── 冷池查找 ────────────────────────────────────────────────
|
|
53
54
|
|
|
54
|
-
export
|
|
55
|
+
export interface FindSourceResult {
|
|
56
|
+
path: string | null;
|
|
57
|
+
error?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function findSource(name: string, coldPool: string, projectDir: string): FindSourceResult {
|
|
55
61
|
// 0. Fully-qualified path: host.tld/owner/repo/skill
|
|
56
62
|
// → cold_pool/host.tld/owner/repo/skills/skill
|
|
57
63
|
// Also handles host.tld/owner/repo (standalone skill without skills/ subdir)
|
|
@@ -65,31 +71,32 @@ export function findSource(name: string, coldPool: string, projectDir: string):
|
|
|
65
71
|
|
|
66
72
|
if (skill) {
|
|
67
73
|
const fqPath = join(coldPool, host, owner, repo, "skills", skill);
|
|
68
|
-
if (existsSync(join(fqPath, "SKILL.md"))) return fqPath;
|
|
74
|
+
if (existsSync(join(fqPath, "SKILL.md"))) return { path: fqPath };
|
|
69
75
|
}
|
|
70
76
|
// fallback: standalone skill at repo root
|
|
71
77
|
const directPath = join(coldPool, host, owner, repo);
|
|
72
|
-
if (existsSync(join(directPath, "SKILL.md"))) return directPath;
|
|
78
|
+
if (existsSync(join(directPath, "SKILL.md"))) return { path: directPath };
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
// 1. 直接路径
|
|
76
82
|
const direct = resolve(coldPool, name);
|
|
77
|
-
if (existsSync(join(direct, "SKILL.md"))) return direct;
|
|
83
|
+
if (existsSync(join(direct, "SKILL.md"))) return { path: direct };
|
|
78
84
|
|
|
79
85
|
// 2. Monorepo: repo/skill → cold_pool/repo/skills/skill
|
|
80
86
|
if (name.includes("/")) {
|
|
81
87
|
const [repo, ...rest] = name.split("/");
|
|
82
88
|
const mono = join(coldPool, repo, "skills", rest.join("/"));
|
|
83
|
-
if (existsSync(join(mono, "SKILL.md"))) return mono;
|
|
89
|
+
if (existsSync(join(mono, "SKILL.md"))) return { path: mono };
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
// 3. 项目本地: <project>/skills/<name>(build 输出目录,优先级高于扁平扫描)
|
|
87
93
|
const local = resolve(projectDir, "skills", name);
|
|
88
|
-
if (existsSync(join(local, "SKILL.md"))) return local;
|
|
94
|
+
if (existsSync(join(local, "SKILL.md"))) return { path: local };
|
|
89
95
|
|
|
90
96
|
// 4. 扁平扫描: cold_pool/<any-repo>/<name> 或 <any-repo>/skills/<name>
|
|
91
97
|
// 跳过隐藏目录(agent working set、git、配置等)和 node_modules,
|
|
92
98
|
// 避免把 .claude/skills/ 里的 symlink 误判为有效 cold-pool 源
|
|
99
|
+
const matches: string[] = [];
|
|
93
100
|
try {
|
|
94
101
|
for (const entry of readdirSync(coldPool, { withFileTypes: true })) {
|
|
95
102
|
if (!entry.isDirectory()) continue;
|
|
@@ -97,17 +104,54 @@ export function findSource(name: string, coldPool: string, projectDir: string):
|
|
|
97
104
|
if (entry.name === 'node_modules') continue;
|
|
98
105
|
const base = join(coldPool, entry.name);
|
|
99
106
|
for (const sub of [join(base, name), join(base, "skills", name)]) {
|
|
100
|
-
if (existsSync(join(sub, "SKILL.md")))
|
|
107
|
+
if (existsSync(join(sub, "SKILL.md"))) {
|
|
108
|
+
matches.push(sub);
|
|
109
|
+
}
|
|
101
110
|
}
|
|
102
111
|
}
|
|
103
112
|
} catch {}
|
|
104
113
|
|
|
105
|
-
|
|
114
|
+
if (matches.length === 1) {
|
|
115
|
+
return { path: matches[0] };
|
|
116
|
+
}
|
|
117
|
+
if (matches.length > 1) {
|
|
118
|
+
const candidates = matches.map(m => relative(coldPool, m)).join(', ');
|
|
119
|
+
return {
|
|
120
|
+
path: null,
|
|
121
|
+
error: `Ambiguous skill name "${name}": found ${matches.length} matches (${candidates}). Use fully-qualified name (e.g., github.com/owner/repo/${name})`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { path: null };
|
|
106
126
|
}
|
|
107
127
|
|
|
128
|
+
// ── 备份工具 ────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
function calculateDirSize(dir: string): number {
|
|
131
|
+
let total = 0;
|
|
132
|
+
try {
|
|
133
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
134
|
+
const p = join(dir, entry.name);
|
|
135
|
+
if (entry.isDirectory()) {
|
|
136
|
+
total += calculateDirSize(p);
|
|
137
|
+
} else if (entry.isFile()) {
|
|
138
|
+
total += statSync(p).size;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {}
|
|
142
|
+
return total;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatBackupDate(d: Date): string {
|
|
146
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
147
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const BACKUP_SIZE_THRESHOLD = 100 * 1024 * 1024; // 100MB
|
|
151
|
+
|
|
108
152
|
// ── 主流程 ──────────────────────────────────────────────────
|
|
109
153
|
|
|
110
|
-
export function linkDeck(cliDeckPath?: string, cliWorkdir?: string): void {
|
|
154
|
+
export function linkDeck(cliDeckPath?: string, cliWorkdir?: string, noBackup?: boolean): void {
|
|
111
155
|
const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
|
|
112
156
|
const DECK_PATH = cliDeck
|
|
113
157
|
? resolve(cliDeck)
|
|
@@ -150,12 +194,16 @@ const errors: string[] = [];
|
|
|
150
194
|
for (const section of ["innate", "tool", "combo"] as const) {
|
|
151
195
|
for (const name of (deck[section]?.skills || [])) {
|
|
152
196
|
if (!name || typeof name !== "string") continue;
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
197
|
+
const result = findSource(name, COLD_POOL, PROJECT_DIR);
|
|
198
|
+
if (result.error) {
|
|
199
|
+
errors.push(result.error);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!result.path) {
|
|
155
203
|
errors.push(`Skill not found: ${name}`);
|
|
156
204
|
continue;
|
|
157
205
|
}
|
|
158
|
-
declared.push({ name, type: section, sourcePath:
|
|
206
|
+
declared.push({ name, type: section, sourcePath: result.path });
|
|
159
207
|
}
|
|
160
208
|
}
|
|
161
209
|
|
|
@@ -239,23 +287,74 @@ if (
|
|
|
239
287
|
|
|
240
288
|
mkdirSync(WORKING_SET, { recursive: true });
|
|
241
289
|
|
|
242
|
-
//
|
|
290
|
+
// Pre-flight: 备份并清理非 symlink 实体(真实目录/文件)
|
|
291
|
+
const nonSymlinks: string[] = [];
|
|
292
|
+
try {
|
|
293
|
+
for (const entry of readdirSync(WORKING_SET)) {
|
|
294
|
+
if (entry.startsWith("_") || entry.startsWith(".")) continue;
|
|
295
|
+
const entryPath = join(WORKING_SET, entry);
|
|
296
|
+
try {
|
|
297
|
+
const st = lstatSync(entryPath);
|
|
298
|
+
if (!st.isSymbolicLink()) {
|
|
299
|
+
nonSymlinks.push(entry);
|
|
300
|
+
}
|
|
301
|
+
} catch { continue; }
|
|
302
|
+
}
|
|
303
|
+
} catch {}
|
|
304
|
+
|
|
305
|
+
if (nonSymlinks.length > 0) {
|
|
306
|
+
// 计算总大小
|
|
307
|
+
let totalSize = 0;
|
|
308
|
+
for (const e of nonSymlinks) {
|
|
309
|
+
totalSize += calculateDirSize(join(WORKING_SET, e));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!noBackup && totalSize > BACKUP_SIZE_THRESHOLD) {
|
|
313
|
+
console.error(`❌ Found ${nonSymlinks.length} real directories in ${relative(PROJECT_DIR, WORKING_SET)} (> 100MB total).`);
|
|
314
|
+
console.error(` Manual review required: ${nonSymlinks.join(", ")}`);
|
|
315
|
+
console.error(` Use --no-backup to skip backup (removes without saving), or clean up manually.`);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!noBackup) {
|
|
320
|
+
const bakName = `skills.bak.${formatBackupDate(new Date())}.tar.gz`;
|
|
321
|
+
const bakPath = join(PROJECT_DIR, ".claude", bakName);
|
|
322
|
+
mkdirSync(join(PROJECT_DIR, ".claude"), { recursive: true });
|
|
323
|
+
|
|
324
|
+
const tarArgs = [
|
|
325
|
+
"czf", bakPath,
|
|
326
|
+
...nonSymlinks.map(e => relative(PROJECT_DIR, join(WORKING_SET, e))),
|
|
327
|
+
];
|
|
328
|
+
try {
|
|
329
|
+
execSync("tar " + tarArgs.map(a => a.includes(" ") ? `"${a}"` : a).join(" "), {
|
|
330
|
+
cwd: PROJECT_DIR,
|
|
331
|
+
stdio: "pipe",
|
|
332
|
+
});
|
|
333
|
+
console.log(`📦 Backed up ${nonSymlinks.length} entr${nonSymlinks.length === 1 ? "y" : "ies"} to .claude/${bakName}`);
|
|
334
|
+
} catch (err: any) {
|
|
335
|
+
console.error(`❌ Backup failed: ${err.message || err}`);
|
|
336
|
+
console.error(` Use --no-backup to skip backup, or fix the issue and retry.`);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
console.log(`⚠️ --no-backup: removing ${nonSymlinks.length} entr${nonSymlinks.length === 1 ? "y" : "ies"} without backup`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const e of nonSymlinks) {
|
|
344
|
+
rmSync(join(WORKING_SET, e), { recursive: true, force: true });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 清理未声明的 symlink
|
|
243
349
|
const declaredNames = new Set(declared.map(d => d.name.split("/")[0]));
|
|
244
350
|
try {
|
|
245
351
|
for (const entry of readdirSync(WORKING_SET)) {
|
|
246
|
-
if (entry.startsWith("_")) continue;
|
|
352
|
+
if (entry.startsWith("_") || entry.startsWith(".")) continue;
|
|
247
353
|
if (!declaredNames.has(entry)) {
|
|
248
354
|
const entryPath = join(WORKING_SET, entry);
|
|
249
355
|
try {
|
|
250
356
|
const st = lstatSync(entryPath);
|
|
251
|
-
if (!st.isSymbolicLink())
|
|
252
|
-
console.warn(`⚠️ Skipping non-symlink entry: ${entry}`);
|
|
253
|
-
console.warn(` → ${entry} is a real directory, not a symlink. Deck only manages symlinks.`);
|
|
254
|
-
const cpRel2 = relative(PROJECT_DIR, COLD_POOL);
|
|
255
|
-
const cpHint2 = cpRel2 === "" ? `skills/${entry}` : `${cpRel2}/${entry}`;
|
|
256
|
-
console.warn(` Move it to your cold pool (${cpHint2}) and run link again.`);
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
357
|
+
if (!st.isSymbolicLink()) continue; // 已在上文处理
|
|
259
358
|
} catch { continue; }
|
|
260
359
|
rmSync(entryPath, { recursive: true, force: true });
|
|
261
360
|
console.log(` 🗑️ Removed: ${entry}`);
|
|
@@ -399,7 +498,7 @@ writeFileSync(LOCK_PATH, JSON.stringify(parsed.data, null, 2) + "\n");
|
|
|
399
498
|
// ── 报告 ────────────────────────────────────────────────────
|
|
400
499
|
|
|
401
500
|
console.log("");
|
|
402
|
-
console.log(`✅ Sync complete: ${linkedSkills.length}
|
|
501
|
+
console.log(`✅ Sync complete: ${linkedSkills.length} skill(s) linked (max_cards: ${MAX_CARDS})`);
|
|
403
502
|
console.log(` lock: ${LOCK_PATH}`);
|
|
404
503
|
if (dirOverlaps.length > 0) {
|
|
405
504
|
console.log(` ⚠️ ${dirOverlaps.length} directory overlap(s) (see warnings above)`);
|
package/src/update.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* deck-update.ts — Update declared skills from their upstream sources
|
|
4
|
+
*
|
|
5
|
+
* 读取 skill-deck.toml → 遍历声明的 skill → 对 git 来源执行 pull。
|
|
6
|
+
* 职责:让冷池跟上上游版本。
|
|
7
|
+
* 不做:下载新 skill(那是 add 的职责)、修改 deck.toml、同步 working set。
|
|
8
|
+
*/
|
|
9
|
+
|
|
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";
|
|
15
|
+
|
|
16
|
+
interface UpdateResult {
|
|
17
|
+
name: string;
|
|
18
|
+
path: string;
|
|
19
|
+
status: "updated" | "up-to-date" | "skipped" | "failed" | "not-git";
|
|
20
|
+
message?: string;
|
|
21
|
+
}
|
|
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
|
+
|
package/src/validate.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { parse as parseToml } from "@iarna/toml";
|
|
10
|
-
import { existsSync, readFileSync } from "fs";
|
|
11
|
-
import { resolve } from "path";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
12
|
import { findDeckToml, expandHome, findSource } from "./link.js";
|
|
13
13
|
|
|
14
14
|
export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
|
|
@@ -73,8 +73,10 @@ export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
|
|
|
73
73
|
}
|
|
74
74
|
declaredNames.add(name);
|
|
75
75
|
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
76
|
+
const result = findSource(name, COLD_POOL, PROJECT_DIR);
|
|
77
|
+
if (result.error) {
|
|
78
|
+
errors.push(result.error);
|
|
79
|
+
} else if (!result.path) {
|
|
78
80
|
errors.push(`Skill not found: ${name} (${section})`);
|
|
79
81
|
}
|
|
80
82
|
}
|