sf snapshot: uncommitted changes after 93m inactivity

This commit is contained in:
Mikael Hugo 2026-05-06 11:37:27 +02:00
parent a73ea845e7
commit f655188814
12 changed files with 302 additions and 19 deletions

View file

@ -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",

View file

@ -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`).

74
docs/dev/SETUP.md Normal file
View file

@ -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.

View file

@ -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.*

View file

@ -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/<extension-id>/` 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`.

View file

@ -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.*

View file

@ -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.

View file

@ -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 {

65
src/env.ts Normal file
View file

@ -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<typeof sfEnvSchema>;
/**
* 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,
};
}

View file

@ -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`);

41
src/tests/env.test.ts Normal file
View file

@ -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");
});
});

View file

@ -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,
},
},
},
},