Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Documentation/getting-started/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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();
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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();
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Pi discovers skills from .pi/skills/&lt;name&gt;/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.
/// </summary>
public class and_the_project_has_nothing_yet : Specification
{
string _tempDir;
IReadOnlyList<string> _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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[Collection(CliSpecsCollection.Name)]
public class and_pi_detected_from_environment : Specification
{
string _tempDir;
string? _previousValue;
IReadOnlyList<AiTool> _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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<AiTool> _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);
}
}
}
5 changes: 5 additions & 0 deletions Source/Cli/Commands/Init/AiTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,9 @@ public enum AiTool
/// Windsurf IDE.
/// </summary>
Windsurf = 3,

/// <summary>
/// Pi coding agent.
/// </summary>
Pi = 4,
}
81 changes: 81 additions & 0 deletions Source/Cli/Commands/Init/AiToolConfigurator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public static IReadOnlyList<string> 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),
_ => [],
};
}
Expand Down Expand Up @@ -58,6 +59,13 @@ public static IReadOnlyList<string> 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;
}

Expand Down Expand Up @@ -228,4 +236,77 @@ static List<string> ConfigureWindsurf(string basePath, bool force)

return actions;
}

/// <summary>
/// Configures Pi, whose project resources live under <c>.pi/</c>.
/// </summary>
/// <remarks>
/// The context reference goes in <c>AGENTS.md</c> 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 <c>.pi/skills/&lt;name&gt;/SKILL.md</c>, which is the same directory-with-frontmatter shape
/// Copilot uses, so the generated skill is written unchanged.
/// </remarks>
/// <param name="basePath">The project base directory.</param>
/// <param name="force">Whether to overwrite existing files.</param>
/// <param name="includeCommands">Whether to generate the prompt and skill files.</param>
/// <param name="llmContextJson">The serialized llm-context JSON to embed in the skill file.</param>
/// <returns>A list of actions taken.</returns>
static List<string> ConfigurePi(string basePath, bool force, bool includeCommands, string llmContextJson)
{
var actions = new List<string>();
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;
}
}
18 changes: 18 additions & 0 deletions Source/Cli/Commands/Init/AiToolDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,6 +84,12 @@ static void DetectFromProjectFiles(string basePath, HashSet<AiTool> 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);
}
}

/// <summary>
Expand Down Expand Up @@ -120,5 +129,14 @@ static void DetectFromEnvironment(HashSet<AiTool> 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);
}
}
}
Loading
Loading