From cb732f18a7b7467bdbb5e7c0ce6f4d126a36d0fa Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 19 Aug 2026 14:53:38 +0200 Subject: [PATCH] Let init skip a context reference that would be overwritten Some repositories generate their instruction files from a shared corpus and propagate them across repositories, so appending @CHRONICLE.md to one is undone by the next sync. That is worse than never adding it: the reference disappears at an unpredictable later moment, and whoever notices sees a project that was configured and silently is not. --no-context writes the skill and prompt and leaves the instruction file alone. The skip is reported rather than silent, and names what to do instead, because a project with the catalog on disk and nothing pointing at it looks configured and loads nothing. The four flags now travel as a record. They all read as bare booleans at the call site, where transposing two produces a configuration that looks right and is not. --- Documentation/getting-started/index.mdx | 2 + README.md | 5 + ..._agents_md_already_references_chronicle.cs | 2 +- .../and_the_project_has_nothing_yet.cs | 2 +- .../and_configuring_pi.cs | 53 +++++++++ ...and_the_tool_only_writes_a_context_file.cs | 48 +++++++++ .../Cli/Commands/Init/AiToolConfiguration.cs | 24 +++++ .../Cli/Commands/Init/AiToolConfigurator.cs | 101 ++++++++++++------ Source/Cli/Commands/Init/InitCommand.cs | 11 +- Source/Cli/Commands/Init/InitSettings.cs | 9 ++ 10 files changed, 218 insertions(+), 39 deletions(-) create mode 100644 Source/Cli.Specs/for_InitCommand/when_the_context_file_is_generated/and_configuring_pi.cs create mode 100644 Source/Cli.Specs/for_InitCommand/when_the_context_file_is_generated/and_the_tool_only_writes_a_context_file.cs create mode 100644 Source/Cli/Commands/Init/AiToolConfiguration.cs diff --git a/Documentation/getting-started/index.mdx b/Documentation/getting-started/index.mdx index eea503b..ac7b261 100644 --- a/Documentation/getting-started/index.mdx +++ b/Documentation/getting-started/index.mdx @@ -154,6 +154,8 @@ cratis init 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. +If the instruction file it would edit is generated from a shared corpus and propagated across repositories, pass `--no-context`. The skill and prompt files are still written; only the `@CHRONICLE.md` reference is left out, and `init` reports where to add it instead — editing a generated file would work until the next sync silently removed it. + 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 64d38b9..b5c3489 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,11 @@ The catalog `init` writes is a snapshot, not a live lookup. After upgrading the 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. +If your instruction file (`AGENTS.md`, `CLAUDE.md`, `.github/copilot-instructions.md`) is generated +from a shared corpus and propagated, pass `--no-context`: the skill and prompt are still written, +and `init` tells you to add the `@CHRONICLE.md` line to whatever generates that file instead of +editing a file the next sync will overwrite. + ## Tab completion asks the server `cratis completions install` writes a completion script for bash, zsh, fish or PowerShell. 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 index a125291..2305c40 100644 --- 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 @@ -20,7 +20,7 @@ void Establish() File.WriteAllText(_agentsMd, "# House rules\n\n@CHRONICLE.md\n"); } - void Because() => AiToolConfigurator.Configure(AiTool.Pi, _tempDir, force: false, includeCommands: false, llmContextJson: "{}"); + void Because() => AiToolConfigurator.Configure(AiTool.Pi, _tempDir, new(Force: false, IncludeCommands: false, IncludeContext: true, LlmContextJson: "{}")); [Fact] void should_not_add_a_second_reference() => File.ReadAllText(_agentsMd).Split("@CHRONICLE.md").Length.ShouldEqual(2); 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 index eee8393..27c93b9 100644 --- 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 @@ -19,7 +19,7 @@ void Establish() Directory.CreateDirectory(_tempDir); } - void Because() => _actions = AiToolConfigurator.Configure(AiTool.Pi, _tempDir, force: false, includeCommands: true, llmContextJson: "{}"); + void Because() => _actions = AiToolConfigurator.Configure(AiTool.Pi, _tempDir, new(Force: false, IncludeCommands: true, IncludeContext: 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(); diff --git a/Source/Cli.Specs/for_InitCommand/when_the_context_file_is_generated/and_configuring_pi.cs b/Source/Cli.Specs/for_InitCommand/when_the_context_file_is_generated/and_configuring_pi.cs new file mode 100644 index 0000000..e678a92 --- /dev/null +++ b/Source/Cli.Specs/for_InitCommand/when_the_context_file_is_generated/and_configuring_pi.cs @@ -0,0 +1,53 @@ +// 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_the_context_file_is_generated; + +/// +/// Some repositories generate their instruction files from a shared corpus and propagate them, so appending +/// to one is undone by the next sync - the reference disappears at an unpredictable later moment, which is +/// worse than never adding it. The skill and prompt are unaffected and still written. +/// +/// The skip is reported rather than silent, because a project that looks configured and loads nothing is +/// the harder failure to notice. +/// +/// +public class and_configuring_pi : Specification +{ + string _tempDir; + string _agentsMd; + IReadOnlyList _actions; + + void Establish() + { + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + _agentsMd = Path.Combine(_tempDir, "AGENTS.md"); + File.WriteAllText(_agentsMd, "# Generated - do not edit\n"); + } + + void Because() => _actions = AiToolConfigurator.Configure( + AiTool.Pi, + _tempDir, + new(Force: false, IncludeCommands: true, IncludeContext: false, LlmContextJson: "{}")); + + [Fact] void should_leave_the_generated_file_untouched() => + File.ReadAllText(_agentsMd).ShouldEqual("# Generated - do not edit\n"); + + [Fact] void should_still_write_the_skill() => + File.Exists(Path.Combine(_tempDir, ".pi", "skills", "chronicle-cli", "SKILL.md")).ShouldBeTrue(); + + [Fact] void should_still_write_the_prompt() => + File.Exists(Path.Combine(_tempDir, ".pi", "prompts", "chronicle-diagnose.md")).ShouldBeTrue(); + + [Fact] void should_say_it_skipped_the_reference() => + _actions.ShouldContain(_ => _.Contains("Skipped the @CHRONICLE.md reference in AGENTS.md", StringComparison.Ordinal)); + + void Destroy() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + } +} diff --git a/Source/Cli.Specs/for_InitCommand/when_the_context_file_is_generated/and_the_tool_only_writes_a_context_file.cs b/Source/Cli.Specs/for_InitCommand/when_the_context_file_is_generated/and_the_tool_only_writes_a_context_file.cs new file mode 100644 index 0000000..f880e21 --- /dev/null +++ b/Source/Cli.Specs/for_InitCommand/when_the_context_file_is_generated/and_the_tool_only_writes_a_context_file.cs @@ -0,0 +1,48 @@ +// 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_the_context_file_is_generated; + +/// +/// Cursor and Windsurf are configured entirely through a rules file, so --no-context leaves them with +/// nothing to write. Reporting that is the whole value: silently doing nothing reads as success. +/// +public class and_the_tool_only_writes_a_context_file : Specification +{ + string _tempDir; + IReadOnlyList _cursor; + IReadOnlyList _windsurf; + + void Establish() + { + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + } + + void Because() + { + var configuration = new AiToolConfiguration(Force: true, IncludeCommands: true, IncludeContext: false, LlmContextJson: "{}"); + _cursor = AiToolConfigurator.Configure(AiTool.Cursor, _tempDir, configuration); + _windsurf = AiToolConfigurator.Configure(AiTool.Windsurf, _tempDir, configuration); + } + + [Fact] void should_not_write_the_cursor_rule() => + File.Exists(Path.Combine(_tempDir, ".cursor", "rules", "chronicle.mdc")).ShouldBeFalse(); + + [Fact] void should_not_write_the_windsurf_rules() => + File.Exists(Path.Combine(_tempDir, ".windsurfrules")).ShouldBeFalse(); + + [Fact] void should_say_why_cursor_got_nothing() => + _cursor.ShouldContain(_ => _.Contains("Skipped the @CHRONICLE.md reference", StringComparison.Ordinal)); + + [Fact] void should_say_why_windsurf_got_nothing() => + _windsurf.ShouldContain(_ => _.Contains("Skipped the @CHRONICLE.md reference", StringComparison.Ordinal)); + + void Destroy() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + } +} diff --git a/Source/Cli/Commands/Init/AiToolConfiguration.cs b/Source/Cli/Commands/Init/AiToolConfiguration.cs new file mode 100644 index 0000000..830dc61 --- /dev/null +++ b/Source/Cli/Commands/Init/AiToolConfiguration.cs @@ -0,0 +1,24 @@ +// 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.Commands.Init; + +/// +/// What cratis init should write for one AI tool. +/// +/// +/// A record rather than a parameter list because the three switches all read as bare booleans at the call +/// site, where transposing two of them produces a configuration that looks configured and is not. +/// +/// Whether to overwrite files that already exist. +/// Whether to write the skill and slash-command/prompt files. +/// +/// Whether to add the @CHRONICLE.md reference to the tool's instruction file. False when that file is +/// generated from a shared corpus, where the edit would be overwritten on the next sync. +/// +/// The serialized llm-context JSON to embed in skill files. +public record AiToolConfiguration( + bool Force, + bool IncludeCommands, + bool IncludeContext, + string LlmContextJson); diff --git a/Source/Cli/Commands/Init/AiToolConfigurator.cs b/Source/Cli/Commands/Init/AiToolConfigurator.cs index 0b47e88..6fcd2a4 100644 --- a/Source/Cli/Commands/Init/AiToolConfigurator.cs +++ b/Source/Cli/Commands/Init/AiToolConfigurator.cs @@ -16,19 +16,17 @@ public static class AiToolConfigurator /// /// The AI tool to configure. /// The project base directory. - /// Whether to overwrite existing files. - /// Whether to generate slash command files. - /// The serialized llm-context JSON to embed in skill files. + /// What to write for this tool. /// A list of actions taken. - public static IReadOnlyList Configure(AiTool tool, string basePath, bool force, bool includeCommands, string llmContextJson) + public static IReadOnlyList Configure(AiTool tool, string basePath, AiToolConfiguration configuration) { return tool switch { - AiTool.Claude => ConfigureClaude(basePath, force, includeCommands, llmContextJson), - AiTool.Copilot => ConfigureCopilot(basePath, force, includeCommands, llmContextJson), - AiTool.Cursor => ConfigureCursor(basePath, force), - AiTool.Windsurf => ConfigureWindsurf(basePath, force), - AiTool.Pi => ConfigurePi(basePath, force, includeCommands, llmContextJson), + AiTool.Claude => ConfigureClaude(basePath, configuration), + AiTool.Copilot => ConfigureCopilot(basePath, configuration), + AiTool.Cursor => ConfigureCursor(basePath, configuration), + AiTool.Windsurf => ConfigureWindsurf(basePath, configuration), + AiTool.Pi => ConfigurePi(basePath, configuration), _ => [], }; } @@ -69,12 +67,29 @@ public static IReadOnlyList RefreshSkillFiles(string basePath, string ll return actions; } - static List ConfigureClaude(string basePath, bool force, bool includeCommands, string llmContextJson) + /// + /// Reports a context reference that was deliberately not written, and says what to do instead. + /// + /// + /// Skipping silently would leave a project that looks configured and loads nothing - the skill is on + /// disk but no instruction file points at CHRONICLE.md, so an agent never reads it. Naming the + /// file and the line to add turns the skip into an instruction rather than an omission. + /// + /// The instruction file that was left alone. + /// The action to report. + static string SkippedContext(string file) => + $"Skipped the @CHRONICLE.md reference in {file} (--no-context) - add it to whatever generates that file, or the catalog is written but never loaded"; + + static List ConfigureClaude(string basePath, AiToolConfiguration configuration) { var actions = new List(); var claudeMd = Path.Combine(basePath, "CLAUDE.md"); - if (File.Exists(claudeMd)) + if (!configuration.IncludeContext) + { + actions.Add(SkippedContext("CLAUDE.md")); + } + else if (File.Exists(claudeMd)) { var content = File.ReadAllText(claudeMd); if (!content.Contains(ChronicleReference, StringComparison.Ordinal)) @@ -93,12 +108,12 @@ static List ConfigureClaude(string basePath, bool force, bool includeCom actions.Add("Created CLAUDE.md with @CHRONICLE.md reference"); } - if (includeCommands) + if (configuration.IncludeCommands) { var commandsDir = Path.Combine(basePath, ".claude", "commands"); var commandPath = Path.Combine(commandsDir, $"{DiagnoseCommandName}.md"); - if (!File.Exists(commandPath) || force) + if (!File.Exists(commandPath) || configuration.Force) { Directory.CreateDirectory(commandsDir); File.WriteAllText(commandPath, SlashCommands.ChronicleDiagnose); @@ -111,10 +126,10 @@ static List ConfigureClaude(string basePath, bool force, bool includeCom var skillPath = Path.Combine(commandsDir, $"{ChronicleSkillGenerator.SkillName}.md"); - if (!File.Exists(skillPath) || force) + if (!File.Exists(skillPath) || configuration.Force) { Directory.CreateDirectory(commandsDir); - File.WriteAllText(skillPath, ChronicleSkillGenerator.Generate(llmContextJson)); + File.WriteAllText(skillPath, ChronicleSkillGenerator.Generate(configuration.LlmContextJson)); actions.Add($"Created .claude/commands/{ChronicleSkillGenerator.SkillName}.md"); } else @@ -126,12 +141,16 @@ static List ConfigureClaude(string basePath, bool force, bool includeCom return actions; } - static List ConfigureCopilot(string basePath, bool force, bool includeCommands, string llmContextJson) + static List ConfigureCopilot(string basePath, AiToolConfiguration configuration) { var actions = new List(); var instructionsPath = Path.Combine(basePath, ".github", "copilot-instructions.md"); - if (File.Exists(instructionsPath)) + if (!configuration.IncludeContext) + { + actions.Add(SkippedContext(".github/copilot-instructions.md")); + } + else if (File.Exists(instructionsPath)) { var content = File.ReadAllText(instructionsPath); if (!content.Contains(ChronicleReference, StringComparison.Ordinal)) @@ -152,12 +171,12 @@ static List ConfigureCopilot(string basePath, bool force, bool includeCo actions.Add("Created .github/copilot-instructions.md with @CHRONICLE.md reference"); } - if (includeCommands) + if (configuration.IncludeCommands) { var promptsDir = Path.Combine(basePath, ".github", "copilot", "prompts"); var promptPath = Path.Combine(promptsDir, $"{DiagnoseCommandName}.prompt.md"); - if (!File.Exists(promptPath) || force) + if (!File.Exists(promptPath) || configuration.Force) { Directory.CreateDirectory(promptsDir); File.WriteAllText(promptPath, SlashCommands.ChronicleDiagnose); @@ -171,10 +190,10 @@ static List ConfigureCopilot(string basePath, bool force, bool includeCo var skillDir = Path.Combine(basePath, ".github", "skills", ChronicleSkillGenerator.SkillName); var skillPath = Path.Combine(skillDir, "SKILL.md"); - if (!File.Exists(skillPath) || force) + if (!File.Exists(skillPath) || configuration.Force) { Directory.CreateDirectory(skillDir); - File.WriteAllText(skillPath, ChronicleSkillGenerator.Generate(llmContextJson)); + File.WriteAllText(skillPath, ChronicleSkillGenerator.Generate(configuration.LlmContextJson)); actions.Add($"Created .github/skills/{ChronicleSkillGenerator.SkillName}/SKILL.md"); } else @@ -186,13 +205,19 @@ static List ConfigureCopilot(string basePath, bool force, bool includeCo return actions; } - static List ConfigureCursor(string basePath, bool force) + static List ConfigureCursor(string basePath, AiToolConfiguration configuration) { var actions = new List(); + + if (!configuration.IncludeContext) + { + return [SkippedContext(".cursor/rules/chronicle.mdc")]; + } + var rulesDir = Path.Combine(basePath, ".cursor", "rules"); var rulePath = Path.Combine(rulesDir, "chronicle.mdc"); - if (!File.Exists(rulePath) || force) + if (!File.Exists(rulePath) || configuration.Force) { Directory.CreateDirectory(rulesDir); File.WriteAllText(rulePath, $"{ChronicleReference}\n"); @@ -206,9 +231,15 @@ static List ConfigureCursor(string basePath, bool force) return actions; } - static List ConfigureWindsurf(string basePath, bool force) + static List ConfigureWindsurf(string basePath, AiToolConfiguration configuration) { var actions = new List(); + + if (!configuration.IncludeContext) + { + return [SkippedContext(".windsurfrules")]; + } + var rulesPath = Path.Combine(basePath, ".windsurfrules"); if (File.Exists(rulesPath)) @@ -224,7 +255,7 @@ static List ConfigureWindsurf(string basePath, bool force) actions.Add(".windsurfrules already references @CHRONICLE.md (skipped)"); } } - else if (force) + else if (configuration.Force) { File.WriteAllText(rulesPath, $"{ChronicleReference}\n"); actions.Add("Created .windsurfrules with @CHRONICLE.md reference"); @@ -248,16 +279,18 @@ static List ConfigureWindsurf(string basePath, bool force) /// 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. + /// What to write for Pi. /// A list of actions taken. - static List ConfigurePi(string basePath, bool force, bool includeCommands, string llmContextJson) + static List ConfigurePi(string basePath, AiToolConfiguration configuration) { var actions = new List(); var agentsMd = Path.Combine(basePath, "AGENTS.md"); - if (File.Exists(agentsMd)) + if (!configuration.IncludeContext) + { + actions.Add(SkippedContext("AGENTS.md")); + } + else if (File.Exists(agentsMd)) { var content = File.ReadAllText(agentsMd); if (!content.Contains(ChronicleReference, StringComparison.Ordinal)) @@ -276,12 +309,12 @@ static List ConfigurePi(string basePath, bool force, bool includeCommand actions.Add("Created AGENTS.md with @CHRONICLE.md reference"); } - if (includeCommands) + if (configuration.IncludeCommands) { var promptsDir = Path.Combine(basePath, ".pi", "prompts"); var promptPath = Path.Combine(promptsDir, $"{DiagnoseCommandName}.md"); - if (!File.Exists(promptPath) || force) + if (!File.Exists(promptPath) || configuration.Force) { Directory.CreateDirectory(promptsDir); File.WriteAllText(promptPath, SlashCommands.ChronicleDiagnose); @@ -295,10 +328,10 @@ static List ConfigurePi(string basePath, bool force, bool includeCommand var skillDir = Path.Combine(basePath, ".pi", "skills", ChronicleSkillGenerator.SkillName); var skillPath = Path.Combine(skillDir, "SKILL.md"); - if (!File.Exists(skillPath) || force) + if (!File.Exists(skillPath) || configuration.Force) { Directory.CreateDirectory(skillDir); - File.WriteAllText(skillPath, ChronicleSkillGenerator.Generate(llmContextJson)); + File.WriteAllText(skillPath, ChronicleSkillGenerator.Generate(configuration.LlmContextJson)); actions.Add($"Created .pi/skills/{ChronicleSkillGenerator.SkillName}/SKILL.md"); } else diff --git a/Source/Cli/Commands/Init/InitCommand.cs b/Source/Cli/Commands/Init/InitCommand.cs index b57a358..4fbc74c 100644 --- a/Source/Cli/Commands/Init/InitCommand.cs +++ b/Source/Cli/Commands/Init/InitCommand.cs @@ -16,6 +16,7 @@ namespace Cratis.Cli.Commands.Init; [LlmOption("--force", "bool", "Overwrite existing files")] [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("--no-context", "bool", "Skip adding the @CHRONICLE.md reference to the tool's instruction file. Use when that file is generated from a shared corpus and the edit would be overwritten.")] [LlmOption("--refresh", "bool", "Re-capture the llm-context snapshot in CHRONICLE.md without reconfiguring AI tool integrations.")] public class InitCommand : AsyncCommand { @@ -101,11 +102,15 @@ protected override async Task ExecuteAsync(CommandContext context, InitSett } else { - var includeCommands = !settings.NoCommands; + var configuration = new AiToolConfiguration( + Force: settings.Force, + IncludeCommands: !settings.NoCommands, + IncludeContext: !settings.NoContext, + LlmContextJson: llmJson); + foreach (var tool in tools) { - var actions = AiToolConfigurator.Configure(tool, basePath, settings.Force, includeCommands, llmJson); - allActions.AddRange(actions); + allActions.AddRange(AiToolConfigurator.Configure(tool, basePath, configuration)); } } diff --git a/Source/Cli/Commands/Init/InitSettings.cs b/Source/Cli/Commands/Init/InitSettings.cs index 81a43a5..bfbc8df 100644 --- a/Source/Cli/Commands/Init/InitSettings.cs +++ b/Source/Cli/Commands/Init/InitSettings.cs @@ -39,4 +39,13 @@ public class InitSettings : GlobalSettings [Description("Re-capture the llm-context snapshot in CHRONICLE.md. Skips AI tool configuration.")] [DefaultValue(false)] public bool Refresh { get; set; } + + /// + /// Gets or sets a value indicating whether the context reference should be left out of the tool's + /// instruction file. + /// + [CommandOption("--no-context")] + [Description("Skip adding the @CHRONICLE.md reference to the tool's instruction file. Use when that file is generated from a shared corpus and would overwrite the edit.")] + [DefaultValue(false)] + public bool NoContext { get; set; } }