dsh-git-badge 0.13.0 → 0.14.1

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 CHANGED
@@ -10,6 +10,35 @@ npm. 0.6.0 is the first release recorded here.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.14.1] - 2026-09-15
14
+
15
+ ### Changed
16
+
17
+ - **The chip's hover card top line is now an `action:` row** — the gh skill
18
+ invocation the checkout justifies (e.g. `/gh push`), in the card's ordinary
19
+ label/value layout, replacing the next-step line's why-plus-copy-chip. The
20
+ actionable surface remains the (+) menu's `/gh` picker; the card row is its
21
+ read-only pointer.
22
+ - **The branch pull-down is removed from the chip.** The (+) menu's `/gh`
23
+ picker supersedes it as the actionable surface, and the chip returns to
24
+ mark + branch + PR token + notice.
25
+
26
+ ## [0.14.0] - 2026-09-15
27
+
28
+ ### Added
29
+
30
+ - **A "next" row at the top of the chip's hover card.** The node half derives
31
+ the single highest-priority next step from fields the status response already
32
+ carries — a paused operation and its resume command, `git pull --ff-only`
33
+ when behind, `git push -u origin <branch>` when no upstream exists on a dirty
34
+ branch, `git push` when ahead, a conservative commit command when dirty
35
+ (`add -p` for unstaged work, `-A` only for untracked-only changes, which
36
+ `add -p` cannot see), and `gh pr checks <n> --watch` when CI fails. Each
37
+ suggestion carries its reason, and the command is a click-to-copy chip — it
38
+ runs in the user's own terminal, never through dsh. Clean, synced, nothing
39
+ failing → no row. Pure derivation: no extra git invocations, and the ranking
40
+ is unit-tested in the node suite.
41
+
13
42
  ## [0.13.0] - 2026-09-15
14
43
 
