dsh-desktop-kit 2.1.4 → 2.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.
package/index.js CHANGED
@@ -15,6 +15,13 @@ import fs from "node:fs";
15
15
  import os from "node:os";
16
16
  import path from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
+ import {
19
+ readManifest,
20
+ readBaseline,
21
+ syncSkills,
22
+ loadSkillState,
23
+ STATE_FILE,
24
+ } from "./lib/skills-sync.js";
18
25
 
19
26
  import * as gkbRuUi from "./plugins/dsh-gkb-ru-ui/lib/index.js";
20
27
  import * as peakGuard from "./plugins/dsh-peak-guard/index.js";
@@ -25,6 +32,13 @@ import * as tokenLab from "./plugins/dsh-token-lab/lib/index.js";
25
32
  import * as skillAutopilot from "./plugins/dsh-skill-autopilot/index.js";
26
33
 
27
34
  const here = path.dirname(fileURLToPath(import.meta.url));
35
+ const KIT_VERSION = (() => {
36
+ try {
37
+ return JSON.parse(fs.readFileSync(path.join(here, "package.json"), "utf8")).version ?? "unknown";
38
+ } catch {
39
+ return "unknown";
40
+ }
41
+ })();
28
42
 
29
43
  const parts = [
30
44
  ["dsh-gkb-ru-ui", gkbRuUi],
@@ -39,32 +53,73 @@ const parts = [
39
53
  /** The kit itself needs no service; each child declares its own. */
40
54
  export const inject = [];
41
55
 
42
- /** Copy bundled skills that the DSH home does not have yet. */
43
- export function installSkills(logger) {
44
- const home = process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
56
+ const runtimeState = {
57
+ kitVersion: KIT_VERSION,
58
+ startedAt: new Date().toISOString(),
59
+ skills: null,
60
+ mountErrors: [],
61
+ mounted: [],
62
+ };
63
+
64
+ function skillsHome() {
65
+ return process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
66
+ }
67
+
68
+ /**
69
+ * Synchronise bundled skills without ever overwriting a user edit.
70
+ * Unchanged skills update; modified skills stay and are reported.
71
+ */
72
+ export function installSkills(logger, options = {}) {
73
+ const home = skillsHome();
45
74
  const source = path.join(here, "skills");
46
75
  const target = path.join(home, "skills");
47
- if (!fs.existsSync(source)) return 0;
48
- fs.mkdirSync(target, { recursive: true });
49
- let added = 0;
50
- for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
51
- if (!entry.isDirectory()) continue;
52
- const destination = path.join(target, entry.name);
53
- if (fs.existsSync(destination)) continue;
54
- fs.cpSync(path.join(source, entry.name), destination, { recursive: true });
55
- added += 1;
76
+ if (!fs.existsSync(source)) return null;
77
+ const manifest = readManifest(here);
78
+ const baseline = readBaseline(here);
79
+ const result = syncSkills({
80
+ source,
81
+ target,
82
+ kitVersion: KIT_VERSION,
83
+ manifest,
84
+ baseline,
85
+ force: options.force === true,
86
+ logger,
87
+ });
88
+ runtimeState.skills = result;
89
+ if (result.conflicts.length > 0) {
90
+ logger?.warn?.(`[dsh-desktop-kit] local edits kept for: ${result.conflicts.join(", ")} (see /kit conflicts)`);
91
+ }
92
+ return result;
93
+ }
94
+
95
+ function kitStatusText() {
96
+ const home = skillsHome();
97
+ const state = loadSkillState(path.join(home, "skills"));
98
+ const skills = runtimeState.skills;
99
+ const tracked = Object.keys(state.skills).length;
100
+ const conflicts = Object.keys(state.conflicts).length;
101
+ const lines = [
102
+ `DSH Desktop Kit ${KIT_VERSION}`,
103
+ `Плагины: ${runtimeState.mounted.length}/${parts.length} смонтированы${runtimeState.mountErrors.length > 0 ? `, ошибок: ${runtimeState.mountErrors.length}` : ""}`,
104
+ `Навыки: пакетных ${tracked} · конфликтов ${conflicts} · состояние ${state.updatedAt || "ещё не записано"}`,
105
+ ];
106
+ if (skills) {
107
+ lines.push(`Последняя синхронизация: +${skills.added.length} ~${skills.updated.length} · конфликтов: ${skills.conflicts.length}`);
108
+ if (skills.conflicts.length > 0) lines.push(`Конфликты (сохранены ваши правки): ${skills.conflicts.join(", ")}`);
56
109
  }
57
- if (added > 0) {
58
- logger?.info?.(`[dsh-desktop-kit] installed ${added} bundled skills into ${target}`);
110
+ if (runtimeState.mountErrors.length > 0) {
111
+ for (const error of runtimeState.mountErrors.slice(0, 5)) lines.push(`Ошибка ${error.name}: ${error.message}`);
59
112
  }
60
- return added;
113
+ lines.push(`Состояние: ${path.join(home, "skills", STATE_FILE)}`);
114
+ lines.push("Команды: /kit status | sync | conflicts | force (перезаписать правки)");
115
+ return lines.join("\n");
61
116
  }
62
117
 
63
118
  export function apply(ctx) {
64
119
  try {
65
120
  installSkills(ctx.logger);
66
121
  } catch (error) {
67
- ctx.logger?.warn?.(`[dsh-desktop-kit] skill install skipped: ${String(error)}`);
122
+ ctx.logger?.warn?.(`[dsh-desktop-kit] skill sync skipped: ${String(error)}`);
68
123
  }
69
124
 
70
125
  for (const [name, namespace] of parts) {
@@ -75,10 +130,59 @@ export function apply(ctx) {
75
130
  } else if (typeof plugin.apply === "function") {
76
131
  plugin.apply(ctx);
77
132
  }
133
+ runtimeState.mounted.push(name);
78
134
  ctx.logger?.debug?.(`[dsh-desktop-kit] mounted ${name}`);
79
135
  } catch (error) {
136
+ runtimeState.mountErrors.push({ name, message: error instanceof Error ? error.message : String(error) });
80
137
  ctx.logger?.warn?.(`[dsh-desktop-kit] ${name} failed to mount: ${String(error)}`);
81
138
  console.error(`[dsh-desktop-kit] ${name} failed to mount`, error);
82
139
  }
83
140
  }
141
+
142
+ /* Optional diagnostics command: useful after a DSH/package update. */
143
+ try {
144
+ ctx.inject(["commands"], (commandCtx) => {
145
+ try {
146
+ const dispose = commandCtx.commands.register({
147
+ name: "kit",
148
+ description: "DSH Desktop Kit: состояние, синхронизация навыков, конфликты",
149
+ input: { hint: "status | sync | conflicts | force" },
150
+ recordInput: false,
151
+ handler: ({ rawInput }) => {
152
+ const command = String(rawInput ?? "").trim().toLowerCase();
153
+ try {
154
+ if (command === "sync" || command === "force") {
155
+ const force = command === "force";
156
+ const result = installSkills(ctx.logger, { force });
157
+ return {
158
+ kind: "success",
159
+ text: result
160
+ ? `${force ? "Принудительная синхронизация" : "Синхронизация"}: добавлено ${result.added.length}, обновлено ${result.updated.length}, конфликтов ${result.conflicts.length}.\n${kitStatusText()}`
161
+ : "Каталог навыков пакета не найден.",
162
+ };
163
+ }
164
+ if (command === "conflicts") {
165
+ const skills = runtimeState.skills;
166
+ if (!skills || skills.conflicts.length === 0) return { kind: "success", text: "Конфликтов нет: все навыки принадлежат пакету." };
167
+ const lines = skills.conflicts.map((name) => {
168
+ const conflict = skills.state?.conflicts?.[name];
169
+ return `- ${name}: ${conflict?.reason ?? "изменён пользователем"} — ваша версия сохранена`;
170
+ });
171
+ return { kind: "success", text: `Конфликты (${lines.length}):\n${lines.join("\n")}\nОбновить принудительно: /kit force (ваши версии уйдут в бэкап)"` };
172
+ }
173
+ return { kind: "success", text: kitStatusText() };
174
+ } catch (error) {
175
+ return { kind: "error", text: `DSH Desktop Kit: ${error instanceof Error ? error.message : String(error)}` };
176
+ }
177
+ },
178
+ });
179
+ ctx.effect(() => dispose, "dsh-desktop-kit: /kit command");
180
+ } catch (error) {
181
+ ctx.logger?.warn?.(`[dsh-desktop-kit] /kit command skipped: ${String(error)}`);
182
+ }
183
+ });
184
+ } catch (error) {
185
+ ctx.logger?.debug?.(`[dsh-desktop-kit] commands service unavailable: ${String(error)}`);
186
+ }
84
187
  }
188
+
package/install.ps1 ADDED
@@ -0,0 +1,99 @@
1
+ #requires -Version 5.1
2
+ <#
3
+ .SYNOPSIS
4
+ Install dsh-desktop-kit into a DeepSeek Harness profile.
5
+
6
+ .DESCRIPTION
7
+ Copies nothing into the profile outside pnpm's own dependency install:
8
+ the kit is linked from this repository, the bundle is added to
9
+ dsh.profile.bundles, and the DSH launcher is asked to run pnpm.
10
+ Skills are copied by the kit itself on its first start.
11
+
12
+ .PARAMETER Profile
13
+ DSH profile name, default "desktop".
14
+
15
+ .PARAMETER DshHome
16
+ DSH home; defaults to $env:DSH_HOME or %USERPROFILE%\.dsh.
17
+
18
+ .PARAMETER RepoPath
19
+ Repository root; defaults to this script's folder.
20
+ #>
21
+ param(
22
+ [string]$Profile = "desktop",
23
+ [string]$DshHome = "",
24
+ [string]$RepoPath = ""
25
+ )
26
+
27
+ $ErrorActionPreference = "Stop"
28
+
29
+ function Write-Step([string]$Text) {
30
+ Write-Host ""
31
+ Write-Host "==> $Text" -ForegroundColor Cyan
32
+ }
33
+
34
+ if ([string]::IsNullOrWhiteSpace($RepoPath)) { $RepoPath = $PSScriptRoot }
35
+ if (-not (Test-Path (Join-Path $RepoPath "package.json"))) {
36
+ throw "package.json not found in $RepoPath"
37
+ }
38
+ if ([string]::IsNullOrWhiteSpace($DshHome)) {
39
+ if ($env:DSH_HOME) { $DshHome = $env:DSH_HOME } else { $DshHome = Join-Path $env:USERPROFILE ".dsh" }
40
+ }
41
+
42
+ $profileDir = Join-Path $DshHome "profiles\$Profile"
43
+ $profileJson = Join-Path $profileDir "package.json"
44
+
45
+ Write-Step "Checking the DSH environment"
46
+ $dsh = $null
47
+ $command = Get-Command dsh -ErrorAction SilentlyContinue
48
+ if ($command) { $dsh = $command.Source }
49
+ if (-not $dsh) {
50
+ $fallback = Join-Path ${env:LOCALAPPDATA} "Programs\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd"
51
+ if (Test-Path $fallback) { $dsh = $fallback }
52
+ }
53
+ if (-not $dsh) { throw "dsh CLI not found. Install DeepSeek Harness, run it once, then retry." }
54
+ if (-not (Test-Path $profileJson)) {
55
+ throw "Profile '$Profile' does not exist ($profileDir). Start DSH once, close it, then retry."
56
+ }
57
+
58
+ Write-Host "dsh: $dsh"
59
+ Write-Host "DSH home: $DshHome"
60
+ Write-Host "Profile: $Profile"
61
+ Write-Host "Repository: $RepoPath"
62
+
63
+ $running = Get-Process -Name "DeepSeek Harness" -ErrorAction SilentlyContinue
64
+ if ($running) {
65
+ Write-Host "DSH is running. Close it before installing so the profile is not written twice." -ForegroundColor Yellow
66
+ }
67
+
68
+ Write-Step "Backing up the profile manifest"
69
+ $stamp = Get-Date -Format "yyyyMMdd-HHmmss"
70
+ $backupDir = Join-Path $profileDir ("backup-kit-" + $stamp)
71
+ New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
72
+ Copy-Item $profileJson (Join-Path $backupDir "package.json") -Force
73
+ $patchFile = Join-Path $profileDir "cordis.patch.yml"
74
+ if (Test-Path $patchFile) { Copy-Item $patchFile (Join-Path $backupDir "cordis.patch.yml") -Force }
75
+ Write-Host "Backup: $backupDir"
76
+
77
+ Write-Step "Installing the package through the DSH launcher (pnpm)"
78
+ & $dsh plugin --profile $Profile add $RepoPath
79
+ if ($LASTEXITCODE -ne 0) {
80
+ throw "dsh plugin add exited with code $LASTEXITCODE. See the output above."
81
+ }
82
+
83
+ Write-Step "Enabling the bundle in the profile manifest"
84
+ $json = Get-Content -Path $profileJson -Raw | ConvertFrom-Json
85
+ if ($null -eq $json.dsh) { $json | Add-Member -NotePropertyName dsh -NotePropertyValue ([pscustomobject]@{}) }
86
+ if ($null -eq $json.dsh.profile) { $json.dsh | Add-Member -NotePropertyName profile -NotePropertyValue ([pscustomobject]@{}) }
87
+ $bundles = @()
88
+ if ($json.dsh.profile.bundles) { $bundles = @($json.dsh.profile.bundles) }
89
+ if ($bundles -notcontains "dsh-desktop-kit") { $bundles += "dsh-desktop-kit" }
90
+ $json.dsh.profile | Add-Member -NotePropertyName bundles -NotePropertyValue $bundles -Force
91
+ $jsonText = $json | ConvertTo-Json -Depth 20
92
+ [System.IO.File]::WriteAllText($profileJson, $jsonText, (New-Object System.Text.UTF8Encoding($false)))
93
+
94
+ Write-Step "Done"
95
+ Write-Host "1. Start DeepSeek Harness (restart it if it was running)." -ForegroundColor Green
96
+ Write-Host "2. Settings -> Plugins: 'dsh-desktop-kit' is enabled." -ForegroundColor Green
97
+ Write-Host "3. The kit copies missing skills into $DshHome\skills on first start." -ForegroundColor Green
98
+ Write-Host ""
99
+ Write-Host "Rollback: run uninstall.ps1, or restore package.json from $backupDir." -ForegroundColor DarkGray
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Update-safe skill synchronisation.
3
+ *
4
+ * Rules:
5
+ * - a missing skill is installed;
6
+ * - a skill that still matches the hash recorded at the last sync is updated
7
+ * (with a backup);
8
+ * - a skill the user edited is never overwritten — it is reported as a
9
+ * conflict and stays untouched;
10
+ * - an installation without a state file is adopted only when its content
11
+ * matches a known baseline hash; otherwise it is treated as user-owned.
12
+ *
13
+ * The state file lives next to the skills and is written atomically.
14
+ */
15
+ import crypto from "node:crypto";
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ export const SKILLS_SYNC_SCHEMA = 1;
20
+ export const STATE_FILE = ".dsh-kit-skills.json";
21
+ export const BACKUP_DIR = ".dsh-kit-backups";
22
+ const SKIP_FILES = new Set([".DS_Store", "Thumbs.db", STATE_FILE]);
23
+
24
+ function sha256(data) {
25
+ return crypto.createHash("sha256").update(data).digest("hex");
26
+ }
27
+
28
+ export function hashText(text) {
29
+ return sha256(String(text ?? ""));
30
+ }
31
+
32
+ /** Deterministic directory hash: sorted relative paths + bytes. */
33
+ export function hashSkillDir(directory) {
34
+ const hash = crypto.createHash("sha256");
35
+ let files = 0;
36
+ let bytes = 0;
37
+ const walk = (current, relative) => {
38
+ let entries;
39
+ try {
40
+ entries = fs.readdirSync(current, { withFileTypes: true });
41
+ } catch {
42
+ return;
43
+ }
44
+ entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
45
+ for (const entry of entries) {
46
+ if (SKIP_FILES.has(entry.name)) continue;
47
+ const full = path.join(current, entry.name);
48
+ const rel = relative.length > 0 ? `${relative}/${entry.name}` : entry.name;
49
+ if (entry.isDirectory()) {
50
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
51
+ walk(full, rel);
52
+ continue;
53
+ }
54
+ if (!entry.isFile()) continue;
55
+ try {
56
+ const content = fs.readFileSync(full);
57
+ hash.update(rel);
58
+ hash.update("\0");
59
+ hash.update(String(content.length));
60
+ hash.update("\0");
61
+ hash.update(content);
62
+ files += 1;
63
+ bytes += content.length;
64
+ } catch {
65
+ /* unreadable file: ignore, the hash stays stable */
66
+ }
67
+ }
68
+ };
69
+ walk(directory, "");
70
+ return { hash: hash.digest("hex"), files, bytes };
71
+ }
72
+
73
+ export function readManifest(packageRoot) {
74
+ try {
75
+ const parsed = JSON.parse(fs.readFileSync(path.join(packageRoot, "skills", "manifest.json"), "utf8"));
76
+ return parsed && parsed.schema === SKILLS_SYNC_SCHEMA && parsed.skills ? parsed : null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ export function readBaseline(packageRoot) {
83
+ try {
84
+ const parsed = JSON.parse(fs.readFileSync(path.join(packageRoot, "skills-baseline.json"), "utf8"));
85
+ return parsed && parsed.versions ? parsed.versions : {};
86
+ } catch {
87
+ return {};
88
+ }
89
+ }
90
+
91
+ export function loadSkillState(target) {
92
+ try {
93
+ const parsed = JSON.parse(fs.readFileSync(path.join(target, STATE_FILE), "utf8"));
94
+ if (!parsed || parsed.schema !== SKILLS_SYNC_SCHEMA) return emptyState();
95
+ return {
96
+ ...emptyState(),
97
+ ...parsed,
98
+ skills: parsed.skills && typeof parsed.skills === "object" ? parsed.skills : {},
99
+ conflicts: parsed.conflicts && typeof parsed.conflicts === "object" ? parsed.conflicts : {},
100
+ };
101
+ } catch {
102
+ return emptyState();
103
+ }
104
+ }
105
+
106
+ function emptyState() {
107
+ return { schema: SKILLS_SYNC_SCHEMA, kitVersion: "", updatedAt: "", skills: {}, conflicts: {} };
108
+ }
109
+
110
+ function writeAtomic(file, text) {
111
+ const temp = `${file}.tmp-${process.pid}-${Date.now()}`;
112
+ fs.writeFileSync(temp, text, "utf8");
113
+ fs.renameSync(temp, file);
114
+ }
115
+
116
+ export function saveSkillState(target, state) {
117
+ try {
118
+ fs.mkdirSync(target, { recursive: true });
119
+ writeAtomic(path.join(target, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
120
+ } catch {
121
+ /* state is an optimization; a failed write must not break skills */
122
+ }
123
+ }
124
+
125
+ function copySkill(source, destination) {
126
+ const parent = path.dirname(destination);
127
+ fs.mkdirSync(parent, { recursive: true });
128
+ const temp = path.join(parent, `.${path.basename(destination)}.kit-tmp-${process.pid}-${Date.now()}`);
129
+ fs.rmSync(temp, { recursive: true, force: true });
130
+ try {
131
+ fs.cpSync(source, temp, { recursive: true });
132
+ } catch (error) {
133
+ fs.rmSync(temp, { recursive: true, force: true });
134
+ throw error;
135
+ }
136
+ return temp;
137
+ }
138
+
139
+ function moveWithBackup(target, source, name) {
140
+ const hadTarget = fs.existsSync(target);
141
+ let backup = null;
142
+ if (hadTarget) {
143
+ const backupRoot = path.join(path.dirname(target), BACKUP_DIR, "skills");
144
+ fs.mkdirSync(backupRoot, { recursive: true });
145
+ const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
146
+ backup = path.join(backupRoot, `${name}-${stamp}`);
147
+ fs.renameSync(target, backup);
148
+ }
149
+ try {
150
+ fs.renameSync(source, target);
151
+ } catch (error) {
152
+ if (backup && !fs.existsSync(target)) {
153
+ try { fs.renameSync(backup, target); } catch { /* best effort */ }
154
+ }
155
+ throw error;
156
+ }
157
+ return backup;
158
+ }
159
+
160
+ /**
161
+ * Synchronise bundled skills into `target`.
162
+ * @returns {{added:string[],updated:string[],adopted:string[],kept:string[],conflicts:string[],removed:string[],state:object}}
163
+ */
164
+ export function syncSkills({ source, target, kitVersion, manifest, baseline, force = false, logger } = {}) {
165
+ const result = { added: [], updated: [], adopted: [], kept: [], conflicts: [], removed: [], state: emptyState() };
166
+ if (typeof source !== "string" || typeof target !== "string" || !fs.existsSync(source)) return result;
167
+ fs.mkdirSync(target, { recursive: true });
168
+ const state = loadSkillState(target);
169
+ state.kitVersion = kitVersion ?? state.kitVersion;
170
+ state.updatedAt = new Date().toISOString();
171
+ state.schema = SKILLS_SYNC_SCHEMA;
172
+ const knownBaselines = new Map();
173
+ for (const version of Object.values(baseline ?? {})) {
174
+ for (const [name, hash] of Object.entries(version ?? {})) {
175
+ if (!knownBaselines.has(name)) knownBaselines.set(name, new Set());
176
+ knownBaselines.get(name).add(hash);
177
+ }
178
+ }
179
+ const bundled = [];
180
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
181
+ if (entry.isDirectory()) bundled.push(entry.name);
182
+ }
183
+ bundled.sort();
184
+
185
+ for (const name of bundled) {
186
+ const sourceDir = path.join(source, name);
187
+ const targetDir = path.join(target, name);
188
+ const sourceInfo = hashSkillDir(sourceDir);
189
+ const manifestHash = manifest?.skills?.[name]?.hash;
190
+ const sourceHash = sourceInfo.hash;
191
+ const targetExists = fs.existsSync(targetDir);
192
+ const entry = state.skills[name];
193
+ const targetHash = targetExists ? hashSkillDir(targetDir).hash : "";
194
+
195
+ if (!targetExists) {
196
+ const temp = copySkill(sourceDir, targetDir);
197
+ moveWithBackup(targetDir, temp, name);
198
+ state.skills[name] = { hash: sourceHash, updatedAt: state.updatedAt, kitVersion: state.kitVersion };
199
+ delete state.conflicts[name];
200
+ result.added.push(name);
201
+ continue;
202
+ }
203
+ if (targetHash === sourceHash || (manifestHash && targetHash === manifestHash && sourceHash === manifestHash)) {
204
+ state.skills[name] = { hash: sourceHash, updatedAt: entry?.updatedAt ?? state.updatedAt, kitVersion: state.kitVersion };
205
+ delete state.conflicts[name];
206
+ if (!entry) result.adopted.push(name);
207
+ continue;
208
+ }
209
+ const unchangedSinceSync = Boolean(entry && targetHash === entry.hash);
210
+ const knownBaseline = !entry && Boolean(knownBaselines.get(name)?.has(targetHash));
211
+ if (force || unchangedSinceSync || knownBaseline) {
212
+ const temp = copySkill(sourceDir, targetDir);
213
+ moveWithBackup(targetDir, temp, name);
214
+ state.skills[name] = { hash: sourceHash, updatedAt: state.updatedAt, kitVersion: state.kitVersion };
215
+ delete state.conflicts[name];
216
+ result.updated.push(name);
217
+ continue;
218
+ }
219
+ state.conflicts[name] = {
220
+ reason: entry ? "modified-since-last-sync" : "modified-unknown-base",
221
+ hash: targetHash,
222
+ at: state.updatedAt,
223
+ };
224
+ result.kept.push(name);
225
+ result.conflicts.push(name);
226
+ }
227
+
228
+ /* Skills removed from the bundle are never deleted: they may be user-owned. */
229
+ for (const name of Object.keys(state.skills)) {
230
+ if (!bundled.includes(name) && fs.existsSync(path.join(target, name))) result.removed.push(name);
231
+ }
232
+
233
+ saveSkillState(target, state);
234
+ result.state = state;
235
+ if (result.added.length + result.updated.length > 0) {
236
+ logger?.info?.(`[dsh-desktop-kit] skills: +${result.added.length} ~${result.updated.length}, conflicts ${result.conflicts.length}`);
237
+ }
238
+ return result;
239
+ }
240
+
241
+ export function kitManifestSummary(manifest) {
242
+ if (!manifest?.skills) return { count: 0, bytes: 0 };
243
+ const values = Object.values(manifest.skills);
244
+ return { count: values.length, bytes: values.reduce((sum, skill) => sum + (Number(skill.bytes) || 0), 0) };
245
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-desktop-kit",
3
- "version": "2.1.4",
4
- "description": "DeepSeek Harness desktop kit 2.1: self-applying skills, live token lab, Skills Studio, self-scanning project memory, Russian UI, peak guard, chat move and profile transfer.",
3
+ "version": "2.2.1",
4
+ "description": "DeepSeek Harness desktop kit 2.2: update-safe self-applying skills, live token lab, Skills Studio, self-scanning project memory, Russian UI, peak guard, chat move and profile transfer.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",
@@ -32,8 +32,14 @@
32
32
  "client.js",
33
33
  "cordis.patch.yml",
34
34
  "README.md",
35
+ "CHANGELOG.md",
35
36
  "LICENSE",
37
+ "doctor.ps1",
38
+ "install.ps1",
39
+ "uninstall.ps1",
40
+ "lib/**",
36
41
  "docs/**",
42
+ "skills-baseline.json",
37
43
  "plugins/*/package.json",
38
44
  "plugins/*/*.js",
39
45
  "plugins/*/lib/**",
@@ -47,8 +53,9 @@
47
53
  ],
48
54
  "scripts": {
49
55
  "build:client": "node tools/build-client.mjs",
56
+ "build:skills": "node tools/build-skill-manifest.mjs",
50
57
  "check:client": "node tools/check-client.mjs client.js",
51
- "test": "node tools/smoke-tests.mjs && node tools/memory-tests.mjs",
58
+ "test": "node tools/smoke-tests.mjs && node tools/memory-tests.mjs && node tools/skills-sync-tests.mjs && node tools/kit-upgrade-tests.mjs",
52
59
  "skills:cost": "node tools/skill-cost.mjs",
53
60
  "skills:check": "node tools/skill-cost.mjs --check"
54
61
  },
@@ -0,0 +1,132 @@
1
+ {
2
+ "schema": 1,
3
+ "kitVersion": "2.2.1",
4
+ "generatedAt": "2026-11-06T12:03:12.122Z",
5
+ "skills": {
6
+ "code-review-checklist": {
7
+ "hash": "e665bf72cfbfb77948a3cd6df6ebced38777f9aff543d7b9c80b2ced8cccc662",
8
+ "files": 1,
9
+ "bytes": 4483
10
+ },
11
+ "content-writing": {
12
+ "hash": "a21b26ff814e0337d03eeb587b200a1dc66e7de91bfea0f32840ddf8884deeee",
13
+ "files": 28,
14
+ "bytes": 271717
15
+ },
16
+ "context-economy": {
17
+ "hash": "bc8fa2e15327d21cf9ebd187b0df5f620d6a5b617976fa88910ae7877eb46b75",
18
+ "files": 4,
19
+ "bytes": 12766
20
+ },
21
+ "database-safety": {
22
+ "hash": "56f43f6bac270aefce34355e2a932192e4d17725d8faec5f91f476792b28c617",
23
+ "files": 1,
24
+ "bytes": 4448
25
+ },
26
+ "debugging-playbook": {
27
+ "hash": "0d70b8102f04d29774e466efd81abb448c84a6537a2ca3438f6931a2d6fe0c3a",
28
+ "files": 1,
29
+ "bytes": 4443
30
+ },
31
+ "design-critique": {
32
+ "hash": "bbc6ca34b0b76798d581dcd78e83a59040dfac028b5e10d8e7a9be28225410b8",
33
+ "files": 6,
34
+ "bytes": 25754
35
+ },
36
+ "dev-workflow": {
37
+ "hash": "832d36da6d3dc3e6e7d1feb18c38260ed8669c7702dffbd2d7a96fc9bc93d22f",
38
+ "files": 4,
39
+ "bytes": 17194
40
+ },
41
+ "git-and-ci": {
42
+ "hash": "0681041edb935da2c124265bbdacd3b6a0a96c7f163323960cff8c8ea67fd2f4",
43
+ "files": 1,
44
+ "bytes": 4192
45
+ },
46
+ "pg-upgrade-verify": {
47
+ "hash": "d734178c5dad317fc55842215bbeeba85230cdd8e7b0ea46d74eed92910b8dea",
48
+ "files": 1,
49
+ "bytes": 4496
50
+ },
51
+ "project-deep-dive": {
52
+ "hash": "a70355f2a40b42a6ff03e745ba42389549b4efe4f392c98033fe5073f946452e",
53
+ "files": 4,
54
+ "bytes": 26290
55
+ },
56
+ "project-memory-maintenance": {
57
+ "hash": "648f175093f6a86e20157156d8731b8e9602badedc79188e77160b18832f4893",
58
+ "files": 4,
59
+ "bytes": 23469
60
+ },
61
+ "release-and-deploy": {
62
+ "hash": "e6ed27856b00e8759d31659162290307c16e203d2b5d28fcacb574be17fc795b",
63
+ "files": 1,
64
+ "bytes": 4409
65
+ },
66
+ "requirements-and-planning": {
67
+ "hash": "532cbccfb2d0f7eca06343927aba100bd653145ccd9b9173ca50299194631a05",
68
+ "files": 4,
69
+ "bytes": 12964
70
+ },
71
+ "security-review": {
72
+ "hash": "8665b2c469de8e83aca670021d5c335fa8c9b93b8dc6bb866759e6062b1abde4",
73
+ "files": 1,
74
+ "bytes": 4257
75
+ },
76
+ "seo": {
77
+ "hash": "ed48ebc4c1278f857ff430f9a5ec55f638c2f9ba07cc406185cfb94be18ee80d",
78
+ "files": 29,
79
+ "bytes": 386169
80
+ },
81
+ "service-desk-conventions": {
82
+ "hash": "22ae6c29b7debb0154d183bc732795b67557aeaa0ac906d7b038134aea8ba39a",
83
+ "files": 1,
84
+ "bytes": 4488
85
+ },
86
+ "service-desk-db-safety": {
87
+ "hash": "672c08d25e0a9262aeb0eec27b6daa2a619fade6ff3269bc2b9dc27e22c83d28",
88
+ "files": 1,
89
+ "bytes": 4482
90
+ },
91
+ "service-desk-release": {
92
+ "hash": "1bd0c03f772bf4b79ec4e887f5d0ebd564653bafd3d0d33d70f6c934afdcce47",
93
+ "files": 1,
94
+ "bytes": 4418
95
+ },
96
+ "service-desk-testing": {
97
+ "hash": "eaff64154a596590684308cdccb3f55099240589b57c0f506a85e5b38ddf5ac5",
98
+ "files": 1,
99
+ "bytes": 4129
100
+ },
101
+ "service-desk-ui": {
102
+ "hash": "a3a0e78ab147f4837e4ca9736f4d87cf77a6638c2db7c5859629df70abdd2c1c",
103
+ "files": 1,
104
+ "bytes": 4201
105
+ },
106
+ "skills-router": {
107
+ "hash": "63c2dcdeb07dd4af464a30a8065465bd06b73e4caa8aa874802e5188d4d5a4ae",
108
+ "files": 3,
109
+ "bytes": 13888
110
+ },
111
+ "testing-strategy": {
112
+ "hash": "773fdf1e389e59841b7766862f8890a11fba4a2fce42a2022b9c4bfaae8f6ecc",
113
+ "files": 1,
114
+ "bytes": 4279
115
+ },
116
+ "ui-design-system": {
117
+ "hash": "19244221171995b758dbad02f2e3602859308370b50cc37d93c6f2a6d44cd6f1",
118
+ "files": 4,
119
+ "bytes": 19454
120
+ },
121
+ "web-research": {
122
+ "hash": "9bf4d8ba6d2a0fc39c241cdd9f5fe54835af28872473aa617941589c526387c8",
123
+ "files": 3,
124
+ "bytes": 14150
125
+ },
126
+ "wow-design": {
127
+ "hash": "81139f32fc759f438a36eb4c0212ed3ea17df9d8075e909580e12bc47a0848dd",
128
+ "files": 52,
129
+ "bytes": 913669
130
+ }
131
+ }
132
+ }