@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 +4 -0
- package/dist/dashboard-model.js +8 -0
- package/dist/dashboard.js +12 -3
- package/dist/main.js +132 -17
- package/dist/skills.js +134 -0
- package/package.json +1 -1
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/dashboard-model.js
CHANGED
|
@@ -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 (
|
|
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 ·
|
|
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
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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
|
|
629
|
-
|
|
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
|
|
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 (
|
|
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(
|
|
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
|
|
2485
|
-
|
|
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.
|
|
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",
|