claude-code as channel model, container trust/permissions fixes, terminal cwd fix
- add claude-code as virtual model in channel messaging (telegram/discord/whatsapp) - new send-claude-code.ts: docker exec claude -p with session resumption - route claude-code model in sendAndAwait before Pi pipeline - append claude-code to listPiModels output - fix container .claude mount (rw for sub-mounts), hooks format (matcher-based) - pre-seed hasTrustDialogAccepted and bypassPermissions in container settings - git init in entrypoint to skip workspace trust prompt - fix ~/~ double-tilde in CommandTerminalWrapper cwd resolution - remove --continue from claude-code panel command Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getGlobalToolsDir, getUserToolsDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalTasksDir, getUserTasksDir, getGlobalResourcesDir, 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 tools = dedup([...scanDir(getGlobalToolsDir(), 'TOOL.md'), ...scanDir(getUserToolsDir(email), 'TOOL.md')]);
|
||||
const skills = dedup([...scanDir(getGlobalSkillsDir(), 'SKILL.md'), ...scanDir(getUserSkillsDir(email), 'SKILL.md')]);
|
||||
const tasks = dedup([...scanDir(getGlobalTasksDir(), 'TASK.md'), ...scanDir(getUserTasksDir(email), 'TASK.md')]);
|
||||
const resources = scanDir(getGlobalResourcesDir(), 'RESOURCE.md');
|
||||
|
||||
const content = `# Officer — Container Environment
|
||||
|
||||
This is a sandboxed development container managed by the Officer platform.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| \`~\` | User home directory (read-write) |
|
||||
| \`~/Projects/\` | User projects |
|
||||
| \`~/Downloads/\` | Downloaded files |
|
||||
| \`/officer/tools/\` | Global tools (read-only) |
|
||||
| \`/officer/user/tools/\` | User tools (read-only) |
|
||||
| \`/officer/skills/\` | Reference skills (read-only) |
|
||||
| \`/officer/data/\` | 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 \`/officer/tools/<name>/TOOL.md\` or \`/officer/user/tools/<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)}
|
||||
## Configured Resources
|
||||
|
||||
Resources are external service integrations (TTS, STT, OCR, etc.) configured in Settings.
|
||||
|
||||
${formatList(resources)}
|
||||
## Creating New Tools
|
||||
|
||||
Create a directory in \`/officer/user/tools/<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' }] };
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Full guide: \`/officer/tools/TOOLS.md\`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| \`OFFICER_EMAIL_DB\` | Path to email SQLite database |
|
||||
| \`OFFICER_RESOURCES\` | JSON with configured resource integrations |
|
||||
| \`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 containerHome = `/home/${username}`;
|
||||
const claudeJsonPath = join(getHomeDir(email), '.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 = containerHome;
|
||||
if (!projects[projectKey]) projects[projectKey] = {};
|
||||
projects[projectKey]!.hasTrustDialogAccepted = true;
|
||||
claudeJson.projects = projects;
|
||||
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
Reference in New Issue
Block a user