@sealant/mend 0.15.2 → 0.17.0

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 CHANGED
@@ -83,6 +83,10 @@ mend sessions [--all] [--project p] [--json]
83
83
  mend status active sessions (alias of mend sessions)
84
84
  mend pair [--url <base url>] pair a phone or a second machine: QR + code + URL
85
85
  mend doctor read-only checklist of this machine's setup
86
+ mend skills [--project [p]] your skill library on the server (or a project's)
87
+ mend skills push [--project [p]] [--prune] [--dir <path>]
88
+ upload ~/.agents/skills bundles; sessions receive
89
+ them at launch (--prune removes what's gone)
86
90
  ```
87
91
 
88
92
  ## Signing in
@@ -208,6 +208,14 @@ export const foldGroupStatus = (group) => {
208
208
  * Null = nothing attachable; opening a new shell is then honest.
209
209
  */
210
210
  export const liveShellOf = (processes) => processes.findLast((process) => process.kind === "shell" && process.exitedAt === null) ?? null;
211
+ /**
212
+ * The session's live protocol agent — a phone pickup driving the same
213
+ * conversation over stream-json. It holds the workspace with no PTY behind it,
214
+ * so a terminal "get me in" must take it over (hand back to a TUI) rather than
215
+ * report the attach unavailable or open a bare shell. Null = no pickup to take.
216
+ */
217
+ export const liveProtocolOf = (processes) => processes.find((process) => process.kind === "agent-protocol" && process.exitedAt === null) ??
218
+ null;
211
219
  /**
212
220
  * The optimistic stop: the row settles AND its live process/service fact
213
221
  * lines drop in the same paint — the server's refetch only confirms.
package/dist/dashboard.js CHANGED
@@ -3,7 +3,7 @@ import { CliRenderEvents, createCliRenderer } from "@opentui/core";
3
3
  import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
4
4
  import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
5
5
  import { useEffect, useRef, useState } from "react";
6
- import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus, labelIsIdentity, rowKeyOf, worktreeDisplayName, fetchWorkbench, liveShellOf, mapWorkbenchSessions, markSessionStopped, removeWorktreeGroup, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
6
+ import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus, labelIsIdentity, rowKeyOf, worktreeDisplayName, fetchWorkbench, liveProtocolOf, liveShellOf, mapWorkbenchSessions, markSessionStopped, removeWorktreeGroup, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
7
7
  import { reviewTargetForSession } from "./review-workflow.js";
8
8
  import { ReviewScreen } from "./review.js";
9
9
  import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, pendingId, } from "./shared.js";
@@ -261,8 +261,17 @@ const App = ({ ctx, onQuit }) => {
261
261
  // one when nothing is attachable (stacking a new bash per attempt is
262
262
  // how a session ends up held open by orphan shells).
263
263
  const detail = await ctx.api("GET", `/sessions/${session.id}`);
264
+ const liveProtocol = liveProtocolOf(detail.processes);
264
265
  const existing = liveShellOf(detail.processes);
265
- if (existing !== null) {
266
+ if (liveProtocol !== null) {
267
+ // A phone pickup holds the session in protocol mode — no PTY behind
268
+ // it. Take it over: end the protocol agent, resume the same
269
+ // conversation as a TUI, then attach to that.
270
+ process.stdout.write(`taking over from the protocol session — same conversation…\n\n`);
271
+ await ctx.api("POST", `/sessions/${session.id}/handoff`, { to: "pty" });
272
+ outcome = await ctx.attachTty(session.id, session.harness);
273
+ }
274
+ else if (existing !== null) {
266
275
  process.stdout.write(`no live terminal — rejoining the open shell\n\n`);
267
276
  outcome = await ctx.attachTty(session.id, "shell", existing.id);
268
277
  }
@@ -997,7 +1006,7 @@ const App = ({ ctx, onQuit }) => {
997
1006
  ? " ↑↓ move · enter start · esc cancel"
998
1007
  : focus === "projects"
999
1008
  ? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
1000
- : " ↑↓ move · enter attach/resume · n new worktree · s session here · x stop · ⇧D remove · v review · e rename · o web · q quit";
1009
+ : " ↑↓ move · enter attach/resume · n new worktree · s session here · ⇧K kill · ⇧D remove · v review · e rename · o web · q quit";
1001
1010
  const loadFailure = data === undefined && failureReason !== null
1002
1011
  ? failureReason instanceof Error
1003
1012
  ? failureReason.message
package/dist/main.js CHANGED
@@ -12,6 +12,7 @@ import { loginCommand } from "./login.js";
12
12
  import { pairCommand, qrCommand } from "./pair.js";
13
13
  import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
14
14
  import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, LIVE_STATUSES, sessionDisplayName, matchProjectByCwd, normalizeProjectName, gitCurrentBranch, parseLaunchArgs, } from "./shared.js";
15
+ import { DEFAULT_SKILLS_DIR, scanSkillLibrary } from "./skills.js";
15
16
  import { sshCommand } from "./ssh-setup.js";
16
17
  // `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
17
18
  // when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
@@ -617,16 +618,41 @@ const exitAfterSessionEnd = (config, sessionId) => {
617
618
  /** Reattach a terminal to a running session (full scrollback replay, then live). */
618
619
  const attach = async (config, args) => {
619
620
  const prefix = args.find((a) => !a.startsWith("--"));
620
- if (prefix === undefined)
621
- return fail("usage: mend attach <session-id-prefix>");
621
+ // No id: the picker IS the selection surface (the resolution `mend shell`
622
+ // already uses), so attaching never demands an id the user must go look up.
623
+ if (prefix === undefined) {
624
+ const picked = await resolveLiveSession(config, undefined, "attach");
625
+ return attachPicked(config, picked);
626
+ }
622
627
  const sessions = await api(config, "GET", "/sessions");
623
628
  const match = sessions.find((s) => s.id.startsWith(prefix));
624
629
  if (match === undefined)
625
630
  return fail(`no active session matches "${prefix}"`);
626
- say(`${green("✓")} attaching to ${sessionDisplayName(match)} · ${match.harness} ${dim(match.id.slice(0, 8))}${detachHint()}`);
631
+ return attachPicked(config, match);
632
+ };
633
+ /** Attach to one resolved session — the tail both `mend attach` paths share. */
634
+ const attachPicked = async (config, session) => {
635
+ say(`${green("✓")} attaching to ${sessionDisplayName(session)} · ${session.harness} ${dim(session.id.slice(0, 8))}${detachHint()}`);
627
636
  say("");
628
- await attachOrExit(config, match.id, match.harness);
629
- exitAfterSessionEnd(config, match.id);
637
+ const outcome = await attachTty(config, session.id, session.harness, 0n, undefined, {
638
+ handleSignals: true,
639
+ });
640
+ // A live protocol agent (a phone pickup) has no PTY behind it — the attach
641
+ // reports "unavailable" with nothing wrong. Take the session over: end the
642
+ // protocol agent, resume the same conversation as a TUI, then attach to it.
643
+ if (outcome === "unavailable") {
644
+ const detail = await api(config, "GET", `/sessions/${session.id}`);
645
+ if (detail.currentAgent?.kind === "agent-protocol" &&
646
+ agentIsLive(detail.session, detail.currentAgent)) {
647
+ say(`${amber("taking over")} from the protocol session`);
648
+ await withSpinner("reopening as a terminal — same conversation…", api(config, "POST", `/sessions/${session.id}/handoff`, { to: "pty" }));
649
+ say("");
650
+ await attachOrExit(config, session.id, session.harness);
651
+ return exitAfterSessionEnd(config, session.id);
652
+ }
653
+ }
654
+ await finishAttach(config, session.id, outcome);
655
+ return exitAfterSessionEnd(config, session.id);
630
656
  };
631
657
  /**
632
658
  * Explicit stop — the one intent detaching never carries. Ends the agent; the
@@ -639,8 +665,15 @@ const stopCommand = async (config, args) => {
639
665
  ? String(args[projectFlag + 1])
640
666
  : null;
641
667
  const prefix = args.find((arg, index) => !arg.startsWith("--") && index !== projectFlag + 1);
668
+ // Neither an id nor --all: pick the session to close, the same resolution
669
+ // `mend attach` uses. --all and --project keep their bulk meaning.
670
+ if (!all && prefix === undefined && projectName === null) {
671
+ const picked = await resolveLiveSession(config, undefined, "stop");
672
+ await stopSessions(config, [picked], false);
673
+ return;
674
+ }
642
675
  if (!all && prefix === undefined) {
643
- return fail("usage: mend stop <session-id-prefix> | --all [--project <p>]");
676
+ return fail("usage: mend stop [session-id-prefix] | --all [--project <p>]");
644
677
  }
645
678
  const [sessions, projects] = await Promise.all([
646
679
  api(config, "GET", "/sessions"),
@@ -670,19 +703,24 @@ const stopCommand = async (config, args) => {
670
703
  }
671
704
  targets = matches;
672
705
  }
706
+ await stopSessions(config, targets, all);
707
+ };
708
+ /** Stop each target and print its record link — the tail every stop path shares. */
709
+ const stopSessions = async (config, targets, summarise) => {
673
710
  for (const session of targets) {
674
711
  await api(config, "POST", `/sessions/${session.id}/stop`);
675
712
  say(`${green("✓")} stopped · ${session.harness} · ${dim(session.id.slice(0, 8))} · ${session.branch}`);
676
713
  say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
677
714
  }
678
- if (all)
715
+ if (summarise) {
679
716
  say(`${green("✓")} stopped ${targets.length} session${targets.length === 1 ? "" : "s"}`);
717
+ }
680
718
  };
681
719
  /** Compact picker: numbered live sessions, one keystroke of typing. */
682
720
  const pickSessionInteractively = async (rows) => {
683
721
  say(dim("more than one live session — pick one:"));
684
722
  rows.forEach((row, index) => {
685
- say(` ${index + 1}. ${row.session.harness.padEnd(8)} ${dim(row.session.id.slice(0, 8))} ${row.projectName} ${dim(row.session.branch)}`);
723
+ say(` ${index + 1}. ${row.session.harness.padEnd(8)} ${dim(row.session.id.slice(0, 8))} ${row.projectName} ${dim(row.session.branch)} ${dim(row.session.status)}`);
686
724
  });
687
725
  const readline = await import("node:readline/promises");
688
726
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -698,7 +736,7 @@ const pickSessionInteractively = async (rows) => {
698
736
  * the cwd's project narrows, one candidate is taken, several go to the
699
737
  * picker, and a non-interactive caller never gets a silent guess.
700
738
  */
701
- const resolveLiveSession = async (config, prefix) => {
739
+ const resolveLiveSession = async (config, prefix, command) => {
702
740
  const sessions = await api(config, "GET", "/sessions?retained=1");
703
741
  if (prefix !== undefined) {
704
742
  const matches = sessions.filter((s) => s.id.startsWith(prefix));
@@ -720,7 +758,7 @@ const resolveLiveSession = async (config, prefix) => {
720
758
  if (candidates.length === 1)
721
759
  return only;
722
760
  if (process.stdin.isTTY !== true) {
723
- return fail("several live sessions — name one: mend shell <session-id-prefix>");
761
+ return fail(`several live sessions — name one: mend ${command} <session-id-prefix>`);
724
762
  }
725
763
  const nameById = new Map(projects.map((p) => [p.id, p.name]));
726
764
  return pickSessionInteractively(candidates.map((session) => ({
@@ -736,7 +774,7 @@ const resolveLiveSession = async (config, prefix) => {
736
774
  */
737
775
  const shellCommand = async (config, args) => {
738
776
  const prefix = args.find((a) => !a.startsWith("--"));
739
- const session = await resolveLiveSession(config, prefix);
777
+ const session = await resolveLiveSession(config, prefix, "shell");
740
778
  say(`${green("✓")} shell in ${session.harness} session ${dim(session.id.slice(0, 8))} · ${dim(session.branch)}${detachHint()}`);
741
779
  const shellProcess = await withSpinner("opening a shell in the session workspace…", api(config, "POST", `/sessions/${session.id}/shell`));
742
780
  say("");
@@ -871,7 +909,7 @@ const serviceAdd = async (config, args) => {
871
909
  return fail(`"${portRaw}" is not a port`);
872
910
  }
873
911
  const prefix = positional.find((a) => a !== portRaw);
874
- const session = await resolveLiveSession(config, prefix);
912
+ const session = await resolveLiveSession(config, prefix, "service add");
875
913
  const service = await mutateService(config, "POST", `/sessions/${session.id}/services`, {
876
914
  port,
877
915
  name,
@@ -952,7 +990,7 @@ const serviceRun = async (config, args) => {
952
990
  if (name === undefined)
953
991
  return fail(usage);
954
992
  const prefix = positionals.length > 1 ? positionals[0] : undefined;
955
- const session = await resolveLiveSession(config, prefix);
993
+ const session = await resolveLiveSession(config, prefix, "service run");
956
994
  const recipes = await api(config, "GET", `/sessions/${session.id}/recipes`);
957
995
  const recipe = recipes.find((entry) => entry.name === name);
958
996
  if (recipe === undefined) {
@@ -995,7 +1033,7 @@ const serviceRun = async (config, args) => {
995
1033
  if (protocol === "udp" && browserScheme !== null)
996
1034
  return fail(usage);
997
1035
  const prefix = head.find((a, i) => !a.startsWith("--") && i !== portFlag + 1 && i !== nameFlag + 1);
998
- const session = await resolveLiveSession(config, prefix);
1036
+ const session = await resolveLiveSession(config, prefix, "service run");
999
1037
  const service = await withSpinner(protocol === "udp"
1000
1038
  ? `starting ${name ?? argv[0]} (udp :${port})…`
1001
1039
  : `starting ${name ?? argv[0]} — waiting for :${port} to answer…`, mutateService(config, "POST", `/sessions/${session.id}/services/run`, {
@@ -1789,6 +1827,75 @@ const dotfilesCommand = async (config, args) => {
1789
1827
  return fail(`unknown dotfiles command "${verb}" — try: mend dotfiles sync | show`);
1790
1828
  }
1791
1829
  };
1830
+ /** With `--project` the project's library; bare, your own. */
1831
+ const skillsList = async (config, args) => {
1832
+ const forProject = args.includes("--project");
1833
+ const project = forProject ? await findProject(config, takeFlagValue(args, "--project")) : null;
1834
+ const skills = await api(config, "GET", project === null ? "/skills" : `/projects/${project.id}/skills`);
1835
+ const library = project === null ? "your library" : project.name;
1836
+ if (skills.length === 0) {
1837
+ say(dim(`no skills in ${library} — push a local one: mend skills push`));
1838
+ return;
1839
+ }
1840
+ for (const skill of skills) {
1841
+ say(` ${skill.name.padEnd(28)} ${dim(`${skill.fileCount} file${skill.fileCount === 1 ? "" : "s"} · ${formatDotfileBytes(skill.bytes)}`)}`);
1842
+ if (skill.description !== "")
1843
+ say(` ${dim(skill.description)}`);
1844
+ }
1845
+ };
1846
+ /**
1847
+ * Scan the shared agent-skills directory (`~/.agents/skills` by convention)
1848
+ * and upload every bundle. The upload is the intent: same-named skills are
1849
+ * replaced, and `--prune` removes what the directory no longer carries.
1850
+ */
1851
+ const skillsPush = async (config, args) => {
1852
+ const dirFlag = takeFlagValue(args, "--dir");
1853
+ const root = dirFlag === null ? path.join(os.homedir(), DEFAULT_SKILLS_DIR) : path.resolve(dirFlag);
1854
+ const scanned = scanSkillLibrary(root);
1855
+ if ("error" in scanned)
1856
+ return fail(scanned.error);
1857
+ for (const note of scanned.notes)
1858
+ say(dim(` ${note.skill}: ${note.message}`));
1859
+ if (scanned.skills.length === 0)
1860
+ return fail(`no skills found under ${root}`);
1861
+ const forProject = args.includes("--project");
1862
+ const project = forProject ? await findProject(config, takeFlagValue(args, "--project")) : null;
1863
+ const report = await api(config, "POST", "/skills/sync", {
1864
+ scope: project === null ? "user" : "project",
1865
+ projectId: project?.id ?? null,
1866
+ skills: scanned.skills.map((skill) => ({
1867
+ name: skill.name,
1868
+ description: skill.description,
1869
+ files: skill.files,
1870
+ })),
1871
+ prune: args.includes("--prune"),
1872
+ });
1873
+ const library = project === null ? "your library" : project.name;
1874
+ const counts = [
1875
+ `${report.created.length} new`,
1876
+ `${report.updated.length} updated`,
1877
+ `${report.unchanged.length} unchanged`,
1878
+ ...(report.removed.length > 0 ? [`${report.removed.length} removed`] : []),
1879
+ ].join(" · ");
1880
+ say(`${green("pushed")} ${scanned.skills.length} skill${scanned.skills.length === 1 ? "" : "s"} from ${root} to ${library} · ${dim(counts)}`);
1881
+ say(dim("sessions receive them from the next launch"));
1882
+ };
1883
+ const skillsCommand = async (config, args) => {
1884
+ const [verb, ...rest] = args;
1885
+ switch (verb) {
1886
+ case "push":
1887
+ return skillsPush(config, rest);
1888
+ case "list":
1889
+ return skillsList(config, rest);
1890
+ case undefined:
1891
+ return skillsList(config, args);
1892
+ default:
1893
+ // Bare flags (`mend skills --project web`) read as the list.
1894
+ if (verb.startsWith("--"))
1895
+ return skillsList(config, args);
1896
+ return fail(`unknown skills command "${verb}" — try: mend skills push | list`);
1897
+ }
1898
+ };
1792
1899
  // ─── completions: live sessions under TAB ───────────────────────────────────
1793
1900
  /**
1794
1901
  * The data half of shell completion: one live session per line as
@@ -1824,6 +1931,7 @@ _mend() {
1824
1931
  'shell:open a shell in a live session workspace'
1825
1932
  'service:reachable ports — add, list, stop'
1826
1933
  'keys:the machine Mend deploy key — init, show, share'
1934
+ 'skills:skill libraries — list, push'
1827
1935
  'accounts:your connected accounts on the platform'
1828
1936
  'pair:pair a phone or a second machine' 'doctor:read-only checklist of this setup'
1829
1937
  'connect:send this machine'"'"'s claude/codex/github credential to the platform'
@@ -1849,7 +1957,7 @@ _mend "$@"
1849
1957
  const BASH_COMPLETIONS = `_mend() {
1850
1958
  local cur=\${COMP_WORDS[COMP_CWORD]}
1851
1959
  if [ "$COMP_CWORD" -eq 1 ]; then
1852
- COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach stop shell service keys pair doctor continue resume rejoin refresh projects sessions status ui help" -- "$cur") )
1960
+ COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach stop shell service keys skills pair doctor continue resume rejoin refresh projects sessions status ui help" -- "$cur") )
1853
1961
  return
1854
1962
  fi
1855
1963
  case \${COMP_WORDS[1]} in
@@ -2475,14 +2583,19 @@ everything else
2475
2583
  mend accounts your connected accounts on the platform (claude, codex, github)
2476
2584
  mend dotfiles your dotfiles on the server: repo + synced home files
2477
2585
  mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
2586
+ mend skills [--project [p]] your skill library on the server (or a project's)
2587
+ mend skills push [--project [p]] [--prune] [--dir <path>]
2588
+ upload ~/.agents/skills bundles into the library; sessions
2589
+ receive them at launch (--prune removes what's gone)
2478
2590
  mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
2479
2591
  hardware keys sign here; Ctrl-C stops sharing)
2480
2592
  mend run -- <command...> same, with an arbitrary command
2481
2593
  mend worktrees [--project <p>] [--json]
2482
2594
  every worktree and the sessions inside it (mend sessions
2483
2595
  --json stays the v1 flat list; --json=v2 groups like this)
2484
- mend attach <session-id-prefix> reattach this terminal to a running session
2485
- mend stop <session-id-prefix> | --all [--project <p>]
2596
+ mend attach [session-id-prefix] reattach this terminal to a running session
2597
+ (no id: pick one from the live sessions)
2598
+ mend stop [session-id-prefix] | --all [--project <p>]
2486
2599
  stop the agent — the workspace harvests and closes; the
2487
2600
  record and review remain
2488
2601
  mend shell [session-id-prefix] open a shell in a live session's workspace
@@ -2545,6 +2658,8 @@ const main = async () => {
2545
2658
  return keysCommand(config, rest);
2546
2659
  case "dotfiles":
2547
2660
  return dotfilesCommand(config, rest);
2661
+ case "skills":
2662
+ return skillsCommand(config, rest);
2548
2663
  case "accounts":
2549
2664
  return accountsCommand(config);
2550
2665
  case "connect":
package/dist/skills.js ADDED
@@ -0,0 +1,134 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ /**
4
+ * Local skill-library scanning for `mend skills push`. Runs on the machine
5
+ * that HAS the skills: the shared agent-skills convention keeps them under
6
+ * `~/.agents/skills/<name>/SKILL.md` (plus support files), and the CLI
7
+ * captures the bundles here and streams them to the server's library. The
8
+ * CLI stays dependency-light by design, so the frontmatter peek below is a
9
+ * few lines of string handling, not a YAML parser — the server re-validates
10
+ * everything.
11
+ */
12
+ /** Where the shared convention keeps user skills, relative to `$HOME`. */
13
+ export const DEFAULT_SKILLS_DIR = ".agents/skills";
14
+ /** The servers' caps, checked here so a mistake fails before the upload. */
15
+ export const MAX_SKILL_FILES = 64;
16
+ export const MAX_SKILL_FILE_BYTES = 512 * 1024;
17
+ const IGNORED_DIRS = new Set([".git", "node_modules", "__pycache__"]);
18
+ const isTextBuffer = (buffer) => !buffer.subarray(0, 8192).includes(0);
19
+ /**
20
+ * Pull `description:` out of a SKILL.md frontmatter block. Single-line plain
21
+ * or quoted scalars only — the common case by far; anything else reads as
22
+ * absent and the server keeps an empty description.
23
+ */
24
+ export const readFrontmatterDescription = (contents) => {
25
+ if (!contents.startsWith("---\n") && !contents.startsWith("---\r\n"))
26
+ return "";
27
+ const end = contents.indexOf("\n---", 3);
28
+ if (end === -1)
29
+ return "";
30
+ for (const line of contents.slice(0, end).split("\n")) {
31
+ const match = /^description:\s*(.*)$/.exec(line);
32
+ if (match === null)
33
+ continue;
34
+ const raw = (match[1] ?? "").trim();
35
+ if (raw === "" || raw === "|" || raw === ">" || raw === "|-" || raw === ">-")
36
+ return "";
37
+ const quoted = /^(['"])(.*)\1$/.exec(raw);
38
+ return quoted?.[2] ?? raw;
39
+ }
40
+ return "";
41
+ };
42
+ const walkFiles = (root) => {
43
+ const found = [];
44
+ const visit = (dir) => {
45
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
46
+ if (IGNORED_DIRS.has(entry.name))
47
+ continue;
48
+ const absolute = path.join(dir, entry.name);
49
+ // stat (not the dirent) so symlinked files and directories both count.
50
+ let stat;
51
+ try {
52
+ stat = fs.statSync(absolute);
53
+ }
54
+ catch {
55
+ continue; // dangling symlink
56
+ }
57
+ if (stat.isDirectory())
58
+ visit(absolute);
59
+ else if (stat.isFile())
60
+ found.push(absolute);
61
+ }
62
+ };
63
+ visit(root);
64
+ return found.toSorted();
65
+ };
66
+ /**
67
+ * Scan a skill library directory: every immediate subdirectory carrying a
68
+ * `SKILL.md` becomes one bundle. Oversized and binary files are skipped with
69
+ * a note; a directory without a SKILL.md is noted and left out entirely.
70
+ */
71
+ export const scanSkillLibrary = (root) => {
72
+ let entries;
73
+ try {
74
+ entries = fs.readdirSync(root, { withFileTypes: true });
75
+ }
76
+ catch {
77
+ return { error: `${root} is not a readable directory` };
78
+ }
79
+ const skills = [];
80
+ const notes = [];
81
+ for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
82
+ if (entry.name.startsWith("."))
83
+ continue;
84
+ const skillDir = path.join(root, entry.name);
85
+ let stat;
86
+ try {
87
+ stat = fs.statSync(skillDir);
88
+ }
89
+ catch {
90
+ continue;
91
+ }
92
+ if (!stat.isDirectory())
93
+ continue;
94
+ if (!fs.existsSync(path.join(skillDir, "SKILL.md"))) {
95
+ notes.push({ skill: entry.name, message: "no SKILL.md — skipped" });
96
+ continue;
97
+ }
98
+ const files = [];
99
+ let bytes = 0;
100
+ for (const absolute of walkFiles(skillDir)) {
101
+ const relative = path.relative(skillDir, absolute).split(path.sep).join("/");
102
+ const buffer = fs.readFileSync(absolute);
103
+ if (buffer.byteLength > MAX_SKILL_FILE_BYTES) {
104
+ notes.push({ skill: entry.name, message: `${relative} is over 512KB — skipped` });
105
+ continue;
106
+ }
107
+ if (!isTextBuffer(buffer)) {
108
+ notes.push({ skill: entry.name, message: `${relative} is binary — skipped` });
109
+ continue;
110
+ }
111
+ files.push({ path: relative, contents: buffer.toString("utf8") });
112
+ bytes += buffer.byteLength;
113
+ }
114
+ if (files.length > MAX_SKILL_FILES) {
115
+ notes.push({
116
+ skill: entry.name,
117
+ message: `${files.length} files is over the ${MAX_SKILL_FILES}-file cap — skipped`,
118
+ });
119
+ continue;
120
+ }
121
+ const entryFile = files.find((file) => file.path === "SKILL.md");
122
+ if (entryFile === undefined) {
123
+ notes.push({ skill: entry.name, message: "SKILL.md was unreadable — skipped" });
124
+ continue;
125
+ }
126
+ skills.push({
127
+ name: entry.name.toLowerCase(),
128
+ description: readFrontmatterDescription(entryFile.contents),
129
+ files,
130
+ bytes,
131
+ });
132
+ }
133
+ return { skills, notes };
134
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.15.2",
3
+ "version": "0.17.0",
4
4
  "description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
5
5
  "keywords": [
6
6
  "claude-code",