Skip to content

Commit 9e13ed4

Browse files
peopleworksclaude
andcommitted
Let any model answer, and a key be enough
Every AI feature reached PeopleWorks Copilot for its credentials -- not for a key the user had configured, but for the model's key, fetched from an account. On a public MIT project that meant no outside user could run any of them. `--enrich` refused without an API URL and token and told the reader to configure credentials for a service they had never heard of; the Description Annotator asked for COPILOT_API_TOKEN and offered nothing else. One resolver now serves both, taking the first route that is configured: `--api-key` on the command line, then OPENAI_API_KEY or ANTHROPIC_API_KEY in the environment, then a PeopleWorks Copilot account. That account keeps working untouched -- it is one option among several rather than the gate. `--ai-base-url` reaches any OpenAI-compatible endpoint, including a local one, and `--ai-model` names the model. Anthropic is reached through the same client, since it serves an OpenAI-compatible surface, so it costs a base URL and a default rather than a second SDK. Someone with none of the routes configured is now told all four. A key is never read from or written to the configuration file. The endpoint and the model name are ordinary settings; a key is a secret, and that file lives in a home directory that gets copied around. The three options are global and read once with a pre-parse rather than threaded through each handler: SetHandler takes at most eight parameters and extract already spends six, so adding three would have forced four commands onto the InvocationContext pattern to carry a setting none of them decides. The environment is read through an injected lookup so the order of the routes is tested without setting process-wide state the whole run would share. 345 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b7ef0a6 commit 9e13ed4

7 files changed

Lines changed: 460 additions & 66 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,26 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
3939
an appearance rule does. Probed at the time: an application went from two declared rules to
4040
three and the diff reported zero changes.
4141