15
44
  ### Added
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,48 @@ window.__ModuleLoader__.load({
225
230
  return "ok";
226
231
  }
227
232
 
233
+
234
+ /**
235
+ * The /gh picker's sub-actions — the skill invocations the checkout
236
+ * justifies, most urgent first. Same ranking the server's nextStep uses,
237
+ * but spoken in skill arguments; the generic "next" entry is skipped when
238
+ * a specific rule already names the same situation (its why would be
239
+ * identical, and two rows for one fact is noise).
240
+ */
241
+ function ghSkillActions(info) {
242
+ if (info === void 0 || info === null || info.git !== true) return [];
243
+ const subs = [];
244
+ const seen = new Set();
245
+ const add = (args, why) => {
246
+ if (seen.has(args)) return;
247
+ seen.add(args);
248
+ subs.push({ args, why });
249
+ };
250
+ const ahead = info.ahead || 0;
251
+ const behind = info.behind || 0;
252
+ const staged = info.stagedFiles || 0;
253
+ const unstaged = info.unstagedFiles || 0;
254
+ const untracked = info.untrackedFiles || 0;
255
+ if (behind > 0) add("pull", behind + " behind " + (info.upstream ?? "upstream"));
256
+ if (ahead > 0) add("push", ahead + " ahead of " + (info.upstream ?? "upstream"));
257
+ if (staged > 0) add("commit", staged + " staged");
258
+ if (unstaged > 0) add("commit", unstaged + " unstaged");
259
+ if (unstaged === 0 && untracked > 0) add("commit", untracked + " untracked");
260
+ const pr = info.pr;
261
+ if (pr !== void 0 && pr !== null && pr.number !== void 0) {
262
+ if (pr.state === "failing") add("checks " + pr.number, "checks failing on #" + pr.number);
263
+ add("pr view " + pr.number, "open pull request #" + pr.number);
264
+ } else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && staged + unstaged + untracked === 0) {
265
+ add("pr", "branch is pushed and has no pull request");
266
+ }
267
+ const next = info.next;
268
+ if (next !== void 0 && next !== null && typeof next.command === "string"
269
+ && !subs.some((entry) => entry.why === next.why)) {
270
+ add("next", next.why);
271
+ }
272
+ return subs.slice(0, 6);
273
+ }
274
+
228
275
  /**
229
276
  * Mark fill per status, from the app's own semantic tokens so BOTH surfaces
230
277
  * follow light/dark and custom themes — which a hardcoded emoji cannot do.
@@ -617,6 +664,7 @@ window.__ModuleLoader__.load({
617
664
  const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
618
665
  const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
619
666
 
667
+
620
668
  /**
621
669
  * Hover card body for the input chip — INPUT CHIP ONLY. Pure presentation
622
670
  * over fields the status response already carries, plus the `detail=1`
@@ -640,6 +688,15 @@ window.__ModuleLoader__.load({
640
688
  }, label)
641
689
  );
642
690
  };
691
+ // The "action" row — the top line, in the card's ordinary row layout:
692
+ // the gh skill invocation the checkout justifies, derived from the same
693
+ // state the /gh picker uses (first sub-action wins). Plain text, no
694
+ // affordance: the actionable surface is the (+) menu's /gh picker, and
695
+ // this row is the read-only pointer to it.
696
+ const topAction = ghSkillActions(data)[0];
697
+ if (topAction !== void 0) {
698
+ add("action:", "/gh " + topAction.args);
699
+ }
643
700
  add("branch", info.branch);
644
701
  add("upstream", info.upstream === void 0 ? "none configured" : info.upstream);
645
702
  if (info.ahead !== void 0 || info.behind !== void 0) {
@@ -731,6 +788,28 @@ window.__ModuleLoader__.load({
731
788
  const seamNotice = seamHint !== null && !seamNoticeDismissed ? seamHint : null;
732
789
  const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
733
790
  if (info === void 0 || info.git !== true) return null;
791
+
792
+ // The status mark wrapped in the chip's hover card — the card's ONLY
793
+ // anchor. Pointer rest here (and only here) enables the detail=1 fetch,
794
+ // so the extras are paid for exactly when the card that can render them
795
+ // is about to open. Defined inside the component: it closes over the
796
+ // hovered state that gates the detail fetch.
797
+ const hoverableMark = (() => {
798
+ const mark = react_jsx_runtime.jsx("span", {
799
+ onPointerEnter: () => setHovered(true),
800
+ style: { display: "inline-flex", alignItems: "center", flex: "none", cursor: "default" },
801
+ children: react_jsx_runtime.jsx(StatusMark, { info })
802
+ });
803
+ if (Tooltip === void 0) return mark;
804
+ return react_jsx_runtime.jsx(Tooltip, {
805
+ side: "top",
806
+ maxWidth: 360,
807
+ // a function label keeps the card's element tree out of every render
808
+ // until the tooltip actually opens
809
+ label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
810
+ children: mark
811
+ });
812
+ })();
734
813
  // the mark is an element now rather than a leading glyph in the string, so
735
814
  // the SAME StatusMark the sidebar row draws carries the status here too;
736
815
  // the container's 4px gap supplies the space the emoji's own did
@@ -757,12 +836,17 @@ window.__ModuleLoader__.load({
757
836
  // null until the delayed check in apply() proves the seam never
758
837
  // appeared.
759
838
  title: seamHint === null ? undefined : seamHint,
760
- // the card is fetched for a pointer that RESTS here, not one that
761
- // merely crosses the chip
762
- onPointerEnter: () => setHovered(true),
763
839
  children: [
764
- react_jsx_runtime.jsx(StatusMark, { key: "mark", info }),
765
- react_jsx_runtime.jsx("span", { key: "text", children: text }),
840
+ // The hover card belongs to the STATUS SYMBOL, not the badge: the
841
+ // circle is the "what does this colour mean" element, and a card
842
+ // opening sideways from a hover on the branch text got in the way of
843
+ // the very pull-down this row now carries. The detail fetch
844
+ // (enabled: hovered) follows the same pointer, so nothing pays for
845
+ // extras the card no longer shows. The seam-absent native title
846
+ // stays on the whole chip — it is about the badge, not the mark.
847
+ hoverableMark,
848
+
849
+ react_jsx_runtime.jsx("span", { key: "text", children: text }),
766
850
  prToken === ""
767
851
  ? null
768
852
  : prUrl === void 0
@@ -864,25 +948,18 @@ window.__ModuleLoader__.load({
864
948
  ),
865
949
  ]
866
950
  });
867
- // No Tooltip primitive (a shell that does not seed it): render the chip
868
- // itself rather than losing the badge. The card is an enhancement, the
951
+ // No Tooltip primitive (a shell that does not seed it): the mark renders
952
+ // bare and the badge still works — the card is an enhancement, the
869
953
  // badge is the feature.
870
- if (Tooltip === void 0) return chip;
871
- return react_jsx_runtime.jsx(Tooltip, {
872
- side: "top",
873
- maxWidth: 360,
874
- // a function label keeps the card's element tree out of every render
875
- // until the tooltip actually opens
876
- label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
877
- children: chip
878
- });
954
+ return chip;
879
955
  }
956
+
880
957
  //#endregion
881
958
 
882
959
  // `workspaces` is no longer required: both surfaces target an id and the
883
960
  // node half resolves the workspace, so the client never needs a service
884
961
  // lookup. Fewer declared services also means fewer ways to fail to load.
885
- const inject = ["slots"];
962
+ const inject = ["slots", "inputTriggers"];
886
963
 
887
964
  /**
888
965
  * Register the badge into the seams. The seam owner hands each entry the row
@@ -890,6 +967,93 @@ window.__ModuleLoader__.load({
890
967
  * deliberately ignores the cwd it may also be given.
891
968
  */
