mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-01 06:42:26 +00:00
feat: sync
This commit is contained in:
544
packages/tfcode/src/cli/cmd/tools.ts
Normal file
544
packages/tfcode/src/cli/cmd/tools.ts
Normal 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 }
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user