jjhub 0.1.2 → 0.1.4
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 +13 -13
- package/dist/cli/{jjh.mjs → jjhub.mjs} +143 -42
- package/package.json +3 -4
- package/skills/jjhub-cli/SKILL.md +37 -36
- package/skills/jjhub-cli/agents-snippet.md +15 -15
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# jjhub
|
|
1
|
+
# jjhub
|
|
2
2
|
|
|
3
3
|
GitHub CLI with Jujutsu-native superpowers: stable change IDs, stacks,
|
|
4
4
|
platform-wide undo, and conflicts that wait — layered over plain GitHub
|
|
@@ -7,7 +7,7 @@ repos by a [JJHub](https://jjhub.johnhenry.me) server.
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npm install -g jjhub # installs `jjhub`
|
|
10
|
+
npm install -g jjhub # installs the `jjhub` command
|
|
11
11
|
# or one-off:
|
|
12
12
|
npx jjhub --help
|
|
13
13
|
```
|
|
@@ -15,20 +15,20 @@ npx jjhub --help
|
|
|
15
15
|
## Quick start
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
18
|
+
jjhub auth login # device-flow sign-in to your JJHub server
|
|
19
|
+
jjhub repo list # what the server tracks
|
|
20
|
+
jjhub change create "Fix login" # a change with a STABLE id (survives rebases)
|
|
21
|
+
jjhub change list
|
|
22
|
+
jjhub undo # platform-wide undo, from the op log
|
|
23
|
+
jjhub agents-md >> AGENTS.md # teach your repo's coding agents to prefer jjhub
|
|
24
|
+
jjhub mcp serve # stdio MCP server over the same API
|
|
25
|
+
jjhub acp serve # ACP agent (Zed etc.)
|
|
26
|
+
jjhub help # everything else
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
The CLI talks to the JJHub server at `JJHUB_URL` (default
|
|
30
|
-
`http://localhost:3000`) — e.g. `JJHUB_URL=https://jjhub.johnhenry.me
|
|
31
|
-
or use `
|
|
30
|
+
`http://localhost:3000`) — e.g. `JJHUB_URL=https://jjhub.johnhenry.me jjhub repo list`,
|
|
31
|
+
or use `jjhub auth login --url https://jjhub.johnhenry.me` once and the saved
|
|
32
32
|
config takes over.
|
|
33
33
|
|
|
34
34
|
This package is the standalone CLI build (single bundled file, no runtime
|
|
@@ -83,7 +83,7 @@ var init_api_client = __esm({
|
|
|
83
83
|
/**
|
|
84
84
|
* `apiKey` falls back to JJHUB_API_KEY when omitted (the legacy shared
|
|
85
85
|
* secret / ops credential). `opts.bearerToken` is an OAuth access token
|
|
86
|
-
* from `
|
|
86
|
+
* from `jjhub auth login` (issue #43) — when present it wins, sent as
|
|
87
87
|
* `Authorization: Bearer`.
|
|
88
88
|
*/
|
|
89
89
|
constructor(baseUrl, apiKey, opts = {}) {
|
|
@@ -298,11 +298,11 @@ var init_api_client = __esm({
|
|
|
298
298
|
{}
|
|
299
299
|
);
|
|
300
300
|
}
|
|
301
|
-
assessLandRisk(id, changeId2) {
|
|
301
|
+
assessLandRisk(id, changeId2, input) {
|
|
302
302
|
return this.request(
|
|
303
303
|
"POST",
|
|
304
304
|
`/repositories/${encodeURIComponent(id)}/changes/${encodeURIComponent(changeId2)}/land-risk`,
|
|
305
|
-
|
|
305
|
+
input
|
|
306
306
|
);
|
|
307
307
|
}
|
|
308
308
|
listOperations(id) {
|
|
@@ -349,6 +349,16 @@ var init_api_client = __esm({
|
|
|
349
349
|
reportCommit(repoId, changeId2, sha) {
|
|
350
350
|
return this.request("POST", `/repositories/${enc(repoId)}/changes/${enc(changeId2)}/commit`, { sha });
|
|
351
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* A short-lived GitHub push credential for real-commit push, minted
|
|
354
|
+
* server-side from this repo's GitHub App installation. Null means
|
|
355
|
+
* there's nothing to mint (no App configured, or not installed on this
|
|
356
|
+
* repo) — the caller (daemon.ts) treats that as a normal graceful-degrade.
|
|
357
|
+
* May not exist on older servers — callers should catch isMissingEndpoint.
|
|
358
|
+
*/
|
|
359
|
+
getPushCredential(id) {
|
|
360
|
+
return this.request("POST", `/repositories/${enc(id)}/push-credential`);
|
|
361
|
+
}
|
|
352
362
|
// --- deleteBookmark: force is a query param, not a JSON body (see operations.ts) ---
|
|
353
363
|
deleteBookmark(repoId, name, opts) {
|
|
354
364
|
const qs = opts?.force ? "?force=1" : "";
|
|
@@ -59435,9 +59445,12 @@ var init_operations = __esm({
|
|
|
59435
59445
|
jjOverlayManaged: external_exports.boolean().optional().describe("Whether JJHub manages jj-native semantics for this repo."),
|
|
59436
59446
|
stackReviewEnabled: external_exports.boolean().optional().describe("Whether the stack review screen is enabled."),
|
|
59437
59447
|
defaultBookmark: external_exports.string().min(1).optional().describe("Default bookmark name, e.g. 'main'."),
|
|
59438
|
-
contentMode: external_exports.enum(["github", "local"]).optional()
|
|
59448
|
+
contentMode: external_exports.enum(["github", "local"]).optional(),
|
|
59449
|
+
landCheckAppearTimeoutMs: external_exports.number().int().min(0).nullable().optional().describe(
|
|
59450
|
+
"Per-repo override (ms) of how long land waits for a check-run to appear before concluding the repo has no CI. 0 skips the wait entirely. null clears the override (inherits the server-wide default)."
|
|
59451
|
+
)
|
|
59439
59452
|
}),
|
|
59440
|
-
description: "Update a repository's post-creation settings (jjOverlayManaged, stackReviewEnabled, defaultBookmark, contentMode) \u2014 the update dual of createRepository's same-named fields. Omitted fields are left unchanged.",
|
|
59453
|
+
description: "Update a repository's post-creation settings (jjOverlayManaged, stackReviewEnabled, defaultBookmark, contentMode, landCheckAppearTimeoutMs) \u2014 the update dual of createRepository's same-named fields. Omitted fields are left unchanged.",
|
|
59441
59454
|
returns: "Repository"
|
|
59442
59455
|
}),
|
|
59443
59456
|
op({
|
|
@@ -59565,9 +59578,12 @@ var init_operations = __esm({
|
|
|
59565
59578
|
content: external_exports.string().describe("File content \u2014 UTF-8 text, or base64 if encoding='base64'."),
|
|
59566
59579
|
encoding: external_exports.enum(["utf-8", "base64"]).optional().describe("Defaults to 'utf-8'.")
|
|
59567
59580
|
})
|
|
59568
|
-
).describe("Files to write.")
|
|
59581
|
+
).optional().describe("Files to write. May be omitted/empty for a delete-only commit."),
|
|
59582
|
+
deletePaths: external_exports.array(external_exports.string()).optional().describe(
|
|
59583
|
+
"Repo-relative paths to remove, in the same commit as `files`. A path may not appear in both."
|
|
59584
|
+
)
|
|
59569
59585
|
}),
|
|
59570
|
-
description: "Commit one or more files directly onto a change's projected GitHub branch.",
|
|
59586
|
+
description: "Commit one or more files directly onto a change's projected GitHub branch \u2014 write, delete, or both in one commit.",
|
|
59571
59587
|
returns: "Change"
|
|
59572
59588
|
}),
|
|
59573
59589
|
op({
|
|
@@ -59749,7 +59765,10 @@ var init_operations = __esm({
|
|
|
59749
59765
|
method: "POST",
|
|
59750
59766
|
pathParams: ["id", "changeId"],
|
|
59751
59767
|
path: ([id, changeId2]) => `/repositories/${id}/changes/${changeId2}/land-risk`,
|
|
59752
|
-
|
|
59768
|
+
// narrative:false = deterministic-only (skips the AI narrator entirely) —
|
|
59769
|
+
// the web land modal uses it so risk renders in ~2 GitHub round trips
|
|
59770
|
+
// instead of waiting up to the AI provider timeout.
|
|
59771
|
+
body: external_exports.object({ narrative: external_exports.boolean().optional() }),
|
|
59753
59772
|
description: "Deterministic land-risk assessment (checks/conflicts/restack/diff-size signals) with an optional AI narrative. Works without an AI provider; read-only.",
|
|
59754
59773
|
returns: '{ riskLevel: "low" | "medium" | "high"; reasons: string[]; signals: Record<string, unknown>; narrative: string | null; model: string | null }'
|
|
59755
59774
|
}),
|
|
@@ -64491,6 +64510,30 @@ var init_acp = __esm({
|
|
|
64491
64510
|
}
|
|
64492
64511
|
});
|
|
64493
64512
|
|
|
64513
|
+
// src/domain/issue-refs.ts
|
|
64514
|
+
function extractClosedIssueNumbers(description) {
|
|
64515
|
+
const numbers = /* @__PURE__ */ new Set();
|
|
64516
|
+
for (const m of description.matchAll(CLOSING_KEYWORD_RE)) {
|
|
64517
|
+
const n = Number(m[1]);
|
|
64518
|
+
if (Number.isInteger(n)) numbers.add(n);
|
|
64519
|
+
}
|
|
64520
|
+
return [...numbers];
|
|
64521
|
+
}
|
|
64522
|
+
function describesClosingIssue(description, issueNumber) {
|
|
64523
|
+
return extractClosedIssueNumbers(description).includes(issueNumber);
|
|
64524
|
+
}
|
|
64525
|
+
function findChangeClosingIssue(items, issueNumber) {
|
|
64526
|
+
const matches = items.filter((item) => describesClosingIssue(item.description, issueNumber));
|
|
64527
|
+
return matches.length === 1 ? matches[0] : null;
|
|
64528
|
+
}
|
|
64529
|
+
var CLOSING_KEYWORD_RE;
|
|
64530
|
+
var init_issue_refs = __esm({
|
|
64531
|
+
"src/domain/issue-refs.ts"() {
|
|
64532
|
+
"use strict";
|
|
64533
|
+
CLOSING_KEYWORD_RE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)\b/gi;
|
|
64534
|
+
}
|
|
64535
|
+
});
|
|
64536
|
+
|
|
64494
64537
|
// src/acp/commands.ts
|
|
64495
64538
|
function toSnakeCase2(name) {
|
|
64496
64539
|
return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
@@ -64689,12 +64732,15 @@ async function serveAcp(api, opts = {}) {
|
|
|
64689
64732
|
process.once("SIGINT", resolve);
|
|
64690
64733
|
});
|
|
64691
64734
|
}
|
|
64692
|
-
var JJHubAcpAgent;
|
|
64735
|
+
var CHANGE_ID_RE, ISSUE_REF_RE, JJHubAcpAgent;
|
|
64693
64736
|
var init_agent = __esm({
|
|
64694
64737
|
"src/acp/agent.ts"() {
|
|
64695
64738
|
"use strict";
|
|
64696
64739
|
init_acp();
|
|
64740
|
+
init_issue_refs();
|
|
64697
64741
|
init_commands();
|
|
64742
|
+
CHANGE_ID_RE = /^CHG-\d+$/i;
|
|
64743
|
+
ISSUE_REF_RE = /^#?(\d+)$/;
|
|
64698
64744
|
JJHubAcpAgent = class {
|
|
64699
64745
|
constructor(api, conn, opts = {}) {
|
|
64700
64746
|
this.api = api;
|
|
@@ -64727,7 +64773,7 @@ var init_agent = __esm({
|
|
|
64727
64773
|
});
|
|
64728
64774
|
return { sessionId };
|
|
64729
64775
|
}
|
|
64730
|
-
/** Credentials come from ~/.config/jjhub (
|
|
64776
|
+
/** Credentials come from ~/.config/jjhub (jjhub auth login) — nothing to do in-protocol. */
|
|
64731
64777
|
authenticate() {
|
|
64732
64778
|
}
|
|
64733
64779
|
async prompt(params) {
|
|
@@ -64749,6 +64795,22 @@ var init_agent = __esm({
|
|
|
64749
64795
|
await this.say(sessionId, `error: ${bound.error}`);
|
|
64750
64796
|
return { stopReason: "end_turn" };
|
|
64751
64797
|
}
|
|
64798
|
+
const changeIdIdx = cmd.pathParams.indexOf("changeId");
|
|
64799
|
+
if (changeIdIdx !== -1) {
|
|
64800
|
+
const rawChangeId = bound.pathArgs[changeIdIdx];
|
|
64801
|
+
const issueMatch = CHANGE_ID_RE.test(rawChangeId) ? null : rawChangeId.match(ISSUE_REF_RE);
|
|
64802
|
+
if (issueMatch) {
|
|
64803
|
+
const issueNumber = Number(issueMatch[1]);
|
|
64804
|
+
const repositoryIdIdx = cmd.pathParams.indexOf("repositoryId");
|
|
64805
|
+
const repositoryId2 = repositoryIdIdx !== -1 ? bound.pathArgs[repositoryIdIdx] : void 0;
|
|
64806
|
+
const resolved = repositoryId2 ? await this.resolveIssueReference(repositoryId2, issueNumber) : null;
|
|
64807
|
+
if (!resolved) {
|
|
64808
|
+
await this.say(sessionId, `couldn't find a change that closes issue #${issueNumber} in this repo`);
|
|
64809
|
+
return { stopReason: "end_turn" };
|
|
64810
|
+
}
|
|
64811
|
+
bound.pathArgs[changeIdIdx] = resolved.changeId;
|
|
64812
|
+
}
|
|
64813
|
+
}
|
|
64752
64814
|
if (cmd.requiresForceConfirmation && bound.body.force === true) {
|
|
64753
64815
|
const confirmed = await this.confirmForce(sessionId, cmd, bound.pathArgs);
|
|
64754
64816
|
if (this.cancelled.has(sessionId)) return { stopReason: "cancelled" };
|
|
@@ -64838,6 +64900,17 @@ var init_agent = __esm({
|
|
|
64838
64900
|
return true;
|
|
64839
64901
|
}
|
|
64840
64902
|
}
|
|
64903
|
+
/**
|
|
64904
|
+
* Resolve "#123" (or "123") to the change whose description closes that
|
|
64905
|
+
* issue, using only data the agent already fetches over its normal API
|
|
64906
|
+
* surface (list_changes) — no new endpoint or GitHub permission needed.
|
|
64907
|
+
* Ambiguous (>1 match) or absent (0 matches) both resolve to null; the
|
|
64908
|
+
* caller must not guess.
|
|
64909
|
+
*/
|
|
64910
|
+
async resolveIssueReference(repositoryId2, issueNumber) {
|
|
64911
|
+
const changes = await this.api.listChanges(repositoryId2);
|
|
64912
|
+
return findChangeClosingIssue(changes, issueNumber);
|
|
64913
|
+
}
|
|
64841
64914
|
say(sessionId, text) {
|
|
64842
64915
|
return this.conn.sessionUpdate({
|
|
64843
64916
|
sessionId,
|
|
@@ -93318,7 +93391,7 @@ async function resolveRepoId(api, repoFlag, resolveContext) {
|
|
|
93318
93391
|
const match = repos.find((r) => r.fullName === fullName);
|
|
93319
93392
|
if (!match) {
|
|
93320
93393
|
throw new Error(
|
|
93321
|
-
`no JJHub repository for ${fullName} \u2014 run \`
|
|
93394
|
+
`no JJHub repository for ${fullName} \u2014 run \`jjhub repo init${repoFlag ? ` ${fullName}` : ""}\` to create it`
|
|
93322
93395
|
);
|
|
93323
93396
|
}
|
|
93324
93397
|
return match.id;
|
|
@@ -93417,7 +93490,7 @@ async function runDaemonOnce(jj, api, repoId, opts = {}) {
|
|
|
93417
93490
|
defaultBookmark = (await api.getRepository(repoId)).defaultBookmark;
|
|
93418
93491
|
} catch {
|
|
93419
93492
|
}
|
|
93420
|
-
const pushEnabled = opts.pushCommits ??
|
|
93493
|
+
const pushEnabled = opts.pushCommits ?? true;
|
|
93421
93494
|
let pusher = pushEnabled ? void 0 : null;
|
|
93422
93495
|
const resolvePusher = async () => {
|
|
93423
93496
|
if (pusher !== void 0) return pusher;
|
|
@@ -93426,16 +93499,38 @@ async function runDaemonOnce(jj, api, repoId, opts = {}) {
|
|
|
93426
93499
|
pusher = opts.pushCommit;
|
|
93427
93500
|
return pusher;
|
|
93428
93501
|
}
|
|
93429
|
-
const token = process.env.GITHUB_TOKEN;
|
|
93430
93502
|
try {
|
|
93431
93503
|
const repo = await api.getRepository(repoId);
|
|
93432
|
-
if (
|
|
93433
|
-
|
|
93434
|
-
|
|
93504
|
+
if (!opts.repoPath) {
|
|
93505
|
+
log("[daemon] commit push skipped: no local repo path");
|
|
93506
|
+
return pusher;
|
|
93507
|
+
}
|
|
93508
|
+
if (!GITHUB_FULLNAME_RE.test(repo.fullName)) {
|
|
93509
|
+
log(
|
|
93510
|
+
`[daemon] commit push skipped: repo fullName "${repo.fullName}" does not look like a GitHub repo`
|
|
93511
|
+
);
|
|
93512
|
+
return pusher;
|
|
93513
|
+
}
|
|
93514
|
+
let credential;
|
|
93515
|
+
try {
|
|
93516
|
+
credential = await api.getPushCredential(repoId);
|
|
93517
|
+
} catch (err) {
|
|
93518
|
+
if (isMissingEndpoint(err)) {
|
|
93519
|
+
log(
|
|
93520
|
+
"[daemon] commit push skipped: server does not support push credentials yet (upgrade the server)"
|
|
93521
|
+
);
|
|
93522
|
+
} else {
|
|
93523
|
+
log(`[daemon] commit push disabled: ${err instanceof Error ? err.message : String(err)}`);
|
|
93524
|
+
}
|
|
93525
|
+
return pusher;
|
|
93526
|
+
}
|
|
93527
|
+
if (!credential) {
|
|
93435
93528
|
log(
|
|
93436
|
-
|
|
93529
|
+
"[daemon] commit push skipped: install the JJHub GitHub App on this repository to enable real-commit push"
|
|
93437
93530
|
);
|
|
93531
|
+
return pusher;
|
|
93438
93532
|
}
|
|
93533
|
+
pusher = makeGitHubPushCommit(opts.repoPath, repo.fullName, credential.token);
|
|
93439
93534
|
} catch (err) {
|
|
93440
93535
|
log(`[daemon] commit push disabled: ${err instanceof Error ? err.message : String(err)}`);
|
|
93441
93536
|
}
|
|
@@ -93631,7 +93726,7 @@ function isProcessAlive(pid) {
|
|
|
93631
93726
|
}
|
|
93632
93727
|
}
|
|
93633
93728
|
var defaultSpawnFn = (command, args, options) => spawn(command, args, options);
|
|
93634
|
-
var CLI_ENTRY = fileURLToPath(new URL("../../bin/
|
|
93729
|
+
var CLI_ENTRY = fileURLToPath(new URL("../../bin/jjhub.mjs", import.meta.url));
|
|
93635
93730
|
function spawnDaemon(opts) {
|
|
93636
93731
|
const args = [
|
|
93637
93732
|
CLI_ENTRY,
|
|
@@ -93715,9 +93810,9 @@ var defaultSpawnGh = (argv) => new Promise((resolve, reject) => {
|
|
|
93715
93810
|
});
|
|
93716
93811
|
|
|
93717
93812
|
// src/cli/index.ts
|
|
93718
|
-
var USAGE = `
|
|
93813
|
+
var USAGE = `jjhub \u2014 GitHub CLI with Jujutsu-native superpowers (JJHub)
|
|
93719
93814
|
|
|
93720
|
-
Usage:
|
|
93815
|
+
Usage: jjhub <command> [args] [--repo owner/name|repoId] [--json]
|
|
93721
93816
|
|
|
93722
93817
|
repo init [owner/name] [--github-token pat]
|
|
93723
93818
|
create the JJHub overlay for this repo
|
|
@@ -93730,7 +93825,7 @@ Usage: jjh <command> [args] [--repo owner/name|repoId] [--json]
|
|
|
93730
93825
|
repo delete hard-delete (ops credential only \u2014 archive is the normal path)
|
|
93731
93826
|
|
|
93732
93827
|
user list list known users (identity is GitHub-federated;
|
|
93733
|
-
sign in with "
|
|
93828
|
+
sign in with "jjhub auth login")
|
|
93734
93829
|
|
|
93735
93830
|
change new <title> [--parent CHG-x] [--stack id]
|
|
93736
93831
|
change list
|
|
@@ -93741,8 +93836,9 @@ Usage: jjh <command> [args] [--repo owner/name|repoId] [--json]
|
|
|
93741
93836
|
change abandon <changeId>
|
|
93742
93837
|
change restack <changeId> rebase a needs-restack change onto its rewritten ancestor
|
|
93743
93838
|
change land <changeId> [--force] --force bypasses a failing/pending check-run gate
|
|
93744
|
-
change commit <changeId> <file...> [-m msg]
|
|
93745
|
-
commit working-tree files onto the change's branch
|
|
93839
|
+
change commit <changeId> [<file...>] [-m msg] [--delete <path>]...
|
|
93840
|
+
commit working-tree files onto the change's branch;
|
|
93841
|
+
--delete removes a path in the same commit (repeatable)
|
|
93746
93842
|
change diff <changeId> per-file diffs of what the change contains
|
|
93747
93843
|
change split <changeId> <newTitle>
|
|
93748
93844
|
change checks <changeId> list GitHub check runs for a change
|
|
@@ -93777,7 +93873,7 @@ Usage: jjh <command> [args] [--repo owner/name|repoId] [--json]
|
|
|
93777
93873
|
slash-commands for ACP clients (Zed's agent panel, ...)
|
|
93778
93874
|
|
|
93779
93875
|
agents-md print an AGENTS.md section teaching coding agents to
|
|
93780
|
-
prefer
|
|
93876
|
+
prefer jjhub over raw git/gh (jjhub agents-md >> AGENTS.md)
|
|
93781
93877
|
|
|
93782
93878
|
sync push overlay state to GitHub
|
|
93783
93879
|
|
|
@@ -93800,9 +93896,9 @@ Usage: jjh <command> [args] [--repo owner/name|repoId] [--json]
|
|
|
93800
93896
|
spawn the same sync as a detached background process
|
|
93801
93897
|
daemon stop [alias]
|
|
93802
93898
|
daemon status [alias]
|
|
93803
|
-
omit alias to list every repo configured via "
|
|
93899
|
+
omit alias to list every repo configured via "jjhub daemon start"
|
|
93804
93900
|
|
|
93805
|
-
Anything else (pr, issue, ...) is passed through to gh. (
|
|
93901
|
+
Anything else (pr, issue, ...) is passed through to gh. (jjhub's own "auth" command
|
|
93806
93902
|
signs into the JJHub server \u2014 it uses your GitHub identity via the server's
|
|
93807
93903
|
GitHub App, but is separate from "gh auth".)
|
|
93808
93904
|
|
|
@@ -93860,7 +93956,8 @@ async function runCli(argv, opts = {}) {
|
|
|
93860
93956
|
"two-way": { type: "boolean", default: false },
|
|
93861
93957
|
members: { type: "string" },
|
|
93862
93958
|
"github-token": { type: "string" },
|
|
93863
|
-
force: { type: "boolean", default: false }
|
|
93959
|
+
force: { type: "boolean", default: false },
|
|
93960
|
+
delete: { type: "string", multiple: true }
|
|
93864
93961
|
}
|
|
93865
93962
|
});
|
|
93866
93963
|
positionals = parsed.positionals;
|
|
@@ -93937,7 +94034,7 @@ async function dispatch(ctx, pos, values) {
|
|
|
93937
94034
|
const requiredBearer = process.env.JJHUB_API_KEY ?? config2.apiKey ?? config2.token;
|
|
93938
94035
|
if (!requiredBearer) {
|
|
93939
94036
|
ctx.err(
|
|
93940
|
-
"warning: no credential configured (JJHUB_API_KEY /
|
|
94037
|
+
"warning: no credential configured (JJHUB_API_KEY / jjhub auth login) \u2014 serving MCP over HTTP without authentication; do not expose this port publicly"
|
|
93941
94038
|
);
|
|
93942
94039
|
}
|
|
93943
94040
|
const server = await serveMcpHttp2(ctx.api, port, { requiredBearer, log: ctx.out });
|
|
@@ -93992,7 +94089,7 @@ async function repoCmd(ctx, pos, values) {
|
|
|
93992
94089
|
const inferred = await ctx.resolveContext();
|
|
93993
94090
|
if (!inferred) {
|
|
93994
94091
|
ctx.err(
|
|
93995
|
-
"error: could not infer a GitHub repository from the current directory \u2014 run `
|
|
94092
|
+
"error: could not infer a GitHub repository from the current directory \u2014 run `jjhub repo init <owner/name>`"
|
|
93996
94093
|
);
|
|
93997
94094
|
return 1;
|
|
93998
94095
|
}
|
|
@@ -94034,7 +94131,7 @@ async function repoCmd(ctx, pos, values) {
|
|
|
94034
94131
|
}
|
|
94035
94132
|
if (sub === "members") {
|
|
94036
94133
|
ctx.err(
|
|
94037
|
-
"error: `
|
|
94134
|
+
"error: `jjhub repo members` was removed \u2014 repo access now derives live from GitHub collaborator permissions (manage collaborators on GitHub itself; see issue #43)"
|
|
94038
94135
|
);
|
|
94039
94136
|
return 1;
|
|
94040
94137
|
}
|
|
@@ -94059,7 +94156,7 @@ async function userCmd(ctx, pos) {
|
|
|
94059
94156
|
}
|
|
94060
94157
|
if (sub === "create" || sub === "token" || sub === "tokens" || sub === "revoke-token") {
|
|
94061
94158
|
ctx.err(
|
|
94062
|
-
`error: \`
|
|
94159
|
+
`error: \`jjhub user ${sub}\` was removed \u2014 identity is GitHub-federated now. Sign in with \`jjhub auth login\` (device flow); tokens come from the OAuth flow (issue #43).`
|
|
94063
94160
|
);
|
|
94064
94161
|
return 1;
|
|
94065
94162
|
}
|
|
@@ -94175,19 +94272,23 @@ async function changeCmd(ctx, pos, values) {
|
|
|
94175
94272
|
}
|
|
94176
94273
|
case "commit": {
|
|
94177
94274
|
const [changeId2, ...paths] = rest;
|
|
94178
|
-
|
|
94179
|
-
|
|
94275
|
+
const deletePaths = values.delete;
|
|
94276
|
+
if (!changeId2 || paths.length === 0 && (!deletePaths || deletePaths.length === 0)) {
|
|
94277
|
+
return usageError(ctx, "change commit <changeId> [<file...>] [-m message] [--delete <path>]...");
|
|
94180
94278
|
}
|
|
94181
94279
|
const { readFileSync: readFileSync4 } = await import("node:fs");
|
|
94182
94280
|
const files = paths.map((p) => ({ path: p, content: readFileSync4(p, "utf-8") }));
|
|
94183
94281
|
const change = await ctx.api.commitFiles(await ctx.repoId(), changeId2, {
|
|
94184
94282
|
message: values.message,
|
|
94185
|
-
files
|
|
94283
|
+
files,
|
|
94284
|
+
deletePaths
|
|
94186
94285
|
});
|
|
94187
94286
|
if (ctx.json) return emitJson(ctx, change);
|
|
94188
|
-
|
|
94189
|
-
`
|
|
94190
|
-
|
|
94287
|
+
const parts = [
|
|
94288
|
+
files.length > 0 ? `wrote ${files.length} file${files.length === 1 ? "" : "s"}` : null,
|
|
94289
|
+
deletePaths && deletePaths.length > 0 ? `deleted ${deletePaths.length} file${deletePaths.length === 1 ? "" : "s"}` : null
|
|
94290
|
+
].filter((p) => p !== null);
|
|
94291
|
+
ctx.out(`${parts.join(", ")} in ${change.changeId} (commit ${change.currentCommitSha})`);
|
|
94191
94292
|
return 0;
|
|
94192
94293
|
}
|
|
94193
94294
|
case "diff": {
|
|
@@ -94525,7 +94626,7 @@ async function authCmd(ctx, pos, values) {
|
|
|
94525
94626
|
throw e;
|
|
94526
94627
|
}
|
|
94527
94628
|
}
|
|
94528
|
-
ctx.err("error: sign-in timed out \u2014 run `
|
|
94629
|
+
ctx.err("error: sign-in timed out \u2014 run `jjhub auth login` again");
|
|
94529
94630
|
return 1;
|
|
94530
94631
|
}
|
|
94531
94632
|
if (sub === "sessions") {
|
|
@@ -94582,7 +94683,7 @@ async function authCmd(ctx, pos, values) {
|
|
|
94582
94683
|
});
|
|
94583
94684
|
}
|
|
94584
94685
|
ctx.out(
|
|
94585
|
-
`connected to ${ctx.api.baseUrl} (${repos.length} repositor${repos.length === 1 ? "y" : "ies"}, no user identity \u2014 sign in with \`
|
|
94686
|
+
`connected to ${ctx.api.baseUrl} (${repos.length} repositor${repos.length === 1 ? "y" : "ies"}, no user identity \u2014 sign in with \`jjhub auth login\`)`
|
|
94586
94687
|
);
|
|
94587
94688
|
return 0;
|
|
94588
94689
|
} catch {
|
|
@@ -94669,7 +94770,7 @@ async function resolveDaemonTarget(ctx, aliasOrPath, registerIfMissing) {
|
|
|
94669
94770
|
const entry = config2.repos?.[aliasOrPath];
|
|
94670
94771
|
if (!entry) {
|
|
94671
94772
|
return {
|
|
94672
|
-
error: `no configured repo alias "${aliasOrPath}" \u2014 run \`
|
|
94773
|
+
error: `no configured repo alias "${aliasOrPath}" \u2014 run \`jjhub daemon start <path>\` from that repo directory first`
|
|
94673
94774
|
};
|
|
94674
94775
|
}
|
|
94675
94776
|
return { repositoryId: entry.repositoryId, localPath: entry.localPath, alias: aliasOrPath };
|
|
@@ -94759,7 +94860,7 @@ async function daemonStatusCmd(ctx, rest) {
|
|
|
94759
94860
|
ctx.err(`error: no configured repo alias "${aliasOrPath}"`);
|
|
94760
94861
|
return 1;
|
|
94761
94862
|
}
|
|
94762
|
-
ctx.out("no repos configured \u2014 run `
|
|
94863
|
+
ctx.out("no repos configured \u2014 run `jjhub daemon start` from a repo directory first");
|
|
94763
94864
|
return 0;
|
|
94764
94865
|
}
|
|
94765
94866
|
const rows = [];
|
|
@@ -94842,7 +94943,7 @@ function emitJson(ctx, value) {
|
|
|
94842
94943
|
return 0;
|
|
94843
94944
|
}
|
|
94844
94945
|
function usageError(ctx, usage) {
|
|
94845
|
-
ctx.err(`usage:
|
|
94946
|
+
ctx.err(`usage: jjhub ${usage}`);
|
|
94846
94947
|
return 1;
|
|
94847
94948
|
}
|
|
94848
94949
|
var invokedDirectly = (() => {
|
package/package.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jjhub",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "jjhub — GitHub CLI with Jujutsu-native superpowers: stable change IDs, stacks, platform-wide undo, and conflicts that wait, layered over plain GitHub repos by a JJHub server.",
|
|
5
5
|
"bin": {
|
|
6
|
-
"jjhub": "./dist/cli/
|
|
7
|
-
"jjh": "./dist/cli/jjh.mjs"
|
|
6
|
+
"jjhub": "./dist/cli/jjhub.mjs"
|
|
8
7
|
},
|
|
9
8
|
"type": "module",
|
|
10
9
|
"engines": {
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: jjhub-cli
|
|
3
|
-
description: Drive a running JJHub instance from the command line with the `
|
|
3
|
+
description: Drive a running JJHub instance from the command line with the `jjhub` CLI — create and land changes, manage stacks and bookmarks, resolve conflicts, and undo/redo via the operation log. Use when scripting or automating repository operations against a JJHub server instead of the web UI.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# jjhub — the JJHub CLI
|
|
7
7
|
|
|
8
|
-
`
|
|
8
|
+
`jjhub` is a GitHub CLI (`gh`)-like wrapper with Jujutsu-native superpowers. It is a thin
|
|
9
9
|
client over JJHub's HTTP API: every command reads or mutates state on a running JJHub
|
|
10
10
|
server, the same server backing the web UI. Repo context is inferred from the current
|
|
11
11
|
directory's git `origin` remote (override with `--repo`), and any top-level command that
|
|
12
|
-
isn't one of `
|
|
12
|
+
isn't one of `jjhub`'s own nouns is passed straight through to the real `gh` binary.
|
|
13
13
|
|
|
14
14
|
## Setup
|
|
15
15
|
|
|
@@ -22,12 +22,12 @@ tests, requirements). This section covers just the CLI.
|
|
|
22
22
|
2. **Point the CLI at it**: `export JJHUB_URL=http://localhost:3000` (this is the default,
|
|
23
23
|
so only needed if the server is elsewhere). If the server was started with
|
|
24
24
|
`JJHUB_API_KEY` set, also `export JJHUB_API_KEY=<the same key>` — every request will
|
|
25
|
-
otherwise get a `401 unauthorized`. (`
|
|
25
|
+
otherwise get a `401 unauthorized`. (`jjhub auth login` persists these to a config file
|
|
26
26
|
instead of needing them exported every shell — see "Server connection" below.)
|
|
27
|
-
3. **Invoke commands**: `npm run
|
|
28
|
-
directly if installed as a binary (`bin/
|
|
27
|
+
3. **Invoke commands**: `npm run jjhub -- <command> [args]` in development, or `jjhub <command>`
|
|
28
|
+
directly if installed as a binary (`bin/jjhub.mjs`).
|
|
29
29
|
4. **Initialize the overlay** for a repo before using any other command:
|
|
30
|
-
`
|
|
30
|
+
`jjhub repo init [owner/name]` (infers `owner/name` from the cwd's git origin if omitted).
|
|
31
31
|
|
|
32
32
|
The same JJHub server also backs a web UI (`npm run web:dev` from the repo root, in a
|
|
33
33
|
second terminal — see [`web/README.md`](../../web/README.md)); the CLI and the web UI are
|
|
@@ -59,8 +59,9 @@ change amend <changeId>
|
|
|
59
59
|
change squash <changeId>
|
|
60
60
|
change abandon <changeId>
|
|
61
61
|
change restack <changeId> rebase a needs-restack change onto its rewritten ancestor
|
|
62
|
-
change commit <changeId> <file...> [-m msg]
|
|
63
|
-
commit working-tree files onto the change's branch
|
|
62
|
+
change commit <changeId> [<file...>] [-m msg] [--delete <path>]...
|
|
63
|
+
commit working-tree files onto the change's branch;
|
|
64
|
+
--delete removes a path in the same commit (repeatable)
|
|
64
65
|
change diff <changeId> per-file diffs of what the change contains
|
|
65
66
|
change files <changeId> list locally-stored files (contentMode "local" repos only)
|
|
66
67
|
change land <changeId> [--force] waits for real CI triggered by its own ready flip,
|
|
@@ -104,7 +105,7 @@ mcp serve --http <port> the same tools over Streamable HTTP (POST
|
|
|
104
105
|
acp serve Agent Client Protocol agent over stdio (Zed's agent
|
|
105
106
|
panel, ...) — every operation as a slash command
|
|
106
107
|
agents-md print an AGENTS.md section teaching coding agents to
|
|
107
|
-
prefer
|
|
108
|
+
prefer jjhub over raw git/gh (jjhub agents-md >> AGENTS.md)
|
|
108
109
|
|
|
109
110
|
daemon <localRepoPath> [--once] [--interval 5] [--engine auto|subprocess|isomorphic] [--two-way]
|
|
110
111
|
sync a local jj working copy into JJHub (inbound; --two-way mirrors JJHub edits back)
|
|
@@ -113,22 +114,22 @@ daemon start [alias|localRepoPath] [--interval 5] [--engine ...] [--two-way]
|
|
|
113
114
|
daemon stop [alias] | daemon status [alias]
|
|
114
115
|
```
|
|
115
116
|
|
|
116
|
-
Anything else (`pr`, `issue`, ...) is passed through to `gh` — e.g. `
|
|
117
|
-
is
|
|
117
|
+
Anything else (`pr`, `issue`, ...) is passed through to `gh` — e.g. `jjhub pr view 7`. `jjhub auth`
|
|
118
|
+
is jjhub's own command (it manages the *JJHub server* connection); use `gh auth` for GitHub auth.
|
|
118
119
|
|
|
119
120
|
### Server connection: flags, env vars, or a config file
|
|
120
121
|
|
|
121
122
|
Precedence is `--flag` > env var (`JJHUB_URL`/`JJHUB_API_KEY`) > `~/.config/jjhub/config.json`
|
|
122
|
-
(written by `
|
|
123
|
+
(written by `jjhub auth login`). Run `jjhub auth login --url http://localhost:3000 --api-key <key>`
|
|
123
124
|
once and every subsequent command in that shell (any shell, any day) picks it up with no env
|
|
124
|
-
vars needed. `
|
|
125
|
+
vars needed. `jjhub auth status` confirms what's currently in effect.
|
|
125
126
|
|
|
126
127
|
### Identity & permissions (issue #43: GitHub-federated)
|
|
127
128
|
|
|
128
|
-
A JJHub user IS a GitHub account. `
|
|
129
|
+
A JJHub user IS a GitHub account. `jjhub auth login` runs an OAuth device flow against the
|
|
129
130
|
JJHub server's own authorization server: open the printed URL, sign in with GitHub, confirm
|
|
130
131
|
the code, and the CLI saves a JJHub-issued OAuth token. Every mutation is attributed to that
|
|
131
|
-
identity (visible in `
|
|
132
|
+
identity (visible in `jjhub op log`).
|
|
132
133
|
|
|
133
134
|
What you can do on a repo is asked of GitHub LIVE at request time (cached ~60s):
|
|
134
135
|
GitHub `admin` → owner, `maintain`/`write` → writer, `triage`/`read` → reader. There is no
|
|
@@ -142,26 +143,26 @@ credential: full access, RBAC bypassed, no attribution. Anonymous requests (no s
|
|
|
142
143
|
|
|
143
144
|
This RBAC is entirely JJHub-internal — a JJHub "owner" isn't checked against real GitHub
|
|
144
145
|
permissions at all. If the server has `JJHUB_REQUIRE_GITHUB_VERIFICATION=1` set,
|
|
145
|
-
`
|
|
146
|
+
`jjhub repo init` requires `--github-token <pat>` (or falls back to `$GITHUB_TOKEN`): the
|
|
146
147
|
server verifies that token has real `admin` access to the exact `owner/name` being created
|
|
147
148
|
via a live GitHub API call, rejecting creation (403) otherwise. The verified token also
|
|
148
149
|
becomes the new repo's GitHub connection automatically. See the top-level README's "GitHub
|
|
149
150
|
admin verification (optional mode)" section.
|
|
150
151
|
|
|
151
|
-
For exact, always-current syntax straight from the CLI binary, run `
|
|
152
|
+
For exact, always-current syntax straight from the CLI binary, run `jjhub help` — this file is
|
|
152
153
|
an expanded, example-rich companion to that output, not a replacement for it.
|
|
153
154
|
|
|
154
|
-
### Install into your repo (teach YOUR coding agents to prefer
|
|
155
|
+
### Install into your repo (teach YOUR coding agents to prefer jjhub)
|
|
155
156
|
|
|
156
157
|
[`agents-snippet.md`](./agents-snippet.md) is a short, imperative AGENTS.md
|
|
157
|
-
section — "version control in this repo goes through JJHub; prefer `
|
|
158
|
+
section — "version control in this repo goes through JJHub; prefer `jjhub
|
|
158
159
|
change/stack/undo` over raw git/gh mutations" — the same pattern as GitHub's
|
|
159
160
|
`gh skill install github/gh-stack`. Append it to a tracked repo's own
|
|
160
161
|
AGENTS.md so any AGENTS.md-aware coding agent (Claude Code, Copilot, Cursor,
|
|
161
162
|
Codex, ...) picks up the workflow automatically:
|
|
162
163
|
|
|
163
164
|
```bash
|
|
164
|
-
|
|
165
|
+
jjhub agents-md >> AGENTS.md # from the CLI
|
|
165
166
|
# or fetch it from a running server:
|
|
166
167
|
curl https://<your-jjhub>/.well-known/skills/jjhub-cli/agents-snippet.md >> AGENTS.md
|
|
167
168
|
```
|
|
@@ -171,14 +172,14 @@ curl https://<your-jjhub>/.well-known/skills/jjhub-cli/agents-snippet.md >> AGEN
|
|
|
171
172
|
### Create and land a change
|
|
172
173
|
|
|
173
174
|
```bash
|
|
174
|
-
npm run
|
|
175
|
-
npm run
|
|
176
|
-
npm run
|
|
177
|
-
npm run
|
|
178
|
-
npm run
|
|
175
|
+
npm run jjhub -- repo init owner/name
|
|
176
|
+
npm run jjhub -- change new "Fix: add user validation"
|
|
177
|
+
npm run jjhub -- change list # note the CHG-n id
|
|
178
|
+
npm run jjhub -- stack create "Fix bundle" --changes CHG-1
|
|
179
|
+
npm run jjhub -- stack land "Fix bundle"
|
|
179
180
|
```
|
|
180
181
|
|
|
181
|
-
A single change not part of a stack lands directly: `npm run
|
|
182
|
+
A single change not part of a stack lands directly: `npm run jjhub -- change land CHG-1`.
|
|
182
183
|
|
|
183
184
|
### Sync a local jj working copy into JJHub
|
|
184
185
|
|
|
@@ -188,22 +189,22 @@ jj bookmark track main --remote=origin # REQUIRED once per clone (issue #28):
|
|
|
188
189
|
# without it, already-landed commits look
|
|
189
190
|
# like new local work and the daemon could
|
|
190
191
|
# try to re-project them
|
|
191
|
-
npm run
|
|
192
|
+
npm run jjhub -- repo init alice/my-project
|
|
192
193
|
jj new -m "Implement feature X" && jj commit
|
|
193
|
-
npm run
|
|
194
|
-
npm run
|
|
194
|
+
npm run jjhub -- daemon . --once # one-shot sync; omit --once to loop every 5s
|
|
195
|
+
npm run jjhub -- op log # verify the sync landed as a new operation
|
|
195
196
|
```
|
|
196
197
|
|
|
197
198
|
### Resolve a conflict
|
|
198
199
|
|
|
199
200
|
```bash
|
|
200
|
-
npm run
|
|
201
|
-
npm run
|
|
202
|
-
npm run
|
|
201
|
+
npm run jjhub -- conflict list # note the conflict id
|
|
202
|
+
npm run jjhub -- conflict resolve CONFLICT-abc123 left # or: right | edited | preserve_unresolved
|
|
203
|
+
npm run jjhub -- conflict list # confirm it now reads Resolved
|
|
203
204
|
```
|
|
204
205
|
|
|
205
206
|
## Undo/redo caution
|
|
206
207
|
|
|
207
|
-
`
|
|
208
|
+
`jjhub undo` and `jjhub redo` move the repository's operation-log head pointer — they affect
|
|
208
209
|
*all* state (changes, stacks, bookmarks, conflicts), not just the last command's target.
|
|
209
|
-
Run `
|
|
210
|
+
Run `jjhub op log` first if you're not sure what the current head operation is before undoing.
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
# Version control in this repo goes through JJHub
|
|
2
2
|
|
|
3
3
|
This repository is tracked by JJHub (a change/stack/operation overlay on
|
|
4
|
-
GitHub). Use the `
|
|
5
|
-
`git`/`gh` — every `
|
|
6
|
-
reversible with `
|
|
4
|
+
GitHub). Use the `jjhub` CLI for version-control mutations instead of raw
|
|
5
|
+
`git`/`gh` — every `jjhub` action is attributed in an operation log and
|
|
6
|
+
reversible with `jjhub undo`, and landing is CI-gated.
|
|
7
7
|
|
|
8
|
-
- Start work: `
|
|
9
|
-
- Describe/retitle: `
|
|
10
|
-
- Amend after edits: `
|
|
8
|
+
- Start work: `jjhub change new "<title>"` (not a git branch).
|
|
9
|
+
- Describe/retitle: `jjhub change describe CHG-N --title t --description d`.
|
|
10
|
+
- Amend after edits: `jjhub change amend CHG-N` (descendants get flagged and
|
|
11
11
|
restack with one command — no force-push ceremony).
|
|
12
|
-
- Stacked work: `
|
|
13
|
-
reorder with `
|
|
14
|
-
`
|
|
15
|
-
- Ship a single change: `
|
|
12
|
+
- Stacked work: `jjhub stack create "<title>" --changes CHG-1,CHG-2`;
|
|
13
|
+
reorder with `jjhub stack reorder`; land root-to-tip with
|
|
14
|
+
`jjhub stack land <stackId>` (all-or-nothing, CI-gated).
|
|
15
|
+
- Ship a single change: `jjhub change land CHG-N`. Do NOT merge the PR with
|
|
16
16
|
`gh pr merge` — that bypasses the gate and the op log.
|
|
17
|
-
- Made a mistake: `
|
|
18
|
-
conflicts; `
|
|
19
|
-
- Conflicts: `
|
|
17
|
+
- Made a mistake: `jjhub undo` (works across changes, stacks, bookmarks, and
|
|
18
|
+
conflicts; `jjhub op log` shows what would be undone).
|
|
19
|
+
- Conflicts: `jjhub conflict list` / `jjhub conflict resolve <id> <state>` —
|
|
20
20
|
conflicts are durable objects; you can defer them and keep working.
|
|
21
21
|
- Machine output: append `--json` to any command.
|
|
22
22
|
- Repo context is inferred from the git `origin` remote; `--repo owner/name`
|
|
23
23
|
overrides.
|
|
24
24
|
|
|
25
|
-
Reads are fine with either tool. Any `
|
|
25
|
+
Reads are fine with either tool. Any `jjhub` command it doesn't recognize
|
|
26
26
|
(`pr`, `issue`, ...) passes through to the real `gh` unchanged. Full command
|
|
27
|
-
reference: run `
|
|
27
|
+
reference: run `jjhub help`, or see the served skill at
|
|
28
28
|
`/.well-known/skills/jjhub-cli/SKILL.md` on your JJHub server.
|