feat: sync

This commit is contained in:
Gab
2026-03-24 13:51:14 +11:00
parent 39bd38040c
commit 4596310485
20 changed files with 1356 additions and 101 deletions

View File

@@ -0,0 +1,544 @@
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"
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"
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
}
interface ToolSyncResult {
success: boolean
tools: SyncedTool[]
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 {
const possible = [
path.join(__dirname, "..", "..", "..", "..", "tf-sync", "src", "tf_sync"),
path.join(process.cwd(), "packages", "tf-sync", "src", "tf_sync"),
]
for (const p of possible) {
if (existsSync(p)) return p
}
return "tf_sync"
}
async function runPythonSync(
method: string,
args: Record<string, unknown> = {},
): Promise<unknown> {
const pythonCode = `
import json
import sys
import os
try:
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
})
print(json.dumps({
"success": result.success,
"tools": tools_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
})
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(),
},
})
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)
}
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",
}[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 { default: prompts } = await import("@clack/prompts")
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(),
})
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(),
})
export { ValidateCommand, SyncCommand, ToolsCommand }

View File

@@ -30,6 +30,7 @@ import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
import { DbCommand } from "./cli/cmd/db"
import { ValidateCommand, SyncCommand, ToolsMainCommand } from "./cli/cmd/tools"
import path from "path"
import { Global } from "./global"
import { JsonMigration } from "./storage/json-migration"
@@ -49,7 +50,7 @@ process.on("uncaughtException", (e) => {
let cli = yargs(hideBin(process.argv))
.parserConfiguration({ "populate--": true })
.scriptName("opencode")
.scriptName("tfcode")
.wrap(100)
.help("help", "show help")
.alias("help", "h")
@@ -145,6 +146,9 @@ let cli = yargs(hideBin(process.argv))
.command(PrCommand)
.command(SessionCommand)
.command(DbCommand)
.command(ValidateCommand)
.command(SyncCommand)
.command(ToolsMainCommand)
if (Installation.isLocal()) {
cli = cli.command(WorkspaceServeCommand)