feat(axiom-pi): add Pi coding agent extension and install manifest

Add an axiom-pi extension for the Pi coding agent. Pi has no sub-agent or
plugin-command system, so this supplies the layers its skills can't:

- the 15 /axiom-* commands, each dispatching a prompt that triggers the
  matching skill inline (sendUserMessage);
- a session hook injecting the iOS-version ground truth and on-PATH tool
  availability (before_agent_start), gated to Apple projects;
- tool hooks: swiftformat + @State access-control guardrail on Swift
  writes, crash-file Read routing to xcsym, and bash-output skill hints.

Pure logic lives in commands/session/guardrails.ts with colocated vitest
tests (31 tests); index.ts is thin Pi wiring, typechecked against the
@earendil-works/pi-coding-agent types.

Declare a pi manifest (pi.skills + pi.extensions) in the root package.json
so `pi install git:github.com/CharlesWiltgen/Axiom` delivers the 27 skills
and the extension in one native command — no npm publish, no build step
(Pi runs the TypeScript directly).
This commit is contained in:
Charles Wiltgen
2026-06-14 17:55:46 -07:00
parent 79c9483346
commit 31224bece0
12 changed files with 4289 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
# axiom-pi
Axiom's commands and hooks for the [Pi coding agent](https://pi.dev/).
Axiom's iOS/Apple-platform expertise lives in **skills**, which Pi loads natively
(`npx skills add CharlesWiltgen/Axiom -a pi -g`). This extension adds the two
layers Pi can't get from skills alone:
- **`/axiom-*` commands** — the Axiom commands, ported to Pi. In Claude Code each
launches a sub-agent; Pi has no sub-agents, so each command sends a
natural-language prompt that triggers the matching skill inline.
- **Session and tool hooks** — the parts of Axiom's Claude Code hooks that are
additive on Pi (skills cover the rest).
## What it adds
### Commands
`/axiom-fix-build`, `/axiom-audit <area>`, `/axiom-health-check`,
`/axiom-analyze-crash`, `/axiom-triage`, `/axiom-console`, `/axiom-ui`,
`/axiom-profile`, `/axiom-compare-traces`, `/axiom-optimize-build`,
`/axiom-run-tests`, `/axiom-test-simulator`, `/axiom-screenshot`,
`/axiom-status`, `/axiom-ask`.
`/axiom-audit` offers the audit areas (memory, concurrency, security, …) as
argument completions.
### Hooks
- **Session ground truth** (`before_agent_start`) — injects the iOS/Xcode
version behavioral rules (so the agent never claims iOS 26 "doesn't exist")
and lists which Axiom command-line tools (`xclog`, `xcsym`, `xcui`, `xcprof`)
are on your `PATH`. Gated to Apple projects; override with
`AXIOM_SESSION_CONTEXT=always|never`.
- **Swift write guardrail** (`tool_result`) — runs `swiftformat` on written
`.swift` files (when installed) and flags `@State` declarations missing an
explicit access level.
- **Crash-file routing** (`tool_result`) — when a `.ips`/`.crash`/`.xccrashpoint`
file is read, suggests the right `xcsym` command.
- **Bash skill hints** (`tool_result`) — scans command output for known iOS
error signatures and points at the matching skill.
## Install
The recommended install gets the skills **and** this extension in one command —
the repo's root `package.json` declares both via a `pi` manifest (`pi.skills` +
`pi.extensions`):
```bash
pi install git:github.com/CharlesWiltgen/Axiom
```
To work on just the extension from a local checkout, symlink it into Pi's
extensions directory instead:
```bash
ln -s "$(pwd)/axiom-pi" ~/.pi/agent/extensions/axiom-pi
```
Pi discovers extensions from `~/.pi/agent/extensions/` (global) and
`.pi/extensions/` (project), reading the entry point from each package's
`pi.extensions` field.
## Develop
```bash
npm install # dev dependencies (Pi types, TypeScript, vitest)
npm run typecheck
npm test
```
The command table, session context, and guardrail logic are pure functions in
`src/{commands,session,guardrails}.ts` with colocated tests; `src/index.ts` is
the thin Pi wiring. Pi loads TypeScript directly — no build step.
+3266
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "axiom-pi",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Axiom extension for the Pi coding agent — /axiom-* commands and session/tool hooks for Apple-platform development",
"pi": {
"extensions": [
"./src/index.ts"
]
},
"files": [
"src",
"README.md",
"LICENSE"
],
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"pi",
"pi-agent",
"ios",
"swift",
"swiftui",
"xcode",
"axiom"
],
"author": "Charles Wiltgen",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/CharlesWiltgen/Axiom.git",
"directory": "axiom-pi"
},
"homepage": "https://charleswiltgen.github.io/Axiom/start/pi-install",
"bugs": {
"url": "https://github.com/CharlesWiltgen/Axiom/issues"
},
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "^0.79.3",
"@types/node": "^25.9.3",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
}
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { AXIOM_COMMANDS, AUDIT_AREAS } from "./commands.ts";
describe("AXIOM_COMMANDS", () => {
it("registers exactly the 15 Axiom commands, in order", () => {
expect(AXIOM_COMMANDS.map((c) => c.name)).toEqual([
"axiom-fix-build", "axiom-audit", "axiom-health-check", "axiom-analyze-crash",
"axiom-triage", "axiom-console", "axiom-ui", "axiom-profile",
"axiom-compare-traces", "axiom-optimize-build", "axiom-run-tests",
"axiom-test-simulator", "axiom-screenshot", "axiom-status", "axiom-ask",
]);
});
it("gives every command a non-empty prompt for both empty and non-empty args", () => {
for (const cmd of AXIOM_COMMANDS) {
expect(cmd.prompt("").length).toBeGreaterThan(0);
expect(cmd.prompt("Foo.swift").length).toBeGreaterThan(0);
}
});
});
describe("axiom-audit prompt", () => {
const audit = AXIOM_COMMANDS.find((c) => c.name === "axiom-audit")!;
it("names the area when one is given", () => {
expect(audit.prompt("memory")).toContain("memory");
});
it("routes 'all' to a full health check", () => {
expect(audit.prompt("all")).toContain("health check");
});
it("suggests audits when no area is given", () => {
expect(audit.prompt("")).toContain("which Axiom audits");
});
it("offers the audit areas as completions", () => {
expect(audit.completions).toBe(AUDIT_AREAS);
});
});
describe("axiom-analyze-crash prompt", () => {
it("includes the crash file path argument", () => {
const cmd = AXIOM_COMMANDS.find((c) => c.name === "axiom-analyze-crash")!;
expect(cmd.prompt("/tmp/foo.ips")).toContain("/tmp/foo.ips");
});
});
+171
View File
@@ -0,0 +1,171 @@
/**
* Axiom commands, ported to Pi.
*
* Each Axiom `/axiom:*` command launches an agent in Claude Code. Pi has no
* sub-agent system, so each command instead registers as `/axiom-<name>` and
* sends a natural-language prompt that triggers the matching skill inline.
*
* Pure data + prompt builders — no Pi imports, so the table is unit-testable.
*/
export type AxiomCommand = {
/** Registered as `/<name>` in Pi. */
name: string;
description: string;
/** Argument completions offered after the command (e.g. audit areas). */
completions?: readonly string[];
/** Builds the user message that triggers the matching skill workflow. */
prompt: (args: string) => string;
};
/** Audit areas accepted by `/axiom-audit` (mirrors the Claude command argument). */
export const AUDIT_AREAS = [
"all", "memory", "concurrency", "accessibility", "energy",
"swiftui-performance", "swiftui-architecture", "swiftui-nav", "swiftui-layout",
"swift-performance", "core-data", "swiftdata", "database-schema", "networking",
"codable", "icloud", "storage", "liquid-glass", "textkit", "testing", "build",
"spritekit", "security", "modernization", "camera", "foundation-models",
"screenshots", "ux-flow",
] as const;
export const AXIOM_COMMANDS: readonly AxiomCommand[] = [
{
name: "axiom-fix-build",
description: "Diagnose and fix an Xcode build failure (environment-first)",
prompt: () =>
"My Xcode build is failing. Use the axiom-build skill to diagnose it " +
"environment-first — check for zombie xcodebuild processes " +
"(`pgrep -x xcodebuild`), Derived Data, the SPM cache, and simulator " +
"state before touching code — then apply the fix and verify it builds.",
},
{
name: "axiom-audit",
description: "Run an Axiom code audit (memory, concurrency, security, …) or suggest relevant ones",
completions: AUDIT_AREAS,
prompt: (args) => {
const area = args.trim();
if (!area) {
return "Analyze this project's structure and tell me which Axiom audits " +
"are most relevant (memory, concurrency, data safety, performance, " +
"security, accessibility, UX), then run the ones I confirm.";
}
if (area === "all") {
return "Run a full Axiom health check across all relevant domains and " +
"give a unified, prioritized report.";
}
return `Run an Axiom ${area} audit on this codebase using the matching ` +
"skill. Report findings by severity (CRITICAL/HIGH/MEDIUM/LOW) with " +
"file:line and concrete fixes.";
},
},
{
name: "axiom-health-check",
description: "Full project health check across all Axiom audit domains",
prompt: () =>
"Run a full Axiom health check on this project — audit across memory, " +
"concurrency, data safety, performance, networking, security, and " +
"accessibility, then give one prioritized, de-duplicated report.",
},
{
name: "axiom-analyze-crash",
description: "Symbolicate and triage a crash report (.ips, MetricKit, .crash, .xccrashpoint)",
prompt: (args) => {
const f = args.trim();
const target = f ? `: ${f}` : " (point me at the file if I haven't given a path)";
return `Analyze this crash report${target}. Run ` +
"`xcsym crash --format=summary` to symbolicate and categorize it, then " +
"explain the crash pattern and the fix. See the axiom-tools crash workflow.";
},
},
{
name: "axiom-triage",
description: "Triage a corpus of production crashes from Sentry / App Store Connect",
prompt: (args) =>
`Triage my production crashes${args.trim() ? ` from ${args.trim()}` : ""}` +
"fetch the unresolved issues, run `xcsym triage` to classify and cluster " +
"them, flag suspension/idle-runloop noise, and rank the root-cause families " +
"to fix first.",
},
{
name: "axiom-console",
description: "Capture simulator console output with xclog",
prompt: (args) =>
`Capture the simulator console with \`xclog\`${args.trim() ? ` for ${args.trim()}` : ""}` +
"use `xclog list` to find the bundle id, then " +
"`xclog launch <bundle-id> --timeout 30s --max-lines 200` for a bounded " +
"capture, and summarize errors. See axiom-tools (xclog-ref).",
},
{
name: "axiom-ui",
description: "Drive and validate the simulator UI & accessibility with xcui",
prompt: (args) =>
`Drive and validate the simulator UI with \`xcui\`${args.trim() ? `: ${args.trim()}` : ""}. ` +
"Run `xcui doctor` first, then tap by accessibility id, dump the " +
"accessibility tree, and assert on labels/traits/VoiceOver/Dynamic Type. " +
"See axiom-tools (xcui-ref).",
},
{
name: "axiom-profile",
description: "Record and analyze a CPU/performance trace with xcprof (no Instruments GUI)",
prompt: (args) =>
`Profile my app's performance with \`xcprof\`${args.trim() ? ` (${args.trim()})` : ""}` +
"`xcprof record --preset cpu --attach <pid|name>` then " +
"`xcprof analyze <trace> --json`, and give an honest hot-frame report. " +
"See axiom-tools (xcprof-ref).",
},
{
name: "axiom-compare-traces",
description: "Diff two performance traces for CPU regressions (xcprof compare)",
prompt: (args) =>
"Compare two performance traces for CPU-share regressions with " +
`\`xcprof compare <baseline> <current>\`${args.trim() ? `: ${args.trim()}` : ""} ` +
"and report what regressed. See the axiom-performance trace-comparison workflow.",
},
{
name: "axiom-optimize-build",
description: "Find and apply Xcode build-time optimizations",
prompt: () =>
"Analyze my Xcode build performance and suggest optimizations — slow " +
"type-checking expressions, expensive build-phase scripts, suboptimal " +
"build settings, and parallelization opportunities. Use the axiom-build skill.",
},
{
name: "axiom-run-tests",
description: "Run XCUITests and parse the results",
prompt: (args) =>
`Run my tests${args.trim() ? ` (${args.trim()})` : ""} with xcodebuild, parse ` +
"the .xcresult, and show failures with details and attachments. Use the " +
"axiom-testing skill.",
},
{
name: "axiom-test-simulator",
description: "Run a simulator test scenario with visual + accessibility verification",
prompt: (args) =>
`Set up and run a simulator test scenario${args.trim() ? `: ${args.trim()}` : ""}` +
"drive the UI with `xcui`, capture screenshots for visual verification, " +
"assert on the accessibility tree, and check logs for crashes/errors.",
},
{
name: "axiom-screenshot",
description: "Capture a simulator screenshot for verification",
prompt: (args) =>
`Capture a simulator screenshot${args.trim() ? ` of ${args.trim()}` : " of the current screen"} ` +
"and verify the UI state (no placeholder text, correct layout, no debug overlays).",
},
{
name: "axiom-status",
description: "Axiom project health dashboard (environment + suggestions)",
prompt: () =>
"Show the Axiom project health dashboard — read the environment " +
"(IPHONEOS_DEPLOYMENT_TARGET, Xcode version, simulator state, available " +
"Axiom tools) and suggest concrete improvements.",
},
{
name: "axiom-ask",
description: "Ask an iOS/Swift question routed to the right Axiom skill",
prompt: (args) =>
args.trim()
? `Answer this iOS/Swift question, loading the most relevant Axiom skill first: ${args.trim()}`
: "What iOS/Swift question can I help with? I'll route it to the most relevant Axiom skill.",
},
];
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import {
unscopedStateVars,
classifyCrashPath,
crashFileHint,
bashOutputHints,
inputPath,
swiftGuardrailWarning,
toolResultHint,
} from "./guardrails.ts";
describe("unscopedStateVars", () => {
it("flags @State var without an access level, with 1-based line numbers", () => {
const swift = [
"struct V: View {",
" @State var count = 0",
' @State private var name = ""',
" @State var flagged = true // axiom-ignore",
"}",
].join("\n");
expect(unscopedStateVars(swift)).toEqual([{ line: 2, text: "@State var count = 0" }]);
});
it("returns nothing when every @State is scoped", () => {
expect(unscopedStateVars("@State private var a = 1\n@State public var b = 2")).toEqual([]);
});
});
describe("classifyCrashPath", () => {
it("classifies each crash-path shape", () => {
expect([
classifyCrashPath("/x/a.ips"),
classifyCrashPath("/x/a.crash"),
classifyCrashPath("/x/Foo.xccrashpoint"),
classifyCrashPath("/x/Foo.xccrashpoint/Logs/a.crash"),
classifyCrashPath("/x/Foo.xccrashpoint/Info.plist"),
classifyCrashPath("/x/main.swift"),
]).toEqual([
"ips", "crash_text", "xccrashpoint_bundle_root",
"xccrashpoint_inner_crash", "xccrashpoint_inner_other", "",
]);
});
});
describe("crashFileHint", () => {
it("names xcsym and echoes the path for a crash file", () => {
const hint = crashFileHint("/tmp/foo.ips");
expect(hint).toContain("xcsym crash");
expect(hint).toContain("/tmp/foo.ips");
});
it("is null for a non-crash path", () => {
expect(crashFileHint("/tmp/main.swift")).toBeNull();
});
});
describe("bashOutputHints", () => {
it("returns the matching skill hints, in rule order", () => {
expect(bashOutputHints("error: Sendable closure; linker command failed")).toEqual([
"💡 Concurrency issue — load the axiom-concurrency skill.",
"💡 Build configuration issue — try /axiom-fix-build.",
]);
});
it("is empty for empty output", () => {
expect(bashOutputHints("")).toEqual([]);
});
});
describe("inputPath", () => {
it("reads either path or filePath, else undefined", () => {
expect(inputPath({ path: "/a" })).toBe("/a");
expect(inputPath({ filePath: "/b" })).toBe("/b");
expect(inputPath({})).toBeUndefined();
expect(inputPath(null)).toBeUndefined();
});
});
describe("swiftGuardrailWarning", () => {
it("warns about unscoped @State with line numbers", () => {
const w = swiftGuardrailWarning("struct V {\n @State var x = 0\n}");
expect(w).toContain("@State private var");
expect(w).toContain("L2");
});
it("is null when every @State is scoped", () => {
expect(swiftGuardrailWarning("@State private var x = 0")).toBeNull();
});
});
describe("toolResultHint", () => {
const readSwift = () => "struct V {\n @State var x = 0\n}";
it("returns the Swift guardrail warning for a .swift write", () => {
const hint = toolResultHint({ toolName: "write", input: { path: "/a/V.swift" }, content: [] }, readSwift);
expect(hint).toContain("@State private var");
});
it("ignores non-Swift writes", () => {
expect(toolResultHint({ toolName: "edit", input: { path: "/a/x.txt" }, content: [] }, readSwift)).toBeNull();
});
it("returns bash skill hints from output text", () => {
const hint = toolResultHint(
{ toolName: "bash", input: {}, content: [{ type: "text", text: "data race here" }] },
readSwift,
);
expect(hint).toBe("💡 Concurrency issue — load the axiom-concurrency skill.");
});
it("returns null when nothing matches", () => {
expect(
toolResultHint({ toolName: "bash", input: {}, content: [{ type: "text", text: "all good" }] }, readSwift),
).toBeNull();
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* Tool-hook logic ported from Axiom's Claude Code hooks. Pure functions so
* they're unit-testable; the Pi wiring in index.ts feeds them tool I/O.
*
* - unscopedStateVars ← swift-guardrails.sh (@State without access control)
* - crashFileHint ← pretool-crash-route.py (route crash Reads to xcsym)
* - bashOutputHints ← posttool-bash-hints.py (suggest a skill from output)
*/
/**
* `@State var` declarations missing an explicit access level. Without one,
* child views can create independent copies of the state — a silent-bug class.
* Honors a `// axiom-ignore` trailing comment, matching the Claude hook.
*/
export function unscopedStateVars(swift: string): { line: number; text: string }[] {
const SCOPED = /@State\s+(private|internal|fileprivate|public|package)\s+var\b/;
const hits: { line: number; text: string }[] = [];
const lines = swift.split("\n");
for (let i = 0; i < lines.length; i++) {
const text = lines[i];
if (!/@State\s+var\b/.test(text)) continue;
if (SCOPED.test(text)) continue;
if (/\/\/\s*axiom-ignore/.test(text)) continue;
hits.push({ line: i + 1, text: text.trim() });
}
return hits;
}
type CrashKind =
| "ips"
| "crash_text"
| "xccrashpoint_bundle_root"
| "xccrashpoint_inner_crash"
| "xccrashpoint_inner_other"
| "";
/** Categorize a path so the caller can pick the right crash hint. */
export function classifyCrashPath(p: string): CrashKind {
if (!p) return "";
if (p.endsWith(".xccrashpoint") || p.endsWith(".xccrashpoint/")) return "xccrashpoint_bundle_root";
if (p.includes(".xccrashpoint/")) {
return p.endsWith(".crash") ? "xccrashpoint_inner_crash" : "xccrashpoint_inner_other";
}
if (p.endsWith(".ips")) return "ips";
if (p.endsWith(".crash")) return "crash_text";
return "";
}
const CRASH_HINTS: Record<Exclude<CrashKind, "">, (p: string) => string> = {
ips: (p) =>
`This is an .ips crash report. Before reading it as text, run ` +
`\`xcsym crash --format=summary "${p}"\` — it symbolicates against local ` +
`dSYMs and tags the crash pattern. Analyze the JSON output, not the raw .ips. ` +
`See the axiom-tools skill (xcsym-ref).`,
crash_text: (p) =>
`This is an Apple legacy .crash text file. Run ` +
`\`xcsym crash --format=summary "${p}"\` first — xcsym parses the legacy ` +
`format, symbolicates via dSYM discovery, and surfaces pattern_tag + crashed frames.`,
xccrashpoint_inner_crash: (p) =>
`This is a .crash inside an .xccrashpoint bundle. Pass it directly to ` +
`\`xcsym crash --format=summary "${p}"\`. Prefer a \`LocallySymbolicated/\` ` +
`sibling with the same timestamp if present.`,
xccrashpoint_bundle_root: (p) =>
`This is an .xccrashpoint bundle (a directory). xcsym needs a .crash file ` +
`inside it — they live at \`${p}/Filters/*/Logs/*.crash\`. List the directory, ` +
`then pass the right one to \`xcsym crash\`.`,
xccrashpoint_inner_other: () =>
`This file is inside an .xccrashpoint bundle but isn't the crash payload. ` +
`For crash analysis, route \`Filters/*/Logs/*.crash\` under the bundle root to ` +
`\`xcsym crash --format=summary\`.`,
};
/** Advisory hint when a crash file is read, or null when the path isn't a crash. */
export function crashFileHint(p: string): string | null {
const kind = classifyCrashPath(p);
return kind ? CRASH_HINTS[kind](p) : null;
}
// Output-signature rules from posttool-bash-hints.py. The Python hook's
// duration-aware rules (slow-build / slow-test) are intentionally omitted: Pi's
// tool_result event carries no command duration to gate them on.
const BASH_PATTERN_RULES: { pattern: RegExp; hint: string }[] = [
{ pattern: /Unable to simultaneously satisfy constraints/, hint: "💡 Auto Layout conflict — load the axiom-uikit skill." },
{ pattern: /Actor-isolated|Sendable|data race|@MainActor/, hint: "💡 Concurrency issue — load the axiom-concurrency skill." },
{ pattern: /no such column|FOREIGN KEY constraint|migration/, hint: "💡 Database migration issue — load the axiom-data skill." },
{ pattern: /retain cycle|memory leak|deinit.*never called/, hint: "💡 Memory issue — load the axiom-performance skill." },
{ pattern: /CKError|CKRecord.*error/, hint: "💡 CloudKit issue — load the axiom-data skill." },
{ pattern: /ubiquitous.*error|iCloud Drive|NSFileCoordinator/, hint: "💡 iCloud Drive issue — load the axiom-data skill." },
{ pattern: /file.*disappeared|file not found|storage.*full/, hint: "💡 File storage issue — load the axiom-data skill." },
{ pattern: /FileProtection|data protection|file.*locked/, hint: "💡 File protection issue — load the axiom-data skill." },
{ pattern: /error:.*module.*not found|linker command failed/, hint: "💡 Build configuration issue — try /axiom-fix-build." },
];
/** Every skill hint whose pattern matches the Bash output, in rule order. */
export function bashOutputHints(output: string): string[] {
if (!output) return [];
return BASH_PATTERN_RULES.filter((r) => r.pattern.test(output)).map((r) => r.hint);
}
/** A file path off a tool event's input, tolerating either `path` or `filePath`. */
export function inputPath(input: unknown): string | undefined {
if (typeof input !== "object" || input === null) return undefined;
const rec = input as Record<string, unknown>;
const p = rec.path ?? rec.filePath;
return typeof p === "string" ? p : undefined;
}
/** Formatted `@State` access-control warning for Swift source, or null. */
export function swiftGuardrailWarning(swiftSource: string): string | null {
const hits = unscopedStateVars(swiftSource).slice(0, 3);
if (hits.length === 0) return null;
const detail = hits.map((h) => ` L${h.line}: ${h.text}`).join("\n");
return (
"⚠️ Axiom guardrail: `@State` without an explicit access level " +
"(use `@State private var` — child views can otherwise create independent " +
`state copies):\n${detail}`
);
}
/** Minimal structural shape of a Pi `tool_result` event (keeps this module Pi-free). */
export type ToolResultLike = {
toolName: string;
input: unknown;
content: ReadonlyArray<{ type: string; text?: string }>;
};
/**
* Advisory text to append to a `write`/`edit`/`bash` tool result, or null.
* `readFile` is injected so this is unit-testable without touching disk.
* Crash-file routing is handled pre-read on `tool_call`, not here.
*/
export function toolResultHint(event: ToolResultLike, readFile: (p: string) => string): string | null {
if (event.toolName === "write" || event.toolName === "edit") {
const p = inputPath(event.input);
if (!p || !p.endsWith(".swift")) return null;
try {
return swiftGuardrailWarning(readFile(p));
} catch {
return null; // file unreadable (e.g. deleted) — nothing to check
}
}
if (event.toolName === "bash") {
const text = event.content.filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n");
const hints = bashOutputHints(text);
return hints.length ? hints.join("\n") : null;
}
return null;
}
+107
View File
@@ -0,0 +1,107 @@
/**
* axiom-pi — Axiom's commands and hooks for the Pi coding agent.
*
* Skills are installed separately (`npx skills add CharlesWiltgen/Axiom -a pi`).
* This extension adds the two layers Pi can't get from skills alone:
* - the `/axiom-*` commands (trigger the matching skill inline), and
* - the SessionStart / tool hooks (version ground truth, Swift guardrail,
* swiftformat, crash-file routing, Bash skill hints).
*/
import type {
ExtensionAPI,
BeforeAgentStartEvent,
ToolCallEvent,
ToolResultEvent,
} from "@earendil-works/pi-coding-agent";
import * as fs from "node:fs";
import { AXIOM_COMMANDS } from "./commands.ts";
import {
AXIOM_TOOLS,
buildAxiomContext,
findOnPath,
resolveContextDecision,
type ResolvedTool,
} from "./session.ts";
import { crashFileHint, inputPath, toolResultHint } from "./guardrails.ts";
export default function axiomPi(pi: ExtensionAPI): void {
// --- Commands: /axiom-<name> → trigger the matching skill ----------------
for (const cmd of AXIOM_COMMANDS) {
pi.registerCommand(cmd.name, {
description: cmd.description,
getArgumentCompletions: (prefix) => {
if (!cmd.completions) return null;
const matches = cmd.completions.filter((c) => c.startsWith(prefix));
return matches.length ? matches.map((value) => ({ value, label: value })) : null;
},
handler: async (args) => {
try {
pi.sendUserMessage(cmd.prompt(args)); // synchronous; queues the prompt
} catch (err) {
console.error(`[axiom-pi] /${cmd.name} failed to dispatch:`, err);
}
},
});
}
// --- Session hook: version ground truth + tool availability --------------
// Computed once from the session's initial cwd (the gate + PATH probe are
// stable for a session) and chained onto the system prompt each turn. Pi
// passes the freshly-rebuilt BASE prompt to this event every turn
// (agent-session resets to base when no extension modifies it), so the
// append is idempotent — it never accumulates. `undefined` = not yet computed.
let cachedContext: string | null | undefined;
function axiomContextForSession(cwd: string): string | null {
if (cachedContext !== undefined) return cachedContext;
if (!resolveContextDecision(cwd, process.env.AXIOM_SESSION_CONTEXT)) {
cachedContext = null;
return null;
}
const availableTools: ResolvedTool[] = [];
for (const t of AXIOM_TOOLS) {
const resolvedPath = findOnPath(t.name);
if (resolvedPath) availableTools.push({ ...t, resolvedPath });
}
cachedContext = buildAxiomContext({ now: new Date(), availableTools });
return cachedContext;
}
pi.on("before_agent_start", (event: BeforeAgentStartEvent, ctx) => {
const context = axiomContextForSession(ctx.cwd);
if (!context) return;
return { systemPrompt: `${event.systemPrompt}\n\n${context}` };
});
// --- Pre-read hook: route crash-file Reads to xcsym ----------------------
// Fires before the Read executes (advisory, never blocks) so the agent is
// told to symbolicate with xcsym before relying on the raw, unsymbolicated file.
pi.on("tool_call", (event: ToolCallEvent) => {
if (event.toolName !== "read") return;
const p = inputPath(event.input);
const hint = p ? crashFileHint(p) : null;
if (hint) pi.sendMessage({ customType: "axiom-crash-hint", content: hint, display: true });
});
// --- Post-tool hooks: Swift guardrail + swiftformat, Bash skill hints -----
pi.on("tool_result", async (event: ToolResultEvent) => {
// Compute the advisory first (reads the pre-format file, so the @State
// warning's line numbers match what the model wrote), then format.
const hint = toolResultHint(event, (p) => fs.readFileSync(p, "utf8"));
if (event.toolName === "write" || event.toolName === "edit") {
const p = inputPath(event.input);
if (p && p.endsWith(".swift") && findOnPath("swiftformat")) {
try {
await pi.exec("swiftformat", [p, "--quiet"]);
} catch {
// Formatting is best-effort — never fail the write.
}
}
}
if (hint) return { content: [...event.content, { type: "text", text: hint }] };
});
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
formatDate,
iosGroundTruth,
findOnPath,
toolContextBlock,
buildAxiomContext,
isAppleProject,
resolveContextDecision,
} from "./session.ts";
describe("formatDate", () => {
it("stamps weekday and ISO date in local time", () => {
expect(formatDate(new Date(2026, 5, 14))).toBe("Sunday, 2026-06-14");
});
});
describe("iosGroundTruth", () => {
it("states the iOS 26 ground truth and no-denial rule, stamped with the date", () => {
const text = iosGroundTruth(new Date(2026, 5, 14));
expect(text).toContain("iOS 26 is the current major line");
expect(text).toContain('NEVER claim an iOS/Xcode version "doesn\'t exist"');
expect(text).toContain("Sunday, 2026-06-14");
});
});
describe("findOnPath", () => {
const env: NodeJS.ProcessEnv = { PATH: ["/a", "/b"].join(path.delimiter) };
it("returns the first PATH dir that contains the binary", () => {
const found = findOnPath("tool", env, (p) => p === path.join("/b", "tool"));
expect(found).toBe(path.join("/b", "tool"));
});
it("returns null when the binary is on no PATH dir", () => {
expect(findOnPath("tool", env, () => false)).toBeNull();
});
});
describe("toolContextBlock", () => {
it("is empty when no tools are available", () => {
expect(toolContextBlock([])).toBe("");
});
it("lists available tools with their resolved paths", () => {
const block = toolContextBlock([{ name: "xcsym", blurb: "crash", resolvedPath: "/usr/local/bin/xcsym" }]);
expect(block).toContain("xcsym");
expect(block).toContain("/usr/local/bin/xcsym");
});
});
describe("buildAxiomContext", () => {
it("wraps ground truth and available tools in the importance marker", () => {
const ctx = buildAxiomContext({
now: new Date(2026, 5, 14),
availableTools: [{ name: "xclog", blurb: "console", resolvedPath: "/bin/xclog" }],
});
expect(ctx).toContain("<EXTREMELY_IMPORTANT>");
expect(ctx).toContain("iOS 26 is the current major line");
expect(ctx).toContain("/bin/xclog");
});
});
describe("isAppleProject / resolveContextDecision", () => {
it("detects a directory containing an Xcode project", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "axiom-apple-"));
try {
fs.mkdirSync(path.join(dir, "App.xcodeproj"));
expect(isAppleProject(dir)).toBe(true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("returns false for a marker-free git repo", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "axiom-plain-"));
try {
fs.writeFileSync(path.join(dir, ".git"), ""); // stops the upward walk at this dir
fs.writeFileSync(path.join(dir, "notes.txt"), "hi");
expect(isAppleProject(dir)).toBe(false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("honors the AXIOM_SESSION_CONTEXT override without scanning", () => {
expect(resolveContextDecision("/nonexistent", "never")).toBe(false);
expect(resolveContextDecision("/nonexistent", "always")).toBe(true);
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* Session-context injection, ported from Axiom's SessionStart hook
* (session-start.py / project_detect.py).
*
* Pi already loads the `axiom-*` skills (their descriptions sit in context),
* so this does NOT re-inject skill content. It injects only what skills can't
* supply: the iOS-version behavioral ground truth, and which bundled Axiom
* command-line tools are on PATH. The Apple-project gate keeps it quiet in
* non-Apple repos (fail-open — doubt injects).
*/
import * as fs from "node:fs";
import * as path from "node:path";
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
/** "Sunday, 2026-06-14" in local time, matching the Claude hook's stamp. */
export function formatDate(now: Date): string {
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${WEEKDAYS[now.getDay()]}, ${y}-${m}-${d}`;
}
/** iOS/Xcode version ground truth + behavioral rules (additive, never asserts a cutoff). */
export function iosGroundTruth(now: Date): string {
return `## iOS / Xcode VERSION GROUND TRUTH (Current date: ${formatDate(now)})
iOS 26 is the current major line — Apple went straight from iOS 18 to iOS 26 at
WWDC 2025; the in-between majors (19-25) were never released. Xcode 26 ships with
it. A newer major may have shipped since your training cutoff — don't assume 26 is
still the latest.
BEHAVIORAL RULES:
1. NEVER claim an iOS/Xcode version "doesn't exist" or is "wrong" because it
postdates your training — that includes iOS 26 and anything above it.
2. NEVER state which iOS/Xcode version is "current" or "latest" from training
alone — defer to Axiom skills, or check https://support.apple.com/en-us/123075.
3. For iOS-version or new-API questions, load the relevant Axiom skill first
(axiom-apple-docs, axiom-swiftui) — they carry WWDC 2025+ documentation.
4. Before giving OS-version-specific advice, establish the user's DEPLOYMENT TARGET.
Advice for a newer OS than the target can name APIs the user cannot ship. For any
API marked new in a newer cycle, give the \`@available\`/\`#available\` gate and the
pre-cycle fallback — not just the new path.
This is a behavioral instruction grounded in Apple's release history, not a claim
about your training data.`;
}
export type AxiomTool = { name: string; blurb: string };
/** The four command-line tools Axiom ships. Detected on PATH at runtime. */
export const AXIOM_TOOLS: readonly AxiomTool[] = [
{ name: "xclog", blurb: "simulator console capture — `xclog list`, `xclog launch <bundle-id> --timeout 30s`" },
{ name: "xcsym", blurb: "crash symbolication — `xcsym crash <file>`, `xcsym verify <file>`" },
{ name: "xcui", blurb: "scriptable sim UI & accessibility — `xcui doctor`, `xcui assert`, `xcui voiceover`" },
{ name: "xcprof", blurb: "structured xctrace capture/analysis — `xcprof record`, `xcprof analyze`, `xcprof compare`" },
];
/** True if `p` is an executable regular file (not a directory or non-exec file). */
function isExecutableFile(p: string): boolean {
try {
if (!fs.statSync(p).isFile()) return false;
fs.accessSync(p, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
/** First directory on PATH holding an executable `name`, or null. Predicate injectable for tests. */
export function findOnPath(
name: string,
env: NodeJS.ProcessEnv = process.env,
isExecutable: (p: string) => boolean = isExecutableFile,
): string | null {
for (const dir of (env.PATH || "").split(path.delimiter)) {
if (dir && isExecutable(path.join(dir, name))) return path.join(dir, name);
}
return null;
}
export type ResolvedTool = { name: string; blurb: string; resolvedPath: string };
/** Markdown block listing the Axiom tools found on PATH, or "" when none. */
export function toolContextBlock(available: readonly ResolvedTool[]): string {
if (available.length === 0) return "";
const lines = available.map((t) => `- **${t.name}** (\`${t.resolvedPath}\`): ${t.blurb}`).join("\n");
return `\n\n---\n\n**Axiom command-line tools on your PATH** — call them via \`bash\`:\n${lines}`;
}
/** The full `<EXTREMELY_IMPORTANT>` context block injected before each turn. */
export function buildAxiomContext(opts: { now: Date; availableTools: readonly ResolvedTool[] }): string {
return `<EXTREMELY_IMPORTANT>
You have Axiom iOS/Apple-platform development skills installed (the \`axiom-*\`
skills). For ANY iOS, Swift, or Xcode question, load the relevant skill before
answering.
${iosGroundTruth(opts.now)}${toolContextBlock(opts.availableTools)}
</EXTREMELY_IMPORTANT>`;
}
// --- Apple-project gate (port of project_detect.py) ------------------------
// Cardinal sin is a false negative (a real Apple project read as non-Apple →
// Axiom silently off), so every path fails OPEN (inject) on doubt or error.
const APPLE_MARKER_SUFFIXES = [".xcodeproj", ".xcworkspace", ".swiftpm", ".playground", ".swift"];
const APPLE_MARKER_NAMES = new Set(["Podfile"]);
const PRUNE_DIRS = new Set([
"node_modules", ".git", "build", ".build", "Pods", "DerivedData", "dist",
"target", ".venv", "venv", "vendor", "Carthage", ".gradle", "__pycache__", "out",
"Intermediate", "Binaries", "Saved", "DerivedDataCache", // Unreal
"Library", "Temp", "Obj", // Unity
]);
const UPWARD_MAX_LEVELS = 6;
const DOWNWARD_MAX_DEPTH = 4;
const MAX_ENTRIES = 10_000;
function isMarker(name: string): boolean {
return APPLE_MARKER_NAMES.has(name) || APPLE_MARKER_SUFFIXES.some((s) => name.endsWith(s));
}
function dirHasMarker(dir: string): boolean {
try {
return fs.readdirSync(dir).some(isMarker);
} catch {
return false;
}
}
/** Bounded, pruned DFS for an Apple marker. Entry-cap hit → fail-open (true). */
function downwardHasMarker(root: string): boolean {
let seen = 0;
const stack: Array<[string, number]> = [[root, 0]];
while (stack.length) {
const [dir, depth] = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
if (++seen > MAX_ENTRIES) return true;
if (isMarker(e.name)) return true;
if (depth < DOWNWARD_MAX_DEPTH && !PRUNE_DIRS.has(e.name)) {
let isDir = false;
try {
isDir = e.isDirectory();
} catch {
isDir = false;
}
if (isDir) stack.push([path.join(dir, e.name), depth + 1]);
}
}
}
return false;
}
/** True if `start` is inside, or contains, an Apple project. Errors → fail-open. */
export function isAppleProject(start: string): boolean {
try {
let cur = path.resolve(start);
if (!fs.existsSync(cur) || !fs.statSync(cur).isDirectory()) return true;
const home = process.env.HOME ? path.resolve(process.env.HOME) : null;
let scanRoot = cur;
let levels = 0;
for (;;) {
if (levels <= UPWARD_MAX_LEVELS && dirHasMarker(cur)) return true;
if (fs.existsSync(path.join(cur, ".git"))) {
scanRoot = cur;
break;
}
const parent = path.dirname(cur);
if (parent === cur) break;
if (home !== null && cur === home) break;
levels++;
cur = parent;
}
return downwardHasMarker(scanRoot);
} catch {
return true;
}
}
/**
* Whether to inject Axiom context. `AXIOM_SESSION_CONTEXT`: "never" → skip,
* "always" → inject without scanning, anything else → auto-detect.
*/
export function resolveContextDecision(cwd: string, override: string | undefined): boolean {
const o = (override || "").trim().toLowerCase();
if (o === "never") return false;
if (o === "always") return true;
return isAppleProject(cwd);
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023"],
"types": ["node"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+4
View File
@@ -3,6 +3,10 @@
"version": "27.0.0-beta.3",
"description": "Battle-tested Claude Code skills, agents, and tools for modern xOS (iOS, iPadOS, tvOS, watchOS) development — Swift 6, SwiftUI, Liquid Glass, Apple Intelligence, and more",
"type": "module",
"pi": {
"skills": [".claude-plugin/plugins/axiom/skills"],
"extensions": ["axiom-pi/src/index.ts"]
},
"scripts": {
"build": "npm run build:codex && npm run build:mcp && npm run build:docs",
"build:codex": "node scripts/build-codex.ts",