Files

573 lines
14 KiB
Go

package agentcli
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
const (
defaultConfigBase = ".dify_conf"
configPullConcurrencyLimit = 4
)
type ConfigFileRef struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
func runConfigPulls(count int, task func(index int) error) error {
errs := make([]error, count)
semaphore := make(chan struct{}, configPullConcurrencyLimit)
var waitGroup sync.WaitGroup
for index := 0; index < count; index++ {
semaphore <- struct{}{}
waitGroup.Go(func() {
defer func() { <-semaphore }()
errs[index] = task(index)
})
}
waitGroup.Wait()
return errors.Join(errs...)
}
// RunConfigManifest executes the `config manifest` command.
func RunConfigManifest(env *Environment) error {
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
body, err := client.GetConfigManifest(context.Background())
if err != nil {
return err
}
fmt.Println(string(body))
return nil
}
// RunConfigSkillsPull executes the `config skills pull` command.
func RunConfigSkillsPull(env *Environment, names []string, localDir string, jsonOutput bool) error {
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
ctx := context.Background()
// Get manifest to find available skills if no names specified
if len(names) == 0 {
body, err := client.GetConfigManifest(ctx)
if err != nil {
return err
}
var manifest struct {
Skills struct {
Items []struct {
Name string `json:"name"`
} `json:"items"`
} `json:"skills"`
}
if err := json.Unmarshal(body, &manifest); err != nil {
return fmt.Errorf("parse manifest: %w", err)
}
for _, item := range manifest.Skills.Items {
names = append(names, item.Name)
}
}
targetDir := localDir
if targetDir == "" {
targetDir = filepath.Join(defaultConfigBase, "skills")
}
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("create target directory: %w", err)
}
type pullItem struct {
Name string `json:"name"`
ArchivePath string `json:"archive_path"`
DirectoryPath string `json:"directory_path"`
SkillMD string `json:"skill_md"`
}
var items []pullItem
if len(names) > 0 {
items = make([]pullItem, len(names))
}
err = runConfigPulls(len(names), func(index int) error {
name := names[index]
download, err := client.CreateConfigDownloadURL(ctx, "skill", name)
if err != nil {
return fmt.Errorf("request config skill %q download URL: %w", name, err)
}
archiveBytes, err := client.DownloadFromURL(download.DownloadURL)
if err != nil {
return fmt.Errorf("download config skill %q: %w", name, err)
}
archivePath := filepath.Join(targetDir, name+".zip")
skillDir := filepath.Join(targetDir, name)
if err := os.MkdirAll(skillDir, 0o755); err != nil {
return fmt.Errorf("create skill directory: %w", err)
}
if err := os.WriteFile(archivePath, archiveBytes, 0o644); err != nil {
return fmt.Errorf("write archive: %w", err)
}
// Extract archive
if err := extractZipArchive(archivePath, skillDir); err != nil {
return fmt.Errorf("extract skill archive: %w", err)
}
skillMDPath := filepath.Join(skillDir, "SKILL.md")
skillMD := ""
if data, err := os.ReadFile(skillMDPath); err == nil {
skillMD = string(data)
}
items[index] = pullItem{
Name: name,
ArchivePath: archivePath,
DirectoryPath: skillDir,
SkillMD: skillMD,
}
return nil
})
if err != nil {
return err
}
if jsonOutput {
out, _ := json.Marshal(map[string]any{"items": items})
fmt.Println(string(out))
return nil
}
for i, item := range items {
if i > 0 {
fmt.Println()
}
fmt.Println(item.DirectoryPath)
fmt.Print(item.SkillMD)
}
return nil
}
// RunConfigFilesPull executes the `config files pull` command.
func RunConfigFilesPull(env *Environment, names []string, localDir string, jsonOutput bool) error {
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
ctx := context.Background()
if len(names) == 0 {
body, err := client.GetConfigManifest(ctx)
if err != nil {
return err
}
var manifest struct {
Files struct {
Items []struct {
Name string `json:"name"`
} `json:"items"`
} `json:"files"`
}
if err := json.Unmarshal(body, &manifest); err != nil {
return fmt.Errorf("parse manifest: %w", err)
}
for _, item := range manifest.Files.Items {
names = append(names, item.Name)
}
}
targetDir := localDir
if targetDir == "" {
targetDir = filepath.Join(defaultConfigBase, "files")
}
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("create target directory: %w", err)
}
type fileItem struct {
Name string `json:"name"`
Path string `json:"path"`
}
var items []fileItem
if len(names) > 0 {
items = make([]fileItem, len(names))
}
err = runConfigPulls(len(names), func(index int) error {
name := names[index]
download, err := client.CreateConfigDownloadURL(ctx, "file", name)
if err != nil {
return fmt.Errorf("request config file %q download URL: %w", name, err)
}
payload, err := client.DownloadFromURL(download.DownloadURL)
if err != nil {
return fmt.Errorf("download config file %q: %w", name, err)
}
targetPath := filepath.Join(targetDir, name)
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("create parent directory: %w", err)
}
if err := os.WriteFile(targetPath, payload, 0o644); err != nil {
return fmt.Errorf("write file: %w", err)
}
items[index] = fileItem{Name: name, Path: targetPath}
return nil
})
if err != nil {
return err
}
if jsonOutput {
out, _ := json.Marshal(map[string]any{"items": items})
fmt.Println(string(out))
return nil
}
for _, item := range items {
fmt.Println(item.Path)
}
return nil
}
// RunConfigSkillsPush executes the `config skills push` command.
func RunConfigSkillsPush(env *Environment, paths []string) error {
if len(paths) == 0 {
return fmt.Errorf("at least one skill directory is required")
}
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
type skillPushItem struct {
Name string `json:"name"`
FileRef *ConfigFileRef `json:"file_ref"`
}
var skills []skillPushItem
for _, path := range paths {
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("resolve path: %w", err)
}
info, err := os.Stat(absPath)
if err != nil || !info.IsDir() {
return fmt.Errorf("config skill path must be a directory: %s", absPath)
}
skillMDPath := filepath.Join(absPath, "SKILL.md")
if _, err := os.Stat(skillMDPath); os.IsNotExist(err) {
return fmt.Errorf("config skill directory must contain SKILL.md: %s", absPath)
}
// Build archive and upload
archivePath, err := buildSkillArchive(absPath)
if err != nil {
return err
}
defer func() { _ = os.Remove(archivePath) }()
name := filepath.Base(absPath)
fileRef, err := uploadConfigFile(client, archivePath)
if err != nil {
return fmt.Errorf("upload config skill %q: %w", name, err)
}
skills = append(skills, skillPushItem{
Name: name,
FileRef: fileRef,
})
}
payload := map[string]any{
"skills": skills,
"files": []any{},
}
body, err := client.PushConfig(context.Background(), payload)
if err != nil {
return fmt.Errorf("push config skills: %w", err)
}
fmt.Println(string(body))
return nil
}
// RunConfigFilesPush executes the `config files push` command.
func RunConfigFilesPush(env *Environment, paths []string) error {
if len(paths) == 0 {
return fmt.Errorf("at least one file path is required")
}
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
type filePushItem struct {
Name string `json:"name"`
FileRef *ConfigFileRef `json:"file_ref"`
}
var files []filePushItem
for _, path := range paths {
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("resolve path: %w", err)
}
info, err := os.Stat(absPath)
if err != nil || info.IsDir() {
return fmt.Errorf("config file path must be a regular file: %s", absPath)
}
name := filepath.Base(absPath)
fileRef, err := uploadConfigFile(client, absPath)
if err != nil {
return fmt.Errorf("upload config file %q: %w", name, err)
}
files = append(files, filePushItem{
Name: name,
FileRef: fileRef,
})
}
payload := map[string]any{
"files": files,
"skills": []any{},
}
body, err := client.PushConfig(context.Background(), payload)
if err != nil {
return fmt.Errorf("push config files: %w", err)
}
fmt.Println(string(body))
return nil
}
func uploadConfigFile(client StubClient, filePath string) (*ConfigFileRef, error) {
filename := filepath.Base(filePath)
mimetype := guessMIMEType(filename)
uploadURL, err := client.CreateToolFileUploadURL(context.Background(), filename, mimetype)
if err != nil {
return nil, fmt.Errorf("request upload URL: %w", err)
}
uploadBody, err := client.UploadFileToURL(uploadURL, filePath, filename, mimetype)
if err != nil {
return nil, fmt.Errorf("upload data: %w", err)
}
var uploadResult struct {
ID string `json:"id"`
}
if err := json.Unmarshal(uploadBody, &uploadResult); err != nil {
return nil, fmt.Errorf("parse upload result: %w", err)
}
if uploadResult.ID == "" {
return nil, fmt.Errorf("upload response is missing id")
}
return &ConfigFileRef{Kind: "tool_file", ID: uploadResult.ID}, nil
}
// RunConfigSkillsDelete executes the `config skills delete` command.
func RunConfigSkillsDelete(env *Environment, names []string) error {
if len(names) == 0 {
return fmt.Errorf("at least one skill name is required")
}
type skillDeleteItem struct {
Name string `json:"name"`
FileRef *any `json:"file_ref"`
}
var skills []skillDeleteItem
for _, name := range names {
skills = append(skills, skillDeleteItem{Name: name, FileRef: nil})
}
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
payload := map[string]any{
"skills": skills,
"files": []any{},
}
body, err := client.PushConfig(context.Background(), payload)
if err != nil {
return err
}
fmt.Println(string(body))
return nil
}
// RunConfigFilesDelete executes the `config files delete` command.
func RunConfigFilesDelete(env *Environment, names []string) error {
if len(names) == 0 {
return fmt.Errorf("at least one file name is required")
}
type fileDeleteItem struct {
Name string `json:"name"`
FileRef *any `json:"file_ref"`
}
var files []fileDeleteItem
for _, name := range names {
files = append(files, fileDeleteItem{Name: name, FileRef: nil})
}
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
payload := map[string]any{
"files": files,
"skills": []any{},
}
body, err := client.PushConfig(context.Background(), payload)
if err != nil {
return err
}
fmt.Println(string(body))
return nil
}
// RunConfigEnvPush executes the `config env push` command.
func RunConfigEnvPush(env *Environment, localPath string) error {
var envText string
if localPath == "-" {
data, err := os.ReadFile("/dev/stdin")
if err != nil {
return fmt.Errorf("read stdin: %w", err)
}
envText = string(data)
} else {
absPath, err := filepath.Abs(localPath)
if err != nil {
return fmt.Errorf("resolve path: %w", err)
}
data, err := os.ReadFile(absPath)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
envText = string(data)
}
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
body, err := client.PatchConfigEnv(context.Background(), envText)
if err != nil {
return err
}
fmt.Println(string(body))
return nil
}
// RunConfigNotePull executes the `config note pull` command.
func RunConfigNotePull(env *Environment, localPath string) error {
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
body, err := client.GetConfigManifest(context.Background())
if err != nil {
return err
}
var manifest struct {
Note string `json:"note"`
}
if err := json.Unmarshal(body, &manifest); err != nil {
return fmt.Errorf("parse manifest: %w", err)
}
targetPath := localPath
if targetPath == "" {
targetPath = filepath.Join(defaultConfigBase, "note.md")
}
absPath, err := filepath.Abs(targetPath)
if err != nil {
return fmt.Errorf("resolve path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
if err := os.WriteFile(absPath, []byte(manifest.Note), 0o644); err != nil {
return fmt.Errorf("write note: %w", err)
}
fmt.Println(absPath)
return nil
}
// RunConfigNotePush executes the `config note push` command.
func RunConfigNotePush(env *Environment, localPath string) error {
var note string
if localPath == "-" {
data, err := os.ReadFile("/dev/stdin")
if err != nil {
return fmt.Errorf("read stdin: %w", err)
}
note = string(data)
} else {
targetPath := localPath
if targetPath == "" {
targetPath = filepath.Join(defaultConfigBase, "note.md")
}
absPath, err := filepath.Abs(targetPath)
if err != nil {
return fmt.Errorf("resolve path: %w", err)
}
data, err := os.ReadFile(absPath)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
note = string(data)
}
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
body, err := client.PutConfigNote(context.Background(), note)
if err != nil {
return err
}
fmt.Println(string(body))
return nil
}
// extractZipArchive extracts a zip file into targetDir.
func extractZipArchive(archivePath string, targetDir string) error {
return extractZip(archivePath, targetDir)
}