@makerx/yoke 0.8.1 → 0.10.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 (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +325 -154
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -23,7 +23,7 @@ yoke sync --user # ~/.claude/skills
23
23
 
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
- - `/review-ci` adds a workflow that reviews each PR in CI and helps set its secrets. [Details](src/features/review/README.md#ci).
26
+ - `/review-ci` adds a workflow that reviews each PR in CI and helps set its variable and secret. [Details](src/features/review/README.md#ci).
27
27
 
28
28
  ## CLI
29
29
 
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.1",
10
+ version: "0.10.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 baseUrlVariable = "REVIEW_CI_BASE_URL";
631
+ var apiKeySecret = "REVIEW_CI_API_KEY";
632
+ function workflowSecrets({ entra }) {
633
+ return entra ? [] : [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,26 @@ 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) {
689
+ const secrets = workflowSecrets(workflow);
657
690
  return [
658
691
  "name: Review PR",
659
692
  "",
@@ -664,23 +697,57 @@ function renderCallerWorkflow({ inputs }) {
664
697
  "permissions:",
665
698
  " contents: read",
666
699
  " pull-requests: write",
700
+ ...workflow.entra ? [" id-token: write"] : [],
667
701
  "",
668
702
  "jobs:",
669
703
  " review:",
670
704
  ` uses: ${reviewWorkflowRef}`,
671
705
  " with:",
672
- ...inputs.map(([name, value]) => ` ${name}: ${yamlValue(value)}`),
673
- " secrets:",
674
- ...ciSecrets.map((secret2) => ` ${secret2}: \${{ secrets.${secret2} }}`),
706
+ ...workflow.inputs.map(([name, value]) => ` ${name}: ${yamlValue(value)}`),
707
+ ` base-url: \${{ vars.${baseUrlVariable} }}`,
708
+ ...secrets.length > 0 ? [" secrets:", ...secrets.map((secret2) => ` ${secret2}: \${{ secrets.${secret2} }}`)] : [],
675
709
  ""
676
710
  ].join("\n");
677
711
  }
678
712
  function shellQuote(value) {
679
713
  return `'${value.replaceAll("'", `'\\''`)}'`;
680
714
  }
681
- function secretCommands(settings, provider) {
682
- const baseUrl = settings[`${provider}.baseUrl`];
683
- return [`gh secret set REVIEW_CI_BASE_URL${baseUrl ? ` --body ${shellQuote(baseUrl)}` : ""}`, "gh secret set REVIEW_CI_API_KEY"];
715
+ function setupCommands(settings, workflow) {
716
+ const baseUrl = settings[`${workflow.provider}.baseUrl`];
717
+ return [
718
+ `gh variable set ${baseUrlVariable}${baseUrl ? ` --body ${shellQuote(baseUrl)}` : ""}`,
719
+ ...workflowSecrets(workflow).map((secret2) => `gh secret set ${secret2}`)
720
+ ];
721
+ }
722
+ var githubOidcIssuer = "https://token.actions.githubusercontent.com";
723
+ var entraTokenExchangeAudience = "api://AzureADTokenExchange";
724
+ function federatedCredential(repo, environment) {
725
+ return {
726
+ issuer: githubOidcIssuer,
727
+ audience: entraTokenExchangeAudience,
728
+ subject: environment ? `repo:${repo}:environment:${environment}` : `repo:${repo}:pull_request`
729
+ };
730
+ }
731
+ function federatedCredentialCommand(clientId, credential) {
732
+ const parameters = JSON.stringify({
733
+ name: "yoke-review-ci",
734
+ issuer: credential.issuer,
735
+ subject: credential.subject,
736
+ audiences: [credential.audience]
737
+ });
738
+ return `az ad app federated-credential create --id ${shellQuote(clientId)} --parameters ${shellQuote(parameters)}`;
739
+ }
740
+ var unknownRepo = "<owner>/<repo>";
741
+ function githubRepo(cwd = process.cwd()) {
742
+ try {
743
+ return execFileSync2("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], {
744
+ cwd,
745
+ encoding: "utf8",
746
+ stdio: ["ignore", "pipe", "ignore"]
747
+ }).trim() || unknownRepo;
748
+ } catch {
749
+ return unknownRepo;
750
+ }
684
751
  }
685
752
  function repoRoot(cwd = process.cwd()) {
686
753
  try {
@@ -699,131 +766,34 @@ function writeCallerWorkflow(root, content, force) {
699
766
  writeFileSync2(path, content);
700
767
  return "wrote";
701
768
  }
702
- function reviewCiReport(outcome, commands) {
769
+ function entraSetup({ clientId, environment }, repo) {
770
+ const credential = federatedCredential(repo, environment);
771
+ return [
772
+ "",
773
+ `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:`,
774
+ ` issuer: ${credential.issuer}`,
775
+ ` audience: ${credential.audience}`,
776
+ ` subject: ${credential.subject}`,
777
+ "for example with:",
778
+ ` ${federatedCredentialCommand(clientId, credential)}`,
779
+ "and give the app the Cognitive Services User role on the Foundry resource.",
780
+ ...environment ? [`The job runs in the ${environment} environment; create it on the repo if it does not exist.`] : []
781
+ ];
782
+ }
783
+ function reviewCiReport(outcome, settings, workflow, repo) {
703
784
  const action = outcome === "wrote" ? "wrote" : "kept";
785
+ const reads = [`variable ${baseUrlVariable}`, ...workflowSecrets(workflow).map((secret2) => `secret ${secret2}`)];
704
786
  return [
705
787
  `${action} ${callerWorkflowPath}`,
706
788
  "",
707
- `The workflow reads ${ciSecrets.join(" and ")}. Set them on the repo (or the org) with:`,
708
- ...commands.map((command) => ` ${command}`),
789
+ `The workflow reads ${reads.join(" and ")}. Set ${reads.length > 1 ? "them" : "it"} on the repo (or the org) with:`,
790
+ ...setupCommands(settings, workflow).map((command) => ` ${command}`),
791
+ ...workflow.entra ? entraSetup(workflow.entra, repo()) : [],
709
792
  "",
710
793
  "Commit and push the workflow; each PR opened from this repo then gets a review."
711
794
  ].join("\n");
712
795
  }
713
796
 
714
- // src/features/review/slots.ts
715
- var slotNames = ["reviewer1", "reviewer2", "synthesis"];
716
- function readProvider(settings, kind) {
717
- const baseUrlKey = `${kind}.baseUrl`;
718
- const apiKeyKey = `${kind}.apiKey`;
719
- const baseUrl = settings[baseUrlKey];
720
- const apiKey = settings[apiKeyKey];
721
- if (baseUrl && apiKey) return { kind, baseUrl, apiKey };
722
- return [
723
- baseUrl ? null : `${baseUrlKey} is not set; ${howToSet(baseUrlKey, "<url>")}`,
724
- apiKey ? null : `${apiKeyKey} is not set; ${howToSet(apiKeyKey, "<key>")}`
725
- ].filter((error) => error !== null);
726
- }
727
- function readSpec(settings, slot, errors) {
728
- const keys = {
729
- harness: `review.${slot}.harness`,
730
- provider: `review.${slot}.provider`,
731
- model: `review.${slot}.model`
732
- };
733
- const harness = settings[keys.harness];
734
- const provider = settings[keys.provider];
735
- const model = settings[keys.model];
736
- if (!harness && !provider && !model) return void 0;
737
- if (harness && !isHarness(harness))
738
- errors.push(`${keys.harness} "${harness}" must be claude or codex; ${howToSet(keys.harness, "<claude|codex>")}`);
739
- if (provider && !isProviderKind(provider))
740
- errors.push(
741
- `${keys.provider} "${provider}" must be ${providerKinds.join(" or ")}; ${howToSet(keys.provider, `<${providerKinds.join("|")}>`)}`
742
- );
743
- if (provider && !model)
744
- errors.push(`${keys.model} is required when ${keys.provider} is set; ${howToSet(keys.model, "<model>")} (see \`yoke models\`)`);
745
- return {
746
- ...harness && isHarness(harness) ? { harness } : {},
747
- ...provider && isProviderKind(provider) ? { provider } : {},
748
- ...model ? { model } : {}
749
- };
750
- }
751
- function toSlot({ harness: chosen, model, provider: kind }, fallback, providers) {
752
- const harness = chosen ?? fallback;
753
- if (!model) return { harness };
754
- const slot = { harness, model };
755
- const provider = kind && providers[kind];
756
- if (provider) return { ...slot, provider: kind, override: buildOverride(slot, provider), probe: buildProbe(slot, provider) };
757
- return { ...slot, override: ownAuthOverride(slot) };
758
- }
759
- function resolveSlots(settings) {
760
- const specs = {};
761
- const errors = [];
762
- for (const name of slotNames) specs[name] = readSpec(settings, name, errors);
763
- const providers = {};
764
- for (const kind of new Set(Object.values(specs).flatMap((spec) => spec?.provider ? [spec.provider] : []))) {
765
- const read = readProvider(settings, kind);
766
- if (Array.isArray(read)) errors.push(...read);
767
- else providers[kind] = read;
768
- }
769
- if (errors.length) throw new ReviewError(["Review settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
770
- const reviewer1 = toSlot(specs.reviewer1 ?? {}, "claude", providers);
771
- return {
772
- reviewer1,
773
- reviewer2: toSlot(specs.reviewer2 ?? {}, "codex", providers),
774
- synthesis: specs.synthesis && toSlot(specs.synthesis, reviewer1.harness, providers)
775
- };
776
- }
777
-
778
- // src/features/review/models.ts
779
- var timeout = 6e4;
780
- var maxBodyChars = 300;
781
- var providerWidth = Math.max(...providerKinds.map((kind) => kind.length));
782
- function modelsRequest({ kind, baseUrl, apiKey }) {
783
- const url = `${deriveEndpoints(kind, baseUrl).responses}/models`;
784
- return { url, headers: kind === "foundry" ? { "api-key": apiKey } : { Authorization: `Bearer ${apiKey}` } };
785
- }
786
- function isModelList(value) {
787
- 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");
788
- }
789
- async function get(url, headers, fetchModels) {
790
- try {
791
- return await fetchModels(url, { headers, signal: AbortSignal.timeout(timeout) });
792
- } catch (error) {
793
- throw new ReviewError(`Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`);
794
- }
795
- }
796
- async function providerModels(provider, fetchModels) {
797
- const { url, headers } = modelsRequest(provider);
798
- const response = await get(url, headers, fetchModels);
799
- if (!response.ok)
800
- throw new ReviewError(`${url} returned HTTP ${response.status}: ${(await response.text()).trim().slice(0, maxBodyChars)}`);
801
- const body = await response.json().catch(() => void 0);
802
- if (!isModelList(body)) throw new ReviewError(`${url} returned an unexpected model list`);
803
- return body.data.map(({ id }) => `${provider.kind.padEnd(providerWidth)} ${id}`);
804
- }
805
- function isConfigured(settings, kind) {
806
- return Boolean(settings[`${kind}.baseUrl`] || settings[`${kind}.apiKey`]);
807
- }
808
- async function listModels(settings, fetchModels = fetch) {
809
- const kinds = providerKinds.filter((kind) => isConfigured(settings, kind));
810
- if (!kinds.length)
811
- throw new ReviewError(
812
- `No model provider is configured; set ${providerKinds.map((kind) => `${kind}.baseUrl and ${kind}.apiKey`).join(", or ")} (see \`yoke config\`)`
813
- );
814
- const providers = kinds.map((kind) => readProvider(settings, kind));
815
- const errors = providers.flatMap((provider) => Array.isArray(provider) ? provider : []);
816
- if (errors.length) throw new ReviewError(["Model provider settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
817
- const ready = providers.filter((provider) => !Array.isArray(provider));
818
- const lists = await Promise.all(ready.map((provider) => providerModels(provider, fetchModels)));
819
- return lists.flat().toSorted();
820
- }
821
-
822
- // src/features/review/review.ts
823
- import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
824
- import { homedir as homedir2, tmpdir } from "os";
825
- import { join as join8 } from "path";
826
-
827
797
  // src/features/review/comments.ts
828
798
  var markerPrefix = "<!-- yoke-review:";
829
799
  function slug(text2) {
@@ -968,7 +938,11 @@ This change adds or edits a workflow that calls \`MakerXStudio/yoke/.github/work
968
938
  - The trigger types, including leaving out \`synchronize\`: a review is re-run by marking the PR as draft and then ready for review.
969
939
  - Draft PRs being reviewed twice: the reusable workflow already skips drafts.
970
940
 
971
- Real defects in it are still findings, such as passing every secret with \`secrets: inherit\` instead of only \`REVIEW_CI_BASE_URL\` and \`REVIEW_CI_API_KEY\`, or an input the reusable workflow does not declare.
941
+ Real defects in it are still findings, such as:
942
+
943
+ - Passing every secret with \`secrets: inherit\` instead of only \`REVIEW_CI_API_KEY\` (plus \`REVIEW_CI_BASE_URL\` from callers that still pass the URL as a secret).
944
+ - An input the reusable workflow does not declare.
945
+ - 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.
972
946
  `;
973
947
  }
974
948
 
@@ -1205,6 +1179,189 @@ async function reportFailure(pr2, reason, ui, deps) {
1205
1179
  }
1206
1180
  }
1207
1181
 
1182
+ // src/features/review/slots.ts
1183
+ var slotNames = ["reviewer1", "reviewer2", "synthesis"];
1184
+ function notSet(key, ciName, placeholder, env) {
1185
+ return isCi(env) ? `${ciName} is not set` : `${key} is not set; ${howToSet(key, placeholder)}`;
1186
+ }
1187
+ function readProvider(settings, kind, env = process.env) {
1188
+ const baseUrlKey = `${kind}.baseUrl`;
1189
+ const apiKeyKey = `${kind}.apiKey`;
1190
+ const baseUrl = settings[baseUrlKey];
1191
+ const apiKey = settings[apiKeyKey];
1192
+ const token = kind === "foundry" ? settings["foundry.token"] : void 0;
1193
+ if (baseUrl && token) return { kind, baseUrl, token };
1194
+ if (baseUrl && apiKey) return { kind, baseUrl, apiKey };
1195
+ return [
1196
+ baseUrl ? null : notSet(baseUrlKey, `Variable ${baseUrlVariable}`, "<url>", env),
1197
+ apiKey || token ? null : notSet(apiKeyKey, `Secret ${apiKeySecret}`, "<key>", env)
1198
+ ].filter((error) => error !== null);
1199
+ }
1200
+ function readSpec(settings, slot, errors) {
1201
+ const keys = {
1202
+ harness: `review.${slot}.harness`,
1203
+ provider: `review.${slot}.provider`,
1204
+ model: `review.${slot}.model`
1205
+ };
1206
+ const harness = settings[keys.harness];
1207
+ const provider = settings[keys.provider];
1208
+ const model = settings[keys.model];
1209
+ if (!harness && !provider && !model) return void 0;
1210
+ if (harness && !isHarness(harness))
1211
+ errors.push(`${keys.harness} "${harness}" must be claude or codex; ${howToSet(keys.harness, "<claude|codex>")}`);
1212
+ if (provider && !isProviderKind(provider))
1213
+ errors.push(
1214
+ `${keys.provider} "${provider}" must be ${providerKinds.join(" or ")}; ${howToSet(keys.provider, `<${providerKinds.join("|")}>`)}`
1215
+ );
1216
+ if (provider && !model)
1217
+ errors.push(`${keys.model} is required when ${keys.provider} is set; ${howToSet(keys.model, "<model>")} (see \`yoke models\`)`);
1218
+ return {
1219
+ ...harness && isHarness(harness) ? { harness } : {},
1220
+ ...provider && isProviderKind(provider) ? { provider } : {},
1221
+ ...model ? { model } : {}
1222
+ };
1223
+ }
1224
+ function toSlot({ harness: chosen, model, provider: kind }, fallback, providers) {
1225
+ const harness = chosen ?? fallback;
1226
+ if (!model) return { harness };
1227
+ const slot = { harness, model };
1228
+ const provider = kind && providers[kind];
1229
+ if (provider) return { ...slot, provider: kind, override: buildOverride(slot, provider), probe: buildProbe(slot, provider) };
1230
+ return { ...slot, override: ownAuthOverride(slot) };
1231
+ }
1232
+ function resolveSlots(settings) {
1233
+ const specs = {};
1234
+ const errors = [];
1235
+ for (const name of slotNames) specs[name] = readSpec(settings, name, errors);
1236
+ const providers = {};
1237
+ for (const kind of new Set(Object.values(specs).flatMap((spec) => spec?.provider ? [spec.provider] : []))) {
1238
+ const read = readProvider(settings, kind);
1239
+ if (Array.isArray(read)) errors.push(...read);
1240
+ else providers[kind] = read;
1241
+ }
1242
+ if (errors.length) throw new ReviewError(["Review settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
1243
+ const reviewer1 = toSlot(specs.reviewer1 ?? {}, "claude", providers);
1244
+ return {
1245
+ reviewer1,
1246
+ reviewer2: toSlot(specs.reviewer2 ?? {}, "codex", providers),
1247
+ synthesis: specs.synthesis && toSlot(specs.synthesis, reviewer1.harness, providers)
1248
+ };
1249
+ }
1250
+
1251
+ // src/features/review/models.ts
1252
+ var timeout = 6e4;
1253
+ var maxBodyChars = 300;
1254
+ var providerWidth = Math.max(...providerKinds.map((kind) => kind.length));
1255
+ function modelsRequest(provider) {
1256
+ const url = `${deriveEndpoints(provider.kind, provider.baseUrl).responses}/models`;
1257
+ if (provider.kind === "foundry" && "apiKey" in provider) return { url, headers: { "api-key": provider.apiKey } };
1258
+ return { url, headers: { Authorization: `Bearer ${credentialOf(provider)}` } };
1259
+ }
1260
+ function isModelList(value) {
1261
+ 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");
1262
+ }
1263
+ async function get(url, headers, fetchModels) {
1264
+ try {
1265
+ return await fetchModels(url, { headers, signal: AbortSignal.timeout(timeout) });
1266
+ } catch (error) {
1267
+ throw new ReviewError(`Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`);
1268
+ }
1269
+ }
1270
+ async function providerModels(provider, fetchModels) {
1271
+ const { url, headers } = modelsRequest(provider);
1272
+ const response = await get(url, headers, fetchModels);
1273
+ if (!response.ok)
1274
+ throw new ReviewError(`${url} returned HTTP ${response.status}: ${(await response.text()).trim().slice(0, maxBodyChars)}`);
1275
+ const body = await response.json().catch(() => void 0);
1276
+ if (!isModelList(body)) throw new ReviewError(`${url} returned an unexpected model list`);
1277
+ return body.data.map(({ id }) => `${provider.kind.padEnd(providerWidth)} ${id}`);
1278
+ }
1279
+ function isConfigured(settings, kind) {
1280
+ return Boolean(settings[`${kind}.baseUrl`] || settings[`${kind}.apiKey`] || kind === "foundry" && settings["foundry.token"]);
1281
+ }
1282
+ async function listModels(settings, fetchModels = fetch) {
1283
+ const kinds = providerKinds.filter((kind) => isConfigured(settings, kind));
1284
+ if (!kinds.length)
1285
+ throw new ReviewError(
1286
+ `No model provider is configured; set ${providerKinds.map((kind) => `${kind}.baseUrl and ${kind}.apiKey`).join(", or ")} (see \`yoke config\`)`
1287
+ );
1288
+ const providers = kinds.map((kind) => readProvider(settings, kind));
1289
+ const errors = providers.flatMap((provider) => Array.isArray(provider) ? provider : []);
1290
+ if (errors.length) throw new ReviewError(["Model provider settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
1291
+ const ready = providers.filter((provider) => !Array.isArray(provider));
1292
+ const lists = await Promise.all(ready.map((provider) => providerModels(provider, fetchModels)));
1293
+ return lists.flat().toSorted();
1294
+ }
1295
+
1296
+ // src/features/review/review.ts
1297
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
1298
+ import { homedir as homedir2, tmpdir } from "os";
1299
+ import { join as join8 } from "path";
1300
+
1301
+ // src/features/review/entra.ts
1302
+ var tokenExchangeAudience = "api://AzureADTokenExchange";
1303
+ var cognitiveServicesScope = "https://cognitiveservices.azure.com/.default";
1304
+ var timeout2 = 3e4;
1305
+ function isRecord(value) {
1306
+ return typeof value === "object" && value !== null;
1307
+ }
1308
+ function errorDetail(json, text2) {
1309
+ const detail = [json.error_description, json.error, json.message].find((value) => typeof value === "string");
1310
+ return typeof detail === "string" ? detail : text2.trim();
1311
+ }
1312
+ async function readJson(response, failure) {
1313
+ const text2 = await response.text();
1314
+ let body;
1315
+ try {
1316
+ body = JSON.parse(text2);
1317
+ } catch {
1318
+ throw new ReviewError(`${failure}: HTTP ${response.status}: ${text2.trim().slice(0, 300)}`);
1319
+ }
1320
+ const json = isRecord(body) ? body : {};
1321
+ if (response.ok) return json;
1322
+ throw new ReviewError(`${failure}: HTTP ${response.status}: ${errorDetail(json, text2).slice(0, 300)}`);
1323
+ }
1324
+ async function githubOidcToken(env, fetchFn) {
1325
+ const { ACTIONS_ID_TOKEN_REQUEST_URL: url, ACTIONS_ID_TOKEN_REQUEST_TOKEN: bearer } = env;
1326
+ if (!url || !bearer) throw new ReviewError("No GitHub OIDC token endpoint; grant id-token: write in the calling workflow's permissions");
1327
+ const request = new URL(url);
1328
+ request.searchParams.set("audience", tokenExchangeAudience);
1329
+ const response = await fetchFn(request, { headers: { Authorization: `bearer ${bearer}` }, signal: AbortSignal.timeout(timeout2) });
1330
+ const { value } = await readJson(response, "GitHub OIDC token request failed");
1331
+ if (typeof value !== "string" || !value) throw new ReviewError("GitHub OIDC token response had no value");
1332
+ return value;
1333
+ }
1334
+ async function exchangeForEntraToken(clientId, tenantId, env, fetchFn = fetch) {
1335
+ const assertion = await githubOidcToken(env, fetchFn);
1336
+ const response = await fetchFn(`https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`, {
1337
+ method: "POST",
1338
+ body: new URLSearchParams({
1339
+ grant_type: "client_credentials",
1340
+ client_id: clientId,
1341
+ scope: cognitiveServicesScope,
1342
+ client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
1343
+ client_assertion: assertion
1344
+ }),
1345
+ signal: AbortSignal.timeout(timeout2)
1346
+ });
1347
+ const { access_token: token } = await readJson(response, `Entra token exchange for client ${clientId} failed`);
1348
+ if (typeof token !== "string" || !token) throw new ReviewError("Entra token response had no access_token");
1349
+ return token;
1350
+ }
1351
+ function usesFoundry(settings) {
1352
+ return ["reviewer1", "reviewer2", "synthesis"].some((slot) => settings[`review.${slot}.provider`] === "foundry");
1353
+ }
1354
+ async function withEntraToken(settings, { env, fetch: fetchFn = fetch, mask }) {
1355
+ const clientId = settings["foundry.azureClientId"];
1356
+ const tenantId = settings["foundry.azureTenantId"];
1357
+ if (!clientId && !tenantId) return settings;
1358
+ if (!clientId || !tenantId) throw new ReviewError("Entra auth needs both foundry.azureClientId and foundry.azureTenantId");
1359
+ if (settings["foundry.token"] || !usesFoundry(settings) || !isCi(env)) return settings;
1360
+ const token = await exchangeForEntraToken(clientId, tenantId, env, fetchFn);
1361
+ mask(token);
1362
+ return { ...settings, "foundry.token": token };
1363
+ }
1364
+
1208
1365
  // src/features/review/harness.ts
1209
1366
  import { spawn } from "child_process";
1210
1367
  import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
@@ -1222,11 +1379,11 @@ function count(source, key) {
1222
1379
  const value = key ? source[key] : void 0;
1223
1380
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1224
1381
  }
1225
- function isRecord(value) {
1382
+ function isRecord2(value) {
1226
1383
  return typeof value === "object" && value !== null && !Array.isArray(value);
1227
1384
  }
1228
1385
  function parseUsage(raw, keys) {
1229
- if (!isRecord(raw)) return void 0;
1386
+ if (!isRecord2(raw)) return void 0;
1230
1387
  const usage = {};
1231
1388
  for (const field2 of fields2) {
1232
1389
  const value = count(raw, keys[field2]);
@@ -1901,6 +2058,10 @@ function logged(ui, append) {
1901
2058
  }
1902
2059
 
1903
2060
  // src/features/review/review.ts
2061
+ function maskInCi(secret2) {
2062
+ if (isCi()) process.stdout.write(`::add-mask::${secret2}
2063
+ `);
2064
+ }
1904
2065
  function reviewRunDir(pr2, env = process.env, home = homedir2()) {
1905
2066
  const [, owner = "", repo = ""] = new URL(pr2.url).pathname.split("/");
1906
2067
  const run2 = `${pr2.number}-${pr2.baseRefOid}-${pr2.headRefOid}`;
@@ -1940,7 +2101,7 @@ async function reviewPr(number, settings, { post, force }, io) {
1940
2101
  io.out(text2);
1941
2102
  };
1942
2103
  const { log } = ui;
1943
- const slots2 = resolveSlots(settings);
2104
+ const slots2 = resolveSlots(await withEntraToken(settings, { env: process.env, mask: maskInCi }));
1944
2105
  const pr2 = await timed(ui, number ? `fetching PR #${number}` : "fetching this branch's PR", () => fetchPr(number));
1945
2106
  log(`#${pr2.number} ${pr2.title}`);
1946
2107
  const [diff, prior] = await timed(
@@ -2024,17 +2185,21 @@ If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
2024
2185
  // src/features/review/ciSkill.ts
2025
2186
  var reviewCiSkill = {
2026
2187
  name: "review-ci",
2027
- description: "Add yoke's adversarial PR review workflow to this repo and set the secrets it needs.",
2188
+ description: "Add yoke's adversarial PR review workflow to this repo and set the variable and secret it needs.",
2028
2189
  disableModelInvocation: true,
2029
2190
  userLevel: true,
2030
2191
  body: `
2031
2192
  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\`.
2032
2193
 
2033
- 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.
2034
- - \`REVIEW_CI_BASE_URL\` is not secret: confirm the URL with the user and run the printed command.
2035
- - \`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.
2194
+ 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.
2195
+
2196
+ Then set the variable and secret it printed. Run \`gh variable list\` and \`gh secret list\` first; if one is missing, ask whether it is already set at the org level before setting it on the repo.
2197
+ - \`REVIEW_CI_BASE_URL\` is a variable, not a secret: confirm the URL with the user and run the printed \`gh variable set\` command.
2198
+ - \`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.
2199
+
2200
+ 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.
2036
2201
 
2037
- Finish by running \`gh secret list\` and telling the user to commit and push the workflow file.
2202
+ Finish by running \`gh variable list\` and \`gh secret list\` and telling the user to commit and push the workflow file.
2038
2203
 
2039
2204
  If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
2040
2205
  `
@@ -2140,14 +2305,20 @@ addSettingOptions(
2140
2305
  });
2141
2306
  var ciKeys = settingKeys.filter((key) => /^review\.\w+\.(harness|model)$/.test(key));
2142
2307
  addSettingOptions(
2143
- 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`),
2308
+ program.command("review-ci").description(
2309
+ `Write ${callerWorkflowPath} so each PR in this repo is reviewed in CI, then print the secrets, or Entra federated credential, it needs`
2310
+ ).option("--provider <provider>", "litellm or foundry; every slot uses it in CI. Default: review.reviewer1.provider, else litellm").option(
2311
+ "--azure-client-id <id>",
2312
+ "Entra app (client) ID to sign in to Foundry with GitHub OIDC instead of an API key; needs --provider foundry"
2313
+ ).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`),
2144
2314
  ciKeys
2145
2315
  ).addHelpText("after", settingsHelp).action((options) => {
2146
2316
  try {
2147
2317
  const settings = resolveSettings(flagSettings(options, ciKeys), process.env, readUserConfig(userConfigPath()));
2148
- const workflow = callerWorkflow(settings, options.provider);
2149
- const outcome = writeCallerWorkflow(repoRoot(), renderCallerWorkflow(workflow), options.force ?? false);
2150
- console.log(reviewCiReport(outcome, secretCommands(settings, workflow.provider)));
2318
+ const workflow = callerWorkflow(settings, options.provider, options);
2319
+ const root = repoRoot();
2320
+ const outcome = writeCallerWorkflow(root, renderCallerWorkflow(workflow), options.force ?? false);
2321
+ console.log(reviewCiReport(outcome, settings, workflow, () => githubRepo(root)));
2151
2322
  } catch (error) {
2152
2323
  if (!(error instanceof ReviewError || error instanceof ConfigError)) throw error;
2153
2324
  console.error(error.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makerx/yoke",
3
- "version": "0.8.1",
3
+ "version": "0.10.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",