diff --git a/Documentation/getting-started/index.mdx b/Documentation/getting-started/index.mdx index 0b18449..eea503b 100644 --- a/Documentation/getting-started/index.mdx +++ b/Documentation/getting-started/index.mdx @@ -146,13 +146,15 @@ cratis completions install It detects your shell automatically (override with `--shell bash|zsh|fish|powershell`); on Windows it writes the hook to your PowerShell `$PROFILE`. Restart your shell to pick it up. -**Teach your AI assistant about your store.** Run `cratis init` inside a project and the CLI writes a `CHRONICLE.md` describing every command it can run, installs instruction files for Claude Code, GitHub Copilot, Cursor, and Windsurf, and adds a `chronicle-diagnose` slash command — so your assistant can help operate the store too: +**Teach your AI assistant about your store.** Run `cratis init` inside a project and the CLI writes a `CHRONICLE.md` describing every command it can run, installs instruction files for Claude Code, GitHub Copilot, Cursor, Windsurf, and Pi, and adds a `chronicle-diagnose` slash command — so your assistant can help operate the store too: ```bash title="Set up AI tooling" cratis init ``` -Refresh the embedded snapshot after a CLI upgrade with `cratis init --refresh`. For the same catalog as raw JSON, run `cratis llm-context` (add `--schema` for its JSON Schema). +Detection uses both the files a project already has and the environment variables each tool exports, so running `cratis init` from inside an assistant's own terminal configures that assistant even before the project has any of its files. + +The catalog is a snapshot taken when `init` runs. After upgrading the CLI it still describes the surface it was generated from, so `cratis init` reports the mismatch and points at `cratis init --refresh`, which re-captures it. For the same catalog as raw JSON, run `cratis llm-context` (add `--schema` for its JSON Schema). ## Recap diff --git a/README.md b/README.md index 493f2ee..64d38b9 100644 --- a/README.md +++ b/README.md @@ -332,13 +332,20 @@ rather than making it the default. That catalog is the other half: ```bash -cratis init # writes CHRONICLE.md, wires up Claude / Copilot / Cursor / Windsurf -cratis llm-context # the whole command surface as JSON, ~50 KB +cratis init # writes CHRONICLE.md, wires up Claude / Copilot / Cursor / Windsurf / Pi +cratis init --refresh # re-capture the catalog after upgrading the CLI +cratis llm-context # the whole command surface as JSON, ~50 KB ``` -`init` detects which tools a project already uses rather than assuming, and `llm-context` -carries per-command descriptions, options, examples and output-format advice — so an agent -can work out that a stuck observer means `failed-partitions show` without being told. +`init` detects which tools a project already uses rather than assuming — from its files, and +from the environment variables the tool exports, so running it inside an agent's own terminal +configures that agent even on a project that has nothing yet. `llm-context` carries per-command +descriptions, options, examples and output-format advice — so an agent can work out that a stuck +observer means `failed-partitions show` without being told. + +The catalog `init` writes is a snapshot, not a live lookup. After upgrading the CLI it still +describes the surface it was generated from, so `init` says so and names `--refresh` as the fix +rather than leaving an agent to confidently call a command that has since changed. ## Tab completion asks the server diff --git a/Source/Cli.Specs/for_GeneratedContextVersion/when_deciding_whether_it_is_stale.cs b/Source/Cli.Specs/for_GeneratedContextVersion/when_deciding_whether_it_is_stale.cs new file mode 100644 index 0000000..00b52a5 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedContextVersion/when_deciding_whether_it_is_stale.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_GeneratedContextVersion; + +/// +/// A file with no version predates stamping or was hand written, so it is deliberately not reported as +/// stale - a warning nobody can act on is how people learn to ignore warnings. +/// +public class when_deciding_whether_it_is_stale : Specification +{ + [Fact] void should_be_stale_when_generated_by_an_older_cli() => + GeneratedContextVersion.IsStale("2.8.2.0", "2.9.0.0").ShouldBeTrue(); + + [Fact] void should_be_stale_when_generated_by_a_newer_cli() => + GeneratedContextVersion.IsStale("2.9.0.0", "2.8.2.0").ShouldBeTrue(); + + [Fact] void should_not_be_stale_when_the_versions_match() => + GeneratedContextVersion.IsStale("2.8.2.0", "2.8.2.0").ShouldBeFalse(); + + [Fact] void should_not_be_stale_when_the_file_carries_no_version() => + GeneratedContextVersion.IsStale(null, "2.8.2.0").ShouldBeFalse(); + + [Fact] void should_not_be_stale_for_a_blank_version() => + GeneratedContextVersion.IsStale(" ", "2.8.2.0").ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_GeneratedContextVersion/when_reading_the_generating_version.cs b/Source/Cli.Specs/for_GeneratedContextVersion/when_reading_the_generating_version.cs new file mode 100644 index 0000000..8d4827d --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedContextVersion/when_reading_the_generating_version.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_GeneratedContextVersion; + +/// +/// The two generated files state their version differently - CHRONICLE.md in prose, the skill inside the +/// embedded descriptor - so either has to answer, or a stale catalog goes unnoticed in whichever file the +/// check happened to read. +/// +public class when_reading_the_generating_version : Specification +{ + [Fact] void should_read_it_from_the_chronicle_md_preamble() => + GeneratedContextVersion.ReadFrom("Generated by `cratis init` (CLI v2.8.2.0). Run `cratis init --refresh` to update.") + .ShouldEqual("2.8.2.0"); + + [Fact] void should_read_it_from_an_embedded_descriptor() => + GeneratedContextVersion.ReadFrom("""{ "tool": "cratis", "version": "2.9.0.0" }""") + .ShouldEqual("2.9.0.0"); + + [Fact] void should_answer_nothing_when_no_version_is_present() => + GeneratedContextVersion.ReadFrom("# Some hand written file").ShouldBeNull(); + + [Fact] void should_answer_nothing_for_empty_content() => + GeneratedContextVersion.ReadFrom(string.Empty).ShouldBeNull(); +} diff --git a/Source/Cli.Specs/for_InitCommand/when_configuring_pi/and_agents_md_already_references_chronicle.cs b/Source/Cli.Specs/for_InitCommand/when_configuring_pi/and_agents_md_already_references_chronicle.cs new file mode 100644 index 0000000..a125291 --- /dev/null +++ b/Source/Cli.Specs/for_InitCommand/when_configuring_pi/and_agents_md_already_references_chronicle.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_InitCommand.when_configuring_pi; + +/// +/// AGENTS.md is shared with other tools and is frequently hand-maintained, so configuring Pi has to be +/// idempotent against it - running init twice must not stack duplicate references into somebody's file. +/// +public class and_agents_md_already_references_chronicle : Specification +{ + string _tempDir; + string _agentsMd; + + void Establish() + { + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + _agentsMd = Path.Combine(_tempDir, "AGENTS.md"); + File.WriteAllText(_agentsMd, "# House rules\n\n@CHRONICLE.md\n"); + } + + void Because() => AiToolConfigurator.Configure(AiTool.Pi, _tempDir, force: false, includeCommands: false, llmContextJson: "{}"); + + [Fact] void should_not_add_a_second_reference() => + File.ReadAllText(_agentsMd).Split("@CHRONICLE.md").Length.ShouldEqual(2); + + [Fact] void should_leave_the_existing_content_alone() => + File.ReadAllText(_agentsMd).ShouldContain("# House rules"); + + void Destroy() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + } +} diff --git a/Source/Cli.Specs/for_InitCommand/when_configuring_pi/and_the_project_has_nothing_yet.cs b/Source/Cli.Specs/for_InitCommand/when_configuring_pi/and_the_project_has_nothing_yet.cs new file mode 100644 index 0000000..eee8393 --- /dev/null +++ b/Source/Cli.Specs/for_InitCommand/when_configuring_pi/and_the_project_has_nothing_yet.cs @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_InitCommand.when_configuring_pi; + +/// +/// Pi discovers skills from .pi/skills/<name>/SKILL.md and prompts from .pi/prompts, and reads its +/// context from AGENTS.md. Writing anywhere else produces files Pi never loads - which looks like success +/// and delivers nothing. +/// +public class and_the_project_has_nothing_yet : Specification +{ + string _tempDir; + IReadOnlyList _actions; + + void Establish() + { + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + } + + void Because() => _actions = AiToolConfigurator.Configure(AiTool.Pi, _tempDir, force: false, includeCommands: true, llmContextJson: "{}"); + + [Fact] void should_write_the_skill_where_pi_looks_for_it() => + File.Exists(Path.Combine(_tempDir, ".pi", "skills", "chronicle-cli", "SKILL.md")).ShouldBeTrue(); + + [Fact] void should_write_the_diagnose_prompt() => + File.Exists(Path.Combine(_tempDir, ".pi", "prompts", "chronicle-diagnose.md")).ShouldBeTrue(); + + [Fact] void should_reference_chronicle_from_agents_md() => + File.ReadAllText(Path.Combine(_tempDir, "AGENTS.md")).ShouldContain("@CHRONICLE.md"); + + [Fact] void should_give_the_skill_the_frontmatter_pi_requires() => + File.ReadAllText(Path.Combine(_tempDir, ".pi", "skills", "chronicle-cli", "SKILL.md")) + .ShouldContain("name: chronicle-cli"); + + [Fact] void should_report_what_it_did() => _actions.ShouldNotBeEmpty(); + + void Destroy() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + } +} diff --git a/Source/Cli.Specs/for_InitCommand/when_detecting_ai_tools/and_pi_detected_from_environment.cs b/Source/Cli.Specs/for_InitCommand/when_detecting_ai_tools/and_pi_detected_from_environment.cs new file mode 100644 index 0000000..60f9c3f --- /dev/null +++ b/Source/Cli.Specs/for_InitCommand/when_detecting_ai_tools/and_pi_detected_from_environment.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_InitCommand.when_detecting_ai_tools; + +/// +/// Pi exports its PI_* variables into every command it runs, which is what lets a first `cratis init` from +/// inside a Pi session configure Pi even though the project carries no .pi directory yet. +/// +[Collection(CliSpecsCollection.Name)] +public class and_pi_detected_from_environment : Specification +{ + string _tempDir; + string? _previousValue; + IReadOnlyList _result; + + void Establish() + { + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + _previousValue = Environment.GetEnvironmentVariable("PI_CODING_AGENT"); + Environment.SetEnvironmentVariable("PI_CODING_AGENT", "1"); + } + + void Because() => _result = AiToolDetector.Detect(_tempDir); + + [Fact] void should_detect_pi_without_project_files() => _result.ShouldContain(AiTool.Pi); + + void Destroy() + { + Environment.SetEnvironmentVariable("PI_CODING_AGENT", _previousValue); + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + } +} diff --git a/Source/Cli.Specs/for_InitCommand/when_detecting_ai_tools/and_pi_directory_exists.cs b/Source/Cli.Specs/for_InitCommand/when_detecting_ai_tools/and_pi_directory_exists.cs new file mode 100644 index 0000000..114ab61 --- /dev/null +++ b/Source/Cli.Specs/for_InitCommand/when_detecting_ai_tools/and_pi_directory_exists.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_InitCommand.when_detecting_ai_tools; + +public class and_pi_directory_exists : Specification +{ + string _tempDir; + IReadOnlyList _result; + + void Establish() + { + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + Directory.CreateDirectory(Path.Combine(_tempDir, ".pi")); + } + + void Because() => _result = AiToolDetector.Detect(_tempDir); + + [Fact] void should_detect_pi() => _result.ShouldContain(AiTool.Pi); + + void Destroy() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + } +} diff --git a/Source/Cli/Commands/Init/AiTool.cs b/Source/Cli/Commands/Init/AiTool.cs index f1b957e..45f772d 100644 --- a/Source/Cli/Commands/Init/AiTool.cs +++ b/Source/Cli/Commands/Init/AiTool.cs @@ -27,4 +27,9 @@ public enum AiTool /// Windsurf IDE. /// Windsurf = 3, + + /// + /// Pi coding agent. + /// + Pi = 4, } diff --git a/Source/Cli/Commands/Init/AiToolConfigurator.cs b/Source/Cli/Commands/Init/AiToolConfigurator.cs index 6d0c4f9..0b47e88 100644 --- a/Source/Cli/Commands/Init/AiToolConfigurator.cs +++ b/Source/Cli/Commands/Init/AiToolConfigurator.cs @@ -28,6 +28,7 @@ public static IReadOnlyList Configure(AiTool tool, string basePath, bool AiTool.Copilot => ConfigureCopilot(basePath, force, includeCommands, llmContextJson), AiTool.Cursor => ConfigureCursor(basePath, force), AiTool.Windsurf => ConfigureWindsurf(basePath, force), + AiTool.Pi => ConfigurePi(basePath, force, includeCommands, llmContextJson), _ => [], }; } @@ -58,6 +59,13 @@ public static IReadOnlyList RefreshSkillFiles(string basePath, string ll actions.Add($"Refreshed .claude/commands/{ChronicleSkillGenerator.SkillName}.md"); } + var piSkillPath = Path.Combine(basePath, ".pi", "skills", ChronicleSkillGenerator.SkillName, "SKILL.md"); + if (File.Exists(piSkillPath)) + { + File.WriteAllText(piSkillPath, skillContent); + actions.Add($"Refreshed .pi/skills/{ChronicleSkillGenerator.SkillName}/SKILL.md"); + } + return actions; } @@ -228,4 +236,77 @@ static List ConfigureWindsurf(string basePath, bool force) return actions; } + + /// + /// Configures Pi, whose project resources live under .pi/. + /// + /// + /// The context reference goes in AGENTS.md rather than a Pi-specific file, because that is what + /// Pi reads and because it is the cross-tool convention - a project already carrying one for another + /// agent gets the reference appended rather than a second file to keep in sync. Skills are discovered + /// from .pi/skills/<name>/SKILL.md, which is the same directory-with-frontmatter shape + /// Copilot uses, so the generated skill is written unchanged. + /// + /// The project base directory. + /// Whether to overwrite existing files. + /// Whether to generate the prompt and skill files. + /// The serialized llm-context JSON to embed in the skill file. + /// A list of actions taken. + static List ConfigurePi(string basePath, bool force, bool includeCommands, string llmContextJson) + { + var actions = new List(); + var agentsMd = Path.Combine(basePath, "AGENTS.md"); + + if (File.Exists(agentsMd)) + { + var content = File.ReadAllText(agentsMd); + if (!content.Contains(ChronicleReference, StringComparison.Ordinal)) + { + File.AppendAllText(agentsMd, $"\n{ChronicleReference}\n"); + actions.Add("Appended @CHRONICLE.md reference to AGENTS.md"); + } + else + { + actions.Add("AGENTS.md already references @CHRONICLE.md (skipped)"); + } + } + else + { + File.WriteAllText(agentsMd, $"{ChronicleReference}\n"); + actions.Add("Created AGENTS.md with @CHRONICLE.md reference"); + } + + if (includeCommands) + { + var promptsDir = Path.Combine(basePath, ".pi", "prompts"); + var promptPath = Path.Combine(promptsDir, $"{DiagnoseCommandName}.md"); + + if (!File.Exists(promptPath) || force) + { + Directory.CreateDirectory(promptsDir); + File.WriteAllText(promptPath, SlashCommands.ChronicleDiagnose); + actions.Add($"Created .pi/prompts/{DiagnoseCommandName}.md"); + } + else + { + actions.Add($".pi/prompts/{DiagnoseCommandName}.md already exists (skipped, use --force to overwrite)"); + } + + var skillDir = Path.Combine(basePath, ".pi", "skills", ChronicleSkillGenerator.SkillName); + var skillPath = Path.Combine(skillDir, "SKILL.md"); + + if (!File.Exists(skillPath) || force) + { + Directory.CreateDirectory(skillDir); + File.WriteAllText(skillPath, ChronicleSkillGenerator.Generate(llmContextJson)); + actions.Add($"Created .pi/skills/{ChronicleSkillGenerator.SkillName}/SKILL.md"); + } + else + { + actions.Add($".pi/skills/{ChronicleSkillGenerator.SkillName}/SKILL.md already exists (skipped, use --force to overwrite)"); + } + } + + return actions; + } } diff --git a/Source/Cli/Commands/Init/AiToolDetector.cs b/Source/Cli/Commands/Init/AiToolDetector.cs index c2ab766..b9261da 100644 --- a/Source/Cli/Commands/Init/AiToolDetector.cs +++ b/Source/Cli/Commands/Init/AiToolDetector.cs @@ -47,6 +47,9 @@ public static bool TryParse(string name, out AiTool tool) case "windsurf": tool = AiTool.Windsurf; return true; + case "pi": + tool = AiTool.Pi; + return true; default: tool = default; return false; @@ -81,6 +84,12 @@ static void DetectFromProjectFiles(string basePath, HashSet tools) { tools.Add(AiTool.Windsurf); } + + // Pi keeps project resources under .pi/ (skills, prompts, extensions, settings). + if (Directory.Exists(Path.Combine(basePath, ".pi"))) + { + tools.Add(AiTool.Pi); + } } /// @@ -120,5 +129,14 @@ static void DetectFromEnvironment(HashSet tools) { tools.Add(AiTool.Windsurf); } + + // Pi exports PI_* variables into every command it runs. PI_CODING_AGENT identifies the harness + // itself, where PI_SESSION_ID and friends only say a session is in flight - so it stays the + // signal even if the session variables are ever narrowed. + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("PI_CODING_AGENT")) || + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("PI_SESSION_ID"))) + { + tools.Add(AiTool.Pi); + } } } diff --git a/Source/Cli/Commands/Init/GeneratedContextVersion.cs b/Source/Cli/Commands/Init/GeneratedContextVersion.cs new file mode 100644 index 0000000..dd18c81 --- /dev/null +++ b/Source/Cli/Commands/Init/GeneratedContextVersion.cs @@ -0,0 +1,70 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.RegularExpressions; + +namespace Cratis.Cli.Commands.Init; + +/// +/// Reads the CLI version that generated the files cratis init wrote, so a stale snapshot can be +/// reported rather than silently believed. +/// +/// +/// The command catalog embedded in CHRONICLE.md and the generated skill is a snapshot taken when +/// cratis init ran, not something resolved live. After a CLI upgrade it keeps describing the older +/// surface: commands that have since been added are invisible to an agent, and ones that were renamed or +/// removed are still advertised. Nothing in the file says it is out of date, so the failure is silent and +/// an agent confidently writes a command that no longer exists. +/// +public static partial class GeneratedContextVersion +{ + /// + /// The version reported when a generated file carries no recognizable version at all. + /// + public const string Unknown = "unknown"; + + [GeneratedRegex(@"CLI v(?[0-9]+(?:\.[0-9]+)+)", RegexOptions.ExplicitCapture, 1000)] + private static partial Regex ProseVersion { get; } + + [GeneratedRegex(@"""version""\s*:\s*""(?[0-9]+(?:\.[0-9]+)+)""", RegexOptions.ExplicitCapture, 1000)] + private static partial Regex DescriptorVersion { get; } + + /// + /// Reads the generating CLI version out of the content of a file cratis init produced. + /// + /// The file content to read. + /// The version string, or when the content carries none. + public static string? ReadFrom(string content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return null; + } + + // CHRONICLE.md states it in prose ("CLI v2.8.2.0"); the skill carries it inside the embedded + // descriptor ("version": "2.8.2.0"). Both are matched so either file can answer. + var prose = ProseVersion.Match(content); + if (prose.Success) + { + return prose.Groups["version"].Value; + } + + var json = DescriptorVersion.Match(content); + return json.Success ? json.Groups["version"].Value : null; + } + + /// + /// Decides whether a generated file is stale relative to the running CLI. + /// + /// The version read from the file, or when absent. + /// The version of the running CLI. + /// when the file was generated by a different version. + /// + /// A file carrying no version is deliberately not reported as stale. It predates version + /// stamping or was hand-written, and warning about something the user cannot act on teaches them to + /// ignore the warning - which costs more than the case it catches. + /// + public static bool IsStale(string? generatedWith, string running) => + !string.IsNullOrWhiteSpace(generatedWith) && + !string.Equals(generatedWith, running, StringComparison.Ordinal); +} diff --git a/Source/Cli/Commands/Init/InitCommand.cs b/Source/Cli/Commands/Init/InitCommand.cs index 12d255f..b57a358 100644 --- a/Source/Cli/Commands/Init/InitCommand.cs +++ b/Source/Cli/Commands/Init/InitCommand.cs @@ -8,17 +8,19 @@ namespace Cratis.Cli.Commands.Init; /// /// Generates CHRONICLE.md and configures AI tools for the current project directory. /// -[LlmDescription("Generates a CHRONICLE.md documentation file and configures AI tools (Claude Code, GitHub Copilot, Cursor, Windsurf) for the current project. Run once per project.")] +[LlmDescription("Generates a CHRONICLE.md documentation file and configures AI tools (Claude Code, GitHub Copilot, Cursor, Windsurf, Pi) for the current project. Run once per project, and again with --refresh after upgrading the CLI.")] [CliCommand("init", "Generate CHRONICLE.md and configure AI tools for the current project")] [CliExample("init")] [CliExample("init", "--tool", "claude")] [CliExample("init", "--force", "--no-commands")] [LlmOption("--force", "bool", "Overwrite existing files")] -[LlmOption("--tool", "string", "Target a specific AI tool: claude, copilot, cursor, windsurf. Omit to auto-detect.")] +[LlmOption("--tool", "string", "Target a specific AI tool: claude, copilot, cursor, windsurf, pi. Omit to auto-detect.")] [LlmOption("--no-commands", "bool", "Skip generating slash commands / prompt files")] [LlmOption("--refresh", "bool", "Re-capture the llm-context snapshot in CHRONICLE.md without reconfiguring AI tool integrations.")] public class InitCommand : AsyncCommand { + static string RunningVersion => typeof(Program).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + /// protected override async Task ExecuteAsync(CommandContext context, InitSettings settings, CancellationToken cancellationToken) { @@ -53,7 +55,16 @@ protected override async Task ExecuteAsync(CommandContext context, InitSett if (File.Exists(chronicleMdPath) && !settings.Force) { - allActions.Add("CHRONICLE.md already exists (skipped, use --force to overwrite)"); + // Skipping an existing file is right - it may have been edited - but staying silent about a + // catalog generated by an older CLI is not: it still describes that CLI's commands, and an + // agent reading it has no way to know. Say so, and name the command that fixes it. + var existing = await File.ReadAllTextAsync(chronicleMdPath, cancellationToken); + var generatedWith = GeneratedContextVersion.ReadFrom(existing); + + allActions.Add(GeneratedContextVersion.IsStale(generatedWith, RunningVersion) + ? $"CHRONICLE.md was generated by CLI v{generatedWith} but this is v{RunningVersion} - run 'cratis init --refresh' to update the command catalog" + : "CHRONICLE.md already exists (skipped, use --force to overwrite)"); + llmJson = LlmContextCommand.BuildDescriptorJson(); } else @@ -72,7 +83,7 @@ protected override async Task ExecuteAsync(CommandContext context, InitSett { if (!AiToolDetector.TryParse(settings.Tool, out var tool)) { - OutputFormatter.WriteError(format, $"Unknown AI tool: '{settings.Tool}'", "Valid tools: claude, copilot, cursor, windsurf", ExitCodes.ValidationErrorCode); + OutputFormatter.WriteError(format, $"Unknown AI tool: '{settings.Tool}'", "Valid tools: claude, copilot, cursor, windsurf, pi", ExitCodes.ValidationErrorCode); return ExitCodes.ValidationError; } diff --git a/Source/Cli/Commands/Init/InitSettings.cs b/Source/Cli/Commands/Init/InitSettings.cs index 571615d..81a43a5 100644 --- a/Source/Cli/Commands/Init/InitSettings.cs +++ b/Source/Cli/Commands/Init/InitSettings.cs @@ -20,7 +20,7 @@ public class InitSettings : GlobalSettings /// Gets or sets the specific AI tool to configure. Omit to auto-detect. /// [CommandOption("--tool ")] - [Description("Target a specific AI tool: claude, copilot, cursor, windsurf. Omit to auto-detect.")] + [Description("Target a specific AI tool: claude, copilot, cursor, windsurf, pi. Omit to auto-detect.")] public string? Tool { get; set; } ///