@skilltap/core 0.3.9 → 0.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skilltap/core",
3
- "version": "0.3.9",
3
+ "version": "0.4.4",
4
4
  "description": "Core library for skilltap — agent skill management",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,102 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { debug, flushDebug, resetDebug } from "./debug";
6
+
7
+ describe("debug", () => {
8
+ let tmpDir: string;
9
+ const origDebug = process.env.SKILLTAP_DEBUG;
10
+ const origXdg = process.env.XDG_CONFIG_HOME;
11
+
12
+ afterEach(async () => {
13
+ resetDebug();
14
+ if (origDebug === undefined) delete process.env.SKILLTAP_DEBUG;
15
+ else process.env.SKILLTAP_DEBUG = origDebug;
16
+ if (origXdg === undefined) delete process.env.XDG_CONFIG_HOME;
17
+ else process.env.XDG_CONFIG_HOME = origXdg;
18
+ if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
19
+ });
20
+
21
+ test("no-op when SKILLTAP_DEBUG is not set", async () => {
22
+ tmpDir = await mkdtemp(join(tmpdir(), "debug-test-"));
23
+ process.env.XDG_CONFIG_HOME = tmpDir;
24
+ delete process.env.SKILLTAP_DEBUG;
25
+
26
+ debug("should not write");
27
+ await flushDebug();
28
+
29
+ const logPath = join(tmpDir, "skilltap", "debug.log");
30
+ const exists = await Bun.file(logPath).exists();
31
+ expect(exists).toBe(false);
32
+ });
33
+
34
+ test("writes to debug.log when SKILLTAP_DEBUG=1", async () => {
35
+ tmpDir = await mkdtemp(join(tmpdir(), "debug-test-"));
36
+ process.env.XDG_CONFIG_HOME = tmpDir;
37
+ process.env.SKILLTAP_DEBUG = "1";
38
+
39
+ debug("hello world");
40
+ await flushDebug();
41
+
42
+ const logPath = join(tmpDir, "skilltap", "debug.log");
43
+ const content = await readFile(logPath, "utf-8");
44
+ expect(content).toContain("hello world");
45
+ expect(content).toMatch(/^\[\d{4}-\d{2}-\d{2}T/);
46
+ });
47
+
48
+ test("includes context as JSON", async () => {
49
+ tmpDir = await mkdtemp(join(tmpdir(), "debug-test-"));
50
+ process.env.XDG_CONFIG_HOME = tmpDir;
51
+ process.env.SKILLTAP_DEBUG = "1";
52
+
53
+ debug("test op", { exitCode: 18, stderr: "denied" });
54
+ await flushDebug();
55
+
56
+ const logPath = join(tmpDir, "skilltap", "debug.log");
57
+ const content = await readFile(logPath, "utf-8");
58
+ expect(content).toContain('"exitCode":18');
59
+ expect(content).toContain('"stderr":"denied"');
60
+ });
61
+
62
+ test("rotates when log exceeds 1 MB", async () => {
63
+ tmpDir = await mkdtemp(join(tmpdir(), "debug-test-"));
64
+ process.env.XDG_CONFIG_HOME = tmpDir;
65
+ process.env.SKILLTAP_DEBUG = "1";
66
+
67
+ const logDir = join(tmpDir, "skilltap");
68
+ const logPath = join(logDir, "debug.log");
69
+ const { mkdir } = await import("node:fs/promises");
70
+ await mkdir(logDir, { recursive: true });
71
+
72
+ // Write >1 MB of content
73
+ const bigContent = "X".repeat(1_200_000);
74
+ await writeFile(logPath, bigContent);
75
+
76
+ debug("after rotation");
77
+ await flushDebug();
78
+
79
+ const content = await readFile(logPath, "utf-8");
80
+ // Should be truncated to ~500KB + new line
81
+ expect(content.length).toBeLessThan(600_000);
82
+ expect(content).toContain("after rotation");
83
+ });
84
+
85
+ test("multiple writes are serialized", async () => {
86
+ tmpDir = await mkdtemp(join(tmpdir(), "debug-test-"));
87
+ process.env.XDG_CONFIG_HOME = tmpDir;
88
+ process.env.SKILLTAP_DEBUG = "1";
89
+
90
+ debug("line 1");
91
+ debug("line 2");
92
+ debug("line 3");
93
+ await flushDebug();
94
+
95
+ const logPath = join(tmpDir, "skilltap", "debug.log");
96
+ const content = await readFile(logPath, "utf-8");
97
+ const lines = content.trim().split("\n");
98
+ expect(lines).toHaveLength(3);
99
+ expect(lines[0]).toContain("line 1");
100
+ expect(lines[2]).toContain("line 3");
101
+ });
102
+ });
package/src/debug.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { getConfigDir } from "./config";
4
+
5
+ const MAX_LOG_SIZE = 1_048_576; // 1 MB
6
+ const KEEP_BYTES = 524_288; // 500 KB
7
+
8
+ let enabled: boolean | null = null;
9
+ let logPath: string | null = null;
10
+ let writeChain: Promise<void> = Promise.resolve();
11
+ let rotationDone = false;
12
+
13
+ function getLogPath(): string {
14
+ if (!logPath) logPath = join(getConfigDir(), "debug.log");
15
+ return logPath;
16
+ }
17
+
18
+ async function rotateIfNeeded(): Promise<void> {
19
+ if (rotationDone) return;
20
+ rotationDone = true;
21
+ try {
22
+ const path = getLogPath();
23
+ const s = await stat(path).catch(() => null);
24
+ if (s && s.size > MAX_LOG_SIZE) {
25
+ const content = await readFile(path);
26
+ await writeFile(path, content.slice(content.length - KEEP_BYTES));
27
+ }
28
+ } catch {
29
+ /* ignore rotation errors */
30
+ }
31
+ }
32
+
33
+ /** Write a timestamped debug message to the log file. No-op when SKILLTAP_DEBUG is not "1". */
34
+ export function debug(msg: string, context?: Record<string, unknown>): void {
35
+ if (enabled === null) enabled = process.env.SKILLTAP_DEBUG === "1";
36
+ if (!enabled) return;
37
+
38
+ const ts = new Date().toISOString();
39
+ const ctx = context ? ` ${JSON.stringify(context)}` : "";
40
+ const line = `[${ts}] ${msg}${ctx}\n`;
41
+
42
+ writeChain = writeChain.then(async () => {
43
+ await rotateIfNeeded();
44
+ const path = getLogPath();
45
+ await mkdir(join(path, ".."), { recursive: true }).catch(() => {});
46
+ await appendFile(path, line).catch(() => {});
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Flush pending debug writes. Only needed in tests — production code uses fire-and-forget.
52
+ * @internal
53
+ */
54
+ export function flushDebug(): Promise<void> {
55
+ return writeChain;
56
+ }
57
+
58
+ /**
59
+ * Reset internal state. Only for tests.
60
+ * @internal
61
+ */
62
+ export function resetDebug(): void {
63
+ enabled = null;
64
+ logPath = null;
65
+ writeChain = Promise.resolve();
66
+ rotationDone = false;
67
+ }
package/src/git.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { $ } from "bun";
2
+ import { debug } from "./debug";
3
+ import { extractStderr } from "./shell";
2
4
  import type { Result } from "./types";
3
5
  import { err, GitError, ok } from "./types";
4
6
 
@@ -13,15 +15,6 @@ export type CloneOptions = {
13
15
  depth?: number;
14
16
  };
15
17
 
16
- function extractStderr(e: unknown): string {
17
- if (e instanceof Error && "stderr" in e) {
18
- const raw = (e as { stderr: unknown }).stderr;
19
- if (raw instanceof Uint8Array) return new TextDecoder().decode(raw).trim();
20
- return String(raw).trim();
21
- }
22
- return String(e);
23
- }
24
-
25
18
  async function wrapGit<T>(
26
19
  fn: () => Promise<T>,
27
20
  msg: string,
@@ -30,8 +23,10 @@ async function wrapGit<T>(
30
23
  try {
31
24
  return ok(await fn());
32
25
  } catch (e) {
26
+ const stderr = extractStderr(e);
27
+ debug(msg, { stderr });
33
28
  return err(
34
- new GitError(`${msg}: ${extractStderr(e)}`, hint ? { hint } : undefined),
29
+ new GitError(`${msg}: ${stderr}`, hint ? { hint } : undefined),
35
30
  );
36
31
  }
37
32
  }
@@ -49,6 +44,7 @@ export async function clone(
49
44
  dest: string,
50
45
  opts?: CloneOptions,
51
46
  ): Promise<Result<void, GitError>> {
47
+ debug("git clone", { url, dest, branch: opts?.branch });
52
48
  const flags: string[] = ["--depth", String(opts?.depth ?? 1)];
53
49
  if (opts?.branch) flags.push("--branch", opts.branch);
54
50
  try {
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { version } from "../package.json";
2
2
  export const VERSION: string = version;
3
+ export * from "./debug";
3
4
  export * from "./doctor";
4
5
 
5
6
  export * from "./adapters";
@@ -26,4 +27,5 @@ export * from "./trust";
26
27
  export * from "./policy";
27
28
  export * from "./validate";
28
29
  export * from "./self-update";
30
+ export * from "./shell";
29
31
  export * from "./types";
package/src/install.ts CHANGED
@@ -2,6 +2,7 @@ import { mkdir } from "node:fs/promises";
2
2
  import { dirname, join, relative } from "node:path";
3
3
  import { $ } from "bun";
4
4
  import { resolveSource } from "./adapters";
5
+ import { debug } from "./debug";
5
6
  import type { AgentAdapter } from "./agents/types";
6
7
  import { loadInstalled, saveInstalled } from "./config";
7
8
  import { makeTmpDir, removeTmpDir } from "./fs";
@@ -16,6 +17,7 @@ import type { StaticWarning } from "./security";
16
17
  import { scanStatic } from "./security";
17
18
  import type { SemanticWarning } from "./security/semantic";
18
19
  import { scanSemantic } from "./security/semantic";
20
+ import { wrapShell } from "./shell";
19
21
  import { createAgentSymlinks } from "./symlink";
20
22
  import type { TapEntry } from "./taps";
21
23
  import { loadTaps } from "./taps";
@@ -218,7 +220,15 @@ async function buildPlacements(params: {
218
220
  // git multi-skill: move clone to cache first, then copy selected skills
219
221
  const cacheRoot = skillCacheDir(resolvedUrl);
220
222
  await mkdir(dirname(cacheRoot), { recursive: true });
221
- await $`mv ${contentDir} ${cacheRoot}`.quiet();
223
+ const mvResult = await wrapShell(
224
+ () =>
225
+ $`cp -a ${contentDir} ${cacheRoot} && rm -rf ${contentDir}`
226
+ .quiet()
227
+ .then(() => undefined),
228
+ "Failed to move clone to cache",
229
+ "Check disk space and permissions.",
230
+ );
231
+ if (!mvResult.ok) throw mvResult.error;
222
232
  for (const skill of selected) {
223
233
  const skillSrcInCache = skill.path.replace(contentDir, cacheRoot);
224
234
  placements.push({
@@ -251,11 +261,18 @@ async function executePlacements(params: {
251
261
 
252
262
  for (const { skill, srcPath, relPath, destDir, useMove } of placements) {
253
263
  await mkdir(dirname(destDir), { recursive: true });
254
- if (useMove) {
255
- await $`mv ${srcPath} ${destDir}`.quiet();
256
- } else {
257
- await $`cp -r ${srcPath} ${destDir}`.quiet();
258
- }
264
+ const op = useMove ? "move" : "copy";
265
+ const shellResult = await wrapShell(
266
+ () =>
267
+ useMove
268
+ ? $`cp -a ${srcPath} ${destDir} && rm -rf ${srcPath}`
269
+ .quiet()
270
+ .then(() => undefined)
271
+ : $`cp -r ${srcPath} ${destDir}`.quiet().then(() => undefined),
272
+ `Failed to ${op} skill '${skill.name}' to ${destDir}`,
273
+ "Check disk space and permissions.",
274
+ );
275
+ if (!shellResult.ok) throw shellResult.error;
259
276
  await createAgentSymlinks(skill.name, destDir, also, options.scope, options.projectRoot);
260
277
  records.push(
261
278
  makeRecord(skill, resolved, sha, relPath, options, also, now, effectiveTap, finalRef, trust, sourceKey),
@@ -342,6 +359,7 @@ export async function installSkill(
342
359
  source: string,
343
360
  options: InstallOptions,
344
361
  ): Promise<Result<InstallResult, UserError | GitError | ScanError | NetworkError>> {
362
+ debug("installSkill", { source, scope: options.scope });
345
363
  const also = options.also ?? [];
346
364
  const allWarnings: StaticWarning[] = [];
347
365
  const allSemanticWarnings: SemanticWarning[] = [];
@@ -409,6 +427,8 @@ export async function installSkill(
409
427
  sha = shaResult.value;
410
428
  }
411
429
 
430
+ debug("content fetched", { contentDir, sha, adapter: resolved.adapter });
431
+
412
432
  // 5. Scan for skills
413
433
  const scanned = await scan(contentDir, { onDeepScan: options.onDeepScan });
414
434
  if (scanned.length === 0) {
@@ -539,6 +559,8 @@ export async function installSkill(
539
559
  effectiveTap, finalRef, trust, sourceKey,
540
560
  });
541
561
 
562
+ debug("placements complete", { installed: newRecords.map((r) => r.name) });
563
+
542
564
  // 10. Save installed.json
543
565
  installed.skills.push(...newRecords);
544
566
  const saveResult = await saveInstalled(installed);
@@ -1,6 +1,8 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { $ } from "bun";
4
+ import { debug } from "./debug";
5
+ import { extractStderr } from "./shell";
4
6
  import type { Result } from "./types";
5
7
  import { err, NetworkError, ok, UserError } from "./types";
6
8
 
@@ -156,6 +158,7 @@ export async function downloadAndExtract(
156
158
  dest: string,
157
159
  integrity?: string,
158
160
  ): Promise<Result<string, NetworkError>> {
161
+ debug("downloadAndExtract", { tarballUrl, dest });
159
162
  let response: Response;
160
163
  try {
161
164
  response = await fetch(tarballUrl);
@@ -184,6 +187,7 @@ export async function downloadAndExtract(
184
187
  const digest = hasher.digest("base64");
185
188
  const expected = integrity.slice("sha512-".length);
186
189
  if (digest !== expected) {
190
+ debug("integrity check failed", { expected, got: digest });
187
191
  return err(
188
192
  new NetworkError(
189
193
  "Tarball integrity check failed. The download may be corrupted.",
@@ -199,8 +203,10 @@ export async function downloadAndExtract(
199
203
  // Extract (npm tarballs always extract to a `package/` subdirectory)
200
204
  try {
201
205
  await $`tar -xzf ${tarPath} -C ${dest}`.quiet();
202
- } catch {
203
- return err(new NetworkError("Failed to extract npm package tarball."));
206
+ } catch (e) {
207
+ const detail = extractStderr(e);
208
+ debug("tar extraction failed", { tarballUrl, error: detail });
209
+ return err(new NetworkError(`Failed to extract npm package tarball: ${detail}`));
204
210
  }
205
211
 
206
212
  return ok(join(dest, "package"));
package/src/remove.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { $ } from "bun";
2
2
  import { loadInstalled, saveInstalled } from "./config";
3
+ import { debug } from "./debug";
3
4
  import { skillCacheDir, skillInstallDir } from "./paths";
5
+ import { wrapShell } from "./shell";
4
6
  import { removeAgentSymlinks } from "./symlink";
5
7
  import type { Result } from "./types";
6
8
  import { err, ok, UserError } from "./types";
@@ -14,6 +16,7 @@ export async function removeSkill(
14
16
  name: string,
15
17
  options: RemoveOptions = {},
16
18
  ): Promise<Result<void, UserError>> {
19
+ debug("removeSkill", { name, scope: options.scope });
17
20
  const installedResult = await loadInstalled();
18
21
  if (!installedResult.ok) return installedResult;
19
22
  const installed = installedResult.value;
@@ -51,7 +54,12 @@ export async function removeSkill(
51
54
  record.scope === "linked" ? "global" : record.scope,
52
55
  options.projectRoot,
53
56
  );
54
- await $`rm -rf ${installPath}`.quiet();
57
+ const rmResult = await wrapShell(
58
+ () => $`rm -rf ${installPath}`.quiet().then(() => undefined),
59
+ `Failed to remove skill directory '${name}'`,
60
+ "Check file permissions.",
61
+ );
62
+ if (!rmResult.ok) return rmResult;
55
63
 
56
64
  // Remove cache if this was the last skill from the repo
57
65
  if (record.path !== null && record.repo) {
@@ -60,7 +68,13 @@ export async function removeSkill(
60
68
  );
61
69
  if (remainingFromSameRepo.length === 0) {
62
70
  const cacheRoot = skillCacheDir(record.repo);
63
- await $`rm -rf ${cacheRoot}`.quiet();
71
+ const cacheResult = await wrapShell(
72
+ () => $`rm -rf ${cacheRoot}`.quiet().then(() => undefined),
73
+ `Failed to remove cache directory for '${name}'`,
74
+ );
75
+ if (!cacheResult.ok) {
76
+ debug("cache cleanup failed", { name, cacheRoot, error: cacheResult.error.message });
77
+ }
64
78
  }
65
79
  }
66
80
 
@@ -1,5 +1,6 @@
1
1
  import { basename, join } from "node:path";
2
2
  import { getConfigDir } from "./config";
3
+ import { extractStderr } from "./shell";
3
4
  import { err, NetworkError, ok, type Result, UserError } from "./types";
4
5
 
5
6
  export type UpdateType = "patch" | "minor" | "major";
@@ -163,13 +164,13 @@ export async function downloadAndInstall(
163
164
  const buffer = await response.arrayBuffer();
164
165
  await Bun.write(tmpPath, buffer);
165
166
  await Bun.$`chmod +x ${tmpPath}`.quiet();
166
- await Bun.$`mv ${tmpPath} ${execPath}`.quiet();
167
+ await Bun.$`cp -a ${tmpPath} ${execPath} && rm -f ${tmpPath}`.quiet();
167
168
  } catch (e) {
168
169
  // Clean up temp file if possible
169
170
  Bun.$`rm -f ${tmpPath}`.quiet();
170
171
  return err(
171
172
  new UserError(
172
- `Failed to replace binary: ${e}`,
173
+ `Failed to replace binary: ${extractStderr(e)}`,
173
174
  "Try running with sudo, or install via npm: npm install -g skilltap",
174
175
  ),
175
176
  );
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { extractExitCode, extractStderr, wrapShell } from "./shell";
3
+
4
+ describe("extractStderr", () => {
5
+ test("extracts Uint8Array stderr", () => {
6
+ const e = Object.assign(new Error("fail"), {
7
+ stderr: new TextEncoder().encode("permission denied"),
8
+ });
9
+ expect(extractStderr(e)).toBe("permission denied");
10
+ });
11
+
12
+ test("extracts string stderr", () => {
13
+ const e = Object.assign(new Error("fail"), { stderr: "not found" });
14
+ expect(extractStderr(e)).toBe("not found");
15
+ });
16
+
17
+ test("falls back to String(e) for plain errors", () => {
18
+ const e = new Error("something broke");
19
+ expect(extractStderr(e)).toBe("Error: something broke");
20
+ });
21
+
22
+ test("handles non-Error values", () => {
23
+ expect(extractStderr("raw string")).toBe("raw string");
24
+ expect(extractStderr(42)).toBe("42");
25
+ });
26
+
27
+ test("trims whitespace from stderr", () => {
28
+ const e = Object.assign(new Error("fail"), { stderr: " spaced \n" });
29
+ expect(extractStderr(e)).toBe("spaced");
30
+ });
31
+ });
32
+
33
+ describe("extractExitCode", () => {
34
+ test("extracts exitCode from ShellError-like object", () => {
35
+ const e = Object.assign(new Error("fail"), { exitCode: 18 });
36
+ expect(extractExitCode(e)).toBe(18);
37
+ });
38
+
39
+ test("returns undefined for plain errors", () => {
40
+ expect(extractExitCode(new Error("nope"))).toBeUndefined();
41
+ });
42
+
43
+ test("returns undefined for non-Error values", () => {
44
+ expect(extractExitCode("string")).toBeUndefined();
45
+ });
46
+ });
47
+
48
+ describe("wrapShell", () => {
49
+ test("returns ok on success", async () => {
50
+ const result = await wrapShell(() => Promise.resolve(42), "test op");
51
+ expect(result.ok).toBe(true);
52
+ if (result.ok) expect(result.value).toBe(42);
53
+ });
54
+
55
+ test("returns err(UserError) with stderr on failure", async () => {
56
+ const shellError = Object.assign(new Error("Failed with exit code 1"), {
57
+ stderr: new TextEncoder().encode("No such file or directory"),
58
+ exitCode: 1,
59
+ });
60
+ const result = await wrapShell(
61
+ () => Promise.reject(shellError),
62
+ "Failed to copy skill",
63
+ "Check permissions.",
64
+ );
65
+ expect(result.ok).toBe(false);
66
+ if (!result.ok) {
67
+ expect(result.error.message).toContain("Failed to copy skill");
68
+ expect(result.error.message).toContain("No such file or directory");
69
+ expect(result.error.hint).toBe("Check permissions.");
70
+ }
71
+ });
72
+
73
+ test("falls back to exit code when no stderr", async () => {
74
+ const shellError = Object.assign(new Error("Failed with exit code 18"), {
75
+ stderr: new Uint8Array(0),
76
+ exitCode: 18,
77
+ });
78
+ const result = await wrapShell(
79
+ () => Promise.reject(shellError),
80
+ "mv failed",
81
+ );
82
+ expect(result.ok).toBe(false);
83
+ if (!result.ok) {
84
+ expect(result.error.message).toContain("exit code 18");
85
+ }
86
+ });
87
+ });
package/src/shell.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { debug } from "./debug";
2
+ import type { Result } from "./types";
3
+ import { err, ok, UserError } from "./types";
4
+
5
+ /** Extract stderr from a Bun ShellError. */
6
+ export function extractStderr(e: unknown): string {
7
+ if (e instanceof Error && "stderr" in e) {
8
+ const raw = (e as { stderr: unknown }).stderr;
9
+ if (raw instanceof Uint8Array) return new TextDecoder().decode(raw).trim();
10
+ return String(raw).trim();
11
+ }
12
+ return String(e);
13
+ }
14
+
15
+ /** Extract exit code from a Bun ShellError. */
16
+ export function extractExitCode(e: unknown): number | undefined {
17
+ if (e instanceof Error && "exitCode" in e) {
18
+ return (e as { exitCode: number }).exitCode;
19
+ }
20
+ return undefined;
21
+ }
22
+
23
+ /**
24
+ * Wrap a shell command with stderr extraction and debug logging.
25
+ * Returns Result<T, UserError> with a descriptive message on failure.
26
+ */
27
+ export async function wrapShell<T>(
28
+ fn: () => Promise<T>,
29
+ msg: string,
30
+ hint?: string,
31
+ ): Promise<Result<T, UserError>> {
32
+ try {
33
+ return ok(await fn());
34
+ } catch (e) {
35
+ const stderr = extractStderr(e);
36
+ const exitCode = extractExitCode(e);
37
+ debug(msg, { stderr, exitCode });
38
+ const detail = stderr || `exit code ${exitCode ?? "unknown"}`;
39
+ return err(new UserError(`${msg}: ${detail}`, hint));
40
+ }
41
+ }
package/src/taps.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { $ } from "bun";
4
4
  import { z } from "zod/v4";
5
5
  import { getConfigDir, loadConfig, saveConfig } from "./config";
6
+ import { extractStderr } from "./shell";
6
7
  import { checkGitInstalled, clone, pull } from "./git";
7
8
  import type { RegistrySource } from "./registry";
8
9
  import { detectTapType, fetchSkillList } from "./registry";
@@ -292,9 +293,7 @@ export async function initTap(name: string): Promise<Result<void, UserError>> {
292
293
  return ok(undefined);
293
294
  } catch (e) {
294
295
  return err(
295
- new UserError(
296
- `Failed to initialize tap: ${e instanceof Error ? e.message : String(e)}`,
297
- ),
296
+ new UserError(`Failed to initialize tap: ${extractStderr(e)}`),
298
297
  );
299
298
  }
300
299
  }
package/src/update.ts CHANGED
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { $ } from "bun";
4
4
  import type { AgentAdapter } from "./agents/types";
5
5
  import { loadInstalled, saveInstalled } from "./config";
6
+ import { debug } from "./debug";
6
7
  import { makeTmpDir, removeTmpDir } from "./fs";
7
8
  import type { DiffStat } from "./git";
8
9
  import { diff, diffStat, fetch, pull, revParse } from "./git";
@@ -18,6 +19,7 @@ import type { StaticWarning } from "./security";
18
19
  import { scanDiff, scanStatic } from "./security";
19
20
  import type { SemanticWarning } from "./security/semantic";
20
21
  import { scanSemantic } from "./security/semantic";
22
+ import { wrapShell } from "./shell";
21
23
  import { createAgentSymlinks, removeAgentSymlinks } from "./symlink";
22
24
  import { parseGitHubRepo, resolveTrust } from "./trust";
23
25
  import type { Result } from "./types";
@@ -94,17 +96,27 @@ async function recopyMultiSkill(
94
96
  workDir: string,
95
97
  record: InstalledSkill,
96
98
  projectRoot?: string,
97
- ): Promise<void> {
98
- if (record.path === null) return;
99
+ ): Promise<Result<void, UserError>> {
100
+ if (record.path === null) return ok(undefined);
99
101
  const skillSrc = join(workDir, record.path);
100
102
  const destDir = skillInstallDir(
101
103
  record.name,
102
104
  record.scope as "global" | "project",
103
105
  projectRoot,
104
106
  );
105
- await $`rm -rf ${destDir}`.quiet();
107
+ const rmResult = await wrapShell(
108
+ () => $`rm -rf ${destDir}`.quiet().then(() => undefined),
109
+ `Failed to remove old skill directory '${record.name}'`,
110
+ );
111
+ if (!rmResult.ok) return rmResult;
112
+
106
113
  await mkdir(dirname(destDir), { recursive: true });
107
- await $`cp -r ${skillSrc} ${destDir}`.quiet();
114
+
115
+ return wrapShell(
116
+ () => $`cp -r ${skillSrc} ${destDir}`.quiet().then(() => undefined),
117
+ `Failed to copy updated skill '${record.name}'`,
118
+ "Check disk space and permissions.",
119
+ );
108
120
  }
109
121
 
110
122
  /** Remove and re-create agent symlinks for a skill (idempotent). */
@@ -225,9 +237,20 @@ async function updateNpmSkill(
225
237
  record.scope as "global" | "project",
226
238
  options.projectRoot,
227
239
  );
228
- await $`rm -rf ${installDir}`.quiet();
240
+ const rmResult = await wrapShell(
241
+ () => $`rm -rf ${installDir}`.quiet().then(() => undefined),
242
+ `Failed to remove old skill directory '${record.name}'`,
243
+ );
244
+ if (!rmResult.ok) return rmResult;
245
+
229
246
  await mkdir(dirname(installDir), { recursive: true });
230
- await $`cp -r ${newSkillDir} ${installDir}`.quiet();
247
+
248
+ const cpResult = await wrapShell(
249
+ () => $`cp -r ${newSkillDir} ${installDir}`.quiet().then(() => undefined),
250
+ `Failed to install updated skill '${record.name}'`,
251
+ "Check disk space and permissions.",
252
+ );
253
+ if (!cpResult.ok) return cpResult;
231
254
 
232
255
  // Semantic scan on updated content
233
256
  if (await runUpdateSemanticScan(installDir, record.name, options)) {
@@ -342,7 +365,10 @@ async function updateGitSkill(
342
365
  const pullResult = await pull(workDir);
343
366
  if (!pullResult.ok) return pullResult;
344
367
 
345
- if (isMulti) await recopyMultiSkill(workDir, record, options.projectRoot);
368
+ if (isMulti) {
369
+ const recopyResult = await recopyMultiSkill(workDir, record, options.projectRoot);
370
+ if (!recopyResult.ok) return recopyResult;
371
+ }
346
372
 
347
373
  const installDir = skillInstallDir(
348
374
  record.name,
@@ -387,6 +413,7 @@ async function updateGitSkill(
387
413
  export async function updateSkill(
388
414
  options: UpdateOptions = {},
389
415
  ): Promise<Result<UpdateResult, UserError | GitError | ScanError | NetworkError>> {
416
+ debug("updateSkill", { name: options.name ?? "all" });
390
417
  const installedResult = await loadInstalled();
391
418
  if (!installedResult.ok) return installedResult;
392
419
  const installed = installedResult.value;