dsh-git-badge 0.12.0 → 0.14.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/CHANGELOG.md +31 -0
- package/lib/client.js +439 -21
- package/lib/index.js +90 -0
- package/package.json +10 -8
- package/seam/apply.js +6 -0
- package/seam/postinstall.js +4 -0
- package/seam/skill.js +53 -0
- package/skill/gh/SKILL.md +68 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,37 @@ npm. 0.6.0 is the first release recorded here.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.14.0] - 2026-09-15
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **A "next" row at the top of the chip's hover card.** The node half derives
|
|
18
|
+
the single highest-priority next step from fields the status response already
|
|
19
|
+
carries — a paused operation and its resume command, `git pull --ff-only`
|
|
20
|
+
when behind, `git push -u origin <branch>` when no upstream exists on a dirty
|
|
21
|
+
branch, `git push` when ahead, a conservative commit command when dirty
|
|
22
|
+
(`add -p` for unstaged work, `-A` only for untracked-only changes, which
|
|
23
|
+
`add -p` cannot see), and `gh pr checks <n> --watch` when CI fails. Each
|
|
24
|
+
suggestion carries its reason, and the command is a click-to-copy chip — it
|
|
25
|
+
runs in the user's own terminal, never through dsh. Clean, synced, nothing
|
|
26
|
+
failing → no row. Pure derivation: no extra git invocations, and the ranking
|
|
27
|
+
is unit-tested in the node suite.
|
|
28
|
+
|
|
29
|
+
## [0.13.0] - 2026-09-15
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **A visible seam-absent notice on the input chip.** When `allowBuilds` keeps
|
|
34
|
+
the postinstall from patching, a market install lands chip-working but
|
|
35
|
+
sidebar-off with no message anywhere — dshmarket cannot see host files, and
|
|
36
|
+
the old hint was a hover tooltip nobody finds. Now, once the 5-second boot
|
|
37
|
+
grace proves the seam is genuinely absent, the chip shows
|
|
38
|
+
*sidebar badges off — run `npx dsh-git-badge apply`* with a dismiss **×**.
|
|
39
|
+
The notice is always true and self-resolving: apply + restart makes the seam
|
|
40
|
+
declare and it never renders again. The × means *stop asking this boot* —
|
|
41
|
+
dismissal is session-scoped, so a seam that is still absent asks again at the
|
|
42
|
+
next boot, and the long-form explanation stays on the chip's hover tooltip.
|
|
43
|
+
|
|
13
44
|
## [0.12.0] - 2026-09-15
|
|
14
45
|
|
|
15
46
|
### Removed
|
package/lib/client.js
CHANGED
|
@@ -50,6 +50,11 @@ window.__ModuleLoader__.load({
|
|
|
50
50
|
primitives = null;
|
|
51
51
|
}
|
|
52
52
|
const Tooltip = primitives === null ? void 0 : primitives.Tooltip;
|
|
53
|
+
// The shell's pull-down primitive (what the input-area selectors use) and
|
|
54
|
+
// its candidate ranker. Same guarded require as Tooltip: a shell without
|
|
55
|
+
// them loses the pull-down affordances, never the badges.
|
|
56
|
+
const Menu = primitives === null ? void 0 : primitives.Menu;
|
|
57
|
+
const rankByName = primitives === null ? void 0 : primitives.rankByName;
|
|
53
58
|
|
|
54
59
|
/** Module-level git-status cache. Entries are invalidated by SSE events, never by age. */
|
|
55
60
|
const GIT_CACHE = new Map();
|
|
@@ -225,6 +230,94 @@ window.__ModuleLoader__.load({
|
|
|
225
230
|
return "ok";
|
|
226
231
|
}
|
|
227
232
|
|
|
233
|
+
/**
|
|
234
|
+
* The git action list both actionable surfaces render — the `!` trigger
|
|
235
|
+
* menu in the input and the branch pull-down on the chip. State-filtered:
|
|
236
|
+
* the server's `next` suggestion leads, then only actions the current
|
|
237
|
+
* status justifies. Deduped by command (the next step already covers one
|
|
238
|
+
* of these when it agrees) and capped, because a menu of twelve is a
|
|
239
|
+
* reference manual, not an action menu.
|
|
240
|
+
*/
|
|
241
|
+
function buildActions(info) {
|
|
242
|
+
if (info === void 0 || info === null || info.git !== true) return [];
|
|
243
|
+
const actions = [];
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
const add = (command, why) => {
|
|
246
|
+
if (typeof command !== "string" || command === "" || seen.has(command)) return;
|
|
247
|
+
seen.add(command);
|
|
248
|
+
actions.push({ name: command, label: command, description: why, command, why });
|
|
249
|
+
};
|
|
250
|
+
const next = info.next;
|
|
251
|
+
if (next !== void 0 && next !== null && typeof next.command === "string") {
|
|
252
|
+
add(next.command, next.why);
|
|
253
|
+
}
|
|
254
|
+
const ahead = info.ahead || 0;
|
|
255
|
+
const behind = info.behind || 0;
|
|
256
|
+
const staged = info.stagedFiles || 0;
|
|
257
|
+
const unstaged = info.unstagedFiles || 0;
|
|
258
|
+
const untracked = info.untrackedFiles || 0;
|
|
259
|
+
if (behind > 0) add("git pull --ff-only", behind + " behind " + (info.upstream ?? "upstream"));
|
|
260
|
+
if (ahead > 0) add("git push", ahead + " ahead of " + (info.upstream ?? "upstream"));
|
|
261
|
+
if (staged > 0) add("git commit", staged + " staged");
|
|
262
|
+
if (unstaged > 0) add("git add -p && git commit", unstaged + " unstaged");
|
|
263
|
+
if (unstaged === 0 && untracked > 0) add("git add -A && git commit", untracked + " untracked");
|
|
264
|
+
if ((info.stashCount || 0) > 0) add("git stash list", info.stashCount + " stashed");
|
|
265
|
+
const pr = info.pr;
|
|
266
|
+
if (pr !== void 0 && pr !== null && pr.number !== void 0) {
|
|
267
|
+
if (pr.state === "failing") add("gh pr checks " + pr.number + " --watch", "checks failing on #" + pr.number);
|
|
268
|
+
add("gh pr view " + pr.number, "open pull request #" + pr.number);
|
|
269
|
+
} else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && staged + unstaged + untracked === 0) {
|
|
270
|
+
add("gh pr create", "branch is pushed and has no pull request");
|
|
271
|
+
}
|
|
272
|
+
return actions.slice(0, 6);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The input text a picked action becomes: the command, then why as a comment. */
|
|
276
|
+
function actionText(action) {
|
|
277
|
+
return "!" + action.command + " # " + action.why;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The /gh picker's sub-actions — the skill invocations the checkout
|
|
282
|
+
* justifies, most urgent first. Same ranking the server's nextStep uses,
|
|
283
|
+
* but spoken in skill arguments; the generic "next" entry is skipped when
|
|
284
|
+
* a specific rule already names the same situation (its why would be
|
|
285
|
+
* identical, and two rows for one fact is noise).
|
|
286
|
+
*/
|
|
287
|
+
function ghSkillActions(info) {
|
|
288
|
+
if (info === void 0 || info === null || info.git !== true) return [];
|
|
289
|
+
const subs = [];
|
|
290
|
+
const seen = new Set();
|
|
291
|
+
const add = (args, why) => {
|
|
292
|
+
if (seen.has(args)) return;
|
|
293
|
+
seen.add(args);
|
|
294
|
+
subs.push({ args, why });
|
|
295
|
+
};
|
|
296
|
+
const ahead = info.ahead || 0;
|
|
297
|
+
const behind = info.behind || 0;
|
|
298
|
+
const staged = info.stagedFiles || 0;
|
|
299
|
+
const unstaged = info.unstagedFiles || 0;
|
|
300
|
+
const untracked = info.untrackedFiles || 0;
|
|
301
|
+
if (behind > 0) add("pull", behind + " behind " + (info.upstream ?? "upstream"));
|
|
302
|
+
if (ahead > 0) add("push", ahead + " ahead of " + (info.upstream ?? "upstream"));
|
|
303
|
+
if (staged > 0) add("commit", staged + " staged");
|
|
304
|
+
if (unstaged > 0) add("commit", unstaged + " unstaged");
|
|
305
|
+
if (unstaged === 0 && untracked > 0) add("commit", untracked + " untracked");
|
|
306
|
+
const pr = info.pr;
|
|
307
|
+
if (pr !== void 0 && pr !== null && pr.number !== void 0) {
|
|
308
|
+
if (pr.state === "failing") add("checks " + pr.number, "checks failing on #" + pr.number);
|
|
309
|
+
add("pr view " + pr.number, "open pull request #" + pr.number);
|
|
310
|
+
} else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && staged + unstaged + untracked === 0) {
|
|
311
|
+
add("pr", "branch is pushed and has no pull request");
|
|
312
|
+
}
|
|
313
|
+
const next = info.next;
|
|
314
|
+
if (next !== void 0 && next !== null && typeof next.command === "string"
|
|
315
|
+
&& !subs.some((entry) => entry.why === next.why)) {
|
|
316
|
+
add("next", next.why);
|
|
317
|
+
}
|
|
318
|
+
return subs.slice(0, 6);
|
|
319
|
+
}
|
|
320
|
+
|
|
228
321
|
/**
|
|
229
322
|
* Mark fill per status, from the app's own semantic tokens so BOTH surfaces
|
|
230
323
|
* follow light/dark and custom themes — which a hardcoded emoji cannot do.
|
|
@@ -617,6 +710,66 @@ window.__ModuleLoader__.load({
|
|
|
617
710
|
const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
|
|
618
711
|
const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
|
|
619
712
|
|
|
713
|
+
/**
|
|
714
|
+
* The "next" row's body: why, then the command as a click-to-copy chip.
|
|
715
|
+
* Clipboard-only by design — no server call, no path exposure; the command
|
|
716
|
+
* lands in the user's clipboard to run in their own terminal. `navigator.
|
|
717
|
+
* clipboard` needs a user gesture (a click is one) and a secure context
|
|
718
|
+
* (localhost is one); a denied write degrades to a no-op and the command
|
|
719
|
+
* is still readable as text.
|
|
720
|
+
*/
|
|
721
|
+
function NextStepRow({ next }) {
|
|
722
|
+
const [copied, setCopied] = react.useState(false);
|
|
723
|
+
const copy = () => {
|
|
724
|
+
if (typeof next.command !== "string") return;
|
|
725
|
+
try {
|
|
726
|
+
void navigator.clipboard.writeText(next.command).then(
|
|
727
|
+
() => {
|
|
728
|
+
setCopied(true);
|
|
729
|
+
setTimeout(() => setCopied(false), 1500);
|
|
730
|
+
},
|
|
731
|
+
() => void 0,
|
|
732
|
+
);
|
|
733
|
+
} catch {
|
|
734
|
+
void 0;
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
return react_jsx_runtime.jsxs(
|
|
738
|
+
"span",
|
|
739
|
+
{
|
|
740
|
+
style: { ...CARD_VALUE, display: "inline-flex", alignItems: "center", gap: "6px", flexWrap: "wrap" },
|
|
741
|
+
children: [
|
|
742
|
+
react_jsx_runtime.jsx("span", { children: next.why }),
|
|
743
|
+
typeof next.command === "string"
|
|
744
|
+
? react_jsx_runtime.jsx(
|
|
745
|
+
"button",
|
|
746
|
+
{
|
|
747
|
+
"aria-label": "Copy command: " + next.command,
|
|
748
|
+
title: "Copy command: " + next.command,
|
|
749
|
+
onClick: copy,
|
|
750
|
+
style: {
|
|
751
|
+
cursor: "pointer",
|
|
752
|
+
color: "inherit",
|
|
753
|
+
background: "none",
|
|
754
|
+
border: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
|
|
755
|
+
borderRadius: "4px",
|
|
756
|
+
fontSize: "11px",
|
|
757
|
+
lineHeight: "16px",
|
|
758
|
+
padding: "0 5px",
|
|
759
|
+
flex: "none",
|
|
760
|
+
fontFamily: "monospace"
|
|
761
|
+
},
|
|
762
|
+
children: copied ? "copied" : next.command
|
|
763
|
+
},
|
|
764
|
+
"cmd"
|
|
765
|
+
)
|
|
766
|
+
: null
|
|
767
|
+
]
|
|
768
|
+
},
|
|
769
|
+
"next-body"
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
620
773
|
/**
|
|
621
774
|
* Hover card body for the input chip — INPUT CHIP ONLY. Pure presentation
|
|
622
775
|
* over fields the status response already carries, plus the `detail=1`
|
|
@@ -640,6 +793,18 @@ window.__ModuleLoader__.load({
|
|
|
640
793
|
}, label)
|
|
641
794
|
);
|
|
642
795
|
};
|
|
796
|
+
// The "next" row — the node half's single highest-priority suggestion,
|
|
797
|
+
// { command, why } or absent (clean + synced + nothing failing). Clicking
|
|
798
|
+
// copies the command; it is the one actionable affordance the card
|
|
799
|
+
// carries, and it costs no server surface: the command runs in the
|
|
800
|
+
// user's own terminal, never here. Per-open state: the copied ack
|
|
801
|
+
// resets the next time the card opens, which is the honest lifetime for it.
|
|
802
|
+
const next = data.next;
|
|
803
|
+
if (next !== void 0 && next !== null && typeof next.why === "string") {
|
|
804
|
+
rows.push(
|
|
805
|
+
react_jsx_runtime.jsxs(NextStepRow, { next }, "next")
|
|
806
|
+
);
|
|
807
|
+
}
|
|
643
808
|
add("branch", info.branch);
|
|
644
809
|
add("upstream", info.upstream === void 0 ? "none configured" : info.upstream);
|
|
645
810
|
if (info.ahead !== void 0 || info.behind !== void 0) {
|
|
@@ -703,6 +868,16 @@ window.__ModuleLoader__.load({
|
|
|
703
868
|
*/
|
|
704
869
|
let seamHint = null;
|
|
705
870
|
|
|
871
|
+
/**
|
|
872
|
+
* Dismissal of the seam-absent notice, deliberately MODULE-level rather
|
|
873
|
+
* than component state: a useState flag would un-dismiss when the chip
|
|
874
|
+
* remounts (a sidebar toggle, a route change), and the × the user just
|
|
875
|
+
* clicked would silently stop meaning anything. Session-scoped on
|
|
876
|
+
* purpose — it resets at the next boot, so a seam that is STILL absent
|
|
877
|
+
* asks again, while one fixed by apply + restart never nags at all.
|
|
878
|
+
*/
|
|
879
|
+
let seamNoticeDismissed = false;
|
|
880
|
+
|
|
706
881
|
function ComposerGitChip({ sessionId }) {
|
|
707
882
|
const target = sessionId === void 0 ? void 0 : { kind: "session", id: sessionId };
|
|
708
883
|
// the PR/CI token is always on the chip, so its fetch is not gated
|
|
@@ -715,8 +890,42 @@ window.__ModuleLoader__.load({
|
|
|
715
890
|
// the PR token's own hover/focus state: it underlines on hover (and on
|
|
716
891
|
// focus) rather than at rest, and that underline is applied inline below
|
|
717
892
|
const [linkHover, setLinkHover] = react.useState(false);
|
|
893
|
+
// seamHint is module state set once, 5s after boot; dismissing it needs a
|
|
894
|
+
// re-render that no fetch will schedule, so the × bumps a counter here.
|
|
895
|
+
const [, bumpNotice] = react.useState(0);
|
|
896
|
+
const seamNotice = seamHint !== null && !seamNoticeDismissed ? seamHint : null;
|
|
897
|
+
// The branch pull-down (model-selection pattern): a chevron opens the
|
|
898
|
+
// shell Menu of state-filtered git actions; picking COPIES the command —
|
|
899
|
+
// the composer has no public insert API (see the trigger source in
|
|
900
|
+
// apply() for the insertion-capable surface), so this surface's contract
|
|
901
|
+
// is paste-and-send, the same one the hover card's chip has always had.
|
|
902
|
+
const [menuOpen, setMenuOpen] = react.useState(false);
|
|
903
|
+
const [copied, setCopied] = react.useState(false);
|
|
718
904
|
const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
|
|
719
905
|
if (info === void 0 || info.git !== true) return null;
|
|
906
|
+
const actions = buildActions({ ...info, ...(detail ?? {}) });
|
|
907
|
+
|
|
908
|
+
// The status mark wrapped in the chip's hover card — the card's ONLY
|
|
909
|
+
// anchor. Pointer rest here (and only here) enables the detail=1 fetch,
|
|
910
|
+
// so the extras are paid for exactly when the card that can render them
|
|
911
|
+
// is about to open. Defined inside the component: it closes over the
|
|
912
|
+
// hovered state that gates the detail fetch.
|
|
913
|
+
const hoverableMark = (() => {
|
|
914
|
+
const mark = react_jsx_runtime.jsx("span", {
|
|
915
|
+
onPointerEnter: () => setHovered(true),
|
|
916
|
+
style: { display: "inline-flex", alignItems: "center", flex: "none", cursor: "default" },
|
|
917
|
+
children: react_jsx_runtime.jsx(StatusMark, { info })
|
|
918
|
+
});
|
|
919
|
+
if (Tooltip === void 0) return mark;
|
|
920
|
+
return react_jsx_runtime.jsx(Tooltip, {
|
|
921
|
+
side: "top",
|
|
922
|
+
maxWidth: 360,
|
|
923
|
+
// a function label keeps the card's element tree out of every render
|
|
924
|
+
// until the tooltip actually opens
|
|
925
|
+
label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
|
|
926
|
+
children: mark
|
|
927
|
+
});
|
|
928
|
+
})();
|
|
720
929
|
// the mark is an element now rather than a leading glyph in the string, so
|
|
721
930
|
// the SAME StatusMark the sidebar row draws carries the status here too;
|
|
722
931
|
// the container's 4px gap supplies the space the emoji's own did
|
|
@@ -738,16 +947,83 @@ window.__ModuleLoader__.load({
|
|
|
738
947
|
whiteSpace: "nowrap",
|
|
739
948
|
cursor: "default"
|
|
740
949
|
},
|
|
741
|
-
// the
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
950
|
+
// the long-form explanation, on hover; the inline seam-notice below is
|
|
951
|
+
// the visible channel (its text is the short form, its title this).
|
|
952
|
+
// null until the delayed check in apply() proves the seam never
|
|
953
|
+
// appeared.
|
|
745
954
|
title: seamHint === null ? undefined : seamHint,
|
|
746
|
-
// the card is fetched for a pointer that RESTS here, not one that
|
|
747
|
-
// merely crosses the chip
|
|
748
|
-
onPointerEnter: () => setHovered(true),
|
|
749
955
|
children: [
|
|
750
|
-
|
|
956
|
+
// The hover card belongs to the STATUS SYMBOL, not the badge: the
|
|
957
|
+
// circle is the "what does this colour mean" element, and a card
|
|
958
|
+
// opening sideways from a hover on the branch text got in the way of
|
|
959
|
+
// the very pull-down this row now carries. The detail fetch
|
|
960
|
+
// (enabled: hovered) follows the same pointer, so nothing pays for
|
|
961
|
+
// extras the card no longer shows. The seam-absent native title
|
|
962
|
+
// stays on the whole chip — it is about the badge, not the mark.
|
|
963
|
+
hoverableMark,
|
|
964
|
+
|
|
965
|
+
Menu === void 0 || actions.length === 0
|
|
966
|
+
? null
|
|
967
|
+
: react_jsx_runtime.jsx(Menu, {
|
|
968
|
+
key: "actions",
|
|
969
|
+
open: menuOpen,
|
|
970
|
+
onClose: () => setMenuOpen(false),
|
|
971
|
+
items: actions.map((action) => ({ id: action.command, label: action.label, description: action.description })),
|
|
972
|
+
onSelect: (picked) => {
|
|
973
|
+
setMenuOpen(false);
|
|
974
|
+
// the shell may hand back the id or the whole item — take either
|
|
975
|
+
const id = typeof picked === "string" ? picked : picked?.id;
|
|
976
|
+
const action = actions.find((entry) => entry.command === id);
|
|
977
|
+
console.info("[dsh-git-badge] pull-down pick:", id, "->", action === void 0 ? "NOT FOUND" : actionText(action));
|
|
978
|
+
if (action === void 0) return;
|
|
979
|
+
try {
|
|
980
|
+
void navigator.clipboard.writeText(actionText(action)).then(() => {
|
|
981
|
+
setCopied(true);
|
|
982
|
+
setTimeout(() => setCopied(false), 1200);
|
|
983
|
+
}, () => void 0);
|
|
984
|
+
} catch {
|
|
985
|
+
void 0;
|
|
986
|
+
}
|
|
987
|
+
},
|
|
988
|
+
align: "start",
|
|
989
|
+
portal: true,
|
|
990
|
+
anchor: react_jsx_runtime.jsxs(
|
|
991
|
+
"button",
|
|
992
|
+
{
|
|
993
|
+
type: "button",
|
|
994
|
+
"aria-haspopup": "menu",
|
|
995
|
+
"aria-expanded": menuOpen,
|
|
996
|
+
"aria-label": "Git actions for " + info.branch,
|
|
997
|
+
title: copied ? "copied — paste in your terminal" : "git actions",
|
|
998
|
+
onClick: () => setMenuOpen((value) => !value),
|
|
999
|
+
// the metrics of the row's own selectors (the access/model
|
|
1000
|
+
// pull-downs): inline-flex, centered on the 24px line, no
|
|
1001
|
+
// border or padding, icon flex-none so the chevron reads as an
|
|
1002
|
+
// affordance, not a second label
|
|
1003
|
+
style: {
|
|
1004
|
+
cursor: "pointer",
|
|
1005
|
+
color: "inherit",
|
|
1006
|
+
background: "none",
|
|
1007
|
+
border: "none",
|
|
1008
|
+
display: "inline-flex",
|
|
1009
|
+
alignItems: "center",
|
|
1010
|
+
justifyContent: "center",
|
|
1011
|
+
fontSize: "12px",
|
|
1012
|
+
lineHeight: "24px",
|
|
1013
|
+
padding: "0",
|
|
1014
|
+
flex: "none"
|
|
1015
|
+
},
|
|
1016
|
+
children: [
|
|
1017
|
+
copied
|
|
1018
|
+
? "\u2713"
|
|
1019
|
+
: (primitives !== null && primitives.IconChevronDownOutline14 !== void 0
|
|
1020
|
+
? react_jsx_runtime.jsx(primitives.IconChevronDownOutline14, {})
|
|
1021
|
+
: "\u25BE")
|
|
1022
|
+
]
|
|
1023
|
+
},
|
|
1024
|
+
"actions-anchor"
|
|
1025
|
+
)
|
|
1026
|
+
}),
|
|
751
1027
|
react_jsx_runtime.jsx("span", { key: "text", children: text }),
|
|
752
1028
|
prToken === ""
|
|
753
1029
|
? null
|
|
@@ -786,27 +1062,82 @@ window.__ModuleLoader__.load({
|
|
|
786
1062
|
onBlur: () => setLinkHover(false),
|
|
787
1063
|
children: prToken
|
|
788
1064
|
}),
|
|
1065
|
+
/**
|
|
1066
|
+
* The seam-absent notice: the market-install notification for the
|
|
1067
|
+
* state a blocked postinstall leaves behind — chip working, sidebar
|
|
1068
|
+
* rows structurally off, nothing patched, nothing to restart INTO.
|
|
1069
|
+
* It is only ever TRUE (seamHint is set 5s after boot, only after
|
|
1070
|
+
* the composed graph is given its chance), and it is self-resolving:
|
|
1071
|
+
* apply + restart makes the seam declare, and this never renders
|
|
1072
|
+
* again. That is what distinguishes it from the old restart notice,
|
|
1073
|
+
* which outlived the restart it asked for. Dismissal is a session
|
|
1074
|
+
* value (see seamNoticeDismissed), so the × means "stop asking this
|
|
1075
|
+
* boot", never "stop asking forever".
|
|
1076
|
+
*/
|
|
1077
|
+
seamNotice === null
|
|
1078
|
+
? null
|
|
1079
|
+
: react_jsx_runtime.jsxs(
|
|
1080
|
+
"span",
|
|
1081
|
+
{
|
|
1082
|
+
style: {
|
|
1083
|
+
display: "inline-flex",
|
|
1084
|
+
alignItems: "center",
|
|
1085
|
+
gap: "6px",
|
|
1086
|
+
paddingLeft: "6px",
|
|
1087
|
+
borderLeft: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
|
|
1088
|
+
whiteSpace: "normal",
|
|
1089
|
+
maxWidth: "320px"
|
|
1090
|
+
},
|
|
1091
|
+
children: [
|
|
1092
|
+
react_jsx_runtime.jsx(
|
|
1093
|
+
"span",
|
|
1094
|
+
{
|
|
1095
|
+
title: seamHint,
|
|
1096
|
+
children: "sidebar badges off — run `npx dsh-git-badge apply`"
|
|
1097
|
+
},
|
|
1098
|
+
"explain"
|
|
1099
|
+
),
|
|
1100
|
+
react_jsx_runtime.jsx(
|
|
1101
|
+
"button",
|
|
1102
|
+
{
|
|
1103
|
+
"aria-label": "Dismiss the sidebar-badges notice (returns next boot while the seam is absent)",
|
|
1104
|
+
onClick: () => {
|
|
1105
|
+
seamNoticeDismissed = true;
|
|
1106
|
+
bumpNotice((n) => n + 1);
|
|
1107
|
+
},
|
|
1108
|
+
style: {
|
|
1109
|
+
cursor: "pointer",
|
|
1110
|
+
color: "inherit",
|
|
1111
|
+
background: "none",
|
|
1112
|
+
border: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
|
|
1113
|
+
borderRadius: "4px",
|
|
1114
|
+
fontSize: "11px",
|
|
1115
|
+
lineHeight: "18px",
|
|
1116
|
+
padding: "0 6px",
|
|
1117
|
+
flex: "none"
|
|
1118
|
+
},
|
|
1119
|
+
children: "×"
|
|
1120
|
+
},
|
|
1121
|
+
"dismiss"
|
|
1122
|
+
)
|
|
1123
|
+
]
|
|
1124
|
+
},
|
|
1125
|
+
"seam-notice"
|
|
1126
|
+
),
|
|
789
1127
|
]
|
|
790
1128
|
});
|
|
791
|
-
// No Tooltip primitive (a shell that does not seed it):
|
|
792
|
-
//
|
|
1129
|
+
// No Tooltip primitive (a shell that does not seed it): the mark renders
|
|
1130
|
+
// bare and the badge still works — the card is an enhancement, the
|
|
793
1131
|
// badge is the feature.
|
|
794
|
-
|
|
795
|
-
return react_jsx_runtime.jsx(Tooltip, {
|
|
796
|
-
side: "top",
|
|
797
|
-
maxWidth: 360,
|
|
798
|
-
// a function label keeps the card's element tree out of every render
|
|
799
|
-
// until the tooltip actually opens
|
|
800
|
-
label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
|
|
801
|
-
children: chip
|
|
802
|
-
});
|
|
1132
|
+
return chip;
|
|
803
1133
|
}
|
|
1134
|
+
|
|
804
1135
|
//#endregion
|
|
805
1136
|
|
|
806
1137
|
// `workspaces` is no longer required: both surfaces target an id and the
|
|
807
1138
|
// node half resolves the workspace, so the client never needs a service
|
|
808
1139
|
// lookup. Fewer declared services also means fewer ways to fail to load.
|
|
809
|
-
const inject = ["slots"];
|
|
1140
|
+
const inject = ["slots", "inputTriggers"];
|
|
810
1141
|
|
|
811
1142
|
/**
|
|
812
1143
|
* Register the badge into the seams. The seam owner hands each entry the row
|
|
@@ -814,6 +1145,93 @@ window.__ModuleLoader__.load({
|
|
|
814
1145
|
* deliberately ignores the cwd it may also be given.
|
|
815
1146
|
*/
|
|
816
1147
|
function apply(ctx) {
|
|
1148
|
+
// The /gh command contribution (the (+) commands menu): the state-aware
|
|
1149
|
+
// entry point to the gh skill. available() gates on the cached git
|
|
1150
|
+
// status; options() lists only the sub-actions the checkout justifies;
|
|
1151
|
+
// onSelect SENDS the skill invocation ("/gh push") as a conversation
|
|
1152
|
+
// message — the agent executes the skill under its own approval flow,
|
|
1153
|
+
// and the shell consumes the /gh token afterwards. This is the one
|
|
1154
|
+
// blessed route from a menu pick to a git action; it is a SEND, which
|
|
1155
|
+
// is why the destructive-step confirmation lives in the skill text.
|
|
1156
|
+
// Guarded: a host without commandUi loses the menu entry, never the
|
|
1157
|
+
// badges.
|
|
1158
|
+
if (typeof ctx.inject === "function") {
|
|
1159
|
+
ctx.inject(["commandUi", "sessions"], (scope) => {
|
|
1160
|
+
try {
|
|
1161
|
+
scope.effect(() => scope.commandUi.register({
|
|
1162
|
+
name: "gh",
|
|
1163
|
+
// the contract calls description() as a FUNCTION at menu-build
|
|
1164
|
+
// time (candidates: contribution.description()) — a string throws
|
|
1165
|
+
// TypeError on every candidates pass and kills the whole menu,
|
|
1166
|
+
// (+) button included
|
|
1167
|
+
description: () => "git/gh actions for this checkout — push, pull, pr, commit, checks",
|
|
1168
|
+
available: (session) => {
|
|
1169
|
+
// called during the host's menu build — a throw here kills the
|
|
1170
|
+
// whole menu, so it degrades to "hidden" instead
|
|
1171
|
+
try {
|
|
1172
|
+
const query = targetQuery({ kind: "session", id: session?.sessionId }, { pr: true });
|
|
1173
|
+
return query !== void 0 && GIT_CACHE.get(query)?.data?.git === true;
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
console.error("[dsh-git-badge] /gh available failed:", error);
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
},
|
|
1179
|
+
ui: {
|
|
1180
|
+
options: async (session, signal) => {
|
|
1181
|
+
try {
|
|
1182
|
+
const sessionId = session?.sessionId;
|
|
1183
|
+
if (sessionId === void 0) return [];
|
|
1184
|
+
const query = targetQuery({ kind: "session", id: sessionId }, { pr: true });
|
|
1185
|
+
if (query === void 0) return [];
|
|
1186
|
+
// cache-first: the badge's own knowledge opens the picker
|
|
1187
|
+
// instantly; a cold session pays one status fetch.
|
|
1188
|
+
let info = GIT_CACHE.get(query)?.data;
|
|
1189
|
+
if (info === void 0 || info === null) {
|
|
1190
|
+
info = await fetch("/api/git-badge?" + query).then((r) => r.json());
|
|
1191
|
+
}
|
|
1192
|
+
const actions = ghSkillActions(info);
|
|
1193
|
+
return actions.map((action) => ({ label: "/gh " + action.args, detail: action.why, args: action.args }));
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
console.error("[dsh-git-badge] /gh options failed:", error);
|
|
1196
|
+
return [];
|
|
1197
|
+
}
|
|
1198
|
+
},
|
|
1199
|
+
onSelect: async (option, session) => {
|
|
1200
|
+
// Send the invocation: the message lands in the transcript
|
|
1201
|
+
// and the agent executes the gh skill — the approval flow
|
|
1202
|
+
// for anything destructive is the agent's, per the skill
|
|
1203
|
+
// text. The prompt path is the conversation's own: the
|
|
1204
|
+
// sessions service binds a sessionId to { session } and
|
|
1205
|
+
// binding.session.prompt(content, "queue") is exactly what
|
|
1206
|
+
// the composer's send() calls.
|
|
1207
|
+
const sessionId = session?.sessionId;
|
|
1208
|
+
const sessions = typeof scope.sessions === "function" ? scope.sessions() : scope.sessions;
|
|
1209
|
+
const binding = sessions?.binding?.(sessionId);
|
|
1210
|
+
const target = binding?.session;
|
|
1211
|
+
if (target?.prompt === void 0) {
|
|
1212
|
+
console.error("[dsh-git-badge] /gh: no prompt path (binding missing or session-less)", {
|
|
1213
|
+
sessionId,
|
|
1214
|
+
hasSessions: sessions !== void 0,
|
|
1215
|
+
hasBinding: binding !== void 0
|
|
1216
|
+
});
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
const result = await target.prompt([{ type: "text", text: "/gh " + option.args }], "queue");
|
|
1220
|
+
if (result !== void 0 && result !== null && result.ok === false) {
|
|
1221
|
+
console.error("[dsh-git-badge] /gh submit refused:", result.error);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}), "dsh-git-badge: /gh contribution");
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
// a failed registration must degrade to "no /gh entry", never to
|
|
1228
|
+
// a broken commands menu — the (+) button and the / menu are the
|
|
1229
|
+
// host's, and this callback runs inside their boot
|
|
1230
|
+
console.error("[dsh-git-badge] /gh contribution failed:", error);
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
|
|
817
1235
|
// inject() re-evaluates when a seam's declaration appears, so boot
|
|
818
1236
|
// order relative to the workspace browser does not matter. On an
|
|
819
1237
|
// unpatched install the seam is never declared, so this callback never
|
|
@@ -895,7 +1313,7 @@ window.__ModuleLoader__.load({
|
|
|
895
1313
|
// Additive; the host reads apply/inject and ignores the rest. The suite
|
|
896
1314
|
// drives these to assert the REQUEST contract — which surface asks for the
|
|
897
1315
|
// expensive extras — without a browser, a fetch or a network.
|
|
898
|
-
exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken };
|
|
1316
|
+
exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, buildActions, actionText, ghSkillActions };
|
|
899
1317
|
return module.exports;
|
|
900
1318
|
}
|
|
901
1319
|
});
|
package/lib/index.js
CHANGED
|
@@ -662,6 +662,93 @@ function operationMarker(gitDir) {
|
|
|
662
662
|
return null;
|
|
663
663
|
}
|
|
664
664
|
|
|
665
|
+
/**
|
|
666
|
+
* The one command a paused operation resumes with. A squash merge records no
|
|
667
|
+
* MERGE_HEAD (its conclusion is an ordinary commit), and bisect's next step is
|
|
668
|
+
* the user's judgement call (good/bad) — it gets a WHY but no command.
|
|
669
|
+
*/
|
|
670
|
+
const OPERATION_NEXT = {
|
|
671
|
+
merge: "git merge --continue",
|
|
672
|
+
squash: "git commit",
|
|
673
|
+
"cherry-pick": "git cherry-pick --continue",
|
|
674
|
+
revert: "git revert --continue",
|
|
675
|
+
rebase: "git rebase --continue",
|
|
676
|
+
sequencer: "git cherry-pick --continue",
|
|
677
|
+
bisect: void 0
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* The hover card's "next" row: the single highest-priority thing to do next,
|
|
682
|
+
* as a conservative command plus the reason it applies. Pure derivation over
|
|
683
|
+
* fields the status response already carries — no extra git invocations, so it
|
|
684
|
+
* rides the base response and a future surface gets it for free. Ranking (the
|
|
685
|
+
* first match wins):
|
|
686
|
+
*
|
|
687
|
+
* 1. a paused operation (conflicts included) — resuming it subsumes everything
|
|
688
|
+
* else; unmerged files without a marker fall back to `git status`
|
|
689
|
+
* 2. behind — reconciling comes before publishing
|
|
690
|
+
* 3. no upstream on a dirty branch — publish it for the first time
|
|
691
|
+
* 4. ahead — publish
|
|
692
|
+
* 5. dirty — stage and commit (add -p for unstaged work; -A only when the
|
|
693
|
+
* change is untracked-only, which add -p cannot see)
|
|
694
|
+
* 6. PR checks failing — watch them land
|
|
695
|
+
*
|
|
696
|
+
* Clean, synced, nothing failing → null: the row is omitted entirely, because
|
|
697
|
+
* "no suggestion" is also a suggestion. Commands are conservative on purpose —
|
|
698
|
+
* the card COPIES them into the user's terminal, it never runs them, but the
|
|
699
|
+
* register still avoids anything destructive.
|
|
700
|
+
*
|
|
701
|
+
* @returns {{ command: string|undefined, why: string } | null}
|
|
702
|
+
*/
|
|
703
|
+
function nextStep(info) {
|
|
704
|
+
if (info === void 0 || info === null || info.git !== true) return null;
|
|
705
|
+
const unmerged = info.unmergedFiles || 0;
|
|
706
|
+
const plural = (n) => (n === 1 ? "" : "s");
|
|
707
|
+
if (info.operation !== void 0 && info.operation !== null) {
|
|
708
|
+
const op = String(info.operation);
|
|
709
|
+
return {
|
|
710
|
+
command: OPERATION_NEXT[op],
|
|
711
|
+
why: unmerged > 0
|
|
712
|
+
? `${unmerged} unmerged file${plural(unmerged)} blocking the paused ${op}`
|
|
713
|
+
: `a ${op} is paused mid-operation`
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
if (unmerged > 0) {
|
|
717
|
+
return { command: "git status", why: `${unmerged} unmerged file${plural(unmerged)} to resolve` };
|
|
718
|
+
}
|
|
719
|
+
const ahead = info.ahead || 0;
|
|
720
|
+
const behind = info.behind || 0;
|
|
721
|
+
if (behind > 0) {
|
|
722
|
+
return { command: "git pull --ff-only", why: `${behind} behind ${info.upstream ?? "upstream"}` };
|
|
723
|
+
}
|
|
724
|
+
const staged = info.stagedFiles || 0;
|
|
725
|
+
const unstaged = info.unstagedFiles || 0;
|
|
726
|
+
const untracked = info.untrackedFiles || 0;
|
|
727
|
+
if (info.upstream === void 0 && staged + unstaged + untracked > 0) {
|
|
728
|
+
return { command: `git push -u origin ${info.branch}`, why: "no upstream configured" };
|
|
729
|
+
}
|
|
730
|
+
if (ahead > 0) {
|
|
731
|
+
return { command: "git push", why: `${ahead} ahead of ${info.upstream ?? "upstream"}` };
|
|
732
|
+
}
|
|
733
|
+
if (staged + unstaged + untracked > 0) {
|
|
734
|
+
const why = [
|
|
735
|
+
staged > 0 ? `${staged} staged` : null,
|
|
736
|
+
unstaged > 0 ? `${unstaged} unstaged` : null,
|
|
737
|
+
untracked > 0 ? `${untracked} untracked` : null
|
|
738
|
+
].filter(Boolean).join(", ");
|
|
739
|
+
const command = staged > 0
|
|
740
|
+
? "git commit"
|
|
741
|
+
: untracked > 0 && unstaged === 0
|
|
742
|
+
? "git add -A && git commit"
|
|
743
|
+
: "git add -p && git commit";
|
|
744
|
+
return { command, why: `work to commit: ${why}` };
|
|
745
|
+
}
|
|
746
|
+
if (info.pr !== void 0 && info.pr !== null && info.pr.number !== void 0 && info.pr.state === "failing") {
|
|
747
|
+
return { command: `gh pr checks ${info.pr.number} --watch`, why: `checks failing on #${info.pr.number}` };
|
|
748
|
+
}
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
|
|
665
752
|
/**
|
|
666
753
|
* Git status for dir; { git: false } when dir is not a repository,
|
|
667
754
|
* GIT_DEGRADED on transient git failure.
|
|
@@ -787,6 +874,8 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
787
874
|
const pr = prStatusFor(toplevel, parsed.branch, notifyChange);
|
|
788
875
|
if (pr !== void 0) info.pr = pr;
|
|
789
876
|
}
|
|
877
|
+
const next = nextStep(info);
|
|
878
|
+
if (next !== null) info.next = next;
|
|
790
879
|
return info;
|
|
791
880
|
}
|
|
792
881
|
|
|
@@ -1355,6 +1444,7 @@ export {
|
|
|
1355
1444
|
config,
|
|
1356
1445
|
OPERATION_MARKERS,
|
|
1357
1446
|
operationMarker,
|
|
1447
|
+
nextStep,
|
|
1358
1448
|
outerGitDir,
|
|
1359
1449
|
parseStatusV2,
|
|
1360
1450
|
parseWorktreeList,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-git-badge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Git status badges for DeepSeek Harness: an input-row chip on every install, plus sidebar session-row badges (git status, PR/CI action tokens) after one manual step: `npx dsh-git-badge apply`, then restart dsh web. Event-driven freshness over SSE.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -47,17 +47,19 @@
|
|
|
47
47
|
"node": ">=20"
|
|
48
48
|
},
|
|
49
49
|
"files": [
|
|
50
|
-
"
|
|
50
|
+
"CHANGELOG.md",
|
|
51
|
+
"LICENSE",
|
|
52
|
+
"README.md",
|
|
53
|
+
"cordis.patch.yml",
|
|
51
54
|
"lib/client.js",
|
|
52
|
-
"
|
|
55
|
+
"lib/index.js",
|
|
56
|
+
"package.json",
|
|
53
57
|
"seam/anchors.js",
|
|
58
|
+
"seam/apply.js",
|
|
54
59
|
"seam/postinstall.js",
|
|
60
|
+
"seam/skill.js",
|
|
55
61
|
"seam/store.js",
|
|
56
62
|
"seam/stub-index.js",
|
|
57
|
-
"
|
|
58
|
-
"package.json",
|
|
59
|
-
"README.md",
|
|
60
|
-
"CHANGELOG.md",
|
|
61
|
-
"LICENSE"
|
|
63
|
+
"skill/gh/SKILL.md"
|
|
62
64
|
]
|
|
63
65
|
}
|
package/seam/apply.js
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
seamPresent,
|
|
56
56
|
} from "./anchors.js";
|
|
57
57
|
|
|
58
|
+
import { installSkill } from "./skill.js";
|
|
58
59
|
import { dataDir, writeRestartMarker } from "./store.js";
|
|
59
60
|
|
|
60
61
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -165,6 +166,11 @@ function writeText(path, text) {
|
|
|
165
166
|
|
|
166
167
|
const VERDICT_EXIT_1 = new Set(["drift", "unreadable", "missing", "ours-corrupt"]);
|
|
167
168
|
|
|
169
|
+
// keep the /gh skill in step with the package on every patcher run
|
|
170
|
+
if (["apply", "revert", "status"].includes(process.argv[2])) {
|
|
171
|
+
console.log(`[dsh-git-badge] gh skill: ${installSkill()}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
168
174
|
const verb = process.argv[2];
|
|
169
175
|
if (!verb || !["apply", "revert", "status"].includes(verb)) {
|
|
170
176
|
console.error("usage: apply.js apply|revert|status");
|
package/seam/postinstall.js
CHANGED
|
@@ -26,6 +26,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
|
|
|
26
26
|
import { dirname, join } from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { applyPatch, firstFailure, isOurs, seamPresent } from "./anchors.js";
|
|
29
|
+
import { installSkill } from "./skill.js";
|
|
29
30
|
import { dataDir, writeRestartMarker } from "./store.js";
|
|
30
31
|
|
|
31
32
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -41,6 +42,9 @@ function dshClientPath() {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
function main() {
|
|
45
|
+
// The skill does not depend on the DSH install — install it before any
|
|
46
|
+
// client-shaped early return, so a market install always gets /gh.
|
|
47
|
+
console.log(`[dsh-git-badge] gh skill: ${installSkill()} — /gh in the input's commands menu.`);
|
|
44
48
|
const client = dshClientPath();
|
|
45
49
|
if (client === null || !existsSync(client)) {
|
|
46
50
|
console.log(`[dsh-git-badge] DSH install not found; skipping seam setup. ${HINT}`);
|
package/seam/skill.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gh skill installer — copies the packaged skill into the user's skills
|
|
3
|
+
* catalog so `/gh` exists in the input's commands menu.
|
|
4
|
+
*
|
|
5
|
+
* WHY A SKILL: the commands menu executes git through the AGENT, not through
|
|
6
|
+
* this plugin — the agent owns the approval/sandbox semantics for mutating
|
|
7
|
+
* commands. The skill is the knowledge; the `/gh` command contribution in the
|
|
8
|
+
* client half is only the state-aware menu entry that invokes it.
|
|
9
|
+
*
|
|
10
|
+
* GUARDRAILS — a skill must never break an install: every failure path returns
|
|
11
|
+
* a reason string instead of throwing, and a current target is a no-op (the
|
|
12
|
+
* byte comparison makes reinstalls and updates idempotent).
|
|
13
|
+
*/
|
|
14
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
export const SKILL_NAME = "gh";
|
|
21
|
+
|
|
22
|
+
/** The user-level skills catalog — DSH_HOME honours the harness override. */
|
|
23
|
+
export function skillsDir() {
|
|
24
|
+
return join(process.env.DSH_HOME || join(process.env.HOME || "", ".dsh"), "skills");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function skillSource() {
|
|
28
|
+
return join(HERE, "..", "skill", "gh", "SKILL.md");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function skillTarget() {
|
|
32
|
+
return join(skillsDir(), SKILL_NAME, "SKILL.md");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Install (or update) the gh skill. Never throws.
|
|
37
|
+
* @returns {"installed"|"current"|"no packaged skill"|string} what happened.
|
|
38
|
+
*/
|
|
39
|
+
export function installSkill() {
|
|
40
|
+
try {
|
|
41
|
+
const source = skillSource();
|
|
42
|
+
if (!existsSync(source)) return "no packaged skill";
|
|
43
|
+
const target = skillTarget();
|
|
44
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
45
|
+
if (existsSync(target) && readFileSync(target, "utf8") === readFileSync(source, "utf8")) {
|
|
46
|
+
return "current";
|
|
47
|
+
}
|
|
48
|
+
copyFileSync(source, target);
|
|
49
|
+
return "installed";
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return "skipped (" + String(error?.message ?? error) + ")";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gh
|
|
3
|
+
description: Git and GitHub operations for the current checkout, driven by an invocation argument — /gh push, /gh pr, /gh pull, /gh commit, /gh checks <n>, or bare /gh to do the single highest-priority next action.
|
|
4
|
+
whenToUse: When the user picks a git action from the commands menu (/gh push, /gh pr, …) or asks to sync, publish, or open a pull request for the checkout they are working in.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# gh — git/GitHub actions for this checkout
|
|
8
|
+
|
|
9
|
+
You are invoked as `/gh <sub>` (or bare `/gh`). The sub-command chooses the
|
|
10
|
+
operation. Anything destructive requires explicit user confirmation before you
|
|
11
|
+
run it, every command is reported with its output, and a failure is reported
|
|
12
|
+
verbatim rather than retried blind.
|
|
13
|
+
|
|
14
|
+
## First: establish state
|
|
15
|
+
|
|
16
|
+
Run one status call and read it before acting (except for `checks`, which acts
|
|
17
|
+
on its argument):
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
git --no-optional-locks status --porcelain=v2 --branch
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Note branch, upstream, ahead/behind, and the dirty breakdown (staged /
|
|
24
|
+
unstaged / untracked / unmerged), plus any in-progress operation marker
|
|
25
|
+
(MERGE_HEAD, rebase-merge, …).
|
|
26
|
+
|
|
27
|
+
## Sub-commands
|
|
28
|
+
|
|
29
|
+
- **`/gh next`** (or bare `/gh`) — do the single highest-priority action, first
|
|
30
|
+
match wins: resume a paused operation (`git merge --continue`,
|
|
31
|
+
`git rebase --continue`, …; a squash merge concludes with `git commit`);
|
|
32
|
+
resolve unmerged files only WITH the user; `git pull --ff-only` when behind;
|
|
33
|
+
`git push -u origin <branch>` when there is no upstream; `git push` when
|
|
34
|
+
ahead; stage and commit when dirty (`git commit` if work is already staged,
|
|
35
|
+
`git add -p && git commit` for unstaged edits, `git add -A && git commit`
|
|
36
|
+
only for untracked-only changes, which `add -p` cannot see); `gh pr checks
|
|
37
|
+
<n> --watch` when CI fails. Clean and synced → say so and stop.
|
|
38
|
+
|
|
39
|
+
- **`/gh push`** — `git push`. If there is no upstream, `git push -u origin
|
|
40
|
+
<branch>`. If the push is rejected as non-fast-forward, STOP and ask: offer
|
|
41
|
+
`--force-with-lease` as the option, never plain `--force`, and never without
|
|
42
|
+
an explicit yes.
|
|
43
|
+
|
|
44
|
+
- **`/gh pr`** — create a pull request for the current branch: push first if
|
|
45
|
+
needed, then `gh pr create` with a title from the branch's commits and a body
|
|
46
|
+
summarising them. If a PR already exists, report its state (`gh pr view`)
|
|
47
|
+
instead of creating another.
|
|
48
|
+
|
|
49
|
+
- **`/gh pull`** — `git pull --ff-only`. On failure, report the divergence and
|
|
50
|
+
ask; do not merge or rebase without the user choosing.
|
|
51
|
+
|
|
52
|
+
- **`/gh commit`** — commit the current work: `git commit` when work is already
|
|
53
|
+
staged; otherwise `git add -p` interactively is not available to you, so
|
|
54
|
+
stage deliberately (`git add <paths>` named from the status output) and say
|
|
55
|
+
exactly what you staged before committing. Untracked-only changes: `git add
|
|
56
|
+
-A && git commit` after listing what -A will pick up. Write the message from
|
|
57
|
+
the diff, not a template.
|
|
58
|
+
|
|
59
|
+
- **`/gh checks <n>`** — `gh pr checks <n>` and summarise: what fails, what is
|
|
60
|
+
pending, and the single most useful next step.
|
|
61
|
+
|
|
62
|
+
## Hard rules
|
|
63
|
+
|
|
64
|
+
- Never `git reset --hard`, `git clean`, `git checkout -- .`, `git push
|
|
65
|
+
--force` (plain), or any history rewrite without an explicit user yes.
|
|
66
|
+
- `GIT_TERMINAL_PROMPT=0` on network commands so a missing credential fails
|
|
67
|
+
fast instead of hanging.
|
|
68
|
+
- One operation per invocation; report the outcome, then stop.
|