@morehao/dsh-skills-console 0.0.0 → 0.2.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.
@@ -0,0 +1,232 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+
6
+ const require = createRequire(import.meta.url);
7
+
8
+ export class CliUnavailableError extends Error {
9
+ constructor(reason) {
10
+ super(reason || 'skills CLI not found');
11
+ this.code = 'error.skills.cli-missing';
12
+ this.reason = reason;
13
+ }
14
+ }
15
+
16
+ export class CliFailedError extends Error {
17
+ constructor(message, detail) {
18
+ super(message);
19
+ this.code = detail?.timedOut ? 'error.skills.cli-timeout' : 'error.skills.cli-failed';
20
+ this.detail = detail;
21
+ }
22
+ }
23
+
24
+ function isExecutable(file) {
25
+ try {
26
+ fs.accessSync(file, fs.constants.X_OK);
27
+ return fs.statSync(file).isFile();
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function findInPath(command, envPath) {
34
+ for (const dir of String(envPath || '').split(path.delimiter)) {
35
+ if (!dir) continue;
36
+ const candidate = path.join(dir, command);
37
+ if (isExecutable(candidate)) return candidate;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * CLI 定位四层(设计 §8.2):显式覆盖 → 插件/宿主自带依赖 → PATH → npx。
44
+ * 每一层都记来源,诊断页要展示"这次到底跑的哪一个"。
45
+ */
46
+ export function locateCli({ env = process.env, logger = () => {} } = {}) {
47
+ const override = env.DSH_SKILLS_CONSOLE_CLI;
48
+ if (override && override.trim()) {
49
+ const parts = override.trim().split(/\s+/);
50
+ // 覆盖值可能是测试桩:仍然让 `add --help` 自己回答能力位,别替它猜
51
+ return { command: parts[0], prefixArgs: parts.slice(1), source: 'env-override', jsonCapable: true };
52
+ }
53
+
54
+ // 宿主 profile 里的 skills 依赖(最常见的实际位置)。
55
+ // 注意:GUI 启动的宿主进程常常拿不到 DSH_PROFILE_DIR,所以再按 DSH_HOME/profiles/* 兜一层探测。
56
+ const profileDir = env.DSH_PROFILE_DIR;
57
+ if (profileDir) {
58
+ const bin = path.join(profileDir, 'node_modules', 'skills', 'bin', 'cli.mjs');
59
+ if (fs.existsSync(bin)) {
60
+ return { command: process.execPath, prefixArgs: [bin], source: 'profile-dependency', jsonCapable: true };
61
+ }
62
+ }
63
+ const dshHome = env.DSH_HOME || path.join(env.HOME || '', '.dsh');
64
+ try {
65
+ const profiles = fs.readdirSync(path.join(dshHome, 'profiles'), { withFileTypes: true });
66
+ for (const entry of profiles) {
67
+ if (!entry.isDirectory()) continue;
68
+ const bin = path.join(dshHome, 'profiles', entry.name, 'node_modules', 'skills', 'bin', 'cli.mjs');
69
+ if (fs.existsSync(bin)) {
70
+ return { command: process.execPath, prefixArgs: [bin], source: `profile:${entry.name}`, jsonCapable: true };
71
+ }
72
+ }
73
+ } catch {
74
+ // 没有 profiles 目录就继续往下走
75
+ }
76
+
77
+ try {
78
+ const pkg = require.resolve('skills/package.json');
79
+ const bin = path.join(path.dirname(pkg), 'bin', 'cli.mjs');
80
+ if (fs.existsSync(bin)) {
81
+ return { command: process.execPath, prefixArgs: [bin], source: 'dependency', jsonCapable: true };
82
+ }
83
+ } catch {
84
+ // 没有作为依赖装在本插件可见范围内,继续往下走
85
+ }
86
+
87
+ const onPath = findInPath('skills', env.PATH);
88
+ if (onPath) return { command: onPath, prefixArgs: [], source: 'path', jsonCapable: true };
89
+
90
+ const npx = findInPath('npx', env.PATH);
91
+ if (npx) {
92
+ const spec = env.DSH_SKILLS_CONSOLE_NPX_SPEC || 'skills';
93
+ return { command: npx, prefixArgs: ['-y', spec], source: 'npx', jsonCapable: true };
94
+ }
95
+
96
+ logger('cli.missing');
97
+ throw new CliUnavailableError('neither `skills` nor `npx` is on PATH');
98
+ }
99
+
100
+ /** 子进程封装:无 shell、argv 直传、硬超时、输出上限、非交互环境注入。 */
101
+ export function runCli(location, args, { timeoutMs = 120000, signal, env = process.env, maxBytes = 4 * 1024 * 1024 } = {}) {
102
+ return new Promise((resolve, reject) => {
103
+ const child = spawn(location.command, [...location.prefixArgs, ...args], {
104
+ shell: false,
105
+ stdio: ['ignore', 'pipe', 'pipe'],
106
+ env: {
107
+ ...env,
108
+ CI: '1',
109
+ NO_COLOR: '1',
110
+ TERM: 'dumb',
111
+ GIT_TERMINAL_PROMPT: '0',
112
+ GIT_HTTP_LOW_SPEED_LIMIT: '1',
113
+ GIT_HTTP_LOW_SPEED_TIME: '30',
114
+ },
115
+ });
116
+
117
+ let stdout = '';
118
+ let stderr = '';
119
+ let timedOut = false;
120
+ let truncated = false;
121
+ const timer = setTimeout(() => {
122
+ timedOut = true;
123
+ child.kill('SIGKILL');
124
+ }, timeoutMs);
125
+ const onAbort = () => child.kill('SIGKILL');
126
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
127
+
128
+ child.stdout.on('data', (chunk) => {
129
+ if (stdout.length + chunk.length > maxBytes) {
130
+ truncated = true;
131
+ stdout += chunk.subarray(0, Math.max(0, maxBytes - stdout.length)).toString('utf8');
132
+ return;
133
+ }
134
+ stdout += chunk.toString('utf8');
135
+ });
136
+ child.stderr.on('data', (chunk) => {
137
+ if (stderr.length < 65536) stderr += chunk.toString('utf8');
138
+ });
139
+
140
+ child.on('error', (error) => {
141
+ clearTimeout(timer);
142
+ if (signal) signal.removeEventListener('abort', onAbort);
143
+ reject(new CliFailedError(error.message, { error }));
144
+ });
145
+
146
+ child.on('close', (code) => {
147
+ clearTimeout(timer);
148
+ if (signal) signal.removeEventListener('abort', onAbort);
149
+ resolve({ code, stdout, stderr, timedOut, truncated, args });
150
+ });
151
+ });
152
+ }
153
+
154
+ export function createCli({ env = process.env, logger = () => {} } = {}) {
155
+ let cached = null;
156
+ let capabilities = null;
157
+
158
+ function location() {
159
+ if (!cached) cached = locateCli({ env, logger });
160
+ return cached;
161
+ }
162
+
163
+ function call(args, options = {}) {
164
+ return runCli(location(), args, { env, ...options });
165
+ }
166
+
167
+ const api = {
168
+ location,
169
+ call,
170
+
171
+ async version() {
172
+ const result = await call(['--version']);
173
+ if (result.code !== 0) return null;
174
+ const match = result.stdout.trim().match(/\d+\.\d+\.\d+[\w.-]*/);
175
+ return match ? match[0] : null;
176
+ },
177
+
178
+ /** 能力位:`--json` 与 `--full-depth` 任一不支持都要在 UI 上禁用写操作(漏装比不可用更危险)。 */
179
+ async capabilities({ force = false } = {}) {
180
+ if (capabilities && !force) return capabilities;
181
+ const loc = location();
182
+ if (!loc.jsonCapable) {
183
+ capabilities = { version: null, json: false, fullDepth: false, source: loc.source, usable: false };
184
+ return capabilities;
185
+ }
186
+ const version = await api.version();
187
+ const help = await call(['add', '--help']);
188
+ const text = `${help.stdout}\n${help.stderr}`;
189
+ capabilities = {
190
+ version,
191
+ json: text.includes('--json'),
192
+ fullDepth: text.includes('--full-depth'),
193
+ source: loc.source,
194
+ usable: Boolean(version) && text.includes('--json') && text.includes('--full-depth'),
195
+ };
196
+ logger('cli.capabilities', capabilities);
197
+ return capabilities;
198
+ },
199
+
200
+ /** 列已装技能(全局)。字段只有 F20 那几项,ref/skillPath/hash 必须另读 lock。 */
201
+ async listGlobal() {
202
+ const result = await call(['list', '--json', '-g']);
203
+ if (result.code !== 0) {
204
+ throw new CliFailedError(`skills list failed`, { stderr: result.stderr, code: result.code });
205
+ }
206
+ try {
207
+ const parsed = JSON.parse(result.stdout);
208
+ return Array.isArray(parsed) ? parsed : [];
209
+ } catch (error) {
210
+ throw new CliFailedError(`skills list returned non-JSON output`, { error: error.message });
211
+ }
212
+ },
213
+
214
+ /**
215
+ * 安装:`-a universal` 必带(F17),`--json` 配 `-y`(F11),`--full-depth` 必带(F24)。
216
+ * 永不带 `--all` / `-a '*'`。
217
+ */
218
+ async add({ source, skills = [], timeoutMs = env.DSH_SKILLS_CONSOLE_TIMEOUT_MS ? Number(env.DSH_SKILLS_CONSOLE_TIMEOUT_MS) : 120000 }) {
219
+ const args = ['add', source, '--skill', ...skills, '-a', 'universal', '-g', '-y', '--json', '--full-depth'];
220
+ const result = await call(args, { timeoutMs });
221
+ return result;
222
+ },
223
+
224
+ /** 卸载:绝不带 `-a`(带上反而删不掉真身),没有 `--json`,结论以磁盘核对为准。 */
225
+ async remove({ names = [], timeoutMs = 120000 }) {
226
+ const args = ['remove', '-s', ...names, '-g', '-y'];
227
+ return call(args, { timeoutMs });
228
+ },
229
+ };
230
+
231
+ return api;
232
+ }
@@ -0,0 +1,46 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const SKIP_DIRS = new Set(['.git', 'node_modules']);
6
+
7
+ function collectFiles(dir, out) {
8
+ let entries;
9
+ try {
10
+ entries = fs.readdirSync(dir, { withFileTypes: true });
11
+ } catch {
12
+ return out;
13
+ }
14
+ for (const entry of entries) {
15
+ if (SKIP_DIRS.has(entry.name)) continue;
16
+ const full = path.join(dir, entry.name);
17
+ if (entry.isDirectory()) collectFiles(full, out);
18
+ else if (entry.isFile()) out.push(full);
19
+ // 符号链接 / 设备文件不计入:与 CLI 的 collectFiles 只收普通文件一致(F6)
20
+ }
21
+ return out;
22
+ }
23
+
24
+ /**
25
+ * 逐字复刻 skills CLI 的内容哈希(F6):
26
+ * 只收普通文件 → 跳过 `.git` / `node_modules` → 相对路径 `/` 归一 → localeCompare 排序
27
+ * → 先 update(路径) 再 update(内容) → sha256。
28
+ *
29
+ * 用途只有一个:进 L1「本地有没有被改过」的基线,绝不与上游 tree SHA 混用。
30
+ */
31
+ export function contentHash(dir) {
32
+ const files = collectFiles(dir, []).map((file) =>
33
+ path.relative(dir, file).split(path.sep).join('/'),
34
+ );
35
+ files.sort((a, b) => a.localeCompare(b));
36
+ const hash = crypto.createHash('sha256');
37
+ for (const rel of files) {
38
+ hash.update(rel);
39
+ hash.update(fs.readFileSync(path.join(dir, rel)));
40
+ }
41
+ return hash.digest('hex');
42
+ }
43
+
44
+ export function sha256(text) {
45
+ return crypto.createHash('sha256').update(text).digest('hex');
46
+ }
@@ -0,0 +1,174 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { contentHash } from './hash.js';
4
+ import { inspectTarget } from './links.js';
5
+
6
+ /**
7
+ * CLI 全局 lock(v3)。读不到、版本不认识、结构损坏都返回空账 ——
8
+ * CLI 自己也会在 `version` 缺失或过低时丢弃整个文件(:3755/:3761-3771),这里保持一致。
9
+ */
10
+ export function readLock(paths) {
11
+ let raw;
12
+ try {
13
+ raw = fs.readFileSync(paths.lockFile, 'utf8');
14
+ } catch {
15
+ return { present: false, version: null, skills: {}, problem: null };
16
+ }
17
+ try {
18
+ const parsed = JSON.parse(raw);
19
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.version !== 'number') {
20
+ return { present: true, version: null, skills: {}, problem: 'lock.version missing' };
21
+ }
22
+ if (parsed.version < 3) {
23
+ return { present: true, version: parsed.version, skills: {}, problem: `lock.version ${parsed.version} too old` };
24
+ }
25
+ const skills = parsed.skills && typeof parsed.skills === 'object' ? parsed.skills : {};
26
+ return { present: true, version: parsed.version, skills, problem: null };
27
+ } catch (error) {
28
+ return { present: true, version: null, skills: {}, problem: `lock not JSON: ${error.message}` };
29
+ }
30
+ }
31
+
32
+ /** canonical 扫描:`<name>/SKILL.md` 目录包,或 `<name>.md` 平铺(原生刻意不支持嵌套)。 */
33
+ export function scanCanonical(paths) {
34
+ const dir = paths.canonical;
35
+ let entries;
36
+ try {
37
+ entries = fs.readdirSync(dir, { withFileTypes: true });
38
+ } catch {
39
+ return [];
40
+ }
41
+ const out = [];
42
+ for (const entry of entries) {
43
+ if (entry.name.startsWith('.')) continue;
44
+ const full = path.join(dir, entry.name);
45
+ let isDir = entry.isDirectory();
46
+ if (entry.isSymbolicLink()) {
47
+ try {
48
+ isDir = fs.statSync(full).isDirectory();
49
+ } catch {
50
+ out.push({ name: entry.name, path: full, kind: 'broken', hasSkillFile: false });
51
+ continue;
52
+ }
53
+ }
54
+ if (isDir) {
55
+ const hasSkill = fs.existsSync(path.join(full, 'SKILL.md'));
56
+ out.push({ name: entry.name, path: full, kind: 'dir', hasSkillFile: hasSkill });
57
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
58
+ out.push({ name: entry.name.slice(0, -3), path: full, kind: 'file', hasSkillFile: true });
59
+ }
60
+ }
61
+ out.sort((a, b) => a.name.localeCompare(b.name));
62
+ return out;
63
+ }
64
+
65
+ export function readFrontmatterDescription(skillPath) {
66
+ try {
67
+ const raw = fs.readFileSync(skillPath, 'utf8');
68
+ const match = raw.match(/^---\n([\s\S]*?)\n---/);
69
+ if (!match) return null;
70
+ const line = match[1].split('\n').find((item) => item.trim().startsWith('description:'));
71
+ if (!line) return null;
72
+ return line.slice(line.indexOf(':') + 1).trim().replace(/^['"]|['"]$/g, '') || null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function statusOf({ install, check, locallyModified, brokenLinks }) {
79
+ if (!install) return 'unmanaged';
80
+ if (locallyModified && check?.status === 'update-available') return 'conflict';
81
+ if (locallyModified) return 'locally-modified';
82
+ if (check?.status === 'upstream-deleted') return 'upstream-deleted';
83
+ if (check?.status === 'update-available') return 'update-available';
84
+ if (brokenLinks.length > 0) return 'broken-link';
85
+ if (check?.status === 'current') return 'current';
86
+ return 'unknown';
87
+ }
88
+
89
+ /**
90
+ * 右栏账本:canonical + installs + links。左栏(模型可见)由调用方从 `ctx.skills.snapshot` 取。
91
+ * 两侧差异本身就是诊断数据,不做自动修复。
92
+ */
93
+ export function buildLedger({ paths, state, registry, lock, visibleNames = new Map() }) {
94
+ const scanned = scanCanonical(paths);
95
+ const targets = registry.detected();
96
+ const installs = new Map(state.installs.map((item) => [item.name, item]));
97
+
98
+ const rows = scanned.map((entry) => {
99
+ const install = installs.get(entry.name);
100
+ const lockEntry = lock.skills?.[entry.name] || null;
101
+ const recordLinks = state.links.filter((link) => link.skill === entry.name);
102
+
103
+ // 期望接入矩阵:已有记录 + 各探测目录的现场状态
104
+ const links = [];
105
+ for (const target of targets) {
106
+ const linkPath = path.join(target.dir, entry.name);
107
+ const live = inspectTarget({ linkPath, canonicalPath: entry.path });
108
+ if (live.state === 'absent') continue;
109
+ const recorded = recordLinks.find((link) => link.target === target.target);
110
+ const stateName =
111
+ live.state === 'managed-external' && recorded ? 'managed' : live.state;
112
+ links.push({
113
+ target: target.target,
114
+ label: target.label,
115
+ linkPath,
116
+ state: stateName,
117
+ recorded: Boolean(recorded),
118
+ broken: Boolean(live.broken),
119
+ });
120
+ }
121
+
122
+ const brokenLinks = links.filter((link) => link.broken);
123
+ let currentHash = null;
124
+ let locallyModified = false;
125
+ if (install && entry.kind === 'dir') {
126
+ currentHash = contentHash(entry.path);
127
+ locallyModified = Boolean(install.installedHash) && currentHash !== install.installedHash;
128
+ }
129
+
130
+ const check = state.checks?.[entry.name] || null;
131
+ const visibleSource = visibleNames.get(entry.name);
132
+ return {
133
+ name: entry.name,
134
+ path: entry.path,
135
+ kind: entry.kind,
136
+ hasSkillFile: entry.hasSkillFile,
137
+ description: entry.hasSkillFile
138
+ ? readFrontmatterDescription(entry.kind === 'file' ? entry.path : path.join(entry.path, 'SKILL.md'))
139
+ : null,
140
+ managed: Boolean(install),
141
+ subscriptionId: install?.subscriptionId ?? null,
142
+ source: lockEntry?.source ?? install?.source ?? null,
143
+ sourceUrl: lockEntry?.sourceUrl ?? null,
144
+ sourceType: lockEntry?.sourceType ?? null,
145
+ ref: lockEntry?.ref ?? install?.ref ?? null,
146
+ skillPath: lockEntry?.skillPath ?? install?.skillPath ?? null,
147
+ commit: install?.commit ?? null,
148
+ lockHash: lockEntry?.skillFolderHash ?? null,
149
+ installedHash: install?.installedHash ?? null,
150
+ currentHash,
151
+ installedAt: install?.installedAt ?? null,
152
+ updatedAt: install?.updatedAt ?? null,
153
+ mode: install?.mode ?? 'on',
154
+ scopeProjects: install?.scopeProjects ?? [],
155
+ backup: install?.backup ?? null,
156
+ links,
157
+ brokenLinks: brokenLinks.length,
158
+ status: statusOf({ install, check, locallyModified, brokenLinks }),
159
+ check,
160
+ visible: visibleNames.has(entry.name),
161
+ visibleSource: visibleSource ?? null,
162
+ note: entry.kind === 'broken' ? 'canonical 里是一个悬空链接' : entry.hasSkillFile ? null : '缺少 SKILL.md,DSH 无法解析',
163
+ };
164
+ });
165
+
166
+ // 左栏有、右栏无:别人装的 / 别的 root 里的
167
+ const visibleOnly = [];
168
+ for (const [name, source] of visibleNames.entries()) {
169
+ if (rows.some((row) => row.name === name)) continue;
170
+ visibleOnly.push({ name, source });
171
+ }
172
+
173
+ return { rows, visibleOnly, scannedAt: new Date().toISOString() };
174
+ }
@@ -0,0 +1,104 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * 四态托管判定(比三态多一个 `managed-external`,见设计 §4.2)+ `absent`。
6
+ *
7
+ * - managed 指向 canonical 同名目录,且插件 state 有创建记录
8
+ * - managed-external 指向 canonical 同名目录,但 state 没有记录(别人建的,可"接管")
9
+ * - foreign-link 是软链但指向别处(含悬空)
10
+ * - real 真实目录或文件(本地实体,绝不覆盖)
11
+ * - absent 目标位置什么都没有
12
+ */
13
+ export const LINK_STATES = ['managed', 'managed-external', 'foreign-link', 'real', 'absent'];
14
+
15
+ function realpathOrNull(target) {
16
+ try {
17
+ return fs.realpathSync(target);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function hasSkillFile(dir) {
24
+ try {
25
+ return fs.statSync(path.join(dir, 'SKILL.md')).isFile();
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ export function inspectTarget({ linkPath, canonicalPath }) {
32
+ let st;
33
+ try {
34
+ st = fs.lstatSync(linkPath);
35
+ } catch {
36
+ return { state: 'absent', linkPath };
37
+ }
38
+
39
+ if (!st.isSymbolicLink()) return { state: 'real', linkPath };
40
+
41
+ let rawTarget = null;
42
+ try {
43
+ rawTarget = fs.readlinkSync(linkPath);
44
+ } catch {
45
+ rawTarget = null;
46
+ }
47
+
48
+ const resolved = realpathOrNull(linkPath);
49
+ if (!resolved) return { state: 'foreign-link', linkPath, target: rawTarget, broken: true };
50
+
51
+ const canonicalReal = canonicalPath ? realpathOrNull(canonicalPath) : null;
52
+ const sameSkill = canonicalReal && resolved === canonicalReal && hasSkillFile(resolved);
53
+ if (sameSkill) return { state: 'managed-external', linkPath, target: rawTarget, resolved };
54
+ return { state: 'foreign-link', linkPath, target: rawTarget, resolved };
55
+ }
56
+
57
+ /**
58
+ * 建链:同层用相对路径(可跨机器迁移),算不出相对就退回绝对路径并标记。
59
+ * Windows 用 junction + 绝对路径(对齐 CLI 的 createSymlink,F2)。
60
+ */
61
+ export function createLink({ linkPath, targetPath, platform = process.platform, fsImpl = fs }) {
62
+ fsImpl.mkdirSync(path.dirname(linkPath), { recursive: true });
63
+ if (platform === 'win32') {
64
+ fsImpl.symlinkSync(fsImpl.realpathSync(targetPath), linkPath, 'junction');
65
+ return { mode: 'junction', linkPath, targetPath };
66
+ }
67
+ // 非 Windows 一律用相对软链:同层时是 `../../.agents/skills/<name>`,跨层时由 path.relative 算出距离。
68
+ const rel = path.relative(path.dirname(linkPath), targetPath);
69
+ fsImpl.symlinkSync(rel || targetPath, linkPath, 'dir');
70
+ return { mode: 'relative', linkPath, targetPath, linkTarget: rel };
71
+ }
72
+
73
+ /** 删除链接:只删软链,且默认只删指向 canonical 的(`force` 仅用于回收悬空链)。 */
74
+ export function removeLink({ linkPath, canonicalPath, force = false }) {
75
+ let st;
76
+ try {
77
+ st = fs.lstatSync(linkPath);
78
+ } catch {
79
+ return { removed: false, reason: 'absent' };
80
+ }
81
+ if (!st.isSymbolicLink()) return { removed: false, reason: 'not-a-link' };
82
+ if (!force) {
83
+ const resolved = realpathOrNull(linkPath);
84
+ const canonicalReal = canonicalPath ? realpathOrNull(canonicalPath) : null;
85
+ if (!resolved || !canonicalReal || resolved !== canonicalReal) {
86
+ return { removed: false, reason: 'not-managed' };
87
+ }
88
+ }
89
+ fs.unlinkSync(linkPath);
90
+ return { removed: true };
91
+ }
92
+
93
+ /** 幂等建链:已存在同内容链接 → 跳过;其他任何占用 → 跳过并说明原因。 */
94
+ export function ensureLink({ linkPath, canonicalPath }) {
95
+ const current = inspectTarget({ linkPath, canonicalPath });
96
+ if (current.state === 'managed' || current.state === 'managed-external') {
97
+ return { created: false, state: current.state, linkPath };
98
+ }
99
+ if (current.state !== 'absent') {
100
+ return { created: false, state: current.state, linkPath, skipped: true };
101
+ }
102
+ const created = createLink({ linkPath, targetPath: canonicalPath });
103
+ return { created: true, state: 'managed', ...created };
104
+ }
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const MAX_LOG_BYTES = 1024 * 1024;
5
+
6
+ /** JSONL 事件日志:1MB 滚动一次(`.1` 覆盖)。写失败不影响主流程。 */
7
+ export function createLogger(file) {
8
+ return function log(event, detail) {
9
+ try {
10
+ if (fs.existsSync(file) && fs.statSync(file).size > MAX_LOG_BYTES) {
11
+ fs.renameSync(file, `${file}.1`);
12
+ }
13
+ fs.mkdirSync(path.dirname(file), { recursive: true });
14
+ fs.appendFileSync(
15
+ file,
16
+ `${JSON.stringify({ at: new Date().toISOString(), event, ...(detail || {}) })}\n`,
17
+ 'utf8',
18
+ );
19
+ } catch {
20
+ // 日志永远不该让操作失败
21
+ }
22
+ };
23
+ }
@@ -0,0 +1,54 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * 所有路径集中在这里,任何模块都不许自己拼 `~`。
6
+ * 覆盖顺序:显式 overrides(测试)> 环境变量 > 默认值。
7
+ */
8
+ export function createPaths(env = process.env, overrides = {}) {
9
+ const home = overrides.home ?? env.HOME ?? os.homedir();
10
+ // 有些 agent 把全局技能放在 XDG config 下(opencode / amp / devin…)
11
+ const configHome = overrides.configHome ?? env.XDG_CONFIG_HOME ?? path.join(home, '.config');
12
+ const dshHome = overrides.dshHome ?? env.DSH_HOME ?? path.join(home, '.dsh');
13
+ const stateDir =
14
+ overrides.stateDir ?? env.DSH_SKILLS_CONSOLE_HOME ?? path.join(dshHome, 'skills-console');
15
+
16
+ return {
17
+ home,
18
+ configHome,
19
+ dshHome,
20
+ stateDir,
21
+ /** canonical 真身:CLI 唯一的落点(F1) */
22
+ canonical: path.join(home, '.agents', 'skills'),
23
+ /** CLI 全局 lock(F4) */
24
+ lockFile: path.join(home, '.agents', '.skill-lock.json'),
25
+ /** DSH 原生 user-dsh 根(rank 400) */
26
+ dshGlobalSkills: path.join(dshHome, 'skills'),
27
+ stateFile: path.join(stateDir, 'state.json'),
28
+ backupsDir: path.join(stateDir, 'backups'),
29
+ trashDir: path.join(stateDir, 'trash'),
30
+ logFile: path.join(stateDir, 'dsh-skills-console.log'),
31
+ };
32
+ }
33
+
34
+ /** 原生 project-dsh 根(rank 100):`<projectRoot>/.dsh/skills`。 */
35
+ export function projectSkillsDir(projectRoot) {
36
+ return path.join(projectRoot, '.dsh', 'skills');
37
+ }
38
+
39
+ /** 项目根 = 最近含 `.git` 的祖先,无则返回 null(F28)。 */
40
+ export function findProjectRoot(startDir, existsSync) {
41
+ let current = path.resolve(startDir || process.cwd());
42
+ for (;;) {
43
+ if (existsSync(path.join(current, '.git'))) return current;
44
+ const parent = path.dirname(current);
45
+ if (parent === current) return null;
46
+ current = parent;
47
+ }
48
+ }
49
+
50
+ /** a 是否等于 b 或位于 b 之下(路径包含校验,防穿越)。 */
51
+ export function isInside(parent, child) {
52
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
53
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
54
+ }