892
969
  function apply(ctx) {
970
+ // The /gh command contribution (the (+) commands menu): the state-aware
971
+ // entry point to the gh skill. available() gates on the cached git
972
+ // status; options() lists only the sub-actions the checkout justifies;
973
+ // onSelect SENDS the skill invocation ("/gh push") as a conversation
974
+ // message — the agent executes the skill under its own approval flow,
975
+ // and the shell consumes the /gh token afterwards. This is the one
976
+ // blessed route from a menu pick to a git action; it is a SEND, which
977
+ // is why the destructive-step confirmation lives in the skill text.
978
+ // Guarded: a host without commandUi loses the menu entry, never the
979
+ // badges.
980
+ if (typeof ctx.inject === "function") {
981
+ ctx.inject(["commandUi", "sessions"], (scope) => {
982
+ try {
983
+ scope.effect(() => scope.commandUi.register({
984
+ name: "gh",
985
+ // the contract calls description() as a FUNCTION at menu-build
986
+ // time (candidates: contribution.description()) — a string throws
987
+ // TypeError on every candidates pass and kills the whole menu,
988
+ // (+) button included
989
+ description: () => "git/gh actions for this checkout — push, pull, pr, commit, checks",
990
+ available: (session) => {
991
+ // called during the host's menu build — a throw here kills the
992
+ // whole menu, so it degrades to "hidden" instead
993
+ try {
994
+ const query = targetQuery({ kind: "session", id: session?.sessionId }, { pr: true });
995
+ return query !== void 0 && GIT_CACHE.get(query)?.data?.git === true;
996
+ } catch (error) {
997
+ console.error("[dsh-git-badge] /gh available failed:", error);
998
+ return false;
999
+ }
1000
+ },
1001
+ ui: {
1002
+ options: async (session, signal) => {
1003
+ try {
1004
+ const sessionId = session?.sessionId;
1005
+ if (sessionId === void 0) return [];
1006
+ const query = targetQuery({ kind: "session", id: sessionId }, { pr: true });
1007
+ if (query === void 0) return [];
1008
+ // cache-first: the badge's own knowledge opens the picker
1009
+ // instantly; a cold session pays one status fetch.
1010
+ let info = GIT_CACHE.get(query)?.data;
1011
+ if (info === void 0 || info === null) {
1012
+ info = await fetch("/api/git-badge?" + query).then((r) => r.json());
1013
+ }
1014
+ const actions = ghSkillActions(info);
1015
+ return actions.map((action) => ({ label: "/gh " + action.args, detail: action.why, args: action.args }));
1016
+ } catch (error) {
1017
+ console.error("[dsh-git-badge] /gh options failed:", error);
1018
+ return [];
1019
+ }
1020
+ },
1021
+ onSelect: async (option, session) => {
1022
+ // Send the invocation: the message lands in the transcript
1023
+ // and the agent executes the gh skill — the approval flow
1024
+ // for anything destructive is the agent's, per the skill
1025
+ // text. The prompt path is the conversation's own: the
1026
+ // sessions service binds a sessionId to { session } and
1027
+ // binding.session.prompt(content, "queue") is exactly what
1028
+ // the composer's send() calls.
1029
+ const sessionId = session?.sessionId;
1030
+ const sessions = typeof scope.sessions === "function" ? scope.sessions() : scope.sessions;
1031
+ const binding = sessions?.binding?.(sessionId);
1032
+ const target = binding?.session;
1033
+ if (target?.prompt === void 0) {
1034
+ console.error("[dsh-git-badge] /gh: no prompt path (binding missing or session-less)", {
1035
+ sessionId,
1036
+ hasSessions: sessions !== void 0,
1037
+ hasBinding: binding !== void 0
1038
+ });
1039
+ return;
1040
+ }
1041
+ const result = await target.prompt([{ type: "text", text: "/gh " + option.args }], "queue");
1042
+ if (result !== void 0 && result !== null && result.ok === false) {
1043
+ console.error("[dsh-git-badge] /gh submit refused:", result.error);
1044
+ }
1045
+ }
1046
+ }
1047
+ }), "dsh-git-badge: /gh contribution");
1048
+ } catch (error) {
1049
+ // a failed registration must degrade to "no /gh entry", never to
1050
+ // a broken commands menu — the (+) button and the / menu are the
1051
+ // host's, and this callback runs inside their boot
1052
+ console.error("[dsh-git-badge] /gh contribution failed:", error);
1053
+ }
1054
+ });
1055
+ }
1056
+
893
1057
  // inject() re-evaluates when a seam's declaration appears, so boot
894
1058
  // order relative to the workspace browser does not matter. On an
895
1059
  // unpatched install the seam is never declared, so this callback never
@@ -971,7 +1135,7 @@ window.__ModuleLoader__.load({
971
1135
  // Additive; the host reads apply/inject and ignores the rest. The suite
972
1136
  // drives these to assert the REQUEST contract — which surface asks for the
973
1137
  // expensive extras — without a browser, a fetch or a network.
974
- exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken };
1138
+ exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions };
975
1139
  return module.exports;
976
1140
  }
977
1141
  });
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.13.0",
3
+ "version": "0.14.1",
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
- "lib/index.js",
50
+ "CHANGELOG.md",
51
+ "LICENSE",
52
+ "README.md",
53
+ "cordis.patch.yml",
51
54
  "lib/client.js",
52
- "seam/apply.js",
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
- "cordis.patch.yml",
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");
@@ -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.