@makerx/yoke 0.8.0 → 0.9.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.
Files changed (2) hide show
  1. package/dist/cli.js +365 -172
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { Command } from "commander";
7
7
  // package.json
8
8
  var package_default = {
9
9
  name: "@makerx/yoke",
10
- version: "0.8.0",
10
+ version: "0.9.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",
@@ -80,6 +80,11 @@ var settingsSchema = z.object({
80
80
  "litellm.apiKey": secret("LiteLLM API key. Env or --user config only"),
81
81
  "foundry.baseUrl": setting("https://<resource>.services.ai.azure.com"),
82
82
  "foundry.apiKey": secret("Foundry API key. Env or --user config only"),
83
+ "foundry.token": secret("Foundry Entra access token, sent as a bearer token in place of foundry.apiKey. Env or --user config only"),
84
+ "foundry.azureClientId": setting(
85
+ "Entra app (client) ID. In GitHub Actions, when a slot uses foundry, yoke exchanges the job OIDC token for foundry.token"
86
+ ),
87
+ "foundry.azureTenantId": setting("Entra tenant ID of foundry.azureClientId"),
83
88
  "review.reviewer1.harness": setting("claude or codex. Default: claude"),
84
89
  "review.reviewer1.provider": setting(providerHelp),
85
90
  "review.reviewer1.model": setting(modelHelp),
@@ -536,8 +541,11 @@ async function checkoutHead(pr2, reviewDir, exec2 = run) {
536
541
  // src/features/review/provider.ts
537
542
  var providerKinds = ["litellm", "foundry"];
538
543
  var harnesses = ["claude", "codex"];
539
- function codexKeyEnv(kind) {
540
- return `YOKE_${kind.toUpperCase()}_API_KEY`;
544
+ function codexKeyEnv(provider) {
545
+ return `YOKE_${provider.kind.toUpperCase()}_${"token" in provider ? "TOKEN" : "API_KEY"}`;
546
+ }
547
+ function credentialOf(provider) {
548
+ return "token" in provider ? provider.token : provider.apiKey;
541
549
  }
542
550
  function isProviderKind(value) {
543
551
  return providerKinds.includes(value);
@@ -553,8 +561,8 @@ function isHarness(value) {
553
561
  function ownAuthOverride({ harness, model }) {
554
562
  return { args: harness === "codex" ? ["-m", model] : ["--model", model], env: {} };
555
563
  }
556
- function buildCodexOverride({ kind, apiKey }, endpoints, model) {
557
- const keyEnv = codexKeyEnv(kind);
564
+ function buildCodexOverride(provider, endpoints, model) {
565
+ const keyEnv = codexKeyEnv(provider);
558
566
  return {
559
567
  args: [
560
568
  "-c",
@@ -570,19 +578,19 @@ function buildCodexOverride({ kind, apiKey }, endpoints, model) {
570
578
  "-m",
571
579
  model
572
580
  ],
573
- env: { [keyEnv]: apiKey }
581
+ env: { [keyEnv]: credentialOf(provider) }
574
582
  };
575
583
  }
576
- function claudeProviderEnv(kind, endpoints, apiKey) {
577
- if (kind === "foundry")
578
- return { CLAUDE_CODE_USE_FOUNDRY: "1", ANTHROPIC_FOUNDRY_BASE_URL: endpoints.anthropic, ANTHROPIC_FOUNDRY_API_KEY: apiKey };
579
- return { ANTHROPIC_BASE_URL: endpoints.anthropic, ANTHROPIC_AUTH_TOKEN: apiKey };
584
+ function claudeProviderEnv(provider, endpoints) {
585
+ if (provider.kind === "foundry" && "apiKey" in provider)
586
+ return { CLAUDE_CODE_USE_FOUNDRY: "1", ANTHROPIC_FOUNDRY_BASE_URL: endpoints.anthropic, ANTHROPIC_FOUNDRY_API_KEY: provider.apiKey };
587
+ return { ANTHROPIC_BASE_URL: endpoints.anthropic, ANTHROPIC_AUTH_TOKEN: credentialOf(provider) };
580
588
  }
581
589
  function buildClaudeOverride(provider, endpoints, model) {
582
590
  return {
583
591
  args: ["--model", model],
584
592
  env: {
585
- ...claudeProviderEnv(provider.kind, endpoints, provider.apiKey),
593
+ ...claudeProviderEnv(provider, endpoints),
586
594
  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
587
595
  ANTHROPIC_DEFAULT_OPUS_MODEL: model,
588
596
  ANTHROPIC_DEFAULT_SONNET_MODEL: model,
@@ -591,16 +599,17 @@ function buildClaudeOverride(provider, endpoints, model) {
591
599
  }
592
600
  };
593
601
  }
594
- function claudeAuthHeaders({ kind, apiKey }) {
595
- if (kind === "foundry") return { "x-api-key": apiKey };
596
- return { Authorization: `Bearer ${apiKey}`, "x-api-key": apiKey };
602
+ function claudeAuthHeaders(provider) {
603
+ if ("token" in provider) return { Authorization: `Bearer ${provider.token}` };
604
+ if (provider.kind === "foundry") return { "x-api-key": provider.apiKey };
605
+ return { Authorization: `Bearer ${provider.apiKey}`, "x-api-key": provider.apiKey };
597
606
  }
598
607
  function buildProbe({ harness, model }, provider) {
599
608
  const endpoints = deriveEndpoints(provider.kind, provider.baseUrl);
600
609
  if (harness === "codex")
601
610
  return {
602
611
  url: `${endpoints.responses}/responses`,
603
- headers: { Authorization: `Bearer ${provider.apiKey}` },
612
+ headers: { Authorization: `Bearer ${credentialOf(provider)}` },
604
613
  body: { model, input: "ping", max_output_tokens: 16 }
605
614
  };
606
615
  return {
@@ -618,7 +627,24 @@ function buildOverride({ harness, model }, provider) {
618
627
  // src/features/review/ci.ts
619
628
  var reviewWorkflowRef = "MakerXStudio/yoke/.github/workflows/review.yml@main";
620
629
  var callerWorkflowPath = join3(".github", "workflows", "review-pr.yml");
621
- var ciSecrets = ["REVIEW_CI_BASE_URL", "REVIEW_CI_API_KEY"];
630
+ var baseUrlSecret = "REVIEW_CI_BASE_URL";
631
+ var apiKeySecret = "REVIEW_CI_API_KEY";
632
+ function workflowSecrets({ entra }) {
633
+ return entra ? [baseUrlSecret] : [baseUrlSecret, apiKeySecret];
634
+ }
635
+ function entraInputs({ azureClientId, azureTenantId, environment }, provider, errors) {
636
+ const clientId = azureClientId?.trim();
637
+ const tenantId = azureTenantId?.trim();
638
+ const env = environment?.trim();
639
+ if (!clientId && !tenantId) {
640
+ if (env) errors.push("--environment needs --azure-client-id and --azure-tenant-id");
641
+ return [];
642
+ }
643
+ if (!clientId) errors.push("--azure-client-id is required with --azure-tenant-id");
644
+ if (!tenantId) errors.push("--azure-tenant-id is required with --azure-client-id");
645
+ if (provider !== "foundry") errors.push("--azure-client-id and --azure-tenant-id need --provider foundry");
646
+ return [["azure-client-id", clientId ?? ""], ["azure-tenant-id", tenantId ?? ""], ...env ? [["environment", env]] : []];
647
+ }
622
648
  var slots = [
623
649
  { slot: "reviewer1", defaultHarness: "claude", required: true },
624
650
  { slot: "reviewer2", defaultHarness: "codex", required: true },
@@ -641,19 +667,25 @@ function slotInputs(settings, errors) {
641
667
  }
642
668
  return inputs;
643
669
  }
644
- function callerWorkflow(settings, providerFlag) {
670
+ function callerWorkflow(settings, providerFlag, entraFlags = {}) {
645
671
  const errors = [];
646
672
  const provider = providerFlag ?? settings["review.reviewer1.provider"] ?? "litellm";
647
673
  if (!isProviderKind(provider)) errors.push(`provider "${provider}" must be ${providerKinds.join(" or ")}; pass --provider`);
648
674
  const inputs = slotInputs(settings, errors);
675
+ const entra = entraInputs(entraFlags, provider, errors);
649
676
  if (errors.length || !isProviderKind(provider))
650
677
  throw new ReviewError(["Cannot write the review workflow:", ...errors.map((error) => `- ${error}`)].join("\n"));
651
- return { provider, inputs: [["provider", provider], ...inputs] };
678
+ const environment = entraFlags.environment?.trim();
679
+ return {
680
+ provider,
681
+ inputs: [["provider", provider], ...inputs, ...entra],
682
+ ...entra.length > 0 && { entra: { clientId: entraFlags.azureClientId?.trim() ?? "", ...environment && { environment } } }
683
+ };
652
684
  }
653
685
  function yamlValue(value) {
654
686
  return /^[\w./:@-]+$/.test(value) && !/^([-+.\d_eE]+|0[xo][\da-f]+|true|false|yes|no|on|off|y|n|null|~)$/i.test(value) ? value : JSON.stringify(value);
655
687
  }
656
- function renderCallerWorkflow({ inputs }) {
688
+ function renderCallerWorkflow(workflow) {
657
689
  return [
658
690
  "name: Review PR",
659
691
  "",
@@ -664,22 +696,56 @@ function renderCallerWorkflow({ inputs }) {
664
696
  "permissions:",
665
697
  " contents: read",
666
698
  " pull-requests: write",
699
+ ...workflow.entra ? [" id-token: write"] : [],
667
700
  "",
668
701
  "jobs:",
669
702
  " review:",
670
703
  ` uses: ${reviewWorkflowRef}`,
671
704
  " with:",
672
- ...inputs.map(([name, value]) => ` ${name}: ${yamlValue(value)}`),
673
- " secrets: inherit",
705
+ ...workflow.inputs.map(([name, value]) => ` ${name}: ${yamlValue(value)}`),
706
+ " secrets:",
707
+ ...workflowSecrets(workflow).map((secret2) => ` ${secret2}: \${{ secrets.${secret2} }}`),
674
708
  ""
675
709
  ].join("\n");
676
710
  }
677
711
  function shellQuote(value) {
678
712
  return `'${value.replaceAll("'", `'\\''`)}'`;
679
713
  }
680
- function secretCommands(settings, provider) {
681
- const baseUrl = settings[`${provider}.baseUrl`];
682
- return [`gh secret set REVIEW_CI_BASE_URL${baseUrl ? ` --body ${shellQuote(baseUrl)}` : ""}`, "gh secret set REVIEW_CI_API_KEY"];
714
+ function secretCommands(settings, workflow) {
715
+ const baseUrl = settings[`${workflow.provider}.baseUrl`];
716
+ return workflowSecrets(workflow).map(
717
+ (secret2) => secret2 === baseUrlSecret && baseUrl ? `gh secret set ${secret2} --body ${shellQuote(baseUrl)}` : `gh secret set ${secret2}`
718
+ );
719
+ }
720
+ var githubOidcIssuer = "https://token.actions.githubusercontent.com";
721
+ var entraTokenExchangeAudience = "api://AzureADTokenExchange";
722
+ function federatedCredential(repo, environment) {
723
+ return {
724
+ issuer: githubOidcIssuer,
725
+ audience: entraTokenExchangeAudience,
726
+ subject: environment ? `repo:${repo}:environment:${environment}` : `repo:${repo}:pull_request`
727
+ };
728
+ }
729
+ function federatedCredentialCommand(clientId, credential) {
730
+ const parameters = JSON.stringify({
731
+ name: "yoke-review-ci",
732
+ issuer: credential.issuer,
733
+ subject: credential.subject,
734
+ audiences: [credential.audience]
735
+ });
736
+ return `az ad app federated-credential create --id ${shellQuote(clientId)} --parameters ${shellQuote(parameters)}`;
737
+ }
738
+ var unknownRepo = "<owner>/<repo>";
739
+ function githubRepo(cwd = process.cwd()) {
740
+ try {
741
+ return execFileSync2("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], {
742
+ cwd,
743
+ encoding: "utf8",
744
+ stdio: ["ignore", "pipe", "ignore"]
745
+ }).trim() || unknownRepo;
746
+ } catch {
747
+ return unknownRepo;
748
+ }
683
749
  }
684
750
  function repoRoot(cwd = process.cwd()) {
685
751
  try {
@@ -698,131 +764,34 @@ function writeCallerWorkflow(root, content, force) {
698
764
  writeFileSync2(path, content);
699
765
  return "wrote";
700
766
  }
701
- function reviewCiReport(outcome, commands) {
767
+ function entraSetup({ clientId, environment }, repo) {
768
+ const credential = federatedCredential(repo, environment);
769
+ return [
770
+ "",
771
+ `Instead of REVIEW_CI_API_KEY, the job signs in to Foundry as Entra app ${clientId} with its GitHub OIDC token. On that app, create a federated credential with:`,
772
+ ` issuer: ${credential.issuer}`,
773
+ ` audience: ${credential.audience}`,
774
+ ` subject: ${credential.subject}`,
775
+ "for example with:",
776
+ ` ${federatedCredentialCommand(clientId, credential)}`,
777
+ "and give the app the Cognitive Services User role on the Foundry resource.",
778
+ ...environment ? [`The job runs in the ${environment} environment; create it on the repo if it does not exist.`] : []
779
+ ];
780
+ }
781
+ function reviewCiReport(outcome, settings, workflow, repo) {
702
782
  const action = outcome === "wrote" ? "wrote" : "kept";
783
+ const secrets = workflowSecrets(workflow);
703
784
  return [
704
785
  `${action} ${callerWorkflowPath}`,
705
786
  "",
706
- `The workflow reads ${ciSecrets.join(" and ")}. Set them on the repo (or the org) with:`,
707
- ...commands.map((command) => ` ${command}`),
787
+ `The workflow reads ${secrets.join(" and ")}. Set ${secrets.length > 1 ? "them" : "it"} on the repo (or the org) with:`,
788
+ ...secretCommands(settings, workflow).map((command) => ` ${command}`),
789
+ ...workflow.entra ? entraSetup(workflow.entra, repo()) : [],
708
790
  "",
709
791
  "Commit and push the workflow; each PR opened from this repo then gets a review."
710
792
  ].join("\n");
711
793
  }
712
794
 
713
- // src/features/review/slots.ts
714
- var slotNames = ["reviewer1", "reviewer2", "synthesis"];
715
- function readProvider(settings, kind) {
716
- const baseUrlKey = `${kind}.baseUrl`;
717
- const apiKeyKey = `${kind}.apiKey`;
718
- const baseUrl = settings[baseUrlKey];
719
- const apiKey = settings[apiKeyKey];
720
- if (baseUrl && apiKey) return { kind, baseUrl, apiKey };
721
- return [
722
- baseUrl ? null : `${baseUrlKey} is not set; ${howToSet(baseUrlKey, "<url>")}`,
723
- apiKey ? null : `${apiKeyKey} is not set; ${howToSet(apiKeyKey, "<key>")}`
724
- ].filter((error) => error !== null);
725
- }
726
- function readSpec(settings, slot, errors) {
727
- const keys = {
728
- harness: `review.${slot}.harness`,
729
- provider: `review.${slot}.provider`,
730
- model: `review.${slot}.model`
731
- };
732
- const harness = settings[keys.harness];
733
- const provider = settings[keys.provider];
734
- const model = settings[keys.model];
735
- if (!harness && !provider && !model) return void 0;
736
- if (harness && !isHarness(harness))
737
- errors.push(`${keys.harness} "${harness}" must be claude or codex; ${howToSet(keys.harness, "<claude|codex>")}`);
738
- if (provider && !isProviderKind(provider))
739
- errors.push(
740
- `${keys.provider} "${provider}" must be ${providerKinds.join(" or ")}; ${howToSet(keys.provider, `<${providerKinds.join("|")}>`)}`
741
- );
742
- if (provider && !model)
743
- errors.push(`${keys.model} is required when ${keys.provider} is set; ${howToSet(keys.model, "<model>")} (see \`yoke models\`)`);
744
- return {
745
- ...harness && isHarness(harness) ? { harness } : {},
746
- ...provider && isProviderKind(provider) ? { provider } : {},
747
- ...model ? { model } : {}
748
- };
749
- }
750
- function toSlot({ harness: chosen, model, provider: kind }, fallback, providers) {
751
- const harness = chosen ?? fallback;
752
- if (!model) return { harness };
753
- const slot = { harness, model };
754
- const provider = kind && providers[kind];
755
- if (provider) return { ...slot, provider: kind, override: buildOverride(slot, provider), probe: buildProbe(slot, provider) };
756
- return { ...slot, override: ownAuthOverride(slot) };
757
- }
758
- function resolveSlots(settings) {
759
- const specs = {};
760
- const errors = [];
761
- for (const name of slotNames) specs[name] = readSpec(settings, name, errors);
762
- const providers = {};
763
- for (const kind of new Set(Object.values(specs).flatMap((spec) => spec?.provider ? [spec.provider] : []))) {
764
- const read = readProvider(settings, kind);
765
- if (Array.isArray(read)) errors.push(...read);
766
- else providers[kind] = read;
767
- }
768
- if (errors.length) throw new ReviewError(["Review settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
769
- const reviewer1 = toSlot(specs.reviewer1 ?? {}, "claude", providers);
770
- return {
771
- reviewer1,
772
- reviewer2: toSlot(specs.reviewer2 ?? {}, "codex", providers),
773
- synthesis: specs.synthesis && toSlot(specs.synthesis, reviewer1.harness, providers)
774
- };
775
- }
776
-
777
- // src/features/review/models.ts
778
- var timeout = 6e4;
779
- var maxBodyChars = 300;
780
- var providerWidth = Math.max(...providerKinds.map((kind) => kind.length));
781
- function modelsRequest({ kind, baseUrl, apiKey }) {
782
- const url = `${deriveEndpoints(kind, baseUrl).responses}/models`;
783
- return { url, headers: kind === "foundry" ? { "api-key": apiKey } : { Authorization: `Bearer ${apiKey}` } };
784
- }
785
- function isModelList(value) {
786
- return typeof value === "object" && value !== null && "data" in value && Array.isArray(value.data) && value.data.every((model) => typeof model === "object" && model !== null && "id" in model && typeof model.id === "string");
787
- }
788
- async function get(url, headers, fetchModels) {
789
- try {
790
- return await fetchModels(url, { headers, signal: AbortSignal.timeout(timeout) });
791
- } catch (error) {
792
- throw new ReviewError(`Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`);
793
- }
794
- }
795
- async function providerModels(provider, fetchModels) {
796
- const { url, headers } = modelsRequest(provider);
797
- const response = await get(url, headers, fetchModels);
798
- if (!response.ok)
799
- throw new ReviewError(`${url} returned HTTP ${response.status}: ${(await response.text()).trim().slice(0, maxBodyChars)}`);
800
- const body = await response.json().catch(() => void 0);
801
- if (!isModelList(body)) throw new ReviewError(`${url} returned an unexpected model list`);
802
- return body.data.map(({ id }) => `${provider.kind.padEnd(providerWidth)} ${id}`);
803
- }
804
- function isConfigured(settings, kind) {
805
- return Boolean(settings[`${kind}.baseUrl`] || settings[`${kind}.apiKey`]);
806
- }
807
- async function listModels(settings, fetchModels = fetch) {
808
- const kinds = providerKinds.filter((kind) => isConfigured(settings, kind));
809
- if (!kinds.length)
810
- throw new ReviewError(
811
- `No model provider is configured; set ${providerKinds.map((kind) => `${kind}.baseUrl and ${kind}.apiKey`).join(", or ")} (see \`yoke config\`)`
812
- );
813
- const providers = kinds.map((kind) => readProvider(settings, kind));
814
- const errors = providers.flatMap((provider) => Array.isArray(provider) ? provider : []);
815
- if (errors.length) throw new ReviewError(["Model provider settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
816
- const ready = providers.filter((provider) => !Array.isArray(provider));
817
- const lists = await Promise.all(ready.map((provider) => providerModels(provider, fetchModels)));
818
- return lists.flat().toSorted();
819
- }
820
-
821
- // src/features/review/review.ts
822
- import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
823
- import { homedir as homedir2, tmpdir } from "os";
824
- import { join as join8 } from "path";
825
-
826
795
  // src/features/review/comments.ts
827
796
  var markerPrefix = "<!-- yoke-review:";
828
797
  function slug(text2) {
@@ -912,10 +881,22 @@ function formatSynthesis({ summary, findings }) {
912
881
  return lines.join("\n").trimEnd();
913
882
  }
914
883
 
915
- // src/features/review/request.ts
884
+ // src/features/review/diff.ts
916
885
  var fileHeader = /^\+\+\+ (.+)$/;
917
886
  var hunkHeader = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
918
- var gutterWidth = 6;
887
+ var escapes = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
888
+ function parseDiff(diff) {
889
+ let file = null;
890
+ const hunk = { next: 0, oldLeft: 0, newLeft: 0 };
891
+ return diff.split("\n").map((text2) => {
892
+ if (hunk.oldLeft > 0 || hunk.newLeft > 0) return { text: text2, file, line: hunkLine(text2, hunk) };
893
+ const header = text2.match(fileHeader)?.[1];
894
+ if (header) file = header === "/dev/null" ? null : diffPath(header);
895
+ const range = text2.match(hunkHeader);
896
+ if (range) Object.assign(hunk, { oldLeft: Number(range[1] ?? 1), next: Number(range[2]), newLeft: Number(range[3] ?? 1) });
897
+ return { text: text2, file, line: null };
898
+ });
899
+ }
919
900
  function hunkLine(text2, hunk) {
920
901
  if (text2.startsWith("-")) hunk.oldLeft--;
921
902
  else if (text2.startsWith("+")) hunk.newLeft--;
@@ -925,33 +906,46 @@ function hunkLine(text2, hunk) {
925
906
  } else return null;
926
907
  return text2.startsWith("-") ? null : hunk.next++;
927
908
  }
928
- var escapes = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
929
- function escapedBytes(escape) {
930
- const code = escape.slice(1);
931
- if (/^[0-7]{3}$/.test(code)) return Buffer.from([parseInt(code, 8)]);
932
- return Buffer.from([escapes[code] ?? code.charCodeAt(0)]);
909
+ function diffPath(raw) {
910
+ const quoted = raw.match(/^"(.*)"$/)?.[1];
911
+ const path = quoted === void 0 ? raw : unquote(quoted);
912
+ return path.startsWith("b/") ? path.slice(2) : path;
933
913
  }
934
914
  function unquote(quoted) {
935
915
  const parts = quoted.split(/(\\[0-7]{3}|\\.)/);
936
916
  return Buffer.concat(parts.map((part, i) => i % 2 ? escapedBytes(part) : Buffer.from(part))).toString("utf8");
937
917
  }
938
- function diffPath(raw) {
939
- const quoted = raw.match(/^"(.*)"$/)?.[1];
940
- const path = quoted === void 0 ? raw : unquote(quoted);
941
- return path.startsWith("b/") ? path.slice(2) : path;
918
+ function escapedBytes(escape) {
919
+ const code = escape.slice(1);
920
+ if (/^[0-7]{3}$/.test(code)) return Buffer.from([parseInt(code, 8)]);
921
+ return Buffer.from([escapes[code] ?? code.charCodeAt(0)]);
942
922
  }
943
- function parseDiff(diff) {
944
- let file = null;
945
- const hunk = { next: 0, oldLeft: 0, newLeft: 0 };
946
- return diff.split("\n").map((text2) => {
947
- if (hunk.oldLeft > 0 || hunk.newLeft > 0) return { text: text2, file, line: hunkLine(text2, hunk) };
948
- const header = text2.match(fileHeader)?.[1];
949
- if (header) file = header === "/dev/null" ? null : diffPath(header);
950
- const range = text2.match(hunkHeader);
951
- if (range) Object.assign(hunk, { oldLeft: Number(range[1] ?? 1), next: Number(range[2]), newLeft: Number(range[3] ?? 1) });
952
- return { text: text2, file, line: null };
953
- });
923
+
924
+ // src/features/review/selfReviewNote.ts
925
+ var yokeCaller = /\buses:\s*['"]?MakerXStudio\/yoke\/\.github\/workflows\/review\.yml@/i;
926
+ function editsYokeCaller(diff) {
927
+ return parseDiff(diff).some(({ text: text2, file, line }) => line !== null && file?.startsWith(".github/workflows/") && yokeCaller.test(text2));
928
+ }
929
+ function selfReviewNote(diff) {
930
+ if (!editsYokeCaller(diff)) return "";
931
+ return `## Yoke's own review workflow
932
+
933
+ This change adds or edits a workflow that calls \`MakerXStudio/yoke/.github/workflows/review.yml\`, the CI setup for this review. Review it like any other file, but these are yoke's deliberate design: do not raise them as findings, keep them in a consolidated review, or mention them in a summary.
934
+
935
+ - Calling the workflow at \`@main\` rather than a tag or SHA.
936
+ - The trigger types, including leaving out \`synchronize\`: a review is re-run by marking the PR as draft and then ready for review.
937
+ - Draft PRs being reviewed twice: the reusable workflow already skips drafts.
938
+
939
+ Real defects in it are still findings, such as:
940
+
941
+ - Passing every secret with \`secrets: inherit\` instead of only \`REVIEW_CI_BASE_URL\` and \`REVIEW_CI_API_KEY\`.
942
+ - An input the reusable workflow does not declare.
943
+ - No \`permissions:\` block limiting the token to \`contents: read\` and \`pull-requests: write\`, plus \`id-token: write\` with \`azure-client-id\`. The reusable workflow sets no permissions of its own, so the caller's block is the only limit on the token its agents run with.
944
+ `;
954
945
  }
946
+
947
+ // src/features/review/request.ts
948
+ var gutterWidth = 6;
955
949
  function gutter(line) {
956
950
  return `${(line === null ? "" : String(line)).padStart(gutterWidth)} `;
957
951
  }
@@ -970,6 +964,7 @@ function commentableLines(diff) {
970
964
  function buildRequest(pr2, diff, prior = []) {
971
965
  const files = pr2.files.map((f) => `- ${f.path}`).join("\n") || "(none)";
972
966
  const priorSection = formatPriorComments(prior);
967
+ const note = selfReviewNote(diff);
973
968
  return `# Code review request
974
969
 
975
970
  - PR: #${pr2.number} ${pr2.title}
@@ -987,7 +982,8 @@ Each added or context line is prefixed with a left gutter showing its line numbe
987
982
  \`\`\`diff
988
983
  ${withNewFileLineNumbers(diff.trimEnd())}
989
984
  \`\`\`
990
- ${priorSection && `
985
+ ${note && `
986
+ ${note}`}${priorSection && `
991
987
  ${priorSection}`}`;
992
988
  }
993
989
 
@@ -1181,6 +1177,189 @@ async function reportFailure(pr2, reason, ui, deps) {
1181
1177
  }
1182
1178
  }
1183
1179
 
1180
+ // src/features/review/slots.ts
1181
+ var slotNames = ["reviewer1", "reviewer2", "synthesis"];
1182
+ function notSet(key, secret2, placeholder, env) {
1183
+ return isCi(env) ? `Secret ${secret2} is not set` : `${key} is not set; ${howToSet(key, placeholder)}`;
1184
+ }
1185
+ function readProvider(settings, kind, env = process.env) {
1186
+ const baseUrlKey = `${kind}.baseUrl`;
1187
+ const apiKeyKey = `${kind}.apiKey`;
1188
+ const baseUrl = settings[baseUrlKey];
1189
+ const apiKey = settings[apiKeyKey];
1190
+ const token = kind === "foundry" ? settings["foundry.token"] : void 0;
1191
+ if (baseUrl && token) return { kind, baseUrl, token };
1192
+ if (baseUrl && apiKey) return { kind, baseUrl, apiKey };
1193
+ return [
1194
+ baseUrl ? null : notSet(baseUrlKey, baseUrlSecret, "<url>", env),
1195
+ apiKey || token ? null : notSet(apiKeyKey, apiKeySecret, "<key>", env)
1196
+ ].filter((error) => error !== null);
1197
+ }
1198
+ function readSpec(settings, slot, errors) {
1199
+ const keys = {
1200
+ harness: `review.${slot}.harness`,
1201
+ provider: `review.${slot}.provider`,
1202
+ model: `review.${slot}.model`
1203
+ };
1204
+ const harness = settings[keys.harness];
1205
+ const provider = settings[keys.provider];
1206
+ const model = settings[keys.model];
1207
+ if (!harness && !provider && !model) return void 0;
1208
+ if (harness && !isHarness(harness))
1209
+ errors.push(`${keys.harness} "${harness}" must be claude or codex; ${howToSet(keys.harness, "<claude|codex>")}`);
1210
+ if (provider && !isProviderKind(provider))
1211
+ errors.push(
1212
+ `${keys.provider} "${provider}" must be ${providerKinds.join(" or ")}; ${howToSet(keys.provider, `<${providerKinds.join("|")}>`)}`
1213
+ );
1214
+ if (provider && !model)
1215
+ errors.push(`${keys.model} is required when ${keys.provider} is set; ${howToSet(keys.model, "<model>")} (see \`yoke models\`)`);
1216
+ return {
1217
+ ...harness && isHarness(harness) ? { harness } : {},
1218
+ ...provider && isProviderKind(provider) ? { provider } : {},
1219
+ ...model ? { model } : {}
1220
+ };
1221
+ }
1222
+ function toSlot({ harness: chosen, model, provider: kind }, fallback, providers) {
1223
+ const harness = chosen ?? fallback;
1224
+ if (!model) return { harness };
1225
+ const slot = { harness, model };
1226
+ const provider = kind && providers[kind];
1227
+ if (provider) return { ...slot, provider: kind, override: buildOverride(slot, provider), probe: buildProbe(slot, provider) };
1228
+ return { ...slot, override: ownAuthOverride(slot) };
1229
+ }
1230
+ function resolveSlots(settings) {
1231
+ const specs = {};
1232
+ const errors = [];
1233
+ for (const name of slotNames) specs[name] = readSpec(settings, name, errors);
1234
+ const providers = {};
1235
+ for (const kind of new Set(Object.values(specs).flatMap((spec) => spec?.provider ? [spec.provider] : []))) {
1236
+ const read = readProvider(settings, kind);
1237
+ if (Array.isArray(read)) errors.push(...read);
1238
+ else providers[kind] = read;
1239
+ }
1240
+ if (errors.length) throw new ReviewError(["Review settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
1241
+ const reviewer1 = toSlot(specs.reviewer1 ?? {}, "claude", providers);
1242
+ return {
1243
+ reviewer1,
1244
+ reviewer2: toSlot(specs.reviewer2 ?? {}, "codex", providers),
1245
+ synthesis: specs.synthesis && toSlot(specs.synthesis, reviewer1.harness, providers)
1246
+ };
1247
+ }
1248
+
1249
+ // src/features/review/models.ts
1250
+ var timeout = 6e4;
1251
+ var maxBodyChars = 300;
1252
+ var providerWidth = Math.max(...providerKinds.map((kind) => kind.length));
1253
+ function modelsRequest(provider) {
1254
+ const url = `${deriveEndpoints(provider.kind, provider.baseUrl).responses}/models`;
1255
+ if (provider.kind === "foundry" && "apiKey" in provider) return { url, headers: { "api-key": provider.apiKey } };
1256
+ return { url, headers: { Authorization: `Bearer ${credentialOf(provider)}` } };
1257
+ }
1258
+ function isModelList(value) {
1259
+ return typeof value === "object" && value !== null && "data" in value && Array.isArray(value.data) && value.data.every((model) => typeof model === "object" && model !== null && "id" in model && typeof model.id === "string");
1260
+ }
1261
+ async function get(url, headers, fetchModels) {
1262
+ try {
1263
+ return await fetchModels(url, { headers, signal: AbortSignal.timeout(timeout) });
1264
+ } catch (error) {
1265
+ throw new ReviewError(`Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`);
1266
+ }
1267
+ }
1268
+ async function providerModels(provider, fetchModels) {
1269
+ const { url, headers } = modelsRequest(provider);
1270
+ const response = await get(url, headers, fetchModels);
1271
+ if (!response.ok)
1272
+ throw new ReviewError(`${url} returned HTTP ${response.status}: ${(await response.text()).trim().slice(0, maxBodyChars)}`);
1273
+ const body = await response.json().catch(() => void 0);
1274
+ if (!isModelList(body)) throw new ReviewError(`${url} returned an unexpected model list`);
1275
+ return body.data.map(({ id }) => `${provider.kind.padEnd(providerWidth)} ${id}`);
1276
+ }
1277
+ function isConfigured(settings, kind) {
1278
+ return Boolean(settings[`${kind}.baseUrl`] || settings[`${kind}.apiKey`] || kind === "foundry" && settings["foundry.token"]);
1279
+ }
1280
+ async function listModels(settings, fetchModels = fetch) {
1281
+ const kinds = providerKinds.filter((kind) => isConfigured(settings, kind));
1282
+ if (!kinds.length)
1283
+ throw new ReviewError(
1284
+ `No model provider is configured; set ${providerKinds.map((kind) => `${kind}.baseUrl and ${kind}.apiKey`).join(", or ")} (see \`yoke config\`)`
1285
+ );
1286
+ const providers = kinds.map((kind) => readProvider(settings, kind));
1287
+ const errors = providers.flatMap((provider) => Array.isArray(provider) ? provider : []);
1288
+ if (errors.length) throw new ReviewError(["Model provider settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
1289
+ const ready = providers.filter((provider) => !Array.isArray(provider));
1290
+ const lists = await Promise.all(ready.map((provider) => providerModels(provider, fetchModels)));
1291
+ return lists.flat().toSorted();
1292
+ }
1293
+
1294
+ // src/features/review/review.ts
1295
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
1296
+ import { homedir as homedir2, tmpdir } from "os";
1297
+ import { join as join8 } from "path";
1298
+
1299
+ // src/features/review/entra.ts
1300
+ var tokenExchangeAudience = "api://AzureADTokenExchange";
1301
+ var cognitiveServicesScope = "https://cognitiveservices.azure.com/.default";
1302
+ var timeout2 = 3e4;
1303
+ function isRecord(value) {
1304
+ return typeof value === "object" && value !== null;
1305
+ }
1306
+ function errorDetail(json, text2) {
1307
+ const detail = [json.error_description, json.error, json.message].find((value) => typeof value === "string");
1308
+ return typeof detail === "string" ? detail : text2.trim();
1309
+ }
1310
+ async function readJson(response, failure) {
1311
+ const text2 = await response.text();
1312
+ let body;
1313
+ try {
1314
+ body = JSON.parse(text2);
1315
+ } catch {
1316
+ throw new ReviewError(`${failure}: HTTP ${response.status}: ${text2.trim().slice(0, 300)}`);
1317
+ }
1318
+ const json = isRecord(body) ? body : {};
1319
+ if (response.ok) return json;
1320
+ throw new ReviewError(`${failure}: HTTP ${response.status}: ${errorDetail(json, text2).slice(0, 300)}`);
1321
+ }
1322
+ async function githubOidcToken(env, fetchFn) {
1323
+ const { ACTIONS_ID_TOKEN_REQUEST_URL: url, ACTIONS_ID_TOKEN_REQUEST_TOKEN: bearer } = env;
1324
+ if (!url || !bearer) throw new ReviewError("No GitHub OIDC token endpoint; grant id-token: write in the calling workflow's permissions");
1325
+ const request = new URL(url);
1326
+ request.searchParams.set("audience", tokenExchangeAudience);
1327
+ const response = await fetchFn(request, { headers: { Authorization: `bearer ${bearer}` }, signal: AbortSignal.timeout(timeout2) });
1328
+ const { value } = await readJson(response, "GitHub OIDC token request failed");
1329
+ if (typeof value !== "string" || !value) throw new ReviewError("GitHub OIDC token response had no value");
1330
+ return value;
1331
+ }
1332
+ async function exchangeForEntraToken(clientId, tenantId, env, fetchFn = fetch) {
1333
+ const assertion = await githubOidcToken(env, fetchFn);
1334
+ const response = await fetchFn(`https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`, {
1335
+ method: "POST",
1336
+ body: new URLSearchParams({
1337
+ grant_type: "client_credentials",
1338
+ client_id: clientId,
1339
+ scope: cognitiveServicesScope,
1340
+ client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
1341
+ client_assertion: assertion
1342
+ }),
1343
+ signal: AbortSignal.timeout(timeout2)
1344
+ });
1345
+ const { access_token: token } = await readJson(response, `Entra token exchange for client ${clientId} failed`);
1346
+ if (typeof token !== "string" || !token) throw new ReviewError("Entra token response had no access_token");
1347
+ return token;
1348
+ }
1349
+ function usesFoundry(settings) {
1350
+ return ["reviewer1", "reviewer2", "synthesis"].some((slot) => settings[`review.${slot}.provider`] === "foundry");
1351
+ }
1352
+ async function withEntraToken(settings, { env, fetch: fetchFn = fetch, mask }) {
1353
+ const clientId = settings["foundry.azureClientId"];
1354
+ const tenantId = settings["foundry.azureTenantId"];
1355
+ if (!clientId && !tenantId) return settings;
1356
+ if (!clientId || !tenantId) throw new ReviewError("Entra auth needs both foundry.azureClientId and foundry.azureTenantId");
1357
+ if (settings["foundry.token"] || !usesFoundry(settings) || !isCi(env)) return settings;
1358
+ const token = await exchangeForEntraToken(clientId, tenantId, env, fetchFn);
1359
+ mask(token);
1360
+ return { ...settings, "foundry.token": token };
1361
+ }
1362
+
1184
1363
  // src/features/review/harness.ts
1185
1364
  import { spawn } from "child_process";
1186
1365
  import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
@@ -1198,11 +1377,11 @@ function count(source, key) {
1198
1377
  const value = key ? source[key] : void 0;
1199
1378
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1200
1379
  }
1201
- function isRecord(value) {
1380
+ function isRecord2(value) {
1202
1381
  return typeof value === "object" && value !== null && !Array.isArray(value);
1203
1382
  }
1204
1383
  function parseUsage(raw, keys) {
1205
- if (!isRecord(raw)) return void 0;
1384
+ if (!isRecord2(raw)) return void 0;
1206
1385
  const usage = {};
1207
1386
  for (const field2 of fields2) {
1208
1387
  const value = count(raw, keys[field2]);
@@ -1877,6 +2056,10 @@ function logged(ui, append) {
1877
2056
  }
1878
2057
 
1879
2058
  // src/features/review/review.ts
2059
+ function maskInCi(secret2) {
2060
+ if (isCi()) process.stdout.write(`::add-mask::${secret2}
2061
+ `);
2062
+ }
1880
2063
  function reviewRunDir(pr2, env = process.env, home = homedir2()) {
1881
2064
  const [, owner = "", repo = ""] = new URL(pr2.url).pathname.split("/");
1882
2065
  const run2 = `${pr2.number}-${pr2.baseRefOid}-${pr2.headRefOid}`;
@@ -1916,7 +2099,7 @@ async function reviewPr(number, settings, { post, force }, io) {
1916
2099
  io.out(text2);
1917
2100
  };
1918
2101
  const { log } = ui;
1919
- const slots2 = resolveSlots(settings);
2102
+ const slots2 = resolveSlots(await withEntraToken(settings, { env: process.env, mask: maskInCi }));
1920
2103
  const pr2 = await timed(ui, number ? `fetching PR #${number}` : "fetching this branch's PR", () => fetchPr(number));
1921
2104
  log(`#${pr2.number} ${pr2.title}`);
1922
2105
  const [diff, prior] = await timed(
@@ -2006,9 +2189,13 @@ var reviewCiSkill = {
2006
2189
  body: `
2007
2190
  Run \`yoke review-ci $ARGUMENTS\`. If it reports that a model is required, run \`yoke models\` to list what the provider serves, ask the user which model each reviewer should run, and rerun with \`--reviewer-1-model\` and \`--reviewer-2-model\`. If it reports the workflow already exists and differs, show the user the difference and ask before rerunning with \`--force\`.
2008
2191
 
2009
- Then set the two secrets it printed. Run \`gh secret list\` first; if either secret is missing, ask whether it is already set at the org level before setting it on the repo.
2192
+ With \`--provider foundry\`, if the Foundry resource has local (API key) auth disabled or the user prefers Entra, rerun with \`--azure-client-id\` and \`--azure-tenant-id\` of the Entra app the job signs in as, plus \`--environment\` if the job should run in a GitHub environment. Ask the user for these values; don't guess them.
2193
+
2194
+ Then set the secrets it printed. Run \`gh secret list\` first; if a secret is missing, ask whether it is already set at the org level before setting it on the repo.
2010
2195
  - \`REVIEW_CI_BASE_URL\` is not secret: confirm the URL with the user and run the printed command.
2011
- - \`REVIEW_CI_API_KEY\` must never enter this conversation: ask the user to run \`gh secret set REVIEW_CI_API_KEY\` in their own terminal, outside this session, and paste the key at its prompt.
2196
+ - \`REVIEW_CI_API_KEY\` must never enter this conversation: ask the user to run \`gh secret set REVIEW_CI_API_KEY\` in their own terminal, outside this session, and paste the key at its prompt. With Entra auth there is no API key to set.
2197
+
2198
+ With Entra auth, show the user the federated credential it printed (issuer, audience, subject) and the \`az ad app federated-credential create\` command, and ask whether to run it or whether they will add the credential in the Azure portal. The app also needs the Cognitive Services User role on the Foundry resource, and the environment, if given, must exist on the repo.
2012
2199
 
2013
2200
  Finish by running \`gh secret list\` and telling the user to commit and push the workflow file.
2014
2201
 
@@ -2116,14 +2303,20 @@ addSettingOptions(
2116
2303
  });
2117
2304
  var ciKeys = settingKeys.filter((key) => /^review\.\w+\.(harness|model)$/.test(key));
2118
2305
  addSettingOptions(
2119
- program.command("review-ci").description(`Write ${callerWorkflowPath} so each PR in this repo is reviewed in CI, then print the gh secret set commands it needs`).option("--provider <provider>", "litellm or foundry; every slot uses it in CI. Default: review.reviewer1.provider, else litellm").option("--force", `overwrite an existing ${callerWorkflowPath} that differs`),
2306
+ program.command("review-ci").description(
2307
+ `Write ${callerWorkflowPath} so each PR in this repo is reviewed in CI, then print the secrets, or Entra federated credential, it needs`
2308
+ ).option("--provider <provider>", "litellm or foundry; every slot uses it in CI. Default: review.reviewer1.provider, else litellm").option(
2309
+ "--azure-client-id <id>",
2310
+ "Entra app (client) ID to sign in to Foundry with GitHub OIDC instead of an API key; needs --provider foundry"
2311
+ ).option("--azure-tenant-id <id>", "Entra tenant ID of that app").option("--environment <name>", "GitHub environment the review job runs in, matching the app's federated credential subject").option("--force", `overwrite an existing ${callerWorkflowPath} that differs`),
2120
2312
  ciKeys
2121
2313
  ).addHelpText("after", settingsHelp).action((options) => {
2122
2314
  try {
2123
2315
  const settings = resolveSettings(flagSettings(options, ciKeys), process.env, readUserConfig(userConfigPath()));
2124
- const workflow = callerWorkflow(settings, options.provider);
2125
- const outcome = writeCallerWorkflow(repoRoot(), renderCallerWorkflow(workflow), options.force ?? false);
2126
- console.log(reviewCiReport(outcome, secretCommands(settings, workflow.provider)));
2316
+ const workflow = callerWorkflow(settings, options.provider, options);
2317
+ const root = repoRoot();
2318
+ const outcome = writeCallerWorkflow(root, renderCallerWorkflow(workflow), options.force ?? false);
2319
+ console.log(reviewCiReport(outcome, settings, workflow, () => githubRepo(root)));
2127
2320
  } catch (error) {
2128
2321
  if (!(error instanceof ReviewError || error instanceof ConfigError)) throw error;
2129
2322
  console.error(error.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makerx/yoke",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",