mirror of
https://github.com/CharlesWiltgen/Axiom.git
synced 2026-09-20 19:58:20 +08:00
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:
@@ -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.
|
||||
Generated
+3266
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }] };
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user