42+
### Changed
43+
44+
- **Any model will do, and a key is enough** ([#24]). Every AI feature reached PeopleWorks Copilot
45+
for its credentials — not for a key the user had configured, but for the *model's* key, fetched
46+
from an account. On a public MIT project that meant no outside user could run any of them:
47+
`--enrich` refused without an API URL and token and told the reader to configure credentials for
48+
a service they had never heard of, and the Description Annotator asked for `COPILOT_API_TOKEN`.
49+
There is now one resolver shared by both, taking the first route that is configured: `--api-key`
50+
on the command line, then `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in the environment, then a
51+
PeopleWorks Copilot account — which still works untouched, as one option among several rather
52+
than the gate. `--ai-base-url` reaches any OpenAI-compatible endpoint, including a local one, and
53+
`--ai-model` names the model. Someone with none of them configured is now told all four.
54+
A key is never read from or written to the configuration file: the endpoint and the model name
55+
are settings, a key is a secret, and that file lives in a home directory that gets copied around.
56+
4257
### Internal
4358

44-
- First tests over `ProjectDiffEngine`, which is why the key above survived. 337 tests.
59+
- First tests over `ProjectDiffEngine`, which is why the key above survived. 345 tests.
60+
61+
[#24]: https://github.com/peopleworks/XAFLogicExplainer/issues/24
4562

4663
[#21]: https://github.com/peopleworks/XAFLogicExplainer/pull/21
4764
[@MBrekhof]: https://github.com/MBrekhof

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,19 @@ Documentation is generated in **English or Spanish** (`--lang en|es`).
295295
Useful flags: `--orm auto\|xpo\|efcore`, `--lang en\|es`, `--enrich` (AI-generated business-logic
296296
summaries per controller and action), `--force`, `--all`.
297297

298+
`--enrich` needs a model, and **any of these is enough** — a key on the command line wins, then the
299+
environment, then a PeopleWorks Copilot account if you happen to have one:
300+
301+
```bash
302+
xaflogic extract --enrich --api-key sk-... # or any OpenAI-compatible endpoint:
303+
xaflogic extract --enrich --api-key ... --ai-base-url http://localhost:11434/v1 --ai-model qwen2.5-coder
304+
305+
export OPENAI_API_KEY=sk-... # picked up with no configuration at all
306+
export ANTHROPIC_API_KEY=sk-ant-...
307+
```
308+
309+
Everything else in this tool runs with no key, no account and no network.
310+
298311
Extraction is **incremental** — a SHA-256 over your `.cs` and `.xafml` files means an unchanged
299312
project is a no-op. There is an MSBuild `.targets` file if you want it to run on build.
300313

@@ -317,7 +330,7 @@ applications. The agent-facing surface is what is landing now, in the open.
317330
|| Pluggable publishing targets (`IDocumentationSink`) |
318331
|| **MCP server** — 10 tools, live against your source |
319332
|| **Installable Claude Code plugin** with skill and MCP server |
320-
|| **337 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
333+
|| **345 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
321334
|| **DevExpress ground-truth catalog**, generated locally by licensees |
322335

323336
PeopleWorks Copilot, where this tool grew up, is now one sink among several rather than the

src/XafLogicExplainer.Cli/Program.cs

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Spectre.Console;
55
using XafLogicExplainer.Cli.Helpers;
66
using XafLogicExplainer.Cli.Models;
7+
using XafLogicExplainer.CopilotSync.Ai;
78
using XafLogicExplainer.CopilotSync.Models;
89
using XafLogicExplainer.CopilotSync.Services;
910
using XafLogicExplainer.Core.Analyzers;
@@ -46,12 +47,34 @@
4647
allOption.AddAlias("-a");
4748
var enrichOption = new Option<bool>("--enrich", "Enrich controllers with AI-generated business logic summaries");
4849

50+
// Global, because every command that can enrich needs them and none of them owns the choice of
51+
// model. Declared as options rather than read out of the environment alone so they appear in
52+
// --help: the reason nobody outside this shop could use --enrich was that nothing said how.
53+
var apiKeyOption = new Option<string?>("--api-key", "API key for the AI provider (or set OPENAI_API_KEY / ANTHROPIC_API_KEY)");
54+
var aiBaseUrlOption = new Option<string?>("--ai-base-url", "Any OpenAI-compatible endpoint, including a local one");
55+
var aiModelOption = new Option<string?>("--ai-model", "Model name, when not the provider's default");
56+
4957
// ============================================================
5058
// ROOT COMMAND
5159
// ============================================================
5260

5361
var rootCommand = new RootCommand("XAF Logic Explainer CLI - Extract and sync XAF project documentation");
5462

63+
rootCommand.AddGlobalOption(apiKeyOption);
64+
rootCommand.AddGlobalOption(aiBaseUrlOption);
65+
rootCommand.AddGlobalOption(aiModelOption);
66+
67+
// Read once, here, rather than threaded through each handler: SetHandler takes at most eight
68+
// parameters and extract already spends six, so adding three would have forced four commands onto
69+
// the InvocationContext pattern to carry a setting none of them decides.
70+
var aiParse = rootCommand.Parse(args);
71+
var aiOverrides = new AiClientRequest
72+
{
73+
ApiKey = aiParse.GetValueForOption(apiKeyOption),
74+
BaseUrl = aiParse.GetValueForOption(aiBaseUrlOption),
75+
Model = aiParse.GetValueForOption(aiModelOption),
76+
};
77+
5578
// ============================================================
5679
// COMMAND: config
5780
// ============================================================
@@ -380,7 +403,7 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Extracting pr
380403

381404
if (enrich)
382405
{
383-
await EnrichWithAi(project, config, language!);
406+
await EnrichWithAi(project, config, language!, aiOverrides);
384407
}
385408

386409
// Summary table
@@ -631,7 +654,7 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Syncing to Pe
631654
{
632655
enrichHook = async (extractedProject) =>
633656
{
634-
await EnrichWithAi(extractedProject, config, language!);
657+
await EnrichWithAi(extractedProject, config, language!, aiOverrides);
635658
};
636659
}
637660
singleResult = await syncService.SyncAsync(singleSyncConfig, BuildExtractionOptions(language, orm), msg =>
@@ -1433,7 +1456,8 @@ await GenerateAgentFiles(
14331456
agentsForce,
14341457
agentsEnrich,
14351458
agentsOptions,
1436-
agentsConfig);
1459+
agentsConfig,
1460+
aiOverrides);
14371461
}
14381462

14391463
return;
@@ -1454,7 +1478,8 @@ await GenerateAgentFiles(
14541478
agentsForce,
14551479
agentsEnrich,
14561480
agentsOptions,
1457-
agentsConfig);
1481+
agentsConfig,
1482+
aiOverrides);
14581483
});
14591484

14601485
rootCommand.AddCommand(agentsCommand);
@@ -1503,7 +1528,7 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Reading the a
15031528

15041529
if (explainEnrich)
15051530
{
1506-
await EnrichWithAi(explained, explainConfig, explainLang);
1531+
await EnrichWithAi(explained, explainConfig, explainLang, aiOverrides);
15071532
}
15081533

15091534
var html = new HtmlExplainerGenerator(ThisAssemblyVersion()).Generate(explained);
@@ -1759,7 +1784,8 @@ static async Task GenerateAgentFiles(
17591784
bool force,
17601785
bool enrich,
17611786
AgentFilesOptions options,
1762-
CliConfig config)
1787+
CliConfig config,
1788+
AiClientRequest aiOverrides)
17631789
{
17641790
if (!Directory.Exists(projectPath))
17651791
{
@@ -1784,7 +1810,7 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Reading the a
17841810

17851811
if (enrich)
17861812
{
1787-
await EnrichWithAi(agentProject, config, language);
1813+
await EnrichWithAi(agentProject, config, language, aiOverrides);
17881814
}
17891815

17901816
var agentGenerator = new MarkdownDocumentationGenerator(language);
@@ -1967,49 +1993,46 @@ static void DisplayDiffSummary(ProjectDiffReport report)
19671993
// HELPER: AI Business Logic Enrichment
19681994
// ============================================================
19691995

1970-
static async Task EnrichWithAi(ExtractedProject project, CliConfig config, string language)
1996+
static async Task EnrichWithAi(
1997+
ExtractedProject project, CliConfig config, string language, AiClientRequest aiOverrides)
19711998
{
19721999
if (project.Controllers.Count == 0)
19732000
{
19742001
AnsiConsole.MarkupLine("[grey] No controllers to enrich.[/]");
19752002
return;
19762003
}
19772004

1978-
var apiUrl = config.ApiUrl;
1979-
var token = config.Token;
1980-
var userName = config.UserName ?? "xaf-logic-explainer";
1981-
var resourceName = config.ResourceName ?? "";
1982-
1983-
if (string.IsNullOrEmpty(apiUrl) || string.IsNullOrEmpty(token))
2005+
// A PeopleWorks Copilot account is now the last of several routes rather than the only one, so
2006+
// it is offered rather than required: an empty URL or token simply means this route is not
2007+
// configured, and the resolver moves on to the next.
2008+
var request = aiOverrides with
19842009
{
1985-
AnsiConsole.MarkupLine("[yellow] --enrich requires API credentials. Configure with: xaflogic config[/]");
1986-
return;
1987-
}
2010+
Copilot = new SyncConfiguration
2011+
{
2012+
CopilotApiBaseUrl = config.ApiUrl ?? "",
2013+
CopilotApiToken = config.Token ?? "",
2014+
UserName = config.UserName ?? "xaf-logic-explainer",
2015+
ResourceName = config.ResourceName ?? "",
2016+
},
2017+
};
19882018

1989-
AnsiConsole.MarkupLine("[grey] Fetching AI provider credentials...[/]");
1990-
AiProviderInfo? aiProvider = null;
1991-
using (var apiClient = new CopilotApiClient(apiUrl, token, userName, resourceName))
1992-
{
1993-
aiProvider = await apiClient.GetAiProviderAsync();
1994-
}
2019+
var resolved = await AiClientResolver.ResolveAsync(request);
19952020

1996-
if (aiProvider == null || string.IsNullOrEmpty(aiProvider.ApiKey))
2021+
if (!resolved.Succeeded)
19972022
{
1998-
AnsiConsole.MarkupLine("[yellow] Could not retrieve AI provider. Skipping enrichment.[/]");
2023+
var problem = resolved.Problem ?? AiClientResolver.NothingConfigured;
2024+
2025+
foreach (var line in problem.Split('\n'))
2026+
AnsiConsole.MarkupLine($"[yellow] {Markup.Escape(line)}[/]");
2027+
19992028
return;
20002029
}
20012030

2002-
var model = aiProvider.Parameters?.GetValueOrDefault("model")?.ToString() ?? "gpt-4o-mini";
2003-
var baseUrl = aiProvider.AiProviderBaseUrl ?? "https://api.openai.com/v1";
2031+
var chatClient = resolved.Client!;
20042032

2005-
AnsiConsole.MarkupLine($"[blue]AI:[/] {Markup.Escape(aiProvider.ProviderName ?? "OpenAI")} / {Markup.Escape(model)}");
2033+
AnsiConsole.MarkupLine($"[blue]AI:[/] {Markup.Escape(resolved.ProviderName)} / {Markup.Escape(resolved.Model)}");
20062034
AnsiConsole.MarkupLine($"[blue] Enriching {project.Controllers.Count} controllers...[/]");
20072035

2008-
var openAiClient = new OpenAIClient(
2009-
new System.ClientModel.ApiKeyCredential(aiProvider.ApiKey),
2010-
new OpenAIClientOptions { Endpoint = new Uri(baseUrl) });
2011-
var chatClient = openAiClient.GetChatClient(model).AsIChatClient();
2012-
20132036
var enricher = new BusinessLogicEnricher(chatClient);
20142037
await enricher.EnrichAsync(project, language, msg =>
20152038
{

0 commit comments

Comments
 (0)