@gethmy/mcp 2.7.0 → 2.8.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/skills.ts CHANGED
@@ -1,7 +1,15 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname } from "node:path";
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join } from "node:path";
3
10
  import { HarmonyApiClient } from "./api-client.js";
4
11
  import { areSkillsInstalled, isConfigured } from "./config.js";
12
+ import { loadHmyConfig } from "./hmy-config.js";
5
13
 
6
14
  /**
7
15
  * Legacy workflow prompt used by Codex / Cursor agents. Kept inline because
@@ -112,10 +120,45 @@ export interface FetchedSkill {
112
120
  * deploys where the API hasn't been upgraded yet.
113
121
  */
114
122
  export function buildSkillFile(skill: FetchedSkill): string {
115
- if (skill.skillVersion !== undefined && !hasMetadataVersion(skill.content)) {
116
- return injectMetadataVersion(skill.content, skill.skillVersion);
123
+ const content = stripSkillPreamble(skill.content);
124
+ if (skill.skillVersion !== undefined && !hasMetadataVersion(content)) {
125
+ return injectMetadataVersion(content, skill.skillVersion);
117
126
  }
118
- return skill.content;
127
+ return content;
128
+ }
129
+
130
+ const PREAMBLE_START = "<!-- hmy-skills-preamble:start -->";
131
+ const PREAMBLE_END = "<!-- hmy-skills-preamble:end -->";
132
+
133
+ /**
134
+ * Remove the legacy auto-update bash block from a rendered SKILL.md before it
135
+ * lands on disk. harmony-api no longer emits this block, but cached renders and
136
+ * already-installed files may still carry it — and that block is exactly what
137
+ * Claude Code's auto-mode classifier denies (curl → chmod +x → exec). Stripping
138
+ * on write means upgraded clients stop hitting the classifier on the next
139
+ * refresh. No-op when the markers are absent.
140
+ */
141
+ export function stripSkillPreamble(content: string): string {
142
+ const start = content.indexOf(PREAMBLE_START);
143
+ const end = content.indexOf(PREAMBLE_END);
144
+ if (start === -1 || end === -1 || end < start) return content;
145
+ const stripped =
146
+ content.slice(0, start) + content.slice(end + PREAMBLE_END.length);
147
+ // Collapse the blank-line run the removal leaves behind; keep a trailing \n.
148
+ return `${stripped.replace(/\n{3,}/g, "\n\n").replace(/\s+$/, "")}\n`;
149
+ }
150
+
151
+ /**
152
+ * Write a file atomically: write to a sibling temp file, then rename over the
153
+ * target (atomic on the same filesystem). Avoids a half-written SKILL.md if the
154
+ * process dies mid-write during a silent background refresh.
155
+ */
156
+ function atomicWrite(filePath: string, content: string): void {
157
+ const dir = dirname(filePath);
158
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
159
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
160
+ writeFileSync(tmp, content);
161
+ renameSync(tmp, filePath);
119
162
  }
120
163
 
121
164
  function hasMetadataVersion(content: string): boolean {
@@ -181,25 +224,72 @@ function findSkillFiles(
181
224
  return results;
182
225
  }
183
226
 
227
+ const HMY_DIR = join(homedir(), ".hmy");
228
+ const HMY_VERSION_FILE = join(HMY_DIR, "VERSION");
229
+ const LAST_CHECK_FILE = join(HMY_DIR, "last-update-check");
230
+ /** Probe the API for skill updates at most once per day per client. */
231
+ const CHECK_TTL_MS = 24 * 60 * 60 * 1000;
232
+
233
+ /** True if we performed a network check within CHECK_TTL_MS. */
234
+ function checkedRecently(now: number = Date.now()): boolean {
235
+ try {
236
+ if (!existsSync(LAST_CHECK_FILE)) return false;
237
+ const ts = Number.parseInt(
238
+ readFileSync(LAST_CHECK_FILE, "utf-8").trim(),
239
+ 10,
240
+ );
241
+ if (!Number.isFinite(ts)) return false;
242
+ return now - ts < CHECK_TTL_MS;
243
+ } catch {
244
+ return false;
245
+ }
246
+ }
247
+
248
+ /** Record that a network check just happened, resetting the TTL window. */
249
+ function recordCheck(now: number = Date.now()): void {
250
+ try {
251
+ if (!existsSync(HMY_DIR)) mkdirSync(HMY_DIR, { recursive: true });
252
+ writeFileSync(LAST_CHECK_FILE, String(now));
253
+ } catch {
254
+ // Best effort — a failed timestamp write only costs an extra probe.
255
+ }
256
+ }
257
+
184
258
  /**
185
259
  * Silently refresh installed skill files if remote versions are newer.
186
- * Called at MCP server startup. Non-blocking — any error (offline, 401,
187
- * partial install) is caught and the existing files are left in place.
260
+ * Called at MCP server `serve` startup (cli.ts). Non-blocking — any error
261
+ * (offline, 401, partial install) is caught and the existing files are left in
262
+ * place. Returns whether any file was rewritten so the caller can fire a
263
+ * `notifications/resources/list_changed` once the transport is connected.
264
+ *
265
+ * Honors `~/.hmy/config.yaml`: `update_check: false` disables it entirely and a
266
+ * `pin` freezes the installed version. The network probe is gated to once per
267
+ * `CHECK_TTL_MS` (tracked in `~/.hmy/last-update-check`) so starting many
268
+ * sessions in a day doesn't re-probe the API each time — pass `force` to skip
269
+ * the gate.
188
270
  *
189
271
  * Phase 3b: per-skill compare. Each skill_resource row carries its own
190
- * monotonic `version`. The mcp-server fetches each skill's `/v1/skills/<name>`
191
- * once, reads the per-skill `skillVersion` from the response, and rewrites
192
- * only the local files that are behind. An admin bumping `hmy` no longer
193
- * triggers a re-fetch of `hmy-plan`, `hmy-cleanup`, etc.
272
+ * monotonic `version`; only files that are behind are rewritten. Writes are
273
+ * atomic (temp + rename) and the legacy auto-update bash block is stripped on
274
+ * the way to disk (see buildSkillFile / stripSkillPreamble).
194
275
  */
195
- export async function refreshSkills(): Promise<void> {
276
+ export async function refreshSkills(
277
+ opts: { force?: boolean } = {},
278
+ ): Promise<{ updated: boolean }> {
196
279
  try {
197
- if (!isConfigured()) return;
280
+ if (!isConfigured()) return { updated: false };
281
+
282
+ const cfg = loadHmyConfig();
283
+ if (!cfg.updateCheck || cfg.pin) return { updated: false };
284
+ if (!opts.force && checkedRecently()) return { updated: false };
285
+
198
286
  const status = areSkillsInstalled();
199
- if (!status.installed) return;
287
+ if (!status.installed) return { updated: false };
200
288
 
201
289
  const client = new HarmonyApiClient();
202
290
  const versionInfo = await client.fetchSkillsVersion();
291
+ // We hit the network — reset the TTL window regardless of outcome.
292
+ recordCheck();
203
293
 
204
294
  const skillFiles = findSkillFiles(status.paths, versionInfo.skills);
205
295
 
@@ -222,7 +312,7 @@ export async function refreshSkills(): Promise<void> {
222
312
  }
223
313
  }
224
314
  }
225
- if (skillFiles.length === 0) return;
315
+ if (skillFiles.length === 0) return { updated: false };
226
316
 
227
317
  let updated = false;
228
318
  for (const { name, filePath } of skillFiles) {
@@ -243,10 +333,7 @@ export async function refreshSkills(): Promise<void> {
243
333
  continue;
244
334
  }
245
335
 
246
- const newContent = buildSkillFile(fetched);
247
- const dir = dirname(filePath);
248
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
249
- writeFileSync(filePath, newContent);
336
+ atomicWrite(filePath, buildSkillFile(fetched));
250
337
  updated = true;
251
338
  } catch (err) {
252
339
  const msg = err instanceof Error ? err.message : String(err);
@@ -254,9 +341,18 @@ export async function refreshSkills(): Promise<void> {
254
341
  }
255
342
  }
256
343
  if (updated) {
344
+ // VERSION is diagnostic now (the bash preamble that read it is gone),
345
+ // but keep it current so `hmy-mcp status` and support reflect reality.
346
+ try {
347
+ atomicWrite(HMY_VERSION_FILE, versionInfo.version);
348
+ } catch {
349
+ // best effort
350
+ }
257
351
  console.error("Harmony: Refreshed skills from server");
258
352
  }
353
+ return { updated };
259
354
  } catch {
260
355
  // Non-blocking — offline / 401 / partial install all fall through here.
356
+ return { updated: false };
261
357
  }
262
358
  }
package/src/tui/setup.ts CHANGED
@@ -271,9 +271,9 @@ async function getAgentFiles(
271
271
  switch (agentId) {
272
272
  case "claude": {
273
273
  // Claude Code skills come from /v1/skills (DB-backed). Fetch the
274
- // canonical list, then each body. hmy-update-check is intentionally
275
- // skipped — it's a shell script the auto-update preamble curls on
276
- // demand, not an installed SKILL.md.
274
+ // canonical list, then each body. hmy-update-check is skipped here — it's
275
+ // a shell script, not an installed SKILL.md. buildSkillFile() strips any
276
+ // legacy auto-update bash block before the content lands on disk.
277
277
  const client = new HarmonyApiClient();
278
278
  const versionInfo = await client.fetchSkillsVersion();
279
279
  const installableNames = versionInfo.skills.filter(
@@ -319,11 +319,11 @@ async function getAgentFiles(
319
319
  );
320
320
  }
321
321
 
322
- // Pre-populate ~/.hmy/VERSION and ~/.hmy/bin/hmy-update-check so the
323
- // lazy bootstrap inside the skill preamble is bypassed on first run.
324
- // Without this, the bootstrap writes "1.0.0" as a fallback whenever the
325
- // version fetch times out (edge function cold start) triggering a
326
- // spurious "upgrade to v6" prompt the moment the skill is first invoked.
322
+ // Seed ~/.hmy/VERSION (the baseline the server's refreshSkills compares
323
+ // against and keeps current) and ~/.hmy/bin/hmy-update-check. The script
324
+ // is no longer invoked by a skill preamble auto-update now runs in the
325
+ // MCP server process but it's kept as a standalone diagnostic and is
326
+ // integrity-checked here against its published sha256.
327
327
  try {
328
328
  const updateCheckFetched = await client.fetchSkill("hmy-update-check");
329
329
  const actualHash = createHash("sha256")