Files
tf_code/packages/tfcode/src/cli/cmd/tools.ts
2026-04-04 17:56:41 +11:00

717 lines
20 KiB
TypeScript

import { cmd } from "@/cli/cmd/cmd"
import { UI } from "@/cli/ui"
import { Log } from "@/util/log"
import { spawn } from "child_process"
import { Filesystem } from "@/util/filesystem"
import { mkdir } from "fs/promises"
import { existsSync } from "fs"
import path from "path"
import { Global } from "@/global"
import * as prompts from "@clack/prompts"
const println = (msg: string) => UI.println(msg)
const printError = (msg: string) => UI.error(msg)
const success = (msg: string) => UI.println(UI.Style.TEXT_SUCCESS_BOLD + msg + UI.Style.TEXT_NORMAL)
const info = (msg: string) => UI.println(UI.Style.TEXT_NORMAL + msg)
type ToolType = "mcp_server" | "agent_skill" | "database_script" | "api_function" | "coder_agent" | "prompt"
interface SyncedTool {
id: string
name: string
description?: string
tool_type: ToolType
is_mcp_server: boolean
is_agent_skill: boolean
is_database_script: boolean
request_type?: string
url?: string
tools: string[]
auth_via: string
interpolation_string?: string
goals?: string
temperature?: number
max_tokens?: number
llm_base_model?: string
llm_provider?: string
}
interface SyncedPrompt {
id: string
label: string
interpolation_string: string
prompt_type?: string
available_to_agents?: string[]
description?: string
}
interface ToolSyncResult {
success: boolean
tools: SyncedTool[]
prompts: SyncedPrompt[]
by_type: Record<string, number>
error?: string
}
interface CredentialValidationResult {
success: boolean
workspace_id?: string
workspace_name?: string
error?: string
}
const TFCODE_CONFIG_DIR = ".tfcode"
const TFCODE_TOOLS_FILE = "tools.json"
function getPythonSyncPath(): string {
// Check embedded python path first (for npm distribution)
const embedded = [
path.join(__dirname, "..", "..", "..", "..", "python"), // packages/tfcode/python
path.join(__dirname, "..", "..", "..", "python"), // dist/python
]
for (const p of embedded) {
if (existsSync(p)) return p
}
// Fallback to development paths
const dev = [
path.join(__dirname, "..", "..", "..", "..", "tf-sync", "src", "tf_sync"),
path.join(process.cwd(), "packages", "tf-sync", "src", "tf_sync"),
]
for (const p of dev) {
if (existsSync(p)) return p
}
return "tf_sync"
}
async function runPythonSync(method: string, args: Record<string, unknown> = {}): Promise<unknown> {
const credentials = await loadCredentials()
const pythonPath = getPythonSyncPath()
const pythonCode = `
import json
import sys
import os
try:
# Add embedded tf_sync module path
sys.path.insert(0, "${pythonPath.replace(/\\/g, "/")}")
from tf_sync.config import load_config, validate_credentials
from tf_sync.tools import sync_tools, sync_tools_by_type, ToolType
from tf_sync.mcp import sync_mcp_servers
method = ${JSON.stringify(method)}
args = ${JSON.stringify(args)}
if method == "validate":
config = load_config()
result = validate_credentials(config)
print(json.dumps({
"success": result.success,
"workspace_id": result.workspace_id,
"workspace_name": result.workspace_name,
"error": result.error
}))
elif method == "sync":
config = load_config()
result = sync_tools(config)
tools_data = []
for tool in result.tools:
tools_data.append({
"id": tool.id,
"name": tool.name,
"description": tool.description,
"tool_type": tool.tool_type.value,
"is_mcp_server": tool.is_mcp_server,
"is_agent_skill": tool.is_agent_skill,
"is_database_script": tool.is_database_script,
"request_type": tool.request_type.value if tool.request_type else None,
"url": tool.url,
"tools": tool.tools,
"auth_via": tool.auth_via,
"interpolation_string": tool.interpolation_string,
"goals": tool.goals,
"temperature": tool.temperature,
"max_tokens": tool.max_tokens,
"llm_base_model": tool.llm_base_model,
"llm_provider": tool.llm_provider
})
prompts_data = []
for prompt in result.prompts:
prompts_data.append({
"id": prompt.id,
"label": prompt.label,
"interpolation_string": prompt.interpolation_string,
"prompt_type": prompt.prompt_type,
"available_to_agents": prompt.available_to_agents,
"description": prompt.description
})
print(json.dumps({
"success": result.success,
"tools": tools_data,
"prompts": prompts_data,
"by_type": result.by_type,
"error": result.error
}))
elif method == "sync_type":
tool_type_str = args.get("tool_type")
if tool_type_str:
tool_type_map = {
"mcp_server": ToolType.MCP_SERVER,
"agent_skill": ToolType.AGENT_SKILL,
"database_script": ToolType.DATABASE_SCRIPT,
"api_function": ToolType.API_FUNCTION
}
tool_type = tool_type_map.get(tool_type_str)
if tool_type:
config = load_config()
result = sync_tools_by_type(config, [tool_type])
tools_data = []
for tool in result.tools:
tools_data.append({
"id": tool.id,
"name": tool.name,
"description": tool.description,
"tool_type": tool.tool_type.value,
"is_mcp_server": tool.is_mcp_server,
"is_agent_skill": tool.is_agent_skill,
"is_database_script": tool.is_database_script,
"request_type": tool.request_type.value if tool.request_type else None,
"url": tool.url,
"tools": tool.tools,
"auth_via": tool.auth_via,
"interpolation_string": tool.interpolation_string,
"goals": tool.goals,
"temperature": tool.temperature,
"max_tokens": tool.max_tokens,
"llm_base_model": tool.llm_base_model,
"llm_provider": tool.llm_provider
})
print(json.dumps({
"success": result.success,
"tools": tools_data,
"by_type": result.by_type,
"error": result.error
}))
else:
print(json.dumps({"success": False, "error": "Missing tool_type argument"}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
sys.exit(0)
`
return new Promise((resolve, reject) => {
const pythonPath = process.env.TFCODE_PYTHON_PATH || "python3"
const proc = spawn(pythonPath, ["-c", pythonCode], {
env: {
...process.env,
PYTHONPATH: getPythonSyncPath(),
TF_WORKSPACE_ID: credentials?.workspace_id || "",
TF_API_KEY: credentials?.api_key || "",
TF_REGION: credentials?.region || "au",
},
})
let stdout = ""
let stderr = ""
proc.stdout.on("data", (data) => {
stdout += data.toString()
})
proc.stderr.on("data", (data) => {
stderr += data.toString()
})
proc.on("close", (code) => {
if (code !== 0 && !stdout) {
reject(new Error(`Python sync failed: ${stderr}`))
return
}
try {
const result = JSON.parse(stdout.trim())
resolve(result)
} catch (e) {
reject(new Error(`Failed to parse Python output: ${stdout}\nstderr: ${stderr}`))
}
})
proc.on("error", (err) => {
reject(err)
})
})
}
function getConfigPath(): string {
return path.join(Global.Path.data, TFCODE_CONFIG_DIR)
}
function getToolsFilePath(): string {
return path.join(getConfigPath(), TFCODE_TOOLS_FILE)
}
function getCredentialsFilePath(): string {
return path.join(getConfigPath(), "credentials.json")
}
async function loadCredentials(): Promise<{ workspace_id: string; api_key: string; region: string } | null> {
const credFile = getCredentialsFilePath()
if (!(await Filesystem.exists(credFile))) {
return null
}
try {
const content = await Bun.file(credFile).text()
return JSON.parse(content)
} catch {
return null
}
}
async function loadCachedTools(): Promise<ToolSyncResult | null> {
const toolsFile = getToolsFilePath()
if (!(await Filesystem.exists(toolsFile))) {
return null
}
try {
const content = await Bun.file(toolsFile).text()
return JSON.parse(content)
} catch {
return null
}
}
async function saveToolsCache(result: ToolSyncResult): Promise<void> {
const configPath = getConfigPath()
await mkdir(configPath, { recursive: true })
await Bun.write(getToolsFilePath(), JSON.stringify(result, null, 2))
}
const ValidateCommand = cmd({
command: "validate",
describe: "validate ToothFairyAI credentials",
handler: async () => {
info("Validating ToothFairyAI credentials...")
try {
const result = (await runPythonSync("validate")) as CredentialValidationResult
if (result.success) {
success("✓ Credentials valid")
if (result.workspace_name) {
info(` Workspace: ${result.workspace_name}`)
}
if (result.workspace_id) {
info(` ID: ${result.workspace_id}`)
}
} else {
printError(`✗ Validation failed: ${result.error || "Unknown error"}`)
process.exitCode = 1
}
} catch (e) {
printError(`Failed to validate: ${e instanceof Error ? e.message : String(e)}`)
process.exitCode = 1
}
},
})
const SyncCommand = cmd({
command: "sync",
describe: "sync tools from ToothFairyAI workspace",
builder: (yargs) =>
yargs.option("force", {
alias: "f",
type: "boolean",
describe: "force re-sync",
default: false,
}),
handler: async (args) => {
info("Syncing tools from ToothFairyAI workspace...")
try {
const result = (await runPythonSync("sync")) as ToolSyncResult
if (result.success) {
await saveToolsCache(result)
success(`✓ Synced ${result.tools.length} tools`)
if (result.by_type && Object.keys(result.by_type).length > 0) {
info("\nBy type:")
for (const [type, count] of Object.entries(result.by_type)) {
info(` ${type}: ${count}`)
}
}
} else {
printError(`✗ Sync failed: ${result.error || "Unknown error"}`)
process.exitCode = 1
}
} catch (e) {
printError(`Failed to sync: ${e instanceof Error ? e.message : String(e)}`)
process.exitCode = 1
}
},
})
const ToolsListCommand = cmd({
command: "list",
describe: "list synced tools",
builder: (yargs) =>
yargs.option("type", {
type: "string",
choices: ["mcp", "skill", "database", "function"] as const,
describe: "filter by tool type",
}),
handler: async (args) => {
const cached = await loadCachedTools()
if (!cached || !cached.success) {
printError("No tools synced. Run 'tfcode sync' first.")
process.exitCode = 1
return
}
let tools = cached.tools
if (args.type) {
const typeMap: Record<string, ToolType> = {
mcp: "mcp_server",
skill: "agent_skill",
database: "database_script",
function: "api_function",
}
const targetType = typeMap[args.type]
tools = tools.filter((t) => t.tool_type === targetType)
}
if (tools.length === 0) {
info("No tools found.")
return
}
info(`\n${tools.length} tool(s):\n`)
for (const tool of tools) {
const typeLabel = {
mcp_server: "MCP",
agent_skill: "Skill",
database_script: "DB",
api_function: "API",
coder_agent: "Coder Agent",
prompt: "Prompt",
}[tool.tool_type]
info(` ${tool.name}`)
info(` Type: ${typeLabel}`)
if (tool.description) {
info(` Description: ${tool.description}`)
}
if (tool.url) {
info(` URL: ${tool.url}`)
}
info(` Auth: ${tool.auth_via}`)
info("")
}
},
})
const ToolsCredentialsSetCommand = cmd({
command: "credentials <name>",
describe: "manage tool credentials",
builder: (yargs) =>
yargs
.positional("name", {
type: "string",
describe: "tool name",
demandOption: true,
})
.option("set", {
type: "boolean",
describe: "set credential",
})
.option("show", {
type: "boolean",
describe: "show stored credential",
}),
handler: async (args) => {
const toolName = args.name as string
const cached = await loadCachedTools()
if (!cached || !cached.success) {
printError("No tools synced. Run 'tfcode sync' first.")
process.exitCode = 1
return
}
const tool = cached.tools.find((t) => t.name === toolName)
if (!tool) {
printError(`Tool '${toolName}' not found.`)
process.exitCode = 1
return
}
if (tool.auth_via !== "user_provided") {
printError(`Tool '${toolName}' uses tf_proxy authentication. Credentials are managed by ToothFairyAI.`)
process.exitCode = 1
return
}
const credentialsFile = path.join(getConfigPath(), "credentials.json")
let credentials: Record<string, string> = {}
if (await Filesystem.exists(credentialsFile)) {
try {
credentials = await Bun.file(credentialsFile).json()
} catch {}
}
if (args.show) {
const cred = credentials[toolName]
if (cred) {
info(`${toolName}: ${cred.substring(0, 8)}...${cred.substring(cred.length - 4)}`)
} else {
info(`No credential stored for '${toolName}'`)
}
return
}
if (args.set) {
const value = await prompts.password({
message: `Enter API key for '${toolName}'`,
})
if (prompts.isCancel(value)) {
printError("Cancelled")
process.exitCode = 1
return
}
credentials[toolName] = value as string
await mkdir(getConfigPath(), { recursive: true })
await Bun.write(credentialsFile, JSON.stringify(credentials, null, 2))
success(`✓ Credential saved for '${toolName}'`)
return
}
printError("Use --set or --show")
process.exitCode = 1
},
})
const ToolsCommand = cmd({
command: "tools",
describe: "manage ToothFairyAI tools",
builder: (yargs) => yargs.command(ToolsListCommand).command(ToolsCredentialsSetCommand).demandCommand(),
async handler() {},
})
const ToolsDebugCommand = cmd({
command: "debug <name>",
describe: "debug tool connection",
builder: (yargs) =>
yargs.positional("name", {
type: "string",
describe: "tool name",
demandOption: true,
}),
handler: async (args) => {
const toolName = args.name as string
const cached = await loadCachedTools()
if (!cached || !cached.success) {
printError("No tools synced. Run 'tfcode sync' first.")
process.exitCode = 1
return
}
const tool = cached.tools.find((t) => t.name === toolName)
if (!tool) {
printError(`Tool '${toolName}' not found.`)
process.exitCode = 1
return
}
info(`\nTool: ${tool.name}`)
info(`Type: ${tool.tool_type}`)
info(`Auth: ${tool.auth_via}`)
if (tool.url) {
info(`URL: ${tool.url}`)
}
if (tool.request_type) {
info(`Request Type: ${tool.request_type}`)
}
info("\nChecking configuration...")
const configPath = getConfigPath()
info(`Config dir: ${configPath}`)
const toolsFile = getToolsFilePath()
info(`Tools cache: ${toolsFile}`)
info(` Exists: ${await Filesystem.exists(toolsFile)}`)
if (tool.auth_via === "user_provided") {
const credentialsFile = path.join(configPath, "credentials.json")
info(`Credentials file: ${credentialsFile}`)
info(` Exists: ${await Filesystem.exists(credentialsFile)}`)
if (await Filesystem.exists(credentialsFile)) {
const credentials = await Bun.file(credentialsFile).json()
info(` Has credential for '${toolName}': ${!!credentials[toolName]}`)
}
}
},
})
const ToolsTestCommand = cmd({
command: "test <name>",
describe: "test tool call",
builder: (yargs) =>
yargs.positional("name", {
type: "string",
describe: "tool name",
demandOption: true,
}),
handler: async (args) => {
const toolName = args.name as string
const cached = await loadCachedTools()
if (!cached || !cached.success) {
printError("No tools synced. Run 'tfcode sync' first.")
process.exitCode = 1
return
}
const tool = cached.tools.find((t) => t.name === toolName)
if (!tool) {
printError(`Tool '${toolName}' not found.`)
process.exitCode = 1
return
}
info(`Testing tool '${toolName}'...`)
info(`This feature is not yet implemented.`)
info(`Tool type: ${tool.tool_type}`)
info(`Authentication: ${tool.auth_via}`)
process.exitCode = 1
},
})
export const ToolsMainCommand = cmd({
command: "tools",
describe: "manage ToothFairyAI tools",
builder: (yargs) =>
yargs
.command(ToolsListCommand)
.command(ToolsCredentialsSetCommand)
.command(ToolsDebugCommand)
.command(ToolsTestCommand)
.demandCommand(),
async handler() {},
})
export const SetupCommand = cmd({
command: "setup",
describe: "configure ToothFairyAI credentials",
builder: (yargs) =>
yargs
.option("api-key", {
type: "string",
describe: "API key",
})
.option("workspace-id", {
type: "string",
describe: "Workspace ID",
})
.option("region", {
type: "string",
describe: "Region (dev, au, eu, us)",
default: "au",
}),
handler: async (args) => {
const configPath = getConfigPath()
const credFile = getCredentialsFilePath()
// Load existing credentials
let existing: { api_key?: string; workspace_id?: string; region?: string } = {}
if (await Filesystem.exists(credFile)) {
try {
existing = await Bun.file(credFile).json()
} catch {}
}
// Get credentials from args or prompt
let apiKey = args["api-key"]
let workspaceId = args["workspace-id"]
let region = args.region || existing.region || "au"
if (!apiKey || !workspaceId) {
info("")
info("ToothFairyAI Credentials Setup")
info("━".repeat(40))
info("")
if (!workspaceId) {
process.stdout.write(`Workspace ID [${existing.workspace_id || ""}]: `)
const input = await new Promise<string>((resolve) => {
process.stdin.once("data", (data) => resolve(data.toString().trim()))
})
workspaceId = input || existing.workspace_id
}
if (!apiKey) {
process.stdout.write(`API Key [${existing.api_key ? "****" + existing.api_key.slice(-4) : ""}]: `)
const input = await new Promise<string>((resolve) => {
process.stdin.once("data", (data) => resolve(data.toString().trim()))
})
apiKey = input || existing.api_key
}
process.stdout.write(`Region [${region}]: `)
const regionInput = await new Promise<string>((resolve) => {
process.stdin.once("data", (data) => resolve(data.toString().trim()))
})
if (regionInput) region = regionInput
}
if (!apiKey || !workspaceId) {
printError("API key and workspace ID are required")
process.exitCode = 1
return
}
// Save credentials
await mkdir(configPath, { recursive: true })
await Bun.write(
credFile,
JSON.stringify(
{
api_key: apiKey,
workspace_id: workspaceId,
region,
},
null,
2,
),
)
success(`✓ Credentials saved to ${credFile}`)
info(` Workspace: ${workspaceId}`)
info(` Region: ${region}`)
info("")
info("Run 'tfcode validate' to test your credentials")
},
})
export { ValidateCommand, SyncCommand, ToolsCommand }