feat: initial commit
This commit is contained in:
16
packages/wow-contexts/package.json
Normal file
16
packages/wow-contexts/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "wow-contexts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"wow-core": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
}
|
||||
}
|
||||
61
packages/wow-contexts/src/builder.ts
Normal file
61
packages/wow-contexts/src/builder.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
type ContextFileEntry,
|
||||
resolveContextPath,
|
||||
scanDir,
|
||||
tryRead,
|
||||
} from "./files";
|
||||
import { resolvePaths } from "./resolver";
|
||||
|
||||
export interface BuildResult {
|
||||
content: string | null;
|
||||
loadedPaths: Map<string, string>;
|
||||
}
|
||||
|
||||
export async function build(cwd: string): Promise<BuildResult> {
|
||||
const paths = resolvePaths(cwd);
|
||||
const files: ContextFileEntry[] = [];
|
||||
const loadedPaths = new Map<string, string>();
|
||||
|
||||
for (const entry of paths) {
|
||||
if (entry.endsWith("/*.md")) {
|
||||
const dir = resolveContextPath(entry.slice(0, -4), cwd);
|
||||
const before = files.length;
|
||||
await scanDir(dir, files);
|
||||
for (let index = before; index < files.length; index++) {
|
||||
const file = files[index];
|
||||
if (file) loadedPaths.set(file.rel, fileHash(file.content));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const abs = resolveContextPath(entry, cwd);
|
||||
const before = files.length;
|
||||
await tryRead(abs, entry, files);
|
||||
if (files.length > before) {
|
||||
const file = files[files.length - 1];
|
||||
if (file) loadedPaths.set(abs, fileHash(file.content));
|
||||
}
|
||||
}
|
||||
|
||||
const nonEmpty = files.filter((file) => file.content.length > 0);
|
||||
|
||||
return {
|
||||
content:
|
||||
nonEmpty.length > 0
|
||||
? nonEmpty
|
||||
.map(
|
||||
(file) => `<file path="${file.rel}">\n${file.content}\n</file>`,
|
||||
)
|
||||
.join("\n\n")
|
||||
: null,
|
||||
loadedPaths,
|
||||
};
|
||||
}
|
||||
|
||||
export function fileHash(content: string): string {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < content.length; index++) {
|
||||
hash = ((hash << 5) - hash + content.charCodeAt(index)) | 0;
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
187
packages/wow-contexts/src/commands.ts
Normal file
187
packages/wow-contexts/src/commands.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { registerWowCommand, show, showInfo, showWarn } from "wow-core";
|
||||
import { fileHash } from "./builder";
|
||||
import { resolveContextPath } from "./files";
|
||||
import { buildInitPrompt } from "./init-prompt";
|
||||
import { resolvePaths } from "./resolver";
|
||||
import { contextState } from "./state";
|
||||
|
||||
interface FileEntry {
|
||||
abs: string;
|
||||
display: string;
|
||||
}
|
||||
|
||||
export function registerCommands(pi: ExtensionAPI): void {
|
||||
registerWowCommand(pi, "contexts:list", {
|
||||
description: "List all wow context files with load status",
|
||||
handler: handleList,
|
||||
});
|
||||
registerWowCommand(pi, "contexts:reload", {
|
||||
description: "Reload wow context files on the next message",
|
||||
handler: handleReload,
|
||||
});
|
||||
registerWowCommand(pi, "init", {
|
||||
description:
|
||||
"Generate AGENTS.md and docs/contexts with a pi-optimized prompt",
|
||||
handler: (args, ctx) => handleInit(args, ctx, pi),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleList(
|
||||
_args: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
): Promise<void> {
|
||||
if (!contextState.cwd) {
|
||||
showWarn(ctx, "No context files scanned yet. Send a message first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const found = await findCandidateFiles(contextState.cwd);
|
||||
if (found.length === 0) {
|
||||
showInfo(ctx, "No context files found in configured paths.");
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded: FileEntry[] = [];
|
||||
const modified: FileEntry[] = [];
|
||||
const unloaded: FileEntry[] = [];
|
||||
|
||||
for (const file of found) {
|
||||
const storedHash = contextState.wowPaths.get(file.abs);
|
||||
if (storedHash === undefined) {
|
||||
unloaded.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentHash = await computeHash(file.abs);
|
||||
if (currentHash !== null && currentHash === storedHash) {
|
||||
loaded.push(file);
|
||||
} else {
|
||||
modified.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = modified.length > 0 || unloaded.length > 0;
|
||||
|
||||
show(
|
||||
ctx,
|
||||
"info",
|
||||
"Context Files",
|
||||
{
|
||||
type: "list",
|
||||
sections: [
|
||||
{
|
||||
summary: `● ${loaded.length} loaded, ○ ${modified.length} modified, ○ ${unloaded.length} unloaded`,
|
||||
items: [
|
||||
...loaded.map((file) => `● ${file.display}`),
|
||||
...modified.map((file) => `○ ${file.display} (modified)`),
|
||||
...unloaded.map((file) => `○ ${file.display} (new)`),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
...(hasChanges
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
content: "Use /wow:contexts:reload to pick up changes",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleReload(
|
||||
_args: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
): Promise<void> {
|
||||
if (!contextState.cwd) {
|
||||
showWarn(ctx, "No context files scanned yet. Send a message first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const hadFiles = contextState.wowPaths.size > 0;
|
||||
contextState.rebuild = true;
|
||||
|
||||
showInfo(
|
||||
ctx,
|
||||
`Context reload queued${hadFiles ? "" : " — no files were loaded previously"}`,
|
||||
["New context files will be picked up on your next message."],
|
||||
);
|
||||
}
|
||||
|
||||
async function handleInit(
|
||||
args: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
pi: ExtensionAPI,
|
||||
): Promise<void> {
|
||||
const prompt = buildInitPrompt(args);
|
||||
if (ctx.isIdle()) {
|
||||
pi.sendUserMessage(prompt);
|
||||
} else {
|
||||
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
||||
}
|
||||
showInfo(ctx, "Repository init prompt sent", [
|
||||
"The agent will generate AGENTS.md and docs/contexts using pi-native guidance.",
|
||||
"If pi-subagents is available, it is instructed to use read-only scout/context-builder fanout before synthesis.",
|
||||
]);
|
||||
}
|
||||
|
||||
async function findCandidateFiles(cwd: string): Promise<FileEntry[]> {
|
||||
const found: FileEntry[] = [];
|
||||
|
||||
for (const entry of resolvePaths(cwd)) {
|
||||
if (entry.endsWith("/*.md")) {
|
||||
const dir = resolveContextPath(entry.slice(0, -4), cwd);
|
||||
const names = await readdirSafe(dir);
|
||||
for (const name of names.sort()) {
|
||||
if (!name.endsWith(".md")) continue;
|
||||
const abs = path.resolve(dir, name);
|
||||
found.push({ abs, display: path.relative(cwd, abs) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const abs = resolveContextPath(entry, cwd);
|
||||
if (await existsSafe(abs)) found.push({ abs, display: entry });
|
||||
}
|
||||
|
||||
const deduped: FileEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const file of found) {
|
||||
if (seen.has(file.abs)) continue;
|
||||
seen.add(file.abs);
|
||||
deduped.push(file);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
async function readdirSafe(dir: string): Promise<string[]> {
|
||||
try {
|
||||
return await fs.readdir(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function existsSafe(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.stat(filePath)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function computeHash(absPath: string): Promise<string | null> {
|
||||
try {
|
||||
const content = await fs.readFile(absPath, "utf-8");
|
||||
return fileHash(content.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
44
packages/wow-contexts/src/files.ts
Normal file
44
packages/wow-contexts/src/files.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { resolvePath } from "wow-core";
|
||||
|
||||
export interface ContextFileEntry {
|
||||
rel: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function resolveContextPath(input: string, cwd: string): string {
|
||||
return resolvePath(input, cwd);
|
||||
}
|
||||
|
||||
export async function scanDir(
|
||||
dir: string,
|
||||
out: ContextFileEntry[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
||||
const filePath = path.join(dir, entry.name);
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
out.push({ rel: filePath, content: content.trim() });
|
||||
}
|
||||
} catch {
|
||||
// Missing/unreadable context directories are ignored.
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryRead(
|
||||
absPath: string,
|
||||
relHint: string,
|
||||
out: ContextFileEntry[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.stat(absPath);
|
||||
if (!stat.isFile()) return;
|
||||
const content = await fs.readFile(absPath, "utf-8");
|
||||
out.push({ rel: relHint, content: content.trim() });
|
||||
} catch {
|
||||
// Missing/unreadable context files are ignored.
|
||||
}
|
||||
}
|
||||
4
packages/wow-contexts/src/global.ts
Normal file
4
packages/wow-contexts/src/global.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createLogger } from "wow-core";
|
||||
|
||||
export const TAG = "contexts";
|
||||
export const log = createLogger(TAG);
|
||||
60
packages/wow-contexts/src/index.ts
Normal file
60
packages/wow-contexts/src/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { BeforeAgentStartEventResult } from "@earendil-works/pi-coding-agent";
|
||||
import type { ModuleSetup } from "wow-core";
|
||||
import { registerModule, resetWowConfigCache } from "wow-core";
|
||||
import { build } from "./builder";
|
||||
import { registerCommands } from "./commands";
|
||||
import { log, TAG } from "./global";
|
||||
import { contextState } from "./state";
|
||||
|
||||
export type { ContextFileEntry } from "./files";
|
||||
|
||||
const setup: ModuleSetup = (pi) => {
|
||||
let builtContent: string | null = null;
|
||||
|
||||
registerCommands(pi);
|
||||
|
||||
contextState.cwd = process.cwd();
|
||||
void rebuild(contextState.cwd).then((content) => {
|
||||
builtContent = content;
|
||||
});
|
||||
|
||||
pi.on(
|
||||
"before_agent_start",
|
||||
async (event, ctx): Promise<BeforeAgentStartEventResult | undefined> => {
|
||||
const forceRebuild = contextState.rebuild;
|
||||
const cwdChanged = ctx.cwd !== contextState.cwd;
|
||||
|
||||
if (forceRebuild || cwdChanged || builtContent === null) {
|
||||
resetWowConfigCache();
|
||||
contextState.rebuild = false;
|
||||
contextState.cwd = ctx.cwd;
|
||||
builtContent = await rebuild(ctx.cwd);
|
||||
}
|
||||
|
||||
if (!builtContent) return undefined;
|
||||
|
||||
return {
|
||||
systemPrompt: `${event.systemPrompt}\n\n${builtContent}`,
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
registerModule({ name: TAG, register: setup });
|
||||
|
||||
async function rebuild(cwd: string): Promise<string> {
|
||||
try {
|
||||
const result = await build(cwd);
|
||||
contextState.wowPaths = result.loadedPaths;
|
||||
if (result.content) {
|
||||
log.debug("context files rebuilt", {
|
||||
paths: [...contextState.wowPaths.keys()],
|
||||
});
|
||||
return result.content;
|
||||
}
|
||||
} catch (error) {
|
||||
contextState.wowPaths = new Map();
|
||||
log.error("context build failed", { error: String(error) });
|
||||
}
|
||||
return "";
|
||||
}
|
||||
99
packages/wow-contexts/src/init-prompt.ts
Normal file
99
packages/wow-contexts/src/init-prompt.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
const INIT_PROMPT_LINES = [
|
||||
"# Generate AGENTS.md by launching multiple explore agents in parallel when available, first identifying the project ecosystem, then scanning evidence-based areas and synthesizing findings into a single file.",
|
||||
"",
|
||||
"This prompt must work for repositories written in any programming language or technology stack, including application code, libraries, services, mobile apps, infrastructure/IaC, docs-only repos, monorepos, polyglot workspaces, and mixed tooling.",
|
||||
"",
|
||||
"Start with an ecosystem discovery pass before assigning deeper scans. Identify languages, runtimes, package/build systems, frameworks, test tools, entry points, deployment shape, and whether the repo is a monorepo or single project. Use concrete files as evidence, not assumptions.",
|
||||
"",
|
||||
"If pi-subagents is installed and available, use it for parallel read-only exploration after the ecosystem pass. Prefer `scout` or `context-builder` agents with distinct evidence-based scopes. If pi-subagents is not available, perform the same exploration sequentially with normal read/search tools and continue without asking for permission just because parallelism is unavailable.",
|
||||
"",
|
||||
"Keep the parent agent responsible for final synthesis and all repository writes. Parallel agents should gather context and produce findings only.",
|
||||
"",
|
||||
"<structure>",
|
||||
"- **Project Overview**: Brief description of project purpose",
|
||||
"- **Architecture & Data Flow**: High-level structure, key modules, data flow",
|
||||
"- **Key Directories**: Main source directories, purposes",
|
||||
"- **Development Commands**: Build, test, lint, run commands",
|
||||
"- **Code Conventions & Common Patterns**: Formatting, naming, error handling, async patterns, dependency injection, state management",
|
||||
"- **Important Files**: Entry points, config files, key modules",
|
||||
"- **Runtime/Tooling Preferences**: Required runtime(s), package/build tools, environment setup, tooling constraints, pi-specific notes when relevant",
|
||||
"- **Testing & QA**: Test frameworks, running tests, coverage expectations",
|
||||
"- **Module Contexts** (`docs/contexts/`): One `.md` per subsystem",
|
||||
"</structure>",
|
||||
"",
|
||||
"<directives>",
|
||||
"- You MUST title the document `Repository Guidelines`",
|
||||
"- You MUST use Markdown headings for structure",
|
||||
"- You MUST be concise and practical",
|
||||
"- You MUST focus on what an AI assistant needs to help with the codebase",
|
||||
"- You SHOULD include examples where helpful (commands, paths, naming patterns)",
|
||||
"- You SHOULD include file paths where relevant",
|
||||
"- You MUST call out architecture and code patterns explicitly",
|
||||
"- You SHOULD omit information obvious from code structure",
|
||||
"- You MUST NOT paste concrete implementation code (function bodies, struct fields, line-by-line logic); signatures or interface shapes are the maximum level of detail",
|
||||
"- You MUST keep every file concise; shorter is better",
|
||||
"- When uncertain, prefer less detail over speculative detail",
|
||||
"- You MUST preserve existing useful guidance from `AGENTS.md`, `CLAUDE.md`, `.pi/`, README files, and docs instead of overwriting it blindly",
|
||||
"- You SHOULD note installed pi helper capabilities when they materially affect future agents, especially `pi-subagents` orchestration patterns if available",
|
||||
"</directives>",
|
||||
"",
|
||||
"<exploration-workflow>",
|
||||
"1. Ecosystem discovery (parent or one scout): identify repo shape and evidence files before deeper work. Look for language/tool markers such as `package.json`, `bun.lock`, `pnpm-lock.yaml`, `pyproject.toml`, `requirements.txt`, `go.mod`, `Cargo.toml`, `pom.xml`, `build.gradle`, `settings.gradle`, `*.sln`, `*.csproj`, `Package.swift`, `Gemfile`, `composer.json`, `mix.exs`, `Makefile`, `CMakeLists.txt`, `Dockerfile`, `docker-compose.yml`, Terraform/OpenTofu files, Kubernetes manifests, CI workflows, and docs.",
|
||||
"2. Parallel or sequential evidence scans: choose scopes based on the discovered ecosystem. Do not force JavaScript-style categories on non-JS repos.",
|
||||
"3. Parent synthesis: verify high-impact claims, resolve contradictions, decide module context files, then write final outputs.",
|
||||
"</exploration-workflow>",
|
||||
"",
|
||||
"<scan-matrix>",
|
||||
"Use these language-agnostic scan targets, adapting names and examples to the actual repo:",
|
||||
"- **Entry Points & Lifecycle**: executable entry points, library exports, service startup, CLI commands, web/mobile app bootstrap, extension hooks, background jobs, migrations, deployment entrypoints.",
|
||||
"- **Architecture & Module Boundaries**: major packages/modules/services, dependency direction, public APIs, data/control flow, storage/network boundaries, generated code boundaries.",
|
||||
"- **Configuration & Environment**: config files, env vars, secrets conventions, profiles/flavors, feature flags, local dev setup, runtime prerequisites.",
|
||||
"- **Build, Install & Release**: build graph, package manager, scripts/tasks, generated artifacts, install/deploy scripts, containers, CI/CD, versioning/release notes.",
|
||||
"- **Testing & Validation**: unit/integration/e2e tests, fixtures, linters, formatters, type checks, static analysis, manual validation flows, known gaps.",
|
||||
"- **Docs & Existing Guidance**: README, AGENTS/CLAUDE files, docs, examples, ADRs/design notes, changelogs, existing `docs/contexts` content to preserve or update.",
|
||||
"- **Operational Risks & Gotchas**: platform assumptions, concurrency/state, migrations, external services, security/privacy concerns, performance-sensitive paths, flaky tests, generated files not to edit.",
|
||||
"</scan-matrix>",
|
||||
"",
|
||||
"<parallel-exploration>",
|
||||
"When pi-subagents is available, launch a read-only parallel context pass after ecosystem discovery. Use fresh context unless inherited conversation decisions are required. Pick 2-4 agents based on repo size and shape. Recommended generic scopes:",
|
||||
"",
|
||||
"1. `scout` or `context-builder`: Entry points, architecture, module boundaries, and data/control flow.",
|
||||
"2. `scout` or `context-builder`: Configuration, environment, build/install/release tooling, generated artifacts, and deployment shape.",
|
||||
"3. `scout` or `context-builder`: Tests, QA, validation commands, fixtures, static analysis, and reliability gaps.",
|
||||
"4. `scout` or `context-builder`: Existing docs/guidance, examples, module context candidates, stale or conflicting instructions.",
|
||||
"",
|
||||
"Each child must return:",
|
||||
"- `Files Retrieved`: exact files and line ranges inspected.",
|
||||
"- `Facts With Evidence`: concrete findings tied to file paths.",
|
||||
"- `Commands`: real commands discovered from the repo, not generic guesses.",
|
||||
"- `Module Candidates`: recommended `docs/contexts/<name>.md` files from this scan area.",
|
||||
"- `Gotchas`: risks or caveats backed by evidence.",
|
||||
"- `Unknowns`: unresolved facts; do not speculate.",
|
||||
"",
|
||||
"Children must not edit project files. The parent must synthesize child outputs, verify important claims by reading high-value files when needed, then write final files.",
|
||||
"</parallel-exploration>",
|
||||
"",
|
||||
"<module-format>",
|
||||
"- No paragraphs, tables, code blocks, or ASCII diagrams",
|
||||
"- Write each file as a reference card: purpose, API surface, key types, file paths only. No explanations or walkthroughs.",
|
||||
"- Include commands/tests/gotchas only when they are specific to that subsystem",
|
||||
"- Keep module context files short enough to be loaded as context",
|
||||
"</module-format>",
|
||||
"",
|
||||
"<output>",
|
||||
"After analysis, you MUST write `AGENTS.md` to the project root.",
|
||||
"You MUST also write all module-related files (`<module-name>.md`) to the `docs/contexts` directory.",
|
||||
"Create `docs/contexts` if it does not exist.",
|
||||
"If existing module context files already exist, update them instead of duplicating them.",
|
||||
"",
|
||||
"$@",
|
||||
"</output>",
|
||||
];
|
||||
|
||||
export const INIT_PROMPT = INIT_PROMPT_LINES.join("\n");
|
||||
|
||||
export function buildInitPrompt(args: string): string {
|
||||
const trimmed = args.trim();
|
||||
if (!trimmed) return INIT_PROMPT;
|
||||
return INIT_PROMPT.replace("$@", trimmed);
|
||||
}
|
||||
16
packages/wow-contexts/src/resolver.ts
Normal file
16
packages/wow-contexts/src/resolver.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getWowSettingSync } from "wow-core";
|
||||
import { TAG } from "./global";
|
||||
|
||||
const DEFAULT_PATHS: string[] = [
|
||||
"~/.pi/agent/contexts/*.md",
|
||||
".pi/contexts/*.md",
|
||||
"docs/contexts/*.md",
|
||||
];
|
||||
|
||||
export function resolvePaths(cwd = process.cwd()): string[] {
|
||||
const configured = getWowSettingSync<string[]>(TAG, cwd);
|
||||
if (Array.isArray(configured) && configured.length > 0) {
|
||||
return configured.map(String);
|
||||
}
|
||||
return DEFAULT_PATHS;
|
||||
}
|
||||
11
packages/wow-contexts/src/state.ts
Normal file
11
packages/wow-contexts/src/state.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface ContextState {
|
||||
cwd: string;
|
||||
wowPaths: Map<string, string>;
|
||||
rebuild: boolean;
|
||||
}
|
||||
|
||||
export const contextState: ContextState = {
|
||||
cwd: "",
|
||||
wowPaths: new Map<string, string>(),
|
||||
rebuild: false,
|
||||
};
|
||||
13
packages/wow-contexts/tsconfig.json
Normal file
13
packages/wow-contexts/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
17
packages/wow-core/package.json
Normal file
17
packages/wow-core/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "wow-core",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"boxen": "^8.0.1",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
}
|
||||
}
|
||||
23
packages/wow-core/src/commands.ts
Normal file
23
packages/wow-core/src/commands.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const WOW_NS = "wow";
|
||||
|
||||
export function registerWowCommand(
|
||||
pi: Pick<ExtensionAPI, "registerCommand">,
|
||||
module: string,
|
||||
options: {
|
||||
description?: string;
|
||||
getArgumentCompletions?: (
|
||||
prefix: string,
|
||||
) => unknown[] | null | Promise<unknown[] | null>;
|
||||
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
|
||||
},
|
||||
): void {
|
||||
pi.registerCommand(
|
||||
`${WOW_NS}:${module}`,
|
||||
options as Parameters<ExtensionAPI["registerCommand"]>[1],
|
||||
);
|
||||
}
|
||||
184
packages/wow-core/src/config.ts
Normal file
184
packages/wow-core/src/config.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import YAML from "yaml";
|
||||
|
||||
export interface WowConfig {
|
||||
contexts?: string[];
|
||||
logger?: { level?: string };
|
||||
inject?: {
|
||||
enabled?: boolean;
|
||||
envFiles?: string[];
|
||||
env?: Record<string, string> | Array<Record<string, string>>;
|
||||
overrideExisting?: boolean;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const AGENT_DIR_VAR = "PI_CODING_AGENT_DIR";
|
||||
const CONFIG_FILES = ["wow.json", "wow.yaml", "wow.yml"];
|
||||
|
||||
let cachedConfig: WowConfig | undefined;
|
||||
let cachedCwd = "";
|
||||
|
||||
export function getAgentDir(): string {
|
||||
return process.env[AGENT_DIR_VAR] || path.join(homedir(), ".pi", "agent");
|
||||
}
|
||||
|
||||
export function resolveConfigPaths(cwd = process.cwd()): string[] {
|
||||
const globalConfigs = CONFIG_FILES.map((file) =>
|
||||
path.join(getAgentDir(), file),
|
||||
);
|
||||
const projectConfigs = CONFIG_FILES.map((file) =>
|
||||
path.join(cwd, ".pi", file),
|
||||
);
|
||||
return [...globalConfigs, ...projectConfigs].filter(
|
||||
(filePath, index, all) => all.indexOf(filePath) === index,
|
||||
);
|
||||
}
|
||||
|
||||
export function resetWowConfigCache(): void {
|
||||
cachedConfig = undefined;
|
||||
cachedCwd = "";
|
||||
}
|
||||
|
||||
export function getWowSettings(cwd = process.cwd()): WowConfig | undefined {
|
||||
if (cachedConfig !== undefined && cachedCwd === cwd) return cachedConfig;
|
||||
cachedCwd = cwd;
|
||||
cachedConfig = loadWowConfig(cwd);
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
export function getWowSettingSync<T = unknown>(
|
||||
key: string,
|
||||
cwd = process.cwd(),
|
||||
): T | undefined {
|
||||
const config = getWowSettings(cwd);
|
||||
return config?.[key] as T | undefined;
|
||||
}
|
||||
|
||||
export async function readWowConfig<T = unknown>(
|
||||
key: string,
|
||||
cwd = process.cwd(),
|
||||
): Promise<T | undefined> {
|
||||
return getWowSettingSync<T>(key, cwd);
|
||||
}
|
||||
|
||||
function loadWowConfig(cwd: string): WowConfig | undefined {
|
||||
let merged: WowConfig | undefined;
|
||||
|
||||
for (const filePath of resolveConfigPaths(cwd)) {
|
||||
const config = readConfigFile(filePath);
|
||||
if (!config) continue;
|
||||
merged = mergeWowConfig(merged ?? {}, config);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function readConfigFile(filePath: string): WowConfig | undefined {
|
||||
try {
|
||||
if (!existsSync(filePath)) return undefined;
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const parsed = parseConfigContent(filePath, content);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
||||
return undefined;
|
||||
return parsed as WowConfig;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfigContent(filePath: string, content: string): unknown {
|
||||
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {
|
||||
return YAML.parse(content);
|
||||
}
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
function mergeWowConfig(base: WowConfig, override: WowConfig): WowConfig {
|
||||
const merged: WowConfig = { ...base, ...override };
|
||||
|
||||
if (base.logger || override.logger) {
|
||||
merged.logger = { ...(base.logger ?? {}), ...(override.logger ?? {}) };
|
||||
}
|
||||
|
||||
if (base.inject || override.inject) {
|
||||
merged.inject = mergeInjectConfig(base.inject, override.inject);
|
||||
}
|
||||
|
||||
if (Array.isArray(base.contexts) || Array.isArray(override.contexts)) {
|
||||
merged.contexts = mergeStringArrays(base.contexts, override.contexts);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeInjectConfig(
|
||||
base: WowConfig["inject"],
|
||||
override: WowConfig["inject"],
|
||||
): WowConfig["inject"] {
|
||||
const merged = { ...(base ?? {}), ...(override ?? {}) };
|
||||
merged.envFiles = mergeStringArrays(base?.envFiles, override?.envFiles);
|
||||
merged.env = mergeEnvConfig(base?.env, override?.env);
|
||||
return merged;
|
||||
}
|
||||
|
||||
type EnvConfig = NonNullable<WowConfig["inject"]>["env"];
|
||||
|
||||
function mergeEnvConfig(
|
||||
base: EnvConfig | undefined,
|
||||
override: EnvConfig | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
const merged: Record<string, string> = {};
|
||||
|
||||
for (const source of [base, override]) {
|
||||
for (const [key, value] of Object.entries(normaliseEnvMap(source))) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
function normaliseEnvMap(input: unknown): Record<string, string> {
|
||||
if (!input) return {};
|
||||
const entries = Array.isArray(input)
|
||||
? Object.assign(
|
||||
{},
|
||||
...input.filter(
|
||||
(item) => item && typeof item === "object" && !Array.isArray(item),
|
||||
),
|
||||
)
|
||||
: input;
|
||||
|
||||
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
||||
return {};
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (typeof value === "string" || value instanceof String) {
|
||||
result[key] = String(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeStringArrays(
|
||||
base?: string[],
|
||||
override?: string[],
|
||||
): string[] | undefined {
|
||||
const merged: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const list of [base, override]) {
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const item of list) {
|
||||
if (typeof item !== "string" || seen.has(item)) continue;
|
||||
seen.add(item);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return merged.length > 0 ? merged : undefined;
|
||||
}
|
||||
7
packages/wow-core/src/index.ts
Normal file
7
packages/wow-core/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./commands";
|
||||
export * from "./config";
|
||||
export * from "./logger";
|
||||
export * from "./registry";
|
||||
export * from "./ui";
|
||||
export * from "./utils";
|
||||
export * from "./utils/ref-resolver";
|
||||
45
packages/wow-core/src/logger/index.ts
Normal file
45
packages/wow-core/src/logger/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { WowConfig } from "../config";
|
||||
import { getWowSettingSync } from "../config";
|
||||
import { parseLevel } from "./levels";
|
||||
import { WowLogger } from "./logger";
|
||||
|
||||
export type { LogLevel } from "./levels";
|
||||
export { WowLogger } from "./logger";
|
||||
|
||||
export type TaggedLogger = {
|
||||
debug: (message: string, data?: Record<string, unknown>) => void;
|
||||
info: (message: string, data?: Record<string, unknown>) => void;
|
||||
warn: (message: string, data?: Record<string, unknown>) => void;
|
||||
error: (message: string, data?: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
let instance: WowLogger | null = null;
|
||||
|
||||
export function createLogger(module: string): TaggedLogger {
|
||||
const logger = getLogger();
|
||||
return {
|
||||
debug: (message, data) => logger.debug(module, message, data),
|
||||
info: (message, data) => logger.info(module, message, data),
|
||||
warn: (message, data) => logger.warn(module, message, data),
|
||||
error: (message, data) => logger.error(module, message, data),
|
||||
};
|
||||
}
|
||||
|
||||
export function initLogger(cwd = process.cwd()): void {
|
||||
const base = resolveDefaultLevel();
|
||||
let finalLevel = base;
|
||||
const cfg = getWowSettingSync<WowConfig["logger"]>("logger", cwd);
|
||||
if (typeof cfg?.level === "string") {
|
||||
finalLevel = parseLevel(cfg.level) ?? finalLevel;
|
||||
}
|
||||
getLogger().setMinLevel(finalLevel);
|
||||
}
|
||||
|
||||
function getLogger(): WowLogger {
|
||||
if (!instance) instance = new WowLogger(resolveDefaultLevel());
|
||||
return instance;
|
||||
}
|
||||
|
||||
function resolveDefaultLevel(): "debug" | "error" | "info" | "warn" {
|
||||
return parseLevel(process.env.WOW_LOG_LEVEL) ?? "info";
|
||||
}
|
||||
28
packages/wow-core/src/logger/levels.ts
Normal file
28
packages/wow-core/src/logger/levels.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Log level definitions for the wow logger.
|
||||
*/
|
||||
|
||||
export const LEVELS = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
} as const;
|
||||
|
||||
export type LogLevel = keyof typeof LEVELS;
|
||||
|
||||
/**
|
||||
* Parse a string to a `LogLevel`. Returns `undefined` for unknown values.
|
||||
*/
|
||||
export function parseLevel(s: string | undefined): LogLevel | undefined {
|
||||
if (!s) return undefined;
|
||||
const lower = s.toLowerCase() as string;
|
||||
for (const level of Object.keys(LEVELS)) {
|
||||
if (lower === level) return level as LogLevel;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function levelToNumber(level: LogLevel): number {
|
||||
return LEVELS[level];
|
||||
}
|
||||
118
packages/wow-core/src/logger/logger.ts
Normal file
118
packages/wow-core/src/logger/logger.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { getAgentDir } from "../config";
|
||||
import type { LogLevel } from "./levels";
|
||||
import { levelToNumber } from "./levels";
|
||||
|
||||
const LOG_DIR = path.join(getAgentDir(), "wow", "logs");
|
||||
const MAX_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_FILES = 5;
|
||||
|
||||
export class WowLogger {
|
||||
#minLevel: number;
|
||||
#dirEnsured = false;
|
||||
|
||||
constructor(level: LogLevel) {
|
||||
this.#minLevel = levelToNumber(level);
|
||||
}
|
||||
|
||||
setMinLevel(level: LogLevel): void {
|
||||
this.#minLevel = levelToNumber(level);
|
||||
}
|
||||
|
||||
debug(module: string, message: string, data?: Record<string, unknown>): void {
|
||||
void this.#write("debug", module, message, data);
|
||||
}
|
||||
|
||||
info(module: string, message: string, data?: Record<string, unknown>): void {
|
||||
void this.#write("info", module, message, data);
|
||||
}
|
||||
|
||||
warn(module: string, message: string, data?: Record<string, unknown>): void {
|
||||
void this.#write("warn", module, message, data);
|
||||
}
|
||||
|
||||
error(module: string, message: string, data?: Record<string, unknown>): void {
|
||||
void this.#write("error", module, message, data);
|
||||
}
|
||||
|
||||
async #write(
|
||||
level: LogLevel,
|
||||
module: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
if (levelToNumber(level) < this.#minLevel) return;
|
||||
|
||||
try {
|
||||
await this.#ensureDir();
|
||||
const date = dateStr();
|
||||
const filePath = logPath(date);
|
||||
await this.#rotateIfNeeded(filePath, date);
|
||||
await fs.appendFile(
|
||||
filePath,
|
||||
`${JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
pid: process.pid,
|
||||
module,
|
||||
message,
|
||||
...data,
|
||||
})}\n`,
|
||||
);
|
||||
} catch {
|
||||
// Logging must never break the extension.
|
||||
}
|
||||
}
|
||||
|
||||
async #ensureDir(): Promise<void> {
|
||||
if (this.#dirEnsured) return;
|
||||
await fs.mkdir(LOG_DIR, { recursive: true });
|
||||
this.#dirEnsured = true;
|
||||
}
|
||||
|
||||
async #rotateIfNeeded(filePath: string, date: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.size < MAX_BYTES) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.unlink(rotatedPath(date, MAX_FILES));
|
||||
} catch {
|
||||
// Rotated file may not exist.
|
||||
}
|
||||
|
||||
for (let i = MAX_FILES - 1; i >= 1; i--) {
|
||||
try {
|
||||
await fs.rename(rotatedPath(date, i), rotatedPath(date, i + 1));
|
||||
} catch {
|
||||
// Older rotation slot may not exist.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(filePath, rotatedPath(date, 1));
|
||||
} catch {
|
||||
// File may have been moved by another process.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logPath(date: string): string {
|
||||
return path.join(LOG_DIR, `wow.${date}.log`);
|
||||
}
|
||||
|
||||
function rotatedPath(date: string, index: number): string {
|
||||
return path.join(LOG_DIR, `wow.${date}.${index}.log`);
|
||||
}
|
||||
|
||||
function dateStr(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
18
packages/wow-core/src/registry.ts
Normal file
18
packages/wow-core/src/registry.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export type ModuleSetup = (pi: ExtensionAPI) => void | Promise<void>;
|
||||
|
||||
export interface WowModule {
|
||||
name: string;
|
||||
register: ModuleSetup;
|
||||
}
|
||||
|
||||
const modules: WowModule[] = [];
|
||||
|
||||
export function registerModule(mod: WowModule): void {
|
||||
modules.push(mod);
|
||||
}
|
||||
|
||||
export function getModules(): readonly WowModule[] {
|
||||
return [...modules].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
50
packages/wow-core/src/ui/component.ts
Normal file
50
packages/wow-core/src/ui/component.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Component types and rendering for the wow-pi display system.
|
||||
*
|
||||
* Each component type knows how to append its visual lines to a buffer.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ListSection {
|
||||
summary: string;
|
||||
items?: string[];
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
export interface ShowText {
|
||||
type: "text";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ShowList {
|
||||
type: "list";
|
||||
sections: ListSection[];
|
||||
}
|
||||
|
||||
export type ShowComponent = ShowText | ShowList;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Append lines for a single component into `buf`. */
|
||||
export function renderComponent(comp: ShowComponent, buf: string[]): void {
|
||||
if (comp.type === "text") {
|
||||
buf.push(comp.content);
|
||||
} else if (comp.type === "list") {
|
||||
for (let si = 0; si < comp.sections.length; si++) {
|
||||
const section = comp.sections[si]!;
|
||||
if (si > 0) buf.push("");
|
||||
buf.push(section.summary);
|
||||
const items = section.items;
|
||||
if (items && items.length > 0) {
|
||||
for (const item of items) buf.push(` ${item}`);
|
||||
} else {
|
||||
buf.push(` ${section.emptyText ?? "(empty)"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/wow-core/src/ui/index.ts
Normal file
2
packages/wow-core/src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./component";
|
||||
export * from "./render";
|
||||
71
packages/wow-core/src/ui/render.ts
Normal file
71
packages/wow-core/src/ui/render.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
||||
import boxen from "boxen";
|
||||
import type { ShowComponent } from "./component";
|
||||
import { renderComponent } from "./component";
|
||||
|
||||
type Level = "info" | "warning" | "error";
|
||||
|
||||
export function show(
|
||||
ctx: { ui: ExtensionCommandContext["ui"] },
|
||||
level: Level,
|
||||
title: string,
|
||||
...components: ShowComponent[]
|
||||
): void {
|
||||
if (components.length === 0) {
|
||||
ctx.ui.notify(title, level);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const [index, component] of components.entries()) {
|
||||
if (index > 0) lines.push("");
|
||||
renderComponent(component, lines);
|
||||
}
|
||||
|
||||
ctx.ui.notify(
|
||||
boxen(lines.join("\n"), {
|
||||
borderStyle: "single",
|
||||
padding: 1,
|
||||
title,
|
||||
titleAlignment: "left",
|
||||
}),
|
||||
level,
|
||||
);
|
||||
}
|
||||
|
||||
export function showInfo(
|
||||
ctx: { ui: ExtensionCommandContext["ui"] },
|
||||
title: string,
|
||||
body?: string[],
|
||||
): void {
|
||||
showText(ctx, "info", title, body);
|
||||
}
|
||||
|
||||
export function showWarn(
|
||||
ctx: { ui: ExtensionCommandContext["ui"] },
|
||||
title: string,
|
||||
body?: string[],
|
||||
): void {
|
||||
showText(ctx, "warning", title, body);
|
||||
}
|
||||
|
||||
export function showError(
|
||||
ctx: { ui: ExtensionCommandContext["ui"] },
|
||||
title: string,
|
||||
body?: string[],
|
||||
): void {
|
||||
showText(ctx, "error", title, body);
|
||||
}
|
||||
|
||||
function showText(
|
||||
ctx: { ui: ExtensionCommandContext["ui"] },
|
||||
level: Level,
|
||||
title: string,
|
||||
body?: string[],
|
||||
): void {
|
||||
if (!body || body.length === 0) {
|
||||
show(ctx, level, title);
|
||||
return;
|
||||
}
|
||||
show(ctx, level, title, { type: "text", content: body.join("\n") });
|
||||
}
|
||||
12
packages/wow-core/src/utils/index.ts
Normal file
12
packages/wow-core/src/utils/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { homedir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
export function expandHome(input: string): string {
|
||||
if (input === "~") return homedir();
|
||||
if (input.startsWith("~/")) return path.join(homedir(), input.slice(2));
|
||||
return input;
|
||||
}
|
||||
|
||||
export function resolvePath(input: string, cwd = process.cwd()): string {
|
||||
return path.resolve(cwd, expandHome(input));
|
||||
}
|
||||
60
packages/wow-core/src/utils/ref-resolver.ts
Normal file
60
packages/wow-core/src/utils/ref-resolver.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as fs from "node:fs";
|
||||
import type { TaggedLogger } from "../logger";
|
||||
import { expandHome } from ".";
|
||||
|
||||
export interface KeyResolver {
|
||||
protocol: string;
|
||||
resolve(value: string): string | undefined;
|
||||
}
|
||||
|
||||
export const resolvers: KeyResolver[] = [];
|
||||
|
||||
export function registerKeyResolver(resolver: KeyResolver): void {
|
||||
resolvers.push(resolver);
|
||||
}
|
||||
|
||||
registerKeyResolver({
|
||||
protocol: "file",
|
||||
resolve(value: string): string | undefined {
|
||||
const content = fs.readFileSync(expandHome(value), "utf-8").trim();
|
||||
return content.length > 0 ? content : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
registerKeyResolver({
|
||||
protocol: "env",
|
||||
resolve(value: string): string | undefined {
|
||||
return process.env[value];
|
||||
},
|
||||
});
|
||||
|
||||
const REF_RE = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*):/;
|
||||
|
||||
export function resolveRef(ref: string, log: TaggedLogger): string | undefined {
|
||||
const match = REF_RE.exec(ref);
|
||||
if (!match) return ref;
|
||||
if (!ref.endsWith("}")) return undefined;
|
||||
|
||||
const protocol = match[1];
|
||||
const value = ref.slice(match[0].length, -1);
|
||||
if (!protocol || !value) return undefined;
|
||||
|
||||
for (const resolver of resolvers) {
|
||||
if (resolver.protocol !== protocol) continue;
|
||||
try {
|
||||
const resolved = resolver.resolve(value);
|
||||
if (resolved !== undefined) {
|
||||
log.debug(`ref "${protocol}" resolved`, {
|
||||
length: resolved.length,
|
||||
value,
|
||||
});
|
||||
return resolved;
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn(`ref "${protocol}" error`, { value, error: String(error) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
13
packages/wow-core/tsconfig.json
Normal file
13
packages/wow-core/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
16
packages/wow-inject/package.json
Normal file
16
packages/wow-inject/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "wow-inject",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"wow-core": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
}
|
||||
}
|
||||
68
packages/wow-inject/src/env-file.ts
Normal file
68
packages/wow-inject/src/env-file.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolvePath } from "wow-core";
|
||||
|
||||
export interface EnvLoadResult {
|
||||
path: string;
|
||||
loaded: string[];
|
||||
skipped: string[];
|
||||
missing: boolean;
|
||||
}
|
||||
|
||||
export function loadEnvFile(
|
||||
rawPath: string,
|
||||
options: { cwd: string; overrideExisting: boolean },
|
||||
): EnvLoadResult {
|
||||
const filePath = resolvePath(rawPath, options.cwd);
|
||||
const result: EnvLoadResult = {
|
||||
path: filePath,
|
||||
loaded: [],
|
||||
skipped: [],
|
||||
missing: false,
|
||||
};
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
result.missing = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const entry = parseEnvLine(line);
|
||||
if (!entry) continue;
|
||||
|
||||
if (!options.overrideExisting && entry.key in process.env) {
|
||||
result.skipped.push(entry.key);
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[entry.key] = entry.value;
|
||||
result.loaded.push(entry.key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseEnvLine(line: string): { key: string; value: string } | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) return null;
|
||||
|
||||
const cleaned = trimmed.startsWith("export ")
|
||||
? trimmed.slice(7).trimStart()
|
||||
: trimmed;
|
||||
const eqIndex = cleaned.indexOf("=");
|
||||
if (eqIndex === -1) return null;
|
||||
|
||||
const key = cleaned.slice(0, eqIndex).trim();
|
||||
if (!key) return null;
|
||||
|
||||
return { key, value: unwrapValue(cleaned.slice(eqIndex + 1).trim()) };
|
||||
}
|
||||
|
||||
function unwrapValue(value: string): string {
|
||||
if (value.length < 2) return value;
|
||||
const quote = value[0];
|
||||
if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
88
packages/wow-inject/src/env.ts
Normal file
88
packages/wow-inject/src/env.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { WowConfig } from "wow-core";
|
||||
import { getWowSettingSync, resolveRef } from "wow-core";
|
||||
import { loadEnvFile } from "./env-file";
|
||||
import { log, resolvedEnv } from "./global";
|
||||
|
||||
interface InjectStats {
|
||||
envFilesLoaded: number;
|
||||
envVarsLoaded: number;
|
||||
skippedExisting: number;
|
||||
missingFiles: number;
|
||||
}
|
||||
|
||||
export function injectProcessEnv(cwd = process.cwd()): InjectStats {
|
||||
const cfg = getWowSettingSync<WowConfig["inject"]>("inject", cwd);
|
||||
const stats: InjectStats = {
|
||||
envFilesLoaded: 0,
|
||||
envVarsLoaded: 0,
|
||||
skippedExisting: 0,
|
||||
missingFiles: 0,
|
||||
};
|
||||
|
||||
if (!cfg || cfg.enabled === false) return stats;
|
||||
|
||||
const overrideExisting = cfg.overrideExisting ?? false;
|
||||
|
||||
for (const rawPath of cfg.envFiles ?? []) {
|
||||
const result = loadEnvFile(rawPath, { cwd, overrideExisting });
|
||||
if (result.missing) {
|
||||
stats.missingFiles++;
|
||||
log.debug("env file missing", { path: result.path });
|
||||
continue;
|
||||
}
|
||||
stats.envFilesLoaded++;
|
||||
stats.envVarsLoaded += result.loaded.length;
|
||||
stats.skippedExisting += result.skipped.length;
|
||||
log.debug("env file loaded", {
|
||||
path: result.path,
|
||||
loaded: result.loaded,
|
||||
skipped: result.skipped,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, rawValue] of Object.entries(normaliseEnvMap(cfg.env))) {
|
||||
if (!overrideExisting && name in process.env) {
|
||||
stats.skippedExisting++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = resolveRef(rawValue, log);
|
||||
if (resolved === undefined) {
|
||||
log.warn("env ref unresolved", { name });
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[name] = resolved;
|
||||
resolvedEnv.set(name, resolved);
|
||||
stats.envVarsLoaded++;
|
||||
}
|
||||
|
||||
if (stats.envFilesLoaded > 0 || stats.envVarsLoaded > 0) {
|
||||
log.info("env injected", { ...stats });
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
function normaliseEnvMap(input: unknown): Record<string, string> {
|
||||
if (!input) return {};
|
||||
const entries = Array.isArray(input)
|
||||
? Object.assign(
|
||||
{},
|
||||
...input.filter(
|
||||
(item) => item && typeof item === "object" && !Array.isArray(item),
|
||||
),
|
||||
)
|
||||
: input;
|
||||
|
||||
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
||||
return {};
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (typeof value === "string" || value instanceof String) {
|
||||
result[key] = String(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
5
packages/wow-inject/src/global.ts
Normal file
5
packages/wow-inject/src/global.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createLogger } from "wow-core";
|
||||
|
||||
export const TAG = "inject";
|
||||
export const log = createLogger(TAG);
|
||||
export const resolvedEnv = new Map<string, string>();
|
||||
28
packages/wow-inject/src/index.ts
Normal file
28
packages/wow-inject/src/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ModuleSetup, WowConfig } from "wow-core";
|
||||
import {
|
||||
getWowSettingSync,
|
||||
registerModule,
|
||||
resetWowConfigCache,
|
||||
} from "wow-core";
|
||||
import { injectProcessEnv } from "./env";
|
||||
import { log, TAG } from "./global";
|
||||
|
||||
const setup: ModuleSetup = (pi) => {
|
||||
const load = (cwd = process.cwd()) => {
|
||||
resetWowConfigCache();
|
||||
const cfg = getWowSettingSync<WowConfig["inject"]>(TAG, cwd);
|
||||
if (cfg?.enabled === false) {
|
||||
log.debug("inject disabled — skipping module");
|
||||
return;
|
||||
}
|
||||
injectProcessEnv(cwd);
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
load(ctx.cwd);
|
||||
});
|
||||
};
|
||||
|
||||
registerModule({ name: TAG, register: setup });
|
||||
13
packages/wow-inject/tsconfig.json
Normal file
13
packages/wow-inject/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
23
packages/wow-pi/package.json
Normal file
23
packages/wow-pi/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "wow-pi",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf ./dist",
|
||||
"build:debug": "bun run clean && bun build ./src/index.ts --outdir ./dist --target node --external @earendil-works/pi-coding-agent --external @earendil-works/pi-ai --external @earendil-works/pi-tui --external typebox --define 'process.env.WOW_LOG_LEVEL=\"debug\"'",
|
||||
"build:release": "bun run clean && bun build ./src/index.ts --outdir ./dist --target node --external @earendil-works/pi-coding-agent --external @earendil-works/pi-ai --external @earendil-works/pi-tui --external typebox --define 'process.env.WOW_LOG_LEVEL=\"info\"' --minify"
|
||||
},
|
||||
"dependencies": {
|
||||
"wow-contexts": "workspace:*",
|
||||
"wow-core": "workspace:*",
|
||||
"wow-inject": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
}
|
||||
}
|
||||
23
packages/wow-pi/src/index.ts
Normal file
23
packages/wow-pi/src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
import "wow-contexts";
|
||||
import "wow-inject";
|
||||
import {
|
||||
createLogger,
|
||||
getModules,
|
||||
initLogger,
|
||||
resetWowConfigCache,
|
||||
} from "wow-core";
|
||||
|
||||
export default async function wowPi(pi: ExtensionAPI): Promise<void> {
|
||||
resetWowConfigCache();
|
||||
initLogger();
|
||||
const log = createLogger("main");
|
||||
|
||||
for (const mod of getModules()) {
|
||||
await mod.register(pi);
|
||||
log.debug("module registered", { module: mod.name });
|
||||
}
|
||||
|
||||
log.info("wow-pi loaded", { modules: getModules().map((mod) => mod.name) });
|
||||
}
|
||||
13
packages/wow-pi/tsconfig.json
Normal file
13
packages/wow-pi/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user