Files
platform/src/servers/generate-container-context.ts
T
pastilhasandClaude Opus 4.8 f3492512ba unify agent items into a flat file-based store, drop the marketplace
Replace the marketplace service dependency and the native/global/user
scope tiers with a single external directory ($OFFICER_ITEMS_DIR) holding
skills, tools, tasks, processes and extensions as plain files.

- tasks move from Postgres to TASK.md files (new file-backed task layer);
  task editing now works, which the DB path never supported
- skills/tools/processes collapse into one shared file router (single dir)
- remove the marketplace client (sync-marketplace/sync-version) and the
  boot-time sync; pi-bridge/pi-manager/sandbox point at the flat store
- drop the dead tasks + vestigial skills/tools/processes/extensions +
  item_chats tables (migration 0004)
- one-time migration script exports DB tasks and consolidates disk items

Migration verified: all 6 tasks round-trip through the runtime parser
identically to their DB rows (pipeline steps, triggers, script impls and
agentic bodies all intact).

NOTE: not yet functionally tested end-to-end — every item (each task mode,
tool, skill, extension) still needs to be run/exercised in the app before
this is trusted. To be done manually.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:39:17 +00:00

183 lines
6.2 KiB
TypeScript

import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { itemsDir, getHomeDir, DATA_PATH } from '@@/data-path';
type HookEntry = { type: string; command: string };
type HookRule = { matcher?: Record<string, unknown>; hooks: HookEntry[] };
type ClaudeSettings = Record<string, unknown> & {
hooks?: Record<string, HookRule[]>;
};
type FrontmatterEntry = { name: string; description: string };
function parseFrontmatter(content: string): Record<string, string> {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const fields: Record<string, string> = {};
for (const line of match[1]!.split('\n')) {
const m = line.match(/^(\w+):\s*(.+)/);
if (m) fields[m[1]!] = m[2]!.trim();
}
return fields;
}
function scanDir(dir: string, metaFile: string): FrontmatterEntry[] {
if (!existsSync(dir)) return [];
const entries: FrontmatterEntry[] = [];
for (const name of readdirSync(dir, { withFileTypes: true })) {
if (!name.isDirectory()) continue;
const filePath = join(dir, name.name, metaFile);
if (!existsSync(filePath)) continue;
const fm = parseFrontmatter(readFileSync(filePath, 'utf-8'));
if (fm.name || fm.label) {
entries.push({ name: fm.name ?? fm.label ?? name.name, description: fm.description ?? '' });
}
}
return entries;
}
function dedup(entries: FrontmatterEntry[]): FrontmatterEntry[] {
const seen = new Map<string, FrontmatterEntry>();
for (const e of entries) seen.set(e.name, e);
return [...seen.values()];
}
function formatList(entries: FrontmatterEntry[]): string {
if (entries.length === 0) return 'None configured.\n';
return entries.map((e) => `- **${e.name}**${e.description ? ` — ${e.description}` : ''}`).join('\n') + '\n';
}
export function generateContainerContext(email: string): string {
const toolsDir = itemsDir('tools');
const skillsDir = itemsDir('skills');
const tasksDir = itemsDir('tasks');
const tools = dedup(scanDir(toolsDir, 'TOOL.md'));
const skills = dedup(scanDir(skillsDir, 'SKILL.md'));
const tasks = dedup(scanDir(tasksDir, 'TASK.md'));
const userDataDir = join(DATA_PATH, email);
const content = `# Officer — User Environment
This is an isolated Linux user environment managed by the Officer platform.
## Directory Layout
| Path | Contents |
|------|----------|
| \`~\` | User home directory (read-write) |
| \`~/Projects/\` | User projects |
| \`~/Downloads/\` | Downloaded files |
| \`${toolsDir}/\` | Tools |
| \`${skillsDir}/\` | Reference skills |
| \`${userDataDir}/\` | User data (emails.db, attachments, etc.) |
## Available Tools
Tools are callable capabilities used by the Officer AI agent (Pi). Each tool has a \`TOOL.md\` with documentation and an \`index.ts\` that exports an \`execute()\` function. Read individual tool docs at \`${toolsDir}/<name>/TOOL.md\`.
${formatList(tools)}
## Available Skills
Skills are reference documentation that the AI agent uses to understand APIs and CLIs.
${formatList(skills)}
## Available Tasks
Tasks are predefined instruction sets the AI agent can execute.
${formatList(tasks)}
## Creating New Tools
Create a directory in \`${toolsDir}/<tool-name>/\` with two files:
**TOOL.md** — Frontmatter metadata + markdown documentation:
\`\`\`yaml
---
name: my_tool
description: What it does and when the agent should use it.
version: 1
language: typescript
inputs:
param:
type: string
description: What this parameter is for.
---
# My Tool
Usage documentation here.
\`\`\`
**index.ts** — Must export an \`execute\` function:
\`\`\`typescript
type ToolResult = { content: Array<{ type: string; text: string }>; isError?: boolean };
export async function execute(_toolCallId: string, params: Record<string, unknown>): Promise<ToolResult> {
return { content: [{ type: 'text', text: 'Done' }] };
}
\`\`\`
## Environment Variables
| Variable | Description |
|----------|-------------|
| \`OFFICER_EMAIL_DB\` | Path to email SQLite database |
| \`PI_TOOLS_DIRS\` | Tool discovery paths (colon-separated) |
| \`PI_SEARXNG_URL\` | Search engine URL |
`;
const contextDir = join(DATA_PATH, email, '.container-context');
mkdirSync(contextDir, { recursive: true });
const filePath = join(contextDir, 'CLAUDE.md');
writeFileSync(filePath, content);
return filePath;
}
export function generateClaudeSettings(email: string, username?: string): string {
const hostSettingsPath = join(process.env.HOME ?? '', '.claude', 'settings.json');
let settings: ClaudeSettings = {};
try {
settings = JSON.parse(readFileSync(hostSettingsPath, 'utf-8')) as ClaudeSettings;
} catch {
// no host settings
}
const hookCommand = `curl -s -X POST http://localhost:5000/api/hooks/claude-done -H 'Content-Type: application/json' -d '{"email":"${email}"}'`;
const hooks = settings.hooks ?? {};
const stopRules = hooks.Stop ?? [];
const hasOurHook = stopRules.some((rule) => rule.hooks?.some((h) => h.command?.includes('/api/hooks/claude-done')));
if (!hasOurHook) {
stopRules.push({ hooks: [{ type: 'command', command: hookCommand }] });
}
hooks.Stop = stopRules;
settings.hooks = hooks;
settings.defaultMode = 'bypassPermissions';
settings.skipDangerousModePermissionPrompt = true;
const contextDir = join(DATA_PATH, email, '.container-context');
mkdirSync(contextDir, { recursive: true });
const filePath = join(contextDir, 'settings.json');
writeFileSync(filePath, JSON.stringify(settings, null, 2));
// Pre-seed trust and skip-permissions in .claude.json so interactive Claude Code skips all prompts
if (username) {
const homeDir = getHomeDir(email);
const claudeJsonPath = join(homeDir, '.claude.json');
let claudeJson: Record<string, unknown> = {};
try {
claudeJson = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')) as Record<string, unknown>;
} catch {
// no existing config
}
const projects = (claudeJson.projects ?? {}) as Record<string, Record<string, unknown>>;
const projectKey = homeDir;
if (!projects[projectKey]) projects[projectKey] = {};
projects[projectKey]!.hasTrustDialogAccepted = true;
claudeJson.projects = projects;
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
}
return filePath;
}