1487 lines
43 KiB
Go
1487 lines
43 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"flag"
|
||
"fmt"
|
||
"errors"
|
||
"io"
|
||
"os/exec"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
defaultVersion = "0.1.0"
|
||
defaultModel = "glm-5.1"
|
||
defaultBaseURL = "https://open.bigmodel.cn/api/paas/v4"
|
||
defaultAITimeout = 30
|
||
defaultModeCompact = "compact"
|
||
defaultModeFull = "full"
|
||
)
|
||
|
||
var (
|
||
defaultVersionTags = []string{"sgclaw", "browser_script", "converter"}
|
||
riskPatterns = map[string][]string{
|
||
"shell_like": {"require(", "process.", "child_process", "spawn(", "exec(", "shell", "subprocess"},
|
||
"network_like": {"fetch(", "XMLHttpRequest", "axios", ".ajax(", "WebSocket(", ".open("},
|
||
"xpath_like": {":contains(", "document.evaluate", "XPathResult", "getByXPath"},
|
||
"expected_domain": {"expected_domain"},
|
||
}
|
||
)
|
||
|
||
type AiConfig struct {
|
||
BaseURL string `json:"base_url"`
|
||
APIKey string `json:"api_key"`
|
||
Model string `json:"model"`
|
||
TimeoutSecond int `json:"timeout"`
|
||
Enabled bool `json:"enabled"`
|
||
}
|
||
|
||
type SkillResult struct {
|
||
SkillName string `json:"skill_name"`
|
||
TargetDir string `json:"target_dir"`
|
||
Files map[string]string `json:"files"`
|
||
Metadata map[string]interface{} `json:"metadata"`
|
||
Validation []string `json:"validation"`
|
||
RiskCheck []string `json:"risk_check"`
|
||
AIAttempted bool `json:"ai_attempted"`
|
||
AIUsed bool `json:"ai_used"`
|
||
AIFallbackReason string `json:"ai_fallback_reason,omitempty"`
|
||
BlockingReason string `json:"blocking_reason,omitempty"`
|
||
}
|
||
|
||
type AIGenerated struct {
|
||
ToolName string `json:"tool_name"`
|
||
ToolDescription string `json:"tool_description"`
|
||
SkillDescription string `json:"skill_description"`
|
||
ArtifactFields []string `json:"artifact_fields"`
|
||
FailureModes []string `json:"failure_modes"`
|
||
SkillToml string `json:"skill_toml"`
|
||
SkillMd string `json:"skill_md"`
|
||
ImplementationNote string `json:"implementation_notes"`
|
||
AssetNote string `json:"asset_notes"`
|
||
RiskCheck []string `json:"risk_check"`
|
||
}
|
||
|
||
func main() {
|
||
// Shared args
|
||
jsPath := flag.String("js-script-path", "", "")
|
||
jsCode := flag.String("js-script-code", "", "")
|
||
skillName := flag.String("skill-name", "", "")
|
||
skillDomain := flag.String("skill-domain", "", "")
|
||
skillUseCase := flag.String("skill-use-case", "", "")
|
||
toolName := flag.String("tool-name", "", "")
|
||
toolDesc := flag.String("tool-description", "", "")
|
||
argSpec := flag.String("arg-spec", "", "")
|
||
artifactFields := flag.String("artifact-fields", "", "")
|
||
preferredMode := flag.String("preferred-mode", defaultModeCompact, "")
|
||
write := flag.Bool("write", false, "")
|
||
skillRoot := flag.String("skill-root", "skills", "")
|
||
noAI := flag.Bool("no-ai", false, "")
|
||
serve := flag.Bool("serve", false, "")
|
||
host := flag.String("host", "127.0.0.1", "")
|
||
port := flag.Int("port", 8787, "")
|
||
flag.Parse()
|
||
|
||
if *serve {
|
||
runServer(*host, *port, *skillRoot)
|
||
return
|
||
}
|
||
|
||
artifactList := parseCommaValues(*artifactFields)
|
||
args, err := parseArgSpec(*argSpec)
|
||
if err != nil {
|
||
panic(fmt.Sprintf("arg-spec 解析失败: %v", err))
|
||
}
|
||
|
||
result, err := generateSkillPackage(
|
||
*jsPath,
|
||
*jsCode,
|
||
*skillName,
|
||
*skillDomain,
|
||
*skillUseCase,
|
||
*toolName,
|
||
*toolDesc,
|
||
args,
|
||
*preferredMode,
|
||
artifactList,
|
||
*write,
|
||
*skillRoot,
|
||
!*noAI,
|
||
nil,
|
||
)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
|
||
out, _ := json.MarshalIndent(result, "", " ")
|
||
fmt.Println(string(out))
|
||
}
|
||
|
||
func runServer(host string, port int, skillRoot string) {
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
|
||
configHandler(w, r, skillRoot)
|
||
})
|
||
mux.HandleFunc("/api/convert", func(w http.ResponseWriter, r *http.Request) {
|
||
convertHandler(w, r, skillRoot)
|
||
})
|
||
mux.HandleFunc("/api/open-dir", func(w http.ResponseWriter, r *http.Request) {
|
||
openDirHandler(w, r)
|
||
})
|
||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
staticHandler(w, r)
|
||
}))
|
||
|
||
addr := fmt.Sprintf("%s:%d", host, port)
|
||
server := &http.Server{
|
||
Addr: addr,
|
||
Handler: mux,
|
||
ReadTimeout: time.Second * 30,
|
||
WriteTimeout: time.Second * 120,
|
||
}
|
||
fmt.Printf("SGClaw converter server running on http://%s\n", addr)
|
||
fmt.Printf("Open http://%s in browser\n", addr)
|
||
if err := server.ListenAndServe(); err != nil {
|
||
fmt.Println("server exit:", err)
|
||
}
|
||
}
|
||
|
||
func staticHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == http.MethodOptions {
|
||
handleCORS(w)
|
||
w.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||
http.Error(w, "not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
urlPath := r.URL.Path
|
||
rel := strings.TrimPrefix(urlPath, "/")
|
||
if rel == "" || rel == "." {
|
||
rel = "index.html"
|
||
}
|
||
rel = filepath.Clean(filepath.ToSlash(rel))
|
||
if rel == ".." || strings.HasPrefix(rel, "../") {
|
||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if strings.HasSuffix(urlPath, "/") && rel != "index.html" {
|
||
rel = filepath.ToSlash(filepath.Join(rel, "index.html"))
|
||
}
|
||
fp := filepath.Join("web", rel)
|
||
info, err := os.Stat(fp)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if info.IsDir() {
|
||
fp = filepath.Join(fp, "index.html")
|
||
if _, err := os.Stat(fp); err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
}
|
||
handleCORS(w)
|
||
http.ServeFile(w, r, fp)
|
||
}
|
||
|
||
func configHandler(w http.ResponseWriter, r *http.Request, skillRoot string) {
|
||
handleCORS(w)
|
||
if r.Method == http.MethodOptions {
|
||
w.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
if r.Method != http.MethodGet {
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{"error": "method not allowed"})
|
||
return
|
||
}
|
||
cfg := resolveAIConfig(nil)
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"ai_enabled": cfg.Enabled,
|
||
"skill_root": skillRoot,
|
||
"model": cfg.Model,
|
||
"base_url": cfg.BaseURL,
|
||
"timeout": cfg.TimeoutSecond,
|
||
"api_key_masked": maskSecret(cfg.APIKey),
|
||
"api_key_present": strings.TrimSpace(cfg.APIKey) != "",
|
||
})
|
||
}
|
||
|
||
func openDirHandler(w http.ResponseWriter, r *http.Request) {
|
||
handleCORS(w)
|
||
if r.Method == http.MethodOptions {
|
||
w.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
if r.Method != http.MethodGet {
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{"error": "method not allowed"})
|
||
return
|
||
}
|
||
|
||
targetDir := strings.TrimSpace(r.URL.Query().Get("path"))
|
||
if targetDir == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "missing path"})
|
||
return
|
||
}
|
||
|
||
abs, err := filepath.Abs(targetDir)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "invalid path"})
|
||
return
|
||
}
|
||
info, err := os.Stat(abs)
|
||
if err != nil || !info.IsDir() {
|
||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "target directory not found"})
|
||
return
|
||
}
|
||
|
||
if err := openFolder(abs); err != nil {
|
||
msg := err.Error()
|
||
if errors.Is(err, exec.ErrNotFound) {
|
||
msg = "open command not available"
|
||
}
|
||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": msg})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "target_dir": filepath.ToSlash(abs)})
|
||
}
|
||
|
||
func convertHandler(w http.ResponseWriter, r *http.Request, skillRoot string) {
|
||
if r.Method == http.MethodOptions {
|
||
handleCORS(w)
|
||
w.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
if r.Method != http.MethodPost {
|
||
handleCORS(w)
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{"error": "method not allowed"})
|
||
return
|
||
}
|
||
handleCORS(w)
|
||
|
||
body, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "invalid body"})
|
||
return
|
||
}
|
||
defer r.Body.Close()
|
||
|
||
var payload map[string]interface{}
|
||
if err := json.Unmarshal(body, &payload); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "bad json"})
|
||
return
|
||
}
|
||
|
||
jsCode := strValue(payload["js_script_code"])
|
||
jsPath := strValue(payload["js_script_path"])
|
||
if jsCode == "" && jsPath == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "missing js_script_code or js_script_path"})
|
||
return
|
||
}
|
||
|
||
mode := strValue(payload["preferred_mode"])
|
||
if mode == "" {
|
||
mode = defaultModeCompact
|
||
}
|
||
artifactList := []string{}
|
||
if af, ok := payload["artifact_fields"].([]interface{}); ok {
|
||
for _, x := range af {
|
||
if s, ok := x.(string); ok {
|
||
artifactList = append(artifactList, strings.TrimSpace(s))
|
||
}
|
||
}
|
||
}
|
||
rawArtifact := strValue(payload["artifact_fields_raw"])
|
||
if rawArtifact != "" {
|
||
artifactList = append(artifactList, parseCommaValues(rawArtifact)...)
|
||
}
|
||
artifactList = dedupeStrings(artifactList)
|
||
|
||
spec := map[string]string{}
|
||
if rawArgSpec, ok := payload["arg_spec"]; ok {
|
||
switch v := rawArgSpec.(type) {
|
||
case string:
|
||
s, err := parseArgSpec(v)
|
||
if err == nil {
|
||
spec = s
|
||
}
|
||
case map[string]interface{}:
|
||
for k, vv := range v {
|
||
spec[k] = fmt.Sprintf("%v", vv)
|
||
}
|
||
}
|
||
}
|
||
write, _ := payload["write"].(bool)
|
||
useAI := true
|
||
if raw, ok := payload["use_ai"].(bool); ok {
|
||
useAI = raw
|
||
}
|
||
overrideSkillRoot := firstNonEmpty(strValue(payload["skill_root"]), skillRoot)
|
||
aiConfig := parseAIConfig(payload["ai_config"])
|
||
|
||
result, err := generateSkillPackage(
|
||
jsPath,
|
||
jsCode,
|
||
strValue(payload["skill_name"]),
|
||
strValue(payload["skill_domain"]),
|
||
strValue(payload["skill_use_case"]),
|
||
strValue(payload["tool_name"]),
|
||
strValue(payload["tool_description"]),
|
||
spec,
|
||
mode,
|
||
artifactList,
|
||
write,
|
||
overrideSkillRoot,
|
||
useAI,
|
||
aiConfig,
|
||
)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, result)
|
||
}
|
||
|
||
type APIResponse map[string]interface{}
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, obj interface{}) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(status)
|
||
out, _ := json.MarshalIndent(obj, "", " ")
|
||
_, _ = w.Write(out)
|
||
}
|
||
|
||
func handleCORS(w http.ResponseWriter) {
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
||
}
|
||
|
||
func generateSkillPackage(
|
||
jsScriptPath string,
|
||
jsScriptCode string,
|
||
skillName string,
|
||
skillDomain string,
|
||
skillUseCase string,
|
||
toolName string,
|
||
toolDescription string,
|
||
argSpec map[string]string,
|
||
preferredMode string,
|
||
artifactFields []string,
|
||
write bool,
|
||
skillRoot string,
|
||
useAI bool,
|
||
aiConfig *AiConfig,
|
||
) (*SkillResult, error) {
|
||
if jsScriptCode == "" && jsScriptPath == "" {
|
||
return nil, fmt.Errorf("js_script_path 或 js_script_code 需要提供其一")
|
||
}
|
||
var scriptCode string
|
||
if jsScriptCode != "" {
|
||
scriptCode = jsScriptCode
|
||
} else {
|
||
b, e := os.ReadFile(jsScriptPath)
|
||
if e != nil {
|
||
return nil, fmt.Errorf("读取 js_script_path 失败: %w", e)
|
||
}
|
||
scriptCode = string(b)
|
||
}
|
||
if strings.TrimSpace(scriptCode) == "" {
|
||
return nil, fmt.Errorf("脚本内容为空")
|
||
}
|
||
|
||
resolvedDomain := stripDomain(skillDomain)
|
||
resolvedSkillName := slugify(filepath.Base(deriveDefaultName(skillName, jsScriptPath)))
|
||
defaultToolName := strings.ReplaceAll(resolvedSkillName, "-", "_")
|
||
if !strings.HasPrefix(defaultToolName, "extract_") {
|
||
defaultToolName = "extract_" + defaultToolName
|
||
}
|
||
resolvedToolName := normalizeToolName(toolName, defaultToolName)
|
||
resolvedToolDesc := firstNonEmpty(toolDescription, skillUseCase, "基于现有脚本生成结构化结果")
|
||
resolvedSkillDesc := firstNonEmpty(skillUseCase, resolvedSkillName+" browser_script tool")
|
||
|
||
inferredArgs := inferArgsFromScript(scriptCode)
|
||
mergedArgs := map[string]string{}
|
||
for k, v := range inferredArgs {
|
||
mergedArgs[k] = v
|
||
}
|
||
for k, v := range argSpec {
|
||
mergedArgs[k] = v
|
||
}
|
||
|
||
aiCtx := map[string]interface{}{
|
||
"skill_name": resolvedSkillName,
|
||
"tool_name": resolvedToolName,
|
||
"resolved_domain": resolvedDomain,
|
||
"script_snippet": scriptCode,
|
||
"inferred_args": keysFromMap(mergedArgs),
|
||
"preferred_mode": preferredMode,
|
||
}
|
||
|
||
var aiOut *AIGenerated
|
||
aiAttempted := false
|
||
aiUsed := false
|
||
aiFallbackReason := ""
|
||
resolvedAI := resolveAIConfig(aiConfig)
|
||
if useAI {
|
||
aiOut, aiFallbackReason, aiAttempted = callAI(aiCtx, &resolvedAI)
|
||
aiUsed = aiOut != nil
|
||
} else {
|
||
aiFallbackReason = "AI disabled by request"
|
||
}
|
||
|
||
if aiOut != nil && aiOut.ToolName != "" {
|
||
resolvedToolName = normalizeToolName(aiOut.ToolName, resolvedToolName)
|
||
}
|
||
if strings.HasPrefix(resolvedToolName, "extract_extract_") {
|
||
resolvedToolName = strings.TrimPrefix(resolvedToolName, "extract_")
|
||
}
|
||
if aiOut != nil && aiOut.ToolDescription != "" {
|
||
resolvedToolDesc = aiOut.ToolDescription
|
||
}
|
||
if aiOut != nil && aiOut.SkillDescription != "" {
|
||
resolvedSkillDesc = aiOut.SkillDescription
|
||
}
|
||
|
||
if len(artifactFields) == 0 {
|
||
if aiOut != nil && len(aiOut.ArtifactFields) > 0 {
|
||
artifactFields = aiOut.ArtifactFields
|
||
} else {
|
||
artifactFields = inferArtifactFields(scriptCode)
|
||
}
|
||
}
|
||
failureModes := []string{
|
||
"blocked_page: 登录/验证码/反爬场景要抛错",
|
||
"partial_data: 数据不足要在注释或结果中说明",
|
||
}
|
||
if aiOut != nil && len(aiOut.FailureModes) > 0 {
|
||
failureModes = aiOut.FailureModes
|
||
}
|
||
|
||
tomlContent := ""
|
||
mdContent := ""
|
||
notesContent := ""
|
||
assetContent := ""
|
||
riskCheck := scanRisk(scriptCode)
|
||
if aiOut != nil && aiOut.SkillToml != "" {
|
||
tomlContent = strings.TrimSpace(aiOut.SkillToml)
|
||
} else {
|
||
tomlContent = buildSkillToml(resolvedSkillName, resolvedSkillDesc, resolvedToolName, resolvedToolDesc, preferredMode, mergedArgs)
|
||
}
|
||
if aiOut != nil && aiOut.SkillMd != "" {
|
||
mdContent = strings.TrimSpace(aiOut.SkillMd)
|
||
} else {
|
||
mdContent = buildSkillMD(resolvedSkillName, resolvedSkillDesc, resolvedToolName, resolvedToolDesc, resolvedDomain, mergedArgs, artifactFields, preferredMode, failureModes)
|
||
}
|
||
if aiOut != nil && aiOut.ImplementationNote != "" {
|
||
notesContent = strings.TrimSpace(aiOut.ImplementationNote)
|
||
} else {
|
||
notesContent = buildReferenceNotes(resolvedSkillName, resolvedToolName, mergedArgs, artifactFields)
|
||
}
|
||
if aiOut != nil && aiOut.AssetNote != "" {
|
||
assetContent = strings.TrimSpace(aiOut.AssetNote)
|
||
} else {
|
||
assetContent = buildAssetNotes(resolvedSkillName, resolvedDomain, preferredMode)
|
||
}
|
||
if aiOut != nil && len(aiOut.RiskCheck) > 0 {
|
||
riskCheck = append(riskCheck, aiOut.RiskCheck...)
|
||
}
|
||
|
||
// Enforce core SGClaw invariants even when AI returns near-valid content.
|
||
commandPath := fmt.Sprintf("scripts/%s.js", resolvedToolName)
|
||
if !strings.Contains(tomlContent, `kind = "browser_script"`) {
|
||
tomlContent = buildSkillToml(resolvedSkillName, resolvedSkillDesc, resolvedToolName, resolvedToolDesc, preferredMode, mergedArgs)
|
||
}
|
||
tomlContent = rewriteTomlCommand(tomlContent, commandPath)
|
||
mdContent = rewriteMarkdownCommand(mdContent, commandPath)
|
||
mdContent = rewriteMarkdownToolName(mdContent, resolvedToolName)
|
||
|
||
files := map[string]string{
|
||
"SKILL.toml": ensureTrailNewline(tomlContent),
|
||
"SKILL.md": ensureTrailNewline(mdContent),
|
||
fmt.Sprintf("scripts/%s.js", resolvedToolName): ensureTrailNewline(strings.TrimRight(scriptCode, "\n") + "\n"),
|
||
"references/implementation-notes.md": ensureTrailNewline(notesContent),
|
||
"assets/notes.md": ensureTrailNewline(assetContent),
|
||
}
|
||
|
||
validation := []string{
|
||
"PASS: 基础文件结构字段已生成",
|
||
"PASS: kind 已固定为 browser_script",
|
||
"PASS: 命令参数已映射为 [tools.args]",
|
||
}
|
||
if resolvedDomain != "" {
|
||
validation = append(validation, fmt.Sprintf("PASS: expected_domain 已在 contract 中声明为 %s", resolvedDomain))
|
||
}
|
||
if len(riskCheck) > 0 {
|
||
validation = append(validation, "WARN: 存在高风险文本片段,需人工复核")
|
||
}
|
||
if len(artifactFields) == 0 && preferredMode == defaultModeFull {
|
||
validation = append(validation, "WARN: 当前未识别到结构化 artifact 字段,建议手工补充")
|
||
}
|
||
|
||
targetDir := filepath.Join(skillRoot, resolvedSkillName)
|
||
if write {
|
||
for p, c := range files {
|
||
fp := filepath.Join(targetDir, filepath.FromSlash(p))
|
||
if err := os.MkdirAll(filepath.Dir(fp), 0o755); err != nil {
|
||
return nil, fmt.Errorf("创建目录失败: %w", err)
|
||
}
|
||
if err := os.WriteFile(fp, []byte(c), 0o644); err != nil {
|
||
return nil, fmt.Errorf("写入 %s 失败: %w", p, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
meta := map[string]interface{}{
|
||
"skill_contract": map[string]interface{}{
|
||
"expected_domain": resolvedDomain,
|
||
"tool_name": resolvedToolName,
|
||
"arguments": keysFromMap(mergedArgs),
|
||
"artifact_fields": artifactFields,
|
||
"failure_modes": failureModes,
|
||
},
|
||
}
|
||
|
||
result := &SkillResult{
|
||
SkillName: resolvedSkillName,
|
||
TargetDir: filepath.ToSlash(targetDir),
|
||
Files: files,
|
||
Metadata: meta,
|
||
Validation: validation,
|
||
RiskCheck: riskCheck,
|
||
AIAttempted: aiAttempted,
|
||
AIUsed: aiUsed,
|
||
AIFallbackReason: aiFallbackReason,
|
||
}
|
||
if len(artifactFields) == 0 && preferredMode == defaultModeFull {
|
||
result.BlockingReason = "无法可靠推断 artifact_fields,建议补充脚本返回字段说明"
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func callAI(ctx map[string]interface{}, cfg *AiConfig) (*AIGenerated, string, bool) {
|
||
if cfg == nil {
|
||
resolved := resolveAIConfig(nil)
|
||
cfg = &resolved
|
||
}
|
||
if cfg.BaseURL == "" {
|
||
return nil, "missing AI base_url", false
|
||
}
|
||
if cfg.APIKey == "" {
|
||
return nil, "missing AI api_key", false
|
||
}
|
||
if !cfg.Enabled {
|
||
return nil, "AI disabled by resolved config", false
|
||
}
|
||
|
||
systemPrompt := strings.TrimSpace(`
|
||
你是 SGClaw 技能打包器。
|
||
|
||
你的唯一任务:把一段“已经存在的 JS 脚本”包装为可落地的 SGClaw browser_script skill 元数据与文档。
|
||
|
||
硬性约束:
|
||
1. 不重写脚本业务逻辑,只做轻量包装和文档化。
|
||
2. 只输出合法 JSON,不输出 markdown 代码块外文字。
|
||
3. 输出必须服务于 SGClaw 运行时,不要生成与运行时无关的花哨描述。
|
||
4. 如果无法可靠推断字段,返回空字符串、空数组,或保留调用方给定值;不要编造。
|
||
5. 优先保证命名、运行契约、错误处理、artifact 结构正确。
|
||
|
||
SGClaw 规则:
|
||
- tool kind 固定为 browser_script
|
||
- command 指向 scripts/<tool>.js
|
||
- tool_name 使用小写英文和下划线,不含空格
|
||
- expected_domain 是裸域名,属于运行契约,不要伪造脚本参数
|
||
- 优先结构化 artifact 输出,避免纯文本二次采集
|
||
- 浏览器提取优先 CSS 选择器,禁止 XPath 和 jQuery :contains
|
||
- 阻断页(登录、验证码、权限不足、反爬)必须显式报错
|
||
- 不引入 shell、网络请求、危险命令或绝对路径
|
||
`)
|
||
rawCtx, _ := json.Marshal(ctx)
|
||
userPrompt := fmt.Sprintf(`下面是待包装脚本的上下文,请生成严格符合 SGClaw 规范的结果 JSON:
|
||
%s
|
||
|
||
你的目标不是“改写脚本”,而是“产出稳定的 skill 包装元数据”。
|
||
|
||
请严格输出以下字段:
|
||
- tool_name
|
||
- tool_description
|
||
- skill_description
|
||
- artifact_fields
|
||
- failure_modes
|
||
- skill_toml
|
||
- skill_md
|
||
- implementation_notes
|
||
- asset_notes
|
||
- risk_check
|
||
|
||
请按下面规则生成:
|
||
1. 保留脚本原始行为,不生成新的抓取逻辑,不改变输入输出语义。
|
||
2. tool_name:
|
||
- 若调用方已给定可用名称,则保持兼容;
|
||
- 否则基于 skill_name 生成;
|
||
- 小写英文/数字/下划线;
|
||
- 若名称已带 extract_ 前缀,不要再重复添加。
|
||
3. tool_description 和 skill_description:
|
||
- 简洁、具体、面向执行;
|
||
- 不写营销文案;
|
||
- 不要出现“智能”“强大”等空话。
|
||
4. artifact_fields:
|
||
- 只列脚本明显返回的结构化字段;
|
||
- 无法可靠识别时返回空数组。
|
||
5. failure_modes:
|
||
- 至少考虑 blocked_page、partial_data;
|
||
- 仅在脚本场景明显相关时增加其他项。
|
||
6. skill_toml:
|
||
- 必须是可落地内容;
|
||
- [skill] 至少含 name/description/version/author/tags;
|
||
- [[tools]] 必须含 name/description/kind/browser_script/command;
|
||
- 如存在参数则写 [tools.args];
|
||
- 不要写绝对路径;
|
||
- 不要引入与 SGClaw 无关字段。
|
||
7. skill_md:
|
||
- 必须包含 Runtime Contract、Blocked-Page Rule、Output Contract;
|
||
- 明确 CSS 选择器优先、禁止 XPath 和 :contains;
|
||
- 明确 expected_domain 是运行契约而不是脚本参数;
|
||
- 明确返回结构化产物优先。
|
||
8. implementation_notes / asset_notes:
|
||
- 简短、可读、可落地;
|
||
- 不要远程链接。
|
||
9. risk_check:
|
||
- 只列真实风险,不要为了凑数输出空泛警告。
|
||
|
||
输出要求:
|
||
- 只输出一个合法 JSON 对象
|
||
- 不要输出解释性文字
|
||
- 如果某字段无法可靠生成,返回空字符串或空数组
|
||
- 优先保证可落地、可审计、可被 SGClaw 运行时消费`, string(rawCtx))
|
||
|
||
requestBody := map[string]interface{}{
|
||
"model": cfg.Model,
|
||
"temperature": 0.2,
|
||
"messages": []map[string]string{
|
||
{"role": "system", "content": systemPrompt},
|
||
{"role": "user", "content": userPrompt},
|
||
},
|
||
}
|
||
body, _ := json.Marshal(requestBody)
|
||
timeout := cfg.TimeoutSecond
|
||
if timeout <= 0 {
|
||
timeout = defaultAITimeout
|
||
}
|
||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(cfg.BaseURL, "/")+"/chat/completions", bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, fmt.Sprintf("build request failed: %v", err), false
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||
req.Header.Set("X-API-Key", cfg.APIKey)
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Sprintf("request failed: %v", err), true
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return nil, fmt.Sprintf("http %d: %s", resp.StatusCode, compactErrorText(raw)), true
|
||
}
|
||
|
||
var parsed map[string]interface{}
|
||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||
return nil, fmt.Sprintf("response json parse failed: %v", err), true
|
||
}
|
||
if errObj, ok := parsed["error"]; ok && errObj != nil {
|
||
return nil, fmt.Sprintf("provider error: %v", errObj), true
|
||
}
|
||
var contentText string
|
||
if choices, ok := parsed["choices"].([]interface{}); ok && len(choices) > 0 {
|
||
if first, ok := choices[0].(map[string]interface{}); ok {
|
||
if msg, ok := first["message"].(map[string]interface{}); ok {
|
||
contentText = fmt.Sprintf("%v", msg["content"])
|
||
}
|
||
if contentText == "" {
|
||
contentText = fmt.Sprintf("%v", first["text"])
|
||
}
|
||
}
|
||
}
|
||
if contentText == "" {
|
||
contentText = string(raw)
|
||
}
|
||
jsonText := extractJSONFromText(contentText)
|
||
if jsonText == nil {
|
||
return nil, "response missing valid JSON payload", true
|
||
}
|
||
out, err := parseAIGenerated(*jsonText)
|
||
if err != nil {
|
||
return nil, fmt.Sprintf("response schema parse failed: %v", err), true
|
||
}
|
||
if !hasUsableAIOutput(out) {
|
||
return nil, "response did not contain usable AI fields", true
|
||
}
|
||
return out, "", true
|
||
}
|
||
|
||
func extractJSONFromText(raw string) *string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
re := regexp.MustCompile("(?s)```json(.*?)```")
|
||
m := re.FindStringSubmatch(raw)
|
||
if len(m) >= 2 {
|
||
v := strings.TrimSpace(m[1])
|
||
return &v
|
||
}
|
||
start := strings.Index(raw, "{")
|
||
end := strings.LastIndex(raw, "}")
|
||
if start >= 0 && end > start {
|
||
v := strings.TrimSpace(raw[start : end+1])
|
||
return &v
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func buildSkillToml(skillName, description, toolName, toolDesc, mode string, args map[string]string) string {
|
||
lines := []string{
|
||
"[skill]",
|
||
fmt.Sprintf("name = %s", toTOMLString(skillName)),
|
||
fmt.Sprintf("description = %s", toTOMLString(description)),
|
||
fmt.Sprintf("version = %s", toTOMLString(defaultVersion)),
|
||
"author = \"sgclaw-skill-converter\"",
|
||
fmt.Sprintf("tags = %s", toTOMLArray(defaultVersionTags)),
|
||
"",
|
||
"[[tools]]",
|
||
fmt.Sprintf("name = %s", toTOMLString(toolName)),
|
||
fmt.Sprintf("description = %s", toTOMLString(toolDesc)),
|
||
`kind = "browser_script"`,
|
||
fmt.Sprintf(`command = %s`, toTOMLString(fmt.Sprintf("scripts/%s.js", toolName))),
|
||
}
|
||
if len(args) > 0 {
|
||
lines = append(lines, "", "[tools.args]")
|
||
for k, v := range args {
|
||
lines = append(lines, fmt.Sprintf("%s = %s", k, toTOMLString(v)))
|
||
}
|
||
}
|
||
if mode == defaultModeFull {
|
||
lines = append(lines, "", "prompts = [")
|
||
lines = append(lines,
|
||
fmt.Sprintf("%s", toTOMLString("只输出可被 SGClaw 安全执行的结构化结果")),
|
||
",",
|
||
fmt.Sprintf("%s", toTOMLString("禁止网络请求与系统命令,仅保留 browser_script 提取逻辑")),
|
||
"]",
|
||
)
|
||
}
|
||
return strings.Join(lines, "\n") + "\n"
|
||
}
|
||
|
||
func buildSkillMD(skillName, description, toolName, toolDesc, domain string, args map[string]string, fields []string, mode string, failures []string) string {
|
||
var b strings.Builder
|
||
b.WriteString("---\n")
|
||
b.WriteString(fmt.Sprintf("name: %s\n", skillName))
|
||
b.WriteString(fmt.Sprintf("description: %s\n", description))
|
||
b.WriteString(fmt.Sprintf("version: %s\n", defaultVersion))
|
||
b.WriteString("author: sgclaw-skill-converter\n")
|
||
b.WriteString("tags:\n - sgclaw\n - browser_script\n - converter\n---\n\n")
|
||
b.WriteString("# Use Cases\n")
|
||
b.WriteString("- 输入 JS 脚本并生成可被 sgclaw 加载的技能包\n")
|
||
b.WriteString("- 适配 browser_script 执行模型,优先返回结构化结果\n")
|
||
b.WriteString("- 与运行时兼容,遵循 expected_domain 与 CSS 选择器约束\n\n")
|
||
b.WriteString("# Workflow\n")
|
||
b.WriteString("1. 解析脚本参数与返回结构\n2. 生成 SKILL.toml 与 SKILL.md\n3. 保持 JS 逻辑原样,落盘到 scripts/<tool>.js\n4. 输出 references 与 assets 文档\n\n")
|
||
b.WriteString("# Runtime Contract\n")
|
||
b.WriteString(fmt.Sprintf("- expected_domain: `%s`\n", orPlaceholder(domain, "未设置")))
|
||
b.WriteString(fmt.Sprintf("- tool name: `%s`\n", toolName))
|
||
b.WriteString(fmt.Sprintf("- tool description: %s\n", toolDesc))
|
||
b.WriteString("- args:\n")
|
||
if len(args) == 0 {
|
||
b.WriteString(" - 无\n")
|
||
}
|
||
for k, v := range args {
|
||
b.WriteString(fmt.Sprintf(" - %s: %s\n", k, v))
|
||
}
|
||
b.WriteString("- kind: browser_script\n")
|
||
b.WriteString(fmt.Sprintf("- command: scripts/%s.js\n\n", toolName))
|
||
b.WriteString("# Blocked-Page Rule\n")
|
||
b.WriteString("- 遇到登录、验证码、权限不足时必须 throw Error,不得返回空数组或空对象。\n")
|
||
b.WriteString("- 明确报错文案,例如:登录/验证码拦截/权限不足。\n\n")
|
||
b.WriteString("# Output Contract\n")
|
||
b.WriteString("- structured-first:返回对象或对象数组,避免 getText 二次采集。\n")
|
||
b.WriteString(fmt.Sprintf("- artifact_fields: %v\n", fields))
|
||
b.WriteString("- superrpa_browser 优先;使用 CSS 选择器,禁止 XPath 与 jQuery :contains\n\n")
|
||
b.WriteString("# Partial/Fallback Rule\n")
|
||
for _, f := range failures {
|
||
b.WriteString(fmt.Sprintf("- %s\n", f))
|
||
}
|
||
b.WriteString("\n# References\n- `references/implementation-notes.md`\n- `assets/notes.md`\n\n")
|
||
b.WriteString(fmt.Sprintf("## 模式\n- preferred_mode: %s\n", mode))
|
||
return b.String() + "\n"
|
||
}
|
||
|
||
func buildReferenceNotes(skillName, toolName string, args map[string]string, fields []string) string {
|
||
var argKeys []string
|
||
for k := range args {
|
||
argKeys = append(argKeys, k)
|
||
}
|
||
return fmt.Sprintf(
|
||
"## Implementation Notes\n\n- skill_name: %s\n- tool: %s\n- args: %s\n- artifact_fields: %s\n- generator: main.go\n- 说明: 保持脚本业务逻辑不变,仅包装与文档产出。\n",
|
||
skillName, toolName, strings.Join(argKeys, ", "), strings.Join(fields, ", "),
|
||
)
|
||
}
|
||
|
||
func buildAssetNotes(skillName, domain, mode string) string {
|
||
return fmt.Sprintf(
|
||
"## Asset Notes\n\n- skill_name: %s\n- expected_domain: %s\n- mode: %s\n- 资源仅使用本地相对文档,不包含远程 markdown 链接。\n",
|
||
skillName,
|
||
orPlaceholder(domain, "未设置"),
|
||
mode,
|
||
)
|
||
}
|
||
|
||
func parseArgSpec(raw string) (map[string]string, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return map[string]string{}, nil
|
||
}
|
||
if strings.HasPrefix(raw, "@") {
|
||
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
|
||
if path == "" {
|
||
return map[string]string{}, nil
|
||
}
|
||
b, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var data map[string]interface{}
|
||
if err := json.Unmarshal(b, &data); err != nil {
|
||
return nil, err
|
||
}
|
||
out := map[string]string{}
|
||
for k, v := range data {
|
||
out[k] = fmt.Sprintf("%v", v)
|
||
}
|
||
return out, nil
|
||
}
|
||
if strings.HasPrefix(raw, "{") && strings.HasSuffix(raw, "}") {
|
||
var parsed map[string]interface{}
|
||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||
return nil, err
|
||
}
|
||
out := map[string]string{}
|
||
for k, v := range parsed {
|
||
out[k] = fmt.Sprintf("%v", v)
|
||
}
|
||
return out, nil
|
||
}
|
||
out := map[string]string{}
|
||
for _, p := range strings.Split(raw, ",") {
|
||
item := strings.TrimSpace(p)
|
||
if item == "" {
|
||
continue
|
||
}
|
||
if eq := strings.Index(item, "="); eq >= 0 {
|
||
k := strings.TrimSpace(item[:eq])
|
||
v := strings.TrimSpace(item[eq+1:])
|
||
if k != "" {
|
||
out[k] = v
|
||
}
|
||
} else if eq := strings.Index(item, ":"); eq >= 0 {
|
||
k := strings.TrimSpace(item[:eq])
|
||
v := strings.TrimSpace(item[eq+1:])
|
||
if k != "" {
|
||
out[k] = orPlaceholder(v, "参数")
|
||
}
|
||
} else {
|
||
out[item] = "参数"
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func parseCommaValues(raw string) []string {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return []string{}
|
||
}
|
||
var out []string
|
||
for _, x := range strings.Split(strings.ReplaceAll(raw, ",", ","), ",") {
|
||
t := strings.TrimSpace(x)
|
||
if t != "" {
|
||
out = append(out, t)
|
||
}
|
||
}
|
||
return dedupeStrings(out)
|
||
}
|
||
|
||
func inferArgsFromScript(script string) map[string]string {
|
||
out := map[string]string{}
|
||
reDirect := regexp.MustCompile(`\bargs\.([A-Za-z_][A-Za-z0-9_]*)\b`)
|
||
for _, g := range reDirect.FindAllStringSubmatch(script, -1) {
|
||
if len(g) >= 2 {
|
||
out[g[1]] = "脚本参数 " + g[1]
|
||
}
|
||
}
|
||
reDestruct := regexp.MustCompile(`\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*\}\s*=\s*args`)
|
||
for _, g := range reDestruct.FindAllStringSubmatch(script, -1) {
|
||
if len(g) >= 2 {
|
||
for _, k := range strings.Split(g[1], ",") {
|
||
kk := strings.TrimSpace(k)
|
||
if kk != "" {
|
||
out[kk] = "脚本参数 " + kk
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func inferArtifactFields(script string) []string {
|
||
keys := []string{"source", "sheet_name", "columns", "rows", "items", "payload", "data"}
|
||
found := []string{}
|
||
s := strings.ToLower(script)
|
||
for _, k := range keys {
|
||
if strings.Contains(s, k) {
|
||
found = append(found, k)
|
||
}
|
||
}
|
||
if !strings.Contains(s, "return") && !strings.Contains(s, "throw") {
|
||
found = append(found, "rows")
|
||
}
|
||
return dedupeStrings(found)
|
||
}
|
||
|
||
func scanRisk(script string) []string {
|
||
s := strings.ToLower(script)
|
||
out := []string{}
|
||
for k, pats := range riskPatterns {
|
||
for _, p := range pats {
|
||
if strings.Contains(s, strings.ToLower(p)) {
|
||
out = append(out, fmt.Sprintf("%s: 检测到可疑片段 %s", k, p))
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if !strings.Contains(s, "return") && !strings.Contains(s, "throw") {
|
||
out = append(out, "脚本未发现明显 return/throw 输出分支,运行后可能无法返回 payload")
|
||
}
|
||
return out
|
||
}
|
||
|
||
func resolveAIConfig(overrides *AiConfig) AiConfig {
|
||
settingsCfg := AiConfig{}
|
||
if settings, ok := readClaudeSettings(); ok {
|
||
settingsCfg.BaseURL = firstNonEmpty(
|
||
getMapString(settings, "base_url"),
|
||
getMapString(settings, "baseUrl"),
|
||
getSectionValue(settings, "env", "ANTHROPIC_BASE_URL"),
|
||
getSectionValue(settings, "envs", "ANTHROPIC_BASE_URL"),
|
||
getSectionValue(settings, "environment", "ANTHROPIC_BASE_URL"),
|
||
)
|
||
settingsCfg.APIKey = firstNonEmpty(
|
||
getMapString(settings, "api_key"),
|
||
getMapString(settings, "apiKey"),
|
||
getSectionValue(settings, "env", "ANTHROPIC_AUTH_TOKEN"),
|
||
getSectionValue(settings, "envs", "ANTHROPIC_AUTH_TOKEN"),
|
||
getSectionValue(settings, "environment", "ANTHROPIC_AUTH_TOKEN"),
|
||
)
|
||
settingsCfg.Model = firstNonEmpty(
|
||
getSectionValue(settings, "env", "ANTHROPIC_DEFAULT_OPUS_MODEL"),
|
||
getSectionValue(settings, "envs", "ANTHROPIC_DEFAULT_OPUS_MODEL"),
|
||
getSectionValue(settings, "environment", "ANTHROPIC_DEFAULT_OPUS_MODEL"),
|
||
getSectionValue(settings, "env", "GLM_MODEL"),
|
||
getSectionValue(settings, "envs", "GLM_MODEL"),
|
||
getSectionValue(settings, "environment", "GLM_MODEL"),
|
||
getMapString(settings, "defaultModel"),
|
||
)
|
||
settingsCfg.TimeoutSecond = firstNonZeroInt(
|
||
parseMillisToSeconds(getSectionValue(settings, "env", "API_TIMEOUT_MS")),
|
||
parseMillisToSeconds(getSectionValue(settings, "envs", "API_TIMEOUT_MS")),
|
||
parseMillisToSeconds(getSectionValue(settings, "environment", "API_TIMEOUT_MS")),
|
||
parseInt(getSectionValue(settings, "env", "GLM_TIMEOUT")),
|
||
parseInt(getSectionValue(settings, "envs", "GLM_TIMEOUT")),
|
||
parseInt(getSectionValue(settings, "environment", "GLM_TIMEOUT")),
|
||
parseInt(getSectionValue(settings, "env", "ANTHROPIC_TIMEOUT")),
|
||
parseInt(getSectionValue(settings, "envs", "ANTHROPIC_TIMEOUT")),
|
||
parseInt(getSectionValue(settings, "environment", "ANTHROPIC_TIMEOUT")),
|
||
)
|
||
}
|
||
|
||
envCfg := AiConfig{
|
||
BaseURL: firstNonEmpty(os.Getenv("GLM_BASE_URL"), os.Getenv("ANTHROPIC_BASE_URL"), os.Getenv("GLM_API_BASE_URL")),
|
||
APIKey: firstNonEmpty(os.Getenv("GLM_API_KEY"), os.Getenv("ANTHROPIC_AUTH_TOKEN"), os.Getenv("OPENAI_API_KEY")),
|
||
Model: firstNonEmpty(os.Getenv("GLM_MODEL"), os.Getenv("ANTHROPIC_DEFAULT_OPUS_MODEL"), os.Getenv("OPENAI_MODEL")),
|
||
TimeoutSecond: firstNonZeroInt(
|
||
parseMillisToSeconds(os.Getenv("API_TIMEOUT_MS")),
|
||
parseInt(os.Getenv("GLM_TIMEOUT")),
|
||
parseInt(os.Getenv("ANTHROPIC_TIMEOUT")),
|
||
parseInt(os.Getenv("OPENAI_TIMEOUT")),
|
||
),
|
||
}
|
||
|
||
cfg := AiConfig{
|
||
BaseURL: firstNonEmpty(settingsCfg.BaseURL, envCfg.BaseURL, defaultBaseURL),
|
||
APIKey: firstNonEmpty(settingsCfg.APIKey, envCfg.APIKey),
|
||
Model: firstNonEmpty(settingsCfg.Model, envCfg.Model, defaultModel),
|
||
TimeoutSecond: firstNonZeroInt(settingsCfg.TimeoutSecond, envCfg.TimeoutSecond, defaultAITimeout),
|
||
}
|
||
cfg.BaseURL = normalizeAIBaseURL(cfg.BaseURL)
|
||
|
||
if overrides != nil {
|
||
cfg.BaseURL = firstNonEmpty(strings.TrimSpace(overrides.BaseURL), cfg.BaseURL)
|
||
cfg.APIKey = firstNonEmpty(strings.TrimSpace(overrides.APIKey), cfg.APIKey)
|
||
cfg.Model = firstNonEmpty(strings.TrimSpace(overrides.Model), cfg.Model)
|
||
if overrides.TimeoutSecond > 0 {
|
||
cfg.TimeoutSecond = overrides.TimeoutSecond
|
||
}
|
||
cfg.BaseURL = normalizeAIBaseURL(cfg.BaseURL)
|
||
}
|
||
cfg.Enabled = cfg.BaseURL != "" && cfg.APIKey != ""
|
||
return cfg
|
||
}
|
||
|
||
func parseAIConfig(raw interface{}) *AiConfig {
|
||
m, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
return nil
|
||
}
|
||
cfg := &AiConfig{}
|
||
if v, ok := m["base_url"].(string); ok {
|
||
cfg.BaseURL = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := m["api_key"].(string); ok {
|
||
cfg.APIKey = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := m["model"].(string); ok {
|
||
cfg.Model = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := m["timeout"]; ok {
|
||
if timeout, ok := toInt(v); ok && timeout > 0 {
|
||
cfg.TimeoutSecond = timeout
|
||
}
|
||
}
|
||
if cfg.BaseURL == "" && cfg.APIKey == "" && cfg.Model == "" && cfg.TimeoutSecond == 0 {
|
||
return nil
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
func readClaudeSettings() (map[string]interface{}, bool) {
|
||
home, err := os.UserHomeDir()
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
p := filepath.Join(home, ".claude", "settings.json")
|
||
raw, err := os.ReadFile(p)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
var data map[string]interface{}
|
||
if err := json.Unmarshal(raw, &data); err != nil {
|
||
return nil, false
|
||
}
|
||
return data, true
|
||
}
|
||
|
||
func getSectionValue(settings map[string]interface{}, section string, key string) string {
|
||
v, ok := settings[section]
|
||
if !ok {
|
||
return ""
|
||
}
|
||
if m, ok := v.(map[string]interface{}); ok {
|
||
if x, ok := m[key]; ok {
|
||
if s, ok := x.(string); ok {
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func getMapString(settings map[string]interface{}, key string) string {
|
||
value, ok := settings[key]
|
||
if !ok {
|
||
return ""
|
||
}
|
||
s, ok := value.(string)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return s
|
||
}
|
||
|
||
func toTOMLString(v string) string {
|
||
return "\"" + strings.ReplaceAll(strings.ReplaceAll(v, "\\", "\\\\"), "\"", "\\\"") + "\""
|
||
}
|
||
|
||
func toTOMLArray(arr []string) string {
|
||
if len(arr) == 0 {
|
||
return "[]"
|
||
}
|
||
parts := make([]string, 0, len(arr))
|
||
for _, a := range arr {
|
||
parts = append(parts, toTOMLString(a))
|
||
}
|
||
return "[" + strings.Join(parts, ", ") + "]"
|
||
}
|
||
|
||
func slugify(s string) string {
|
||
s = strings.ToLower(s)
|
||
re := regexp.MustCompile(`[^a-z0-9_-]+`)
|
||
s = re.ReplaceAllString(s, "-")
|
||
s = strings.Trim(s, "-")
|
||
s = regexp.MustCompile(`-+`).ReplaceAllString(s, "-")
|
||
if s == "" {
|
||
return "skill"
|
||
}
|
||
if len(s) > 56 {
|
||
s = s[:56]
|
||
}
|
||
return s
|
||
}
|
||
|
||
func normalizeToolName(v, fallback string) string {
|
||
v = strings.TrimSpace(strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(v, "-", "_"), " ", "_")))
|
||
re := regexp.MustCompile(`[^a-z0-9_]`)
|
||
v = re.ReplaceAllString(v, "_")
|
||
v = strings.Trim(v, "_")
|
||
if v == "" {
|
||
return fallback
|
||
}
|
||
if v[0] >= '0' && v[0] <= '9' {
|
||
return fallback + "_" + v
|
||
}
|
||
return v
|
||
}
|
||
|
||
func stripDomain(raw string) string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
raw = strings.TrimPrefix(raw, "http://")
|
||
raw = strings.TrimPrefix(raw, "https://")
|
||
if i := strings.Index(raw, "/"); i >= 0 {
|
||
raw = raw[:i]
|
||
}
|
||
return raw
|
||
}
|
||
|
||
func dedupeStrings(arr []string) []string {
|
||
m := map[string]struct{}{}
|
||
out := []string{}
|
||
for _, a := range arr {
|
||
a = strings.TrimSpace(a)
|
||
if a == "" {
|
||
continue
|
||
}
|
||
if _, ok := m[a]; ok {
|
||
continue
|
||
}
|
||
m[a] = struct{}{}
|
||
out = append(out, a)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func deriveDefaultName(skillName, jsPath string) string {
|
||
if skillName != "" {
|
||
return skillName
|
||
}
|
||
if jsPath != "" {
|
||
base := filepath.Base(jsPath)
|
||
return strings.TrimSuffix(base, filepath.Ext(base))
|
||
}
|
||
return "converted-skill"
|
||
}
|
||
|
||
func ensureTrailNewline(s string) string {
|
||
if strings.HasSuffix(s, "\n") {
|
||
return s
|
||
}
|
||
return s + "\n"
|
||
}
|
||
|
||
func orPlaceholder(v, d string) string {
|
||
if strings.TrimSpace(v) == "" {
|
||
return d
|
||
}
|
||
return v
|
||
}
|
||
|
||
func firstNonEmpty(items ...string) string {
|
||
for _, v := range items {
|
||
if strings.TrimSpace(v) != "" {
|
||
return strings.TrimSpace(v)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func firstNonZeroInt(items ...int) int {
|
||
for _, v := range items {
|
||
if v > 0 {
|
||
return v
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func parseInt(v string) int {
|
||
if strings.TrimSpace(v) == "" {
|
||
return 0
|
||
}
|
||
i, err := strconv.Atoi(strings.TrimSpace(v))
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return i
|
||
}
|
||
|
||
func parseMillisToSeconds(v string) int {
|
||
if strings.TrimSpace(v) == "" {
|
||
return 0
|
||
}
|
||
i, err := strconv.Atoi(strings.TrimSpace(v))
|
||
if err != nil || i <= 0 {
|
||
return 0
|
||
}
|
||
if i < 1000 {
|
||
return 1
|
||
}
|
||
return i / 1000
|
||
}
|
||
|
||
func toInt(v interface{}) (int, bool) {
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return int(n), true
|
||
case float32:
|
||
return int(n), true
|
||
case int:
|
||
return n, true
|
||
case int64:
|
||
return int(n), true
|
||
case int32:
|
||
return int(n), true
|
||
case json.Number:
|
||
i, err := n.Int64()
|
||
if err != nil {
|
||
return 0, false
|
||
}
|
||
return int(i), true
|
||
case string:
|
||
if strings.TrimSpace(n) == "" {
|
||
return 0, false
|
||
}
|
||
i, err := strconv.Atoi(strings.TrimSpace(n))
|
||
if err != nil {
|
||
return 0, false
|
||
}
|
||
return i, true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
func keysFromMap(m map[string]string) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func openFolder(path string) error {
|
||
switch runtime.GOOS {
|
||
case "windows":
|
||
cmd := exec.Command("cmd", "/c", "start", "", path)
|
||
return cmd.Run()
|
||
case "darwin":
|
||
return exec.Command("open", path).Run()
|
||
default:
|
||
return exec.Command("xdg-open", path).Run()
|
||
}
|
||
}
|
||
|
||
func strValue(v interface{}) string {
|
||
if v == nil {
|
||
return ""
|
||
}
|
||
if s, ok := v.(string); ok {
|
||
return s
|
||
}
|
||
if num, ok := v.(float64); ok {
|
||
return strconv.FormatFloat(num, 'f', -1, 64)
|
||
}
|
||
return fmt.Sprintf("%v", v)
|
||
}
|
||
|
||
func maskSecret(v string) string {
|
||
v = strings.TrimSpace(v)
|
||
if v == "" {
|
||
return ""
|
||
}
|
||
runes := []rune(v)
|
||
if len(runes) <= 6 {
|
||
return string(runes[:1]) + "***" + string(runes[len(runes)-1:])
|
||
}
|
||
return string(runes[:3]) + "***" + string(runes[len(runes)-3:])
|
||
}
|
||
|
||
func normalizeAIBaseURL(v string) string {
|
||
v = strings.TrimSpace(v)
|
||
if v == "" {
|
||
return v
|
||
}
|
||
if strings.Contains(v, "api.z.ai/api/anthropic") || strings.Contains(v, "docs.z.ai") {
|
||
return "https://api.z.ai/api/coding/paas/v4"
|
||
}
|
||
return strings.TrimRight(v, "/")
|
||
}
|
||
|
||
func hasUsableAIOutput(out *AIGenerated) bool {
|
||
if out == nil {
|
||
return false
|
||
}
|
||
return strings.TrimSpace(out.ToolName) != "" ||
|
||
strings.TrimSpace(out.ToolDescription) != "" ||
|
||
strings.TrimSpace(out.SkillDescription) != "" ||
|
||
strings.TrimSpace(out.SkillToml) != "" ||
|
||
strings.TrimSpace(out.SkillMd) != ""
|
||
}
|
||
|
||
func compactErrorText(raw []byte) string {
|
||
text := strings.TrimSpace(string(raw))
|
||
if text == "" {
|
||
return "empty response"
|
||
}
|
||
if len(text) > 180 {
|
||
return text[:180] + "..."
|
||
}
|
||
return text
|
||
}
|
||
|
||
func parseAIGenerated(raw string) (*AIGenerated, error) {
|
||
var data map[string]interface{}
|
||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||
return nil, err
|
||
}
|
||
out := &AIGenerated{
|
||
ToolName: coerceString(data["tool_name"]),
|
||
ToolDescription: coerceString(data["tool_description"]),
|
||
SkillDescription: coerceString(data["skill_description"]),
|
||
ArtifactFields: coerceStringSlice(data["artifact_fields"]),
|
||
FailureModes: coerceStringSlice(data["failure_modes"]),
|
||
SkillToml: coerceString(data["skill_toml"]),
|
||
SkillMd: coerceString(data["skill_md"]),
|
||
ImplementationNote: coerceString(data["implementation_notes"]),
|
||
AssetNote: coerceString(data["asset_notes"]),
|
||
RiskCheck: coerceStringSlice(data["risk_check"]),
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func coerceString(v interface{}) string {
|
||
switch x := v.(type) {
|
||
case nil:
|
||
return ""
|
||
case string:
|
||
return strings.TrimSpace(x)
|
||
case json.Number:
|
||
return x.String()
|
||
case float64, float32, int, int32, int64, bool:
|
||
return strings.TrimSpace(fmt.Sprintf("%v", x))
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func coerceStringSlice(v interface{}) []string {
|
||
switch x := v.(type) {
|
||
case nil:
|
||
return []string{}
|
||
case string:
|
||
if strings.TrimSpace(x) == "" {
|
||
return []string{}
|
||
}
|
||
return dedupeStrings(parseCommaValues(strings.ReplaceAll(x, "\n", ",")))
|
||
case []interface{}:
|
||
out := make([]string, 0, len(x))
|
||
for _, item := range x {
|
||
switch y := item.(type) {
|
||
case string:
|
||
if s := strings.TrimSpace(y); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
case map[string]interface{}:
|
||
for k := range y {
|
||
if s := strings.TrimSpace(k); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
default:
|
||
if s := coerceString(y); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
}
|
||
return dedupeStrings(out)
|
||
case map[string]interface{}:
|
||
out := make([]string, 0, len(x))
|
||
for k := range x {
|
||
if s := strings.TrimSpace(k); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return dedupeStrings(out)
|
||
default:
|
||
if s := coerceString(x); s != "" {
|
||
return []string{s}
|
||
}
|
||
return []string{}
|
||
}
|
||
}
|
||
|
||
func rewriteTomlCommand(tomlContent string, commandPath string) string {
|
||
lines := strings.Split(tomlContent, "\n")
|
||
replaced := false
|
||
for i, line := range lines {
|
||
trimmed := strings.TrimSpace(line)
|
||
if strings.HasPrefix(trimmed, "command = ") {
|
||
lines[i] = fmt.Sprintf(`command = "%s"`, commandPath)
|
||
replaced = true
|
||
break
|
||
}
|
||
}
|
||
if !replaced {
|
||
return tomlContent
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func rewriteMarkdownCommand(mdContent string, commandPath string) string {
|
||
re := regexp.MustCompile(`(?m)^- command: .*$`)
|
||
if re.MatchString(mdContent) {
|
||
return re.ReplaceAllString(mdContent, fmt.Sprintf("- command: %s", commandPath))
|
||
}
|
||
return mdContent
|
||
}
|
||
|
||
func rewriteMarkdownToolName(mdContent string, toolName string) string {
|
||
re := regexp.MustCompile("(?m)^- tool name: `.*`$")
|
||
if re.MatchString(mdContent) {
|
||
return re.ReplaceAllString(mdContent, fmt.Sprintf("- tool name: `%s`", toolName))
|
||
}
|
||
return mdContent
|
||
}
|