From f65518881466cfd32d8d547f07c3b13180a3ba61 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Wed, 6 May 2026 11:37:27 +0200 Subject: [PATCH] sf snapshot: uncommitted changes after 93m inactivity --- biome.json | 2 +- docs/QUALITY_SCORE.md | 3 + docs/dev/SETUP.md | 74 ++++++++++++++++ .../24-file-reference-example-extensions.md | 18 ++-- .../dev/extending-pi/26-extension-template.md | 88 +++++++++++++++++++ docs/dev/extending-pi/README.md | 2 +- src/app-paths.ts | 4 +- src/cli-logs.ts | 4 +- src/env.ts | 65 ++++++++++++++ src/headless-query.ts | 8 +- src/tests/env.test.ts | 41 +++++++++ vitest.config.ts | 12 +++ 12 files changed, 302 insertions(+), 19 deletions(-) create mode 100644 docs/dev/SETUP.md create mode 100644 docs/dev/extending-pi/26-extension-template.md create mode 100644 src/env.ts create mode 100644 src/tests/env.test.ts diff --git a/biome.json b/biome.json index 82a04738e..711bb6704 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/docs/QUALITY_SCORE.md b/docs/QUALITY_SCORE.md index 20b5e8578..ba39ec4de 100644 --- a/docs/QUALITY_SCORE.md +++ b/docs/QUALITY_SCORE.md @@ -28,6 +28,9 @@ Coverage thresholds (enforced by `npm run test:coverage`): - Lines: **40%** minimum - Branches: **20%** minimum - Functions: **20%** minimum +- Autonomous path overrides: + - `src/resources/extensions/sf/auto/**`: **60%** statements/lines/functions, **40%** branches + - `src/resources/extensions/sf/uok/**`: **60%** statements/lines/functions, **40%** branches These are floors, not targets. The real quality bar is purposeful tests that assert behavior contracts (see `docs/SPEC_FIRST_TDD.md`). diff --git a/docs/dev/SETUP.md b/docs/dev/SETUP.md new file mode 100644 index 000000000..9091f9c56 --- /dev/null +++ b/docs/dev/SETUP.md @@ -0,0 +1,74 @@ +# Developer Setup + +This page is the short path for contributors who already have the repository +checked out and want a working local SF development environment. + +## Prerequisites + +- Node.js 24 or newer +- npm +- Git +- Rust toolchain for native engine work +- GitHub CLI for label, issue, and PR workflows +- Docker or a compatible container runtime for devcontainer verification + +## First-Time Setup + +```bash +npm install +npm run secret-scan:install-hook +node scripts/tech-debt-scan.mjs +``` + +Optional but recommended: + +```bash +devcontainer build --workspace-folder . +``` + +## Daily Checks + +Use the narrowest command that proves the change: + +```bash +npm run lint +npm run typecheck:extensions +npm run test:unit +``` + +Before shipping changes that affect the CLI runtime: + +```bash +npm run build:core +npm run test:smoke +``` + +For coverage-sensitive changes: + +```bash +npm run test:coverage +``` + +The global floor is intentionally modest, but autonomous paths have stricter +coverage thresholds in `vitest.config.ts`. + +## Environment Variables + +SF-specific environment variables are parsed through `src/env.ts` for typed +callers. Add new `SF_*` variables there before introducing new production reads. +Document user-facing variables in `docs/user-docs/configuration.md`. + +Common local overrides: + +| Variable | Purpose | +|----------|---------| +| `SF_HOME` | Redirect global SF runtime state away from `~/.sf`. | +| `SF_AGENT_DIR` | Override the managed agent directory used by headless/runtime loaders. | +| `SF_PROJECT_ID` | Override the project state key; must be alphanumeric, hyphen, or underscore. | +| `SF_BIN_PATH` | Point child processes at a specific SF loader. | + +## Contribution Contract + +Every behavioral change starts with a failing behavior test or executable +evidence. Keep commits focused, avoid drive-by formatting, and do not commit +transient `.sf/` runtime state. diff --git a/docs/dev/extending-pi/24-file-reference-example-extensions.md b/docs/dev/extending-pi/24-file-reference-example-extensions.md index 5a793fa1f..8bb46b5e2 100644 --- a/docs/dev/extending-pi/24-file-reference-example-extensions.md +++ b/docs/dev/extending-pi/24-file-reference-example-extensions.md @@ -1,9 +1,15 @@ # File Reference — Example Extensions -All paths relative to: +Upstream example extensions are copied into the managed agent directory during +runtime setup. In source checkouts, use the bundled extension tree under +`src/resources/extensions/` for SF-owned examples and tests. + +Common reference roots: + ``` -/Users/lexchristopherson/.nvm/versions/node/v22.20.0/lib/node_modules/@mariozechner/pi-coding-agent/examples/extensions/ +src/resources/extensions/ +~/.sf/agent/extensions/ ``` ### Lifecycle & Safety @@ -125,8 +131,6 @@ All paths relative to: --- -*This document was generated from the Pi extension documentation and examples. Source docs are at:* -``` -/Users/lexchristopherson/.nvm/versions/node/v22.20.0/lib/node_modules/@mariozechner/pi-coding-agent/docs/ -/Users/lexchristopherson/.nvm/versions/node/v22.20.0/lib/node_modules/@mariozechner/pi-coding-agent/examples/extensions/ -``` +*This document was generated from the Pi extension documentation and examples. +Use repository-relative paths for SF source work and `~/.sf/agent/extensions/` +for installed runtime inspection.* diff --git a/docs/dev/extending-pi/26-extension-template.md b/docs/dev/extending-pi/26-extension-template.md new file mode 100644 index 000000000..667e86df6 --- /dev/null +++ b/docs/dev/extending-pi/26-extension-template.md @@ -0,0 +1,88 @@ +# Extension Development Template + +Use this template for small repo-local extensions before adding packaging, +dependencies, or distribution metadata. + +## Directory Layout + +```text +~/.sf/agent/extensions/my-extension/ +├── index.ts +├── extension-manifest.json +└── README.md +``` + +For source-tree development inside this repository, place bundled extensions +under `src/resources/extensions//` and keep tests next to the +extension's existing test conventions. + +## `extension-manifest.json` + +```json +{ + "id": "my-extension", + "name": "My Extension", + "version": "0.1.0", + "description": "Adds one focused command or tool.", + "tier": "project", + "entry": "index.ts", + "provides": { + "tools": ["my_tool"], + "commands": ["/my-command"], + "hooks": [] + }, + "dependencies": { + "extensions": [], + "runtime": [] + } +} +``` + +## `index.ts` + +```ts +import { Type } from "@sinclair/typebox"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@mariozechner/pi-coding-agent"; + +/** + * Register the extension's command and tool. + * + * Purpose: expose one project-specific operation through a typed tool and a + * human-triggered command. + * + * Consumer: SF runtime extension loader. + */ +export default function activate(pi: ExtensionAPI, ctx: ExtensionContext) { + pi.registerTool({ + name: "my_tool", + description: "Return a concise project-specific status.", + parameters: Type.Object({ + label: Type.String({ description: "Status label to display." }), + }), + async execute({ label }) { + return { ok: true, label }; + }, + }); + + pi.registerCommand("my-command", { + description: "Show the current extension status.", + async run() { + ctx.ui.notify("My extension is loaded.", "info"); + }, + }); +} +``` + +## Review Checklist + +- The extension has one clear purpose and a named production consumer. +- Every tool parameter has a TypeBox schema. +- Persistent state is written under the project `.sf/` tree or the managed + agent directory, not arbitrary home-directory paths. +- Commands and tools degrade with useful messages when dependencies are missing. +- Tests cover the behavior contract, not only mocks or call counts. +- User-facing environment variables are added to `src/env.ts` and + `docs/user-docs/configuration.md`. diff --git a/docs/dev/extending-pi/README.md b/docs/dev/extending-pi/README.md index 2cd82acf0..a68ebf1e2 100644 --- a/docs/dev/extending-pi/README.md +++ b/docs/dev/extending-pi/README.md @@ -29,8 +29,8 @@ - [23. File Reference — Documentation](./23-file-reference-documentation.md) - [24. File Reference — Example Extensions](./24-file-reference-example-extensions.md) - [25. Slash Command Subcommand Patterns](./25-slash-command-subcommand-patterns.md) +- [26. Extension Development Template](./26-extension-template.md) --- *Split into per-section files for surgical context loading.* - diff --git a/src/app-paths.ts b/src/app-paths.ts index 859220cdf..424748723 100644 --- a/src/app-paths.ts +++ b/src/app-paths.ts @@ -1,5 +1,5 @@ -import { homedir } from "node:os"; import { join } from "node:path"; +import { getSfEnv } from "./env.js"; /** * app-paths.ts — central directory and file path constants for the sf runtime. @@ -26,7 +26,7 @@ import { join } from "node:path"; * remote-questions-config.ts (global preferences), extension-registry.ts * (registry JSON), and every other derived path in this module. */ -export const appRoot = process.env.SF_HOME || join(homedir(), ".sf"); +export const appRoot = getSfEnv().sfHome; /** * Returns the path to the managed agent directory. diff --git a/src/cli-logs.ts b/src/cli-logs.ts index a0bf90c37..fd3d57f38 100644 --- a/src/cli-logs.ts +++ b/src/cli-logs.ts @@ -9,8 +9,8 @@ import { unwatchFile, watchFile, } from "node:fs"; -import { homedir } from "node:os"; import { basename, join } from "node:path"; +import { getSfEnv } from "./env.js"; export type LogSourceName = "notif" | "session" | "activity" | "audit"; @@ -62,7 +62,7 @@ function normalizeSource(value: string | undefined): LogSourceName | undefined { } function sfHomeFromEnv(): string { - return process.env.SF_HOME || join(homedir(), ".sf"); + return getSfEnv().sfHome; } export function getProjectSessionKey(basePath: string): string { diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 000000000..d4986f639 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,65 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; + +const optionalNonEmptyString = z.string().trim().min(1).optional(); + +const booleanOneZero = z + .enum(["0", "1"]) + .optional() + .transform((value) => value === "1"); + +export const sfEnvSchema = z.object({ + SF_HOME: optionalNonEmptyString, + SF_AGENT_DIR: optionalNonEmptyString, + SF_CODING_AGENT_DIR: optionalNonEmptyString, + SF_STATE_DIR: optionalNonEmptyString, + SF_PROJECT_ID: z + .string() + .trim() + .regex(/^[A-Za-z0-9_-]+$/, { + message: + "SF_PROJECT_ID must contain only letters, numbers, hyphens, and underscores", + }) + .optional(), + SF_BIN_PATH: optionalNonEmptyString, + SF_VERSION: optionalNonEmptyString, + SF_WEB_PROJECT_CWD: optionalNonEmptyString, + SF_WEB_DAEMON_MODE: booleanOneZero, +}); + +export type SfEnv = z.infer; + +/** + * Parse supported SF_* environment variables into a typed object. + * + * Purpose: give runtime code a shared contract for SF-specific environment + * variables instead of scattering ad hoc `process.env` parsing across entry + * points. + * + * Consumer: root CLI/headless modules and web bridge code that need stable SF + * path and mode values. + */ +export function parseSfEnv(env: NodeJS.ProcessEnv = process.env): SfEnv { + return sfEnvSchema.parse(env); +} + +/** + * Return typed SF environment values with path defaults applied. + * + * Purpose: centralize default path behavior for SF_HOME and the managed agent + * directory while still validating user-provided overrides. + * + * Consumer: app-paths.ts, cli-logs.ts, headless-query.ts, and future env readers. + */ +export function getSfEnv(env: NodeJS.ProcessEnv = process.env) { + const parsed = parseSfEnv(env); + const sfHome = parsed.SF_HOME ?? join(homedir(), ".sf"); + const agentDir = + parsed.SF_AGENT_DIR ?? parsed.SF_CODING_AGENT_DIR ?? join(sfHome, "agent"); + return { + ...parsed, + sfHome, + agentDir, + }; +} diff --git a/src/headless-query.ts b/src/headless-query.ts index 02fcf6b3d..4a09af699 100644 --- a/src/headless-query.ts +++ b/src/headless-query.ts @@ -16,10 +16,10 @@ */ import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { createJiti } from "@mariozechner/jiti"; import { resolveBundledSourceResource } from "./bundled-resource-path.js"; +import { getSfEnv } from "./env.js"; import type { SFState } from "./resources/extensions/sf/types.js"; const jiti = createJiti(import.meta.filename, { @@ -29,11 +29,7 @@ const jiti = createJiti(import.meta.filename, { // Resolve extensions from the synced agent directory so headless-query // loads the same extension copy as interactive/auto modes (#3471). // The synced runtime is compiled .js; source-tree fallback is .ts. -const agentExtensionsDir = join( - process.env.SF_AGENT_DIR || join(homedir(), ".sf", "agent"), - "extensions", - "sf", -); +const agentExtensionsDir = join(getSfEnv().agentDir, "extensions", "sf"); const useAgentDir = existsSync(join(agentExtensionsDir, "state.js")); const sfExtensionPath = (moduleName: string) => { if (useAgentDir) return join(agentExtensionsDir, `${moduleName}.js`); diff --git a/src/tests/env.test.ts b/src/tests/env.test.ts new file mode 100644 index 000000000..70e1a8623 --- /dev/null +++ b/src/tests/env.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { describe, test } from "vitest"; +import { getSfEnv, parseSfEnv } from "../env.js"; + +describe("sf env schema", () => { + test("parseSfEnv_when_project_id_is_valid_returns_typed_values", () => { + const env = parseSfEnv({ + SF_HOME: "/tmp/sf-home", + SF_PROJECT_ID: "repo_123-main", + SF_WEB_DAEMON_MODE: "1", + }); + + assert.equal(env.SF_HOME, "/tmp/sf-home"); + assert.equal(env.SF_PROJECT_ID, "repo_123-main"); + assert.equal(env.SF_WEB_DAEMON_MODE, true); + }); + + test("parseSfEnv_when_project_id_contains_path_separator_rejects", () => { + assert.throws( + () => parseSfEnv({ SF_PROJECT_ID: "../escape" }), + /SF_PROJECT_ID/, + ); + }); + + test("getSfEnv_when_agent_dir_unset_uses_sf_home_agent", () => { + const env = getSfEnv({ SF_HOME: "/tmp/sf-home" }); + + assert.equal(env.sfHome, "/tmp/sf-home"); + assert.equal(env.agentDir, "/tmp/sf-home/agent"); + }); + + test("getSfEnv_when_agent_dir_set_prefers_explicit_agent_dir", () => { + const env = getSfEnv({ + SF_HOME: "/tmp/sf-home", + SF_AGENT_DIR: "/tmp/agent-dir", + SF_CODING_AGENT_DIR: "/tmp/coding-agent-dir", + }); + + assert.equal(env.agentDir, "/tmp/agent-dir"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index f4514b08a..8385c0970 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -225,6 +225,18 @@ export default defineConfig({ lines: 40, branches: 20, functions: 20, + "src/resources/extensions/sf/auto/**": { + statements: 60, + lines: 60, + branches: 40, + functions: 60, + }, + "src/resources/extensions/sf/uok/**": { + statements: 60, + lines: 60, + branches: 40, + functions: 60, + }, }, }, },