@sealant/mend 0.15.2 → 0.16.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
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).
@@ -1789,6 +1790,75 @@ const dotfilesCommand = async (config, args) => {
1789
1790
  return fail(`unknown dotfiles command "${verb}" — try: mend dotfiles sync | show`);
1790
1791
  }
1791
1792
  };
1793
+ /** With `--project` the project's library; bare, your own. */
1794
+ const skillsList = async (config, args) => {
1795
+ const forProject = args.includes("--project");
1796
+ const project = forProject ? await findProject(config, takeFlagValue(args, "--project")) : null;
1797
+ const skills = await api(config, "GET", project === null ? "/skills" : `/projects/${project.id}/skills`);
1798
+ const library = project === null ? "your library" : project.name;
1799
+ if (skills.length === 0) {
1800
+ say(dim(`no skills in ${library} — push a local one: mend skills push`));
1801
+ return;
1802
+ }
1803
+ for (const skill of skills) {
1804
+ say(` ${skill.name.padEnd(28)} ${dim(`${skill.fileCount} file${skill.fileCount === 1 ? "" : "s"} · ${formatDotfileBytes(skill.bytes)}`)}`);
1805
+ if (skill.description !== "")
1806
+ say(` ${dim(skill.description)}`);
1807
+ }
1808
+ };
1809
+ /**
1810
+ * Scan the shared agent-skills directory (`~/.agents/skills` by convention)
1811
+ * and upload every bundle. The upload is the intent: same-named skills are
1812
+ * replaced, and `--prune` removes what the directory no longer carries.
1813
+ */
1814
+ const skillsPush = async (config, args) => {
1815
+ const dirFlag = takeFlagValue(args, "--dir");
1816
+ const root = dirFlag === null ? path.join(os.homedir(), DEFAULT_SKILLS_DIR) : path.resolve(dirFlag);
1817
+ const scanned = scanSkillLibrary(root);
1818
+ if ("error" in scanned)
1819
+ return fail(scanned.error);
1820
+ for (const note of scanned.notes)
1821
+ say(dim(` ${note.skill}: ${note.message}`));
1822
+ if (scanned.skills.length === 0)
1823
+ return fail(`no skills found under ${root}`);
1824
+ const forProject = args.includes("--project");
1825
+ const project = forProject ? await findProject(config, takeFlagValue(args, "--project")) : null;
1826
+ const report = await api(config, "POST", "/skills/sync", {
1827
+ scope: project === null ? "user" : "project",
1828
+ projectId: project?.id ?? null,
1829
+ skills: scanned.skills.map((skill) => ({
1830
+ name: skill.name,
1831
+ description: skill.description,
1832
+ files: skill.files,
1833
+ })),
1834
+ prune: args.includes("--prune"),
1835
+ });
1836
+ const library = project === null ? "your library" : project.name;
1837
+ const counts = [
1838
+ `${report.created.length} new`,
1839
+ `${report.updated.length} updated`,
1840
+ `${report.unchanged.length} unchanged`,
1841
+ ...(report.removed.length > 0 ? [`${report.removed.length} removed`] : []),
1842
+ ].join(" · ");
1843
+ say(`${green("pushed")} ${scanned.skills.length} skill${scanned.skills.length === 1 ? "" : "s"} from ${root} to ${library} · ${dim(counts)}`);
1844
+ say(dim("sessions receive them from the next launch"));
1845
+ };
1846
+ const skillsCommand = async (config, args) => {
1847
+ const [verb, ...rest] = args;
1848
+ switch (verb) {
1849
+ case "push":
1850
+ return skillsPush(config, rest);
1851
+ case "list":
1852
+ return skillsList(config, rest);
1853
+ case undefined:
1854
+ return skillsList(config, args);
1855
+ default:
1856
+ // Bare flags (`mend skills --project web`) read as the list.
1857
+ if (verb.startsWith("--"))
1858
+ return skillsList(config, args);
1859
+ return fail(`unknown skills command "${verb}" — try: mend skills push | list`);
1860
+ }
1861
+ };
1792
1862
  // ─── completions: live sessions under TAB ───────────────────────────────────
1793
1863
  /**
1794
1864
  * The data half of shell completion: one live session per line as
@@ -1824,6 +1894,7 @@ _mend() {
1824
1894
  'shell:open a shell in a live session workspace'
1825
1895
  'service:reachable ports — add, list, stop'
1826
1896
  'keys:the machine Mend deploy key — init, show, share'
1897
+ 'skills:skill libraries — list, push'
1827
1898
  'accounts:your connected accounts on the platform'
1828
1899
  'pair:pair a phone or a second machine' 'doctor:read-only checklist of this setup'
1829
1900
  'connect:send this machine'"'"'s claude/codex/github credential to the platform'
@@ -1849,7 +1920,7 @@ _mend "$@"
1849
1920
  const BASH_COMPLETIONS = `_mend() {
1850
1921
  local cur=\${COMP_WORDS[COMP_CWORD]}
1851
1922
  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") )
1923
+ 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
1924
  return
1854
1925
  fi
1855
1926
  case \${COMP_WORDS[1]} in
@@ -2475,6 +2546,10 @@ everything else
2475
2546
  mend accounts your connected accounts on the platform (claude, codex, github)
2476
2547
  mend dotfiles your dotfiles on the server: repo + synced home files
2477
2548
  mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
2549
+ mend skills [--project [p]] your skill library on the server (or a project's)
2550
+ mend skills push [--project [p]] [--prune] [--dir <path>]
2551
+ upload ~/.agents/skills bundles into the library; sessions
2552
+ receive them at launch (--prune removes what's gone)
2478
2553
  mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
2479
2554
  hardware keys sign here; Ctrl-C stops sharing)
2480
2555
  mend run -- <command...> same, with an arbitrary command
@@ -2545,6 +2620,8 @@ const main = async () => {
2545
2620
  return keysCommand(config, rest);
2546
2621
  case "dotfiles":
2547
2622
  return dotfilesCommand(config, rest);
2623
+ case "skills":
2624
+ return skillsCommand(config, rest);
2548
2625
  case "accounts":
2549
2626
  return accountsCommand(config);
2550
2627
  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.16.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",