@makerx/yoke 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/cli.js +415 -67
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -24,13 +24,16 @@ yoke sync --user # ~/.claude/skills
|
|
|
24
24
|
- `/pr` raises a PR with a short What/Why/(How) description.
|
|
25
25
|
- `/review [number]` reviews a PR with Claude and Codex, combines their findings and offers to post them. [Configure](src/features/review/README.md#settings).
|
|
26
26
|
- `/review-ci` makes CI review each PR, adding a workflow or switching the repo's existing LLM review workflow over, and helps set its variable and secret. [Details](src/features/review/README.md#ci).
|
|
27
|
+
- `/review-ci-gate` blocks merging until the CI review passes, once its workflow is merged. [Details](src/features/review/README.md#merge-gate).
|
|
27
28
|
|
|
28
29
|
## CLI
|
|
29
30
|
|
|
30
31
|
```bash
|
|
31
32
|
yoke sync [--user] [--force] # write yoke's skills
|
|
33
|
+
yoke update # update yoke and re-sync its skills where they were synced
|
|
32
34
|
yoke review [number] # review a PR (used by /review)
|
|
33
35
|
yoke review-ci # add the CI review workflow (used by /review-ci)
|
|
36
|
+
yoke review-ci-gate # require the review / gate check on the default branch (used by /review-ci-gate)
|
|
34
37
|
yoke review-gate <result> # block merging until the PR's latest review succeeds (used by review.yml)
|
|
35
38
|
yoke models # list each provider's models
|
|
36
39
|
```
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { relative } from "path";
|
|
4
|
+
import { relative as relative2 } from "path";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
|
|
7
7
|
// package.json
|
|
8
8
|
var package_default = {
|
|
9
9
|
name: "@makerx/yoke",
|
|
10
|
-
version: "0.
|
|
10
|
+
version: "0.15.0",
|
|
11
11
|
description: "Lean, user-invoked Claude Code skills for MakerX AI-assisted delivery, plus the CLI they call.",
|
|
12
12
|
license: "MIT",
|
|
13
13
|
author: "MakerX",
|
|
@@ -43,6 +43,7 @@ var package_default = {
|
|
|
43
43
|
dependencies: {
|
|
44
44
|
commander: "^15.0.0",
|
|
45
45
|
"log-update": "^8.0.0",
|
|
46
|
+
yaml: "^2.9.1",
|
|
46
47
|
zod: "^4.6.7"
|
|
47
48
|
},
|
|
48
49
|
devDependencies: {
|
|
@@ -54,8 +55,7 @@ var package_default = {
|
|
|
54
55
|
"oxlint-tsgolint": "^7.0.2004",
|
|
55
56
|
tsup: "^9.0.0",
|
|
56
57
|
typescript: "^6.0.3",
|
|
57
|
-
vitest: "^5.0.5"
|
|
58
|
-
yaml: "^2.9.1"
|
|
58
|
+
vitest: "^5.0.5"
|
|
59
59
|
},
|
|
60
60
|
overrides: {
|
|
61
61
|
minimatch: "^11.0.3"
|
|
@@ -159,6 +159,28 @@ function howToSet(key, placeholder = "<value>") {
|
|
|
159
159
|
return `${ways.slice(0, -1).join(", ")} or ${ways.at(-1)}`;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// src/cliSettings.ts
|
|
163
|
+
function addSettingOptions(command, keys) {
|
|
164
|
+
for (const key of keys) {
|
|
165
|
+
const flag = settingFlag(key);
|
|
166
|
+
if (flag) command.option(`${flag} <value>`, settingDescription(key));
|
|
167
|
+
}
|
|
168
|
+
return command;
|
|
169
|
+
}
|
|
170
|
+
function optionName(flag) {
|
|
171
|
+
return flag.slice(2).replace(/-(.)/g, (_, next) => next.toUpperCase());
|
|
172
|
+
}
|
|
173
|
+
function flagSettings(options, keys) {
|
|
174
|
+
const flags = {};
|
|
175
|
+
for (const key of keys) {
|
|
176
|
+
const flag = settingFlag(key);
|
|
177
|
+
const value = flag && options[optionName(flag)];
|
|
178
|
+
if (typeof value === "string") flags[key] = value;
|
|
179
|
+
}
|
|
180
|
+
return flags;
|
|
181
|
+
}
|
|
182
|
+
var settingsHelp = "\nEach setting falls back to its YOKE_* env var, then `yoke config set --user`; API keys come from env or config only. Run `yoke config` to see them all.";
|
|
183
|
+
|
|
162
184
|
// src/features/config/userConfig.ts
|
|
163
185
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
164
186
|
import { homedir } from "os";
|
|
@@ -464,9 +486,9 @@ var ReviewError = class extends Error {
|
|
|
464
486
|
}
|
|
465
487
|
};
|
|
466
488
|
var maxBuffer = 256 * 1024 * 1024;
|
|
467
|
-
var run = (command, args, cwd, input) => new Promise((
|
|
489
|
+
var run = (command, args, cwd, input) => new Promise((resolve3, reject) => {
|
|
468
490
|
const child = execFile(command, args, { cwd, encoding: "utf8", maxBuffer }, (error, stdout, stderr) => {
|
|
469
|
-
if (!error) return
|
|
491
|
+
if (!error) return resolve3(stdout);
|
|
470
492
|
const detail = stderr.trim();
|
|
471
493
|
reject(new ReviewError(`\`${command} ${args.join(" ")}\` failed${detail ? `: ${detail}` : ""}`));
|
|
472
494
|
});
|
|
@@ -548,6 +570,9 @@ async function discardPendingReview(pr2, review, exec2 = run) {
|
|
|
548
570
|
async function githubGet(path, exec2 = run) {
|
|
549
571
|
return JSON.parse(await exec2("gh", ["api", path]));
|
|
550
572
|
}
|
|
573
|
+
async function githubPost(path, body, exec2 = run) {
|
|
574
|
+
return JSON.parse(await exec2("gh", ["api", "--method", "POST", path, "--input", "-"], void 0, JSON.stringify(body)));
|
|
575
|
+
}
|
|
551
576
|
var github = {
|
|
552
577
|
open: (pr2) => openPendingReview(pr2),
|
|
553
578
|
add: (review, comment) => addReviewComment(review, comment),
|
|
@@ -761,21 +786,6 @@ function setupCommands(settings, workflow) {
|
|
|
761
786
|
...workflowSecrets(workflow).map((secret2) => `gh secret set ${secret2}`)
|
|
762
787
|
];
|
|
763
788
|
}
|
|
764
|
-
var gateRuleset = {
|
|
765
|
-
name: "yoke review",
|
|
766
|
-
target: "branch",
|
|
767
|
-
enforcement: "active",
|
|
768
|
-
conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
|
|
769
|
-
rules: [
|
|
770
|
-
{
|
|
771
|
-
type: "required_status_checks",
|
|
772
|
-
parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: gateCheck }] }
|
|
773
|
-
}
|
|
774
|
-
]
|
|
775
|
-
};
|
|
776
|
-
function rulesetCommand() {
|
|
777
|
-
return `echo ${shellQuote(JSON.stringify(gateRuleset))} | gh api --method POST 'repos/{owner}/{repo}/rulesets' --input -`;
|
|
778
|
-
}
|
|
779
789
|
var unknownRepo = "<owner>/<repo>";
|
|
780
790
|
function githubRepo(cwd = process.cwd()) {
|
|
781
791
|
try {
|
|
@@ -832,28 +842,242 @@ function reviewCiReport(outcome, settings, workflow, repo) {
|
|
|
832
842
|
"",
|
|
833
843
|
outcome === "printed" ? "Once the repo's review workflow runs this review, commit and push it; each PR opened from this repo then gets a review." : `Commit and push ${callerWorkflowPath}; each PR opened from this repo then gets a review.`,
|
|
834
844
|
"",
|
|
835
|
-
`To block merging while the review fails,
|
|
836
|
-
` ${rulesetCommand()}`,
|
|
837
|
-
"Run it only after this workflow is merged to the default branch, or pushes to open PRs get stuck waiting for a status."
|
|
845
|
+
`To block merging while the review fails, run /review-ci-gate once this workflow is merged to the default branch; it requires the ${gateCheck} check there.`
|
|
838
846
|
].join("\n");
|
|
839
847
|
}
|
|
840
848
|
|
|
849
|
+
// src/features/review/ciGate.ts
|
|
850
|
+
import { z as z3 } from "zod";
|
|
851
|
+
|
|
852
|
+
// src/features/review/ciGateCaller.ts
|
|
853
|
+
import { parse } from "yaml";
|
|
854
|
+
import { z as z2 } from "zod";
|
|
855
|
+
var yokeRepo = "MakerXStudio/yoke";
|
|
856
|
+
var localReviewWorkflow = "./.github/workflows/review.yml";
|
|
857
|
+
var reviewWorkflowPrefix = reviewWorkflowRef.slice(0, reviewWorkflowRef.indexOf("@") + 1).toLowerCase();
|
|
858
|
+
var workflowSchema = z2.looseObject({
|
|
859
|
+
on: z2.unknown().optional(),
|
|
860
|
+
permissions: z2.unknown().optional(),
|
|
861
|
+
jobs: z2.record(z2.string(), z2.looseObject({ uses: z2.unknown().optional(), permissions: z2.unknown().optional() }).nullable()).optional()
|
|
862
|
+
});
|
|
863
|
+
function parseWorkflow(content) {
|
|
864
|
+
try {
|
|
865
|
+
const result = workflowSchema.safeParse(parse(content));
|
|
866
|
+
return result.success ? result.data : void 0;
|
|
867
|
+
} catch {
|
|
868
|
+
return void 0;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
function callsReview(uses, repo) {
|
|
872
|
+
if (typeof uses !== "string") return false;
|
|
873
|
+
if (uses.toLowerCase().startsWith(reviewWorkflowPrefix)) return true;
|
|
874
|
+
return repo.toLowerCase() === yokeRepo.toLowerCase() && uses === localReviewWorkflow;
|
|
875
|
+
}
|
|
876
|
+
function listIncludes(value, item) {
|
|
877
|
+
return value === item || Array.isArray(value) && value.includes(item);
|
|
878
|
+
}
|
|
879
|
+
function pullRequestTrigger(on) {
|
|
880
|
+
if (typeof on !== "object" || on === null || Array.isArray(on) || !("pull_request" in on)) return void 0;
|
|
881
|
+
const trigger = on.pull_request;
|
|
882
|
+
return typeof trigger === "object" && trigger !== null && !Array.isArray(trigger) ? { ...trigger } : void 0;
|
|
883
|
+
}
|
|
884
|
+
function runsOnSynchronize(on) {
|
|
885
|
+
if (typeof on === "string" || Array.isArray(on)) return listIncludes(on, "pull_request");
|
|
886
|
+
if (typeof on !== "object" || on === null || !("pull_request" in on)) return false;
|
|
887
|
+
const trigger = pullRequestTrigger(on);
|
|
888
|
+
if (!trigger || !("types" in trigger)) return true;
|
|
889
|
+
return listIncludes(trigger.types, "synchronize");
|
|
890
|
+
}
|
|
891
|
+
function globMatches(pattern, branch) {
|
|
892
|
+
const source = pattern.split(/(\*\*|\*|\?)/).map((part) => ({ "**": ".*", "*": "[^/]*", "?": "." })[part] ?? part.replace(/[.+^${}()|[\]\\]/g, "\\$&")).join("");
|
|
893
|
+
return new RegExp(`^${source}$`).test(branch);
|
|
894
|
+
}
|
|
895
|
+
function patterns(value) {
|
|
896
|
+
if (typeof value === "string") return [value];
|
|
897
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
898
|
+
}
|
|
899
|
+
function branchesInclude(value, branch) {
|
|
900
|
+
return patterns(value).reduce(
|
|
901
|
+
(included, pattern) => pattern.startsWith("!") ? included && !globMatches(pattern.slice(1), branch) : included || globMatches(pattern, branch),
|
|
902
|
+
false
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
function skipFilters(on, branch) {
|
|
906
|
+
const trigger = pullRequestTrigger(on);
|
|
907
|
+
if (!trigger) return [];
|
|
908
|
+
const filters = ["paths", "paths-ignore"].filter((key) => key in trigger);
|
|
909
|
+
if ("branches" in trigger && !branchesInclude(trigger.branches, branch)) filters.push("branches");
|
|
910
|
+
if ("branches-ignore" in trigger && patterns(trigger["branches-ignore"]).some((pattern) => globMatches(pattern, branch)))
|
|
911
|
+
filters.push("branches-ignore");
|
|
912
|
+
return filters;
|
|
913
|
+
}
|
|
914
|
+
function canReadActions(permissions) {
|
|
915
|
+
if (permissions === "read-all" || permissions === "write-all") return true;
|
|
916
|
+
if (typeof permissions !== "object" || permissions === null || !("actions" in permissions)) return false;
|
|
917
|
+
return permissions.actions === "read" || permissions.actions === "write";
|
|
918
|
+
}
|
|
919
|
+
function checkWorkflow(path, workflow, repo, branch) {
|
|
920
|
+
const jobs = Object.values(workflow.jobs ?? {}).filter((job) => job && callsReview(job.uses, repo));
|
|
921
|
+
if (jobs.length === 0) return void 0;
|
|
922
|
+
const problems2 = [];
|
|
923
|
+
if (!runsOnSynchronize(workflow.on)) problems2.push("lacks the `synchronize` type in its `pull_request` trigger");
|
|
924
|
+
if (!jobs.some((job) => canReadActions(job?.permissions ?? workflow.permissions)))
|
|
925
|
+
problems2.push("lacks `actions: read` in its permissions");
|
|
926
|
+
const filters = skipFilters(workflow.on, branch);
|
|
927
|
+
if (filters.length > 0)
|
|
928
|
+
problems2.push(
|
|
929
|
+
`skips some PRs to ${branch} through its \`pull_request\` ${filters.map((filter) => `\`${filter}\``).join(", ")} filter${filters.length > 1 ? "s" : ""}, so those PRs could never merge`
|
|
930
|
+
);
|
|
931
|
+
return { path, problems: problems2 };
|
|
932
|
+
}
|
|
933
|
+
function checkCallerWorkflows(files, repo, branch) {
|
|
934
|
+
const callers = files.flatMap(({ path, content }) => {
|
|
935
|
+
const workflow = parseWorkflow(content);
|
|
936
|
+
const check = workflow && checkWorkflow(path, workflow, repo, branch);
|
|
937
|
+
return check ? [check] : [];
|
|
938
|
+
});
|
|
939
|
+
return callers.find((caller) => caller.problems.length === 0) ?? callers[0];
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/features/review/ciGate.ts
|
|
943
|
+
var gateRuleset = {
|
|
944
|
+
name: "yoke review",
|
|
945
|
+
target: "branch",
|
|
946
|
+
enforcement: "active",
|
|
947
|
+
conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
|
|
948
|
+
rules: [
|
|
949
|
+
{
|
|
950
|
+
type: "required_status_checks",
|
|
951
|
+
parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: gateCheck }] }
|
|
952
|
+
}
|
|
953
|
+
]
|
|
954
|
+
};
|
|
955
|
+
var recentPrCount = 20;
|
|
956
|
+
var repoSchema = z3.object({
|
|
957
|
+
full_name: z3.string(),
|
|
958
|
+
html_url: z3.string(),
|
|
959
|
+
default_branch: z3.string()
|
|
960
|
+
});
|
|
961
|
+
var branchRulesSchema = z3.array(
|
|
962
|
+
z3.object({
|
|
963
|
+
type: z3.string(),
|
|
964
|
+
ruleset_id: z3.number().optional(),
|
|
965
|
+
parameters: z3.object({ required_status_checks: z3.array(z3.object({ context: z3.string() })).optional() }).optional()
|
|
966
|
+
})
|
|
967
|
+
);
|
|
968
|
+
var directorySchema = z3.array(z3.object({ type: z3.string(), name: z3.string(), path: z3.string() }));
|
|
969
|
+
var fileSchema = z3.object({ content: z3.string() });
|
|
970
|
+
var pullsSchema = z3.array(z3.object({ head: z3.object({ sha: z3.string() }) }));
|
|
971
|
+
var checkRunsSchema = z3.object({ total_count: z3.number() });
|
|
972
|
+
var createdSchema = z3.object({ id: z3.number() });
|
|
973
|
+
var rulesetsSchema = z3.array(z3.object({ id: z3.number(), name: z3.string(), enforcement: z3.string() }));
|
|
974
|
+
var githubApi = { get: (path) => githubGet(path), post: (path, body) => githubPost(path, body) };
|
|
975
|
+
async function getUnlessMissing(api, path) {
|
|
976
|
+
try {
|
|
977
|
+
return await api.get(path);
|
|
978
|
+
} catch (error) {
|
|
979
|
+
if (error instanceof ReviewError && error.message.includes("(HTTP 404)")) return void 0;
|
|
980
|
+
throw error;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
async function workflowFiles(api, repo) {
|
|
984
|
+
const ref = `ref=${encodeURIComponent(repo.default_branch)}`;
|
|
985
|
+
const listing = await getUnlessMissing(api, `repos/${repo.full_name}/contents/.github/workflows?${ref}`);
|
|
986
|
+
if (listing === void 0) return [];
|
|
987
|
+
const files = directorySchema.parse(listing).filter((entry) => entry.type === "file" && /\.ya?ml$/.test(entry.name));
|
|
988
|
+
return Promise.all(
|
|
989
|
+
files.map(async ({ path }) => {
|
|
990
|
+
const { content } = fileSchema.parse(await api.get(`repos/${repo.full_name}/contents/${path}?${ref}`));
|
|
991
|
+
return { path, content: Buffer.from(content, "base64").toString("utf8") };
|
|
992
|
+
})
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
async function callerProblem(api, repo) {
|
|
996
|
+
const branch = repo.default_branch;
|
|
997
|
+
const caller = checkCallerWorkflows(await workflowFiles(api, repo), repo.full_name, branch);
|
|
998
|
+
if (!caller) return `No workflow on ${branch} calls yoke's review.yml. Run /review-ci, then merge its ${callerWorkflowPath} to ${branch}.`;
|
|
999
|
+
if (caller.problems.length > 0)
|
|
1000
|
+
return `${caller.path} on ${branch} calls yoke's review.yml but ${caller.problems.join(" and ")}. Fix it and merge the change to ${branch}.`;
|
|
1001
|
+
return void 0;
|
|
1002
|
+
}
|
|
1003
|
+
async function gateHasReported(api, repo) {
|
|
1004
|
+
const pulls = pullsSchema.parse(
|
|
1005
|
+
await api.get(`repos/${repo.full_name}/pulls?state=all&sort=updated&direction=desc&per_page=${recentPrCount}`)
|
|
1006
|
+
);
|
|
1007
|
+
for (const { head } of pulls) {
|
|
1008
|
+
const runs = checkRunsSchema.parse(
|
|
1009
|
+
await api.get(`repos/${repo.full_name}/commits/${head.sha}/check-runs?check_name=${encodeURIComponent(gateCheck)}&per_page=1`)
|
|
1010
|
+
);
|
|
1011
|
+
if (runs.total_count > 0) return true;
|
|
1012
|
+
}
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
async function rulesetProblem(api, repo) {
|
|
1016
|
+
const rulesets = rulesetsSchema.parse(await api.get(`repos/${repo.full_name}/rulesets?includes_parents=false&per_page=100`));
|
|
1017
|
+
const existing = rulesets.find((ruleset) => ruleset.name === gateRuleset.name);
|
|
1018
|
+
if (!existing) return void 0;
|
|
1019
|
+
return `The "${gateRuleset.name}" ruleset already exists with enforcement \`${existing.enforcement}\` but doesn't require ${gateCheck} on ${repo.default_branch}: ${repo.html_url}/rules/${existing.id}. Set its enforcement to Active and its required check to ${gateCheck}, or delete it and run this again.`;
|
|
1020
|
+
}
|
|
1021
|
+
async function createRuleset(api, repo) {
|
|
1022
|
+
try {
|
|
1023
|
+
return await api.post(`repos/${repo.full_name}/rulesets`, gateRuleset);
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
if (error instanceof ReviewError && /\(HTTP 40[34]\)/.test(error.message))
|
|
1026
|
+
throw new ReviewError(
|
|
1027
|
+
`GitHub refused to add the "${gateRuleset.name}" ruleset to ${repo.full_name}: your gh user can't edit its repository rules. Ask a repo admin, or someone whose role can edit repository rules, to run \`yoke review-ci-gate\`.`
|
|
1028
|
+
);
|
|
1029
|
+
throw error;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
async function problems(api, repo) {
|
|
1033
|
+
const found = [];
|
|
1034
|
+
const ruleset = await rulesetProblem(api, repo);
|
|
1035
|
+
if (ruleset) found.push(ruleset);
|
|
1036
|
+
const caller = await callerProblem(api, repo);
|
|
1037
|
+
if (caller) found.push(caller);
|
|
1038
|
+
if (!await gateHasReported(api, repo))
|
|
1039
|
+
found.push(
|
|
1040
|
+
`No ${gateCheck} check run on the ${recentPrCount} most recently updated PRs. Open or push to a PR so the gate reports, then run this again.`
|
|
1041
|
+
);
|
|
1042
|
+
return found;
|
|
1043
|
+
}
|
|
1044
|
+
async function enforceReviewGate(api = githubApi) {
|
|
1045
|
+
const repo = repoSchema.parse(await api.get("repos/{owner}/{repo}"));
|
|
1046
|
+
const branch = repo.default_branch;
|
|
1047
|
+
const rules = branchRulesSchema.parse(await api.get(`repos/${repo.full_name}/rules/branches/${encodeURIComponent(branch)}`));
|
|
1048
|
+
const existing = rules.find(
|
|
1049
|
+
(rule) => rule.type === "required_status_checks" && rule.parameters?.required_status_checks?.some((check) => check.context === gateCheck)
|
|
1050
|
+
);
|
|
1051
|
+
if (existing) {
|
|
1052
|
+
const where = existing.ruleset_id ? ` by ${repo.html_url}/rules/${existing.ruleset_id}` : "";
|
|
1053
|
+
return { created: false, message: `${gateCheck} is already required on ${branch}${where}; nothing changed.` };
|
|
1054
|
+
}
|
|
1055
|
+
const found = await problems(api, repo);
|
|
1056
|
+
if (found.length > 0)
|
|
1057
|
+
throw new ReviewError([`Cannot require ${gateCheck} on ${branch} yet:`, ...found.map((problem) => `- ${problem}`)].join("\n"));
|
|
1058
|
+
const { id } = createdSchema.parse(await createRuleset(api, repo));
|
|
1059
|
+
return {
|
|
1060
|
+
created: true,
|
|
1061
|
+
message: `Added the "${gateRuleset.name}" ruleset, which blocks merging to ${branch} until ${gateCheck} passes: ${repo.html_url}/rules/${id}`
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
841
1065
|
// src/features/review/gate.ts
|
|
842
1066
|
import { appendFileSync, readFileSync as readFileSync3 } from "fs";
|
|
843
1067
|
import { setTimeout } from "timers/promises";
|
|
844
|
-
import { z as
|
|
845
|
-
var eventSchema =
|
|
846
|
-
action:
|
|
847
|
-
pull_request:
|
|
848
|
-
number:
|
|
849
|
-
head:
|
|
850
|
-
user:
|
|
1068
|
+
import { z as z4 } from "zod";
|
|
1069
|
+
var eventSchema = z4.object({
|
|
1070
|
+
action: z4.string(),
|
|
1071
|
+
pull_request: z4.object({
|
|
1072
|
+
number: z4.number(),
|
|
1073
|
+
head: z4.object({ ref: z4.string(), repo: z4.object({ full_name: z4.string() }).nullable() }),
|
|
1074
|
+
user: z4.object({ type: z4.string() })
|
|
851
1075
|
})
|
|
852
1076
|
});
|
|
853
|
-
var runsSchema =
|
|
854
|
-
workflow_runs:
|
|
1077
|
+
var runsSchema = z4.object({
|
|
1078
|
+
workflow_runs: z4.array(z4.object({ id: z4.number(), run_number: z4.number(), pull_requests: z4.array(z4.object({ number: z4.number() })) }))
|
|
855
1079
|
});
|
|
856
|
-
var jobsSchema =
|
|
1080
|
+
var jobsSchema = z4.object({ jobs: z4.array(z4.object({ name: z4.string(), status: z4.string(), conclusion: z4.string().nullable() })) });
|
|
857
1081
|
var pollInterval = 3e4;
|
|
858
1082
|
var rerunHint = "Mark the PR as draft, then ready for review, to run the review again.";
|
|
859
1083
|
var firstRunHint = "Mark the PR as ready for review (from draft if it is not one) to run it.";
|
|
@@ -877,7 +1101,7 @@ function gateContext(env = process.env) {
|
|
|
877
1101
|
};
|
|
878
1102
|
}
|
|
879
1103
|
async function earlierRuns({ repo, runId, runNumber, event }, deps) {
|
|
880
|
-
const { workflow_id } =
|
|
1104
|
+
const { workflow_id } = z4.object({ workflow_id: z4.number() }).parse(await deps.get(`repos/${repo}/actions/runs/${runId}`));
|
|
881
1105
|
const query = new URLSearchParams({ event: "pull_request", branch: event.pull_request.head.ref, per_page: "100" });
|
|
882
1106
|
const { workflow_runs } = runsSchema.parse(await deps.get(`repos/${repo}/actions/workflows/${workflow_id}/runs?${query.toString()}`));
|
|
883
1107
|
return workflow_runs.filter((run2) => run2.run_number < runNumber && run2.pull_requests.some((pr2) => pr2.number === event.pull_request.number)).toSorted((a, b) => b.run_number - a.run_number).map((run2) => run2.id);
|
|
@@ -1680,7 +1904,7 @@ function exitError(harness, code, output) {
|
|
|
1680
1904
|
function hasReview(path) {
|
|
1681
1905
|
return existsSync3(path) && readFileSync4(path, "utf8").trim().length > 0;
|
|
1682
1906
|
}
|
|
1683
|
-
var runHarness = (run2) => new Promise((
|
|
1907
|
+
var runHarness = (run2) => new Promise((resolve3) => {
|
|
1684
1908
|
const shell = process.platform === "win32";
|
|
1685
1909
|
const args = harnessArgs(run2);
|
|
1686
1910
|
const child = spawn(run2.harness, shell ? args.map(windowsQuote) : args, {
|
|
@@ -1717,14 +1941,14 @@ var runHarness = (run2) => new Promise((resolve) => {
|
|
|
1717
1941
|
child.stdin.on("error", () => {
|
|
1718
1942
|
});
|
|
1719
1943
|
child.stdin.end(run2.prompt);
|
|
1720
|
-
child.on("error", (error) =>
|
|
1944
|
+
child.on("error", (error) => resolve3({ ok: false, error: spawnError(run2.harness, error) }));
|
|
1721
1945
|
child.on("close", (code) => {
|
|
1722
1946
|
onLine(pending);
|
|
1723
1947
|
if (code !== 0)
|
|
1724
|
-
return
|
|
1948
|
+
return resolve3({ ok: false, error: exitError(run2.harness, code, [stderr || finalText, lastError].filter(Boolean).join("\n")) });
|
|
1725
1949
|
if (run2.harness === "claude") writeFileSync3(run2.outputPath, finalText);
|
|
1726
|
-
if (!hasReview(run2.outputPath)) return
|
|
1727
|
-
|
|
1950
|
+
if (!hasReview(run2.outputPath)) return resolve3({ ok: false, error: `${run2.harness} returned an empty review` });
|
|
1951
|
+
resolve3({ ok: true, ...model ? { model } : {}, ...usage ? { usage } : {} });
|
|
1728
1952
|
});
|
|
1729
1953
|
});
|
|
1730
1954
|
|
|
@@ -2064,9 +2288,9 @@ var commandTimeout = 3e4;
|
|
|
2064
2288
|
var probeTimeout = 6e4;
|
|
2065
2289
|
var maxBodyChars2 = 300;
|
|
2066
2290
|
function exec(harness, args) {
|
|
2067
|
-
return new Promise((
|
|
2291
|
+
return new Promise((resolve3) => {
|
|
2068
2292
|
const options = { encoding: "utf8", timeout: commandTimeout, shell: process.platform === "win32" };
|
|
2069
|
-
execFile2(harness, args, options, (error, stdout) =>
|
|
2293
|
+
execFile2(harness, args, options, (error, stdout) => resolve3({ ok: !error, stdout }));
|
|
2070
2294
|
});
|
|
2071
2295
|
}
|
|
2072
2296
|
async function claudeSignedIn() {
|
|
@@ -2129,8 +2353,8 @@ function slotsToRun(slots2) {
|
|
|
2129
2353
|
return [...distinct.values()];
|
|
2130
2354
|
}
|
|
2131
2355
|
async function assertViable(slots2, check = checks) {
|
|
2132
|
-
const
|
|
2133
|
-
const errors =
|
|
2356
|
+
const problems2 = await Promise.all(slotsToRun(slots2).map((use) => slotProblem(use, check)));
|
|
2357
|
+
const errors = problems2.filter((problem) => problem !== void 0);
|
|
2134
2358
|
if (errors.length)
|
|
2135
2359
|
throw new ReviewFailure("a reviewer could not run", ["Cannot review:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
2136
2360
|
}
|
|
@@ -2304,6 +2528,21 @@ If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
|
|
|
2304
2528
|
`
|
|
2305
2529
|
};
|
|
2306
2530
|
|
|
2531
|
+
// src/features/review/ciGateSkill.ts
|
|
2532
|
+
var reviewCiGateSkill = {
|
|
2533
|
+
name: "review-ci-gate",
|
|
2534
|
+
description: "Block merging to this repo's default branch until yoke's CI review passes, once its review workflow is merged.",
|
|
2535
|
+
disableModelInvocation: true,
|
|
2536
|
+
userLevel: true,
|
|
2537
|
+
body: `
|
|
2538
|
+
Run \`yoke review-ci-gate\` and relay its output.
|
|
2539
|
+
|
|
2540
|
+
If it fails, say what is missing and how to fix it, as it reports. Don't create the ruleset any other way.
|
|
2541
|
+
|
|
2542
|
+
If \`yoke\` is not found, run \`npm i -g @makerx/yoke\`.
|
|
2543
|
+
`
|
|
2544
|
+
};
|
|
2545
|
+
|
|
2307
2546
|
// src/features/review/ciSkill.ts
|
|
2308
2547
|
var reviewCiSkill = {
|
|
2309
2548
|
name: "review-ci",
|
|
@@ -2338,7 +2577,7 @@ Never ask whether they are set for the organisation, and don't query org setting
|
|
|
2338
2577
|
|
|
2339
2578
|
With Entra, check \`az ad app federated-credential list --id <client-id>\` and show the printed create command if the credential is missing.
|
|
2340
2579
|
|
|
2341
|
-
Finish with what changed and what's left: commit and push the workflow, then,
|
|
2580
|
+
Finish with what changed and what's left: commit and push the workflow, then, once it is merged to the default branch, run \`/review-ci-gate\` to require \`review / gate\` before merging.
|
|
2342
2581
|
|
|
2343
2582
|
If \`yoke\` is not found, run \`npm i -g @makerx/yoke\`.
|
|
2344
2583
|
`
|
|
@@ -2359,7 +2598,7 @@ If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
|
|
|
2359
2598
|
};
|
|
2360
2599
|
|
|
2361
2600
|
// src/features/sync/sync.ts
|
|
2362
|
-
var bundledSkills = [prSkill, reviewSkill, reviewCiSkill];
|
|
2601
|
+
var bundledSkills = [prSkill, reviewSkill, reviewCiSkill, reviewCiGateSkill];
|
|
2363
2602
|
function skillsRoot(user, cwd = process.cwd(), home = homedir3()) {
|
|
2364
2603
|
return join9(user ? home : cwd, ".claude", "skills");
|
|
2365
2604
|
}
|
|
@@ -2382,17 +2621,137 @@ function syncSkills(destination, userDestination, force = false, skills = bundle
|
|
|
2382
2621
|
return result;
|
|
2383
2622
|
}
|
|
2384
2623
|
|
|
2624
|
+
// src/features/update/install.ts
|
|
2625
|
+
import { execSync } from "child_process";
|
|
2626
|
+
import { dirname as dirname3, isAbsolute, relative, resolve } from "path";
|
|
2627
|
+
import { fileURLToPath } from "url";
|
|
2628
|
+
function installDir(moduleUrl = import.meta.url) {
|
|
2629
|
+
return resolve(dirname3(fileURLToPath(moduleUrl)), "..");
|
|
2630
|
+
}
|
|
2631
|
+
function npmGlobalRoot() {
|
|
2632
|
+
return execSync("npm root -g", { stdio: "pipe" }).toString().trim();
|
|
2633
|
+
}
|
|
2634
|
+
function gitToplevel(dir) {
|
|
2635
|
+
return execSync("git rev-parse --show-toplevel", { cwd: dir, stdio: "pipe" }).toString().trim();
|
|
2636
|
+
}
|
|
2637
|
+
function isGitClone(dir, toplevel = gitToplevel) {
|
|
2638
|
+
try {
|
|
2639
|
+
return resolve(toplevel(dir)) === resolve(dir);
|
|
2640
|
+
} catch {
|
|
2641
|
+
return false;
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
function isInside(parent, child, platform = process.platform) {
|
|
2645
|
+
const fold = (path) => platform === "win32" ? resolve(path).toLowerCase() : resolve(path);
|
|
2646
|
+
const rel = relative(fold(parent), fold(child));
|
|
2647
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
2648
|
+
}
|
|
2649
|
+
function isGlobalNpmInstall(dir, globalRoot = npmGlobalRoot, platform = process.platform) {
|
|
2650
|
+
try {
|
|
2651
|
+
return isInside(globalRoot(), dir, platform);
|
|
2652
|
+
} catch {
|
|
2653
|
+
return false;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
function detectInstall(dir, globalRoot = npmGlobalRoot, toplevel = gitToplevel) {
|
|
2657
|
+
if (isGitClone(dir, toplevel)) return "git";
|
|
2658
|
+
return isGlobalNpmInstall(dir, globalRoot) ? "npm" : "unknown";
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
// src/features/update/targets.ts
|
|
2662
|
+
import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
|
|
2663
|
+
import { homedir as homedir4 } from "os";
|
|
2664
|
+
import { join as join10, resolve as resolve2 } from "path";
|
|
2665
|
+
function holdsYokeSkill(root, skills) {
|
|
2666
|
+
return skills.some((skill) => {
|
|
2667
|
+
if (skill.userLevel) return false;
|
|
2668
|
+
const file = join10(root, skill.name, "SKILL.md");
|
|
2669
|
+
return existsSync9(file) && readFileSync10(file, "utf8").includes(ownerMarker);
|
|
2670
|
+
});
|
|
2671
|
+
}
|
|
2672
|
+
function syncTargets(cwd = process.cwd(), home = homedir4(), skills = bundledSkills) {
|
|
2673
|
+
const userRoot = skillsRoot(true, cwd, home);
|
|
2674
|
+
const repoRoot2 = skillsRoot(false, cwd, home);
|
|
2675
|
+
const targets = [];
|
|
2676
|
+
if (holdsYokeSkill(userRoot, skills)) targets.push("user");
|
|
2677
|
+
if (resolve2(repoRoot2) !== resolve2(userRoot) && holdsYokeSkill(repoRoot2, skills)) targets.push("repo");
|
|
2678
|
+
return targets;
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
// src/features/update/update.ts
|
|
2682
|
+
import { execSync as execSync2 } from "child_process";
|
|
2683
|
+
var packageName = "@makerx/yoke";
|
|
2684
|
+
var syncCommands = { user: "yoke sync --user", repo: "yoke sync" };
|
|
2685
|
+
var noTargetsMessage = "No yoke skills were synced here before, so none were re-synced. Run `yoke sync` for this repo's .claude/skills, or `yoke sync --user` for ~/.claude/skills.";
|
|
2686
|
+
function runInherited(command, cwd) {
|
|
2687
|
+
execSync2(command, { cwd, stdio: "inherit" });
|
|
2688
|
+
}
|
|
2689
|
+
function installLatest(install, dir, run2, log) {
|
|
2690
|
+
if (install === "npm") {
|
|
2691
|
+
log("Updating the global npm install...");
|
|
2692
|
+
run2(`npm i -g ${packageName}@latest`);
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
2695
|
+
log("Pulling the latest yoke...");
|
|
2696
|
+
run2("git pull", dir);
|
|
2697
|
+
log("Installing dependencies...");
|
|
2698
|
+
run2("npm ci", dir);
|
|
2699
|
+
log("Building...");
|
|
2700
|
+
run2("npm run build", dir);
|
|
2701
|
+
}
|
|
2702
|
+
function updateYoke({ dir, install, targets, run: run2 = runInherited, log, error }) {
|
|
2703
|
+
log(`yoke is installed at ${dir}`);
|
|
2704
|
+
if (install === "unknown") {
|
|
2705
|
+
error(
|
|
2706
|
+
`Could not tell how yoke was installed at ${dir}. Expected a git clone or a global npm install; reinstall with \`npm i -g ${packageName}@latest\`.`
|
|
2707
|
+
);
|
|
2708
|
+
return 1;
|
|
2709
|
+
}
|
|
2710
|
+
try {
|
|
2711
|
+
installLatest(install, dir, run2, log);
|
|
2712
|
+
} catch {
|
|
2713
|
+
error("Updating yoke failed, so no skills were re-synced.");
|
|
2714
|
+
return 1;
|
|
2715
|
+
}
|
|
2716
|
+
const found = targets();
|
|
2717
|
+
if (found.length === 0) {
|
|
2718
|
+
log(noTargetsMessage);
|
|
2719
|
+
return 0;
|
|
2720
|
+
}
|
|
2721
|
+
for (const target of found) {
|
|
2722
|
+
const command = syncCommands[target];
|
|
2723
|
+
log(`Running ${command}...`);
|
|
2724
|
+
try {
|
|
2725
|
+
run2(command);
|
|
2726
|
+
} catch {
|
|
2727
|
+
error(`${command} failed.`);
|
|
2728
|
+
return 1;
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
return 0;
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2385
2734
|
// src/cli.ts
|
|
2386
2735
|
var { version } = package_default;
|
|
2387
2736
|
var program = new Command("yoke").description("Lean Claude Code skills and the CLI they call.").version(version);
|
|
2388
2737
|
program.command("sync").description("Write yoke's skills into this repo's .claude/skills, or ~/.claude/skills with --user").option("--user", "write to ~/.claude/skills instead, for every repo").option("--force", "overwrite same-named skills that yoke didn't write").action((options) => {
|
|
2389
2738
|
const user = options.user ?? false;
|
|
2390
2739
|
const cwd = process.cwd();
|
|
2391
|
-
const display = (path) => path.startsWith(cwd) ?
|
|
2740
|
+
const display = (path) => path.startsWith(cwd) ? relative2(cwd, path) : path;
|
|
2392
2741
|
const { written, skipped } = syncSkills(skillsRoot(user), skillsRoot(true), options.force ?? false);
|
|
2393
2742
|
for (const file of written) console.log(`wrote ${display(file)}`);
|
|
2394
2743
|
for (const dir of skipped) console.log(`skipped ${display(dir)} (not yoke's skill; rerun with --force to overwrite it)`);
|
|
2395
2744
|
});
|
|
2745
|
+
program.command("update").description("Update yoke to the latest version, then re-sync its skills wherever they were synced before").action(() => {
|
|
2746
|
+
const dir = installDir();
|
|
2747
|
+
process.exitCode = updateYoke({
|
|
2748
|
+
dir,
|
|
2749
|
+
install: detectInstall(dir),
|
|
2750
|
+
targets: () => syncTargets(),
|
|
2751
|
+
log: console.log,
|
|
2752
|
+
error: console.error
|
|
2753
|
+
});
|
|
2754
|
+
});
|
|
2396
2755
|
var pr = program.command("pr").description("Pull request commands");
|
|
2397
2756
|
pr.command("raise").description("Validate a PR title and body, then create the PR with gh").requiredOption("--title <title>", "single-line PR title").requiredOption("--body <body>", "PR body with ## What, ## Why and optionally ## How").option("--base <branch>", "base branch (defaults to the repo default)").option("--no-draft", "create a ready-for-review PR instead of a draft").addHelpText("after", raiseGuide).action((options) => {
|
|
2398
2757
|
try {
|
|
@@ -2404,26 +2763,6 @@ pr.command("raise").description("Validate a PR title and body, then create the P
|
|
|
2404
2763
|
}
|
|
2405
2764
|
});
|
|
2406
2765
|
var providerKeys = settingKeys.filter((key) => !key.startsWith("review."));
|
|
2407
|
-
function addSettingOptions(command, keys) {
|
|
2408
|
-
for (const key of keys) {
|
|
2409
|
-
const flag = settingFlag(key);
|
|
2410
|
-
if (flag) command.option(`${flag} <value>`, settingDescription(key));
|
|
2411
|
-
}
|
|
2412
|
-
return command;
|
|
2413
|
-
}
|
|
2414
|
-
function optionName(flag) {
|
|
2415
|
-
return flag.slice(2).replace(/-(.)/g, (_, next) => next.toUpperCase());
|
|
2416
|
-
}
|
|
2417
|
-
function flagSettings(options, keys) {
|
|
2418
|
-
const flags = {};
|
|
2419
|
-
for (const key of keys) {
|
|
2420
|
-
const flag = settingFlag(key);
|
|
2421
|
-
const value = flag && options[optionName(flag)];
|
|
2422
|
-
if (typeof value === "string") flags[key] = value;
|
|
2423
|
-
}
|
|
2424
|
-
return flags;
|
|
2425
|
-
}
|
|
2426
|
-
var settingsHelp = "\nEach setting falls back to its YOKE_* env var, then `yoke config set --user`; API keys come from env or config only. Run `yoke config` to see them all.";
|
|
2427
2766
|
addSettingOptions(
|
|
2428
2767
|
program.command("review").description("Review the current branch's PR, or PR <number>, with Claude and Codex in parallel, then combine their findings").argument("[number]", "PR number; reviewed without checking it out").option("--post", "post the findings to the PR as one review, reusing the saved review of the PR head when there is one").option("--force", "discard this PR head's cached reviews and run every pass again").option("--verbose", "print every step and each reviewer's tool calls as log lines instead of live spinners"),
|
|
2429
2768
|
settingKeys
|
|
@@ -2464,6 +2803,15 @@ addSettingOptions(
|
|
|
2464
2803
|
process.exitCode = 1;
|
|
2465
2804
|
}
|
|
2466
2805
|
});
|
|
2806
|
+
program.command("review-ci-gate").description(`Require the ${gateCheck} check on the default branch with a ruleset, once the CI review workflow is merged`).action(async () => {
|
|
2807
|
+
try {
|
|
2808
|
+
console.log((await enforceReviewGate()).message);
|
|
2809
|
+
} catch (error) {
|
|
2810
|
+
if (!(error instanceof ReviewError)) throw error;
|
|
2811
|
+
console.error(error.message);
|
|
2812
|
+
process.exitCode = 1;
|
|
2813
|
+
}
|
|
2814
|
+
});
|
|
2467
2815
|
program.command("review-gate").description("Block merging until the latest review that ran for the PR succeeds; fork and bot PRs pass. Used by review.yml's gate job").argument("<result>", "result of the review job on this event: success, failure, cancelled or skipped").action(async (result) => {
|
|
2468
2816
|
try {
|
|
2469
2817
|
if (!await runReviewGate(result, console.log)) process.exitCode = 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makerx/yoke",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Lean, user-invoked Claude Code skills for MakerX AI-assisted delivery, plus the CLI they call.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "MakerX",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"commander": "^15.0.0",
|
|
38
38
|
"log-update": "^8.0.0",
|
|
39
|
+
"yaml": "^2.9.1",
|
|
39
40
|
"zod": "^4.6.7"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
@@ -47,8 +48,7 @@
|
|
|
47
48
|
"oxlint-tsgolint": "^7.0.2004",
|
|
48
49
|
"tsup": "^9.0.0",
|
|
49
50
|
"typescript": "^6.0.3",
|
|
50
|
-
"vitest": "^5.0.5"
|
|
51
|
-
"yaml": "^2.9.1"
|
|
51
|
+
"vitest": "^5.0.5"
|
|
52
52
|
},
|
|
53
53
|
"overrides": {
|
|
54
54
|
"minimatch": "^11.0.3"
|