From fda06d058248bd766bc24cbda0abef420bab60f7 Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 16 Sep 2026 09:16:53 -0400 Subject: [PATCH] refactor(sdk): separate responsibilities and share golden harness Signed-off-by: samzong --- .github/workflows/check.yml | 3 + CONTRIBUTING.md | 6 + Makefile | 2 +- docs/architecture.mmd | 10 +- go-cobra/skill.go | 77 +- go/bundle.go | 283 ++++ go/github.go | 124 ++ go/hosts.go | 406 ++++++ go/install.go | 194 +++ go/kitup.go | 1933 --------------------------- go/storage.go | 247 ++++ go/types.go | 226 ++++ go/workflow.go | 385 ++++++ python/src/kitup/__init__.py | 3 +- python/src/kitup/_metadata.py | 98 +- python/src/kitup/bundle.py | 47 +- python/src/kitup/hosts.py | 146 +- python/src/kitup/install.py | 479 +------ python/src/kitup/workflow.py | 273 ++-- python/tests/conftest.py | 63 + python/tests/golden_test.py | 703 ++-------- python/tests/test_bundle.py | 21 +- python/tests/test_hosts.py | 84 +- python/tests/test_install.py | 131 +- python/tests/test_workflow.py | 214 +-- rust/src/bundle.rs | 308 +++++ rust/src/github.rs | 155 +++ rust/src/hosts.rs | 409 ++++++ rust/src/install.rs | 334 +++++ rust/src/lib.rs | 2272 +------------------------------- rust/src/metadata.rs | 95 ++ rust/src/types.rs | 279 ++++ rust/src/workflow.rs | 423 ++++++ rust/tests/golden.rs | 1084 ++++----------- scripts/check.mjs | 2 + scripts/golden.d.mts | 4 + scripts/golden.mjs | 332 +++++ tests/go-golden/golden_test.go | 665 ++-------- ts/src/bundle.ts | 319 +++++ ts/src/hosts.ts | 277 ++++ ts/src/index.ts | 2025 +--------------------------- ts/src/installer.ts | 206 +++ ts/src/storage.ts | 262 ++++ ts/src/types.ts | 243 ++++ ts/src/workflow.ts | 468 +++++++ ts/test/golden.test.ts | 611 ++------- 46 files changed, 7223 insertions(+), 9708 deletions(-) create mode 100644 go/bundle.go create mode 100644 go/github.go create mode 100644 go/hosts.go create mode 100644 go/install.go delete mode 100644 go/kitup.go create mode 100644 go/storage.go create mode 100644 go/types.go create mode 100644 go/workflow.go create mode 100644 python/tests/conftest.py create mode 100644 rust/src/bundle.rs create mode 100644 rust/src/github.rs create mode 100644 rust/src/hosts.rs create mode 100644 rust/src/install.rs create mode 100644 rust/src/metadata.rs create mode 100644 rust/src/types.rs create mode 100644 rust/src/workflow.rs create mode 100644 scripts/golden.d.mts create mode 100644 scripts/golden.mjs create mode 100644 ts/src/bundle.ts create mode 100644 ts/src/hosts.ts create mode 100644 ts/src/installer.ts create mode 100644 ts/src/storage.ts create mode 100644 ts/src/types.ts create mode 100644 ts/src/workflow.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 71119b5..419b950 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -69,6 +69,9 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5f79d4..8cfd741 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,8 @@ scripts/sync-hosts.mjs generated host constants TypeScript, Go, Rust, and Python SDKs live in `ts/`, `go/`, `rust/`, and `python/`. +Each SDK separates bundle loading, host resolution, installation, and CLI workflow. Public entry points stay at the package root. Start with the workflow for selection and confirmation, the installer for lifecycle decisions, and storage or metadata for ownership and filesystem writes. See [the architecture map](docs/architecture.mmd) for the data flow. + ## Common Commands ```bash @@ -61,6 +63,10 @@ make check This validates the shared spec, fixtures, generated host constants, standalone Go modules, TypeScript, Go, Rust, Python, and examples. +`scripts/golden.mjs` owns golden fixture preparation, the local GitHub server, and result and filesystem assertions. Each language's golden test invokes its native SDK and returns structured results. These repository tests require Node.js 24; the published Go, Rust, and Python SDKs do not require Node.js. + +The harness retains each SDK's existing hash order: TypeScript uses `localeCompare`, while Go, Rust, and Python use lexical order. It verifies each implementation against that order; this does not establish identical hashes across languages. + ## Host Adapter Changes Host support is data-first. diff --git a/Makefile b/Makefile index 8081d6c..44ea0c1 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ test-python: ## Run Python SDK tests fmt: fmt-ts fmt-go fmt-rust fmt-python ## Format all SDK code fmt-ts: ## Format TypeScript code - cd $(TS_DIR) && pnpm exec prettier --write src test ../examples/ts/cli.ts ../scripts/check.mjs ../scripts/check-go-modules.mjs ../scripts/prepare-release.mjs + cd $(TS_DIR) && pnpm exec prettier --write src test ../examples/ts/cli.ts ../scripts/check.mjs ../scripts/check-go-modules.mjs ../scripts/prepare-release.mjs ../scripts/golden.mjs ../scripts/golden.d.mts fmt-go: ## Format Go code gofmt -w $(GO_FILES) diff --git a/docs/architecture.mmd b/docs/architecture.mmd index da74e1a..7115ba5 100644 --- a/docs/architecture.mmd +++ b/docs/architecture.mmd @@ -14,7 +14,8 @@ flowchart TB HOSTSPEC["Host Spec\nspec/hosts.json"]:::contract SCHEMAS["Schemas\nspec/hosts.schema.json\ntestdata/cases.schema.json"]:::contract CASES["Golden Cases\ntestdata/cases/*.json"]:::contract - GOGOLDEN["Go Golden Harness\ntests/go-golden"]:::artifact + GOLDEN["Shared Golden Harness\nscripts/golden.mjs\nfixtures · local GitHub server · assertions"]:::control + ADAPTERS["Native Test Adapters\nTypeScript · Go · Rust · Python"]:::artifact GENHOSTS["Generated Host Constants\nts / go / rust / python"]:::artifact VERIFY["Verification\ncheck.mjs · sync-hosts.mjs · standalone Go modules"]:::control GITHUB["GitHub API"]:::external @@ -39,8 +40,11 @@ flowchart TB HOSTSPEC -.->|"generates"| GENHOSTS VERIFY -.-> HOSTSPEC VERIFY -.->|"checks parity"| CASES - VERIFY -.-> GOGOLDEN - CASES -.-> GOGOLDEN + VERIFY -.-> GOLDEN + CASES -.-> GOLDEN + GOLDEN -->|"prepared cases"| ADAPTERS + ADAPTERS -->|"native SDK calls"| SDK + ADAPTERS -->|"structured results"| GOLDEN VERIFY -.-> GENHOSTS classDef actor fill:#1f2937,stroke:#94a3b8,color:#f8fafc,stroke-width:1px; diff --git a/go-cobra/skill.go b/go-cobra/skill.go index bf0a28f..9b28934 100644 --- a/go-cobra/skill.go +++ b/go-cobra/skill.go @@ -91,6 +91,13 @@ func NewUninstallCommand(opts Options) *cobra.Command { Short: "Uninstall bundled Agent Skill", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { + emit := func(report kitup.UninstallReport) error { + if jsonOutput { + return writeJSON(output(cmd, opts), report) + } + renderUninstallReport(output(cmd, opts), report) + return nil + } parsed := kitup.ParseInstallFlags(kitup.InstallFlagValues{Scope: scope, ScopeSet: true, Agents: agents, Yes: yes}) if err := kitup.InstallFlagError(parsed.Errors); err != nil { return err @@ -115,22 +122,13 @@ func NewUninstallCommand(opts Options) *cobra.Command { } if len(status.Conflicts)+len(status.Errors) > 0 { report := uninstallReportFromStatus(status) - if jsonOutput { - if err := writeJSON(output(cmd, opts), report); err != nil { - return err - } - } else { - renderUninstallReport(output(cmd, opts), report) + if err := emit(report); err != nil { + return err } return errors.New("kitup: uninstall has conflicts") } if len(status.Installed) == 0 { - report := uninstallReportFromStatus(status) - if jsonOutput { - return writeJSON(output(cmd, opts), report) - } - renderUninstallReport(output(cmd, opts), report) - return nil + return emit(uninstallReportFromStatus(status)) } promptOut := output(cmd, opts) if jsonOutput { @@ -162,12 +160,8 @@ func NewUninstallCommand(opts Options) *cobra.Command { if err != nil { return err } - if jsonOutput { - if err := writeJSON(output(cmd, opts), report); err != nil { - return err - } - } else { - renderUninstallReport(output(cmd, opts), report) + if err := emit(report); err != nil { + return err } if len(report.Conflicts)+len(report.Errors) > 0 { return errors.New("kitup: uninstall failed") @@ -183,35 +177,21 @@ func NewUninstallCommand(opts Options) *cobra.Command { } func NewInstallCommand(opts Options) *cobra.Command { - scope := "" - var agents []string - var yes bool - var dryRun bool - var force bool + flags := kitup.InstallFlagValues{} cmd := &cobra.Command{ Use: kitup.InstallUX.InstallUse, Short: kitup.InstallUX.InstallShort, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - parsed := kitup.ParseInstallFlags(kitup.InstallFlagValues{ - Scope: scope, - ScopeSet: cmd.Flags().Changed("scope"), - Agents: agents, - Yes: yes, - DryRun: dryRun, - Force: force, - }) + flags.ScopeSet = cmd.Flags().Changed("scope") + parsed := kitup.ParseInstallFlags(flags) if err := kitup.InstallFlagError(parsed.Errors); err != nil { return err } report, err := kitup.RunBundledSkillInstall(kitup.InstallWorkflowOptions{ InstallOptions: kitup.InstallOptions{ - BaseOptions: kitup.BaseOptions{ - Home: opts.Home, - CWD: opts.CWD, - HostsFile: opts.HostsFile, - }, + BaseOptions: baseOptions(opts), AppID: opts.AppID, SkillBundle: opts.Bundle, Scope: parsed.Scope, @@ -235,11 +215,11 @@ func NewInstallCommand(opts Options) *cobra.Command { return kitup.InstallWorkflowError(report) }, } - cmd.Flags().StringVar(&scope, "scope", scope, kitup.InstallUX.ScopeFlag) - cmd.Flags().StringArrayVar(&agents, "agent", nil, kitup.InstallUX.AgentFlag) - cmd.Flags().BoolVar(&dryRun, "dry-run", false, kitup.InstallUX.DryRunFlag) - cmd.Flags().BoolVarP(&yes, "yes", "y", false, kitup.InstallUX.YesFlag) - cmd.Flags().BoolVar(&force, "force", false, kitup.InstallUX.ForceFlag) + cmd.Flags().StringVar(&flags.Scope, "scope", "", kitup.InstallUX.ScopeFlag) + cmd.Flags().StringArrayVar(&flags.Agents, "agent", nil, kitup.InstallUX.AgentFlag) + cmd.Flags().BoolVar(&flags.DryRun, "dry-run", false, kitup.InstallUX.DryRunFlag) + cmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, kitup.InstallUX.YesFlag) + cmd.Flags().BoolVar(&flags.Force, "force", false, kitup.InstallUX.ForceFlag) return cmd } @@ -332,12 +312,7 @@ func renderStatusReport(out io.Writer, report kitup.StatusReport) { for _, item := range report.Missing { _, _ = fmt.Fprintf(out, "missing\t%s\t%s\n", targetHosts(item), item.TargetDir) } - for _, item := range report.Conflicts { - _, _ = fmt.Fprintf(out, "conflict\t%s\t%s\t%s\n", targetHosts(item.TargetResult), item.TargetDir, item.Reason) - } - for _, item := range report.Errors { - _, _ = fmt.Fprintf(out, "error\t%s\n", item.Reason) - } + renderProblems(out, report.Conflicts, report.Errors) } func renderUninstallReport(out io.Writer, report kitup.UninstallReport) { @@ -347,10 +322,14 @@ func renderUninstallReport(out io.Writer, report kitup.UninstallReport) { for _, item := range report.Skipped { _, _ = fmt.Fprintf(out, "skipped\t%s\t%s\t%s\n", targetHosts(item.TargetResult), item.TargetDir, item.Reason) } - for _, item := range report.Conflicts { + renderProblems(out, report.Conflicts, report.Errors) +} + +func renderProblems(out io.Writer, conflicts []kitup.TargetStatus, errors []kitup.ReportError) { + for _, item := range conflicts { _, _ = fmt.Fprintf(out, "conflict\t%s\t%s\t%s\n", targetHosts(item.TargetResult), item.TargetDir, item.Reason) } - for _, item := range report.Errors { + for _, item := range errors { _, _ = fmt.Fprintf(out, "error\t%s\n", item.Reason) } } diff --git a/go/bundle.go b/go/bundle.go new file mode 100644 index 0000000..5d0b8b5 --- /dev/null +++ b/go/bundle.go @@ -0,0 +1,283 @@ +package kitup + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "maps" + "os" + "regexp" + "sort" + "strings" +) + +func DirectoryBundle(dir string) SkillBundle { + return SkillBundle{kind: "directory", dir: dir} +} + +func FSBundle(fsys fs.FS, root string) SkillBundle { + return SkillBundle{kind: "fs", fsys: fsys, root: root} +} + +func FilesBundle(files []SkillFile) SkillBundle { + return SkillBundle{kind: "files", files: files} +} + +func GitHubBundle(opts GitHubBundleOptions) SkillBundle { + return SkillBundle{kind: "github", github: opts} +} + +func WithBundleMetadata(bundle SkillBundle, meta BundledSkillMetadata) SkillBundle { + meta.Provenance = maps.Clone(meta.Provenance) + bundle.meta = meta + bundle.metaSet = true + return bundle +} + +type bundleMetadata struct { + Source string + SourceID string + Version string + CLIVersion string + CLIRevision string + Provenance map[string]string + Explicit bool +} + +type normalizedSkillBundle struct { + Files []SkillFile + ByPath map[string]SkillFile +} + +var skillNamePattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +func isValidSkillName(skillName string) bool { + return skillNamePattern.MatchString(skillName) +} + +func ValidateSkillBundle(bundle SkillBundle) SkillInfo { + normalized, err := readSkillBundle(bundle) + if err != nil { + return SkillInfo{Valid: false, ErrorCode: "invalid-skill-bundle"} + } + return validateNormalizedSkill(normalized) +} + +func validateNormalizedSkill(bundle normalizedSkillBundle) SkillInfo { + file, ok := bundle.ByPath["SKILL.md"] + if !ok { + return SkillInfo{Valid: false, ErrorCode: "missing-skill-md"} + } + text := string(file.Contents) + if !strings.HasPrefix(text, "---\n") { + return SkillInfo{Valid: false, ErrorCode: "invalid-frontmatter"} + } + end := strings.Index(text[4:], "\n---\n") + if end < 0 { + return SkillInfo{Valid: false, ErrorCode: "invalid-frontmatter"} + } + fields := parseFrontmatter(text[4 : 4+end]) + name := fields["name"] + description := fields["description"] + if !isValidSkillName(name) || len(description) < 1 || len(description) > 1024 { + return SkillInfo{Valid: false, ErrorCode: "invalid-frontmatter"} + } + return SkillInfo{Valid: true, SkillName: name, Description: description} +} + +func ComputeBundleContentHash(bundle SkillBundle) (string, error) { + normalized, err := readSkillBundle(bundle) + if err != nil { + return "", err + } + return contentHash(normalized), nil +} + +func contentHash(bundle normalizedSkillBundle) string { + hash := sha256.New() + for _, file := range bundle.Files { + hash.Write([]byte(file.Path)) + hash.Write([]byte{0}) + hash.Write(file.Contents) + hash.Write([]byte{0}) + } + return "sha256:" + hex.EncodeToString(hash.Sum(nil)) +} + +func parseFrontmatter(content string) map[string]string { + fields := map[string]string{} + for _, line := range strings.Split(content, "\n") { + before, after, ok := strings.Cut(line, ":") + if ok { + fields[before] = strings.TrimSpace(after) + } + } + return fields +} + +func resolveSkillBundle(bundle SkillBundle) (normalizedSkillBundle, bundleMetadata, error) { + var normalized normalizedSkillBundle + var meta bundleMetadata + var err error + switch bundle.kind { + case "github": + normalized, meta, err = resolveGitHubBundle(bundle.github) + default: + normalized, err = readSkillBundle(bundle) + meta = bundleMetadata{Source: "bundled"} + } + if err != nil { + return normalizedSkillBundle{}, bundleMetadata{}, err + } + if bundle.meta.SourceID != "" { + meta.SourceID = bundle.meta.SourceID + } + meta.CLIVersion = bundle.meta.CLIVersion + meta.CLIRevision = bundle.meta.CLIRevision + if len(bundle.meta.Provenance) > 0 { + meta.Provenance = maps.Clone(meta.Provenance) + if meta.Provenance == nil { + meta.Provenance = map[string]string{} + } + maps.Copy(meta.Provenance, bundle.meta.Provenance) + } + meta.Explicit = bundle.metaSet + return normalized, meta, nil +} + +func readSkillBundle(bundle SkillBundle) (normalizedSkillBundle, error) { + var files []SkillFile + var err error + switch bundle.kind { + case "directory": + files, err = readDirectoryBundleFiles(bundle.dir) + case "fs": + files, err = readFSBundleFiles(bundle.fsys, bundle.root) + case "files": + files = bundle.files + case "github": + normalized, _, err := resolveGitHubBundle(bundle.github) + return normalized, err + default: + return normalizedSkillBundle{}, errors.New("missing skill bundle") + } + if err != nil { + return normalizedSkillBundle{}, err + } + return normalizeSkillFiles(files) +} + +func readDirectoryBundleFiles(root string) ([]SkillFile, error) { + info, err := os.Lstat(root) + if err != nil || !info.IsDir() { + return nil, err + } + return walkBundleFiles(os.DirFS(root), ".", false) +} + +func readFSBundleFiles(fsys fs.FS, root string) ([]SkillFile, error) { + if fsys == nil { + return nil, errors.New("missing skill fs") + } + root = strings.Trim(root, "/") + if root == "" { + root = "." + } + if !fs.ValidPath(root) { + return nil, errors.New("invalid skill fs root") + } + return walkBundleFiles(fsys, root, true) +} + +func walkBundleFiles(fsys fs.FS, root string, embedded bool) ([]SkillFile, error) { + var files []SkillFile + err := fs.WalkDir(fsys, root, func(path string, entry fs.DirEntry, err error) error { + if err != nil || path == root { + return err + } + if skipName(entry.Name()) { + if entry.IsDir() { + return fs.SkipDir + } + return nil + } + info, err := entry.Info() + if err != nil || !info.Mode().IsRegular() { + return err + } + rel := strings.TrimPrefix(path, root+"/") + contents, err := fs.ReadFile(fsys, path) + if err != nil { + return err + } + mode := info.Mode().Perm() + if embedded && mode == 0o444 { + mode = defaultBundleFileMode(rel) + } + files = append(files, SkillFile{Path: rel, Contents: contents, Mode: mode}) + return nil + }) + return files, err +} + +func normalizeSkillFiles(files []SkillFile) (normalizedSkillBundle, error) { + byPath := map[string]SkillFile{} + for _, file := range files { + normalizedPath, include, err := normalizeBundlePath(file.Path) + if err != nil { + return normalizedSkillBundle{}, err + } + if !include { + continue + } + if _, ok := byPath[normalizedPath]; ok { + return normalizedSkillBundle{}, errors.New("duplicate skill file: " + normalizedPath) + } + mode := file.Mode.Perm() + if mode == 0 { + mode = defaultBundleFileMode(normalizedPath) + } + byPath[normalizedPath] = SkillFile{Path: normalizedPath, Contents: file.Contents, Mode: mode} + } + paths := make([]string, 0, len(byPath)) + for path := range byPath { + paths = append(paths, path) + } + sort.Strings(paths) + normalized := normalizedSkillBundle{Files: make([]SkillFile, 0, len(paths)), ByPath: byPath} + for _, path := range paths { + normalized.Files = append(normalized.Files, byPath[path]) + } + return normalized, nil +} + +func defaultBundleFileMode(path string) fs.FileMode { + if strings.HasPrefix(path, "scripts/") { + return 0o755 + } + return 0o644 +} + +func normalizeBundlePath(value string) (string, bool, error) { + if value == "" || strings.Contains(value, "\\") || strings.HasPrefix(value, "/") { + return "", false, errors.New("invalid skill file path: " + value) + } + if len(value) > 1 && value[1] == ':' { + return "", false, errors.New("invalid skill file path: " + value) + } + parts := strings.Split(value, "/") + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return "", false, errors.New("invalid skill file path: " + value) + } + if skipName(part) { + return "", false, nil + } + } + return value, true, nil +} + +func skipName(name string) bool { + return name == ".git" || name == ".kitup.json" || name == ".DS_Store" || strings.HasSuffix(name, ".swp") || strings.HasSuffix(name, "~") +} diff --git a/go/github.go b/go/github.go new file mode 100644 index 0000000..13c5cc9 --- /dev/null +++ b/go/github.go @@ -0,0 +1,124 @@ +package kitup + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +func resolveGitHubBundle(opts GitHubBundleOptions) (normalizedSkillBundle, bundleMetadata, error) { + root := strings.Trim(opts.Path, "/") + if opts.Owner == "" || opts.Repo == "" || root == "" || opts.Ref == "" { + return normalizedSkillBundle{}, bundleMetadata{}, errors.New("invalid github bundle") + } + apiBase := envBaseURL("KITUP_GITHUB_API_BASE_URL", "https://api.github.com") + rawBase := envBaseURL("KITUP_GITHUB_RAW_BASE_URL", "https://raw.githubusercontent.com") + var commit struct { + Sha string `json:"sha"` + Commit struct { + Tree struct { + Sha string `json:"sha"` + } `json:"tree"` + } `json:"commit"` + } + if err := getJSON(apiBase+"/repos/"+url.PathEscape(opts.Owner)+"/"+url.PathEscape(opts.Repo)+"/commits/"+url.PathEscape(opts.Ref), &commit); err != nil { + return normalizedSkillBundle{}, bundleMetadata{}, err + } + if commit.Sha == "" || commit.Commit.Tree.Sha == "" { + return normalizedSkillBundle{}, bundleMetadata{}, errors.New("invalid github commit") + } + var tree struct { + Tree []struct { + Path string `json:"path"` + Type string `json:"type"` + Mode string `json:"mode"` + } `json:"tree"` + } + if err := getJSON(apiBase+"/repos/"+url.PathEscape(opts.Owner)+"/"+url.PathEscape(opts.Repo)+"/git/trees/"+url.PathEscape(commit.Commit.Tree.Sha)+"?recursive=1", &tree); err != nil { + return normalizedSkillBundle{}, bundleMetadata{}, err + } + prefix := root + "/" + files := []SkillFile{} + for _, item := range tree.Tree { + if item.Type != "blob" || !strings.HasPrefix(item.Path, prefix) { + continue + } + contents, err := getBytes(rawBase + "/" + url.PathEscape(opts.Owner) + "/" + url.PathEscape(opts.Repo) + "/" + url.PathEscape(commit.Sha) + "/" + escapePath(item.Path)) + if err != nil { + return normalizedSkillBundle{}, bundleMetadata{}, err + } + mode := fs.FileMode(0o644) + if item.Mode == "100755" { + mode = 0o755 + } + files = append(files, SkillFile{Path: strings.TrimPrefix(item.Path, prefix), Contents: contents, Mode: mode}) + } + if len(files) == 0 { + return normalizedSkillBundle{}, bundleMetadata{}, errors.New("github bundle path not found") + } + bundle, err := normalizeSkillFiles(files) + if err != nil { + return normalizedSkillBundle{}, bundleMetadata{}, err + } + return bundle, bundleMetadata{ + Source: "github", + SourceID: "github:" + opts.Owner + "/" + opts.Repo + "/" + root, + Version: opts.Ref, + Provenance: map[string]string{ + "owner": opts.Owner, + "repo": opts.Repo, + "path": root, + "ref": opts.Ref, + "resolvedCommit": commit.Sha, + }, + }, nil +} + +func envBaseURL(name, fallback string) string { + value := strings.TrimRight(os.Getenv(name), "/") + if value == "" { + return fallback + } + return value +} + +func getJSON(url string, value any) error { + data, err := getBytes(url) + if err != nil { + return err + } + return json.Unmarshal(data, value) +} + +func getBytes(value string) ([]byte, error) { + request, err := http.NewRequest(http.MethodGet, value, nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", "kitup") + client := http.Client{Timeout: 30 * time.Second} + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode > 299 { + return nil, fmt.Errorf("github request failed: %s", value) + } + return io.ReadAll(response.Body) +} + +func escapePath(value string) string { + parts := strings.Split(value, "/") + for index, part := range parts { + parts[index] = url.PathEscape(part) + } + return strings.Join(parts, "/") +} diff --git a/go/hosts.go b/go/hosts.go new file mode 100644 index 0000000..f9975d0 --- /dev/null +++ b/go/hosts.go @@ -0,0 +1,406 @@ +package kitup + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "sort" + "strings" +) + +type hostSpec struct { + SchemaVersion int `json:"schemaVersion"` + Hosts []Host `json:"hosts"` +} + +func LoadHostSpec(hostsFile string) ([]Host, error) { + data := []byte(defaultHostsSpecJSON) + if hostsFile != "" { + var err error + data, err = os.ReadFile(hostsFile) + if err != nil { + return nil, err + } + } + var spec hostSpec + if err := json.Unmarshal(data, &spec); err != nil { + return nil, err + } + if err := validateHostSpec(spec.Hosts); err != nil { + return nil, err + } + return spec.Hosts, nil +} + +func validateHostSpec(hosts []Host) error { + for _, host := range hosts { + installDirs := map[string]bool{} + for _, path := range host.ProjectSkillsDir { + if !isProjectHostPath(path) { + return fmt.Errorf("invalid project path %q for host %q", path, host.ID) + } + installDirs[path] = true + } + for _, path := range host.UserSkillsDir { + if !isHomeHostPath(path) { + return fmt.Errorf("invalid user path %q for host %q", path, host.ID) + } + installDirs[path] = true + } + for _, path := range host.Detect { + if !isHomeHostPath(path) && !isProjectHostPath(path) { + return fmt.Errorf("invalid detect path %q for host %q", path, host.ID) + } + if !isGenericDetectPath(path) && installDirs[path] { + return fmt.Errorf("detect path is an install target for host %q: %q", host.ID, path) + } + } + } + return nil +} + +func isProjectHostPath(path string) bool { + return path != "" && !strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "~") && isSafeHostPath(path) +} + +func isHomeHostPath(path string) bool { + return strings.HasPrefix(path, "~/") && isSafeHostPath(path[2:]) +} + +func isSafeHostPath(path string) bool { + return path != "." && fs.ValidPath(path) && !strings.ContainsAny(path, "\x00\\:") +} + +func ResolveHosts(agents AgentSelector, hosts []Host) ([]Host, []map[string]any) { + if agents.Kind == "*" { + return hosts, []map[string]any{} + } + if agents.Kind == "" || agents.Kind == "auto" { + return []Host{}, []map[string]any{} + } + byName := map[string]Host{} + for _, host := range hosts { + byName[host.ID] = host + for _, alias := range host.Aliases { + byName[alias] = host + } + } + seen := map[string]bool{} + resolved := []Host{} + errs := []map[string]any{} + for _, id := range agents.IDs { + host, ok := byName[id] + if !ok { + errs = append(errs, map[string]any{"agent": id, "reason": "unknown-host"}) + continue + } + if !seen[host.ID] { + seen[host.ID] = true + resolved = append(resolved, host) + } + } + return resolved, errs +} + +func DetectHosts(opts BaseOptions, scope Scope) ([]Host, error) { + hosts, err := LoadHostSpec(opts.HostsFile) + if err != nil { + return nil, err + } + home, cwd := defaults(opts) + detected := []Host{} + for _, host := range hosts { + for _, path := range host.Detect { + if isGenericDetectPath(path) { + continue + } + if detectionPathExists(expandHostPath(path, home, cwd)) { + detected = append(detected, host) + break + } + } + } + if scope == "" { + return detected, nil + } + sort.Slice(detected, func(i, j int) bool { + a := canonicalScopePath(detected[i], scope, home, cwd) + b := canonicalScopePath(detected[j], scope, home, cwd) + if a == b { + return detected[i].ID < detected[j].ID + } + return a < b + }) + return detected, nil +} + +func ResolveInstallSelection(opts InstallSelectionOptions) (InstallSelection, error) { + hosts, err := LoadHostSpec(opts.HostsFile) + if err != nil { + return InstallSelection{}, err + } + explicitAgents := opts.Agents.Kind != "" && opts.Agents.Kind != "auto" + if opts.CurrentAgent != "" && !explicitAgents { + selected, errs := ResolveHosts(ExplicitAgents(opts.CurrentAgent), hosts) + selected = addUniversalHost(selected, hosts) + return installSelection(hostIDList(selected), nil, !opts.Yes && opts.StdinTTY, errs), nil + } + if explicitAgents { + if opts.Agents.Kind == "*" { + return installSelection(hostIDList(hosts), nil, !opts.Yes && opts.StdinTTY, nil), nil + } + selected, errs := ResolveHosts(opts.Agents, hosts) + if len(errs) > 0 { + return errorSelection(errs, nil), nil + } + return installSelection(hostIDList(selected), nil, !opts.Yes && opts.StdinTTY, nil), nil + } + detected, err := DetectHosts(opts.BaseOptions, opts.Scope) + if err != nil { + return InstallSelection{}, err + } + detectedIDs := hostIDList(detected) + if !opts.StdinTTY && !opts.Yes { + return errorSelection([]map[string]any{{"reason": "agent-selection-required"}}, detectedIDs), nil + } + if opts.Yes { + if len(detected) == 0 { + return errorSelection([]map[string]any{{"reason": "no-detected-hosts"}}, detectedIDs), nil + } + return installSelection(detectedIDs, detectedIDs, false, nil), nil + } + if len(detected) == 0 { + return selectAgentsSelection(hostIDList(hosts), detectedIDs, []string{}), nil + } + if len(detected) == 1 { + return installSelection(detectedIDs, detectedIDs, true, nil), nil + } + return selectAgentsSelection(detectedIDs, detectedIDs, []string{}), nil +} + +func ResolveInstallTargets(opts BaseOptions, agents AgentSelector, scope Scope, skillName string) ([]TargetGroup, []map[string]any, []string, error) { + return resolveInstallTargets(opts, agents, scope, skillName, "") +} + +func resolveInstallTargets(opts BaseOptions, agents AgentSelector, scope Scope, skillName, uninstallAppID string) ([]TargetGroup, []map[string]any, []string, error) { + if !isValidSkillName(skillName) { + return nil, []map[string]any{{ + "skillName": skillName, + "reason": "invalid-skill-name", + }}, nil, nil + } + hosts, err := LoadHostSpec(opts.HostsFile) + if err != nil { + return nil, nil, nil, err + } + home, cwd := defaults(opts) + if agents.Kind == "" { + agents = AutoAgents() + } + selected := []Host{} + errs := []map[string]any{} + if agents.Kind == "auto" { + selected, err = DetectHosts(opts, scope) + if err != nil { + return nil, nil, nil, err + } + } else { + selected, errs = ResolveHosts(agents, hosts) + } + byTarget := map[string]*TargetGroup{} + for _, host := range selected { + roots := []string{} + if uninstallAppID != "" { + roots = uninstallScopePaths(host, scope, home, cwd, skillName, uninstallAppID) + } else if root := chooseScopePath(host, scope, home, cwd, skillName); root != "" { + roots = append(roots, root) + } + if len(roots) == 0 { + errs = append(errs, map[string]any{ + "hostId": host.ID, "skillName": skillName, "scope": string(scope), "reason": "unsupported-scope", + }) + continue + } + for _, root := range roots { + targetDir := filepath.Join(root, skillName) + group := byTarget[targetDir] + if group == nil { + group = &TargetGroup{SkillName: skillName, TargetDir: targetDir} + byTarget[targetDir] = group + } + if !slices.Contains(group.HostIDs, host.ID) { + group.HostIDs = append(group.HostIDs, host.ID) + } + } + } + targets := []TargetGroup{} + for _, target := range byTarget { + targets = append(targets, *target) + } + sort.Slice(targets, func(i, j int) bool { return targets[i].TargetDir < targets[j].TargetDir }) + detected := []string{} + for _, target := range targets { + detected = append(detected, target.HostIDs...) + } + return targets, errs, detected, nil +} + +func addUniversalHost(selected []Host, hosts []Host) []Host { + for _, selectedHost := range selected { + if selectedHost.ID == "universal" { + return selected + } + } + for _, host := range hosts { + if host.ID == "universal" { + return append(selected, host) + } + } + return selected +} + +func hostIDList(hosts []Host) []string { + ids := make([]string, 0, len(hosts)) + for _, host := range hosts { + ids = append(ids, host.ID) + } + return ids +} + +func installSelection(selectedHostIDs, detectedHostIDs []string, needsConfirmation bool, errs []map[string]any) InstallSelection { + if selectedHostIDs == nil { + selectedHostIDs = []string{} + } + if detectedHostIDs == nil { + detectedHostIDs = []string{} + } + if errs == nil { + errs = []map[string]any{} + } + action := "install" + if len(errs) > 0 { + action = "error" + needsConfirmation = false + } + return InstallSelection{Action: action, SelectedHostIDs: selectedHostIDs, CandidateHostIDs: []string{}, DetectedHostIDs: detectedHostIDs, NeedsConfirmation: needsConfirmation, Errors: errs} +} + +func selectAgentsSelection(candidateHostIDs, detectedHostIDs, selectedHostIDs []string) InstallSelection { + return InstallSelection{Action: "select-agents", SelectedHostIDs: selectedHostIDs, CandidateHostIDs: candidateHostIDs, DetectedHostIDs: detectedHostIDs, NeedsConfirmation: true, Errors: []map[string]any{}} +} + +func errorSelection(errs []map[string]any, detectedHostIDs []string) InstallSelection { + if detectedHostIDs == nil { + detectedHostIDs = []string{} + } + return InstallSelection{Action: "error", SelectedHostIDs: []string{}, CandidateHostIDs: []string{}, DetectedHostIDs: detectedHostIDs, NeedsConfirmation: false, Errors: errs} +} + +func hostsByID(hosts []Host, ids []string) []Host { + byID := map[string]Host{} + for _, host := range hosts { + byID[host.ID] = host + } + selected := []Host{} + for _, id := range ids { + if host, ok := byID[id]; ok { + selected = append(selected, host) + } + } + return selected +} + +func canonicalScopePath(host Host, scope Scope, home, cwd string) string { + paths := scopePaths(host, scope) + if len(paths) == 0 { + return "" + } + return expandHostPath(paths[0], home, cwd) +} + +func chooseScopePath(host Host, scope Scope, home, cwd, skillName string) string { + fallback := "" + for _, path := range scopePaths(host, scope) { + root := expandHostPath(path, home, cwd) + if !isDirectory(root) { + continue + } + if fallback == "" { + fallback = root + } + meta, _, managed := readMetadata(filepath.Join(root, skillName)) + if managed && meta.SkillName == skillName { + return root + } + } + if fallback != "" { + return fallback + } + return canonicalScopePath(host, scope, home, cwd) +} + +func uninstallScopePaths(host Host, scope Scope, home, cwd, skillName, appID string) []string { + owned := []string{} + for _, path := range scopePaths(host, scope) { + root := expandHostPath(path, home, cwd) + _, reason := inspectTarget(filepath.Join(root, skillName), appID, skillName) + if reason == "" { + owned = append(owned, root) + } + } + if len(owned) > 0 { + return owned + } + if fallback := chooseScopePath(host, scope, home, cwd, skillName); fallback != "" { + return []string{fallback} + } + return nil +} + +func scopePaths(host Host, scope Scope) []string { + if scope == UserScope { + return host.UserSkillsDir + } + return host.ProjectSkillsDir +} + +func expandHostPath(path, home, cwd string) string { + if strings.HasPrefix(path, "~/") { + return filepath.Join(home, path[2:]) + } + return filepath.Join(cwd, path) +} + +func defaults(opts BaseOptions) (string, string) { + home := opts.Home + if home == "" { + home, _ = os.UserHomeDir() + } + cwd := opts.CWD + if cwd == "" { + cwd, _ = os.Getwd() + } + return home, cwd +} + +func isGenericDetectPath(path string) bool { + switch path { + case "~/.agents", "~/.agents/skills", "~/.config/agents", + ".agents", ".agents/skills", "package.json": + return true + } + return false +} + +func detectionPathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func isDirectory(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/go/install.go b/go/install.go new file mode 100644 index 0000000..b9c19c1 --- /dev/null +++ b/go/install.go @@ -0,0 +1,194 @@ +package kitup + +func InstallBundledSkill(opts InstallOptions) (InstallReport, error) { + return installOrPlan(opts, true) +} + +func PlanBundledSkill(opts InstallOptions) (InstallReport, error) { + return installOrPlan(opts, false) +} + +func UpdateBundledSkill(opts InstallOptions) (InstallReport, error) { + return InstallBundledSkill(opts) +} + +func UninstallBundledSkill(opts UninstallOptions) (UninstallReport, error) { + if opts.AppID == "" { + return emptyUninstallReport([]map[string]any{{"reason": "invalid-app-id"}}), nil + } + targets, errs, _, err := resolveInstallTargets(opts.BaseOptions, opts.Agents, opts.Scope, opts.SkillName, opts.AppID) + if err != nil { + return UninstallReport{}, err + } + report := emptyUninstallReport(errs) + for _, target := range targets { + result := targetResult(target) + _, reason := inspectTarget(target.TargetDir, opts.AppID, opts.SkillName) + switch reason { + case "missing": + report.Skipped = append(report.Skipped, withReason(result, "missing")) + case "unmanaged", "owner-mismatch": + report.Conflicts = append(report.Conflicts, withReason(result, reason)) + default: + reason, err := removeManagedSkill(target.TargetDir, opts.AppID, opts.SkillName) + if err != nil { + return report, err + } + if reason != "" { + report.Conflicts = append(report.Conflicts, withReason(result, reason)) + continue + } + report.Removed = append(report.Removed, result) + } + } + return report, nil +} + +func StatusBundledSkill(opts StatusOptions) (StatusReport, error) { + if opts.AppID == "" { + return emptyStatusReport([]map[string]any{{"reason": "invalid-app-id"}}), nil + } + targets, errs, _, err := resolveInstallTargets(opts.BaseOptions, opts.Agents, opts.Scope, opts.SkillName, opts.AppID) + if err != nil { + return StatusReport{}, err + } + report := emptyStatusReport(errs) + for _, target := range targets { + result := targetResult(target) + meta, reason := inspectTarget(target.TargetDir, opts.AppID, opts.SkillName) + switch reason { + case "missing": + report.Missing = append(report.Missing, result) + case "unmanaged", "owner-mismatch": + report.Conflicts = append(report.Conflicts, withReason(result, reason)) + default: + report.Installed = append(report.Installed, InstalledTarget{TargetResult: result, Metadata: meta}) + } + } + return report, nil +} + +func installOrPlan(opts InstallOptions, write bool) (InstallReport, error) { + if opts.AppID == "" { + return emptyInstallReport([]map[string]any{{"reason": "invalid-app-id"}}), nil + } + bundle, bundleMeta, err := resolveSkillBundle(opts.SkillBundle) + if err != nil { + reason := "invalid-skill-bundle" + if opts.SkillBundle.kind == "github" { + reason = "bundle-resolve-failed" + } + return emptyInstallReport([]map[string]any{{"reason": reason}}), nil + } + skill := validateNormalizedSkill(bundle) + if !skill.Valid { + return emptyInstallReport([]map[string]any{{"reason": skill.ErrorCode}}), nil + } + hash := contentHash(bundle) + targets, errs, _, err := ResolveInstallTargets(opts.BaseOptions, opts.Agents, opts.Scope, skill.SkillName) + if err != nil { + return InstallReport{}, err + } + report := emptyInstallReport(errs) + for _, target := range targets { + result := targetResult(target) + meta, reason := inspectTarget(target.TargetDir, opts.AppID, skill.SkillName) + if reason != "" && reason != "missing" && !opts.Force { + report.Conflicts = append(report.Conflicts, withReason(result, reason)) + continue + } + if reason == "" && meta.Hash == hash { + repaired, repairErr := repairSkillBundleModes(bundle, target.TargetDir, write) + if repairErr != nil { + return report, repairErr + } + metadataChanged := bundleMeta.Explicit && !installedMetadataEqual(meta, newInstalledMetadata(opts.AppID, skill.SkillName, hash, bundleMeta)) + if !repaired && !metadataChanged { + report.Skipped = append(report.Skipped, withReason(result, "unchanged")) + continue + } + if write { + err = writeMetadata(target.TargetDir, opts.AppID, skill.SkillName, hash, bundleMeta) + } + } else if write { + err = writeManagedSkill(bundle, target.TargetDir, opts.AppID, skill.SkillName, hash, bundleMeta, reason != "missing") + } + if err != nil { + return report, err + } + if reason == "missing" { + report.Installed = append(report.Installed, result) + } else { + report.Updated = append(report.Updated, result) + } + } + return report, nil +} + +func targetResult(target TargetGroup) TargetResult { + result := TargetResult{SkillName: target.SkillName, TargetDir: target.TargetDir} + if len(target.HostIDs) == 1 { + result.HostID = target.HostIDs[0] + } else { + result.HostIDs = target.HostIDs + } + return result +} + +func withReason(result TargetResult, reason string) TargetStatus { + return TargetStatus{TargetResult: result, Reason: reason} +} + +func emptyInstallReport(errs []map[string]any) InstallReport { + return InstallReport{ + Installed: []TargetResult{}, + Updated: []TargetResult{}, + Skipped: []TargetStatus{}, + Conflicts: []TargetStatus{}, + Errors: reportErrors(errs), + } +} + +func emptyUninstallReport(errs []map[string]any) UninstallReport { + return UninstallReport{ + Removed: []TargetResult{}, + Skipped: []TargetStatus{}, + Conflicts: []TargetStatus{}, + Errors: reportErrors(errs), + } +} + +func emptyStatusReport(errs []map[string]any) StatusReport { + return StatusReport{ + Installed: []InstalledTarget{}, + Missing: []TargetResult{}, + Conflicts: []TargetStatus{}, + Errors: reportErrors(errs), + } +} + +func reportErrors(errs []map[string]any) []ReportError { + if errs == nil { + return []ReportError{} + } + result := make([]ReportError, 0, len(errs)) + for _, err := range errs { + result = append(result, ReportError{ + Agent: stringField(err, "agent"), + Flag: stringField(err, "flag"), + HostID: stringField(err, "hostId"), + Reason: stringField(err, "reason"), + Scope: Scope(stringField(err, "scope")), + SkillName: stringField(err, "skillName"), + Value: stringField(err, "value"), + }) + } + return result +} + +func stringField(value map[string]any, key string) string { + if text, ok := value[key].(string); ok { + return text + } + return "" +} diff --git a/go/kitup.go b/go/kitup.go deleted file mode 100644 index 7e94137..0000000 --- a/go/kitup.go +++ /dev/null @@ -1,1933 +0,0 @@ -package kitup - -import ( - "bufio" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "maps" - "net/http" - "net/url" - "os" - pathpkg "path" - "path/filepath" - "regexp" - "slices" - "sort" - "strconv" - "strings" - "time" -) - -type Scope string - -const ( - UserScope Scope = "user" - ProjectScope Scope = "project" -) - -type InstallUXText struct { - SkillUse string - SkillShort string - InstallUse string - InstallShort string - ScopeFlag string - AgentFlag string - DryRunFlag string - YesFlag string - ForceFlag string - SelectScope string - ScopePrompt string - InvalidScopeSelection string - SelectAgents string - AgentsPrompt string - InvalidAgentSelection string - Proceed string - InstallSummary string - ErrorPrefix string - Canceled string - SelectionError string - Conflict string - Failed string - InvalidFlags string -} - -var InstallUX = InstallUXText{ - SkillUse: "skill", - SkillShort: "Manage bundled Agent Skill", - InstallUse: "install", - InstallShort: "Install bundled Agent Skill", - ScopeFlag: "Install scope: user or project", - AgentFlag: "Target agent id. Repeat for multiple agents. Use '*' for all.", - DryRunFlag: "Show install plan without writing", - YesFlag: "Skip prompts and accept policy-selected targets", - ForceFlag: "Overwrite unsafe target conflicts", - SelectScope: "Select install scope:", - ScopePrompt: "Scope (user/project)", - InvalidScopeSelection: "Invalid scope selection.", - SelectAgents: "Select agents:", - AgentsPrompt: "Agents (numbers, ids, comma-separated, empty cancels)", - InvalidAgentSelection: "Invalid agent selection.", - Proceed: "Proceed? [y/N] ", - InstallSummary: "Install summary:", - ErrorPrefix: "kitup:", - Canceled: "Installation canceled.", - SelectionError: "Agent selection failed.", - Conflict: "Installation has conflicts.", - Failed: "Installation failed.", - InvalidFlags: "Invalid install flags.", -} - -type AgentSelector struct { - Kind string - IDs []string -} - -type InstallFlagValues struct { - Scope string - ScopeSet bool - Agents []string - Yes bool - DryRun bool - Force bool -} - -type ParsedInstallFlags struct { - Scope Scope - ScopeSet bool - Agents AgentSelector - Yes bool - DryRun bool - Force bool - Errors []map[string]any -} - -type InstallWorkflowExit struct { - OK bool `json:"ok"` - Code string `json:"code"` - Message string `json:"message"` -} - -func AutoAgents() AgentSelector { return AgentSelector{Kind: "auto"} } -func AllAgents() AgentSelector { return AgentSelector{Kind: "*"} } -func ExplicitAgents(ids ...string) AgentSelector { - return AgentSelector{Kind: "explicit", IDs: ids} -} - -func ParseInstallFlags(flags InstallFlagValues) ParsedInstallFlags { - errs := []map[string]any{} - scope, scopeErrs := ParseScopeFlag(flags.Scope) - errs = append(errs, scopeErrs...) - agents, agentErrs := AgentSelectorFromFlags(flags.Agents) - errs = append(errs, agentErrs...) - return ParsedInstallFlags{Scope: scope, ScopeSet: flags.ScopeSet || flags.Scope != "", Agents: agents, Yes: flags.Yes, DryRun: flags.DryRun, Force: flags.Force, Errors: errs} -} - -func AgentSelectorFromFlags(values []string) (AgentSelector, []map[string]any) { - agents := splitFlagValues(values) - if len(agents) == 0 { - return AutoAgents(), []map[string]any{} - } - for _, agent := range agents { - if agent == "*" { - errs := []map[string]any{} - if len(agents) > 1 { - errs = append(errs, map[string]any{"flag": "agent", "reason": "agent-star-must-be-alone", "value": strings.Join(agents, ",")}) - } - return AllAgents(), errs - } - } - seen := map[string]bool{} - ids := []string{} - for _, agent := range agents { - if !seen[agent] { - seen[agent] = true - ids = append(ids, agent) - } - } - return ExplicitAgents(ids...), []map[string]any{} -} - -func ParseScopeFlag(value string) (Scope, []map[string]any) { - if value == "" || value == string(UserScope) { - return UserScope, []map[string]any{} - } - if value == string(ProjectScope) { - return ProjectScope, []map[string]any{} - } - return UserScope, []map[string]any{{"flag": "scope", "reason": "invalid-scope", "value": value}} -} - -func splitFlagValues(values []string) []string { - var out []string - for _, value := range values { - for _, part := range strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) { - if part != "" { - out = append(out, part) - } - } - } - return out -} - -func ClassifyInstallWorkflowExit(report InstallWorkflowReport) InstallWorkflowExit { - switch { - case report.Canceled: - return InstallWorkflowExit{OK: false, Code: "canceled", Message: InstallUX.Canceled} - case len(report.Selection.Errors) > 0: - return InstallWorkflowExit{OK: false, Code: "selection-error", Message: InstallUX.SelectionError} - case len(report.Report.Conflicts) > 0: - return InstallWorkflowExit{OK: false, Code: "conflict", Message: InstallUX.Conflict} - case len(report.Report.Errors) > 0: - return InstallWorkflowExit{OK: false, Code: "error", Message: InstallUX.Failed} - default: - return InstallWorkflowExit{OK: true, Code: "ok"} - } -} - -func InstallWorkflowError(report InstallWorkflowReport) error { - exit := ClassifyInstallWorkflowExit(report) - if exit.OK || exit.Code == "canceled" { - return nil - } - return errors.New(exit.Message) -} - -func InstallFlagError(errs []map[string]any) error { - if len(errs) == 0 { - return nil - } - return errors.New(InstallUX.InvalidFlags) -} - -type Host struct { - ID string `json:"id"` - DisplayName string `json:"displayName"` - Aliases []string `json:"aliases,omitempty"` - ProjectSkillsDir []string `json:"projectSkillsDirs"` - UserSkillsDir []string `json:"userSkillsDirs"` - Detect []string `json:"detect"` - Status string `json:"status"` - Notes []string `json:"notes,omitempty"` -} - -type hostSpec struct { - SchemaVersion int `json:"schemaVersion"` - Hosts []Host `json:"hosts"` -} - -type BaseOptions struct { - Home string - CWD string - HostsFile string -} - -type InstallOptions struct { - BaseOptions - AppID string - SkillBundle SkillBundle - Scope Scope - Agents AgentSelector - Force bool -} - -type UninstallOptions struct { - BaseOptions - AppID string - SkillName string - Scope Scope - Agents AgentSelector -} - -type StatusOptions struct { - BaseOptions - AppID string - SkillName string - Scope Scope - Agents AgentSelector -} - -type InstallSelectionOptions struct { - BaseOptions - Scope Scope - Agents AgentSelector - Yes bool - StdinTTY bool - CurrentAgent string -} - -type InstallWorkflowOptions struct { - InstallOptions - Yes bool - DryRun bool - StdinTTY bool - CurrentAgent string - DefaultScope Scope - ScopeSet bool - PromptScope bool - In io.Reader - Out io.Writer - Err io.Writer -} - -type SkillInfo struct { - Valid bool `json:"valid"` - SkillName string `json:"skillName,omitempty"` - Description string `json:"description,omitempty"` - ErrorCode string `json:"errorCode,omitempty"` -} - -type SkillFile struct { - Path string - Contents []byte - Mode fs.FileMode -} - -type SkillBundle struct { - kind string - dir string - fsys fs.FS - root string - files []SkillFile - github GitHubBundleOptions - meta BundledSkillMetadata - metaSet bool -} - -type BundledSkillMetadata struct { - SourceID string - CLIVersion string - CLIRevision string - Provenance map[string]string -} - -type GitHubBundleOptions struct { - Owner string - Repo string - Path string - Ref string -} - -func DirectoryBundle(dir string) SkillBundle { - return SkillBundle{kind: "directory", dir: dir} -} - -func FSBundle(fsys fs.FS, root string) SkillBundle { - return SkillBundle{kind: "fs", fsys: fsys, root: root} -} - -func FilesBundle(files []SkillFile) SkillBundle { - return SkillBundle{kind: "files", files: files} -} - -func GitHubBundle(opts GitHubBundleOptions) SkillBundle { - return SkillBundle{kind: "github", github: opts} -} - -func WithBundleMetadata(bundle SkillBundle, meta BundledSkillMetadata) SkillBundle { - bundle.meta = BundledSkillMetadata{ - SourceID: meta.SourceID, - CLIVersion: meta.CLIVersion, - CLIRevision: meta.CLIRevision, - Provenance: maps.Clone(meta.Provenance), - } - bundle.metaSet = true - return bundle -} - -type TargetGroup struct { - HostIDs []string - SkillName string - TargetDir string -} - -type TargetResult struct { - HostID string `json:"hostId,omitempty"` - HostIDs []string `json:"hostIds,omitempty"` - SkillName string `json:"skillName"` - TargetDir string `json:"targetDir"` -} - -type TargetStatus struct { - TargetResult - Reason string `json:"reason"` -} - -type ReportError struct { - Agent string `json:"agent,omitempty"` - Flag string `json:"flag,omitempty"` - HostID string `json:"hostId,omitempty"` - Reason string `json:"reason"` - Scope Scope `json:"scope,omitempty"` - SkillName string `json:"skillName,omitempty"` - Value string `json:"value,omitempty"` -} - -type InstallReport struct { - Installed []TargetResult `json:"installed"` - Updated []TargetResult `json:"updated"` - Skipped []TargetStatus `json:"skipped"` - Conflicts []TargetStatus `json:"conflicts"` - Errors []ReportError `json:"errors"` -} - -type UninstallReport struct { - Removed []TargetResult `json:"removed"` - Skipped []TargetStatus `json:"skipped"` - Conflicts []TargetStatus `json:"conflicts"` - Errors []ReportError `json:"errors"` -} - -type InstalledMetadata struct { - SchemaVersion int `json:"schemaVersion"` - AppID string `json:"appId"` - SkillName string `json:"skillName"` - Source string `json:"source"` - Hash string `json:"hash"` - SourceID string `json:"sourceId,omitempty"` - Version string `json:"version,omitempty"` - CLIVersion string `json:"cliVersion,omitempty"` - CLIRevision string `json:"cliRevision,omitempty"` - Provenance map[string]string `json:"provenance,omitempty"` -} - -type InstalledTarget struct { - TargetResult - Metadata InstalledMetadata `json:"metadata"` -} - -type StatusReport struct { - Installed []InstalledTarget `json:"installed"` - Missing []TargetResult `json:"missing"` - Conflicts []TargetStatus `json:"conflicts"` - Errors []ReportError `json:"errors"` -} - -type InstallSelection struct { - Action string `json:"action"` - SelectedHostIDs []string `json:"selectedHostIds"` - CandidateHostIDs []string `json:"candidateHostIds"` - DetectedHostIDs []string `json:"detectedHostIds"` - NeedsConfirmation bool `json:"needsConfirmation"` - Errors []map[string]any `json:"errors"` -} - -type InstallWorkflowReport struct { - Selection InstallSelection `json:"selection"` - Scope Scope `json:"scope"` - Plan InstallReport `json:"plan"` - Report InstallReport `json:"report"` - Canceled bool `json:"canceled"` - DryRun bool `json:"dryRun"` -} - -type metadata = InstalledMetadata - -type bundleMetadata struct { - Source string - SourceID string - Version string - CLIVersion string - CLIRevision string - Provenance map[string]string - Explicit bool -} - -type normalizedSkillBundle struct { - Files []SkillFile - ByPath map[string]SkillFile -} - -var skillNamePattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) - -func isValidSkillName(skillName string) bool { - return skillNamePattern.MatchString(skillName) -} - -func LoadHostSpec(hostsFile string) ([]Host, error) { - data := []byte(defaultHostsSpecJSON) - if hostsFile != "" { - var err error - data, err = os.ReadFile(hostsFile) - if err != nil { - return nil, err - } - } - var spec hostSpec - if err := json.Unmarshal(data, &spec); err != nil { - return nil, err - } - if err := validateHostSpec(spec.Hosts); err != nil { - return nil, err - } - return spec.Hosts, nil -} - -func validateHostSpec(hosts []Host) error { - for _, host := range hosts { - installDirs := map[string]bool{} - for _, path := range host.ProjectSkillsDir { - if !isProjectHostPath(path) { - return fmt.Errorf("invalid project path %q for host %q", path, host.ID) - } - installDirs[path] = true - } - for _, path := range host.UserSkillsDir { - if !isHomeHostPath(path) { - return fmt.Errorf("invalid user path %q for host %q", path, host.ID) - } - installDirs[path] = true - } - for _, path := range host.Detect { - if !isHomeHostPath(path) && !isProjectHostPath(path) { - return fmt.Errorf("invalid detect path %q for host %q", path, host.ID) - } - if !isGenericDetectPath(path) && installDirs[path] { - return fmt.Errorf("detect path is an install target for host %q: %q", host.ID, path) - } - } - } - return nil -} - -func isProjectHostPath(path string) bool { - return path != "" && !strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "~") && isSafeHostPath(path) -} - -func isHomeHostPath(path string) bool { - return strings.HasPrefix(path, "~/") && isSafeHostPath(path[2:]) -} - -func isSafeHostPath(path string) bool { - if strings.ContainsAny(path, "\x00\\:") { - return false - } - for _, segment := range strings.Split(path, "/") { - if segment == "." || segment == ".." || segment == "" { - return false - } - } - return true -} - -func ResolveHosts(agents AgentSelector, hosts []Host) ([]Host, []map[string]any) { - if agents.Kind == "*" { - return hosts, []map[string]any{} - } - if agents.Kind == "" || agents.Kind == "auto" { - return []Host{}, []map[string]any{} - } - byName := map[string]Host{} - for _, host := range hosts { - byName[host.ID] = host - for _, alias := range host.Aliases { - byName[alias] = host - } - } - seen := map[string]bool{} - resolved := []Host{} - errs := []map[string]any{} - for _, id := range agents.IDs { - host, ok := byName[id] - if !ok { - errs = append(errs, map[string]any{"agent": id, "reason": "unknown-host"}) - continue - } - if !seen[host.ID] { - seen[host.ID] = true - resolved = append(resolved, host) - } - } - return resolved, errs -} - -func DetectHosts(opts BaseOptions, scope Scope) ([]Host, error) { - hosts, err := LoadHostSpec(opts.HostsFile) - if err != nil { - return nil, err - } - home, cwd := defaults(opts) - detected := []Host{} - for _, host := range hosts { - for _, path := range host.Detect { - if isGenericDetectPath(path) { - continue - } - if detectionPathExists(expandHostPath(path, home, cwd)) { - detected = append(detected, host) - break - } - } - } - if scope == "" { - return detected, nil - } - sort.Slice(detected, func(i, j int) bool { - a := canonicalScopePath(detected[i], scope, home, cwd) - b := canonicalScopePath(detected[j], scope, home, cwd) - if a == b { - return detected[i].ID < detected[j].ID - } - return a < b - }) - return detected, nil -} - -func ResolveInstallSelection(opts InstallSelectionOptions) (InstallSelection, error) { - hosts, err := LoadHostSpec(opts.HostsFile) - if err != nil { - return InstallSelection{}, err - } - explicitAgents := opts.Agents.Kind != "" && opts.Agents.Kind != "auto" - if opts.CurrentAgent != "" && !explicitAgents { - selected, errs := ResolveHosts(ExplicitAgents(opts.CurrentAgent), hosts) - selected = addUniversalHost(selected, hosts) - return installSelection(hostIDList(selected), nil, !opts.Yes && opts.StdinTTY, errs), nil - } - if explicitAgents { - if opts.Agents.Kind == "*" { - return installSelection(hostIDList(hosts), nil, !opts.Yes && opts.StdinTTY, nil), nil - } - selected, errs := ResolveHosts(opts.Agents, hosts) - if len(errs) > 0 { - return errorSelection(errs, nil), nil - } - return installSelection(hostIDList(selected), nil, !opts.Yes && opts.StdinTTY, nil), nil - } - detected, err := DetectHosts(opts.BaseOptions, opts.Scope) - if err != nil { - return InstallSelection{}, err - } - detectedIDs := hostIDList(detected) - if !opts.StdinTTY && !opts.Yes { - return errorSelection([]map[string]any{{"reason": "agent-selection-required"}}, detectedIDs), nil - } - if opts.Yes { - if len(detected) == 0 { - return errorSelection([]map[string]any{{"reason": "no-detected-hosts"}}, detectedIDs), nil - } - return installSelection(detectedIDs, detectedIDs, false, nil), nil - } - if len(detected) == 0 { - return selectAgentsSelection(hostIDList(hosts), detectedIDs, []string{}), nil - } - if len(detected) == 1 { - return installSelection(detectedIDs, detectedIDs, true, nil), nil - } - return selectAgentsSelection(detectedIDs, detectedIDs, []string{}), nil -} - -func ResolveInstallTargets(opts BaseOptions, agents AgentSelector, scope Scope, skillName string) ([]TargetGroup, []map[string]any, []string, error) { - return resolveInstallTargets(opts, agents, scope, skillName, "") -} - -func resolveInstallTargets(opts BaseOptions, agents AgentSelector, scope Scope, skillName, uninstallAppID string) ([]TargetGroup, []map[string]any, []string, error) { - if !isValidSkillName(skillName) { - return nil, []map[string]any{{ - "skillName": skillName, - "reason": "invalid-skill-name", - }}, nil, nil - } - hosts, err := LoadHostSpec(opts.HostsFile) - if err != nil { - return nil, nil, nil, err - } - home, cwd := defaults(opts) - if agents.Kind == "" { - agents = AutoAgents() - } - selected := []Host{} - errs := []map[string]any{} - if agents.Kind == "auto" { - selected, err = DetectHosts(opts, scope) - if err != nil { - return nil, nil, nil, err - } - } else { - selected, errs = ResolveHosts(agents, hosts) - } - byTarget := map[string]*TargetGroup{} - for _, host := range selected { - roots := []string{} - if uninstallAppID != "" { - roots = uninstallScopePaths(host, scope, home, cwd, skillName, uninstallAppID) - } else if root := chooseScopePath(host, scope, home, cwd, skillName); root != "" { - roots = append(roots, root) - } - if len(roots) == 0 { - errs = append(errs, map[string]any{ - "hostId": host.ID, "skillName": skillName, "scope": string(scope), "reason": "unsupported-scope", - }) - continue - } - for _, root := range roots { - targetDir := filepath.Join(root, skillName) - group := byTarget[targetDir] - if group == nil { - group = &TargetGroup{SkillName: skillName, TargetDir: targetDir} - byTarget[targetDir] = group - } - if !slices.Contains(group.HostIDs, host.ID) { - group.HostIDs = append(group.HostIDs, host.ID) - } - } - } - targets := []TargetGroup{} - for _, target := range byTarget { - targets = append(targets, *target) - } - sort.Slice(targets, func(i, j int) bool { return targets[i].TargetDir < targets[j].TargetDir }) - detected := []string{} - for _, target := range targets { - detected = append(detected, target.HostIDs...) - } - return targets, errs, detected, nil -} - -func addUniversalHost(selected []Host, hosts []Host) []Host { - for _, selectedHost := range selected { - if selectedHost.ID == "universal" { - return selected - } - } - for _, host := range hosts { - if host.ID == "universal" { - return append(selected, host) - } - } - return selected -} - -func hostIDList(hosts []Host) []string { - ids := make([]string, 0, len(hosts)) - for _, host := range hosts { - ids = append(ids, host.ID) - } - return ids -} - -func installSelection(selectedHostIDs, detectedHostIDs []string, needsConfirmation bool, errs []map[string]any) InstallSelection { - if selectedHostIDs == nil { - selectedHostIDs = []string{} - } - if detectedHostIDs == nil { - detectedHostIDs = []string{} - } - if errs == nil { - errs = []map[string]any{} - } - action := "install" - if len(errs) > 0 { - action = "error" - needsConfirmation = false - } - return InstallSelection{Action: action, SelectedHostIDs: selectedHostIDs, CandidateHostIDs: []string{}, DetectedHostIDs: detectedHostIDs, NeedsConfirmation: needsConfirmation, Errors: errs} -} - -func selectAgentsSelection(candidateHostIDs, detectedHostIDs, selectedHostIDs []string) InstallSelection { - return InstallSelection{Action: "select-agents", SelectedHostIDs: selectedHostIDs, CandidateHostIDs: candidateHostIDs, DetectedHostIDs: detectedHostIDs, NeedsConfirmation: true, Errors: []map[string]any{}} -} - -func errorSelection(errs []map[string]any, detectedHostIDs []string) InstallSelection { - if detectedHostIDs == nil { - detectedHostIDs = []string{} - } - return InstallSelection{Action: "error", SelectedHostIDs: []string{}, CandidateHostIDs: []string{}, DetectedHostIDs: detectedHostIDs, NeedsConfirmation: false, Errors: errs} -} - -func ValidateSkillBundle(bundle SkillBundle) SkillInfo { - normalized, err := readSkillBundle(bundle) - if err != nil { - return SkillInfo{Valid: false, ErrorCode: "invalid-skill-bundle"} - } - return validateNormalizedSkill(normalized) -} - -func validateNormalizedSkill(bundle normalizedSkillBundle) SkillInfo { - file, ok := bundle.ByPath["SKILL.md"] - if !ok { - return SkillInfo{Valid: false, ErrorCode: "missing-skill-md"} - } - text := string(file.Contents) - if !strings.HasPrefix(text, "---\n") { - return SkillInfo{Valid: false, ErrorCode: "invalid-frontmatter"} - } - end := strings.Index(text[4:], "\n---\n") - if end < 0 { - return SkillInfo{Valid: false, ErrorCode: "invalid-frontmatter"} - } - fields := parseFrontmatter(text[4 : 4+end]) - name := fields["name"] - description := fields["description"] - if !isValidSkillName(name) || len(description) < 1 || len(description) > 1024 { - return SkillInfo{Valid: false, ErrorCode: "invalid-frontmatter"} - } - return SkillInfo{Valid: true, SkillName: name, Description: description} -} - -func ComputeBundleContentHash(bundle SkillBundle) (string, error) { - normalized, err := readSkillBundle(bundle) - if err != nil { - return "", err - } - return contentHash(normalized), nil -} - -func contentHash(bundle normalizedSkillBundle) string { - hash := sha256.New() - for _, file := range bundle.Files { - hash.Write([]byte(file.Path)) - hash.Write([]byte{0}) - hash.Write(file.Contents) - hash.Write([]byte{0}) - } - return "sha256:" + hex.EncodeToString(hash.Sum(nil)) -} - -func InstallBundledSkill(opts InstallOptions) (InstallReport, error) { - return installOrPlan(opts, true) -} - -func PlanBundledSkill(opts InstallOptions) (InstallReport, error) { - return installOrPlan(opts, false) -} - -func RunBundledSkillInstall(opts InstallWorkflowOptions) (InstallWorkflowReport, error) { - in := opts.In - if in == nil { - in = os.Stdin - } - if !opts.StdinTTY { - if file, ok := in.(*os.File); ok { - if info, err := file.Stat(); err == nil && info.Mode()&os.ModeCharDevice != 0 { - opts.StdinTTY = true - } - } - } - out := opts.Out - if out == nil { - out = os.Stdout - } - reader := bufio.NewReader(in) - scope, scopeError, err := resolveWorkflowScope(reader, out, opts.Scope, opts.ScopeSet, opts.PromptScope, opts.DefaultScope, opts.Yes, opts.StdinTTY) - if err != nil { - return InstallWorkflowReport{}, err - } - if len(scopeError.Errors) > 0 { - renderSelectionErrors(out, scopeError) - empty := emptyInstallReport(nil) - return InstallWorkflowReport{Selection: scopeError, Scope: scope, Plan: empty, Report: empty, DryRun: opts.DryRun}, nil - } - selection, err := ResolveInstallSelection(InstallSelectionOptions{ - BaseOptions: opts.BaseOptions, - Scope: scope, - Agents: opts.Agents, - Yes: opts.Yes, - StdinTTY: opts.StdinTTY, - CurrentAgent: opts.CurrentAgent, - }) - if err != nil { - return InstallWorkflowReport{}, err - } - if selection.Action == "error" { - renderSelectionErrors(out, selection) - empty := emptyInstallReport(nil) - return InstallWorkflowReport{Selection: selection, Scope: scope, Plan: empty, Report: empty, DryRun: opts.DryRun}, nil - } - if selection.Action == "select-agents" { - hosts, err := LoadHostSpec(opts.HostsFile) - if err != nil { - return InstallWorkflowReport{}, err - } - selected, err := promptAgentSelection(reader, out, selection, hosts) - if err != nil { - return InstallWorkflowReport{}, err - } - selection = installSelection(selected, selection.DetectedHostIDs, !opts.Yes && opts.StdinTTY, nil) - if len(selected) == 0 { - empty := emptyInstallReport(nil) - return InstallWorkflowReport{Selection: selection, Scope: scope, Plan: empty, Report: empty, Canceled: true, DryRun: opts.DryRun}, nil - } - } - installOpts := opts.InstallOptions - installOpts.Agents = ExplicitAgents(selection.SelectedHostIDs...) - installOpts.Scope = scope - plan, err := PlanBundledSkill(installOpts) - if err != nil { - return InstallWorkflowReport{}, err - } - if len(plan.Installed)+len(plan.Updated)+len(plan.Conflicts)+len(plan.Errors) == 0 { - return InstallWorkflowReport{Selection: selection, Scope: scope, Plan: plan, Report: plan, DryRun: opts.DryRun}, nil - } - if opts.DryRun { - renderInstallSummary(out, plan) - return InstallWorkflowReport{Selection: selection, Scope: scope, Plan: plan, Report: plan, DryRun: true}, nil - } - if len(plan.Conflicts)+len(plan.Errors) > 0 { - report := plan - report.Installed = []TargetResult{} - report.Updated = []TargetResult{} - return InstallWorkflowReport{Selection: selection, Scope: scope, Plan: plan, Report: report, DryRun: opts.DryRun}, nil - } - renderInstallSummary(out, plan) - if selection.NeedsConfirmation { - confirmed, err := promptConfirmation(reader, out) - if err != nil { - return InstallWorkflowReport{}, err - } - if !confirmed { - return InstallWorkflowReport{Selection: selection, Scope: scope, Plan: plan, Report: emptyInstallReport(nil), Canceled: true}, nil - } - } - report, err := InstallBundledSkill(installOpts) - if err != nil { - return InstallWorkflowReport{}, err - } - return InstallWorkflowReport{Selection: selection, Scope: scope, Plan: plan, Report: report}, nil -} - -func UpdateBundledSkill(opts InstallOptions) (InstallReport, error) { - return InstallBundledSkill(opts) -} - -func UninstallBundledSkill(opts UninstallOptions) (UninstallReport, error) { - if opts.AppID == "" { - return emptyUninstallReport([]map[string]any{{"reason": "invalid-app-id"}}), nil - } - targets, errs, _, err := resolveInstallTargets(opts.BaseOptions, opts.Agents, opts.Scope, opts.SkillName, opts.AppID) - if err != nil { - return UninstallReport{}, err - } - report := emptyUninstallReport(errs) - for _, target := range targets { - result := targetResult(target) - meta, present, managed := readMetadata(target.TargetDir) - switch { - case !present: - report.Skipped = append(report.Skipped, withReason(result, "missing")) - case !managed || meta.SkillName != opts.SkillName: - report.Conflicts = append(report.Conflicts, withReason(result, "unmanaged")) - case meta.AppID != opts.AppID: - report.Conflicts = append(report.Conflicts, withReason(result, "owner-mismatch")) - default: - reason, err := removeManagedSkill(target.TargetDir, opts.AppID, opts.SkillName) - if err != nil { - return report, err - } - if reason != "" { - report.Conflicts = append(report.Conflicts, withReason(result, reason)) - continue - } - report.Removed = append(report.Removed, result) - } - } - return report, nil -} - -func StatusBundledSkill(opts StatusOptions) (StatusReport, error) { - if opts.AppID == "" { - return emptyStatusReport([]map[string]any{{"reason": "invalid-app-id"}}), nil - } - targets, errs, _, err := resolveInstallTargets(opts.BaseOptions, opts.Agents, opts.Scope, opts.SkillName, opts.AppID) - if err != nil { - return StatusReport{}, err - } - report := emptyStatusReport(errs) - for _, target := range targets { - result := targetResult(target) - meta, present, managed := readMetadata(target.TargetDir) - switch { - case !present: - report.Missing = append(report.Missing, result) - case !managed || meta.SkillName != opts.SkillName: - report.Conflicts = append(report.Conflicts, withReason(result, "unmanaged")) - case meta.AppID != opts.AppID: - report.Conflicts = append(report.Conflicts, withReason(result, "owner-mismatch")) - default: - report.Installed = append(report.Installed, InstalledTarget{TargetResult: result, Metadata: meta}) - } - } - return report, nil -} - -func ReadInstalledMetadata(targetDir string) (InstalledMetadata, bool, error) { - meta, present, managed := readMetadata(targetDir) - if !present { - return InstalledMetadata{}, false, nil - } - if !managed { - return InstalledMetadata{}, false, errors.New("unmanaged install metadata") - } - return meta, true, nil -} - -func installOrPlan(opts InstallOptions, write bool) (InstallReport, error) { - if opts.AppID == "" { - return emptyInstallReport([]map[string]any{{"reason": "invalid-app-id"}}), nil - } - bundle, bundleMeta, err := resolveSkillBundle(opts.SkillBundle) - if err != nil { - reason := "invalid-skill-bundle" - if opts.SkillBundle.kind == "github" { - reason = "bundle-resolve-failed" - } - return emptyInstallReport([]map[string]any{{"reason": reason}}), nil - } - skill := validateNormalizedSkill(bundle) - if !skill.Valid { - return emptyInstallReport([]map[string]any{{"reason": skill.ErrorCode}}), nil - } - hash := contentHash(bundle) - targets, errs, _, err := ResolveInstallTargets(opts.BaseOptions, opts.Agents, opts.Scope, skill.SkillName) - if err != nil { - return InstallReport{}, err - } - report := emptyInstallReport(errs) - for _, target := range targets { - result := targetResult(target) - meta, present, managed := readMetadata(target.TargetDir) - switch { - case !present: - if write { - if err := copyManagedSkill(bundle, target.TargetDir, opts.AppID, skill.SkillName, hash, bundleMeta); err != nil { - return report, err - } - } - report.Installed = append(report.Installed, result) - case !managed || meta.SkillName != skill.SkillName: - if opts.Force { - if write { - if err := replaceManagedSkill(bundle, target.TargetDir, opts.AppID, skill.SkillName, hash, bundleMeta); err != nil { - return report, err - } - } - report.Updated = append(report.Updated, result) - continue - } - report.Conflicts = append(report.Conflicts, withReason(result, "unmanaged")) - case meta.AppID != opts.AppID: - if opts.Force { - if write { - if err := replaceManagedSkill(bundle, target.TargetDir, opts.AppID, skill.SkillName, hash, bundleMeta); err != nil { - return report, err - } - } - report.Updated = append(report.Updated, result) - continue - } - report.Conflicts = append(report.Conflicts, withReason(result, "owner-mismatch")) - case meta.Hash == hash: - repaired, err := repairSkillBundleModes(bundle, target.TargetDir, write) - if err != nil { - return report, err - } - metadataChanged := bundleMeta.Explicit && !installedMetadataEqual(meta, newInstalledMetadata(opts.AppID, skill.SkillName, hash, bundleMeta)) - if repaired || metadataChanged { - if write { - if err := writeMetadata(target.TargetDir, opts.AppID, skill.SkillName, hash, bundleMeta); err != nil { - return report, err - } - } - report.Updated = append(report.Updated, result) - } else { - report.Skipped = append(report.Skipped, withReason(result, "unchanged")) - } - default: - if write { - if err := replaceManagedSkill(bundle, target.TargetDir, opts.AppID, skill.SkillName, hash, bundleMeta); err != nil { - return report, err - } - } - report.Updated = append(report.Updated, result) - } - } - return report, nil -} - -func copyManagedSkill(bundle normalizedSkillBundle, targetDir, appID, skillName, hash string, bundleMeta bundleMetadata) error { - tmp, err := makeStagingDir(targetDir) - if err != nil { - return err - } - if err := copySkillBundle(bundle, tmp); err != nil { - _ = os.RemoveAll(tmp) - return err - } - if err := writeMetadata(tmp, appID, skillName, hash, bundleMeta); err != nil { - _ = os.RemoveAll(tmp) - return err - } - if err := os.Rename(tmp, targetDir); err != nil { - _ = os.RemoveAll(tmp) - return err - } - return nil -} - -func replaceManagedSkill(bundle normalizedSkillBundle, targetDir, appID, skillName, hash string, bundleMeta bundleMetadata) error { - tmp, err := makeStagingDir(targetDir) - if err != nil { - return err - } - backup := tmp + "-backup" - if err := copySkillBundle(bundle, tmp); err != nil { - _ = os.RemoveAll(tmp) - return err - } - if err := writeMetadata(tmp, appID, skillName, hash, bundleMeta); err != nil { - _ = os.RemoveAll(tmp) - return err - } - if err := os.Rename(targetDir, backup); err != nil { - _ = os.RemoveAll(tmp) - return err - } - if err := os.Rename(tmp, targetDir); err != nil { - _ = os.RemoveAll(tmp) - if !exists(targetDir) && exists(backup) { - _ = os.Rename(backup, targetDir) - } - return err - } - return os.RemoveAll(backup) -} - -func makeStagingDir(targetDir string) (string, error) { - parent := filepath.Dir(targetDir) - if err := os.MkdirAll(parent, 0o755); err != nil { - return "", err - } - tmp, err := os.MkdirTemp(parent, "."+filepath.Base(targetDir)+".kitup-") - if err != nil { - return "", err - } - if err := os.Chmod(tmp, 0o755); err != nil { - _ = os.RemoveAll(tmp) - return "", err - } - return tmp, nil -} - -func removeManagedSkill(targetDir, appID, skillName string) (string, error) { - quarantine, err := makeStagingDir(targetDir) - if err != nil { - return "", err - } - if err := os.Remove(quarantine); err != nil { - return "", err - } - if err := os.Rename(targetDir, quarantine); err != nil { - return "", err - } - meta, present, managed := readMetadata(quarantine) - reason := "" - switch { - case !present || !managed || meta.SkillName != skillName: - reason = "unmanaged" - case meta.AppID != appID: - reason = "owner-mismatch" - } - if reason != "" { - if exists(targetDir) { - return "", fmt.Errorf("cannot restore changed install; preserved at %s", quarantine) - } - if err := os.Rename(quarantine, targetDir); err != nil { - return "", err - } - return reason, nil - } - return "", os.RemoveAll(quarantine) -} - -func copySkillBundle(bundle normalizedSkillBundle, dest string) error { - if err := os.MkdirAll(dest, 0o755); err != nil { - return err - } - for _, file := range bundle.Files { - to := filepath.Join(dest, filepath.FromSlash(file.Path)) - if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { - return err - } - mode := file.Mode.Perm() - if err := os.WriteFile(to, file.Contents, mode); err != nil { - return err - } - if err := os.Chmod(to, mode); err != nil { - return err - } - } - return nil -} - -func repairSkillBundleModes(bundle normalizedSkillBundle, dest string, write bool) (bool, error) { - repaired := false - for _, file := range bundle.Files { - to := filepath.Join(dest, filepath.FromSlash(file.Path)) - info, err := os.Lstat(to) - if err != nil { - if os.IsNotExist(err) { - continue - } - return false, err - } - if info.Mode().IsRegular() && info.Mode().Perm() != file.Mode.Perm() { - repaired = true - if write { - if err := os.Chmod(to, file.Mode.Perm()); err != nil { - return false, err - } - } - } - } - return repaired, nil -} - -func writeMetadata(targetDir, appID, skillName, hash string, bundleMeta bundleMetadata) error { - meta := newInstalledMetadata(appID, skillName, hash, bundleMeta) - data, err := json.MarshalIndent(meta, "", " ") - if err != nil { - return err - } - return os.WriteFile(filepath.Join(targetDir, ".kitup.json"), append(data, '\n'), 0o644) -} - -func readMetadata(targetDir string) (metadata, bool, bool) { - if !exists(targetDir) { - return metadata{}, false, false - } - data, err := os.ReadFile(filepath.Join(targetDir, ".kitup.json")) - if err != nil { - return metadata{}, true, false - } - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return metadata{}, true, false - } - var meta metadata - fields := map[string]any{ - "schemaVersion": &meta.SchemaVersion, - "appId": &meta.AppID, - "skillName": &meta.SkillName, - "source": &meta.Source, - "hash": &meta.Hash, - "sourceId": &meta.SourceID, - "version": &meta.Version, - "cliVersion": &meta.CLIVersion, - "cliRevision": &meta.CLIRevision, - "provenance": &meta.Provenance, - } - for name, destination := range fields { - value, ok := raw[name] - if !ok { - continue - } - if err := json.Unmarshal(value, destination); err != nil { - return metadata{}, true, false - } - } - if !isOwnedMetadata(meta) { - return metadata{}, true, false - } - return meta, true, true -} - -func newInstalledMetadata(appID, skillName, hash string, bundleMeta bundleMetadata) InstalledMetadata { - source := bundleMeta.Source - if source == "" { - source = "bundled" - } - return InstalledMetadata{ - SchemaVersion: 1, - AppID: appID, - SkillName: skillName, - Source: source, - Hash: hash, - SourceID: bundleMeta.SourceID, - Version: bundleMeta.Version, - CLIVersion: bundleMeta.CLIVersion, - CLIRevision: bundleMeta.CLIRevision, - Provenance: maps.Clone(bundleMeta.Provenance), - } -} - -func installedMetadataEqual(left, right InstalledMetadata) bool { - return left.SchemaVersion == right.SchemaVersion && - left.AppID == right.AppID && - left.SkillName == right.SkillName && - left.Source == right.Source && - left.Hash == right.Hash && - left.SourceID == right.SourceID && - left.Version == right.Version && - left.CLIVersion == right.CLIVersion && - left.CLIRevision == right.CLIRevision && - maps.Equal(left.Provenance, right.Provenance) -} - -func isOwnedMetadata(meta metadata) bool { - return meta.SchemaVersion == 1 && - meta.AppID != "" && - isValidSkillName(meta.SkillName) && - (meta.Source == "bundled" || meta.Source == "github") && - meta.Hash != "" -} - -func targetResult(target TargetGroup) TargetResult { - result := TargetResult{SkillName: target.SkillName, TargetDir: target.TargetDir} - if len(target.HostIDs) == 1 { - result.HostID = target.HostIDs[0] - } else { - result.HostIDs = target.HostIDs - } - return result -} - -func withReason(result TargetResult, reason string) TargetStatus { - return TargetStatus{TargetResult: result, Reason: reason} -} - -func emptyInstallReport(errs []map[string]any) InstallReport { - return InstallReport{ - Installed: []TargetResult{}, - Updated: []TargetResult{}, - Skipped: []TargetStatus{}, - Conflicts: []TargetStatus{}, - Errors: reportErrors(errs), - } -} - -func emptyUninstallReport(errs []map[string]any) UninstallReport { - return UninstallReport{ - Removed: []TargetResult{}, - Skipped: []TargetStatus{}, - Conflicts: []TargetStatus{}, - Errors: reportErrors(errs), - } -} - -func emptyStatusReport(errs []map[string]any) StatusReport { - return StatusReport{ - Installed: []InstalledTarget{}, - Missing: []TargetResult{}, - Conflicts: []TargetStatus{}, - Errors: reportErrors(errs), - } -} - -func reportErrors(errs []map[string]any) []ReportError { - if errs == nil { - return []ReportError{} - } - result := make([]ReportError, 0, len(errs)) - for _, err := range errs { - result = append(result, ReportError{ - Agent: stringField(err, "agent"), - Flag: stringField(err, "flag"), - HostID: stringField(err, "hostId"), - Reason: stringField(err, "reason"), - Scope: Scope(stringField(err, "scope")), - SkillName: stringField(err, "skillName"), - Value: stringField(err, "value"), - }) - } - return result -} - -func stringField(value map[string]any, key string) string { - if text, ok := value[key].(string); ok { - return text - } - return "" -} - -func resolveWorkflowScope(reader *bufio.Reader, out io.Writer, requested Scope, scopeSet, promptScope bool, defaultScope Scope, yes, stdinTTY bool) (Scope, InstallSelection, error) { - if defaultScope == "" { - defaultScope = UserScope - } - if requested == "" { - requested = defaultScope - } - if scopeSet || !promptScope { - return requested, InstallSelection{}, nil - } - if yes { - return defaultScope, InstallSelection{}, nil - } - if !stdinTTY { - return "", errorSelection([]map[string]any{{"reason": "scope-selection-required"}}, nil), nil - } - scope, err := promptScopeSelection(reader, out, defaultScope) - return scope, InstallSelection{}, err -} - -func promptScopeSelection(reader *bufio.Reader, out io.Writer, defaultScope Scope) (Scope, error) { - for { - fmt.Fprintln(out, InstallUX.SelectScope) - fmt.Fprintf(out, " 1. %s\n", UserScope) - fmt.Fprintf(out, " 2. %s\n", ProjectScope) - fmt.Fprintf(out, "%s [%s]: ", InstallUX.ScopePrompt, defaultScope) - line, err := readPromptLine(reader) - if err != nil { - return "", err - } - scope, ok := parseScopeSelection(line, defaultScope) - if ok { - return scope, nil - } - fmt.Fprintln(out, InstallUX.InvalidScopeSelection) - } -} - -func parseScopeSelection(line string, defaultScope Scope) (Scope, bool) { - switch strings.ToLower(strings.TrimSpace(line)) { - case "": - return defaultScope, true - case "1", "u", "user": - return UserScope, true - case "2", "p", "project": - return ProjectScope, true - default: - return "", false - } -} - -func promptAgentSelection(reader *bufio.Reader, out io.Writer, selection InstallSelection, hosts []Host) ([]string, error) { - candidates := hostsByID(hosts, selection.CandidateHostIDs) - for { - fmt.Fprintln(out, InstallUX.SelectAgents) - for i, host := range candidates { - fmt.Fprintf(out, " %d. %s (%s)\n", i+1, host.DisplayName, host.ID) - } - suffix := "" - if len(selection.SelectedHostIDs) > 0 { - suffix = " [" + strings.Join(selection.SelectedHostIDs, ",") + "]" - } - fmt.Fprintf(out, "%s%s: ", InstallUX.AgentsPrompt, suffix) - line, err := readPromptLine(reader) - if err != nil { - return nil, err - } - selected, ok := parseAgentSelection(line, selection, candidates) - if ok { - return selected, nil - } - fmt.Fprintln(out, InstallUX.InvalidAgentSelection) - } -} - -func parseAgentSelection(line string, selection InstallSelection, candidates []Host) ([]string, bool) { - line = strings.TrimSpace(line) - if line == "" { - return selection.SelectedHostIDs, true - } - if line == "*" { - return hostIDList(candidates), true - } - byName := map[string]string{} - for index, host := range candidates { - byName[strconv.Itoa(index+1)] = host.ID - byName[host.ID] = host.ID - for _, alias := range host.Aliases { - byName[alias] = host.ID - } - } - seen := map[string]bool{} - selected := []string{} - for _, part := range strings.FieldsFunc(line, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) { - id, ok := byName[part] - if !ok { - return nil, false - } - if !seen[id] { - seen[id] = true - selected = append(selected, id) - } - } - return selected, true -} - -func promptConfirmation(reader *bufio.Reader, out io.Writer) (bool, error) { - fmt.Fprint(out, InstallUX.Proceed) - line, err := readPromptLine(reader) - if err != nil { - return false, err - } - line = strings.ToLower(strings.TrimSpace(line)) - return line == "y" || line == "yes", nil -} - -func readPromptLine(reader *bufio.Reader) (string, error) { - line, err := reader.ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - return "", err - } - return strings.TrimRight(line, "\r\n"), nil -} - -func renderInstallSummary(out io.Writer, report InstallReport) { - for _, item := range append(append([]TargetResult{}, report.Installed...), report.Updated...) { - for _, host := range summaryHosts(item) { - fmt.Fprintf(out, " - %s -> %s (%s)\n", item.SkillName, item.TargetDir, host) - } - } -} - -func summaryHosts(item TargetResult) []string { - if item.HostID != "" { - return []string{item.HostID} - } - return item.HostIDs -} - -func renderSelectionErrors(out io.Writer, selection InstallSelection) { - for _, err := range selection.Errors { - fmt.Fprintf(out, "%s %s\n", InstallUX.ErrorPrefix, err["reason"]) - } -} - -func hostsByID(hosts []Host, ids []string) []Host { - byID := map[string]Host{} - for _, host := range hosts { - byID[host.ID] = host - } - selected := []Host{} - for _, id := range ids { - if host, ok := byID[id]; ok { - selected = append(selected, host) - } - } - return selected -} - -func canonicalScopePath(host Host, scope Scope, home, cwd string) string { - paths := scopePaths(host, scope) - if len(paths) == 0 { - return "" - } - return expandHostPath(paths[0], home, cwd) -} - -func chooseScopePath(host Host, scope Scope, home, cwd, skillName string) string { - paths := scopePaths(host, scope) - existing := []string{} - for _, path := range paths { - expanded := expandHostPath(path, home, cwd) - if isDirectory(expanded) { - existing = append(existing, expanded) - } - } - for _, root := range existing { - meta, _, managed := readMetadata(filepath.Join(root, skillName)) - if managed && meta.SkillName == skillName { - return root - } - } - if len(existing) > 0 { - return existing[0] - } - if len(paths) == 0 { - return "" - } - return expandHostPath(paths[0], home, cwd) -} - -func uninstallScopePaths(host Host, scope Scope, home, cwd, skillName, appID string) []string { - owned := []string{} - for _, path := range scopePaths(host, scope) { - root := expandHostPath(path, home, cwd) - meta, _, managed := readMetadata(filepath.Join(root, skillName)) - if managed && meta.SkillName == skillName && meta.AppID == appID { - owned = append(owned, root) - } - } - if len(owned) > 0 { - return owned - } - if fallback := chooseScopePath(host, scope, home, cwd, skillName); fallback != "" { - return []string{fallback} - } - return nil -} - -func scopePaths(host Host, scope Scope) []string { - if scope == UserScope { - return host.UserSkillsDir - } - return host.ProjectSkillsDir -} - -func expandHostPath(path, home, cwd string) string { - if strings.HasPrefix(path, "~/") { - return filepath.Join(home, path[2:]) - } - return filepath.Join(cwd, path) -} - -func defaults(opts BaseOptions) (string, string) { - home := opts.Home - if home == "" { - home, _ = os.UserHomeDir() - } - cwd := opts.CWD - if cwd == "" { - cwd, _ = os.Getwd() - } - return home, cwd -} - -func parseFrontmatter(content string) map[string]string { - fields := map[string]string{} - for _, line := range strings.Split(content, "\n") { - before, after, ok := strings.Cut(line, ":") - if ok { - fields[before] = strings.TrimSpace(after) - } - } - return fields -} - -func resolveSkillBundle(bundle SkillBundle) (normalizedSkillBundle, bundleMetadata, error) { - var normalized normalizedSkillBundle - var meta bundleMetadata - var err error - switch bundle.kind { - case "github": - normalized, meta, err = resolveGitHubBundle(bundle.github) - default: - normalized, err = readSkillBundle(bundle) - meta = bundleMetadata{Source: "bundled"} - } - if err != nil { - return normalizedSkillBundle{}, bundleMetadata{}, err - } - if bundle.meta.SourceID != "" { - meta.SourceID = bundle.meta.SourceID - } - meta.CLIVersion = bundle.meta.CLIVersion - meta.CLIRevision = bundle.meta.CLIRevision - if len(bundle.meta.Provenance) > 0 { - meta.Provenance = maps.Clone(meta.Provenance) - if meta.Provenance == nil { - meta.Provenance = map[string]string{} - } - for key, value := range bundle.meta.Provenance { - meta.Provenance[key] = value - } - } - meta.Explicit = bundle.metaSet - return normalized, meta, nil -} - -func resolveGitHubBundle(opts GitHubBundleOptions) (normalizedSkillBundle, bundleMetadata, error) { - root := trimGitHubPath(opts.Path) - if opts.Owner == "" || opts.Repo == "" || root == "" || opts.Ref == "" { - return normalizedSkillBundle{}, bundleMetadata{}, errors.New("invalid github bundle") - } - apiBase := envBaseURL("KITUP_GITHUB_API_BASE_URL", "https://api.github.com") - rawBase := envBaseURL("KITUP_GITHUB_RAW_BASE_URL", "https://raw.githubusercontent.com") - var commit struct { - Sha string `json:"sha"` - Commit struct { - Tree struct { - Sha string `json:"sha"` - } `json:"tree"` - } `json:"commit"` - } - if err := getJSON(apiBase+"/repos/"+escapePathPart(opts.Owner)+"/"+escapePathPart(opts.Repo)+"/commits/"+escapePathPart(opts.Ref), &commit); err != nil { - return normalizedSkillBundle{}, bundleMetadata{}, err - } - if commit.Sha == "" || commit.Commit.Tree.Sha == "" { - return normalizedSkillBundle{}, bundleMetadata{}, errors.New("invalid github commit") - } - var tree struct { - Tree []struct { - Path string `json:"path"` - Type string `json:"type"` - Mode string `json:"mode"` - } `json:"tree"` - } - if err := getJSON(apiBase+"/repos/"+escapePathPart(opts.Owner)+"/"+escapePathPart(opts.Repo)+"/git/trees/"+escapePathPart(commit.Commit.Tree.Sha)+"?recursive=1", &tree); err != nil { - return normalizedSkillBundle{}, bundleMetadata{}, err - } - prefix := root + "/" - files := []SkillFile{} - for _, item := range tree.Tree { - if item.Type != "blob" || !strings.HasPrefix(item.Path, prefix) { - continue - } - contents, err := getBytes(rawBase + "/" + escapePathPart(opts.Owner) + "/" + escapePathPart(opts.Repo) + "/" + escapePathPart(commit.Sha) + "/" + escapePath(item.Path)) - if err != nil { - return normalizedSkillBundle{}, bundleMetadata{}, err - } - mode := fs.FileMode(0o644) - if item.Mode == "100755" { - mode = 0o755 - } - files = append(files, SkillFile{Path: strings.TrimPrefix(item.Path, prefix), Contents: contents, Mode: mode}) - } - if len(files) == 0 { - return normalizedSkillBundle{}, bundleMetadata{}, errors.New("github bundle path not found") - } - bundle, err := normalizeSkillFiles(files) - if err != nil { - return normalizedSkillBundle{}, bundleMetadata{}, err - } - return bundle, bundleMetadata{ - Source: "github", - SourceID: "github:" + opts.Owner + "/" + opts.Repo + "/" + root, - Version: opts.Ref, - Provenance: map[string]string{ - "owner": opts.Owner, - "repo": opts.Repo, - "path": root, - "ref": opts.Ref, - "resolvedCommit": commit.Sha, - }, - }, nil -} - -func envBaseURL(name, fallback string) string { - value := strings.TrimRight(os.Getenv(name), "/") - if value == "" { - return fallback - } - return value -} - -func getJSON(url string, value any) error { - data, err := getBytes(url) - if err != nil { - return err - } - return json.Unmarshal(data, value) -} - -func getBytes(value string) ([]byte, error) { - request, err := http.NewRequest(http.MethodGet, value, nil) - if err != nil { - return nil, err - } - request.Header.Set("User-Agent", "kitup") - client := http.Client{Timeout: 30 * time.Second} - response, err := client.Do(request) - if err != nil { - return nil, err - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode > 299 { - return nil, fmt.Errorf("github request failed: %s", value) - } - return io.ReadAll(response.Body) -} - -func trimGitHubPath(value string) string { - return strings.Trim(value, "/") -} - -func escapePath(value string) string { - parts := strings.Split(value, "/") - for index, part := range parts { - parts[index] = escapePathPart(part) - } - return strings.Join(parts, "/") -} - -func escapePathPart(value string) string { - return url.PathEscape(value) -} - -func readSkillBundle(bundle SkillBundle) (normalizedSkillBundle, error) { - switch bundle.kind { - case "directory": - files, err := readDirectoryBundleFiles(bundle.dir) - if err != nil { - return normalizedSkillBundle{}, err - } - return normalizeSkillFiles(files) - case "fs": - files, err := readFSBundleFiles(bundle.fsys, bundle.root) - if err != nil { - return normalizedSkillBundle{}, err - } - return normalizeSkillFiles(files) - case "files": - return normalizeSkillFiles(bundle.files) - case "github": - normalized, _, err := resolveGitHubBundle(bundle.github) - return normalized, err - default: - return normalizedSkillBundle{}, errors.New("missing skill bundle") - } -} - -func readDirectoryBundleFiles(root string) ([]SkillFile, error) { - var files []SkillFile - err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if path == root { - return nil - } - if skipName(entry.Name()) { - if entry.IsDir() { - return filepath.SkipDir - } - return nil - } - info, err := entry.Info() - if err != nil { - return err - } - if info.Mode().IsRegular() { - rel, err := filepath.Rel(root, path) - if err != nil { - return err - } - contents, err := os.ReadFile(path) - if err != nil { - return err - } - files = append(files, SkillFile{Path: filepath.ToSlash(rel), Contents: contents, Mode: info.Mode().Perm()}) - } - return nil - }) - return files, err -} - -func readFSBundleFiles(fsys fs.FS, root string) ([]SkillFile, error) { - if fsys == nil { - return nil, errors.New("missing skill fs") - } - root = strings.Trim(root, "/") - if root == "" { - root = "." - } - if root != "." && !fs.ValidPath(root) { - return nil, errors.New("invalid skill fs root") - } - var files []SkillFile - err := fs.WalkDir(fsys, root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if path == root { - return nil - } - if skipName(entry.Name()) { - if entry.IsDir() { - return fs.SkipDir - } - return nil - } - info, err := entry.Info() - if err != nil { - return err - } - if info.Mode().IsRegular() { - rel := path - if root != "." { - rel = strings.TrimPrefix(path, root+"/") - } - contents, err := fs.ReadFile(fsys, path) - if err != nil { - return err - } - mode := info.Mode().Perm() - if mode == 0o444 { - mode = defaultBundleFileMode(rel) - } - files = append(files, SkillFile{Path: rel, Contents: contents, Mode: mode}) - } - return nil - }) - return files, err -} - -func normalizeSkillFiles(files []SkillFile) (normalizedSkillBundle, error) { - byPath := map[string]SkillFile{} - for _, file := range files { - normalizedPath, include, err := normalizeBundlePath(file.Path) - if err != nil { - return normalizedSkillBundle{}, err - } - if !include { - continue - } - if _, ok := byPath[normalizedPath]; ok { - return normalizedSkillBundle{}, errors.New("duplicate skill file: " + normalizedPath) - } - mode := file.Mode.Perm() - if mode == 0 { - mode = defaultBundleFileMode(normalizedPath) - } - byPath[normalizedPath] = SkillFile{Path: normalizedPath, Contents: file.Contents, Mode: mode} - } - paths := make([]string, 0, len(byPath)) - for path := range byPath { - paths = append(paths, path) - } - sort.Strings(paths) - normalized := normalizedSkillBundle{Files: make([]SkillFile, 0, len(paths)), ByPath: byPath} - for _, path := range paths { - normalized.Files = append(normalized.Files, byPath[path]) - } - return normalized, nil -} - -func defaultBundleFileMode(path string) fs.FileMode { - if strings.HasPrefix(path, "scripts/") { - return 0o755 - } - return 0o644 -} - -func normalizeBundlePath(value string) (string, bool, error) { - if value == "" || strings.Contains(value, "\\") || pathpkg.IsAbs(value) || strings.HasPrefix(value, "/") { - return "", false, errors.New("invalid skill file path: " + value) - } - if len(value) > 1 && value[1] == ':' { - return "", false, errors.New("invalid skill file path: " + value) - } - parts := strings.Split(value, "/") - for _, part := range parts { - if part == "" || part == "." || part == ".." { - return "", false, errors.New("invalid skill file path: " + value) - } - if skipName(part) { - return "", false, nil - } - } - return strings.Join(parts, "/"), true, nil -} - -func skipName(name string) bool { - return name == ".git" || name == ".kitup.json" || name == ".DS_Store" || strings.HasSuffix(name, ".swp") || strings.HasSuffix(name, "~") -} - -func isGenericDetectPath(path string) bool { - switch path { - case "~/.agents", "~/.agents/skills", "~/.config/agents", - ".agents", ".agents/skills", "package.json": - return true - } - return false -} - -func exists(path string) bool { - _, err := os.Stat(path) - return err == nil || !errors.Is(err, os.ErrNotExist) -} - -func detectionPathExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -func isDirectory(path string) bool { - info, err := os.Stat(path) - return err == nil && info.IsDir() -} diff --git a/go/storage.go b/go/storage.go new file mode 100644 index 0000000..35cfadf --- /dev/null +++ b/go/storage.go @@ -0,0 +1,247 @@ +package kitup + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "path/filepath" +) + +type metadata = InstalledMetadata + +func inspectTarget(targetDir, appID, skillName string) (InstalledMetadata, string) { + meta, present, managed := readMetadata(targetDir) + switch { + case !present: + return meta, "missing" + case !managed || meta.SkillName != skillName: + return meta, "unmanaged" + case meta.AppID != appID: + return meta, "owner-mismatch" + default: + return meta, "" + } +} + +func ReadInstalledMetadata(targetDir string) (InstalledMetadata, bool, error) { + meta, present, managed := readMetadata(targetDir) + if !present { + return InstalledMetadata{}, false, nil + } + if !managed { + return InstalledMetadata{}, false, errors.New("unmanaged install metadata") + } + return meta, true, nil +} + +func writeManagedSkill(bundle normalizedSkillBundle, targetDir, appID, skillName, hash string, bundleMeta bundleMetadata, replace bool) error { + tmp, err := makeStagingDir(targetDir) + if err != nil { + return err + } + defer os.RemoveAll(tmp) + if err := copySkillBundle(bundle, tmp); err != nil { + return err + } + if err := writeMetadata(tmp, appID, skillName, hash, bundleMeta); err != nil { + return err + } + backup := tmp + "-backup" + if replace { + if err := os.Rename(targetDir, backup); err != nil { + return err + } + } + if err := os.Rename(tmp, targetDir); err != nil { + if replace && !exists(targetDir) && exists(backup) { + _ = os.Rename(backup, targetDir) + } + return err + } + if replace { + return os.RemoveAll(backup) + } + return nil +} + +func makeStagingDir(targetDir string) (string, error) { + parent := filepath.Dir(targetDir) + if err := os.MkdirAll(parent, 0o755); err != nil { + return "", err + } + tmp, err := os.MkdirTemp(parent, "."+filepath.Base(targetDir)+".kitup-") + if err != nil { + return "", err + } + if err := os.Chmod(tmp, 0o755); err != nil { + _ = os.RemoveAll(tmp) + return "", err + } + return tmp, nil +} + +func removeManagedSkill(targetDir, appID, skillName string) (string, error) { + quarantine, err := makeStagingDir(targetDir) + if err != nil { + return "", err + } + if err := os.Remove(quarantine); err != nil { + return "", err + } + if err := os.Rename(targetDir, quarantine); err != nil { + return "", err + } + _, reason := inspectTarget(quarantine, appID, skillName) + if reason == "missing" { + reason = "unmanaged" + } + if reason != "" { + if exists(targetDir) { + return "", fmt.Errorf("cannot restore changed install; preserved at %s", quarantine) + } + if err := os.Rename(quarantine, targetDir); err != nil { + return "", err + } + return reason, nil + } + return "", os.RemoveAll(quarantine) +} + +func copySkillBundle(bundle normalizedSkillBundle, dest string) error { + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + for _, file := range bundle.Files { + to := filepath.Join(dest, filepath.FromSlash(file.Path)) + if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { + return err + } + mode := file.Mode.Perm() + if err := os.WriteFile(to, file.Contents, mode); err != nil { + return err + } + if err := os.Chmod(to, mode); err != nil { + return err + } + } + return nil +} + +func repairSkillBundleModes(bundle normalizedSkillBundle, dest string, write bool) (bool, error) { + repaired := false + for _, file := range bundle.Files { + to := filepath.Join(dest, filepath.FromSlash(file.Path)) + info, err := os.Lstat(to) + if err != nil { + if os.IsNotExist(err) { + continue + } + return false, err + } + if info.Mode().IsRegular() && info.Mode().Perm() != file.Mode.Perm() { + repaired = true + if write { + if err := os.Chmod(to, file.Mode.Perm()); err != nil { + return false, err + } + } + } + } + return repaired, nil +} + +func writeMetadata(targetDir, appID, skillName, hash string, bundleMeta bundleMetadata) error { + meta := newInstalledMetadata(appID, skillName, hash, bundleMeta) + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(targetDir, ".kitup.json"), append(data, '\n'), 0o644) +} + +func readMetadata(targetDir string) (metadata, bool, bool) { + if !exists(targetDir) { + return metadata{}, false, false + } + data, err := os.ReadFile(filepath.Join(targetDir, ".kitup.json")) + if err != nil { + return metadata{}, true, false + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return metadata{}, true, false + } + var meta metadata + fields := map[string]any{ + "schemaVersion": &meta.SchemaVersion, + "appId": &meta.AppID, + "skillName": &meta.SkillName, + "source": &meta.Source, + "hash": &meta.Hash, + "sourceId": &meta.SourceID, + "version": &meta.Version, + "cliVersion": &meta.CLIVersion, + "cliRevision": &meta.CLIRevision, + "provenance": &meta.Provenance, + } + for name, destination := range fields { + value, ok := raw[name] + if !ok { + continue + } + if err := json.Unmarshal(value, destination); err != nil { + return metadata{}, true, false + } + } + if !isOwnedMetadata(meta) { + return metadata{}, true, false + } + return meta, true, true +} + +func newInstalledMetadata(appID, skillName, hash string, bundleMeta bundleMetadata) InstalledMetadata { + source := bundleMeta.Source + if source == "" { + source = "bundled" + } + return InstalledMetadata{ + SchemaVersion: 1, + AppID: appID, + SkillName: skillName, + Source: source, + Hash: hash, + SourceID: bundleMeta.SourceID, + Version: bundleMeta.Version, + CLIVersion: bundleMeta.CLIVersion, + CLIRevision: bundleMeta.CLIRevision, + Provenance: maps.Clone(bundleMeta.Provenance), + } +} + +func installedMetadataEqual(left, right InstalledMetadata) bool { + return left.SchemaVersion == right.SchemaVersion && + left.AppID == right.AppID && + left.SkillName == right.SkillName && + left.Source == right.Source && + left.Hash == right.Hash && + left.SourceID == right.SourceID && + left.Version == right.Version && + left.CLIVersion == right.CLIVersion && + left.CLIRevision == right.CLIRevision && + maps.Equal(left.Provenance, right.Provenance) +} + +func isOwnedMetadata(meta metadata) bool { + return meta.SchemaVersion == 1 && + meta.AppID != "" && + isValidSkillName(meta.SkillName) && + (meta.Source == "bundled" || meta.Source == "github") && + meta.Hash != "" +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil || !errors.Is(err, os.ErrNotExist) +} diff --git a/go/types.go b/go/types.go new file mode 100644 index 0000000..443aba5 --- /dev/null +++ b/go/types.go @@ -0,0 +1,226 @@ +package kitup + +import ( + "io" + "io/fs" +) + +type Scope string + +const ( + UserScope Scope = "user" + ProjectScope Scope = "project" +) + +type AgentSelector struct { + Kind string + IDs []string +} + +type InstallFlagValues struct { + Scope string + ScopeSet bool + Agents []string + Yes bool + DryRun bool + Force bool +} + +type ParsedInstallFlags struct { + Scope Scope + ScopeSet bool + Agents AgentSelector + Yes bool + DryRun bool + Force bool + Errors []map[string]any +} + +type InstallWorkflowExit struct { + OK bool `json:"ok"` + Code string `json:"code"` + Message string `json:"message"` +} + +type Host struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Aliases []string `json:"aliases,omitempty"` + ProjectSkillsDir []string `json:"projectSkillsDirs"` + UserSkillsDir []string `json:"userSkillsDirs"` + Detect []string `json:"detect"` + Status string `json:"status"` + Notes []string `json:"notes,omitempty"` +} + +type BaseOptions struct { + Home string + CWD string + HostsFile string +} + +type InstallOptions struct { + BaseOptions + AppID string + SkillBundle SkillBundle + Scope Scope + Agents AgentSelector + Force bool +} + +type UninstallOptions struct { + BaseOptions + AppID string + SkillName string + Scope Scope + Agents AgentSelector +} + +type StatusOptions UninstallOptions + +type InstallSelectionOptions struct { + BaseOptions + Scope Scope + Agents AgentSelector + Yes bool + StdinTTY bool + CurrentAgent string +} + +type InstallWorkflowOptions struct { + InstallOptions + Yes bool + DryRun bool + StdinTTY bool + CurrentAgent string + DefaultScope Scope + ScopeSet bool + PromptScope bool + In io.Reader + Out io.Writer + Err io.Writer +} + +type SkillInfo struct { + Valid bool `json:"valid"` + SkillName string `json:"skillName,omitempty"` + Description string `json:"description,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` +} + +type SkillFile struct { + Path string + Contents []byte + Mode fs.FileMode +} + +type SkillBundle struct { + kind string + dir string + fsys fs.FS + root string + files []SkillFile + github GitHubBundleOptions + meta BundledSkillMetadata + metaSet bool +} + +type BundledSkillMetadata struct { + SourceID string + CLIVersion string + CLIRevision string + Provenance map[string]string +} + +type GitHubBundleOptions struct { + Owner string + Repo string + Path string + Ref string +} + +type TargetGroup struct { + HostIDs []string + SkillName string + TargetDir string +} + +type TargetResult struct { + HostID string `json:"hostId,omitempty"` + HostIDs []string `json:"hostIds,omitempty"` + SkillName string `json:"skillName"` + TargetDir string `json:"targetDir"` +} + +type TargetStatus struct { + TargetResult + Reason string `json:"reason"` +} + +type ReportError struct { + Agent string `json:"agent,omitempty"` + Flag string `json:"flag,omitempty"` + HostID string `json:"hostId,omitempty"` + Reason string `json:"reason"` + Scope Scope `json:"scope,omitempty"` + SkillName string `json:"skillName,omitempty"` + Value string `json:"value,omitempty"` +} + +type InstallReport struct { + Installed []TargetResult `json:"installed"` + Updated []TargetResult `json:"updated"` + Skipped []TargetStatus `json:"skipped"` + Conflicts []TargetStatus `json:"conflicts"` + Errors []ReportError `json:"errors"` +} + +type UninstallReport struct { + Removed []TargetResult `json:"removed"` + Skipped []TargetStatus `json:"skipped"` + Conflicts []TargetStatus `json:"conflicts"` + Errors []ReportError `json:"errors"` +} + +type InstalledMetadata struct { + SchemaVersion int `json:"schemaVersion"` + AppID string `json:"appId"` + SkillName string `json:"skillName"` + Source string `json:"source"` + Hash string `json:"hash"` + SourceID string `json:"sourceId,omitempty"` + Version string `json:"version,omitempty"` + CLIVersion string `json:"cliVersion,omitempty"` + CLIRevision string `json:"cliRevision,omitempty"` + Provenance map[string]string `json:"provenance,omitempty"` +} + +type InstalledTarget struct { + TargetResult + Metadata InstalledMetadata `json:"metadata"` +} + +type StatusReport struct { + Installed []InstalledTarget `json:"installed"` + Missing []TargetResult `json:"missing"` + Conflicts []TargetStatus `json:"conflicts"` + Errors []ReportError `json:"errors"` +} + +type InstallSelection struct { + Action string `json:"action"` + SelectedHostIDs []string `json:"selectedHostIds"` + CandidateHostIDs []string `json:"candidateHostIds"` + DetectedHostIDs []string `json:"detectedHostIds"` + NeedsConfirmation bool `json:"needsConfirmation"` + Errors []map[string]any `json:"errors"` +} + +type InstallWorkflowReport struct { + Selection InstallSelection `json:"selection"` + Scope Scope `json:"scope"` + Plan InstallReport `json:"plan"` + Report InstallReport `json:"report"` + Canceled bool `json:"canceled"` + DryRun bool `json:"dryRun"` +} diff --git a/go/workflow.go b/go/workflow.go new file mode 100644 index 0000000..c222647 --- /dev/null +++ b/go/workflow.go @@ -0,0 +1,385 @@ +package kitup + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "slices" + "strconv" + "strings" +) + +type InstallUXText struct { + SkillUse string + SkillShort string + InstallUse string + InstallShort string + ScopeFlag string + AgentFlag string + DryRunFlag string + YesFlag string + ForceFlag string + SelectScope string + ScopePrompt string + InvalidScopeSelection string + SelectAgents string + AgentsPrompt string + InvalidAgentSelection string + Proceed string + InstallSummary string + ErrorPrefix string + Canceled string + SelectionError string + Conflict string + Failed string + InvalidFlags string +} + +var InstallUX = InstallUXText{ + SkillUse: "skill", + SkillShort: "Manage bundled Agent Skill", + InstallUse: "install", + InstallShort: "Install bundled Agent Skill", + ScopeFlag: "Install scope: user or project", + AgentFlag: "Target agent id. Repeat for multiple agents. Use '*' for all.", + DryRunFlag: "Show install plan without writing", + YesFlag: "Skip prompts and accept policy-selected targets", + ForceFlag: "Overwrite unsafe target conflicts", + SelectScope: "Select install scope:", + ScopePrompt: "Scope (user/project)", + InvalidScopeSelection: "Invalid scope selection.", + SelectAgents: "Select agents:", + AgentsPrompt: "Agents (numbers, ids, comma-separated, empty cancels)", + InvalidAgentSelection: "Invalid agent selection.", + Proceed: "Proceed? [y/N] ", + InstallSummary: "Install summary:", + ErrorPrefix: "kitup:", + Canceled: "Installation canceled.", + SelectionError: "Agent selection failed.", + Conflict: "Installation has conflicts.", + Failed: "Installation failed.", + InvalidFlags: "Invalid install flags.", +} + +func AutoAgents() AgentSelector { return AgentSelector{Kind: "auto"} } + +func AllAgents() AgentSelector { return AgentSelector{Kind: "*"} } + +func ExplicitAgents(ids ...string) AgentSelector { + return AgentSelector{Kind: "explicit", IDs: ids} +} + +func ParseInstallFlags(flags InstallFlagValues) ParsedInstallFlags { + errs := []map[string]any{} + scope, scopeErrs := ParseScopeFlag(flags.Scope) + errs = append(errs, scopeErrs...) + agents, agentErrs := AgentSelectorFromFlags(flags.Agents) + errs = append(errs, agentErrs...) + return ParsedInstallFlags{Scope: scope, ScopeSet: flags.ScopeSet || flags.Scope != "", Agents: agents, Yes: flags.Yes, DryRun: flags.DryRun, Force: flags.Force, Errors: errs} +} + +func AgentSelectorFromFlags(values []string) (AgentSelector, []map[string]any) { + agents := splitFlagValues(values) + if len(agents) == 0 { + return AutoAgents(), []map[string]any{} + } + if slices.Contains(agents, "*") { + errs := []map[string]any{} + if len(agents) > 1 { + errs = append(errs, map[string]any{"flag": "agent", "reason": "agent-star-must-be-alone", "value": strings.Join(agents, ",")}) + } + return AllAgents(), errs + } + seen := map[string]bool{} + ids := []string{} + for _, agent := range agents { + if !seen[agent] { + seen[agent] = true + ids = append(ids, agent) + } + } + return ExplicitAgents(ids...), []map[string]any{} +} + +func ParseScopeFlag(value string) (Scope, []map[string]any) { + if value == "" || value == string(UserScope) { + return UserScope, []map[string]any{} + } + if value == string(ProjectScope) { + return ProjectScope, []map[string]any{} + } + return UserScope, []map[string]any{{"flag": "scope", "reason": "invalid-scope", "value": value}} +} + +func splitFlagValues(values []string) []string { + var out []string + for _, value := range values { + for _, part := range strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) { + out = append(out, part) + } + } + return out +} + +func ClassifyInstallWorkflowExit(report InstallWorkflowReport) InstallWorkflowExit { + switch { + case report.Canceled: + return InstallWorkflowExit{OK: false, Code: "canceled", Message: InstallUX.Canceled} + case len(report.Selection.Errors) > 0: + return InstallWorkflowExit{OK: false, Code: "selection-error", Message: InstallUX.SelectionError} + case len(report.Report.Conflicts) > 0: + return InstallWorkflowExit{OK: false, Code: "conflict", Message: InstallUX.Conflict} + case len(report.Report.Errors) > 0: + return InstallWorkflowExit{OK: false, Code: "error", Message: InstallUX.Failed} + default: + return InstallWorkflowExit{OK: true, Code: "ok"} + } +} + +func InstallWorkflowError(report InstallWorkflowReport) error { + exit := ClassifyInstallWorkflowExit(report) + if exit.OK || exit.Code == "canceled" { + return nil + } + return errors.New(exit.Message) +} + +func InstallFlagError(errs []map[string]any) error { + if len(errs) == 0 { + return nil + } + return errors.New(InstallUX.InvalidFlags) +} + +func RunBundledSkillInstall(opts InstallWorkflowOptions) (InstallWorkflowReport, error) { + in, out := opts.In, opts.Out + if in == nil { + in = os.Stdin + } + if out == nil { + out = os.Stdout + } + if !opts.StdinTTY { + if file, ok := in.(*os.File); ok { + if info, err := file.Stat(); err == nil { + opts.StdinTTY = info.Mode()&os.ModeCharDevice != 0 + } + } + } + reader := bufio.NewReader(in) + scope, selection, err := resolveWorkflowScope(reader, out, opts.Scope, opts.ScopeSet, opts.PromptScope, opts.DefaultScope, opts.Yes, opts.StdinTTY) + if err != nil { + return InstallWorkflowReport{}, err + } + if len(selection.Errors) == 0 { + selection, err = ResolveInstallSelection(InstallSelectionOptions{ + BaseOptions: opts.BaseOptions, Scope: scope, Agents: opts.Agents, + Yes: opts.Yes, StdinTTY: opts.StdinTTY, CurrentAgent: opts.CurrentAgent, + }) + if err != nil { + return InstallWorkflowReport{}, err + } + } + result := InstallWorkflowReport{Selection: selection, Scope: scope, Plan: emptyInstallReport(nil), Report: emptyInstallReport(nil), DryRun: opts.DryRun} + if selection.Action == "error" { + renderSelectionErrors(out, selection) + return result, nil + } + if selection.Action == "select-agents" { + hosts, err := LoadHostSpec(opts.HostsFile) + if err != nil { + return InstallWorkflowReport{}, err + } + selected, err := promptAgentSelection(reader, out, selection, hosts) + if err != nil { + return InstallWorkflowReport{}, err + } + result.Selection = installSelection(selected, selection.DetectedHostIDs, !opts.Yes && opts.StdinTTY, nil) + if len(selected) == 0 { + result.Canceled = true + return result, nil + } + } + installOpts := opts.InstallOptions + installOpts.Agents = ExplicitAgents(result.Selection.SelectedHostIDs...) + installOpts.Scope = scope + plan, err := PlanBundledSkill(installOpts) + if err != nil { + return InstallWorkflowReport{}, err + } + result.Plan, result.Report = plan, plan + if len(plan.Installed)+len(plan.Updated)+len(plan.Conflicts)+len(plan.Errors) == 0 { + return result, nil + } + if opts.DryRun { + renderInstallSummary(out, plan) + return result, nil + } + if len(plan.Conflicts)+len(plan.Errors) > 0 { + result.Report.Installed, result.Report.Updated = []TargetResult{}, []TargetResult{} + return result, nil + } + renderInstallSummary(out, plan) + if result.Selection.NeedsConfirmation { + confirmed, err := promptConfirmation(reader, out) + if err != nil { + return InstallWorkflowReport{}, err + } + if !confirmed { + result.Canceled, result.Report = true, emptyInstallReport(nil) + return result, nil + } + } + result.Report, err = InstallBundledSkill(installOpts) + if err != nil { + return InstallWorkflowReport{}, err + } + return result, nil +} + +func resolveWorkflowScope(reader *bufio.Reader, out io.Writer, requested Scope, scopeSet, promptScope bool, defaultScope Scope, yes, stdinTTY bool) (Scope, InstallSelection, error) { + if defaultScope == "" { + defaultScope = UserScope + } + if requested == "" { + requested = defaultScope + } + if scopeSet || !promptScope { + return requested, InstallSelection{}, nil + } + if yes { + return defaultScope, InstallSelection{}, nil + } + if !stdinTTY { + return "", errorSelection([]map[string]any{{"reason": "scope-selection-required"}}, nil), nil + } + scope, err := promptScopeSelection(reader, out, defaultScope) + return scope, InstallSelection{}, err +} + +func promptScopeSelection(reader *bufio.Reader, out io.Writer, defaultScope Scope) (Scope, error) { + for { + fmt.Fprintln(out, InstallUX.SelectScope) + fmt.Fprintf(out, " 1. %s\n", UserScope) + fmt.Fprintf(out, " 2. %s\n", ProjectScope) + fmt.Fprintf(out, "%s [%s]: ", InstallUX.ScopePrompt, defaultScope) + line, err := readPromptLine(reader) + if err != nil { + return "", err + } + scope, ok := parseScopeSelection(line, defaultScope) + if ok { + return scope, nil + } + fmt.Fprintln(out, InstallUX.InvalidScopeSelection) + } +} + +func parseScopeSelection(line string, defaultScope Scope) (Scope, bool) { + switch strings.ToLower(strings.TrimSpace(line)) { + case "": + return defaultScope, true + case "1", "u", "user": + return UserScope, true + case "2", "p", "project": + return ProjectScope, true + default: + return "", false + } +} + +func promptAgentSelection(reader *bufio.Reader, out io.Writer, selection InstallSelection, hosts []Host) ([]string, error) { + candidates := hostsByID(hosts, selection.CandidateHostIDs) + for { + fmt.Fprintln(out, InstallUX.SelectAgents) + for i, host := range candidates { + fmt.Fprintf(out, " %d. %s (%s)\n", i+1, host.DisplayName, host.ID) + } + suffix := "" + if len(selection.SelectedHostIDs) > 0 { + suffix = " [" + strings.Join(selection.SelectedHostIDs, ",") + "]" + } + fmt.Fprintf(out, "%s%s: ", InstallUX.AgentsPrompt, suffix) + line, err := readPromptLine(reader) + if err != nil { + return nil, err + } + selected, ok := parseAgentSelection(line, selection, candidates) + if ok { + return selected, nil + } + fmt.Fprintln(out, InstallUX.InvalidAgentSelection) + } +} + +func parseAgentSelection(line string, selection InstallSelection, candidates []Host) ([]string, bool) { + line = strings.TrimSpace(line) + if line == "" { + return selection.SelectedHostIDs, true + } + if line == "*" { + return hostIDList(candidates), true + } + byName := map[string]string{} + for index, host := range candidates { + byName[strconv.Itoa(index+1)] = host.ID + byName[host.ID] = host.ID + for _, alias := range host.Aliases { + byName[alias] = host.ID + } + } + seen := map[string]bool{} + selected := []string{} + for _, part := range strings.FieldsFunc(line, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) { + id, ok := byName[part] + if !ok { + return nil, false + } + if !seen[id] { + seen[id] = true + selected = append(selected, id) + } + } + return selected, true +} + +func promptConfirmation(reader *bufio.Reader, out io.Writer) (bool, error) { + fmt.Fprint(out, InstallUX.Proceed) + line, err := readPromptLine(reader) + if err != nil { + return false, err + } + line = strings.ToLower(strings.TrimSpace(line)) + return line == "y" || line == "yes", nil +} + +func readPromptLine(reader *bufio.Reader) (string, error) { + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil +} + +func renderInstallSummary(out io.Writer, report InstallReport) { + for _, item := range append(append([]TargetResult{}, report.Installed...), report.Updated...) { + for _, host := range summaryHosts(item) { + fmt.Fprintf(out, " - %s -> %s (%s)\n", item.SkillName, item.TargetDir, host) + } + } +} + +func summaryHosts(item TargetResult) []string { + if item.HostID != "" { + return []string{item.HostID} + } + return item.HostIDs +} + +func renderSelectionErrors(out io.Writer, selection InstallSelection) { + for _, err := range selection.Errors { + fmt.Fprintf(out, "%s %s\n", InstallUX.ErrorPrefix, err["reason"]) + } +} diff --git a/python/src/kitup/__init__.py b/python/src/kitup/__init__.py index 8be6879..3f7abc4 100644 --- a/python/src/kitup/__init__.py +++ b/python/src/kitup/__init__.py @@ -7,11 +7,10 @@ validate_skill_bundle, with_bundle_metadata, ) -from .hosts import detect_hosts, load_host_spec, resolve_hosts +from .hosts import detect_hosts, load_host_spec, resolve_hosts, resolve_install_targets from .install import ( install_bundled_skill, plan_bundled_skill, - resolve_install_targets, read_installed_metadata, status_bundled_skill, uninstall_bundled_skill, diff --git a/python/src/kitup/_metadata.py b/python/src/kitup/_metadata.py index a3d9053..63680ae 100644 --- a/python/src/kitup/_metadata.py +++ b/python/src/kitup/_metadata.py @@ -4,6 +4,8 @@ import re from pathlib import Path +from .types import InstalledMetadata + _SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") @@ -11,41 +13,22 @@ def is_valid_skill_name(skill_name: str) -> bool: return bool(_SKILL_NAME_RE.fullmatch(skill_name)) -def write_install_metadata( - target_dir: Path, - *, - app_id: str, - skill_name: str, - digest: str, - source: str, - source_id: str | None = None, - version: str | None = None, - cli_version: str | None = None, - cli_revision: str | None = None, - provenance: dict[str, str] | None = None, -) -> None: - payload = { - "schemaVersion": 1, - "appId": app_id, - "skillName": skill_name, - "source": source, - "hash": digest, - } - if source_id is not None: - payload["sourceId"] = source_id - if version is not None: - payload["version"] = version - if cli_version is not None: - payload["cliVersion"] = cli_version - if cli_revision is not None: - payload["cliRevision"] = cli_revision - if provenance is not None: - payload["provenance"] = provenance +def write_install_metadata(target_dir: Path, payload: dict[str, object]) -> None: (target_dir / ".kitup.json").write_text( json.dumps(payload, indent=2) + "\n", encoding="utf-8" ) +def ownership_conflict( + metadata: dict[str, object] | None, app_id: str, skill_name: str +) -> str | None: + if metadata is None or metadata.get("skillName") != skill_name: + return "unmanaged" + if metadata.get("appId") != app_id: + return "owner-mismatch" + return None + + def read_install_metadata(target_dir: Path) -> dict[str, object] | None: metadata_file = target_dir / ".kitup.json" if not metadata_file.exists(): @@ -91,3 +74,58 @@ def is_owned_metadata(payload: dict[str, object]) -> bool: for key, value in provenance.items() ) ) + + +def _installed_metadata(payload: dict[str, object]) -> InstalledMetadata: + return InstalledMetadata( + schema_version=1, + app_id=str(payload["appId"]), + skill_name=str(payload["skillName"]), + source=str(payload["source"]), + hash=str(payload["hash"]), + source_id=_nonempty_metadata_text(payload, "sourceId"), + version=_nonempty_metadata_text(payload, "version"), + cli_version=_nonempty_metadata_text(payload, "cliVersion"), + cli_revision=_nonempty_metadata_text(payload, "cliRevision"), + provenance=_metadata_provenance(payload) or None, + ) + + +def _installed_metadata_dict( + *, app_id: str, skill_name: str, digest: str, metadata: dict[str, object] +) -> dict[str, object]: + value: dict[str, object] = { + "schemaVersion": 1, + "appId": app_id, + "skillName": skill_name, + "source": metadata["source"], + "hash": digest, + } + for source_key, target_key in ( + ("source_id", "sourceId"), + ("version", "version"), + ("cli_version", "cliVersion"), + ("cli_revision", "cliRevision"), + ): + field_value = _metadata_text(metadata, source_key) + if field_value is not None: + value[target_key] = field_value + provenance = _metadata_provenance(metadata) + if provenance: + value["provenance"] = provenance + return value + + +def _metadata_text(metadata: dict[str, object], key: str) -> str | None: + value = metadata.get(key) + return value if isinstance(value, str) else None + + +def _nonempty_metadata_text(metadata: dict[str, object], key: str) -> str | None: + value = _metadata_text(metadata, key) + return value or None + + +def _metadata_provenance(metadata: dict[str, object]) -> dict[str, object] | None: + value = metadata.get("provenance") + return value if isinstance(value, dict) else None diff --git a/python/src/kitup/bundle.py b/python/src/kitup/bundle.py index 79dc8c4..5eb7649 100644 --- a/python/src/kitup/bundle.py +++ b/python/src/kitup/bundle.py @@ -11,8 +11,8 @@ except ImportError: from importlib.abc import Traversable -from ._github import fetch_github_directory -from ._metadata import is_valid_skill_name +from ._github import fetch_github_directory, fetch_github_directory_with_metadata +from ._metadata import is_valid_skill_name, _metadata_text, _metadata_provenance from ._paths import normalize_bundle_path, resolve_path, skip_name from .types import ( BundleFile, @@ -234,3 +234,46 @@ def _parse_frontmatter(content: str) -> dict[str, str]: key, value = line.split(":", 1) fields[key] = value.strip() return fields + + +def _resolve_bundle_and_metadata( + skill_bundle: object, *, cwd: str | None +) -> tuple[object, dict[str, object]]: + if isinstance(skill_bundle, MetadataBundle): + normalized, metadata = _resolve_bundle_and_metadata( + skill_bundle.bundle, cwd=cwd + ) + supplied = skill_bundle.metadata + provenance = { + **(_metadata_provenance(metadata) or {}), + **supplied.provenance, + } + metadata.update( + { + "source_id": supplied.source_id + or _metadata_text(metadata, "source_id"), + "cli_version": supplied.cli_version or None, + "cli_revision": supplied.cli_revision or None, + "provenance": provenance or None, + "explicit": True, + } + ) + return normalized, metadata + if isinstance(skill_bundle, DirectoryBundle): + return normalize_directory_bundle(skill_bundle.path, cwd=cwd), { + "source": "bundled" + } + if isinstance(skill_bundle, FilesBundle): + return normalize_files_bundle(skill_bundle.files), {"source": "bundled"} + if isinstance(skill_bundle, GitHubBundle): + files, metadata = fetch_github_directory_with_metadata(skill_bundle.options) + return normalize_files_bundle(files), metadata + raise TypeError(f"unsupported bundle: {type(skill_bundle)!r}") + + +def _is_github_bundle(skill_bundle: object) -> bool: + if isinstance(skill_bundle, GitHubBundle): + return True + if isinstance(skill_bundle, MetadataBundle): + return _is_github_bundle(skill_bundle.bundle) + return False diff --git a/python/src/kitup/hosts.py b/python/src/kitup/hosts.py index 329c0a4..2ec0970 100644 --- a/python/src/kitup/hosts.py +++ b/python/src/kitup/hosts.py @@ -2,7 +2,16 @@ from pathlib import Path from ._hosts_generated import DEFAULT_HOSTS_SPEC_JSON -from .types import BaseOptions, Host, HostSpec, KitupError, Scope +from ._metadata import is_valid_skill_name, read_install_metadata +from .types import ( + BaseOptions, + Host, + HostSpec, + KitupError, + Scope, + TargetError, + TargetGroup, +) _GENERIC_DETECT_PATHS = { "~/.agents", @@ -145,3 +154,138 @@ def _expand_host_path(path: str, *, home: Path, cwd: Path) -> Path: if path.startswith("~/"): return home / path[2:] return cwd / path + + +def choose_scope_path( + host: Host, + *, + scope: Scope, + home: Path, + cwd: Path, + skill_name: str, +) -> Path | None: + paths = host.user_skills_dirs if scope == "user" else host.project_skills_dirs + existing = [ + expanded + for path in paths + if (expanded := _expand_host_path(path, home=home, cwd=cwd)).is_dir() + ] + for root in existing: + metadata = read_install_metadata(root / skill_name) + if metadata and metadata.get("skillName") == skill_name: + return root + if existing: + return existing[0] + if not paths: + return None + return _expand_host_path(paths[0], home=home, cwd=cwd) + + +def _uninstall_scope_paths( + host: Host, + *, + scope: Scope, + home: Path, + cwd: Path, + skill_name: str, + app_id: str, +) -> list[Path]: + paths = host.user_skills_dirs if scope == "user" else host.project_skills_dirs + owned = [] + for path in paths: + root = _expand_host_path(path, home=home, cwd=cwd) + metadata = read_install_metadata(root / skill_name) + if ( + metadata + and metadata.get("skillName") == skill_name + and metadata.get("appId") == app_id + ): + owned.append(root) + if owned: + return owned + fallback = choose_scope_path( + host, + scope=scope, + home=home, + cwd=cwd, + skill_name=skill_name, + ) + return [fallback] if fallback is not None else [] + + +def resolve_install_targets( + options: BaseOptions, + agents: str | list[str] | None, + scope: Scope, + skill_name: str, +) -> list[TargetGroup]: + targets, _ = _resolve_install_targets_with_errors( + options, agents, scope, skill_name + ) + return targets + + +def _resolve_install_targets_with_errors( + options: BaseOptions, + agents: str | list[str] | None, + scope: Scope, + skill_name: str, + *, + uninstall_app_id: str | None = None, +) -> tuple[list[TargetGroup], list[TargetError]]: + if not is_valid_skill_name(skill_name): + return [], [TargetError(reason="invalid-skill-name", skill_name=skill_name)] + + spec = load_host_spec(options.hosts_file) + home = Path(options.home).expanduser() if options.home else Path.home() + cwd = Path(options.cwd) if options.cwd else Path.cwd() + if agents in (None, "auto"): + selected = detect_hosts(options, scope) + errors: list[TargetError] = [] + else: + selected, resolution_errors = resolve_hosts(agents, spec.hosts) + errors = [ + TargetError(reason=error["reason"], agent=error["agent"]) + for error in resolution_errors + ] + + by_target: dict[str, TargetGroup] = {} + for host in selected: + if uninstall_app_id is not None: + roots = _uninstall_scope_paths( + host, + scope=scope, + home=home, + cwd=cwd, + skill_name=skill_name, + app_id=uninstall_app_id, + ) + else: + root = choose_scope_path( + host, + scope=scope, + home=home, + cwd=cwd, + skill_name=skill_name, + ) + roots = [root] if root is not None else [] + if not roots: + errors.append( + TargetError( + reason="unsupported-scope", + host_id=host.id, + skill_name=skill_name, + scope=scope, + ) + ) + continue + for root in roots: + target_dir = str(root / skill_name) + group = by_target.get(target_dir) + if group is None: + group = TargetGroup(skill_name=skill_name, target_dir=target_dir) + by_target[target_dir] = group + if host.id not in group.host_ids: + group.host_ids.append(host.id) + + return [by_target[path] for path in sorted(by_target)], errors diff --git a/python/src/kitup/install.py b/python/src/kitup/install.py index d7e9835..a97de77 100644 --- a/python/src/kitup/install.py +++ b/python/src/kitup/install.py @@ -1,38 +1,33 @@ from __future__ import annotations +from dataclasses import asdict import stat import shutil import tempfile from pathlib import Path -from ._github import fetch_github_directory_with_metadata from ._metadata import ( - is_valid_skill_name, + _installed_metadata, + _installed_metadata_dict, + ownership_conflict, read_install_metadata, write_install_metadata, ) from .bundle import ( - DirectoryBundle, - FilesBundle, - GitHubBundle, - MetadataBundle, + _resolve_bundle_and_metadata, + _is_github_bundle, copy_normalized_bundle, compute_normalized_bundle_content_hash, - normalize_directory_bundle, - normalize_files_bundle, validate_normalized_skill_bundle, ) -from .hosts import detect_hosts, load_host_spec, resolve_hosts +from .hosts import _resolve_install_targets_with_errors from .types import ( - BaseOptions, BundleFile, - Host, InstalledMetadata, InstalledTarget, InstallOptions, InstallReport, KitupError, - Scope, StatusOptions, StatusReport, TargetError, @@ -44,116 +39,19 @@ ) -def expand_host_path(path: str, *, home: Path, cwd: Path) -> Path: - if path.startswith("~/"): - return home / path[2:] - return cwd / path - - -def choose_scope_path( - host: Host, - *, - scope: Scope, - home: Path, - cwd: Path, - skill_name: str, -) -> Path | None: - paths = host.user_skills_dirs if scope == "user" else host.project_skills_dirs - existing = [ - expanded - for path in paths - if (expanded := expand_host_path(path, home=home, cwd=cwd)).is_dir() - ] - for root in existing: - metadata = read_install_metadata(root / skill_name) - if metadata and metadata.get("skillName") == skill_name: - return root - if existing: - return existing[0] - if not paths: - return None - return expand_host_path(paths[0], home=home, cwd=cwd) - - -def _uninstall_scope_paths( - host: Host, - *, - scope: Scope, - home: Path, - cwd: Path, - skill_name: str, - app_id: str, -) -> list[Path]: - paths = host.user_skills_dirs if scope == "user" else host.project_skills_dirs - owned = [] - for path in paths: - root = expand_host_path(path, home=home, cwd=cwd) - metadata = read_install_metadata(root / skill_name) - if ( - metadata - and metadata.get("skillName") == skill_name - and metadata.get("appId") == app_id - ): - owned.append(root) - if owned: - return owned - fallback = choose_scope_path( - host, - scope=scope, - home=home, - cwd=cwd, - skill_name=skill_name, - ) - return [fallback] if fallback is not None else [] - - -def resolve_install_targets( - options: BaseOptions, - agents: str | list[str] | None, - scope: Scope, - skill_name: str, -) -> list[TargetGroup]: - targets, _ = _resolve_install_targets_with_errors( - options, agents, scope, skill_name - ) - return targets - - -def empty_install_report(errors: list[TargetError] | None = None) -> InstallReport: - return InstallReport(errors=errors or []) - - -def empty_uninstall_report(errors: list[TargetError] | None = None) -> UninstallReport: - return UninstallReport(errors=errors or []) - - -def empty_status_report(errors: list[TargetError] | None = None) -> StatusReport: - return StatusReport(errors=errors or []) - - def target_result(target: TargetGroup) -> TargetResult: - if len(target.host_ids) == 1: - return TargetResult( - host_id=target.host_ids[0], - skill_name=target.skill_name, - target_dir=target.target_dir, - ) + hosts = ( + {"host_id": target.host_ids[0]} + if len(target.host_ids) == 1 + else {"host_ids": list(target.host_ids)} + ) return TargetResult( - host_ids=list(target.host_ids), - skill_name=target.skill_name, - target_dir=target.target_dir, + skill_name=target.skill_name, target_dir=target.target_dir, **hosts ) def target_status(target: TargetGroup, reason: str) -> TargetStatus: - result = target_result(target) - return TargetStatus( - host_id=result.host_id, - host_ids=result.host_ids, - skill_name=result.skill_name, - target_dir=result.target_dir, - reason=reason, - ) + return TargetStatus(**asdict(target_result(target)), reason=reason) def plan_bundled_skill(options: InstallOptions) -> InstallReport: @@ -172,9 +70,6 @@ def write_managed_bundle( target_dir: Path, *, files: list[BundleFile], - app_id: str, - skill_name: str, - digest: str, metadata: dict[str, object], replace: bool, ) -> None: @@ -189,13 +84,7 @@ def write_managed_bundle( try: staged_dir.chmod(0o755) copy_normalized_bundle(files, staged_dir) - write_bundle_metadata( - staged_dir, - app_id=app_id, - skill_name=skill_name, - digest=digest, - metadata=metadata, - ) + write_install_metadata(staged_dir, metadata) if replace and target_dir.exists(): backup_dir = Path( tempfile.mkdtemp( @@ -218,8 +107,7 @@ def write_managed_bundle( def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: if not options.app_id: - return empty_install_report([TargetError(reason="invalid-app-id")]) - + return InstallReport(errors=[TargetError(reason="invalid-app-id")]) try: normalized, bundle_metadata = _resolve_bundle_and_metadata( options.skill_bundle, cwd=options.base.cwd @@ -230,109 +118,45 @@ def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: if _is_github_bundle(options.skill_bundle) else "invalid-skill-bundle" ) - return empty_install_report([TargetError(reason=reason)]) - + return InstallReport(errors=[TargetError(reason=reason)]) info = validate_normalized_skill_bundle(normalized) if not info.valid or not info.skill_name: - return empty_install_report( - [TargetError(reason=info.error_code or "invalid-skill-bundle")] + return InstallReport( + errors=[TargetError(reason=info.error_code or "invalid-skill-bundle")] ) - digest = compute_normalized_bundle_content_hash(normalized) + desired = _installed_metadata_dict( + app_id=options.app_id, + skill_name=info.skill_name, + digest=digest, + metadata=bundle_metadata, + ) targets, errors = _resolve_install_targets_with_errors( - options.base, - options.agents, - options.scope, - info.skill_name, + options.base, options.agents, options.scope, info.skill_name ) - report = empty_install_report(errors) + report = InstallReport(errors=errors) for target in targets: - result = target_result(target) target_dir = Path(target.target_dir) metadata = read_install_metadata(target_dir) - - if not target_dir.exists(): - if write: - write_managed_bundle( - target_dir, - app_id=options.app_id, - skill_name=info.skill_name, - digest=digest, - metadata=bundle_metadata, - files=normalized.files, - replace=False, - ) - report.installed.append(result) + exists = target_dir.exists() + conflict = ownership_conflict(metadata, options.app_id, info.skill_name) + if exists and conflict and not options.force: + report.conflicts.append(target_status(target, conflict)) continue - - if metadata is None or metadata.get("skillName") != info.skill_name: - if options.force: - if write: - write_managed_bundle( - target_dir, - app_id=options.app_id, - skill_name=info.skill_name, - digest=digest, - metadata=bundle_metadata, - files=normalized.files, - replace=True, - ) - report.updated.append(result) - else: - report.conflicts.append(target_status(target, "unmanaged")) - continue - if metadata.get("appId") != options.app_id: - if options.force: - if write: - write_managed_bundle( - target_dir, - app_id=options.app_id, - skill_name=info.skill_name, - digest=digest, - metadata=bundle_metadata, - files=normalized.files, - replace=True, - ) - report.updated.append(result) - else: - report.conflicts.append(target_status(target, "owner-mismatch")) - continue - if metadata.get("hash") == digest: + if exists and not conflict and metadata.get("hash") == digest: repaired = repair_bundle_modes(normalized.files, target_dir, write=write) - metadata_changed = bool( - bundle_metadata.get("explicit") - ) and metadata != _installed_metadata_dict( - app_id=options.app_id, - skill_name=info.skill_name, - digest=digest, - metadata=bundle_metadata, - ) - if repaired or metadata_changed: - if write: - write_bundle_metadata( - target_dir, - app_id=options.app_id, - skill_name=info.skill_name, - digest=digest, - metadata=bundle_metadata, - ) - report.updated.append(result) - else: + if not repaired and not ( + bundle_metadata.get("explicit") and metadata != desired + ): report.skipped.append(target_status(target, "unchanged")) - continue - - if write: + continue + if write: + write_install_metadata(target_dir, desired) + elif write: write_managed_bundle( - target_dir, - app_id=options.app_id, - skill_name=info.skill_name, - digest=digest, - metadata=bundle_metadata, - files=normalized.files, - replace=True, + target_dir, files=normalized.files, metadata=desired, replace=exists ) - report.updated.append(result) - + (report.updated if exists else report.installed).append(target_result(target)) return report @@ -353,31 +177,9 @@ def repair_bundle_modes( return repaired -def write_bundle_metadata( - target_dir: Path, - *, - app_id: str, - skill_name: str, - digest: str, - metadata: dict[str, object], -) -> None: - write_install_metadata( - target_dir, - app_id=app_id, - skill_name=skill_name, - digest=digest, - source=str(metadata["source"]), - source_id=_metadata_text(metadata, "source_id"), - version=_metadata_text(metadata, "version"), - cli_version=_metadata_text(metadata, "cli_version"), - cli_revision=_metadata_text(metadata, "cli_revision"), - provenance=_metadata_provenance(metadata), - ) - - def uninstall_bundled_skill(options: UninstallOptions) -> UninstallReport: if not options.app_id: - return empty_uninstall_report([TargetError(reason="invalid-app-id")]) + return UninstallReport(errors=[TargetError(reason="invalid-app-id")]) targets, errors = _resolve_install_targets_with_errors( options.base, @@ -386,7 +188,7 @@ def uninstall_bundled_skill(options: UninstallOptions) -> UninstallReport: options.skill_name, uninstall_app_id=options.app_id, ) - report = empty_uninstall_report(errors) + report = UninstallReport(errors=errors) for target in targets: result = target_result(target) target_dir = Path(target.target_dir) @@ -395,11 +197,9 @@ def uninstall_bundled_skill(options: UninstallOptions) -> UninstallReport: if not target_dir.exists(): report.skipped.append(target_status(target, "missing")) continue - if metadata is None or metadata.get("skillName") != options.skill_name: - report.conflicts.append(target_status(target, "unmanaged")) - continue - if metadata.get("appId") != options.app_id: - report.conflicts.append(target_status(target, "owner-mismatch")) + conflict = ownership_conflict(metadata, options.app_id, options.skill_name) + if conflict: + report.conflicts.append(target_status(target, conflict)) continue reason = _remove_managed_skill( @@ -417,7 +217,7 @@ def uninstall_bundled_skill(options: UninstallOptions) -> UninstallReport: def status_bundled_skill(options: StatusOptions) -> StatusReport: if not options.app_id: - return empty_status_report([TargetError(reason="invalid-app-id")]) + return StatusReport(errors=[TargetError(reason="invalid-app-id")]) targets, errors = _resolve_install_targets_with_errors( options.base, options.agents, @@ -425,24 +225,21 @@ def status_bundled_skill(options: StatusOptions) -> StatusReport: options.skill_name, uninstall_app_id=options.app_id, ) - report = empty_status_report(errors) + report = StatusReport(errors=errors) for target in targets: result = target_result(target) target_dir = Path(target.target_dir) metadata = read_install_metadata(target_dir) if not target_dir.exists(): report.missing.append(result) - elif metadata is None or metadata.get("skillName") != options.skill_name: - report.conflicts.append(target_status(target, "unmanaged")) - elif metadata.get("appId") != options.app_id: - report.conflicts.append(target_status(target, "owner-mismatch")) + elif conflict := ownership_conflict( + metadata, options.app_id, options.skill_name + ): + report.conflicts.append(target_status(target, conflict)) else: report.installed.append( InstalledTarget( - host_id=result.host_id, - host_ids=result.host_ids, - skill_name=result.skill_name, - target_dir=result.target_dir, + **asdict(result), metadata=_installed_metadata(metadata), ) ) @@ -461,49 +258,6 @@ def read_installed_metadata( return _installed_metadata(metadata) -def _resolve_bundle_and_metadata( - skill_bundle: object, *, cwd: str | None -) -> tuple[object, dict[str, object]]: - if isinstance(skill_bundle, MetadataBundle): - normalized, metadata = _resolve_bundle_and_metadata( - skill_bundle.bundle, cwd=cwd - ) - supplied = skill_bundle.metadata - provenance = { - **(_metadata_provenance(metadata) or {}), - **supplied.provenance, - } - metadata.update( - { - "source_id": supplied.source_id - or _metadata_text(metadata, "source_id"), - "cli_version": supplied.cli_version or None, - "cli_revision": supplied.cli_revision or None, - "provenance": provenance or None, - "explicit": True, - } - ) - return normalized, metadata - if isinstance(skill_bundle, DirectoryBundle): - return normalize_directory_bundle(skill_bundle.path, cwd=cwd), { - "source": "bundled" - } - if isinstance(skill_bundle, FilesBundle): - return normalize_files_bundle(skill_bundle.files), {"source": "bundled"} - if isinstance(skill_bundle, GitHubBundle): - files, metadata = fetch_github_directory_with_metadata(skill_bundle.options) - return normalize_files_bundle(files), metadata - raise TypeError(f"unsupported bundle: {type(skill_bundle)!r}") - - -def _is_github_bundle(skill_bundle: object) -> bool: - if isinstance(skill_bundle, GitHubBundle): - return True - if isinstance(skill_bundle, MetadataBundle): - return _is_github_bundle(skill_bundle.bundle) - return False - - def _remove_managed_skill( target_dir: Path, *, app_id: str, skill_name: str ) -> str | None: @@ -516,11 +270,7 @@ def _remove_managed_skill( quarantine.rmdir() target_dir.replace(quarantine) metadata = read_install_metadata(quarantine) - reason = None - if metadata is None or metadata.get("skillName") != skill_name: - reason = "unmanaged" - elif metadata.get("appId") != app_id: - reason = "owner-mismatch" + reason = ownership_conflict(metadata, app_id, skill_name) if reason is not None: if target_dir.exists(): raise KitupError(f"cannot restore changed install: {target_dir}") @@ -528,124 +278,3 @@ def _remove_managed_skill( return reason shutil.rmtree(quarantine) return None - - -def _installed_metadata(payload: dict[str, object]) -> InstalledMetadata: - return InstalledMetadata( - schema_version=1, - app_id=str(payload["appId"]), - skill_name=str(payload["skillName"]), - source=str(payload["source"]), - hash=str(payload["hash"]), - source_id=_nonempty_metadata_text(payload, "sourceId"), - version=_nonempty_metadata_text(payload, "version"), - cli_version=_nonempty_metadata_text(payload, "cliVersion"), - cli_revision=_nonempty_metadata_text(payload, "cliRevision"), - provenance=_metadata_provenance(payload) or None, - ) - - -def _installed_metadata_dict( - *, app_id: str, skill_name: str, digest: str, metadata: dict[str, object] -) -> dict[str, object]: - value: dict[str, object] = { - "schemaVersion": 1, - "appId": app_id, - "skillName": skill_name, - "source": metadata["source"], - "hash": digest, - } - for source_key, target_key in ( - ("source_id", "sourceId"), - ("version", "version"), - ("cli_version", "cliVersion"), - ("cli_revision", "cliRevision"), - ): - field_value = _metadata_text(metadata, source_key) - if field_value is not None: - value[target_key] = field_value - provenance = _metadata_provenance(metadata) - if provenance: - value["provenance"] = provenance - return value - - -def _resolve_install_targets_with_errors( - options: BaseOptions, - agents: str | list[str] | None, - scope: Scope, - skill_name: str, - *, - uninstall_app_id: str | None = None, -) -> tuple[list[TargetGroup], list[TargetError]]: - if not is_valid_skill_name(skill_name): - return [], [TargetError(reason="invalid-skill-name", skill_name=skill_name)] - - spec = load_host_spec(options.hosts_file) - home = Path(options.home).expanduser() if options.home else Path.home() - cwd = Path(options.cwd) if options.cwd else Path.cwd() - if agents in (None, "auto"): - selected = detect_hosts(options, scope) - errors: list[TargetError] = [] - else: - selected, resolution_errors = resolve_hosts(agents, spec.hosts) - errors = [ - TargetError(reason=error["reason"], agent=error["agent"]) - for error in resolution_errors - ] - - by_target: dict[str, TargetGroup] = {} - for host in selected: - if uninstall_app_id is not None: - roots = _uninstall_scope_paths( - host, - scope=scope, - home=home, - cwd=cwd, - skill_name=skill_name, - app_id=uninstall_app_id, - ) - else: - root = choose_scope_path( - host, - scope=scope, - home=home, - cwd=cwd, - skill_name=skill_name, - ) - roots = [root] if root is not None else [] - if not roots: - errors.append( - TargetError( - reason="unsupported-scope", - host_id=host.id, - skill_name=skill_name, - scope=scope, - ) - ) - continue - for root in roots: - target_dir = str(root / skill_name) - group = by_target.get(target_dir) - if group is None: - group = TargetGroup(skill_name=skill_name, target_dir=target_dir) - by_target[target_dir] = group - if host.id not in group.host_ids: - group.host_ids.append(host.id) - - return [by_target[path] for path in sorted(by_target)], errors - - -def _metadata_text(metadata: dict[str, object], key: str) -> str | None: - value = metadata.get(key) - return value if isinstance(value, str) else None - - -def _nonempty_metadata_text(metadata: dict[str, object], key: str) -> str | None: - value = _metadata_text(metadata, key) - return value or None - - -def _metadata_provenance(metadata: dict[str, object]) -> dict[str, object] | None: - value = metadata.get("provenance") - return value if isinstance(value, dict) else None diff --git a/python/src/kitup/workflow.py b/python/src/kitup/workflow.py index b867169..fa902f1 100644 --- a/python/src/kitup/workflow.py +++ b/python/src/kitup/workflow.py @@ -86,12 +86,6 @@ def resolve_install_selection(options: InstallSelectionOptions) -> InstallSelect ) if explicit_agents: - if options.agents == "*": - return _install_selection( - [host.id for host in spec.hosts], - [], - stdin_tty and not options.yes, - ) selected, errors = resolve_hosts(options.agents, spec.hosts) if errors: return _error_selection(errors, []) @@ -112,43 +106,32 @@ def resolve_install_selection(options: InstallSelectionOptions) -> InstallSelect if not detected_host_ids: return _error_selection([{"reason": "no-detected-hosts"}], []) return _install_selection(detected_host_ids, detected_host_ids, False) - if not detected_host_ids: - return _select_agents_selection( - [host.id for host in spec.hosts], detected_host_ids, [] - ) if len(detected_host_ids) == 1: return _install_selection(detected_host_ids, detected_host_ids, True) - return _select_agents_selection(detected_host_ids, detected_host_ids, []) + return InstallSelection( + action="select-agents", + selected_host_ids=[], + candidate_host_ids=detected_host_ids or [host.id for host in spec.hosts], + detected_host_ids=detected_host_ids, + needs_confirmation=True, + errors=[], + ) def classify_install_workflow_exit( report: InstallWorkflowReport | dict[str, object], ) -> InstallWorkflowExit: if _workflow_value(report, "canceled"): - return InstallWorkflowExit( - ok=False, code="canceled", message=INSTALL_UX["canceled"] - ) - selection = _workflow_value(report, "selection") - if _workflow_value(selection, "errors"): - return InstallWorkflowExit( - ok=False, - code="selection-error", - message=INSTALL_UX["selection_error"], - ) - run_report = _workflow_value(report, "report") - if _workflow_value(run_report, "conflicts"): - return InstallWorkflowExit( - ok=False, - code="conflict", - message=INSTALL_UX["conflict"], - ) - if _workflow_value(run_report, "errors"): - return InstallWorkflowExit( - ok=False, - code="error", - message=INSTALL_UX["failed"], - ) - return InstallWorkflowExit(ok=True, code="ok", message="") + code, message = "canceled", INSTALL_UX["canceled"] + elif _workflow_value(_workflow_value(report, "selection"), "errors"): + code, message = "selection-error", INSTALL_UX["selection_error"] + elif _workflow_value(_workflow_value(report, "report"), "conflicts"): + code, message = "conflict", INSTALL_UX["conflict"] + elif _workflow_value(_workflow_value(report, "report"), "errors"): + code, message = "error", INSTALL_UX["failed"] + else: + code, message = "ok", "" + return InstallWorkflowExit(ok=code == "ok", code=code, message=message) def install_flag_error(errors: list[dict[str, str]]) -> Exception | None: @@ -187,147 +170,81 @@ def run_bundled_skill_install_with_io( ) -> InstallWorkflowReport: reader = _LineReader(input) writer = _OutputWriter(output) - scope, scope_error = _resolve_workflow_scope( - reader=reader, - output=writer, - requested=options.install.scope, - scope_set=options.scope_set, - prompt_scope=options.prompt_scope, - configured_default=options.default_scope, - yes=options.yes, - stdin_tty=options.stdin_tty, - ) - if scope_error is not None: - _render_selection_errors(writer, scope_error) - return InstallWorkflowReport( - selection=scope_error, - scope=scope, - plan=InstallReport(), - report=InstallReport(), - canceled=False, - dry_run=options.dry_run, - ) - - selection = resolve_install_selection( - InstallSelectionOptions( - base=options.install.base, - scope=scope, - agents=options.install.agents, - yes=options.yes, - stdin_tty=options.stdin_tty, - current_agent=options.current_agent, + scope, selection = _resolve_workflow_scope(reader, writer, options) + if selection is None: + selection = resolve_install_selection( + InstallSelectionOptions( + base=options.install.base, + scope=scope, + agents=options.install.agents, + yes=options.yes, + stdin_tty=options.stdin_tty, + current_agent=options.current_agent, + ) ) + result = InstallWorkflowReport( + selection=selection, + scope=scope, + plan=InstallReport(), + report=InstallReport(), + canceled=False, + dry_run=options.dry_run, ) if selection.action == "error": _render_selection_errors(writer, selection) - return InstallWorkflowReport( - selection=selection, - scope=scope, - plan=InstallReport(), - report=InstallReport(), - canceled=False, - dry_run=options.dry_run, - ) + return result if selection.action == "select-agents": hosts = load_host_spec(options.install.base.hosts_file).hosts - selected_host_ids = _prompt_agent_selection(reader, writer, selection, hosts) + selected = _prompt_agent_selection(reader, writer, selection, hosts) selection = _install_selection( - selected_host_ids, - selection.detected_host_ids, - options.stdin_tty and not options.yes, + selected, selection.detected_host_ids, options.stdin_tty and not options.yes ) - if not selected_host_ids: - return InstallWorkflowReport( - selection=selection, - scope=scope, - plan=InstallReport(), - report=InstallReport(), - canceled=True, - dry_run=options.dry_run, - ) + result.selection = selection + if not selected: + result.canceled = True + return result install_options = replace( - options.install, - scope=scope, - agents=selection.selected_host_ids, + options.install, scope=scope, agents=selection.selected_host_ids ) plan = plan_bundled_skill(install_options) - if not _has_visible_install_plan(plan): - return InstallWorkflowReport( - selection=selection, - scope=scope, - plan=plan, - report=plan, - canceled=False, - dry_run=options.dry_run, - ) - + result.plan = result.report = plan + if not (plan.installed or plan.updated or plan.conflicts or plan.errors): + return result if options.dry_run: _render_install_summary(writer, plan) - return InstallWorkflowReport( - selection=selection, - scope=scope, - plan=plan, - report=plan, - canceled=False, - dry_run=True, - ) - if len(plan.conflicts) + len(plan.errors) > 0: - return InstallWorkflowReport( - selection=selection, - scope=scope, - plan=plan, - report=replace(plan, installed=[], updated=[]), - canceled=False, - dry_run=False, - ) + return result + if plan.conflicts or plan.errors: + result.report = replace(plan, installed=[], updated=[]) + return result _render_install_summary(writer, plan) if selection.needs_confirmation and not _prompt_confirmation(reader, writer): - return InstallWorkflowReport( - selection=selection, - scope=scope, - plan=plan, - report=InstallReport(), - canceled=True, - dry_run=False, - ) - - report = install_bundled_skill(install_options) - return InstallWorkflowReport( - selection=selection, - scope=scope, - plan=plan, - report=report, - canceled=False, - dry_run=False, - ) + result.report = InstallReport() + result.canceled = True + return result + result.report = install_bundled_skill(install_options) + return result def _resolve_workflow_scope( - *, reader: "_LineReader", - output: "_OutputWriter", - requested: Scope, - scope_set: bool, - prompt_scope: bool, - configured_default: Scope, - yes: bool, - stdin_tty: bool, + output: object, + options: InstallWorkflowOptions, ) -> tuple[Scope | str, InstallSelection | None]: - default_scope = configured_default or "user" - scope = requested or default_scope - if scope_set or not prompt_scope: + default_scope = options.default_scope or "user" + scope = options.install.scope or default_scope + if options.scope_set or not options.prompt_scope: return scope, None - if yes: + if options.yes: return default_scope, None - if not stdin_tty: + if not options.stdin_tty: return "", _error_selection([{"reason": "scope-selection-required"}], []) return _prompt_scope_selection(reader, output, default_scope), None def _prompt_scope_selection( reader: "_LineReader", - output: "_OutputWriter", + output: object, default_scope: Scope, ) -> Scope: while True: @@ -354,7 +271,7 @@ def _parse_scope_selection(line: str, default_scope: Scope) -> Scope | None: def _prompt_agent_selection( reader: "_LineReader", - output: "_OutputWriter", + output: object, selection: InstallSelection, hosts: list[object], ) -> list[str]: @@ -407,13 +324,13 @@ def _parse_agent_selection( return selected -def _prompt_confirmation(reader: "_LineReader", output: "_OutputWriter") -> bool: +def _prompt_confirmation(reader: "_LineReader", output: object) -> bool: output.write(INSTALL_UX["proceed"]) line = (reader.read_line() or "").strip().lower() return line in {"y", "yes"} -def _render_install_summary(output: "_OutputWriter", report: InstallReport) -> None: +def _render_install_summary(output: object, report: InstallReport) -> None: for item in [*report.installed, *report.updated]: for host_id in _summary_hosts(item): _write_line( @@ -429,27 +346,15 @@ def _summary_hosts(item: object) -> list[str]: return list(host_ids or []) -def _render_selection_errors( - output: "_OutputWriter", selection: InstallSelection -) -> None: +def _render_selection_errors(output: object, selection: InstallSelection) -> None: for error in selection.errors: _write_line(output, f"{INSTALL_UX['error_prefix']} {error['reason']}") -def _write_line(output: "_OutputWriter", line: str) -> None: +def _write_line(output: object, line: str) -> None: output.write(f"{line}\n") -def _has_visible_install_plan(report: InstallReport) -> bool: - return ( - len(report.installed) - + len(report.updated) - + len(report.conflicts) - + len(report.errors) - > 0 - ) - - def _install_selection( selected_host_ids: list[str], detected_host_ids: list[str], @@ -467,32 +372,10 @@ def _install_selection( ) -def _select_agents_selection( - candidate_host_ids: list[str], - detected_host_ids: list[str], - selected_host_ids: list[str], -) -> InstallSelection: - return InstallSelection( - action="select-agents", - selected_host_ids=selected_host_ids, - candidate_host_ids=candidate_host_ids, - detected_host_ids=detected_host_ids, - needs_confirmation=True, - errors=[], - ) - - def _error_selection( errors: list[dict[str, str]], detected_host_ids: list[str] ) -> InstallSelection: - return InstallSelection( - action="error", - selected_host_ids=[], - candidate_host_ids=[], - detected_host_ids=detected_host_ids, - needs_confirmation=False, - errors=list(errors), - ) + return _install_selection([], detected_host_ids, False, errors) def _add_universal_host(selected: list[object], hosts: list[object]) -> list[object]: @@ -532,10 +415,7 @@ def __init__(self, source: object | None) -> None: and hasattr(source, "readline") else None ) - self._lines: list[str] = ( - [] if self._stream is not None else list(self._iter_lines(source)) - ) - self._index = 0 + self._lines = iter([] if self._stream is not None else self._iter_lines(source)) def read_line(self) -> str | None: if self._stream is not None: @@ -546,11 +426,7 @@ def read_line(self) -> str | None: if line == "": return None return line.rstrip("\n").rstrip("\r") - if self._index >= len(self._lines): - return None - line = self._lines[self._index] - self._index += 1 - return line + return next(self._lines, None) def _iter_lines(self, source: object | None) -> Iterable[str]: if source is None: @@ -580,6 +456,5 @@ def __init__(self, target: object | None) -> None: self._target = target def write(self, chunk: str) -> None: - if self._target is None: - return - self._target.write(chunk) + if self._target is not None: + self._target.write(chunk) diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 0000000..bdb8da5 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,63 @@ +from dataclasses import replace +import json + +import pytest + +from kitup import BaseOptions, InstallOptions, directory_bundle + + +@pytest.fixture +def base(tmp_path): + for name in ("home", "workspace"): + (tmp_path / name).mkdir() + return BaseOptions(home=str(tmp_path / "home"), cwd=str(tmp_path / "workspace")) + + +@pytest.fixture +def install_options(base, tmp_path): + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_text("---\nname: basic\ndescription: demo\n---\n") + return InstallOptions( + base, "example-cli", directory_bundle(str(skill)), "user", ["codex"] + ) + + +@pytest.fixture +def host_options(base, tmp_path): + hosts = { + "codex": ( + "Codex", + ".agents/skills", + ["~/.agents/skills", "~/.codex/skills"], + ["~/.codex", "~/.agents/skills", "~/.agents"], + ), + "claude-code": ( + "Claude Code", + ".claude/skills", + ["~/.claude/skills"], + ["~/.claude"], + ), + "eve": ("Eve", "agent/skills", [], ["agent", "package.json"]), + "generic": ("Generic", ".agents/skills", ["~/.agents/skills"], ["~/.agents"]), + } + + def write(ids): + records = [] + for host_id in ids: + display, project, user, detect = hosts[host_id] + records.append( + dict( + id=host_id, + displayName=display, + projectSkillsDirs=[project], + userSkillsDirs=user, + detect=detect, + status="community" if host_id in ("eve", "generic") else "verified", + ) + ) + path = tmp_path / "hosts.json" + path.write_text(json.dumps({"schemaVersion": 1, "hosts": records})) + return replace(base, hosts_file=str(path)) + + return write diff --git a/python/tests/golden_test.py b/python/tests/golden_test.py index d1a337d..1743ebe 100644 --- a/python/tests/golden_test.py +++ b/python/tests/golden_test.py @@ -1,623 +1,152 @@ -from __future__ import annotations - -from dataclasses import asdict -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from dataclasses import fields, is_dataclass import io import json import os from pathlib import Path -import shutil -import tempfile -import threading +import subprocess +import sys -from kitup import ( - BaseOptions, - BundledSkillMetadata, - InstallOptions, - InstallSelectionOptions, - InstallWorkflowOptions, - ParsedInstallFlags, - StatusOptions, - UninstallOptions, - classify_install_workflow_exit, - compute_bundle_content_hash, - detect_hosts, - directory_bundle, - files_bundle, - github_bundle, - install_bundled_skill, - load_host_spec, - parse_install_flags, - plan_bundled_skill, - resolve_hosts, - resolve_install_selection, - resolve_install_targets, - run_bundled_skill_install_with_io, - status_bundled_skill, - uninstall_bundled_skill, - update_bundled_skill, - validate_skill_bundle, - with_bundle_metadata, -) -from kitup.types import GitHubBundleOptions, SkillFile +import kitup as sdk def test_golden_cases(): - cases = json.loads( - repo_path("testdata/cases/bundled-skill-install.json").read_text() - )["cases"] - for case in cases: - root = Path(tempfile.mkdtemp(prefix=f"kitup-{case['id']}-")) - home = root / "home" - workspace = root / "workspace" - home.mkdir(parents=True) - workspace.mkdir(parents=True) - server = None - env_backup = None + if "KITUP_GOLDEN_INPUT" not in os.environ: + script = Path(__file__).resolve().parents[2] / "scripts" / "golden.mjs" + subprocess.run( + ["node", str(script), "--", sys.executable, "-m", "pytest", __file__, "-q"], + check=True, + ) + return + results = [] + for request in json.loads(Path(os.environ["KITUP_GOLDEN_INPUT"]).read_text()): + for key in ("KITUP_GITHUB_API_BASE_URL", "KITUP_GITHUB_RAW_BASE_URL"): + if key in request["env"]: + os.environ[key] = request["env"][key] + else: + os.environ.pop(key, None) try: - server, env_backup = setup_given(case, home, workspace) - run_case(case, home, workspace) - finally: - if server is not None: - server.shutdown() - server.server_close() - if env_backup is not None: - restore_github_env(env_backup) - shutil.rmtree(root, ignore_errors=True) + result = run_case(request) + except Exception as error: + result = {"threw": True, "error": str(error)} + results.append({"id": request["id"], **normalize(result)}) + Path(os.environ["KITUP_GOLDEN_OUTPUT"]).write_text(json.dumps(results)) -def run_case(case, home: Path, workspace: Path) -> None: - operation = case["operation"] +def run_case(request): + operation, values = request["operation"], request["options"] + base = sdk.BaseOptions(values["home"], values["cwd"], values["hostsFile"]) if operation == "resolve-hosts": - spec = load_host_spec(repo_path(case["given"]["hostsFile"])) - hosts, errors = resolve_hosts(case["options"]["agents"], spec.hosts) - expected = case["expected"] - if "count" in expected: - assert len(hosts) == expected["count"] - if "hostIds" in expected: - assert [host.id for host in hosts] == expected["hostIds"] - if "resolvedHostIds" in expected: - assert [host.id for host in hosts] == expected["resolvedHostIds"] - if "errors" in expected: - assert errors == expected["errors"] - return - + hosts, errors = sdk.resolve_hosts( + values["agents"], sdk.load_host_spec(request["given"]["hostsFile"]).hosts + ) + ids = [host.id for host in hosts] + return dict(count=len(ids), hostIds=ids, resolvedHostIds=ids, errors=errors) if operation == "validate": - result = validate_skill_bundle(skill_bundle_from_case(case)) - assert result.valid == case["expected"]["valid"] - assert result.error_code == case["expected"].get("errorCode") - return - + result = sdk.validate_skill_bundle(bundle(values)) + return dict(valid=result.valid, errorCode=result.error_code) if operation == "parse-install-flags": - parsed = parse_install_flags(case["options"]) - assert normalize_parsed_flags(parsed) == case["expected"]["parsed"] - return - + parsed = normalize(sdk.parse_install_flags(values)) + agents = parsed.pop("agents") + parsed["agentKind"] = "explicit" if isinstance(agents, list) else agents + parsed["agentIds"] = agents if isinstance(agents, list) else [] + return {"parsed": parsed} if operation == "resolve-install-selection": - selection = resolve_install_selection( - selection_options_from_case(case, home, workspace) - ) - assert_selection( - normalize_value(selection), - expand_value(case["expected"]["selection"], home, workspace), - ) - return - - if operation == "resolve-install-targets": - targets = resolve_install_targets( - BaseOptions( - home=str(home), - cwd=str(workspace), - hosts_file=case_hosts_file(case, home, workspace), - ), - case["options"]["agents"], - case["options"]["scope"], - case["options"]["skillName"], - ) - actual = [ - { - "hostIds": target.host_ids, - "skillName": target.skill_name, - "targetDir": target.target_dir, - } - for target in targets - ] - assert actual == expand_value(case["expected"]["targets"], home, workspace) - return - - if operation == "run-install-workflow": - input_stream = io.StringIO(case["options"].get("input", "")) - output_stream = io.StringIO() - workflow = run_bundled_skill_install_with_io( - workflow_options_from_case(case, home, workspace), - input_stream, - output_stream, - ) - assert_workflow( - normalize_value(workflow), - expand_value(case["expected"].get("workflow"), home, workspace), - ) - if "exit" in case["expected"]: - assert ( - normalize_value(classify_install_workflow_exit(workflow)) - == case["expected"]["exit"] + return { + "selection": sdk.resolve_install_selection( + options(sdk.InstallSelectionOptions, values, base=base) ) - assert_output(output_stream.getvalue(), case["expected"].get("output")) - assert_output_contains( - output_stream.getvalue(), case["expected"].get("outputContains") - ) - if "report" in case["expected"]: - assert normalize_value(workflow.report) == camel_to_snake_dict( - expand_value(case["expected"]["report"], home, workspace) + } + if operation == "resolve-install-targets": + return { + "targets": sdk.resolve_install_targets( + base, values["agents"], values["scope"], values["skillName"] ) - assert_expected_files(case, home, workspace) - assert_expected_file_modes(case, home, workspace) - assert_expected_metadata(case, home, workspace) - return - - if "detectedHosts" in case["expected"]: - hosts = detect_hosts( - BaseOptions( - home=str(home), - cwd=str(workspace), - hosts_file=repo_path("spec/hosts.json"), + } + if operation == "run-install-workflow": + output = io.StringIO() + workflow = sdk.run_bundled_skill_install_with_io( + options( + sdk.InstallWorkflowOptions, + values, + install=options( + sdk.InstallOptions, values, base=base, skill_bundle=bundle(values) + ), ), - case["options"]["scope"], - ) - assert [host.id for host in hosts] == case["expected"]["detectedHosts"] - - if case["expected"].get("throws"): - try: - run_report_case(case, home, workspace) - except Exception: - assert_expected_files(case, home, workspace) - return - raise AssertionError("expected operation to throw") - - report = run_report_case(case, home, workspace) - if "report" in case["expected"]: - assert normalize_value(report) == camel_to_snake_dict( - expand_value(case["expected"]["report"], home, workspace) - ) - assert_expected_write_counts(case, report, home, workspace) - assert_expected_files(case, home, workspace) - assert_expected_file_modes(case, home, workspace) - assert_expected_metadata(case, home, workspace) - - -def run_report_case(case, home: Path, workspace: Path): - operation = case["operation"] - if operation == "status": - return status_bundled_skill(status_options_from_case(case, home, workspace)) - if operation == "uninstall": - return uninstall_bundled_skill( - uninstall_options_from_case(case, home, workspace) - ) - install_options = install_options_from_case(case, home, workspace) - if operation == "update": - return update_bundled_skill(install_options) - if operation == "plan": - return plan_bundled_skill(install_options) - if operation == "install": - return install_bundled_skill(install_options) - raise AssertionError(f"unsupported operation: {operation}") - - -def setup_given(case, home: Path, workspace: Path): - for value in case["given"].get("dirs", []): - expand_path(value, home, workspace).mkdir(parents=True, exist_ok=True) - for path, value in case["given"].get("files", {}).items(): - write_fixture_file(expand_path(path, home, workspace), value) - if "copySkillBundleTo" in case["given"]: - target = expand_path(case["given"]["copySkillBundleTo"], home, workspace) - shutil.rmtree(target, ignore_errors=True) - shutil.copytree(case_skill_bundle_dir(case), target) - for path, mode in case["given"].get("fileModes", {}).items(): - expand_path(path, home, workspace).chmod(int(mode, 8)) - if "metadata" in case["given"]: - write_metadata_fixture(case, home, workspace, case["given"]["metadata"]) - github = case["given"].get("github") - if github is None: - return None, None - return start_github_fixture(github) - - -def assert_expected_files(case, home: Path, workspace: Path) -> None: - for value in case["expected"].get("filesPresent", []): - path = expand_path(value, home, workspace) - assert path.exists(), f"expected file to exist: {path}" - for value in case["expected"].get("filesAbsent", []): - path = expand_path(value, home, workspace) - assert not path.exists(), f"expected file to be absent: {path}" - - -def assert_expected_file_modes(case, home: Path, workspace: Path) -> None: - for value, expected in case["expected"].get("fileModes", {}).items(): - path = expand_path(value, home, workspace) - assert oct(path.stat().st_mode & 0o777)[2:] == expected - - -def assert_expected_metadata(case, home: Path, workspace: Path) -> None: - metadata = case["expected"].get("metadata") - if metadata is None: - return - path = expand_path(metadata["path"], home, workspace) - actual = json.loads(path.read_text()) - for key, value in metadata["fields"].items(): - assert actual[key] == value - assert actual["hash"] == expected_bundle_hash(case, metadata["hash"]) - - -def assert_selection(actual, expected) -> None: - actual_value = dict(actual) - expected_value = dict(expected) - if "selectedCount" in expected_value: - assert len(actual_value["selected_host_ids"]) == expected_value["selectedCount"] - actual_value.pop("selected_host_ids") - expected_value.pop("selectedCount") - if "candidateCount" in expected_value: - assert ( - len(actual_value["candidate_host_ids"]) == expected_value["candidateCount"] - ) - actual_value.pop("candidate_host_ids") - expected_value.pop("candidateCount") - assert actual_value == camel_to_snake_dict(expected_value) - - -def assert_workflow(actual, expected) -> None: - if expected is None: - return - for key, value in camel_to_snake_dict(expected).items(): - assert actual[key] == value - - -def assert_output_contains(actual: str, expected) -> None: - if expected is None: - return - for value in expected: - assert value in actual, f"expected output to contain {value!r}, got:\n{actual}" - - -def assert_output(actual: str, expected) -> None: - if expected is None: - return - assert actual == expected - - -def assert_expected_write_counts(case, report, home: Path, workspace: Path) -> None: - expected = case["expected"].get("writeCountByTargetDir") - if expected is None: - return - actual: dict[str, int] = {} - normalized = normalize_value(report) - for key in ["installed", "updated"]: - for item in normalized[key]: - target_dir = item["target_dir"] - actual[target_dir] = actual.get(target_dir, 0) + 1 - assert actual == expand_value(expected, home, workspace) - - -def write_metadata_fixture(case, home: Path, workspace: Path, metadata) -> None: - fields = dict(metadata["fields"]) - fields["hash"] = expected_bundle_hash(case, metadata["hash"]) - write_fixture_file(expand_path(metadata["path"], home, workspace), fields) - - -def expected_bundle_hash(case, marker: str) -> str: - if marker == "from-skill-bundle-dir": - return compute_bundle_content_hash( - directory_bundle(str(case_skill_bundle_dir(case))) + io.StringIO(values.get("input", "")), + output, ) - if marker == "from-skill-files": - return compute_bundle_content_hash( - files_bundle(skill_files(case["options"]["skillFiles"])) + return dict( + workflow=workflow, + report=workflow.report, + exit=sdk.classify_install_workflow_exit(workflow), + output=output.getvalue(), ) - if marker == "from-github-bundle": - return compute_bundle_content_hash(files_bundle(github_skill_files(case))) - return marker - - -def write_fixture_file(path: Path, value) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - if isinstance(value, str): - path.write_text(value, encoding="utf-8") - else: - path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") - - -def start_github_fixture(github): - owner = github["owner"] - repo = github["repo"] - ref_name = github["ref"] - commit = github["commit"] - tree_sha = github["treeSha"] - files = github["files"] - - class Handler(BaseHTTPRequestHandler): - def do_GET(self): - path = self.path.split("?", 1)[0] - commit_path = f"/repos/{owner}/{repo}/commits/{ref_name}" - tree_path = f"/repos/{owner}/{repo}/git/trees/{tree_sha}" - if path == commit_path: - self._write_json({"sha": commit, "commit": {"tree": {"sha": tree_sha}}}) - return - if path == tree_path: - self._write_json( - { - "tree": [ - { - "path": file_path, - "type": "blob", - "mode": "100755" - if file_path.endswith(".sh") - else "100644", - } - for file_path in files - ] - } - ) - return - raw_prefix = f"/{owner}/{repo}/{commit}/" - if path.startswith(raw_prefix): - relative = path[len(raw_prefix) :] - if relative in files: - self._write_bytes(files[relative].encode("utf-8")) - return - self.send_response(404) - self.send_header("content-type", "text/plain") - self.end_headers() - self.wfile.write(b"not found") - - def log_message(self, format, *args): - return - - def _write_json(self, value): - payload = json.dumps(value).encode("utf-8") - self.send_response(200) - self.send_header("content-type", "application/json") - self.send_header("content-length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - def _write_bytes(self, value: bytes): - self.send_response(200) - self.send_header("content-type", "application/octet-stream") - self.send_header("content-length", str(len(value))) - self.end_headers() - self.wfile.write(value) - - server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - - base_url = f"http://127.0.0.1:{server.server_port}" - env_backup = { - "KITUP_GITHUB_API_BASE_URL": os.environ.get("KITUP_GITHUB_API_BASE_URL"), - "KITUP_GITHUB_RAW_BASE_URL": os.environ.get("KITUP_GITHUB_RAW_BASE_URL"), + result = {} + if request["detect"]: + result["detectedHosts"] = [ + host.id for host in sdk.detect_hosts(base, values["scope"]) + ] + action, cls = { + "status": (sdk.status_bundled_skill, sdk.StatusOptions), + "uninstall": (sdk.uninstall_bundled_skill, sdk.UninstallOptions), + "plan": (sdk.plan_bundled_skill, sdk.InstallOptions), + "install": (sdk.install_bundled_skill, sdk.InstallOptions), + "update": (sdk.update_bundled_skill, sdk.InstallOptions), + }[operation] + extra = {"skill_bundle": bundle(values)} if cls is sdk.InstallOptions else {} + result["report"] = action(options(cls, values, base=base, **extra)) + return result + + +def options(cls, values, **extra): + values = { + "scope": "user", + "stdinTTY": False, + "scopeSet": "scope" in values, + **values, } - os.environ["KITUP_GITHUB_API_BASE_URL"] = base_url - os.environ["KITUP_GITHUB_RAW_BASE_URL"] = base_url - return server, env_backup - - -def restore_github_env(env_backup) -> None: - for key, value in env_backup.items(): - if value is None: - os.environ.pop(key, None) - else: - os.environ[key] = value - - -def case_hosts_file(case, home: Path, workspace: Path) -> str: - raw = case["options"].get("hostsFile", "spec/hosts.json") - if "$HOME" in raw or "$WORKSPACE" in raw: - return str(expand_path(raw, home, workspace)) - return str(repo_path(raw)) - - -def install_options_from_case(case, home: Path, workspace: Path) -> InstallOptions: - return InstallOptions( - base=BaseOptions( - home=str(home), - cwd=str(workspace), - hosts_file=case_hosts_file(case, home, workspace), - ), - app_id=case["options"]["appId"], - skill_bundle=skill_bundle_from_case(case), - scope=case["options"].get("scope", "user"), - agents=case["options"].get("agents", "auto"), - force=case["options"].get("force", False), - ) - - -def uninstall_options_from_case(case, home: Path, workspace: Path) -> UninstallOptions: - return UninstallOptions( - base=BaseOptions( - home=str(home), - cwd=str(workspace), - hosts_file=case_hosts_file(case, home, workspace), - ), - app_id=case["options"]["appId"], - skill_name=case["options"]["skillName"], - scope=case["options"]["scope"], - agents=case["options"].get("agents", "auto"), - ) - - -def status_options_from_case(case, home: Path, workspace: Path) -> StatusOptions: - return StatusOptions( - base=BaseOptions( - home=str(home), - cwd=str(workspace), - hosts_file=case_hosts_file(case, home, workspace), - ), - app_id=case["options"]["appId"], - skill_name=case["options"]["skillName"], - scope=case["options"]["scope"], - agents=case["options"].get("agents", "auto"), - ) - - -def selection_options_from_case( - case, home: Path, workspace: Path -) -> InstallSelectionOptions: - return InstallSelectionOptions( - base=BaseOptions( - home=str(home), - cwd=str(workspace), - hosts_file=case_hosts_file(case, home, workspace), - ), - scope=case["options"].get("scope", "user"), - agents=case["options"].get("agents", "auto"), - yes=case["options"].get("yes", False), - stdin_tty=case["options"].get("stdinTTY", False), - current_agent=case["options"].get("currentAgent"), - ) - - -def workflow_options_from_case( - case, home: Path, workspace: Path -) -> InstallWorkflowOptions: - return InstallWorkflowOptions( - install=install_options_from_case(case, home, workspace), - yes=case["options"].get("yes", False), - dry_run=case["options"].get("dryRun", False), - stdin_tty=case["options"].get("stdinTTY", False), - current_agent=case["options"].get("currentAgent"), - default_scope=case["options"].get("defaultScope", "user"), - scope_set=case["options"].get("scopeSet", "scope" in case["options"]), - prompt_scope=case["options"].get("promptScope", False), + return cls( + **{ + **{ + field.name: values[camel(field.name)] + for field in fields(cls) + if camel(field.name) in values + }, + **extra, + } ) -def skill_bundle_from_case(case) -> object: - bundle: object - if "skillFiles" in case["options"]: - bundle = files_bundle(skill_files(case["options"]["skillFiles"])) - elif "skillBundleDir" in case["options"]: - bundle = directory_bundle(str(repo_path(case["options"]["skillBundleDir"]))) - elif "githubBundle" in case["options"]: - bundle = case["options"]["githubBundle"] - bundle = github_bundle( - GitHubBundleOptions( - owner=bundle["owner"], - repo=bundle["repo"], - path=bundle["path"], - ref=bundle["ref"], - ) +def bundle(values): + if "skillFiles" in values: + result = sdk.files_bundle( + [sdk.SkillFile(**file) for file in values["skillFiles"]] ) + elif "skillBundleDir" in values: + result = sdk.directory_bundle(values["skillBundleDir"]) else: - raise AssertionError(f"missing skill bundle for case {case['id']}") - if "bundleMetadata" in case["options"]: - metadata = case["options"]["bundleMetadata"] - bundle = with_bundle_metadata( - bundle, - BundledSkillMetadata( - source_id=metadata.get("sourceId"), - cli_version=metadata.get("cliVersion"), - cli_revision=metadata.get("cliRevision"), - provenance=metadata.get("provenance", {}), - ), - ) - return bundle - - -def skill_files(values) -> list[SkillFile]: - return [ - SkillFile( - path=value["path"], - contents=value["contents"], - mode=value.get("mode"), + result = sdk.github_bundle(sdk.GitHubBundleOptions(**values["githubBundle"])) + if "bundleMetadata" in values: + result = sdk.with_bundle_metadata( + result, options(sdk.BundledSkillMetadata, values["bundleMetadata"]) ) - for value in values - ] - - -def github_skill_files(case) -> list[SkillFile]: - root = f"{case['options']['githubBundle']['path'].strip('/')}/" - return [ - SkillFile(path=path[len(root) :], contents=contents) - for path, contents in case["given"]["github"]["files"].items() - if path.startswith(root) - ] - + return result -def case_skill_bundle_dir(case) -> Path: - if "skillBundleDir" in case["options"]: - return repo_path(case["options"]["skillBundleDir"]) - return repo_path(f"testdata/skills/{case['options']['skillName']}") +def camel(name): + head, *tail = name.split("_") + return head + "".join("TTY" if part == "tty" else part.title() for part in tail) -def repo_path(path: str) -> Path: - repo = Path(__file__).resolve().parents[2] - target = Path(path) - return target if target.is_absolute() else repo / target - -def expand_value(value, home: Path, workspace: Path): - if isinstance(value, str): - if "$HOME" in value or "$WORKSPACE" in value: - return str(expand_path(value, home, workspace)) - return value - if isinstance(value, list): - return [expand_value(item, home, workspace) for item in value] - if isinstance(value, dict): +def normalize(value): + if is_dataclass(value): return { - str(expand_path(key, home, workspace)): expand_value(item, home, workspace) - for key, item in value.items() + camel(field.name): normalize(item) + for field in fields(value) + if (item := getattr(value, field.name)) is not None } - return value - - -def expand_path(value: str, home: Path, workspace: Path) -> Path: - return Path(value.replace("$HOME", str(home)).replace("$WORKSPACE", str(workspace))) - - -def normalize_value(value): - if hasattr(value, "__dataclass_fields__"): - return normalize_value(asdict(value)) - if isinstance(value, Path): - return str(value) if isinstance(value, list): - return [normalize_value(item) for item in value] + return [normalize(item) for item in value] if isinstance(value, dict): - return { - key: normalize_value(item) - for key, item in value.items() - if item is not None - } + return {key: normalize(item) for key, item in value.items()} return value - - -def normalize_parsed_flags(parsed: ParsedInstallFlags) -> dict[str, object]: - return { - "scope": parsed.scope, - "scopeSet": parsed.scope_set, - "agentKind": "explicit" if isinstance(parsed.agents, list) else parsed.agents, - "agentIds": parsed.agents if isinstance(parsed.agents, list) else [], - "yes": parsed.yes, - "dryRun": parsed.dry_run, - "force": parsed.force, - "errors": parsed.errors, - } - - -def camel_to_snake_dict(value): - if isinstance(value, list): - return [camel_to_snake_dict(item) for item in value] - if not isinstance(value, dict): - return value - return { - camel_to_snake(key): camel_to_snake_dict(item) for key, item in value.items() - } - - -def camel_to_snake(value: str) -> str: - result: list[str] = [] - for char in value: - if char.isupper(): - result.append("_") - result.append(char.lower()) - else: - result.append(char) - return "".join(result).lstrip("_") diff --git a/python/tests/test_bundle.py b/python/tests/test_bundle.py index 7087c87..046c694 100644 --- a/python/tests/test_bundle.py +++ b/python/tests/test_bundle.py @@ -2,7 +2,6 @@ from pathlib import Path from kitup import ( - BaseOptions, InstallOptions, compute_bundle_content_hash, directory_bundle, @@ -109,12 +108,9 @@ def test_resources_bundle_skips_symlinked_directories(tmp_path): ) -def test_resources_bundle_preserves_path_file_modes(tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" +def test_resources_bundle_preserves_path_file_modes(base, tmp_path): + home = Path(base.home) root = tmp_path / "skill" - home.mkdir() - workspace.mkdir() (root / "scripts").mkdir(parents=True) (root / "bin").mkdir() (root / "SKILL.md").write_text(_skill_md(), encoding="utf-8") @@ -127,7 +123,7 @@ def test_resources_bundle_preserves_path_file_modes(tmp_path): install_bundled_skill( InstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), + base=base, app_id="example-cli", skill_bundle=resources_bundle(root), scope="user", @@ -140,15 +136,14 @@ def test_resources_bundle_preserves_path_file_modes(tmp_path): assert (target / "bin" / "run.sh").stat().st_mode & 0o777 == 0o755 -def test_resources_bundle_installs_embedded_tree(tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() +def test_resources_bundle_installs_embedded_tree( + base, +): + home = Path(base.home) report = install_bundled_skill( InstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), + base=base, app_id="example-cli", skill_bundle=resources_bundle(_repo_skills_basic()), scope="user", diff --git a/python/tests/test_hosts.py b/python/tests/test_hosts.py index dd342cb..d20855d 100644 --- a/python/tests/test_hosts.py +++ b/python/tests/test_hosts.py @@ -1,7 +1,6 @@ -import json +from pathlib import Path from kitup import detect_hosts, load_host_spec, resolve_hosts -from kitup.types import BaseOptions def test_load_host_spec_uses_baked_default_when_no_override(): @@ -29,85 +28,43 @@ def test_resolve_hosts_reports_unknown_ids(): assert errors == [{"agent": "missing-agent", "reason": "unknown-host"}] -def test_detect_hosts_skips_generic_detect_paths_and_sorts_by_scope_path(tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() +def test_detect_hosts_skips_generic_detect_paths_and_sorts_by_scope_path( + base, + host_options, +): + home = Path(base.home) (home / ".claude").mkdir() (home / ".codex").mkdir() - hosts_file = tmp_path / "hosts.json" - hosts_file.write_text( - json.dumps( - { - "$schema": "./hosts.schema.json", - "schemaVersion": 1, - "hosts": [ - { - "id": "generic", - "displayName": "Generic", - "projectSkillsDirs": [".agents/skills"], - "userSkillsDirs": ["~/.agents/skills"], - "detect": ["~/.agents"], - "status": "community", - }, - { - "id": "claude-code", - "displayName": "Claude Code", - "projectSkillsDirs": [".claude/skills"], - "userSkillsDirs": ["~/.claude/skills"], - "detect": ["~/.claude"], - "status": "verified", - }, - { - "id": "codex", - "displayName": "Codex", - "projectSkillsDirs": [".agents/skills"], - "userSkillsDirs": ["~/.agents/skills", "~/.codex/skills"], - "detect": ["~/.codex"], - "status": "verified", - }, - ], - } - ) - ) + base = host_options(["generic", "claude-code", "codex"]) hosts = detect_hosts( - BaseOptions( - home=str(home), - cwd=str(workspace), - hosts_file=str(hosts_file), - ), + base, scope="user", ) assert [host.id for host in hosts] == ["codex", "claude-code"] -def test_detect_hosts_scans_all_non_generic_detect_paths(tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() +def test_detect_hosts_scans_all_non_generic_detect_paths( + base, +): + home = Path(base.home) (home / ".kimi").mkdir() hosts = detect_hosts( - BaseOptions( - home=str(home), - cwd=str(workspace), - ), + base, scope="user", ) assert "kimi-cli" in [host.id for host in hosts] -def test_detect_hosts_never_counts_generic_detect_paths(tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() +def test_detect_hosts_never_counts_generic_detect_paths( + base, +): + home = Path(base.home) + workspace = Path(base.cwd) (home / ".agents").mkdir() (home / ".agents" / "skills").mkdir() (home / ".config").mkdir() @@ -115,10 +72,7 @@ def test_detect_hosts_never_counts_generic_detect_paths(tmp_path): (workspace / "package.json").write_text("{}") hosts = detect_hosts( - BaseOptions( - home=str(home), - cwd=str(workspace), - ), + base, scope="user", ) diff --git a/python/tests/test_install.py b/python/tests/test_install.py index 30f73a1..f0a1c64 100644 --- a/python/tests/test_install.py +++ b/python/tests/test_install.py @@ -1,4 +1,5 @@ import json +from pathlib import Path import kitup from kitup import ( @@ -14,7 +15,6 @@ ) from kitup.bundle import compute_bundle_content_hash from kitup.types import ( - BaseOptions, InstallOptions, InstallReport, InstallSelection, @@ -31,15 +31,14 @@ ) -def test_resolve_install_targets_prefers_first_existing_user_dir(tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() +def test_resolve_install_targets_prefers_first_existing_user_dir( + base, +): + home = Path(base.home) (home / ".agents" / "skills").mkdir(parents=True) targets = resolve_install_targets( - BaseOptions(home=str(home), cwd=str(workspace)), + base, ["codex"], "user", "basic", @@ -50,15 +49,14 @@ def test_resolve_install_targets_prefers_first_existing_user_dir(tmp_path): ] -def test_resolve_install_targets_groups_hosts_by_shared_target_dir(tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() +def test_resolve_install_targets_groups_hosts_by_shared_target_dir( + base, +): + home = Path(base.home) (home / ".agents" / "skills").mkdir(parents=True) targets = resolve_install_targets( - BaseOptions(home=str(home), cwd=str(workspace)), + base, ["codex", "warp", "gemini-cli"], "user", "basic", @@ -72,45 +70,19 @@ def test_resolve_install_targets_groups_hosts_by_shared_target_dir(tmp_path): ] -def test_resolve_install_targets_auto_detects_supported_hosts(tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() +def test_resolve_install_targets_auto_detects_supported_hosts( + base, + host_options, +): + home = Path(base.home) (home / ".codex").mkdir() (home / ".claude").mkdir() (home / ".agents" / "skills").mkdir(parents=True) (home / ".claude" / "skills").mkdir(parents=True) - hosts_file = tmp_path / "hosts.json" - hosts_file.write_text( - json.dumps( - { - "$schema": "./hosts.schema.json", - "schemaVersion": 1, - "hosts": [ - { - "id": "codex", - "displayName": "Codex", - "projectSkillsDirs": [".agents/skills"], - "userSkillsDirs": ["~/.agents/skills", "~/.codex/skills"], - "detect": ["~/.codex", "~/.agents/skills", "~/.agents"], - "status": "verified", - }, - { - "id": "claude-code", - "displayName": "Claude Code", - "projectSkillsDirs": [".claude/skills"], - "userSkillsDirs": ["~/.claude/skills"], - "detect": ["~/.claude"], - "status": "verified", - }, - ], - } - ) - ) + base = host_options(["codex", "claude-code"]) targets = resolve_install_targets( - BaseOptions(home=str(home), cwd=str(workspace), hosts_file=str(hosts_file)), + base, "auto", "user", "basic", @@ -122,38 +94,15 @@ def test_resolve_install_targets_auto_detects_supported_hosts(tmp_path): ] -def test_resolve_install_targets_skips_hosts_without_scope_path(tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() +def test_resolve_install_targets_skips_hosts_without_scope_path( + base, + host_options, +): - hosts_file = tmp_path / "hosts.json" - hosts_file.write_text( - json.dumps( - { - "$schema": "./hosts.schema.json", - "schemaVersion": 1, - "hosts": [ - { - "id": "eve", - "displayName": "Eve", - "projectSkillsDirs": ["agent/skills"], - "userSkillsDirs": [], - "detect": ["agent", "package.json"], - "status": "community", - } - ], - } - ) - ) + base = host_options(["eve"]) targets = resolve_install_targets( - BaseOptions( - home=str(home), - cwd=str(workspace), - hosts_file=str(hosts_file), - ), + base, ["eve"], "user", "basic", @@ -162,7 +111,7 @@ def test_resolve_install_targets_skips_hosts_without_scope_path(tmp_path): assert targets == [] -def test_install_update_uninstall_round_trip(tmp_path): +def test_install_update_uninstall_round_trip(base, tmp_path): skill = tmp_path / "skill" skill.mkdir() (skill / "SKILL.md").write_text( @@ -176,13 +125,10 @@ def test_install_update_uninstall_round_trip(tmp_path): legacy = skill / "legacy.txt" legacy.write_text("remove me on update\n", encoding="utf-8") - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() + home = Path(base.home) install_options = InstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), + base=base, app_id="kitup-python-test", skill_bundle=directory_bundle(str(skill)), scope="user", @@ -231,7 +177,7 @@ def test_install_update_uninstall_round_trip(tmp_path): uninstall_report = uninstall_bundled_skill( UninstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), + base=base, app_id="kitup-python-test", skill_name="basic", scope="user", @@ -243,21 +189,17 @@ def test_install_update_uninstall_round_trip(tmp_path): assert not target.exists() -def test_install_lifecycle_reports_owner_mismatch_and_missing(tmp_path): +def test_install_lifecycle_reports_owner_mismatch_and_missing(base, tmp_path): skill = tmp_path / "skill" skill.mkdir() (skill / "SKILL.md").write_text( "---\nname: basic\ndescription: demo\n---\n", encoding="utf-8", ) - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() install_bundled_skill( InstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), + base=base, app_id="kitup-python-test", skill_bundle=directory_bundle(str(skill)), scope="user", @@ -267,7 +209,7 @@ def test_install_lifecycle_reports_owner_mismatch_and_missing(tmp_path): conflict_report = install_bundled_skill( InstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), + base=base, app_id="other-app", skill_bundle=directory_bundle(str(skill)), scope="user", @@ -276,7 +218,7 @@ def test_install_lifecycle_reports_owner_mismatch_and_missing(tmp_path): ) missing_report = uninstall_bundled_skill( UninstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), + base=base, app_id="kitup-python-test", skill_name="missing", scope="user", @@ -288,17 +230,14 @@ def test_install_lifecycle_reports_owner_mismatch_and_missing(tmp_path): assert missing_report.skipped[0].reason == "missing" -def test_install_force_overwrites_unmanaged_and_owner_mismatch(tmp_path): +def test_install_force_overwrites_unmanaged_and_owner_mismatch(base, tmp_path): skill = tmp_path / "skill" skill.mkdir() (skill / "SKILL.md").write_text( "---\nname: basic\ndescription: demo\n---\n", encoding="utf-8", ) - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() + home = Path(base.home) codex_target = home / ".agents" / "skills" / "basic" codex_target.mkdir(parents=True) @@ -329,7 +268,7 @@ def test_install_force_overwrites_unmanaged_and_owner_mismatch(tmp_path): report = install_bundled_skill( InstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), + base=base, app_id="kitup-python-test", skill_bundle=directory_bundle(str(skill)), scope="user", diff --git a/python/tests/test_workflow.py b/python/tests/test_workflow.py index c7c9b96..f93c15a 100644 --- a/python/tests/test_workflow.py +++ b/python/tests/test_workflow.py @@ -1,9 +1,8 @@ import io -import json +from pathlib import Path import kitup from kitup import ( - BaseOptions, GitHubBundleOptions, InstallOptions, InstallSelectionOptions, @@ -11,7 +10,6 @@ SkillFile, agent_selector_from_flags, classify_install_workflow_exit, - directory_bundle, github_bundle, install_flag_error, install_workflow_error, @@ -25,19 +23,6 @@ from kitup.workflow import split_flag_values -def write_hosts_file(path, hosts) -> None: - path.write_text( - json.dumps( - { - "$schema": "./hosts.schema.json", - "schemaVersion": 1, - "hosts": hosts, - } - ), - encoding="utf-8", - ) - - def test_parse_install_flags_defaults_to_user_auto(): parsed = parse_install_flags({}) @@ -93,32 +78,17 @@ def test_flag_helpers_normalize_lists(): assert errors == [] -def test_resolve_install_selection_requires_agents_without_tty_or_yes(tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - hosts_file = tmp_path / "hosts.json" - home.mkdir() - workspace.mkdir() +def test_resolve_install_selection_requires_agents_without_tty_or_yes( + base, + host_options, +): + home = Path(base.home) (home / ".codex").mkdir() - write_hosts_file( - hosts_file, - [ - { - "id": "codex", - "displayName": "Codex", - "projectSkillsDirs": [".agents/skills"], - "userSkillsDirs": ["~/.agents/skills"], - "detect": ["~/.codex"], - "status": "verified", - } - ], - ) + base = host_options(["codex"]) selection = resolve_install_selection( InstallSelectionOptions( - base=BaseOptions( - home=str(home), cwd=str(workspace), hosts_file=str(hosts_file) - ), + base=base, scope="user", stdin_tty=False, yes=False, @@ -130,41 +100,18 @@ def test_resolve_install_selection_requires_agents_without_tty_or_yes(tmp_path): assert selection.errors == [{"reason": "agent-selection-required"}] -def test_resolve_install_selection_tty_prompts_for_multiple_detected_hosts(tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - hosts_file = tmp_path / "hosts.json" - home.mkdir() - workspace.mkdir() +def test_resolve_install_selection_tty_prompts_for_multiple_detected_hosts( + base, + host_options, +): + home = Path(base.home) (home / ".codex").mkdir() (home / ".claude").mkdir() - write_hosts_file( - hosts_file, - [ - { - "id": "codex", - "displayName": "Codex", - "projectSkillsDirs": [".agents/skills"], - "userSkillsDirs": ["~/.agents/skills"], - "detect": ["~/.codex"], - "status": "verified", - }, - { - "id": "claude-code", - "displayName": "Claude Code", - "projectSkillsDirs": [".claude/skills"], - "userSkillsDirs": ["~/.claude/skills"], - "detect": ["~/.claude"], - "status": "verified", - }, - ], - ) + base = host_options(["codex", "claude-code"]) selection = resolve_install_selection( InstallSelectionOptions( - base=BaseOptions( - home=str(home), cwd=str(workspace), hosts_file=str(hosts_file) - ), + base=base, scope="user", stdin_tty=True, yes=False, @@ -178,32 +125,14 @@ def test_resolve_install_selection_tty_prompts_for_multiple_detected_hosts(tmp_p def test_resolve_install_selection_explicit_agents_with_unknown_host_is_pure_error( - tmp_path, + base, + host_options, ): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - hosts_file = tmp_path / "hosts.json" - home.mkdir() - workspace.mkdir() - write_hosts_file( - hosts_file, - [ - { - "id": "codex", - "displayName": "Codex", - "projectSkillsDirs": [".agents/skills"], - "userSkillsDirs": ["~/.agents/skills"], - "detect": ["~/.codex"], - "status": "verified", - } - ], - ) + base = host_options(["codex"]) selection = resolve_install_selection( InstallSelectionOptions( - base=BaseOptions( - home=str(home), cwd=str(workspace), hosts_file=str(hosts_file) - ), + base=base, scope="user", agents=["codex", "missing-agent"], stdin_tty=False, @@ -259,28 +188,16 @@ def test_error_helpers_map_flag_and_workflow_failures(): ) -def test_run_bundled_skill_install_scope_prompt_and_top_level_exports(tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() - skill = workspace / "skill" - skill.mkdir() - (skill / "SKILL.md").write_text( - "---\nname: basic\ndescription: demo\n---\n", - encoding="utf-8", - ) +def test_run_bundled_skill_install_scope_prompt_and_top_level_exports( + base, + install_options, +): + workspace = Path(base.cwd) output = io.StringIO() report = run_bundled_skill_install_with_io( InstallWorkflowOptions( - install=InstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), - app_id="example-cli", - skill_bundle=directory_bundle(str(skill)), - scope="user", - agents=["codex"], - ), + install=install_options, stdin_tty=True, prompt_scope=True, scope_set=False, @@ -327,18 +244,11 @@ def read(self) -> str: def test_run_bundled_skill_install_uses_stdio_defaults_for_interactive_flow( - monkeypatch, tmp_path + base, + install_options, + monkeypatch, ): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() - skill = workspace / "skill" - skill.mkdir() - (skill / "SKILL.md").write_text( - "---\nname: basic\ndescription: demo\n---\n", - encoding="utf-8", - ) + workspace = Path(base.cwd) stdin = _TTYInput("project\ny\n") stdout = io.StringIO() @@ -347,13 +257,7 @@ def test_run_bundled_skill_install_uses_stdio_defaults_for_interactive_flow( report = run_bundled_skill_install( InstallWorkflowOptions( - install=InstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), - app_id="example-cli", - skill_bundle=directory_bundle(str(skill)), - scope="user", - agents=["codex"], - ), + install=install_options, prompt_scope=True, scope_set=False, ) @@ -367,29 +271,15 @@ def test_run_bundled_skill_install_uses_stdio_defaults_for_interactive_flow( def test_run_bundled_skill_install_reads_interactive_stream_line_by_line( - tmp_path, + base, + install_options, ): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() - skill = workspace / "skill" - skill.mkdir() - (skill / "SKILL.md").write_text( - "---\nname: basic\ndescription: demo\n---\n", - encoding="utf-8", - ) + workspace = Path(base.cwd) output = io.StringIO() report = run_bundled_skill_install( InstallWorkflowOptions( - install=InstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), - app_id="example-cli", - skill_bundle=directory_bundle(str(skill)), - scope="user", - agents=["codex"], - ), + install=install_options, prompt_scope=True, scope_set=False, input=_ReadlineOnlyTTYInput("project\ny\n"), @@ -405,31 +295,18 @@ def test_run_bundled_skill_install_reads_interactive_stream_line_by_line( def test_run_bundled_skill_install_infers_tty_from_custom_input_stream( - monkeypatch, tmp_path + base, + install_options, + monkeypatch, ): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() - skill = workspace / "skill" - skill.mkdir() - (skill / "SKILL.md").write_text( - "---\nname: basic\ndescription: demo\n---\n", - encoding="utf-8", - ) + workspace = Path(base.cwd) monkeypatch.setattr("sys.stdin", _NonTTYInput("")) stdout = io.StringIO() report = run_bundled_skill_install( InstallWorkflowOptions( - install=InstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), - app_id="example-cli", - skill_bundle=directory_bundle(str(skill)), - scope="user", - agents=["codex"], - ), + install=install_options, prompt_scope=True, scope_set=False, input=_TTYInput("project\ny\n"), @@ -444,11 +321,10 @@ def test_run_bundled_skill_install_infers_tty_from_custom_input_stream( assert (workspace / ".agents" / "skills" / "basic" / "SKILL.md").exists() -def test_plan_bundled_skill_uses_single_github_snapshot(monkeypatch, tmp_path): - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() +def test_plan_bundled_skill_uses_single_github_snapshot( + base, + monkeypatch, +): fetch_calls: list[str] = [] @@ -480,13 +356,13 @@ def unexpected_refetch(_options): raise AssertionError("bundle was re-fetched after snapshot resolution") monkeypatch.setattr( - "kitup.install.fetch_github_directory_with_metadata", fake_fetch_with_metadata + "kitup.bundle.fetch_github_directory_with_metadata", fake_fetch_with_metadata ) monkeypatch.setattr("kitup.bundle.fetch_github_directory", unexpected_refetch) report = plan_bundled_skill( InstallOptions( - base=BaseOptions(home=str(home), cwd=str(workspace)), + base=base, app_id="example-cli", skill_bundle=github_bundle( GitHubBundleOptions( diff --git a/rust/src/bundle.rs b/rust/src/bundle.rs new file mode 100644 index 0000000..de5ce1e --- /dev/null +++ b/rust/src/bundle.rs @@ -0,0 +1,308 @@ +use crate::github::resolve_github_bundle; +use crate::types::*; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +pub fn directory_bundle(path: impl Into) -> SkillBundle { + SkillBundle::Directory(path.into()) +} + +pub fn files_bundle(files: Vec) -> SkillBundle { + SkillBundle::Files(files) +} + +#[cfg(feature = "include-dir")] +pub fn include_dir_bundle(dir: &include_dir::Dir<'_>) -> SkillBundle { + fn collect(root: &Path, dir: &include_dir::Dir<'_>, files: &mut Vec) { + for file in dir.files() { + let path = file.path(); + let path = if root.as_os_str().is_empty() { + path + } else { + path.strip_prefix(root).unwrap_or(path) + }; + files.push(SkillFile { + path: path.to_string_lossy().replace('\\', "/"), + contents: file.contents().to_vec(), + mode: None, + }); + } + for dir in dir.dirs() { + collect(root, dir, files); + } + } + + let mut files = Vec::new(); + collect(dir.path(), dir, &mut files); + files_bundle(files) +} + +pub fn github_bundle(options: GitHubBundleOptions) -> SkillBundle { + SkillBundle::GitHub(options) +} + +pub fn with_bundle_metadata(bundle: SkillBundle, metadata: BundledSkillMetadata) -> SkillBundle { + SkillBundle::Metadata(Box::new(bundle), metadata) +} + +#[derive(Clone, Debug)] +pub(crate) struct BundleFile { + pub(crate) contents: Vec, + pub(crate) mode: u32, +} + +pub(crate) type NormalizedSkillBundle = BTreeMap; + +#[derive(Clone, Debug, Default)] +pub(crate) struct BundleMetadata { + pub(crate) source: String, + pub(crate) source_id: Option, + pub(crate) version: Option, + pub(crate) cli_version: Option, + pub(crate) cli_revision: Option, + pub(crate) provenance: BTreeMap, + pub(crate) explicit: bool, +} + +pub fn validate_skill_bundle(bundle: &SkillBundle) -> SkillInfo { + read_skill_bundle(bundle).map_or_else( + |_| invalid_skill("invalid-skill-bundle"), + |bundle| validate_normalized_skill(&bundle), + ) +} + +pub(crate) fn validate_normalized_skill(bundle: &NormalizedSkillBundle) -> SkillInfo { + let Some(file) = bundle.get("SKILL.md") else { + return invalid_skill("missing-skill-md"); + }; + let content = String::from_utf8_lossy(&file.contents); + let Some(rest) = content.strip_prefix("---\n") else { + return invalid_skill("invalid-frontmatter"); + }; + let Some(end) = rest.find("\n---\n") else { + return invalid_skill("invalid-frontmatter"); + }; + let fields = parse_frontmatter(&rest[..end]); + let name = fields.get("name").cloned().unwrap_or_default(); + let description = fields.get("description").cloned().unwrap_or_default(); + if !valid_skill_name(&name) || description.is_empty() || description.len() > 1024 { + return invalid_skill("invalid-frontmatter"); + } + SkillInfo { + valid: true, + skill_name: Some(name), + description: Some(description), + error_code: None, + } +} + +pub fn compute_bundle_content_hash(bundle: &SkillBundle) -> io::Result { + Ok(content_hash(&read_skill_bundle(bundle)?)) +} + +pub(crate) fn content_hash(bundle: &NormalizedSkillBundle) -> String { + let mut hash = Sha256::new(); + for (path, file) in bundle { + hash.update(path.as_bytes()); + hash.update([0]); + hash.update(&file.contents); + hash.update([0]); + } + format!("sha256:{:x}", hash.finalize()) +} + +fn parse_frontmatter(content: &str) -> HashMap { + content + .lines() + .filter_map(|line| line.split_once(':')) + .map(|(key, value)| (key.into(), value.trim().into())) + .collect() +} + +fn invalid_skill(reason: &str) -> SkillInfo { + SkillInfo { + error_code: Some(reason.into()), + ..SkillInfo::default() + } +} + +pub(crate) fn valid_skill_name(name: &str) -> bool { + name.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) +} + +pub(crate) fn resolve_skill_bundle( + bundle: &SkillBundle, +) -> io::Result<(NormalizedSkillBundle, BundleMetadata)> { + let files = match bundle { + SkillBundle::GitHub(options) => return resolve_github_bundle(options), + SkillBundle::Metadata(bundle, supplied) => { + let (bundle, mut metadata) = resolve_skill_bundle(bundle)?; + if let Some(source_id) = supplied + .source_id + .as_ref() + .filter(|value| !value.is_empty()) + { + metadata.source_id = Some(source_id.clone()); + } + for (target, value) in [ + (&mut metadata.cli_version, &supplied.cli_version), + (&mut metadata.cli_revision, &supplied.cli_revision), + ] { + *target = value.as_ref().filter(|value| !value.is_empty()).cloned(); + } + metadata.provenance.extend(supplied.provenance.clone()); + metadata.explicit = true; + return Ok((bundle, metadata)); + } + SkillBundle::Directory(root) => read_directory_bundle_files(root)?, + SkillBundle::Files(files) => files.clone(), + }; + Ok(( + normalize_skill_files(files)?, + BundleMetadata { + source: "bundled".into(), + ..BundleMetadata::default() + }, + )) +} + +fn read_skill_bundle(bundle: &SkillBundle) -> io::Result { + resolve_skill_bundle(bundle).map(|(bundle, _)| bundle) +} + +pub(crate) fn is_github_bundle(bundle: &SkillBundle) -> bool { + match bundle { + SkillBundle::GitHub(_) => true, + SkillBundle::Metadata(bundle, _) => is_github_bundle(bundle), + _ => false, + } +} + +fn read_directory_bundle_files(root: &Path) -> io::Result> { + let mut files = Vec::new(); + collect_directory_bundle_files(root, root, &mut files)?; + Ok(files) +} + +fn collect_directory_bundle_files( + root: &Path, + dir: &Path, + files: &mut Vec, +) -> io::Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if skip_name(&name) { + continue; + } + let path = entry.path(); + let metadata = entry.metadata()?; + if metadata.is_dir() { + collect_directory_bundle_files(root, &path, files)?; + } else if metadata.is_file() { + let rel = path.strip_prefix(root).unwrap(); + files.push(SkillFile { + path: rel.to_string_lossy().replace('\\', "/"), + contents: fs::read(&path)?, + mode: mode_bits(&metadata), + }); + } + } + Ok(()) +} + +pub(crate) fn normalize_skill_files(files: Vec) -> io::Result { + let mut by_path = BTreeMap::new(); + for file in files { + let Some(path) = normalize_bundle_path(&file.path)? else { + continue; + }; + if by_path.contains_key(&path) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("duplicate skill file: {path}"), + )); + } + let mode = file.mode.unwrap_or_else(|| default_bundle_file_mode(&path)); + by_path.insert( + path, + BundleFile { + contents: file.contents, + mode, + }, + ); + } + Ok(by_path) +} + +fn default_bundle_file_mode(path: &str) -> u32 { + if path.starts_with("scripts/") { + 0o755 + } else { + 0o644 + } +} + +fn normalize_bundle_path(value: &str) -> io::Result> { + if value.is_empty() + || value.contains('\\') + || value.starts_with('/') + || value.as_bytes().get(1) == Some(&b':') + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid skill file path: {value}"), + )); + } + for part in value.split('/') { + if part.is_empty() || part == "." || part == ".." { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid skill file path: {value}"), + )); + } + if skip_name(part) { + return Ok(None); + } + } + Ok(Some(value.to_string())) +} + +#[cfg(unix)] +pub(crate) fn mode_bits(metadata: &fs::Metadata) -> Option { + use std::os::unix::fs::PermissionsExt; + Some(metadata.permissions().mode() & 0o777) +} + +#[cfg(not(unix))] +pub(crate) fn mode_bits(_metadata: &fs::Metadata) -> Option { + None +} + +#[cfg(unix)] +pub(crate) fn set_mode(path: &Path, mode: u32) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(mode)) +} + +#[cfg(not(unix))] +pub(crate) fn set_mode(_path: &Path, _mode: u32) -> io::Result<()> { + Ok(()) +} + +fn skip_name(name: &str) -> bool { + name == ".git" + || name == ".kitup.json" + || name == ".DS_Store" + || name.ends_with(".swp") + || name.ends_with('~') +} diff --git a/rust/src/github.rs b/rust/src/github.rs new file mode 100644 index 0000000..0c210a5 --- /dev/null +++ b/rust/src/github.rs @@ -0,0 +1,155 @@ +use crate::bundle::{normalize_skill_files, BundleMetadata, NormalizedSkillBundle}; +use crate::types::{GitHubBundleOptions, SkillFile}; +use serde_json::Value; +use std::io; +use std::time::Duration; + +pub(crate) fn resolve_github_bundle( + options: &GitHubBundleOptions, +) -> io::Result<(NormalizedSkillBundle, BundleMetadata)> { + let root = options.path.trim_matches('/').to_string(); + if options.owner.is_empty() + || options.repo.is_empty() + || root.is_empty() + || options.ref_name.is_empty() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid github bundle", + )); + } + let api_base = env_base_url("KITUP_GITHUB_API_BASE_URL", "https://api.github.com"); + let raw_base = env_base_url( + "KITUP_GITHUB_RAW_BASE_URL", + "https://raw.githubusercontent.com", + ); + let commit: Value = get_json(&format!( + "{}/repos/{}/{}/commits/{}", + api_base, + escape_path_part(&options.owner), + escape_path_part(&options.repo), + escape_path_part(&options.ref_name) + ))?; + let resolved_commit = commit["sha"].as_str().unwrap_or("").to_string(); + let tree_sha = commit["commit"]["tree"]["sha"] + .as_str() + .unwrap_or("") + .to_string(); + if resolved_commit.is_empty() || tree_sha.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid github commit", + )); + } + let tree: Value = get_json(&format!( + "{}/repos/{}/{}/git/trees/{}?recursive=1", + api_base, + escape_path_part(&options.owner), + escape_path_part(&options.repo), + escape_path_part(&tree_sha) + ))?; + let prefix = format!("{root}/"); + let mut files = Vec::new(); + for item in tree["tree"].as_array().into_iter().flatten() { + let path = item["path"].as_str().unwrap_or(""); + if item["type"].as_str() != Some("blob") || !path.starts_with(&prefix) { + continue; + } + let contents = get_bytes(&format!( + "{}/{}/{}/{}/{}", + raw_base, + escape_path_part(&options.owner), + escape_path_part(&options.repo), + escape_path_part(&resolved_commit), + escape_path(path) + ))?; + let mode = if item["mode"].as_str() == Some("100755") { + Some(0o755) + } else { + Some(0o644) + }; + files.push(SkillFile { + path: path.strip_prefix(&prefix).unwrap().to_string(), + contents, + mode, + }); + } + if files.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "github bundle path not found", + )); + } + let provenance = [ + ("owner", options.owner.clone()), + ("repo", options.repo.clone()), + ("path", root.clone()), + ("ref", options.ref_name.clone()), + ("resolvedCommit", resolved_commit), + ] + .into_iter() + .map(|(key, value)| (key.into(), value)) + .collect(); + Ok(( + normalize_skill_files(files)?, + BundleMetadata { + source: "github".to_string(), + source_id: Some(format!( + "github:{}/{}/{}", + options.owner, options.repo, root + )), + version: Some(options.ref_name.clone()), + provenance, + ..BundleMetadata::default() + }, + )) +} + +fn env_base_url(name: &str, fallback: &str) -> String { + std::env::var(name) + .unwrap_or_else(|_| fallback.to_string()) + .trim_end_matches('/') + .to_string() +} + +fn get_json(url: &str) -> io::Result { + serde_json::from_slice(&get_bytes(url)?).map_err(io::Error::other) +} + +fn get_bytes(url: &str) -> io::Result> { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(30)) + .build(); + let response = agent + .get(url) + .set("User-Agent", "kitup") + .call() + .map_err(io::Error::other)?; + if response.status() < 200 || response.status() > 299 { + return Err(io::Error::other(format!("github request failed: {url}"))); + } + let mut reader = response.into_reader(); + let mut data = Vec::new(); + std::io::Read::read_to_end(&mut reader, &mut data)?; + Ok(data) +} + +fn escape_path(value: &str) -> String { + value + .split('/') + .map(escape_path_part) + .collect::>() + .join("/") +} + +fn escape_path_part(value: &str) -> String { + let mut out = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + out.push(byte as char); + } else { + out.push_str(&format!("%{byte:02X}")); + } + } + out +} diff --git a/rust/src/hosts.rs b/rust/src/hosts.rs new file mode 100644 index 0000000..a6e8eda --- /dev/null +++ b/rust/src/hosts.rs @@ -0,0 +1,409 @@ +use crate::bundle::valid_skill_name; +use crate::hosts_generated; +use crate::metadata::{read_metadata, MetadataState}; +use crate::types::*; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +#[derive(Deserialize)] +struct HostSpec { + hosts: Vec, +} + +pub fn load_host_spec(hosts_file: Option<&Path>) -> io::Result> { + let spec: HostSpec = match hosts_file { + Some(path) => serde_json::from_slice(&fs::read(path)?)?, + None => serde_json::from_str(hosts_generated::DEFAULT_HOSTS_SPEC_JSON)?, + }; + validate_host_spec(&spec.hosts)?; + Ok(spec.hosts) +} + +fn validate_host_spec(hosts: &[Host]) -> io::Result<()> { + for host in hosts { + let install_dirs: HashSet<_> = host + .project_skills_dirs + .iter() + .chain(&host.user_skills_dirs) + .collect(); + for (kind, paths) in [ + ("project", &host.project_skills_dirs), + ("user", &host.user_skills_dirs), + ("detect", &host.detect), + ] { + for path in paths { + let valid = match kind { + "project" => is_project_host_path(path), + "user" => is_home_host_path(path), + _ => is_home_host_path(path) || is_project_host_path(path), + }; + if !valid { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid {kind} path {path:?} for host {}", host.id), + )); + } + if kind == "detect" && !is_generic_detect_path(path) && install_dirs.contains(path) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "detect path is an install target for host {}: {path:?}", + host.id + ), + )); + } + } + } + } + Ok(()) +} + +fn is_project_host_path(path: &str) -> bool { + !path.is_empty() && !path.starts_with('/') && !path.starts_with('~') && is_safe_host_path(path) +} + +fn is_home_host_path(path: &str) -> bool { + path.starts_with("~/") && is_safe_host_path(&path[2..]) +} + +fn is_safe_host_path(path: &str) -> bool { + !path + .chars() + .any(|character| matches!(character, '\0' | '\\' | ':')) + && !path + .split('/') + .any(|segment| segment == "." || segment == ".." || segment.is_empty()) +} + +pub fn resolve_hosts(agents: &AgentSelector, hosts: &[Host]) -> (Vec, Vec) { + match agents { + AgentSelector::All => (hosts.to_vec(), vec![]), + AgentSelector::Auto => (vec![], vec![]), + AgentSelector::Explicit(ids) => { + let mut by_name = HashMap::new(); + for host in hosts { + by_name.insert(host.id.as_str(), host); + for alias in &host.aliases { + by_name.insert(alias.as_str(), host); + } + } + let mut seen = HashSet::new(); + let mut resolved = Vec::new(); + let mut errors = Vec::new(); + for id in ids { + if let Some(host) = by_name.get(id.as_str()) { + if seen.insert(host.id.clone()) { + resolved.push((*host).clone()); + } + } else { + errors.push(json!({ "agent": id, "reason": "unknown-host" })); + } + } + (resolved, errors) + } + } +} + +pub fn detect_hosts(options: &BaseOptions, scope: Option) -> io::Result> { + let hosts = load_host_spec(options.hosts_file.as_deref())?; + let (home, cwd) = defaults(options)?; + let mut detected = Vec::new(); + for host in hosts { + if host.detect.iter().any(|path| { + !is_generic_detect_path(path) && expand_host_path(path, &home, &cwd).exists() + }) { + detected.push(host); + } + } + if let Some(scope) = scope { + detected.sort_by(|a, b| { + let left = canonical_scope_path(a, scope, &home, &cwd).unwrap_or_default(); + let right = canonical_scope_path(b, scope, &home, &cwd).unwrap_or_default(); + left.cmp(&right).then_with(|| a.id.cmp(&b.id)) + }); + } + Ok(detected) +} + +pub fn resolve_install_selection( + options: &InstallSelectionOptions, +) -> io::Result { + let hosts = load_host_spec(options.base.hosts_file.as_deref())?; + let confirm = !options.yes && options.stdin_tty; + if let Some(agents @ (AgentSelector::All | AgentSelector::Explicit(_))) = &options.agents { + let (selected, errors) = resolve_hosts(agents, &hosts); + return Ok(if errors.is_empty() { + install_selection(host_ids(&selected), vec![], confirm, errors) + } else { + error_selection(errors, vec![]) + }); + } + if let Some(current_agent) = &options.current_agent { + let (selected, errors) = resolve_hosts( + &AgentSelector::Explicit(vec![current_agent.clone()]), + &hosts, + ); + return Ok(install_selection( + host_ids(&add_universal_host(selected, &hosts)), + vec![], + confirm, + errors, + )); + } + let detected = detect_hosts(&options.base, Some(options.scope.unwrap_or(Scope::User)))?; + let ids = host_ids(&detected); + match (options.yes, options.stdin_tty, detected.len()) { + (false, false, _) => Ok(error_selection( + vec![json!({ "reason": "agent-selection-required" })], + ids, + )), + (true, _, 0) => Ok(error_selection( + vec![json!({ "reason": "no-detected-hosts" })], + ids, + )), + (true, _, _) | (false, true, 1) => Ok(install_selection(ids.clone(), ids, confirm, vec![])), + _ => Ok(InstallSelection { + action: "select-agents".into(), + candidate_host_ids: if detected.is_empty() { + host_ids(&hosts) + } else { + ids.clone() + }, + detected_host_ids: ids, + needs_confirmation: true, + ..InstallSelection::default() + }), + } +} + +pub fn resolve_install_targets( + options: &BaseOptions, + agents: &AgentSelector, + scope: Scope, + skill_name: &str, +) -> io::Result<(Vec, Vec, Vec)> { + resolve_install_targets_for_lifecycle(options, agents, scope, skill_name, None) +} + +pub(crate) fn resolve_install_targets_for_lifecycle( + options: &BaseOptions, + agents: &AgentSelector, + scope: Scope, + skill_name: &str, + uninstall_app_id: Option<&str>, +) -> io::Result<(Vec, Vec, Vec)> { + if !valid_skill_name(skill_name) { + return Ok(( + vec![], + vec![json!({ + "skillName": skill_name, + "reason": "invalid-skill-name" + })], + vec![], + )); + } + let hosts = load_host_spec(options.hosts_file.as_deref())?; + let (home, cwd) = defaults(options)?; + let (selected, mut errors) = match agents { + AgentSelector::Auto => (detect_hosts(options, Some(scope))?, vec![]), + _ => resolve_hosts(agents, &hosts), + }; + let mut by_target: BTreeMap = BTreeMap::new(); + for host in selected { + let roots = match uninstall_app_id { + Some(app_id) => uninstall_scope_paths(&host, scope, &home, &cwd, skill_name, app_id), + None => choose_scope_path(&host, scope, &home, &cwd, skill_name) + .into_iter() + .collect(), + }; + if roots.is_empty() { + errors.push(json!({ + "hostId": host.id, + "skillName": skill_name, + "scope": scope_text(scope), + "reason": "unsupported-scope" + })); + } else { + for root in roots { + let target_dir = root.join(skill_name); + let group = by_target + .entry(target_dir.clone()) + .or_insert_with(|| TargetGroup { + host_ids: Vec::new(), + skill_name: skill_name.to_string(), + target_dir, + }); + if !group.host_ids.contains(&host.id) { + group.host_ids.push(host.id.clone()); + } + } + } + } + let targets: Vec<_> = by_target.into_values().collect(); + let detected_host_ids = targets + .iter() + .flat_map(|target| target.host_ids.clone()) + .collect(); + Ok((targets, errors, detected_host_ids)) +} + +fn add_universal_host(mut selected: Vec, hosts: &[Host]) -> Vec { + if selected.iter().any(|host| host.id == "universal") { + return selected; + } + if let Some(host) = hosts.iter().find(|host| host.id == "universal") { + selected.push(host.clone()); + } + selected +} + +pub(crate) fn host_ids(hosts: &[Host]) -> Vec { + hosts.iter().map(|host| host.id.clone()).collect() +} + +pub(crate) fn install_selection( + selected_host_ids: Vec, + detected_host_ids: Vec, + needs_confirmation: bool, + errors: Vec, +) -> InstallSelection { + InstallSelection { + action: if errors.is_empty() { + "install" + } else { + "error" + } + .into(), + selected_host_ids, + detected_host_ids, + needs_confirmation: needs_confirmation && errors.is_empty(), + errors, + ..InstallSelection::default() + } +} + +pub(crate) fn error_selection( + errors: Vec, + detected_host_ids: Vec, +) -> InstallSelection { + install_selection(vec![], detected_host_ids, false, errors) +} + +pub(crate) fn hosts_by_id(hosts: &[Host], ids: &[String]) -> Vec { + let by_id: HashMap<_, _> = hosts.iter().map(|host| (host.id.as_str(), host)).collect(); + ids.iter() + .filter_map(|id| by_id.get(id.as_str()).map(|host| (*host).clone())) + .collect() +} + +fn canonical_scope_path(host: &Host, scope: Scope, home: &Path, cwd: &Path) -> Option { + let paths = scope_paths(host, scope); + paths.first().map(|path| expand_host_path(path, home, cwd)) +} + +fn choose_scope_path( + host: &Host, + scope: Scope, + home: &Path, + cwd: &Path, + skill_name: &str, +) -> Option { + let paths = scope_paths(host, scope); + let existing: Vec<_> = paths + .iter() + .map(|path| expand_host_path(path, home, cwd)) + .filter(|path| path.is_dir()) + .collect(); + for root in &existing { + if let MetadataState::Managed(metadata) = read_metadata(&root.join(skill_name)) { + if metadata.skill_name == skill_name { + return Some(root.clone()); + } + } + } + if let Some(root) = existing.into_iter().next() { + return Some(root); + } + paths.first().map(|path| expand_host_path(path, home, cwd)) +} + +fn uninstall_scope_paths( + host: &Host, + scope: Scope, + home: &Path, + cwd: &Path, + skill_name: &str, + app_id: &str, +) -> Vec { + let owned: Vec<_> = scope_paths(host, scope) + .iter() + .map(|path| expand_host_path(path, home, cwd)) + .filter(|root| { + matches!( + read_metadata(&root.join(skill_name)).for_owner(app_id, skill_name), + MetadataState::Managed(_) + ) + }) + .collect(); + if !owned.is_empty() { + return owned; + } + choose_scope_path(host, scope, home, cwd, skill_name) + .into_iter() + .collect() +} + +fn scope_paths(host: &Host, scope: Scope) -> &[String] { + match scope { + Scope::User => &host.user_skills_dirs, + Scope::Project => &host.project_skills_dirs, + } +} + +fn expand_host_path(path: &str, home: &Path, cwd: &Path) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + home.join(rest) + } else { + cwd.join(path) + } +} + +fn defaults(options: &BaseOptions) -> io::Result<(PathBuf, PathBuf)> { + let home = match &options.home { + Some(home) => home.clone(), + None => PathBuf::from( + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_default(), + ), + }; + let cwd = match &options.cwd { + Some(cwd) => cwd.clone(), + None => std::env::current_dir()?, + }; + Ok((home, cwd)) +} + +fn is_generic_detect_path(path: &str) -> bool { + matches!( + path, + "~/.agents" + | "~/.agents/skills" + | "~/.config/agents" + | ".agents" + | ".agents/skills" + | "package.json" + ) +} + +pub(crate) fn scope_text(scope: Scope) -> &'static str { + match scope { + Scope::User => "user", + Scope::Project => "project", + } +} diff --git a/rust/src/install.rs b/rust/src/install.rs new file mode 100644 index 0000000..ccb1f9c --- /dev/null +++ b/rust/src/install.rs @@ -0,0 +1,334 @@ +use crate::bundle::{ + content_hash, is_github_bundle, mode_bits, resolve_skill_bundle, set_mode, + validate_normalized_skill, NormalizedSkillBundle, +}; +use crate::hosts::{resolve_install_targets, resolve_install_targets_for_lifecycle}; +use crate::metadata::{installed_metadata, read_metadata, write_metadata, MetadataState}; +use crate::types::*; +use serde_json::{json, Value}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub fn install_bundled_skill(options: &InstallOptions) -> io::Result { + install_or_plan(options, true) +} + +pub fn plan_bundled_skill(options: &InstallOptions) -> io::Result { + install_or_plan(options, false) +} + +pub fn update_bundled_skill(options: &InstallOptions) -> io::Result { + install_bundled_skill(options) +} + +pub fn uninstall_bundled_skill(options: &UninstallOptions) -> io::Result { + if options.app_id.is_empty() { + return Ok(uninstall_report(vec![json!({ + "reason": "invalid-app-id" + })])); + } + let (targets, errors, _) = resolve_install_targets_for_lifecycle( + &options.base, + &options.agents, + options.scope, + &options.skill_name, + Some(&options.app_id), + )?; + let mut report = uninstall_report(errors); + for target in targets { + let result = target_result(&target); + match read_metadata(&target.target_dir).for_owner(&options.app_id, &options.skill_name) { + MetadataState::Missing => report.skipped.push(with_reason(result, "missing")), + MetadataState::Conflict(reason) => report.conflicts.push(with_reason(result, reason)), + MetadataState::Managed(_) => { + if let Some(reason) = + remove_managed_skill(&target.target_dir, &options.app_id, &options.skill_name)? + { + report.conflicts.push(with_reason(result, &reason)); + continue; + } + report.removed.push(result); + } + } + } + Ok(report) +} + +pub fn status_bundled_skill(options: &StatusOptions) -> io::Result { + if options.app_id.is_empty() { + return Ok(status_report(vec![json!({ "reason": "invalid-app-id" })])); + } + let (targets, errors, _) = resolve_install_targets_for_lifecycle( + &options.base, + &options.agents, + options.scope, + &options.skill_name, + Some(&options.app_id), + )?; + let mut report = status_report(errors); + for target in targets { + let result = target_result(&target); + match read_metadata(&target.target_dir).for_owner(&options.app_id, &options.skill_name) { + MetadataState::Missing => report.missing.push(result), + MetadataState::Conflict(reason) => report.conflicts.push(with_reason(result, reason)), + MetadataState::Managed(metadata) => report.installed.push(InstalledTarget { + target: result, + metadata: *metadata, + }), + } + } + Ok(report) +} + +fn install_or_plan(options: &InstallOptions, write: bool) -> io::Result { + if options.app_id.is_empty() { + return Ok(install_report(vec![json!({ "reason": "invalid-app-id" })])); + } + let (bundle, bundle_metadata) = match resolve_skill_bundle(&options.skill_bundle) { + Ok(value) => value, + Err(_) => { + let reason = if is_github_bundle(&options.skill_bundle) { + "bundle-resolve-failed" + } else { + "invalid-skill-bundle" + }; + return Ok(install_report(vec![json!({ "reason": reason })])); + } + }; + let skill = validate_normalized_skill(&bundle); + if !skill.valid { + return Ok(install_report(vec![json!({ "reason": skill.error_code })])); + } + let skill_name = skill.skill_name.unwrap(); + let metadata = installed_metadata( + &options.app_id, + &skill_name, + &content_hash(&bundle), + &bundle_metadata, + ); + let (targets, errors, _) = + resolve_install_targets(&options.base, &options.agents, options.scope, &skill_name)?; + let mut report = install_report(errors); + for target in targets { + let result = target_result(&target); + let state = read_metadata(&target.target_dir).for_owner(&options.app_id, &skill_name); + if let MetadataState::Conflict(reason) = &state { + if !options.force { + report.conflicts.push(with_reason(result, reason)); + continue; + } + } + match state { + MetadataState::Managed(previous) if previous.hash == metadata.hash => { + let repaired = repair_skill_bundle_modes(&bundle, &target.target_dir, write)?; + if repaired || (bundle_metadata.explicit && *previous != metadata) { + if write { + write_metadata(&target.target_dir, &metadata)?; + } + report.updated.push(result); + } else { + report.skipped.push(with_reason(result, "unchanged")); + } + } + state => { + let replace = !matches!(state, MetadataState::Missing); + if write { + write_managed_skill(&bundle, &target.target_dir, &metadata, replace)?; + } + if replace { + report.updated.push(result); + } else { + report.installed.push(result); + } + } + } + } + Ok(report) +} + +fn write_managed_skill( + bundle: &NormalizedSkillBundle, + target_dir: &Path, + metadata: &InstalledMetadata, + replace: bool, +) -> io::Result<()> { + let tmp = make_staging_dir(target_dir)?; + let backup = PathBuf::from(format!("{}-backup", tmp.display())); + let result = (|| { + copy_skill_bundle(bundle, &tmp)?; + write_metadata(&tmp, metadata)?; + if replace { + fs::rename(target_dir, &backup)?; + } + if let Err(error) = fs::rename(&tmp, target_dir) { + if replace && !target_dir.exists() && backup.exists() { + let _ = fs::rename(&backup, target_dir); + } + return Err(error); + } + if replace { + fs::remove_dir_all(&backup)?; + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&tmp); + } + result +} + +static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn make_staging_dir(target_dir: &Path) -> io::Result { + let parent = target_dir.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "install target has no parent") + })?; + fs::create_dir_all(parent)?; + let name = target_dir + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "install target has no name"))? + .to_string_lossy(); + loop { + let candidate = parent.join(format!( + ".{name}.kitup-{}-{}", + std::process::id(), + STAGING_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + match fs::create_dir(&candidate) { + Ok(()) => { + if let Err(error) = set_mode(&candidate, 0o755) { + let _ = fs::remove_dir_all(&candidate); + return Err(error); + } + return Ok(candidate); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } +} + +fn remove_managed_skill( + target_dir: &Path, + app_id: &str, + skill_name: &str, +) -> io::Result> { + let quarantine = make_staging_dir(target_dir)?; + fs::remove_dir(&quarantine)?; + fs::rename(target_dir, &quarantine)?; + let reason = match read_metadata(&quarantine).for_owner(app_id, skill_name) { + MetadataState::Managed(_) => { + fs::remove_dir_all(quarantine)?; + return Ok(None); + } + MetadataState::Conflict(reason) => reason, + MetadataState::Missing => "unmanaged", + }; + if target_dir.exists() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("cannot restore changed install: {}", target_dir.display()), + )); + } + fs::rename(&quarantine, target_dir)?; + Ok(Some(reason.into())) +} + +fn copy_skill_bundle(bundle: &NormalizedSkillBundle, dest: &Path) -> io::Result<()> { + fs::create_dir_all(dest)?; + for (path, file) in bundle { + let to = dest.join(path.replace('/', std::path::MAIN_SEPARATOR_STR)); + if let Some(parent) = to.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&to, &file.contents)?; + set_mode(&to, file.mode)?; + } + Ok(()) +} + +fn repair_skill_bundle_modes( + bundle: &NormalizedSkillBundle, + dest: &Path, + write: bool, +) -> io::Result { + let mut repaired = false; + for (path, file) in bundle { + let to = dest.join(path.replace('/', std::path::MAIN_SEPARATOR_STR)); + let metadata = match fs::symlink_metadata(&to) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + }; + if metadata.is_file() && mode_bits(&metadata).is_some_and(|mode| mode != file.mode) { + repaired = true; + if write { + set_mode(&to, file.mode)?; + } + } + } + Ok(repaired) +} + +fn target_result(target: &TargetGroup) -> TargetResult { + let single = target.host_ids.len() == 1; + TargetResult { + host_id: single.then(|| target.host_ids[0].clone()), + host_ids: if single { + vec![] + } else { + target.host_ids.clone() + }, + skill_name: target.skill_name.clone(), + target_dir: target.target_dir.clone(), + } +} + +fn with_reason(target: TargetResult, reason: &str) -> TargetStatus { + TargetStatus { + target, + reason: reason.to_string(), + } +} + +fn install_report(errors: Vec) -> InstallReport { + InstallReport { + errors: report_errors(errors), + ..InstallReport::default() + } +} + +fn uninstall_report(errors: Vec) -> UninstallReport { + UninstallReport { + errors: report_errors(errors), + ..UninstallReport::default() + } +} + +fn status_report(errors: Vec) -> StatusReport { + StatusReport { + errors: report_errors(errors), + ..StatusReport::default() + } +} + +fn report_errors(errors: Vec) -> Vec { + errors + .into_iter() + .map(|error| ReportError { + agent: string_value(&error, "agent"), + flag: string_value(&error, "flag"), + host_id: string_value(&error, "hostId"), + reason: string_value(&error, "reason").unwrap_or_default(), + scope: string_value(&error, "scope"), + skill_name: string_value(&error, "skillName"), + value: string_value(&error, "value"), + }) + .collect() +} + +fn string_value(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_str).map(String::from) +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index fb095b0..589e7e2 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,2244 +1,36 @@ +mod bundle; +mod github; +mod hosts; mod hosts_generated; - -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use sha2::{Digest, Sha256}; -use std::collections::{BTreeMap, HashMap, HashSet}; -use std::fs; -use std::io::{self, BufRead, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Scope { - User, - Project, -} - -pub struct InstallUxText { - pub skill_use: &'static str, - pub skill_short: &'static str, - pub install_use: &'static str, - pub install_short: &'static str, - pub scope_flag: &'static str, - pub agent_flag: &'static str, - pub dry_run_flag: &'static str, - pub yes_flag: &'static str, - pub force_flag: &'static str, - pub select_scope: &'static str, - pub scope_prompt: &'static str, - pub invalid_scope_selection: &'static str, - pub select_agents: &'static str, - pub agents_prompt: &'static str, - pub invalid_agent_selection: &'static str, - pub proceed: &'static str, - pub install_summary: &'static str, - pub error_prefix: &'static str, - pub canceled: &'static str, - pub selection_error: &'static str, - pub conflict: &'static str, - pub failed: &'static str, - pub invalid_flags: &'static str, -} - -pub const INSTALL_UX: InstallUxText = InstallUxText { - skill_use: "skill", - skill_short: "Manage bundled Agent Skill", - install_use: "install", - install_short: "Install bundled Agent Skill", - scope_flag: "Install scope: user or project", - agent_flag: "Target agent id. Repeat for multiple agents. Use '*' for all.", - dry_run_flag: "Show install plan without writing", - yes_flag: "Skip prompts and accept policy-selected targets", - force_flag: "Overwrite unsafe target conflicts", - select_scope: "Select install scope:", - scope_prompt: "Scope (user/project)", - invalid_scope_selection: "Invalid scope selection.", - select_agents: "Select agents:", - agents_prompt: "Agents (numbers, ids, comma-separated, empty cancels)", - invalid_agent_selection: "Invalid agent selection.", - proceed: "Proceed? [y/N] ", - install_summary: "Install summary:", - error_prefix: "kitup:", - canceled: "Installation canceled.", - selection_error: "Agent selection failed.", - conflict: "Installation has conflicts.", - failed: "Installation failed.", - invalid_flags: "Invalid install flags.", +mod install; +mod metadata; +mod types; +mod workflow; + +pub use bundle::{ + compute_bundle_content_hash, directory_bundle, files_bundle, github_bundle, + validate_skill_bundle, with_bundle_metadata, +}; +pub use hosts::{ + detect_hosts, load_host_spec, resolve_hosts, resolve_install_selection, resolve_install_targets, +}; +pub use install::{ + install_bundled_skill, plan_bundled_skill, status_bundled_skill, uninstall_bundled_skill, + update_bundled_skill, +}; +pub use metadata::read_installed_metadata; +pub use types::{ + AgentSelector, BaseOptions, BundledSkillMetadata, GitHubBundleOptions, Host, InstallFlagValues, + InstallOptions, InstallReport, InstallSelection, InstallSelectionOptions, InstallWorkflowExit, + InstallWorkflowOptions, InstallWorkflowReport, InstalledMetadata, InstalledTarget, + ParsedInstallFlags, ReportError, Scope, SkillBundle, SkillFile, SkillInfo, StatusOptions, + StatusReport, TargetGroup, TargetResult, TargetStatus, UninstallOptions, UninstallReport, +}; +pub use workflow::{ + agent_selector_from_flags, classify_install_workflow_exit, install_flag_error, + install_workflow_error, parse_install_flags, parse_scope_flag, run_bundled_skill_install, + run_bundled_skill_install_with_io, InstallUxText, INSTALL_UX, }; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum AgentSelector { - Auto, - All, - Explicit(Vec), -} - -#[derive(Clone, Debug, Default)] -pub struct InstallFlagValues { - pub scope: Option, - pub scope_set: bool, - pub agents: Vec, - pub yes: bool, - pub dry_run: bool, - pub force: bool, -} - -#[derive(Clone, Debug)] -pub struct ParsedInstallFlags { - pub scope: Scope, - pub scope_set: bool, - pub agents: AgentSelector, - pub yes: bool, - pub dry_run: bool, - pub force: bool, - pub errors: Vec, -} - -#[derive(Clone, Debug, Default)] -pub struct BaseOptions { - pub home: Option, - pub cwd: Option, - pub hosts_file: Option, -} - -#[derive(Clone, Debug)] -pub struct InstallOptions { - pub base: BaseOptions, - pub app_id: String, - pub skill_bundle: SkillBundle, - pub scope: Scope, - pub agents: AgentSelector, - pub force: bool, -} - -#[derive(Clone, Debug)] -pub struct UninstallOptions { - pub base: BaseOptions, - pub app_id: String, - pub skill_name: String, - pub scope: Scope, - pub agents: AgentSelector, -} - -#[derive(Clone, Debug)] -pub struct StatusOptions { - pub base: BaseOptions, - pub app_id: String, - pub skill_name: String, - pub scope: Scope, - pub agents: AgentSelector, -} - -#[derive(Clone, Debug, Default)] -pub struct InstallSelectionOptions { - pub base: BaseOptions, - pub scope: Option, - pub agents: Option, - pub yes: bool, - pub stdin_tty: bool, - pub current_agent: Option, -} - -#[derive(Clone, Debug)] -pub struct InstallWorkflowOptions { - pub install: InstallOptions, - pub yes: bool, - pub dry_run: bool, - pub stdin_tty: bool, - pub current_agent: Option, - pub default_scope: Option, - pub scope_set: bool, - pub prompt_scope: bool, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Host { - pub id: String, - pub display_name: String, - #[serde(default)] - pub aliases: Vec, - pub project_skills_dirs: Vec, - pub user_skills_dirs: Vec, - pub detect: Vec, - pub status: String, - #[serde(default)] - pub notes: Vec, -} - -#[derive(Deserialize)] -struct HostSpec { - hosts: Vec, -} - -#[derive(Clone, Debug)] -pub struct SkillInfo { - pub valid: bool, - pub skill_name: Option, - pub description: Option, - pub error_code: Option, -} - -#[derive(Clone, Debug)] -pub struct SkillFile { - pub path: String, - pub contents: Vec, - pub mode: Option, -} - -#[derive(Clone, Debug)] -pub enum SkillBundle { - Directory(PathBuf), - Files(Vec), - GitHub(GitHubBundleOptions), - Metadata(Box, BundledSkillMetadata), -} - -#[derive(Clone, Debug, Default)] -pub struct BundledSkillMetadata { - pub source_id: Option, - pub cli_version: Option, - pub cli_revision: Option, - pub provenance: BTreeMap, -} - -#[derive(Clone, Debug)] -pub struct GitHubBundleOptions { - pub owner: String, - pub repo: String, - pub path: String, - pub ref_name: String, -} - -pub fn directory_bundle(path: impl Into) -> SkillBundle { - SkillBundle::Directory(path.into()) -} - -pub fn files_bundle(files: Vec) -> SkillBundle { - SkillBundle::Files(files) -} #[cfg(feature = "include-dir")] -pub fn include_dir_bundle(dir: &include_dir::Dir<'_>) -> SkillBundle { - fn collect(root: &Path, dir: &include_dir::Dir<'_>, files: &mut Vec) { - for file in dir.files() { - let path = file.path(); - let path = if root.as_os_str().is_empty() { - path - } else { - path.strip_prefix(root).unwrap_or(path) - }; - files.push(SkillFile { - path: path.to_string_lossy().replace('\\', "/"), - contents: file.contents().to_vec(), - mode: None, - }); - } - for dir in dir.dirs() { - collect(root, dir, files); - } - } - - let mut files = Vec::new(); - collect(dir.path(), dir, &mut files); - files_bundle(files) -} - -pub fn github_bundle(options: GitHubBundleOptions) -> SkillBundle { - SkillBundle::GitHub(options) -} - -pub fn with_bundle_metadata(bundle: SkillBundle, metadata: BundledSkillMetadata) -> SkillBundle { - SkillBundle::Metadata(Box::new(bundle), metadata) -} - -#[derive(Clone, Debug)] -pub struct TargetGroup { - pub host_ids: Vec, - pub skill_name: String, - pub target_dir: PathBuf, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TargetResult { - #[serde(skip_serializing_if = "Option::is_none")] - pub host_id: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub host_ids: Vec, - pub skill_name: String, - pub target_dir: PathBuf, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TargetStatus { - #[serde(flatten)] - pub target: TargetResult, - pub reason: String, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReportError { - #[serde(skip_serializing_if = "Option::is_none")] - pub agent: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub flag: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub host_id: Option, - pub reason: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub scope: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub skill_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub value: Option, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct InstallReport { - pub installed: Vec, - pub updated: Vec, - pub skipped: Vec, - pub conflicts: Vec, - pub errors: Vec, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct UninstallReport { - pub removed: Vec, - pub skipped: Vec, - pub conflicts: Vec, - pub errors: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct InstalledMetadata { - pub schema_version: u32, - pub app_id: String, - pub skill_name: String, - pub source: String, - pub hash: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub source_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cli_version: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cli_revision: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub provenance: BTreeMap, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct InstalledTarget { - #[serde(flatten)] - pub target: TargetResult, - pub metadata: InstalledMetadata, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct StatusReport { - pub installed: Vec, - pub missing: Vec, - pub conflicts: Vec, - pub errors: Vec, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct InstallSelection { - pub action: String, - pub selected_host_ids: Vec, - pub candidate_host_ids: Vec, - pub detected_host_ids: Vec, - pub needs_confirmation: bool, - pub errors: Vec, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct InstallWorkflowReport { - pub selection: InstallSelection, - pub scope: String, - pub plan: InstallReport, - pub report: InstallReport, - pub canceled: bool, - pub dry_run: bool, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct InstallWorkflowExit { - pub ok: bool, - pub code: String, - pub message: String, -} - -type Metadata = InstalledMetadata; - -#[derive(Clone, Debug)] -struct BundleFile { - path: String, - contents: Vec, - mode: u32, -} - -#[derive(Clone, Debug)] -struct NormalizedSkillBundle { - by_path: BTreeMap, -} - -#[derive(Clone, Debug)] -struct BundleMetadata { - source: String, - source_id: Option, - version: Option, - cli_version: Option, - cli_revision: Option, - provenance: BTreeMap, - explicit: bool, -} - -pub fn parse_install_flags(flags: InstallFlagValues) -> ParsedInstallFlags { - let mut errors = Vec::new(); - let scope = parse_scope_flag(flags.scope.as_deref(), &mut errors); - let agents = agent_selector_from_flags(&flags.agents, &mut errors); - ParsedInstallFlags { - scope, - scope_set: flags.scope_set || flags.scope.is_some(), - agents, - yes: flags.yes, - dry_run: flags.dry_run, - force: flags.force, - errors, - } -} - -pub fn agent_selector_from_flags(values: &[String], errors: &mut Vec) -> AgentSelector { - let agents = split_flag_values(values); - if agents.is_empty() { - return AgentSelector::Auto; - } - if agents.iter().any(|agent| agent == "*") { - if agents.len() > 1 { - errors.push(json!({ - "flag": "agent", - "reason": "agent-star-must-be-alone", - "value": agents.join(",") - })); - } - return AgentSelector::All; - } - let mut seen = HashSet::new(); - AgentSelector::Explicit( - agents - .into_iter() - .filter(|agent| seen.insert(agent.clone())) - .collect(), - ) -} - -pub fn parse_scope_flag(value: Option<&str>, errors: &mut Vec) -> Scope { - match value.unwrap_or("user") { - "" | "user" => Scope::User, - "project" => Scope::Project, - value => { - errors.push(json!({ "flag": "scope", "reason": "invalid-scope", "value": value })); - Scope::User - } - } -} - -fn split_flag_values(values: &[String]) -> Vec { - values - .iter() - .flat_map(|value| value.split(|ch: char| ch == ',' || ch.is_whitespace())) - .filter(|value| !value.is_empty()) - .map(String::from) - .collect() -} - -pub fn classify_install_workflow_exit(report: &InstallWorkflowReport) -> InstallWorkflowExit { - if report.canceled { - return install_workflow_exit(false, "canceled", INSTALL_UX.canceled); - } - if !report.selection.errors.is_empty() { - return install_workflow_exit(false, "selection-error", INSTALL_UX.selection_error); - } - if !report.report.conflicts.is_empty() { - return install_workflow_exit(false, "conflict", INSTALL_UX.conflict); - } - if !report.report.errors.is_empty() { - return install_workflow_exit(false, "error", INSTALL_UX.failed); - } - install_workflow_exit(true, "ok", "") -} - -pub fn install_workflow_error(report: &InstallWorkflowReport) -> io::Result<()> { - let exit = classify_install_workflow_exit(report); - if exit.ok || exit.code == "canceled" { - Ok(()) - } else { - Err(io::Error::other(exit.message)) - } -} - -pub fn install_flag_error(errors: &[Value]) -> io::Result<()> { - if errors.is_empty() { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::InvalidInput, - INSTALL_UX.invalid_flags, - )) - } -} - -fn install_workflow_exit(ok: bool, code: &str, message: &str) -> InstallWorkflowExit { - InstallWorkflowExit { - ok, - code: code.to_string(), - message: message.to_string(), - } -} - -pub fn load_host_spec(hosts_file: Option<&Path>) -> io::Result> { - let spec: HostSpec = match hosts_file { - Some(path) => serde_json::from_slice(&fs::read(path)?)?, - None => serde_json::from_str(hosts_generated::DEFAULT_HOSTS_SPEC_JSON)?, - }; - validate_host_spec(&spec.hosts)?; - Ok(spec.hosts) -} - -fn validate_host_spec(hosts: &[Host]) -> io::Result<()> { - for host in hosts { - let install_dirs: HashSet<&str> = host - .project_skills_dirs - .iter() - .chain(&host.user_skills_dirs) - .map(String::as_str) - .collect(); - for path in &host.project_skills_dirs { - if !is_project_host_path(path) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid project path {path:?} for host {}", host.id), - )); - } - } - for path in &host.user_skills_dirs { - if !is_home_host_path(path) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid user path {path:?} for host {}", host.id), - )); - } - } - for path in &host.detect { - if !is_home_host_path(path) && !is_project_host_path(path) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid detect path {path:?} for host {}", host.id), - )); - } - if !is_generic_detect_path(path) && install_dirs.contains(path.as_str()) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "detect path is an install target for host {}: {path:?}", - host.id - ), - )); - } - } - } - Ok(()) -} - -fn is_project_host_path(path: &str) -> bool { - !path.is_empty() && !path.starts_with('/') && !path.starts_with('~') && is_safe_host_path(path) -} - -fn is_home_host_path(path: &str) -> bool { - path.starts_with("~/") && is_safe_host_path(&path[2..]) -} - -fn is_safe_host_path(path: &str) -> bool { - !path - .chars() - .any(|character| matches!(character, '\0' | '\\' | ':')) - && !path - .split('/') - .any(|segment| segment == "." || segment == ".." || segment.is_empty()) -} - -pub fn resolve_hosts(agents: &AgentSelector, hosts: &[Host]) -> (Vec, Vec) { - match agents { - AgentSelector::All => (hosts.to_vec(), vec![]), - AgentSelector::Auto => (vec![], vec![]), - AgentSelector::Explicit(ids) => { - let mut by_name = HashMap::new(); - for host in hosts { - by_name.insert(host.id.as_str(), host); - for alias in &host.aliases { - by_name.insert(alias.as_str(), host); - } - } - let mut seen = HashSet::new(); - let mut resolved = Vec::new(); - let mut errors = Vec::new(); - for id in ids { - if let Some(host) = by_name.get(id.as_str()) { - if seen.insert(host.id.clone()) { - resolved.push((*host).clone()); - } - } else { - errors.push(json!({ "agent": id, "reason": "unknown-host" })); - } - } - (resolved, errors) - } - } -} - -pub fn detect_hosts(options: &BaseOptions, scope: Option) -> io::Result> { - let hosts = load_host_spec(options.hosts_file.as_deref())?; - let (home, cwd) = defaults(options)?; - let mut detected = Vec::new(); - for host in hosts { - if host.detect.iter().any(|path| { - !is_generic_detect_path(path) && expand_host_path(path, &home, &cwd).exists() - }) { - detected.push(host); - } - } - if let Some(scope) = scope { - detected.sort_by(|a, b| { - let left = canonical_scope_path(a, scope, &home, &cwd).unwrap_or_default(); - let right = canonical_scope_path(b, scope, &home, &cwd).unwrap_or_default(); - left.cmp(&right).then_with(|| a.id.cmp(&b.id)) - }); - } - Ok(detected) -} - -pub fn resolve_install_selection( - options: &InstallSelectionOptions, -) -> io::Result { - let hosts = load_host_spec(options.base.hosts_file.as_deref())?; - let explicit_agents = matches!( - options.agents, - Some(AgentSelector::All) | Some(AgentSelector::Explicit(_)) - ); - if let Some(current_agent) = &options.current_agent { - if !explicit_agents { - let (selected, errors) = resolve_hosts( - &AgentSelector::Explicit(vec![current_agent.clone()]), - &hosts, - ); - let selected = add_universal_host(selected, &hosts); - return Ok(install_selection( - host_ids(&selected), - vec![], - !options.yes && options.stdin_tty, - errors, - )); - } - } - if explicit_agents { - match options.agents.as_ref().unwrap() { - AgentSelector::All => { - return Ok(install_selection( - host_ids(&hosts), - vec![], - !options.yes && options.stdin_tty, - vec![], - )) - } - AgentSelector::Explicit(_) => { - let (selected, errors) = resolve_hosts(options.agents.as_ref().unwrap(), &hosts); - if !errors.is_empty() { - return Ok(error_selection(errors, vec![])); - } - return Ok(install_selection( - host_ids(&selected), - vec![], - !options.yes && options.stdin_tty, - vec![], - )); - } - AgentSelector::Auto => {} - } - } - let scope = options.scope.unwrap_or(Scope::User); - let detected = detect_hosts(&options.base, Some(scope))?; - let detected_host_ids = host_ids(&detected); - if !options.stdin_tty && !options.yes { - return Ok(error_selection( - vec![json!({ "reason": "agent-selection-required" })], - detected_host_ids, - )); - } - if options.yes { - if detected.is_empty() { - return Ok(error_selection( - vec![json!({ "reason": "no-detected-hosts" })], - detected_host_ids, - )); - } - return Ok(install_selection( - detected_host_ids.clone(), - detected_host_ids, - false, - vec![], - )); - } - if detected.is_empty() { - return Ok(select_agents_selection(host_ids(&hosts), vec![], vec![])); - } - if detected.len() == 1 { - return Ok(install_selection( - detected_host_ids.clone(), - detected_host_ids, - true, - vec![], - )); - } - Ok(select_agents_selection( - detected_host_ids.clone(), - detected_host_ids, - vec![], - )) -} - -pub fn resolve_install_targets( - options: &BaseOptions, - agents: &AgentSelector, - scope: Scope, - skill_name: &str, -) -> io::Result<(Vec, Vec, Vec)> { - resolve_install_targets_for_lifecycle(options, agents, scope, skill_name, None) -} - -fn resolve_install_targets_for_lifecycle( - options: &BaseOptions, - agents: &AgentSelector, - scope: Scope, - skill_name: &str, - uninstall_app_id: Option<&str>, -) -> io::Result<(Vec, Vec, Vec)> { - if !valid_skill_name(skill_name) { - return Ok(( - vec![], - vec![json!({ - "skillName": skill_name, - "reason": "invalid-skill-name" - })], - vec![], - )); - } - let hosts = load_host_spec(options.hosts_file.as_deref())?; - let (home, cwd) = defaults(options)?; - let (selected, mut errors) = match agents { - AgentSelector::Auto => (detect_hosts(options, Some(scope))?, vec![]), - _ => resolve_hosts(agents, &hosts), - }; - let mut by_target: BTreeMap = BTreeMap::new(); - for host in selected { - let roots = match uninstall_app_id { - Some(app_id) => uninstall_scope_paths(&host, scope, &home, &cwd, skill_name, app_id), - None => choose_scope_path(&host, scope, &home, &cwd, skill_name) - .into_iter() - .collect(), - }; - if roots.is_empty() { - errors.push(json!({ - "hostId": host.id, - "skillName": skill_name, - "scope": scope_text(scope), - "reason": "unsupported-scope" - })); - } else { - for root in roots { - let target_dir = root.join(skill_name); - let group = by_target - .entry(target_dir.clone()) - .or_insert_with(|| TargetGroup { - host_ids: Vec::new(), - skill_name: skill_name.to_string(), - target_dir, - }); - if !group.host_ids.contains(&host.id) { - group.host_ids.push(host.id.clone()); - } - } - } - } - let targets: Vec<_> = by_target.into_values().collect(); - let detected_host_ids = targets - .iter() - .flat_map(|target| target.host_ids.clone()) - .collect(); - Ok((targets, errors, detected_host_ids)) -} - -fn add_universal_host(mut selected: Vec, hosts: &[Host]) -> Vec { - if selected.iter().any(|host| host.id == "universal") { - return selected; - } - if let Some(host) = hosts.iter().find(|host| host.id == "universal") { - selected.push(host.clone()); - } - selected -} - -fn host_ids(hosts: &[Host]) -> Vec { - hosts.iter().map(|host| host.id.clone()).collect() -} - -fn install_selection( - selected_host_ids: Vec, - detected_host_ids: Vec, - mut needs_confirmation: bool, - errors: Vec, -) -> InstallSelection { - let action = if errors.is_empty() { - "install" - } else { - needs_confirmation = false; - "error" - }; - InstallSelection { - action: action.to_string(), - selected_host_ids, - candidate_host_ids: vec![], - detected_host_ids, - needs_confirmation, - errors, - } -} - -fn select_agents_selection( - candidate_host_ids: Vec, - detected_host_ids: Vec, - selected_host_ids: Vec, -) -> InstallSelection { - InstallSelection { - action: "select-agents".to_string(), - selected_host_ids, - candidate_host_ids, - detected_host_ids, - needs_confirmation: true, - errors: vec![], - } -} - -fn error_selection(errors: Vec, detected_host_ids: Vec) -> InstallSelection { - InstallSelection { - action: "error".to_string(), - selected_host_ids: vec![], - candidate_host_ids: vec![], - detected_host_ids, - needs_confirmation: false, - errors, - } -} - -pub fn validate_skill_bundle(bundle: &SkillBundle) -> SkillInfo { - let bundle = match read_skill_bundle(bundle) { - Ok(bundle) => bundle, - Err(_) => { - return SkillInfo { - valid: false, - skill_name: None, - description: None, - error_code: Some("invalid-skill-bundle".to_string()), - } - } - }; - validate_normalized_skill(&bundle) -} - -fn validate_normalized_skill(bundle: &NormalizedSkillBundle) -> SkillInfo { - let Some(file) = bundle.by_path.get("SKILL.md") else { - return SkillInfo { - valid: false, - skill_name: None, - description: None, - error_code: Some("missing-skill-md".to_string()), - }; - }; - let content = String::from_utf8_lossy(&file.contents); - let Some(rest) = content.strip_prefix("---\n") else { - return invalid_frontmatter(); - }; - let Some(end) = rest.find("\n---\n") else { - return invalid_frontmatter(); - }; - let fields = parse_frontmatter(&rest[..end]); - let name = fields.get("name").cloned().unwrap_or_default(); - let description = fields.get("description").cloned().unwrap_or_default(); - if !valid_skill_name(&name) || description.is_empty() || description.len() > 1024 { - return invalid_frontmatter(); - } - SkillInfo { - valid: true, - skill_name: Some(name), - description: Some(description), - error_code: None, - } -} - -pub fn compute_bundle_content_hash(bundle: &SkillBundle) -> io::Result { - Ok(content_hash(&read_skill_bundle(bundle)?)) -} - -fn content_hash(bundle: &NormalizedSkillBundle) -> String { - let mut hash = Sha256::new(); - for file in bundle.by_path.values() { - hash.update(file.path.as_bytes()); - hash.update([0]); - hash.update(&file.contents); - hash.update([0]); - } - format!("sha256:{:x}", hash.finalize()) -} - -pub fn install_bundled_skill(options: &InstallOptions) -> io::Result { - install_or_plan(options, true) -} - -pub fn plan_bundled_skill(options: &InstallOptions) -> io::Result { - install_or_plan(options, false) -} - -pub fn run_bundled_skill_install( - options: &InstallWorkflowOptions, -) -> io::Result { - let stdin = io::stdin(); - let mut input = stdin.lock(); - let stdout = io::stdout(); - let mut output = stdout.lock(); - run_bundled_skill_install_with_io(options, &mut input, &mut output) -} - -pub fn run_bundled_skill_install_with_io( - options: &InstallWorkflowOptions, - input: &mut R, - output: &mut W, -) -> io::Result { - let (scope, scope_error) = resolve_workflow_scope(input, output, options)?; - if let Some(selection) = scope_error { - render_selection_errors(output, &selection)?; - let empty = install_report(vec![]); - return Ok(InstallWorkflowReport { - selection, - scope: String::new(), - plan: empty.clone(), - report: empty, - canceled: false, - dry_run: options.dry_run, - }); - } - let scope = scope.unwrap(); - let workflow_scope = scope_text(scope).to_string(); - let mut selection = resolve_install_selection(&InstallSelectionOptions { - base: options.install.base.clone(), - scope: Some(scope), - agents: Some(options.install.agents.clone()), - yes: options.yes, - stdin_tty: options.stdin_tty, - current_agent: options.current_agent.clone(), - })?; - if selection.action == "error" { - render_selection_errors(output, &selection)?; - let empty = install_report(vec![]); - return Ok(InstallWorkflowReport { - selection, - scope: workflow_scope, - plan: empty.clone(), - report: empty, - canceled: false, - dry_run: options.dry_run, - }); - } - if selection.action == "select-agents" { - let hosts = load_host_spec(options.install.base.hosts_file.as_deref())?; - let selected = prompt_agent_selection(input, output, &selection, &hosts)?; - selection = install_selection( - selected, - selection.detected_host_ids.clone(), - !options.yes && options.stdin_tty, - vec![], - ); - if selection.selected_host_ids.is_empty() { - let empty = install_report(vec![]); - return Ok(InstallWorkflowReport { - selection, - scope: workflow_scope, - plan: empty.clone(), - report: empty, - canceled: true, - dry_run: options.dry_run, - }); - } - } - let mut install = options.install.clone(); - install.agents = AgentSelector::Explicit(selection.selected_host_ids.clone()); - install.scope = scope; - let plan = plan_bundled_skill(&install)?; - if plan.installed.len() + plan.updated.len() + plan.conflicts.len() + plan.errors.len() == 0 { - return Ok(InstallWorkflowReport { - selection, - scope: workflow_scope, - plan: plan.clone(), - report: plan, - canceled: false, - dry_run: options.dry_run, - }); - } - if options.dry_run { - render_install_summary(output, &plan)?; - return Ok(InstallWorkflowReport { - selection, - scope: workflow_scope, - plan: plan.clone(), - report: plan, - canceled: false, - dry_run: true, - }); - } - if !plan.conflicts.is_empty() || !plan.errors.is_empty() { - let mut report = plan.clone(); - report.installed.clear(); - report.updated.clear(); - return Ok(InstallWorkflowReport { - selection, - scope: workflow_scope, - plan: plan.clone(), - report, - canceled: false, - dry_run: options.dry_run, - }); - } - render_install_summary(output, &plan)?; - if selection.needs_confirmation && !prompt_confirmation(input, output)? { - return Ok(InstallWorkflowReport { - selection, - scope: workflow_scope, - plan, - report: install_report(vec![]), - canceled: true, - dry_run: false, - }); - } - let report = install_bundled_skill(&install)?; - Ok(InstallWorkflowReport { - selection, - scope: workflow_scope, - plan, - report, - canceled: false, - dry_run: false, - }) -} - -pub fn update_bundled_skill(options: &InstallOptions) -> io::Result { - install_bundled_skill(options) -} - -pub fn uninstall_bundled_skill(options: &UninstallOptions) -> io::Result { - if options.app_id.is_empty() { - return Ok(uninstall_report(vec![json!({ - "reason": "invalid-app-id" - })])); - } - let (targets, errors, _) = resolve_install_targets_for_lifecycle( - &options.base, - &options.agents, - options.scope, - &options.skill_name, - Some(&options.app_id), - )?; - let mut report = uninstall_report(errors); - for target in targets { - let result = target_result(&target); - match read_metadata(&target.target_dir) { - MetadataState::Missing => report.skipped.push(with_reason(result, "missing")), - MetadataState::Unmanaged => report.conflicts.push(with_reason(result, "unmanaged")), - MetadataState::Managed(meta) if meta.skill_name != options.skill_name => { - report.conflicts.push(with_reason(result, "unmanaged")) - } - MetadataState::Managed(meta) if meta.app_id != options.app_id => { - report.conflicts.push(with_reason(result, "owner-mismatch")) - } - MetadataState::Managed(_) => { - if let Some(reason) = - remove_managed_skill(&target.target_dir, &options.app_id, &options.skill_name)? - { - report.conflicts.push(with_reason(result, &reason)); - continue; - } - report.removed.push(result); - } - } - } - Ok(report) -} - -pub fn status_bundled_skill(options: &StatusOptions) -> io::Result { - if options.app_id.is_empty() { - return Ok(status_report(vec![json!({ "reason": "invalid-app-id" })])); - } - let (targets, errors, _) = resolve_install_targets_for_lifecycle( - &options.base, - &options.agents, - options.scope, - &options.skill_name, - Some(&options.app_id), - )?; - let mut report = status_report(errors); - for target in targets { - let result = target_result(&target); - match read_metadata(&target.target_dir) { - MetadataState::Missing => report.missing.push(result), - MetadataState::Unmanaged => report.conflicts.push(with_reason(result, "unmanaged")), - MetadataState::Managed(meta) if meta.skill_name != options.skill_name => { - report.conflicts.push(with_reason(result, "unmanaged")) - } - MetadataState::Managed(meta) if meta.app_id != options.app_id => { - report.conflicts.push(with_reason(result, "owner-mismatch")) - } - MetadataState::Managed(metadata) => report.installed.push(InstalledTarget { - target: result, - metadata: *metadata, - }), - } - } - Ok(report) -} - -pub fn read_installed_metadata(target_dir: &Path) -> io::Result> { - match read_metadata(target_dir) { - MetadataState::Missing => Ok(None), - MetadataState::Unmanaged => Err(io::Error::new( - io::ErrorKind::InvalidData, - "unmanaged install metadata", - )), - MetadataState::Managed(metadata) => Ok(Some(*metadata)), - } -} - -fn install_or_plan(options: &InstallOptions, write: bool) -> io::Result { - if options.app_id.is_empty() { - return Ok(install_report(vec![json!({ - "reason": "invalid-app-id" - })])); - } - let (bundle, bundle_metadata) = match resolve_skill_bundle(&options.skill_bundle) { - Ok(value) => value, - Err(_) => { - let reason = if is_github_bundle(&options.skill_bundle) { - "bundle-resolve-failed" - } else { - "invalid-skill-bundle" - }; - return Ok(install_report(vec![json!({ - "reason": reason - })])); - } - }; - let skill = validate_normalized_skill(&bundle); - if !skill.valid { - return Ok(install_report(vec![json!({ - "reason": skill.error_code - })])); - } - let skill_name = skill.skill_name.unwrap(); - let hash = content_hash(&bundle); - let (targets, errors, _) = - resolve_install_targets(&options.base, &options.agents, options.scope, &skill_name)?; - let mut report = install_report(errors); - for target in targets { - let result = target_result(&target); - match read_metadata(&target.target_dir) { - MetadataState::Missing => { - if write { - copy_managed_skill( - &bundle, - &target.target_dir, - &options.app_id, - &skill_name, - &hash, - &bundle_metadata, - )?; - } - report.installed.push(result); - } - MetadataState::Unmanaged => { - if options.force { - if write { - replace_managed_skill( - &bundle, - &target.target_dir, - &options.app_id, - &skill_name, - &hash, - &bundle_metadata, - )?; - } - report.updated.push(result); - } else { - report.conflicts.push(with_reason(result, "unmanaged")); - } - } - MetadataState::Managed(meta) if meta.skill_name != skill_name => { - if options.force { - if write { - replace_managed_skill( - &bundle, - &target.target_dir, - &options.app_id, - &skill_name, - &hash, - &bundle_metadata, - )?; - } - report.updated.push(result); - } else { - report.conflicts.push(with_reason(result, "unmanaged")); - } - } - MetadataState::Managed(meta) if meta.app_id != options.app_id => { - if options.force { - if write { - replace_managed_skill( - &bundle, - &target.target_dir, - &options.app_id, - &skill_name, - &hash, - &bundle_metadata, - )?; - } - report.updated.push(result); - } else { - report.conflicts.push(with_reason(result, "owner-mismatch")); - } - } - MetadataState::Managed(meta) if meta.hash == hash => { - let repaired = repair_skill_bundle_modes(&bundle, &target.target_dir, write)?; - let metadata_changed = bundle_metadata.explicit - && *meta - != installed_metadata( - &options.app_id, - &skill_name, - &hash, - &bundle_metadata, - ); - if repaired || metadata_changed { - if write { - write_metadata( - &target.target_dir, - &options.app_id, - &skill_name, - &hash, - &bundle_metadata, - )?; - } - report.updated.push(result); - } else { - report.skipped.push(with_reason(result, "unchanged")) - } - } - MetadataState::Managed(_) => { - if write { - replace_managed_skill( - &bundle, - &target.target_dir, - &options.app_id, - &skill_name, - &hash, - &bundle_metadata, - )?; - } - report.updated.push(result); - } - } - } - Ok(report) -} - -enum MetadataState { - Missing, - Unmanaged, - Managed(Box), -} - -fn copy_managed_skill( - bundle: &NormalizedSkillBundle, - target_dir: &Path, - app_id: &str, - skill_name: &str, - hash: &str, - bundle_metadata: &BundleMetadata, -) -> io::Result<()> { - let tmp = make_staging_dir(target_dir)?; - if let Err(error) = (|| -> io::Result<()> { - copy_skill_bundle(bundle, &tmp)?; - write_metadata(&tmp, app_id, skill_name, hash, bundle_metadata)?; - fs::rename(&tmp, target_dir)?; - Ok(()) - })() { - let _ = fs::remove_dir_all(&tmp); - return Err(error); - } - Ok(()) -} - -fn replace_managed_skill( - bundle: &NormalizedSkillBundle, - target_dir: &Path, - app_id: &str, - skill_name: &str, - hash: &str, - bundle_metadata: &BundleMetadata, -) -> io::Result<()> { - let tmp = make_staging_dir(target_dir)?; - let backup = PathBuf::from(format!("{}-backup", tmp.display())); - if let Err(error) = copy_skill_bundle(bundle, &tmp) { - let _ = fs::remove_dir_all(&tmp); - return Err(error); - } - if let Err(error) = write_metadata(&tmp, app_id, skill_name, hash, bundle_metadata) { - let _ = fs::remove_dir_all(&tmp); - return Err(error); - } - if let Err(error) = fs::rename(target_dir, &backup) { - let _ = fs::remove_dir_all(&tmp); - return Err(error); - } - if let Err(error) = fs::rename(&tmp, target_dir) { - let _ = fs::remove_dir_all(&tmp); - if !target_dir.exists() && backup.exists() { - let _ = fs::rename(&backup, target_dir); - } - return Err(error); - } - fs::remove_dir_all(backup) -} - -static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0); - -fn make_staging_dir(target_dir: &Path) -> io::Result { - let parent = target_dir.parent().ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "install target has no parent") - })?; - fs::create_dir_all(parent)?; - let name = target_dir - .file_name() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "install target has no name"))? - .to_string_lossy(); - loop { - let candidate = parent.join(format!( - ".{name}.kitup-{}-{}", - std::process::id(), - STAGING_COUNTER.fetch_add(1, Ordering::Relaxed) - )); - match fs::create_dir(&candidate) { - Ok(()) => { - if let Err(error) = set_mode(&candidate, 0o755) { - let _ = fs::remove_dir_all(&candidate); - return Err(error); - } - return Ok(candidate); - } - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error), - } - } -} - -fn remove_managed_skill( - target_dir: &Path, - app_id: &str, - skill_name: &str, -) -> io::Result> { - let quarantine = make_staging_dir(target_dir)?; - fs::remove_dir(&quarantine)?; - fs::rename(target_dir, &quarantine)?; - let reason = match read_metadata(&quarantine) { - MetadataState::Managed(metadata) - if metadata.skill_name == skill_name && metadata.app_id == app_id => - { - None - } - MetadataState::Managed(metadata) if metadata.skill_name == skill_name => { - Some("owner-mismatch".to_string()) - } - _ => Some("unmanaged".to_string()), - }; - if let Some(reason) = reason { - if target_dir.exists() { - return Err(io::Error::new( - io::ErrorKind::AlreadyExists, - format!("cannot restore changed install: {}", target_dir.display()), - )); - } - fs::rename(&quarantine, target_dir)?; - return Ok(Some(reason)); - } - fs::remove_dir_all(quarantine)?; - Ok(None) -} - -fn copy_skill_bundle(bundle: &NormalizedSkillBundle, dest: &Path) -> io::Result<()> { - fs::create_dir_all(dest)?; - for file in bundle.by_path.values() { - let to = dest.join(PathBuf::from( - file.path.replace('/', std::path::MAIN_SEPARATOR_STR), - )); - if let Some(parent) = to.parent() { - fs::create_dir_all(parent)?; - } - fs::write(&to, &file.contents)?; - set_mode(&to, file.mode)?; - } - Ok(()) -} - -fn repair_skill_bundle_modes( - bundle: &NormalizedSkillBundle, - dest: &Path, - write: bool, -) -> io::Result { - let mut repaired = false; - for file in bundle.by_path.values() { - let to = dest.join(PathBuf::from( - file.path.replace('/', std::path::MAIN_SEPARATOR_STR), - )); - let metadata = match fs::symlink_metadata(&to) { - Ok(metadata) => metadata, - Err(error) if error.kind() == io::ErrorKind::NotFound => continue, - Err(error) => return Err(error), - }; - if metadata.is_file() && mode_bits(&metadata).is_some_and(|mode| mode != file.mode) { - repaired = true; - if write { - set_mode(&to, file.mode)?; - } - } - } - Ok(repaired) -} - -fn write_metadata( - target_dir: &Path, - app_id: &str, - skill_name: &str, - hash: &str, - bundle_metadata: &BundleMetadata, -) -> io::Result<()> { - let value = installed_metadata(app_id, skill_name, hash, bundle_metadata); - let data = serde_json::to_vec_pretty(&value)?; - let mut data = data; - data.push(b'\n'); - fs::write(target_dir.join(".kitup.json"), data) -} - -fn installed_metadata( - app_id: &str, - skill_name: &str, - hash: &str, - bundle_metadata: &BundleMetadata, -) -> InstalledMetadata { - InstalledMetadata { - schema_version: 1, - app_id: app_id.to_string(), - skill_name: skill_name.to_string(), - source: bundle_metadata.source.clone(), - hash: hash.to_string(), - source_id: bundle_metadata.source_id.clone(), - version: bundle_metadata.version.clone(), - cli_version: bundle_metadata.cli_version.clone(), - cli_revision: bundle_metadata.cli_revision.clone(), - provenance: bundle_metadata.provenance.clone(), - } -} - -fn read_metadata(target_dir: &Path) -> MetadataState { - if !target_dir.exists() { - return MetadataState::Missing; - } - let Ok(data) = fs::read(target_dir.join(".kitup.json")) else { - return MetadataState::Unmanaged; - }; - match serde_json::from_slice::(&data) { - Ok(mut meta) if is_owned_metadata(&meta) => { - if meta.source_id.as_deref() == Some("") { - meta.source_id = None; - } - if meta.version.as_deref() == Some("") { - meta.version = None; - } - if meta.cli_version.as_deref() == Some("") { - meta.cli_version = None; - } - if meta.cli_revision.as_deref() == Some("") { - meta.cli_revision = None; - } - MetadataState::Managed(Box::new(meta)) - } - _ => MetadataState::Unmanaged, - } -} - -fn is_owned_metadata(meta: &Metadata) -> bool { - meta.schema_version == 1 - && !meta.app_id.is_empty() - && valid_skill_name(&meta.skill_name) - && (meta.source == "bundled" || meta.source == "github") - && !meta.hash.is_empty() -} - -fn target_result(target: &TargetGroup) -> TargetResult { - if target.host_ids.len() == 1 { - TargetResult { - host_id: Some(target.host_ids[0].clone()), - host_ids: vec![], - skill_name: target.skill_name.clone(), - target_dir: target.target_dir.clone(), - } - } else { - TargetResult { - host_id: None, - host_ids: target.host_ids.clone(), - skill_name: target.skill_name.clone(), - target_dir: target.target_dir.clone(), - } - } -} - -fn with_reason(target: TargetResult, reason: &str) -> TargetStatus { - TargetStatus { - target, - reason: reason.to_string(), - } -} - -fn install_report(errors: Vec) -> InstallReport { - InstallReport { - installed: vec![], - updated: vec![], - skipped: vec![], - conflicts: vec![], - errors: report_errors(errors), - } -} - -fn resolve_workflow_scope( - input: &mut R, - output: &mut W, - options: &InstallWorkflowOptions, -) -> io::Result<(Option, Option)> { - let default_scope = options.default_scope.unwrap_or(Scope::User); - if options.scope_set || !options.prompt_scope { - return Ok((Some(options.install.scope), None)); - } - if options.yes { - return Ok((Some(default_scope), None)); - } - if !options.stdin_tty { - return Ok(( - None, - Some(error_selection( - vec![json!({ "reason": "scope-selection-required" })], - vec![], - )), - )); - } - Ok(( - Some(prompt_scope_selection(input, output, default_scope)?), - None, - )) -} - -fn prompt_scope_selection( - input: &mut R, - output: &mut W, - default_scope: Scope, -) -> io::Result { - loop { - writeln!(output, "{}", INSTALL_UX.select_scope)?; - writeln!(output, " 1. user")?; - writeln!(output, " 2. project")?; - write!( - output, - "{} [{}]: ", - INSTALL_UX.scope_prompt, - scope_text(default_scope) - )?; - output.flush()?; - let line = read_prompt_line(input)?; - if let Some(scope) = parse_scope_selection(&line, default_scope) { - return Ok(scope); - } - writeln!(output, "{}", INSTALL_UX.invalid_scope_selection)?; - } -} - -fn parse_scope_selection(line: &str, default_scope: Scope) -> Option { - match line.trim().to_ascii_lowercase().as_str() { - "" => Some(default_scope), - "1" | "u" | "user" => Some(Scope::User), - "2" | "p" | "project" => Some(Scope::Project), - _ => None, - } -} - -fn prompt_agent_selection( - input: &mut R, - output: &mut W, - selection: &InstallSelection, - hosts: &[Host], -) -> io::Result> { - let candidates = hosts_by_id(hosts, &selection.candidate_host_ids); - loop { - writeln!(output, "{}", INSTALL_UX.select_agents)?; - for (index, host) in candidates.iter().enumerate() { - writeln!( - output, - " {}. {} ({})", - index + 1, - host.display_name, - host.id - )?; - } - let suffix = if selection.selected_host_ids.is_empty() { - String::new() - } else { - format!(" [{}]", selection.selected_host_ids.join(",")) - }; - write!(output, "{}{}: ", INSTALL_UX.agents_prompt, suffix)?; - output.flush()?; - let line = read_prompt_line(input)?; - if let Some(selected) = parse_agent_selection(&line, selection, &candidates) { - return Ok(selected); - } - writeln!(output, "{}", INSTALL_UX.invalid_agent_selection)?; - } -} - -fn parse_agent_selection( - line: &str, - selection: &InstallSelection, - candidates: &[Host], -) -> Option> { - let line = line.trim(); - if line.is_empty() { - return Some(selection.selected_host_ids.clone()); - } - if line == "*" { - return Some(host_ids(candidates)); - } - let mut by_name = HashMap::new(); - for (index, host) in candidates.iter().enumerate() { - by_name.insert((index + 1).to_string(), host.id.clone()); - by_name.insert(host.id.clone(), host.id.clone()); - for alias in &host.aliases { - by_name.insert(alias.clone(), host.id.clone()); - } - } - let mut seen = HashSet::new(); - let mut selected = Vec::new(); - for part in line.split(|value: char| value == ',' || value.is_whitespace()) { - if part.is_empty() { - continue; - } - let id = by_name.get(part)?; - if seen.insert(id.clone()) { - selected.push(id.clone()); - } - } - Some(selected) -} - -fn prompt_confirmation(input: &mut R, output: &mut W) -> io::Result { - write!(output, "{}", INSTALL_UX.proceed)?; - output.flush()?; - let line = read_prompt_line(input)?; - let line = line.trim().to_ascii_lowercase(); - Ok(line == "y" || line == "yes") -} - -fn read_prompt_line(input: &mut R) -> io::Result { - let mut line = String::new(); - input.read_line(&mut line)?; - Ok(line.trim_end_matches(['\r', '\n']).to_string()) -} - -fn render_install_summary(output: &mut W, report: &InstallReport) -> io::Result<()> { - for item in report.installed.iter().chain(&report.updated) { - for host in summary_hosts(item) { - writeln!( - output, - " - {} -> {} ({})", - item.skill_name, - item.target_dir.display(), - host, - )?; - } - } - Ok(()) -} - -fn summary_hosts(item: &TargetResult) -> Vec { - if let Some(host) = &item.host_id { - return vec![host.clone()]; - } - item.host_ids.clone() -} - -fn render_selection_errors( - output: &mut W, - selection: &InstallSelection, -) -> io::Result<()> { - for error in &selection.errors { - writeln!( - output, - "{} {}", - INSTALL_UX.error_prefix, - error["reason"].as_str().unwrap_or("error") - )?; - } - Ok(()) -} - -fn hosts_by_id(hosts: &[Host], ids: &[String]) -> Vec { - let by_id: HashMap<_, _> = hosts.iter().map(|host| (host.id.as_str(), host)).collect(); - ids.iter() - .filter_map(|id| by_id.get(id.as_str()).map(|host| (*host).clone())) - .collect() -} - -fn uninstall_report(errors: Vec) -> UninstallReport { - UninstallReport { - removed: vec![], - skipped: vec![], - conflicts: vec![], - errors: report_errors(errors), - } -} - -fn status_report(errors: Vec) -> StatusReport { - StatusReport { - installed: vec![], - missing: vec![], - conflicts: vec![], - errors: report_errors(errors), - } -} - -fn report_errors(errors: Vec) -> Vec { - errors - .into_iter() - .map(|error| ReportError { - agent: string_value(&error, "agent"), - flag: string_value(&error, "flag"), - host_id: string_value(&error, "hostId"), - reason: string_value(&error, "reason").unwrap_or_default(), - scope: string_value(&error, "scope"), - skill_name: string_value(&error, "skillName"), - value: string_value(&error, "value"), - }) - .collect() -} - -fn string_value(value: &Value, key: &str) -> Option { - value.get(key).and_then(Value::as_str).map(String::from) -} - -fn canonical_scope_path(host: &Host, scope: Scope, home: &Path, cwd: &Path) -> Option { - let paths = scope_paths(host, scope); - paths.first().map(|path| expand_host_path(path, home, cwd)) -} - -fn choose_scope_path( - host: &Host, - scope: Scope, - home: &Path, - cwd: &Path, - skill_name: &str, -) -> Option { - let paths = scope_paths(host, scope); - let existing: Vec<_> = paths - .iter() - .map(|path| expand_host_path(path, home, cwd)) - .filter(|path| path.is_dir()) - .collect(); - for root in &existing { - if let MetadataState::Managed(metadata) = read_metadata(&root.join(skill_name)) { - if metadata.skill_name == skill_name { - return Some(root.clone()); - } - } - } - if let Some(root) = existing.into_iter().next() { - return Some(root); - } - paths.first().map(|path| expand_host_path(path, home, cwd)) -} - -fn uninstall_scope_paths( - host: &Host, - scope: Scope, - home: &Path, - cwd: &Path, - skill_name: &str, - app_id: &str, -) -> Vec { - let owned: Vec<_> = scope_paths(host, scope) - .iter() - .map(|path| expand_host_path(path, home, cwd)) - .filter(|root| match read_metadata(&root.join(skill_name)) { - MetadataState::Managed(metadata) => { - metadata.skill_name == skill_name && metadata.app_id == app_id - } - _ => false, - }) - .collect(); - if !owned.is_empty() { - return owned; - } - choose_scope_path(host, scope, home, cwd, skill_name) - .into_iter() - .collect() -} - -fn scope_paths(host: &Host, scope: Scope) -> &[String] { - match scope { - Scope::User => &host.user_skills_dirs, - Scope::Project => &host.project_skills_dirs, - } -} - -fn expand_host_path(path: &str, home: &Path, cwd: &Path) -> PathBuf { - if let Some(rest) = path.strip_prefix("~/") { - home.join(rest) - } else { - cwd.join(path) - } -} - -fn defaults(options: &BaseOptions) -> io::Result<(PathBuf, PathBuf)> { - let home = match &options.home { - Some(home) => home.clone(), - None => PathBuf::from( - std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_default(), - ), - }; - let cwd = match &options.cwd { - Some(cwd) => cwd.clone(), - None => std::env::current_dir()?, - }; - Ok((home, cwd)) -} - -fn parse_frontmatter(content: &str) -> HashMap { - let mut fields = HashMap::new(); - for line in content.lines() { - if let Some((key, value)) = line.split_once(':') { - fields.insert(key.to_string(), value.trim().to_string()); - } - } - fields -} - -fn invalid_frontmatter() -> SkillInfo { - SkillInfo { - valid: false, - skill_name: None, - description: None, - error_code: Some("invalid-frontmatter".to_string()), - } -} - -fn valid_skill_name(name: &str) -> bool { - let mut last_dash = false; - if name.is_empty() || name.starts_with('-') || name.ends_with('-') { - return false; - } - for byte in name.bytes() { - let ok = byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'; - if !ok || (byte == b'-' && last_dash) { - return false; - } - last_dash = byte == b'-'; - } - true -} - -fn resolve_skill_bundle( - bundle: &SkillBundle, -) -> io::Result<(NormalizedSkillBundle, BundleMetadata)> { - match bundle { - SkillBundle::GitHub(options) => resolve_github_bundle(options), - SkillBundle::Metadata(bundle, supplied) => { - let (bundle, mut metadata) = resolve_skill_bundle(bundle)?; - if let Some(source_id) = supplied.source_id.as_ref().filter(|value| !value.is_empty()) { - metadata.source_id = Some(source_id.clone()); - } - metadata.cli_version = supplied - .cli_version - .as_ref() - .filter(|value| !value.is_empty()) - .cloned(); - metadata.cli_revision = supplied - .cli_revision - .as_ref() - .filter(|value| !value.is_empty()) - .cloned(); - metadata.provenance.extend(supplied.provenance.clone()); - metadata.explicit = true; - Ok((bundle, metadata)) - } - _ => Ok(( - read_skill_bundle(bundle)?, - BundleMetadata { - source: "bundled".to_string(), - source_id: None, - version: None, - cli_version: None, - cli_revision: None, - provenance: BTreeMap::new(), - explicit: false, - }, - )), - } -} - -fn resolve_github_bundle( - options: &GitHubBundleOptions, -) -> io::Result<(NormalizedSkillBundle, BundleMetadata)> { - let root = trim_github_path(&options.path); - if options.owner.is_empty() - || options.repo.is_empty() - || root.is_empty() - || options.ref_name.is_empty() - { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "invalid github bundle", - )); - } - let api_base = env_base_url("KITUP_GITHUB_API_BASE_URL", "https://api.github.com"); - let raw_base = env_base_url( - "KITUP_GITHUB_RAW_BASE_URL", - "https://raw.githubusercontent.com", - ); - let commit: Value = get_json(&format!( - "{}/repos/{}/{}/commits/{}", - api_base, - escape_path_part(&options.owner), - escape_path_part(&options.repo), - escape_path_part(&options.ref_name) - ))?; - let resolved_commit = commit["sha"].as_str().unwrap_or("").to_string(); - let tree_sha = commit["commit"]["tree"]["sha"] - .as_str() - .unwrap_or("") - .to_string(); - if resolved_commit.is_empty() || tree_sha.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "invalid github commit", - )); - } - let tree: Value = get_json(&format!( - "{}/repos/{}/{}/git/trees/{}?recursive=1", - api_base, - escape_path_part(&options.owner), - escape_path_part(&options.repo), - escape_path_part(&tree_sha) - ))?; - let prefix = format!("{root}/"); - let mut files = Vec::new(); - for item in tree["tree"].as_array().into_iter().flatten() { - let path = item["path"].as_str().unwrap_or(""); - if item["type"].as_str() != Some("blob") || !path.starts_with(&prefix) { - continue; - } - let contents = get_bytes(&format!( - "{}/{}/{}/{}/{}", - raw_base, - escape_path_part(&options.owner), - escape_path_part(&options.repo), - escape_path_part(&resolved_commit), - escape_path(path) - ))?; - let mode = if item["mode"].as_str() == Some("100755") { - Some(0o755) - } else { - Some(0o644) - }; - files.push(SkillFile { - path: path.strip_prefix(&prefix).unwrap().to_string(), - contents, - mode, - }); - } - if files.is_empty() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - "github bundle path not found", - )); - } - let mut provenance = BTreeMap::new(); - provenance.insert("owner".to_string(), options.owner.clone()); - provenance.insert("repo".to_string(), options.repo.clone()); - provenance.insert("path".to_string(), root.clone()); - provenance.insert("ref".to_string(), options.ref_name.clone()); - provenance.insert("resolvedCommit".to_string(), resolved_commit); - Ok(( - normalize_skill_files(files)?, - BundleMetadata { - source: "github".to_string(), - source_id: Some(format!( - "github:{}/{}/{}", - options.owner, options.repo, root - )), - version: Some(options.ref_name.clone()), - cli_version: None, - cli_revision: None, - provenance, - explicit: false, - }, - )) -} - -fn env_base_url(name: &str, fallback: &str) -> String { - std::env::var(name) - .unwrap_or_else(|_| fallback.to_string()) - .trim_end_matches('/') - .to_string() -} - -fn get_json(url: &str) -> io::Result { - serde_json::from_slice(&get_bytes(url)?).map_err(io::Error::other) -} - -fn get_bytes(url: &str) -> io::Result> { - let agent = ureq::AgentBuilder::new() - .timeout(Duration::from_secs(30)) - .build(); - let response = agent - .get(url) - .set("User-Agent", "kitup") - .call() - .map_err(io::Error::other)?; - if response.status() < 200 || response.status() > 299 { - return Err(io::Error::other(format!("github request failed: {url}"))); - } - let mut reader = response.into_reader(); - let mut data = Vec::new(); - std::io::Read::read_to_end(&mut reader, &mut data)?; - Ok(data) -} - -fn trim_github_path(value: &str) -> String { - value.trim_matches('/').to_string() -} - -fn escape_path(value: &str) -> String { - value - .split('/') - .map(escape_path_part) - .collect::>() - .join("/") -} - -fn escape_path_part(value: &str) -> String { - let mut out = String::new(); - for byte in value.bytes() { - if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { - out.push(byte as char); - } else { - out.push_str(&format!("%{byte:02X}")); - } - } - out -} - -fn read_skill_bundle(bundle: &SkillBundle) -> io::Result { - match bundle { - SkillBundle::Directory(root) => normalize_skill_files(read_directory_bundle_files(root)?), - SkillBundle::Files(files) => normalize_skill_files(files.clone()), - SkillBundle::GitHub(options) => { - let (bundle, _) = resolve_github_bundle(options)?; - Ok(bundle) - } - SkillBundle::Metadata(bundle, _) => read_skill_bundle(bundle), - } -} - -fn is_github_bundle(bundle: &SkillBundle) -> bool { - match bundle { - SkillBundle::GitHub(_) => true, - SkillBundle::Metadata(bundle, _) => is_github_bundle(bundle), - _ => false, - } -} - -fn read_directory_bundle_files(root: &Path) -> io::Result> { - let mut files = Vec::new(); - collect_directory_bundle_files(root, root, &mut files)?; - Ok(files) -} - -fn collect_directory_bundle_files( - root: &Path, - dir: &Path, - files: &mut Vec, -) -> io::Result<()> { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let name = entry.file_name(); - let name = name.to_string_lossy(); - if skip_name(&name) { - continue; - } - let path = entry.path(); - let metadata = entry.metadata()?; - if metadata.is_dir() { - collect_directory_bundle_files(root, &path, files)?; - } else if metadata.is_file() { - let rel = path.strip_prefix(root).unwrap(); - files.push(SkillFile { - path: rel.to_string_lossy().replace('\\', "/"), - contents: fs::read(&path)?, - mode: mode_bits(&metadata), - }); - } - } - Ok(()) -} - -fn normalize_skill_files(files: Vec) -> io::Result { - let mut by_path = BTreeMap::new(); - for file in files { - let Some(path) = normalize_bundle_path(&file.path)? else { - continue; - }; - if by_path.contains_key(&path) { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("duplicate skill file: {path}"), - )); - } - let mode = file.mode.unwrap_or_else(|| default_bundle_file_mode(&path)); - by_path.insert( - path.clone(), - BundleFile { - path, - contents: file.contents, - mode, - }, - ); - } - Ok(NormalizedSkillBundle { by_path }) -} - -fn default_bundle_file_mode(path: &str) -> u32 { - if path.starts_with("scripts/") { - 0o755 - } else { - 0o644 - } -} - -fn normalize_bundle_path(value: &str) -> io::Result> { - if value.is_empty() - || value.contains('\\') - || value.starts_with('/') - || value.as_bytes().get(1) == Some(&b':') - { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("invalid skill file path: {value}"), - )); - } - for part in value.split('/') { - if part.is_empty() || part == "." || part == ".." { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("invalid skill file path: {value}"), - )); - } - if skip_name(part) { - return Ok(None); - } - } - Ok(Some(value.to_string())) -} - -#[cfg(unix)] -fn mode_bits(metadata: &fs::Metadata) -> Option { - use std::os::unix::fs::PermissionsExt; - Some(metadata.permissions().mode() & 0o777) -} - -#[cfg(not(unix))] -fn mode_bits(_metadata: &fs::Metadata) -> Option { - None -} - -#[cfg(unix)] -fn set_mode(path: &Path, mode: u32) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(mode)) -} - -#[cfg(not(unix))] -fn set_mode(_path: &Path, _mode: u32) -> io::Result<()> { - Ok(()) -} - -fn skip_name(name: &str) -> bool { - name == ".git" - || name == ".kitup.json" - || name == ".DS_Store" - || name.ends_with(".swp") - || name.ends_with('~') -} - -fn is_generic_detect_path(path: &str) -> bool { - matches!( - path, - "~/.agents" - | "~/.agents/skills" - | "~/.config/agents" - | ".agents" - | ".agents/skills" - | "package.json" - ) -} - -fn scope_text(scope: Scope) -> &'static str { - match scope { - Scope::User => "user", - Scope::Project => "project", - } -} +pub use bundle::include_dir_bundle; diff --git a/rust/src/metadata.rs b/rust/src/metadata.rs new file mode 100644 index 0000000..17cacbb --- /dev/null +++ b/rust/src/metadata.rs @@ -0,0 +1,95 @@ +use crate::bundle::{valid_skill_name, BundleMetadata}; +use crate::types::InstalledMetadata; +use std::fs; +use std::io; +use std::path::Path; + +pub fn read_installed_metadata(target_dir: &Path) -> io::Result> { + match read_metadata(target_dir) { + MetadataState::Missing => Ok(None), + MetadataState::Conflict(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "unmanaged install metadata", + )), + MetadataState::Managed(metadata) => Ok(Some(*metadata)), + } +} + +pub(crate) enum MetadataState { + Missing, + Conflict(&'static str), + Managed(Box), +} + +impl MetadataState { + pub(crate) fn for_owner(self, app_id: &str, skill_name: &str) -> Self { + match self { + Self::Managed(metadata) if metadata.skill_name != skill_name => { + Self::Conflict("unmanaged") + } + Self::Managed(metadata) if metadata.app_id != app_id => { + Self::Conflict("owner-mismatch") + } + state => state, + } + } +} + +pub(crate) fn write_metadata(target_dir: &Path, metadata: &InstalledMetadata) -> io::Result<()> { + let mut data = serde_json::to_vec_pretty(metadata)?; + data.push(b'\n'); + fs::write(target_dir.join(".kitup.json"), data) +} + +pub(crate) fn installed_metadata( + app_id: &str, + skill_name: &str, + hash: &str, + bundle_metadata: &BundleMetadata, +) -> InstalledMetadata { + InstalledMetadata { + schema_version: 1, + app_id: app_id.to_string(), + skill_name: skill_name.to_string(), + source: bundle_metadata.source.clone(), + hash: hash.to_string(), + source_id: bundle_metadata.source_id.clone(), + version: bundle_metadata.version.clone(), + cli_version: bundle_metadata.cli_version.clone(), + cli_revision: bundle_metadata.cli_revision.clone(), + provenance: bundle_metadata.provenance.clone(), + } +} + +pub(crate) fn read_metadata(target_dir: &Path) -> MetadataState { + if !target_dir.exists() { + return MetadataState::Missing; + } + let Ok(data) = fs::read(target_dir.join(".kitup.json")) else { + return MetadataState::Conflict("unmanaged"); + }; + match serde_json::from_slice::(&data) { + Ok(mut meta) if is_owned_metadata(&meta) => { + for field in [ + &mut meta.source_id, + &mut meta.version, + &mut meta.cli_version, + &mut meta.cli_revision, + ] { + if field.as_deref() == Some("") { + *field = None; + } + } + MetadataState::Managed(Box::new(meta)) + } + _ => MetadataState::Conflict("unmanaged"), + } +} + +fn is_owned_metadata(meta: &InstalledMetadata) -> bool { + meta.schema_version == 1 + && !meta.app_id.is_empty() + && valid_skill_name(&meta.skill_name) + && (meta.source == "bundled" || meta.source == "github") + && !meta.hash.is_empty() +} diff --git a/rust/src/types.rs b/rust/src/types.rs new file mode 100644 index 0000000..a2e80d5 --- /dev/null +++ b/rust/src/types.rs @@ -0,0 +1,279 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Scope { + User, + Project, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AgentSelector { + Auto, + All, + Explicit(Vec), +} + +#[derive(Clone, Debug, Default)] +pub struct InstallFlagValues { + pub scope: Option, + pub scope_set: bool, + pub agents: Vec, + pub yes: bool, + pub dry_run: bool, + pub force: bool, +} + +#[derive(Clone, Debug)] +pub struct ParsedInstallFlags { + pub scope: Scope, + pub scope_set: bool, + pub agents: AgentSelector, + pub yes: bool, + pub dry_run: bool, + pub force: bool, + pub errors: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct BaseOptions { + pub home: Option, + pub cwd: Option, + pub hosts_file: Option, +} + +#[derive(Clone, Debug)] +pub struct InstallOptions { + pub base: BaseOptions, + pub app_id: String, + pub skill_bundle: SkillBundle, + pub scope: Scope, + pub agents: AgentSelector, + pub force: bool, +} + +#[derive(Clone, Debug)] +pub struct UninstallOptions { + pub base: BaseOptions, + pub app_id: String, + pub skill_name: String, + pub scope: Scope, + pub agents: AgentSelector, +} + +#[derive(Clone, Debug)] +pub struct StatusOptions { + pub base: BaseOptions, + pub app_id: String, + pub skill_name: String, + pub scope: Scope, + pub agents: AgentSelector, +} + +#[derive(Clone, Debug, Default)] +pub struct InstallSelectionOptions { + pub base: BaseOptions, + pub scope: Option, + pub agents: Option, + pub yes: bool, + pub stdin_tty: bool, + pub current_agent: Option, +} + +#[derive(Clone, Debug)] +pub struct InstallWorkflowOptions { + pub install: InstallOptions, + pub yes: bool, + pub dry_run: bool, + pub stdin_tty: bool, + pub current_agent: Option, + pub default_scope: Option, + pub scope_set: bool, + pub prompt_scope: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Host { + pub id: String, + pub display_name: String, + #[serde(default)] + pub aliases: Vec, + pub project_skills_dirs: Vec, + pub user_skills_dirs: Vec, + pub detect: Vec, + pub status: String, + #[serde(default)] + pub notes: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct SkillInfo { + pub valid: bool, + pub skill_name: Option, + pub description: Option, + pub error_code: Option, +} + +#[derive(Clone, Debug)] +pub struct SkillFile { + pub path: String, + pub contents: Vec, + pub mode: Option, +} + +#[derive(Clone, Debug)] +pub enum SkillBundle { + Directory(PathBuf), + Files(Vec), + GitHub(GitHubBundleOptions), + Metadata(Box, BundledSkillMetadata), +} + +#[derive(Clone, Debug, Default)] +pub struct BundledSkillMetadata { + pub source_id: Option, + pub cli_version: Option, + pub cli_revision: Option, + pub provenance: BTreeMap, +} + +#[derive(Clone, Debug)] +pub struct GitHubBundleOptions { + pub owner: String, + pub repo: String, + pub path: String, + pub ref_name: String, +} + +#[derive(Clone, Debug)] +pub struct TargetGroup { + pub host_ids: Vec, + pub skill_name: String, + pub target_dir: PathBuf, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TargetResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub host_id: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub host_ids: Vec, + pub skill_name: String, + pub target_dir: PathBuf, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TargetStatus { + #[serde(flatten)] + pub target: TargetResult, + pub reason: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportError { + #[serde(skip_serializing_if = "Option::is_none")] + pub agent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub flag: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub host_id: Option, + pub reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub skill_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +#[derive(Clone, Debug, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct InstallReport { + pub installed: Vec, + pub updated: Vec, + pub skipped: Vec, + pub conflicts: Vec, + pub errors: Vec, +} + +#[derive(Clone, Debug, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct UninstallReport { + pub removed: Vec, + pub skipped: Vec, + pub conflicts: Vec, + pub errors: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledMetadata { + pub schema_version: u32, + pub app_id: String, + pub skill_name: String, + pub source: String, + pub hash: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cli_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cli_revision: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub provenance: BTreeMap, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledTarget { + #[serde(flatten)] + pub target: TargetResult, + pub metadata: InstalledMetadata, +} + +#[derive(Clone, Debug, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct StatusReport { + pub installed: Vec, + pub missing: Vec, + pub conflicts: Vec, + pub errors: Vec, +} + +#[derive(Clone, Debug, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct InstallSelection { + pub action: String, + pub selected_host_ids: Vec, + pub candidate_host_ids: Vec, + pub detected_host_ids: Vec, + pub needs_confirmation: bool, + pub errors: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallWorkflowReport { + pub selection: InstallSelection, + pub scope: String, + pub plan: InstallReport, + pub report: InstallReport, + pub canceled: bool, + pub dry_run: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallWorkflowExit { + pub ok: bool, + pub code: String, + pub message: String, +} diff --git a/rust/src/workflow.rs b/rust/src/workflow.rs new file mode 100644 index 0000000..2ce1074 --- /dev/null +++ b/rust/src/workflow.rs @@ -0,0 +1,423 @@ +use crate::hosts::{ + error_selection, host_ids, hosts_by_id, install_selection, load_host_spec, + resolve_install_selection, scope_text, +}; +use crate::install::{install_bundled_skill, plan_bundled_skill}; +use crate::types::*; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::io::{self, BufRead, Write}; + +pub struct InstallUxText { + pub skill_use: &'static str, + pub skill_short: &'static str, + pub install_use: &'static str, + pub install_short: &'static str, + pub scope_flag: &'static str, + pub agent_flag: &'static str, + pub dry_run_flag: &'static str, + pub yes_flag: &'static str, + pub force_flag: &'static str, + pub select_scope: &'static str, + pub scope_prompt: &'static str, + pub invalid_scope_selection: &'static str, + pub select_agents: &'static str, + pub agents_prompt: &'static str, + pub invalid_agent_selection: &'static str, + pub proceed: &'static str, + pub install_summary: &'static str, + pub error_prefix: &'static str, + pub canceled: &'static str, + pub selection_error: &'static str, + pub conflict: &'static str, + pub failed: &'static str, + pub invalid_flags: &'static str, +} + +pub const INSTALL_UX: InstallUxText = InstallUxText { + skill_use: "skill", + skill_short: "Manage bundled Agent Skill", + install_use: "install", + install_short: "Install bundled Agent Skill", + scope_flag: "Install scope: user or project", + agent_flag: "Target agent id. Repeat for multiple agents. Use '*' for all.", + dry_run_flag: "Show install plan without writing", + yes_flag: "Skip prompts and accept policy-selected targets", + force_flag: "Overwrite unsafe target conflicts", + select_scope: "Select install scope:", + scope_prompt: "Scope (user/project)", + invalid_scope_selection: "Invalid scope selection.", + select_agents: "Select agents:", + agents_prompt: "Agents (numbers, ids, comma-separated, empty cancels)", + invalid_agent_selection: "Invalid agent selection.", + proceed: "Proceed? [y/N] ", + install_summary: "Install summary:", + error_prefix: "kitup:", + canceled: "Installation canceled.", + selection_error: "Agent selection failed.", + conflict: "Installation has conflicts.", + failed: "Installation failed.", + invalid_flags: "Invalid install flags.", +}; + +pub fn parse_install_flags(flags: InstallFlagValues) -> ParsedInstallFlags { + let mut errors = Vec::new(); + let scope = parse_scope_flag(flags.scope.as_deref(), &mut errors); + let agents = agent_selector_from_flags(&flags.agents, &mut errors); + ParsedInstallFlags { + scope, + scope_set: flags.scope_set || flags.scope.is_some(), + agents, + yes: flags.yes, + dry_run: flags.dry_run, + force: flags.force, + errors, + } +} + +pub fn agent_selector_from_flags(values: &[String], errors: &mut Vec) -> AgentSelector { + let agents = split_flag_values(values); + if agents.is_empty() { + return AgentSelector::Auto; + } + if agents.iter().any(|agent| agent == "*") { + if agents.len() > 1 { + errors.push(json!({ + "flag": "agent", + "reason": "agent-star-must-be-alone", + "value": agents.join(",") + })); + } + return AgentSelector::All; + } + let mut seen = HashSet::new(); + AgentSelector::Explicit( + agents + .into_iter() + .filter(|agent| seen.insert(agent.clone())) + .collect(), + ) +} + +pub fn parse_scope_flag(value: Option<&str>, errors: &mut Vec) -> Scope { + match value.unwrap_or("user") { + "" | "user" => Scope::User, + "project" => Scope::Project, + value => { + errors.push(json!({ "flag": "scope", "reason": "invalid-scope", "value": value })); + Scope::User + } + } +} + +fn split_flag_values(values: &[String]) -> Vec { + values + .iter() + .flat_map(|value| value.split(|ch: char| ch == ',' || ch.is_whitespace())) + .filter(|value| !value.is_empty()) + .map(String::from) + .collect() +} + +pub fn classify_install_workflow_exit(report: &InstallWorkflowReport) -> InstallWorkflowExit { + let (code, message) = [ + (report.canceled, "canceled", INSTALL_UX.canceled), + ( + !report.selection.errors.is_empty(), + "selection-error", + INSTALL_UX.selection_error, + ), + ( + !report.report.conflicts.is_empty(), + "conflict", + INSTALL_UX.conflict, + ), + (!report.report.errors.is_empty(), "error", INSTALL_UX.failed), + ] + .into_iter() + .find_map(|(failed, code, message)| failed.then_some((code, message))) + .unwrap_or(("ok", "")); + InstallWorkflowExit { + ok: code == "ok", + code: code.into(), + message: message.into(), + } +} + +pub fn install_workflow_error(report: &InstallWorkflowReport) -> io::Result<()> { + let exit = classify_install_workflow_exit(report); + if exit.ok || exit.code == "canceled" { + Ok(()) + } else { + Err(io::Error::other(exit.message)) + } +} + +pub fn install_flag_error(errors: &[Value]) -> io::Result<()> { + if errors.is_empty() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + INSTALL_UX.invalid_flags, + )) + } +} + +pub fn run_bundled_skill_install( + options: &InstallWorkflowOptions, +) -> io::Result { + run_bundled_skill_install_with_io(options, &mut io::stdin().lock(), &mut io::stdout().lock()) +} + +pub fn run_bundled_skill_install_with_io( + options: &InstallWorkflowOptions, + input: &mut R, + output: &mut W, +) -> io::Result { + let (scope, scope_error) = resolve_workflow_scope(input, output, options)?; + let selection = match scope_error { + Some(error) => error, + None => resolve_install_selection(&InstallSelectionOptions { + base: options.install.base.clone(), + scope, + agents: Some(options.install.agents.clone()), + yes: options.yes, + stdin_tty: options.stdin_tty, + current_agent: options.current_agent.clone(), + })?, + }; + let mut workflow = InstallWorkflowReport { + selection, + scope: scope.map(scope_text).unwrap_or_default().into(), + plan: InstallReport::default(), + report: InstallReport::default(), + canceled: false, + dry_run: options.dry_run, + }; + if workflow.selection.action == "error" { + render_selection_errors(output, &workflow.selection)?; + return Ok(workflow); + } + if workflow.selection.action == "select-agents" { + let hosts = load_host_spec(options.install.base.hosts_file.as_deref())?; + let selected = prompt_agent_selection(input, output, &workflow.selection, &hosts)?; + workflow.selection = install_selection( + selected, + workflow.selection.detected_host_ids, + !options.yes && options.stdin_tty, + vec![], + ); + if workflow.selection.selected_host_ids.is_empty() { + workflow.canceled = true; + return Ok(workflow); + } + } + let mut install = options.install.clone(); + install.agents = AgentSelector::Explicit(workflow.selection.selected_host_ids.clone()); + install.scope = scope.unwrap(); + workflow.plan = plan_bundled_skill(&install)?; + workflow.report = workflow.plan.clone(); + let plan = &workflow.plan; + if plan.installed.is_empty() + && plan.updated.is_empty() + && plan.conflicts.is_empty() + && plan.errors.is_empty() + { + return Ok(workflow); + } + if options.dry_run { + render_install_summary(output, plan)?; + } else if !plan.conflicts.is_empty() || !plan.errors.is_empty() { + workflow.report.installed.clear(); + workflow.report.updated.clear(); + } else { + render_install_summary(output, plan)?; + workflow.canceled = + workflow.selection.needs_confirmation && !prompt_confirmation(input, output)?; + workflow.report = if workflow.canceled { + InstallReport::default() + } else { + install_bundled_skill(&install)? + }; + } + Ok(workflow) +} + +fn resolve_workflow_scope( + input: &mut R, + output: &mut W, + options: &InstallWorkflowOptions, +) -> io::Result<(Option, Option)> { + let default_scope = options.default_scope.unwrap_or(Scope::User); + if options.scope_set || !options.prompt_scope { + return Ok((Some(options.install.scope), None)); + } + if options.yes { + return Ok((Some(default_scope), None)); + } + if !options.stdin_tty { + return Ok(( + None, + Some(error_selection( + vec![json!({ "reason": "scope-selection-required" })], + vec![], + )), + )); + } + Ok(( + Some(prompt_scope_selection(input, output, default_scope)?), + None, + )) +} + +fn prompt_scope_selection( + input: &mut R, + output: &mut W, + default_scope: Scope, +) -> io::Result { + loop { + writeln!(output, "{}", INSTALL_UX.select_scope)?; + writeln!(output, " 1. user")?; + writeln!(output, " 2. project")?; + write!( + output, + "{} [{}]: ", + INSTALL_UX.scope_prompt, + scope_text(default_scope) + )?; + output.flush()?; + let line = read_prompt_line(input)?; + if let Some(scope) = parse_scope_selection(&line, default_scope) { + return Ok(scope); + } + writeln!(output, "{}", INSTALL_UX.invalid_scope_selection)?; + } +} + +fn parse_scope_selection(line: &str, default_scope: Scope) -> Option { + match line.trim().to_ascii_lowercase().as_str() { + "" => Some(default_scope), + "1" | "u" | "user" => Some(Scope::User), + "2" | "p" | "project" => Some(Scope::Project), + _ => None, + } +} + +fn prompt_agent_selection( + input: &mut R, + output: &mut W, + selection: &InstallSelection, + hosts: &[Host], +) -> io::Result> { + let candidates = hosts_by_id(hosts, &selection.candidate_host_ids); + loop { + writeln!(output, "{}", INSTALL_UX.select_agents)?; + for (index, host) in candidates.iter().enumerate() { + writeln!( + output, + " {}. {} ({})", + index + 1, + host.display_name, + host.id + )?; + } + let suffix = if selection.selected_host_ids.is_empty() { + String::new() + } else { + format!(" [{}]", selection.selected_host_ids.join(",")) + }; + write!(output, "{}{}: ", INSTALL_UX.agents_prompt, suffix)?; + output.flush()?; + let line = read_prompt_line(input)?; + if let Some(selected) = parse_agent_selection(&line, selection, &candidates) { + return Ok(selected); + } + writeln!(output, "{}", INSTALL_UX.invalid_agent_selection)?; + } +} + +fn parse_agent_selection( + line: &str, + selection: &InstallSelection, + candidates: &[Host], +) -> Option> { + let line = line.trim(); + if line.is_empty() { + return Some(selection.selected_host_ids.clone()); + } + if line == "*" { + return Some(host_ids(candidates)); + } + let mut by_name = HashMap::new(); + for (index, host) in candidates.iter().enumerate() { + by_name.insert((index + 1).to_string(), host.id.clone()); + by_name.insert(host.id.clone(), host.id.clone()); + for alias in &host.aliases { + by_name.insert(alias.clone(), host.id.clone()); + } + } + let mut seen = HashSet::new(); + let mut selected = Vec::new(); + for part in line.split(|value: char| value == ',' || value.is_whitespace()) { + if part.is_empty() { + continue; + } + let id = by_name.get(part)?; + if seen.insert(id.clone()) { + selected.push(id.clone()); + } + } + Some(selected) +} + +fn prompt_confirmation(input: &mut R, output: &mut W) -> io::Result { + write!(output, "{}", INSTALL_UX.proceed)?; + output.flush()?; + let line = read_prompt_line(input)?; + let line = line.trim().to_ascii_lowercase(); + Ok(line == "y" || line == "yes") +} + +fn read_prompt_line(input: &mut R) -> io::Result { + let mut line = String::new(); + input.read_line(&mut line)?; + Ok(line.trim_end_matches(['\r', '\n']).to_string()) +} + +fn render_install_summary(output: &mut W, report: &InstallReport) -> io::Result<()> { + for item in report.installed.iter().chain(&report.updated) { + for host in summary_hosts(item) { + writeln!( + output, + " - {} -> {} ({})", + item.skill_name, + item.target_dir.display(), + host, + )?; + } + } + Ok(()) +} + +fn summary_hosts(item: &TargetResult) -> Vec { + if let Some(host) = &item.host_id { + return vec![host.clone()]; + } + item.host_ids.clone() +} + +fn render_selection_errors( + output: &mut W, + selection: &InstallSelection, +) -> io::Result<()> { + for error in &selection.errors { + writeln!( + output, + "{} {}", + INSTALL_UX.error_prefix, + error["reason"].as_str().unwrap_or("error") + )?; + } + Ok(()) +} diff --git a/rust/tests/golden.rs b/rust/tests/golden.rs index 65f2657..50c9eed 100644 --- a/rust/tests/golden.rs +++ b/rust/tests/golden.rs @@ -1,695 +1,229 @@ -use kitup::{ - classify_install_workflow_exit, compute_bundle_content_hash, detect_hosts, directory_bundle, - files_bundle, github_bundle, install_bundled_skill, load_host_spec, parse_install_flags, - plan_bundled_skill, resolve_hosts, resolve_install_selection, resolve_install_targets, - run_bundled_skill_install_with_io, status_bundled_skill, uninstall_bundled_skill, - update_bundled_skill, validate_skill_bundle, with_bundle_metadata, AgentSelector, BaseOptions, - BundledSkillMetadata, GitHubBundleOptions, InstallFlagValues, InstallOptions, - InstallSelectionOptions, InstallWorkflowOptions, ParsedInstallFlags, Scope, SkillBundle, - SkillFile, StatusOptions, UninstallOptions, -}; -use serde::Deserialize; -use serde_json::{json, Map, Value}; +use kitup::*; +use serde_json::{json, Value}; use std::fs; -use std::io::{Cursor, Read}; -use std::net::{TcpListener, TcpStream}; -use std::path::{Path, PathBuf}; -use std::thread; -use std::time::{SystemTime, UNIX_EPOCH}; - -#[derive(Deserialize)] -struct GoldenFile { - cases: Vec, -} - -#[derive(Clone, Deserialize)] -struct GoldenCase { - id: String, - operation: String, - options: Map, - given: Map, - expected: Map, -} +use std::io::{self, Cursor}; +use std::path::PathBuf; +use std::process::Command; #[test] fn golden_cases() { - let file: GoldenFile = read_json(&repo_path("testdata/cases/bundled-skill-install.json")); - for case in file.cases { - let root = temp_dir(&case.id); - let home = root.join("home"); - let workspace = root.join("workspace"); - fs::create_dir_all(&home).unwrap(); - fs::create_dir_all(&workspace).unwrap(); - setup_given(&case, &home, &workspace); - run_case(&case, &home, &workspace); - let _ = fs::remove_dir_all(root); - } -} - -#[cfg(feature = "include-dir")] -#[test] -fn include_dir_bundle_installs_embedded_tree() { - static SKILLS: include_dir::Dir<'_> = - include_dir::include_dir!("$CARGO_MANIFEST_DIR/../testdata/skills"); - - let root = temp_dir("include-dir-bundle"); - let home = root.join("home"); - - let report = install_bundled_skill(&InstallOptions { - base: BaseOptions { - home: Some(home.clone()), - hosts_file: Some(repo_path("spec/hosts.json")), - ..BaseOptions::default() - }, - app_id: "example-cli".to_string(), - skill_bundle: kitup::include_dir_bundle(SKILLS.get_dir("basic").unwrap()), - scope: Scope::User, - agents: AgentSelector::Explicit(vec!["codex".to_string()]), - force: false, - }) + let Ok(input) = std::env::var("KITUP_GOLDEN_INPUT") else { + let root = std::env::var_os("KITUP_TEST_REPO_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")); + assert!(Command::new("node") + .arg(root.join("scripts/golden.mjs")) + .arg("--") + .arg(std::env::current_exe().unwrap()) + .args(["--exact", "golden_cases", "--nocapture"]) + .status() + .unwrap() + .success()); + return; + }; + let requests: Vec = serde_json::from_slice(&fs::read(input).unwrap()).unwrap(); + let results: Vec<_> = requests + .iter() + .map(|request| { + for name in ["KITUP_GITHUB_API_BASE_URL", "KITUP_GITHUB_RAW_BASE_URL"] { + if let Some(value) = request["env"][name].as_str() { + std::env::set_var(name, value); + } else { + std::env::remove_var(name); + } + } + let mut result = dispatch(request) + .unwrap_or_else(|error| json!({"threw": true, "error": error.to_string()})); + result["id"] = request["id"].clone(); + result + }) + .collect(); + fs::write( + std::env::var("KITUP_GOLDEN_OUTPUT").unwrap(), + serde_json::to_vec(&results).unwrap(), + ) .unwrap(); - - let target = home.join(".agents/skills/basic"); - assert_eq!(report.installed.len(), 1); - for file in ["SKILL.md", "references/guide.md", "assets/template.json"] { - assert!(target.join(file).is_file()); - } - #[cfg(unix)] - assert_eq!( - mode_string( - &fs::metadata(target.join("scripts/helper.sh")) - .unwrap() - .permissions() - ), - "755" - ); - let _ = fs::remove_dir_all(root); } -fn run_case(case: &GoldenCase, home: &Path, workspace: &Path) { - let options = expand_value(&Value::Object(case.options.clone()), home, workspace); - let options = options.as_object().unwrap(); - match case.operation.as_str() { +fn dispatch(request: &Value) -> io::Result { + let options = &request["options"]; + let base = BaseOptions { + home: optional_text(options, "home").map(PathBuf::from), + cwd: optional_text(options, "cwd").map(PathBuf::from), + hosts_file: optional_text(options, "hostsFile").map(PathBuf::from), + }; + let operation = request["operation"].as_str().unwrap(); + let scope = if operation == "parse-install-flags" { + Scope::User + } else { + scope(options["scope"].as_str().unwrap_or("user")) + }; + let agents = options + .get("agents") + .map(agent_selector) + .unwrap_or(AgentSelector::Auto); + let detected = if flag(request, "detect") { + Some(json!(host_ids(&detect_hosts(&base, Some(scope))?))) + } else { + None + }; + let mut result = match operation { "resolve-hosts" => { - let hosts_file = repo_path(case.given["hostsFile"].as_str().unwrap()); - let hosts = load_host_spec(Some(&hosts_file)).unwrap(); - let (hosts, errors) = resolve_hosts(&agent_selector(&options["agents"]), &hosts); - if let Some(count) = case.expected.get("count") { - assert_eq!(hosts.len(), count.as_u64().unwrap() as usize); - } - if let Some(expected) = case.expected.get("hostIds") { - assert_json_eq(&json!(host_ids(&hosts)), expected.clone()); - } - if let Some(expected) = case.expected.get("resolvedHostIds") { - assert_json_eq(&json!(host_ids(&hosts)), expected.clone()); - } - if let Some(expected) = case.expected.get("errors") { - assert_json_eq(&json!(errors), expected.clone()); - } + let hosts_file = PathBuf::from(request["given"]["hostsFile"].as_str().unwrap()); + let (hosts, errors) = resolve_hosts(&agents, &load_host_spec(Some(&hosts_file))?); + json!({ + "count": hosts.len(), "hostIds": host_ids(&hosts), + "resolvedHostIds": host_ids(&hosts), "errors": errors, + }) } "validate" => { - let result = validate_skill_bundle(&skill_bundle_from_options(options)); - assert_eq!(result.valid, case.expected["valid"].as_bool().unwrap()); - assert_eq!( - result.error_code.as_deref(), - case.expected.get("errorCode").and_then(Value::as_str) - ); + let skill = validate_skill_bundle(&skill_bundle(options)); + json!({"valid": skill.valid, "errorCode": skill.error_code}) } "parse-install-flags" => { let parsed = parse_install_flags(InstallFlagValues { - scope: options - .get("scope") - .and_then(Value::as_str) - .map(String::from), - scope_set: options - .get("scopeSet") - .and_then(Value::as_bool) - .unwrap_or_else(|| options.get("scope").is_some()), - agents: options - .get("agents") - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .map(|item| item.as_str().unwrap().to_string()) - .collect() - }) - .unwrap_or_default(), - yes: options.get("yes").and_then(Value::as_bool).unwrap_or(false), - dry_run: options - .get("dryRun") - .and_then(Value::as_bool) - .unwrap_or(false), - force: options - .get("force") - .and_then(Value::as_bool) - .unwrap_or(false), + scope: optional_text(options, "scope"), + scope_set: scope_set(options), + agents: options["agents"] + .as_array() + .into_iter() + .flatten() + .map(|value| value.as_str().unwrap().to_string()) + .collect(), + yes: flag(options, "yes"), + dry_run: flag(options, "dryRun"), + force: flag(options, "force"), }); - assert_json_eq( - &normalized_parsed_flags(&parsed), - case.expected["parsed"].clone(), - ); + let (kind, ids) = match parsed.agents { + AgentSelector::Auto => ("auto", vec![]), + AgentSelector::All => ("*", vec![]), + AgentSelector::Explicit(ids) => ("explicit", ids), + }; + json!({"parsed": { + "scope": if parsed.scope == Scope::User { "user" } else { "project" }, + "scopeSet": parsed.scope_set, "agentKind": kind, "agentIds": ids, + "yes": parsed.yes, "dryRun": parsed.dry_run, + "force": parsed.force, "errors": parsed.errors, + }}) } "resolve-install-selection" => { let selection = resolve_install_selection(&InstallSelectionOptions { - base: BaseOptions { - home: Some(home.to_path_buf()), - cwd: Some(workspace.to_path_buf()), - hosts_file: Some(repo_path("spec/hosts.json")), - }, - scope: Some(scope(options["scope"].as_str().unwrap())), + base: base.clone(), + scope: Some(scope), agents: options.get("agents").map(agent_selector), - yes: options.get("yes").and_then(Value::as_bool).unwrap_or(false), - stdin_tty: options - .get("stdinTTY") - .and_then(Value::as_bool) - .unwrap_or(false), - current_agent: options - .get("currentAgent") - .and_then(Value::as_str) - .map(String::from), - }) - .unwrap(); - assert_selection( - serde_json::to_value(selection).unwrap(), - case.expected["selection"].clone(), - ); + yes: flag(options, "yes"), + stdin_tty: flag(options, "stdinTTY"), + current_agent: optional_text(options, "currentAgent"), + })?; + json!({"selection": selection}) } "resolve-install-targets" => { - let base = case_base_options(options, home, workspace); let (targets, _, _) = resolve_install_targets( &base, - &agent_selector(&options["agents"]), - scope(options["scope"].as_str().unwrap()), + &agents, + scope, options["skillName"].as_str().unwrap(), - ) - .unwrap(); - let actual = Value::Array( - targets - .into_iter() - .map(|target| { - json!({ - "hostIds": target.host_ids, - "skillName": target.skill_name, - "targetDir": target.target_dir, - }) + )?; + let targets: Vec<_> = targets + .into_iter() + .map(|target| { + json!({ + "hostIds": target.host_ids, + "skillName": target.skill_name, "targetDir": target.target_dir, }) - .collect(), - ); - assert_json_eq( - &actual, - expand_value(&case.expected["targets"], home, workspace), - ); - } - "run-install-workflow" => { - let mut input = Cursor::new( - options - .get("input") - .and_then(Value::as_str) - .unwrap_or("") - .as_bytes() - .to_vec(), - ); - let mut output = Vec::new(); - let workflow = run_bundled_skill_install_with_io( - &InstallWorkflowOptions { - install: InstallOptions { - base: BaseOptions { - home: Some(home.to_path_buf()), - cwd: Some(workspace.to_path_buf()), - hosts_file: Some(repo_path("spec/hosts.json")), - }, - app_id: options["appId"].as_str().unwrap().to_string(), - skill_bundle: skill_bundle_from_options(options), - scope: scope( - options - .get("scope") - .and_then(Value::as_str) - .unwrap_or("user"), - ), - agents: options - .get("agents") - .map(agent_selector) - .unwrap_or(AgentSelector::Auto), - force: options - .get("force") - .and_then(Value::as_bool) - .unwrap_or(false), - }, - yes: options.get("yes").and_then(Value::as_bool).unwrap_or(false), - dry_run: options - .get("dryRun") - .and_then(Value::as_bool) - .unwrap_or(false), - stdin_tty: options - .get("stdinTTY") - .and_then(Value::as_bool) - .unwrap_or(false), - current_agent: options - .get("currentAgent") - .and_then(Value::as_str) - .map(String::from), - default_scope: options - .get("defaultScope") - .and_then(Value::as_str) - .map(scope), - scope_set: options - .get("scopeSet") - .and_then(Value::as_bool) - .unwrap_or_else(|| options.get("scope").is_some()), - prompt_scope: options - .get("promptScope") - .and_then(Value::as_bool) - .unwrap_or(false), - }, - &mut input, - &mut output, - ) - .unwrap(); - let workflow_value = serde_json::to_value(&workflow).unwrap(); - assert_workflow(&workflow_value, case.expected.get("workflow")); - if let Some(expected) = case.expected.get("exit") { - assert_json_eq( - &serde_json::to_value(classify_install_workflow_exit(&workflow)).unwrap(), - expected.clone(), - ); - } - assert_output( - &String::from_utf8(output.clone()).unwrap(), - case.expected.get("output"), - ); - assert_output_contains( - &String::from_utf8(output).unwrap(), - case.expected.get("outputContains"), - ); - if let Some(expected) = case.expected.get("report") { - assert_json_eq( - &serde_json::to_value(&workflow.report).unwrap(), - expand_value(expected, home, workspace), - ); - } - assert_expected_files(case, home, workspace); - assert_expected_file_modes(case, home, workspace); - assert_expected_metadata(case, home, workspace); - } - _ => { - let base = case_base_options(options, home, workspace); - if let Some(expected) = case.expected.get("detectedHosts") { - let hosts = - detect_hosts(&base, Some(scope(options["scope"].as_str().unwrap()))).unwrap(); - assert_json_eq(&json!(host_ids(&hosts)), expected.clone()); - } - let result = run_report_case(case, options, base); - if case.expected.get("throws").and_then(Value::as_bool) == Some(true) { - assert!(result.is_err(), "expected operation to throw"); - assert_expected_files(case, home, workspace); - return; - } - let report = result.unwrap(); - if let Some(expected) = case.expected.get("report") { - assert_json_eq(&report, expand_value(expected, home, workspace)); - } - assert_expected_write_counts(case, &report, home, workspace); - assert_expected_files(case, home, workspace); - assert_expected_file_modes(case, home, workspace); - assert_expected_metadata(case, home, workspace); - } - } -} - -fn case_base_options(options: &Map, home: &Path, workspace: &Path) -> BaseOptions { - let hosts_file = match options.get("hostsFile").and_then(Value::as_str) { - Some(path) => { - let expanded = expand_value(&Value::String(path.to_string()), home, workspace); - let expanded = expanded.as_str().unwrap(); - if Path::new(expanded).is_absolute() { - PathBuf::from(expanded) - } else { - repo_path(expanded) - } + }) + .collect(); + json!({"targets": targets}) } - None => repo_path("spec/hosts.json"), - }; - BaseOptions { - home: Some(home.to_path_buf()), - cwd: Some(workspace.to_path_buf()), - hosts_file: Some(hosts_file), - } -} - -fn run_report_case( - case: &GoldenCase, - options: &Map, - base: BaseOptions, -) -> Result> { - match case.operation.as_str() { - "status" => Ok(serde_json::to_value(status_bundled_skill( - &StatusOptions { - base, - app_id: options["appId"].as_str().unwrap().to_string(), - skill_name: options["skillName"].as_str().unwrap().to_string(), - scope: scope(options["scope"].as_str().unwrap()), - agents: agent_selector(&options["agents"]), - }, - )?)?), - "uninstall" => Ok(serde_json::to_value(uninstall_bundled_skill( - &UninstallOptions { - base, - app_id: options["appId"].as_str().unwrap().to_string(), - skill_name: options["skillName"].as_str().unwrap().to_string(), - scope: scope(options["scope"].as_str().unwrap()), - agents: agent_selector(&options["agents"]), - }, - )?)?), - "install" | "update" | "plan" => { - let options = InstallOptions { - base, - app_id: options["appId"].as_str().unwrap().to_string(), - skill_bundle: skill_bundle_from_options(options), - scope: scope(options["scope"].as_str().unwrap()), - agents: agent_selector(&options["agents"]), - force: options - .get("force") - .and_then(Value::as_bool) - .unwrap_or(false), + "status" => json!({"report": status_bundled_skill(&StatusOptions { + base: base.clone(), app_id: text(options, "appId"), + skill_name: text(options, "skillName"), scope, agents, + })?}), + "uninstall" => json!({"report": uninstall_bundled_skill(&UninstallOptions { + base: base.clone(), app_id: text(options, "appId"), + skill_name: text(options, "skillName"), scope, agents, + })?}), + operation => { + let install = InstallOptions { + base: base.clone(), + app_id: text(options, "appId"), + skill_bundle: skill_bundle(options), + scope, + agents, + force: flag(options, "force"), }; - match case.operation.as_str() { - "update" => Ok(serde_json::to_value(update_bundled_skill(&options)?)?), - "plan" => Ok(serde_json::to_value(plan_bundled_skill(&options)?)?), - _ => Ok(serde_json::to_value(install_bundled_skill(&options)?)?), + match operation { + "run-install-workflow" => { + let mut input = Cursor::new(options["input"].as_str().unwrap_or("").as_bytes()); + let mut output = Vec::new(); + let workflow = run_bundled_skill_install_with_io( + &InstallWorkflowOptions { + install, + yes: flag(options, "yes"), + dry_run: flag(options, "dryRun"), + stdin_tty: flag(options, "stdinTTY"), + current_agent: optional_text(options, "currentAgent"), + default_scope: options["defaultScope"].as_str().map(crate::scope), + scope_set: scope_set(options), + prompt_scope: flag(options, "promptScope"), + }, + &mut input, + &mut output, + )?; + json!({ + "exit": classify_install_workflow_exit(&workflow), + "report": workflow.report, "workflow": workflow, + "output": String::from_utf8(output).unwrap(), + }) + } + "install" => json!({"report": install_bundled_skill(&install)?}), + "update" => json!({"report": update_bundled_skill(&install)?}), + "plan" => json!({"report": plan_bundled_skill(&install)?}), + other => panic!("unsupported operation: {other}"), } } - other => panic!("unsupported operation: {other}"), - } -} - -fn setup_given(case: &GoldenCase, home: &Path, workspace: &Path) { - for dir in case - .given - .get("dirs") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - fs::create_dir_all(expand_string(dir.as_str().unwrap(), home, workspace)).unwrap(); - } - if let Some(files) = case.given.get("files").and_then(Value::as_object) { - for (path, value) in files { - write_fixture_file(&expand_string(path, home, workspace), value); - } - } - if let Some(target) = case.given.get("copySkillBundleTo").and_then(Value::as_str) { - let target = expand_string(target, home, workspace); - let _ = fs::remove_dir_all(&target); - copy_dir(&case_skill_bundle_dir(case), &target); - } - if let Some(modes) = case.given.get("fileModes").and_then(Value::as_object) { - for (path, mode) in modes { - set_fixture_mode( - &expand_string(path, home, workspace), - u32::from_str_radix(mode.as_str().unwrap(), 8).unwrap(), - ); - } - } - if let Some(metadata) = case.given.get("metadata").and_then(Value::as_object) { - write_metadata_fixture(case, home, workspace, metadata); - } - if let Some(github) = case.given.get("github").and_then(Value::as_object) { - start_github_fixture(github); - } -} - -fn assert_expected_files(case: &GoldenCase, home: &Path, workspace: &Path) { - for path in case - .expected - .get("filesPresent") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - let path = expand_string(path.as_str().unwrap(), home, workspace); - assert!(path.exists(), "expected file to exist: {}", path.display()); - } - for path in case - .expected - .get("filesAbsent") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - let path = expand_string(path.as_str().unwrap(), home, workspace); - assert!( - !path.exists(), - "expected file to be absent: {}", - path.display() - ); - } -} - -fn assert_expected_file_modes(case: &GoldenCase, home: &Path, workspace: &Path) { - let Some(modes) = case.expected.get("fileModes").and_then(Value::as_object) else { - return; - }; - for (path, expected) in modes { - let path = expand_string(path, home, workspace); - let mode = fs::metadata(&path).unwrap().permissions(); - assert_eq!(mode_string(&mode), expected.as_str().unwrap()); - } -} - -#[cfg(unix)] -fn mode_string(mode: &fs::Permissions) -> String { - use std::os::unix::fs::PermissionsExt; - format!("{:o}", mode.mode() & 0o777) -} - -#[cfg(not(unix))] -fn mode_string(_mode: &fs::Permissions) -> String { - String::new() -} - -#[cfg(unix)] -fn set_fixture_mode(path: &Path, mode: u32) { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap(); -} - -#[cfg(not(unix))] -fn set_fixture_mode(_path: &Path, _mode: u32) {} - -fn assert_expected_metadata(case: &GoldenCase, home: &Path, workspace: &Path) { - let Some(metadata) = case.expected.get("metadata").and_then(Value::as_object) else { - return; - }; - let path = expand_string(metadata["path"].as_str().unwrap(), home, workspace); - let actual: Value = read_json(&path); - for (key, value) in metadata["fields"].as_object().unwrap() { - assert_json_eq(&actual[key], value.clone()); - } - let hash = expected_bundle_hash(case, metadata["hash"].as_str().unwrap()); - assert_eq!(actual["hash"], hash); -} - -fn assert_selection(mut actual: Value, mut expected: Value) { - if let Some(selected_count) = expected.get("selectedCount").and_then(Value::as_u64) { - assert_eq!( - actual["selectedHostIds"].as_array().unwrap().len(), - selected_count as usize - ); - actual.as_object_mut().unwrap().remove("selectedHostIds"); - expected.as_object_mut().unwrap().remove("selectedCount"); - } - if let Some(candidate_count) = expected.get("candidateCount").and_then(Value::as_u64) { - assert_eq!( - actual["candidateHostIds"].as_array().unwrap().len(), - candidate_count as usize - ); - actual.as_object_mut().unwrap().remove("candidateHostIds"); - expected.as_object_mut().unwrap().remove("candidateCount"); - } - assert_json_eq(&actual, expected); -} - -fn normalized_parsed_flags(parsed: &ParsedInstallFlags) -> Value { - let (agent_kind, agent_ids) = match &parsed.agents { - AgentSelector::Auto => ("auto", vec![]), - AgentSelector::All => ("*", vec![]), - AgentSelector::Explicit(ids) => ("explicit", ids.clone()), - }; - json!({ - "scope": match parsed.scope { - Scope::User => "user", - Scope::Project => "project", - }, - "scopeSet": parsed.scope_set, - "agentKind": agent_kind, - "agentIds": agent_ids, - "yes": parsed.yes, - "dryRun": parsed.dry_run, - "force": parsed.force, - "errors": parsed.errors - }) -} - -fn assert_workflow(actual: &Value, expected: Option<&Value>) { - let Some(expected) = expected.and_then(Value::as_object) else { - return; - }; - for (key, value) in expected { - assert_json_eq(&actual[key], value.clone()); - } -} - -fn assert_output_contains(actual: &str, expected: Option<&Value>) { - let Some(expected) = expected.and_then(Value::as_array) else { - return; - }; - for value in expected { - let text = value.as_str().unwrap(); - assert!( - actual.contains(text), - "expected output to contain {text}, got:\n{actual}" - ); - } -} - -fn assert_output(actual: &str, expected: Option<&Value>) { - let Some(expected) = expected.and_then(Value::as_str) else { - return; - }; - assert_eq!(actual, expected); -} - -fn assert_expected_write_counts(case: &GoldenCase, report: &Value, home: &Path, workspace: &Path) { - let Some(expected) = case.expected.get("writeCountByTargetDir") else { - return; }; - let mut actual = Map::new(); - for key in ["installed", "updated"] { - for item in report[key].as_array().unwrap() { - let target_dir = item["targetDir"].as_str().unwrap(); - let count = actual.get(target_dir).and_then(Value::as_u64).unwrap_or(0) + 1; - actual.insert(target_dir.to_string(), json!(count)); - } - } - assert_json_eq( - &Value::Object(actual), - expand_value(expected, home, workspace), - ); -} - -fn write_metadata_fixture( - case: &GoldenCase, - home: &Path, - workspace: &Path, - metadata: &Map, -) { - let mut fields = metadata["fields"].as_object().unwrap().clone(); - let hash = expected_bundle_hash(case, metadata["hash"].as_str().unwrap()); - fields.insert("hash".to_string(), json!(hash)); - write_fixture_file( - &expand_string(metadata["path"].as_str().unwrap(), home, workspace), - &Value::Object(fields), - ); -} - -fn expected_bundle_hash(case: &GoldenCase, marker: &str) -> String { - match marker { - "from-skill-bundle-dir" => { - compute_bundle_content_hash(&directory_bundle(case_skill_bundle_dir(case))).unwrap() - } - "from-skill-files" => compute_bundle_content_hash(&files_bundle(skill_files( - case.options["skillFiles"].as_array().unwrap(), - ))) - .unwrap(), - "from-github-bundle" => { - compute_bundle_content_hash(&files_bundle(github_skill_files(case))).unwrap() - } - _ => marker.to_string(), - } -} - -fn write_fixture_file(path: &Path, value: &Value) { - fs::create_dir_all(path.parent().unwrap()).unwrap(); - if let Some(text) = value.as_str() { - fs::write(path, text).unwrap(); - } else { - fs::write( - path, - format!("{}\n", serde_json::to_string_pretty(value).unwrap()), - ) - .unwrap(); + if let Some(detected) = detected { + result["detectedHosts"] = detected; } + Ok(result) } -fn copy_dir(src: &Path, dest: &Path) { - fs::create_dir_all(dest).unwrap(); - for entry in fs::read_dir(src).unwrap() { - let entry = entry.unwrap(); - let to = dest.join(entry.file_name()); - if entry.file_type().unwrap().is_dir() { - copy_dir(&entry.path(), &to); - } else { - fs::copy(entry.path(), to).unwrap(); - } - } -} - -fn agent_selector(value: &Value) -> AgentSelector { - if let Some(text) = value.as_str() { - if text == "*" { - AgentSelector::All - } else { - AgentSelector::Auto - } - } else { - AgentSelector::Explicit( - value - .as_array() - .unwrap() +fn skill_bundle(options: &Value) -> SkillBundle { + let bundle = if let Some(files) = options["skillFiles"].as_array() { + files_bundle( + files .iter() - .map(|item| item.as_str().unwrap().to_string()) + .map(|file| SkillFile { + path: text(file, "path"), + contents: text(file, "contents").into_bytes(), + mode: file["mode"].as_u64().map(|mode| mode as u32), + }) .collect(), ) - } -} - -fn skill_bundle_from_options(options: &Map) -> SkillBundle { - let bundle = if let Some(files) = options.get("skillFiles").and_then(Value::as_array) { - files_bundle(skill_files(files)) - } else if let Some(dir) = options.get("skillBundleDir").and_then(Value::as_str) { - directory_bundle(repo_path(dir)) - } else if let Some(bundle) = options.get("githubBundle").and_then(Value::as_object) { + } else if let Some(path) = options["skillBundleDir"].as_str() { + directory_bundle(path) + } else if let Some(github) = options.get("githubBundle") { github_bundle(GitHubBundleOptions { - owner: bundle["owner"].as_str().unwrap().to_string(), - repo: bundle["repo"].as_str().unwrap().to_string(), - path: bundle["path"].as_str().unwrap().to_string(), - ref_name: bundle["ref"].as_str().unwrap().to_string(), + owner: text(github, "owner"), + repo: text(github, "repo"), + path: text(github, "path"), + ref_name: text(github, "ref"), }) } else { - files_bundle(Vec::new()) + files_bundle(vec![]) }; - let Some(metadata) = options.get("bundleMetadata").and_then(Value::as_object) else { + let Some(metadata) = options.get("bundleMetadata") else { return bundle; }; with_bundle_metadata( bundle, BundledSkillMetadata { - source_id: metadata - .get("sourceId") - .and_then(Value::as_str) - .map(String::from), - cli_version: metadata - .get("cliVersion") - .and_then(Value::as_str) - .map(String::from), - cli_revision: metadata - .get("cliRevision") - .and_then(Value::as_str) - .map(String::from), - provenance: metadata - .get("provenance") - .and_then(Value::as_object) + source_id: optional_text(metadata, "sourceId"), + cli_version: optional_text(metadata, "cliVersion"), + cli_revision: optional_text(metadata, "cliRevision"), + provenance: metadata["provenance"] + .as_object() .into_iter() .flatten() .map(|(key, value)| (key.clone(), value.as_str().unwrap().to_string())) @@ -698,123 +232,23 @@ fn skill_bundle_from_options(options: &Map) -> SkillBundle { ) } -fn skill_files(values: &[Value]) -> Vec { - values - .iter() - .map(|value| { - let value = value.as_object().unwrap(); - SkillFile { - path: value["path"].as_str().unwrap().to_string(), - contents: value["contents"].as_str().unwrap().as_bytes().to_vec(), - mode: value - .get("mode") - .and_then(Value::as_u64) - .map(|mode| mode as u32), - } - }) - .collect() -} - -fn github_skill_files(case: &GoldenCase) -> Vec { - let bundle = case.options["githubBundle"].as_object().unwrap(); - let root = format!("{}/", bundle["path"].as_str().unwrap().trim_matches('/')); - let files = case.given["github"]["files"].as_object().unwrap(); - files - .iter() - .filter_map(|(path, contents)| { - path.strip_prefix(&root).map(|relative| SkillFile { - path: relative.to_string(), - contents: contents.as_str().unwrap().as_bytes().to_vec(), - mode: None, - }) - }) - .collect() -} - -fn start_github_fixture(github: &Map) { - let owner = github["owner"].as_str().unwrap().to_string(); - let repo = github["repo"].as_str().unwrap().to_string(); - let ref_name = github["ref"].as_str().unwrap().to_string(); - let commit = github["commit"].as_str().unwrap().to_string(); - let tree_sha = github["treeSha"].as_str().unwrap().to_string(); - let files = github["files"].as_object().unwrap().clone(); - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let base = format!("http://{}", addr); - std::env::set_var("KITUP_GITHUB_API_BASE_URL", &base); - std::env::set_var("KITUP_GITHUB_RAW_BASE_URL", &base); - thread::spawn(move || { - for stream in listener.incoming().flatten() { - handle_github_fixture_connection( - stream, &owner, &repo, &ref_name, &commit, &tree_sha, &files, - ); - } - }); -} - -fn handle_github_fixture_connection( - mut stream: TcpStream, - owner: &str, - repo: &str, - ref_name: &str, - commit: &str, - tree_sha: &str, - files: &Map, -) { - let mut buffer = [0; 4096]; - let Ok(size) = stream.read(&mut buffer) else { - return; - }; - let request = String::from_utf8_lossy(&buffer[..size]); - let path = request - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .unwrap_or("/"); - let path = path.split('?').next().unwrap_or(path); - let commit_path = format!("/repos/{owner}/{repo}/commits/{ref_name}"); - let tree_path = format!("/repos/{owner}/{repo}/git/trees/{tree_sha}"); - if path == commit_path { - write_http_json( - &mut stream, - json!({ "sha": commit, "commit": { "tree": { "sha": tree_sha } } }), - ); - return; - } - if path == tree_path { - let tree: Vec<_> = files - .keys() - .map(|path| { - json!({ - "path": path, - "type": "blob", - "mode": if path.ends_with(".sh") { "100755" } else { "100644" } - }) - }) - .collect(); - write_http_json(&mut stream, json!({ "tree": tree })); - return; - } - let raw_prefix = format!("/{owner}/{repo}/{commit}/"); - if let Some(file) = path.strip_prefix(&raw_prefix) { - if let Some(contents) = files.get(file).and_then(Value::as_str) { - write_http(&mut stream, "200 OK", "application/octet-stream", contents); - return; - } +fn agent_selector(value: &Value) -> AgentSelector { + match value.as_str() { + Some("*") => AgentSelector::All, + Some(_) => AgentSelector::Auto, + None => AgentSelector::Explicit( + value + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap().to_string()) + .collect(), + ), } - write_http(&mut stream, "404 Not Found", "text/plain", "not found"); -} - -fn write_http_json(stream: &mut TcpStream, value: Value) { - write_http(stream, "200 OK", "application/json", &value.to_string()); } -fn write_http(stream: &mut TcpStream, status: &str, content_type: &str, body: &str) { - let response = format!( - "HTTP/1.1 {status}\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - let _ = std::io::Write::write_all(stream, response.as_bytes()); +fn host_ids(hosts: &[Host]) -> Vec<&str> { + hosts.iter().map(|host| host.id.as_str()).collect() } fn scope(value: &str) -> Scope { @@ -825,84 +259,66 @@ fn scope(value: &str) -> Scope { } } -fn case_skill_bundle_dir(case: &GoldenCase) -> PathBuf { - if let Some(skill_bundle_dir) = case.options.get("skillBundleDir").and_then(Value::as_str) { - repo_path(skill_bundle_dir) - } else { - repo_path(&format!( - "testdata/skills/{}", - case.options["skillName"].as_str().unwrap() - )) - } -} - -fn repo_path(path: &str) -> PathBuf { - let path = PathBuf::from(path); - if path.is_absolute() { - path - } else { - PathBuf::from("..").join(path) - } +fn scope_set(options: &Value) -> bool { + options["scopeSet"] + .as_bool() + .unwrap_or_else(|| options.get("scope").is_some()) } -fn host_ids(hosts: &[kitup::Host]) -> Vec { - hosts.iter().map(|host| host.id.clone()).collect() +fn flag(options: &Value, key: &str) -> bool { + options[key].as_bool().unwrap_or(false) } -fn expand_value(value: &Value, home: &Path, workspace: &Path) -> Value { - match value { - Value::String(text) => json!(expand_string(text, home, workspace)), - Value::Array(items) => Value::Array( - items - .iter() - .map(|item| expand_value(item, home, workspace)) - .collect(), - ), - Value::Object(object) => Value::Object( - object - .iter() - .map(|(key, value)| { - ( - expand_string(key, home, workspace) - .to_string_lossy() - .to_string(), - expand_value(value, home, workspace), - ) - }) - .collect(), - ), - _ => value.clone(), - } +fn optional_text(options: &Value, key: &str) -> Option { + options[key].as_str().map(String::from) } -fn expand_string(value: &str, home: &Path, workspace: &Path) -> PathBuf { - PathBuf::from( - value - .replace("$HOME", &home.to_string_lossy()) - .replace("$WORKSPACE", &workspace.to_string_lossy()), - ) +fn text(options: &Value, key: &str) -> String { + options[key].as_str().unwrap().to_string() } -fn assert_json_eq(left: &Value, right: Value) { - assert_eq!( - left, - &right, - "got:\n{}\nwant:\n{}", - serde_json::to_string_pretty(left).unwrap(), - serde_json::to_string_pretty(&right).unwrap() - ); -} - -fn read_json Deserialize<'de>>(path: &Path) -> T { - serde_json::from_slice(&fs::read(path).unwrap()).unwrap() -} - -fn temp_dir(name: &str) -> PathBuf { - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = std::env::temp_dir().join(format!("kitup-{name}-{suffix}")); - fs::create_dir_all(&path).unwrap(); - path +#[cfg(feature = "include-dir")] +#[test] +fn include_dir_bundle_installs_embedded_tree() { + static SKILLS: include_dir::Dir<'_> = + include_dir::include_dir!("$CARGO_MANIFEST_DIR/../testdata/skills"); + let root = std::env::temp_dir().join(format!( + "kitup-include-dir-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let home = root.join("home"); + let report = install_bundled_skill(&InstallOptions { + base: BaseOptions { + home: Some(home.clone()), + ..BaseOptions::default() + }, + app_id: "example-cli".into(), + skill_bundle: include_dir_bundle(SKILLS.get_dir("basic").unwrap()), + scope: Scope::User, + agents: AgentSelector::Explicit(vec!["codex".into()]), + force: false, + }) + .unwrap(); + let target = home.join(".agents/skills/basic"); + assert_eq!(report.installed.len(), 1); + for file in ["SKILL.md", "references/guide.md", "assets/template.json"] { + assert!(target.join(file).is_file()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(target.join("scripts/helper.sh")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o755 + ); + } + let _ = fs::remove_dir_all(root); } diff --git a/scripts/check.mjs b/scripts/check.mjs index 7f35770..6202a41 100755 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -451,6 +451,8 @@ for (const [group, name, command, args, cwd, env] of [ "../scripts/check.mjs", "../scripts/check-go-modules.mjs", "../scripts/prepare-release.mjs", + "../scripts/golden.mjs", + "../scripts/golden.d.mts", ], rootPath, ], diff --git a/scripts/golden.d.mts b/scripts/golden.d.mts new file mode 100644 index 0000000..94cf4f4 --- /dev/null +++ b/scripts/golden.d.mts @@ -0,0 +1,4 @@ +export function runGoldenCases( + execute: (request: any) => Promise, + comparePaths?: (a: string, b: string) => number, +): Promise; diff --git a/scripts/golden.mjs b/scripts/golden.mjs new file mode 100644 index 0000000..8d91f12 --- /dev/null +++ b/scripts/golden.mjs @@ -0,0 +1,332 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { + chmod, + cp, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repo = fileURLToPath(new URL("../", import.meta.url)); +const githubEnv = ["KITUP_GITHUB_API_BASE_URL", "KITUP_GITHUB_RAW_BASE_URL"]; +const ignored = (name) => + [".git", ".kitup.json", ".DS_Store"].includes(name) || + name.endsWith(".swp") || + name.endsWith("~"); + +export async function runGoldenCases( + execute, + comparePaths = (a, b) => (a < b ? -1 : a > b ? 1 : 0), +) { + const { cases } = JSON.parse( + await readFile( + join(repo, "testdata/cases/bundled-skill-install.json"), + "utf8", + ), + ); + const root = await mkdtemp(join(tmpdir(), "kitup-golden-")); + const servers = []; + const previousEnv = githubEnv.map((key) => process.env[key]); + try { + const requests = []; + const expectations = []; + for (const testCase of cases) { + const home = join(root, testCase.id, "home"); + const cwd = join(root, testCase.id, "workspace"); + await mkdir(home, { recursive: true }); + await mkdir(cwd, { recursive: true }); + const { options, given, expected } = expand(testCase, home, cwd); + options.home = home; + options.cwd = cwd; + options.hostsFile = resolve(repo, options.hostsFile ?? "spec/hosts.json"); + if (options.skillBundleDir) + options.skillBundleDir = resolve(repo, options.skillBundleDir); + if (given.hostsFile) given.hostsFile = resolve(repo, given.hostsFile); + const bundleDir = + options.skillBundleDir ?? + join(repo, "testdata/skills", options.skillName ?? ""); + for (const dir of given.dirs ?? []) await mkdir(dir, { recursive: true }); + for (const [path, value] of Object.entries(given.files ?? {})) + await writeFixture(path, value); + if (given.copySkillBundleTo) { + await rm(given.copySkillBundleTo, { recursive: true, force: true }); + await cp(bundleDir, given.copySkillBundleTo, { recursive: true }); + } + for (const [path, mode] of Object.entries(given.fileModes ?? {})) + await chmod(path, parseInt(mode, 8)); + for (const metadata of [given.metadata, expected.metadata]) { + if (metadata) + metadata.hash = await bundleHash( + metadata.hash, + options, + given, + bundleDir, + comparePaths, + ); + } + if (given.metadata) + await writeFixture(given.metadata.path, { + ...given.metadata.fields, + hash: given.metadata.hash, + }); + const env = given.github + ? await githubFixture(given.github, servers) + : {}; + requests.push({ + id: testCase.id, + operation: testCase.operation, + options, + given, + detect: "detectedHosts" in expected, + env, + }); + expectations.push(expected); + } + + let results; + if (typeof execute === "function") { + results = []; + for (const request of requests) { + setGithubEnv(request.env); + try { + results.push({ ...(await execute(request)), id: request.id }); + } catch (error) { + results.push({ id: request.id, threw: true, error: String(error) }); + } + } + } else { + const input = join(root, "requests.json"); + const output = join(root, "results.json"); + await writeFixture(input, requests); + const child = spawn(execute[0], execute.slice(1), { + env: { + ...process.env, + KITUP_GOLDEN_INPUT: input, + KITUP_GOLDEN_OUTPUT: output, + }, + stdio: "inherit", + }); + const [code, signal] = await once(child, "exit"); + assert.equal(code, 0, `native golden adapter failed: ${signal ?? code}`); + results = JSON.parse(await readFile(output, "utf8")); + } + assert.deepEqual( + results.map((result) => result.id), + cases.map((testCase) => testCase.id), + "golden case coverage changed", + ); + for (const [index, actual] of results.entries()) { + try { + await assertCase(actual, expectations[index]); + } catch (error) { + error.message = `${actual.id}: ${error.message}`; + throw error; + } + } + console.log(`ok: ${cases.length} shared golden cases`); + } finally { + setGithubEnv( + Object.fromEntries( + githubEnv.map((key, index) => [key, previousEnv[index]]), + ), + ); + await Promise.all( + servers.map((server) => new Promise((done) => server.close(done))), + ); + await rm(root, { recursive: true, force: true }); + } +} + +async function assertCase(actual, expected) { + assert.equal(actual.threw ?? false, expected.throws ?? false, actual.error); + for (const key of [ + "count", + "hostIds", + "resolvedHostIds", + "errors", + "valid", + "parsed", + "targets", + "exit", + "output", + "report", + "detectedHosts", + ]) { + if (key in expected) assert.deepEqual(actual[key], expected[key], key); + } + if ("valid" in expected) + assert.equal(actual.errorCode ?? null, expected.errorCode ?? null); + if (expected.selection) { + const selection = { ...actual.selection }; + const wanted = { ...expected.selection }; + for (const key of ["selected", "candidate"]) { + if (`${key}Count` in wanted) { + assert.equal(selection[`${key}HostIds`].length, wanted[`${key}Count`]); + delete selection[`${key}HostIds`]; + delete wanted[`${key}Count`]; + } + } + assert.deepEqual(selection, wanted); + } + for (const [key, value] of Object.entries(expected.workflow ?? {})) + assert.deepEqual(actual.workflow[key], value, `workflow.${key}`); + for (const text of expected.outputContains ?? []) + assert.ok(actual.output.includes(text), `missing output: ${text}`); + for (const [key, present] of [ + ["filesPresent", true], + ["filesAbsent", false], + ]) { + for (const path of expected[key] ?? []) { + const exists = await stat(path).then( + () => true, + (error) => { + if (error.code === "ENOENT") return false; + throw error; + }, + ); + assert.equal(exists, present, path); + } + } + for (const [path, mode] of Object.entries(expected.fileModes ?? {})) + assert.equal(((await stat(path)).mode & 0o777).toString(8), mode, path); + if (expected.metadata) { + const metadata = JSON.parse(await readFile(expected.metadata.path, "utf8")); + for (const [key, value] of Object.entries({ + ...expected.metadata.fields, + hash: expected.metadata.hash, + })) + assert.deepEqual(metadata[key], value, `metadata.${key}`); + } + if (expected.writeCountByTargetDir) { + const counts = {}; + for (const item of [...actual.report.installed, ...actual.report.updated]) + counts[item.targetDir] = (counts[item.targetDir] ?? 0) + 1; + assert.deepEqual(counts, expected.writeCountByTargetDir); + } +} + +async function bundleHash(marker, options, given, bundleDir, comparePaths) { + let files; + if (marker === "from-skill-bundle-dir") + files = await directoryFiles(bundleDir); + else if (marker === "from-skill-files") files = options.skillFiles; + else if (marker === "from-github-bundle") { + const prefix = options.githubBundle.path.replace(/^\/+|\/+$/g, "") + "/"; + files = Object.entries(given.github.files) + .filter(([path]) => path.startsWith(prefix)) + .map(([path, contents]) => ({ + path: path.slice(prefix.length), + contents, + })); + } else return marker; + const hash = createHash("sha256"); + for (const file of files + .filter((file) => !file.path.split("/").some(ignored)) + .sort((a, b) => comparePaths(a.path, b.path))) + hash.update(file.path).update("\0").update(file.contents).update("\0"); + return `sha256:${hash.digest("hex")}`; +} + +async function directoryFiles(root, prefix = "") { + const files = []; + for (const entry of await readdir(join(root, prefix), { + withFileTypes: true, + })) { + if (ignored(entry.name)) continue; + const path = prefix + entry.name; + if (entry.isDirectory()) + files.push(...(await directoryFiles(root, `${path}/`))); + else files.push({ path, contents: await readFile(join(root, path)) }); + } + return files; +} + +async function githubFixture(github, servers) { + const routes = new Map([ + [ + `/repos/${github.owner}/${github.repo}/commits/${github.ref}`, + JSON.stringify({ + sha: github.commit, + commit: { tree: { sha: github.treeSha } }, + }), + ], + [ + `/repos/${github.owner}/${github.repo}/git/trees/${github.treeSha}`, + JSON.stringify({ + tree: Object.keys(github.files).map((path) => ({ + path, + type: "blob", + mode: path.endsWith(".sh") ? "100755" : "100644", + })), + }), + ], + ...Object.entries(github.files).map(([path, contents]) => [ + `/${github.owner}/${github.repo}/${github.commit}/${path}`, + contents, + ]), + ]); + const server = createServer((request, response) => { + const payload = routes.get( + decodeURIComponent(new URL(request.url, "http://localhost").pathname), + ); + response.writeHead(payload === undefined ? 404 : 200); + response.end(payload ?? "not found"); + }); + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const base = `http://127.0.0.1:${server.address().port}`; + return Object.fromEntries(githubEnv.map((key) => [key, base])); +} + +function setGithubEnv(values) { + for (const key of githubEnv) { + if (values[key] === undefined) delete process.env[key]; + else process.env[key] = values[key]; + } +} + +function expand(value, home, cwd) { + if (typeof value === "string") + return value.replaceAll("$HOME", home).replaceAll("$WORKSPACE", cwd); + if (Array.isArray(value)) return value.map((item) => expand(item, home, cwd)); + if (value && typeof value === "object") + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + expand(key, home, cwd), + expand(item, home, cwd), + ]), + ); + return value; +} + +async function writeFixture(path, value) { + await mkdir(dirname(path), { recursive: true }); + await writeFile( + path, + typeof value === "string" ? value : JSON.stringify(value, null, 2) + "\n", + ); +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + assert.equal( + process.argv[2], + "--", + "Usage: node scripts/golden.mjs -- ", + ); + await runGoldenCases(process.argv.slice(3)); +} diff --git a/tests/go-golden/golden_test.go b/tests/go-golden/golden_test.go index d6208f0..7bb5a60 100644 --- a/tests/go-golden/golden_test.go +++ b/tests/go-golden/golden_test.go @@ -3,439 +3,164 @@ package kitup import ( "bytes" "encoding/json" - "net/http" - "net/http/httptest" "os" + "os/exec" "path/filepath" - "reflect" - "strconv" "strings" "testing" ) -type goldenFile struct { - Cases []goldenCase `json:"cases"` +type goldenRequest struct { + ID string `json:"id"` + Operation string `json:"operation"` + Options map[string]any `json:"options"` + Given map[string]any `json:"given"` + Detect bool `json:"detect"` + Env map[string]string `json:"env"` } -type goldenCase struct { - ID string `json:"id"` - Operation string `json:"operation"` - Options map[string]any `json:"options"` - Given map[string]any `json:"given"` - Expected map[string]any `json:"expected"` -} - -const testRepoRootEnv = "KITUP_TEST_REPO_ROOT" - func TestGoldenCases(t *testing.T) { - if os.Getenv(testRepoRootEnv) == "" { - t.Skip("shared golden cases require KITUP_TEST_REPO_ROOT") + input := os.Getenv("KITUP_GOLDEN_INPUT") + if input == "" { + root := os.Getenv("KITUP_TEST_REPO_ROOT") + if root == "" { + t.Skip("shared golden cases require KITUP_TEST_REPO_ROOT") + } + command := exec.Command("node", filepath.Join(root, "scripts/golden.mjs"), "--", os.Args[0], "-test.run=^TestGoldenCases$", "-test.v") + command.Stdout, command.Stderr = os.Stdout, os.Stderr + must(t, command.Run()) + return } - var file goldenFile - readJSON(t, repoPathFromCase("testdata/cases/bundled-skill-install.json"), &file) - for _, tc := range file.Cases { - t.Run(tc.ID, func(t *testing.T) { - root := t.TempDir() - home := filepath.Join(root, "home") - workspace := filepath.Join(root, "workspace") - must(t, os.MkdirAll(home, 0o755)) - must(t, os.MkdirAll(workspace, 0o755)) - setupGiven(t, tc, home, workspace) - runCase(t, tc, home, workspace) - }) + data, err := os.ReadFile(input) + must(t, err) + var requests []goldenRequest + must(t, json.Unmarshal(data, &requests)) + results := make([]map[string]any, 0, len(requests)) + for _, request := range requests { + for _, name := range []string{"KITUP_GITHUB_API_BASE_URL", "KITUP_GITHUB_RAW_BASE_URL"} { + if value, ok := request.Env[name]; ok { + t.Setenv(name, value) + } else { + must(t, os.Unsetenv(name)) + } + } + result, err := dispatchGolden(request) + if result == nil { + result = map[string]any{} + } + result["id"] = request.ID + if err != nil { + result["threw"], result["error"] = true, err.Error() + } + results = append(results, result) } + data, err = json.Marshal(results) + must(t, err) + must(t, os.WriteFile(os.Getenv("KITUP_GOLDEN_OUTPUT"), data, 0o600)) } -func runCase(t *testing.T, tc goldenCase, home, workspace string) { - opts := expandValue(tc.Options, home, workspace).(map[string]any) - switch tc.Operation { +func dispatchGolden(request goldenRequest) (map[string]any, error) { + opts := request.Options + base := BaseOptions{ + Home: stringValue(opts["home"]), CWD: stringValue(opts["cwd"]), HostsFile: stringValue(opts["hostsFile"]), + } + lifecycle := UninstallOptions{ + BaseOptions: base, AppID: stringValue(opts["appId"]), SkillName: stringValue(opts["skillName"]), + Scope: Scope(stringValue(opts["scope"])), Agents: agentSelector(opts["agents"]), + } + install := InstallOptions{ + BaseOptions: base, AppID: lifecycle.AppID, SkillBundle: skillBundleFromOptions(opts), + Scope: lifecycle.Scope, Agents: lifecycle.Agents, Force: boolValue(opts["force"]), + } + switch request.Operation { case "resolve-hosts": - hostsFile := repoPathFromCase(tc.Given["hostsFile"].(string)) - hosts, err := LoadHostSpec(hostsFile) - must(t, err) - resolved, errs := ResolveHosts(agentSelector(opts["agents"]), hosts) - if expected, ok := tc.Expected["count"]; ok && int(expected.(float64)) != len(resolved) { - t.Fatalf("count: got %d want %v", len(resolved), expected) - } - if expected, ok := tc.Expected["hostIds"]; ok { - equal(t, hostIDs(resolved), expected) - } - if expected, ok := tc.Expected["resolvedHostIds"]; ok { - equal(t, hostIDs(resolved), expected) - } - if expected, ok := tc.Expected["errors"]; ok { - equal(t, errs, expected) + hosts, err := LoadHostSpec(stringValue(request.Given["hostsFile"])) + if err != nil { + return nil, err } + selected, errs := ResolveHosts(lifecycle.Agents, hosts) + return map[string]any{"count": len(selected), "hostIds": hostIDList(selected), "resolvedHostIds": hostIDList(selected), "errors": errs}, nil case "validate": - result := ValidateSkillBundle(skillBundleFromOptions(opts)) - equal(t, result.Valid, tc.Expected["valid"]) - equal(t, result.ErrorCode, tc.Expected["errorCode"]) + info := ValidateSkillBundle(install.SkillBundle) + return map[string]any{"valid": info.Valid, "errorCode": info.ErrorCode}, nil case "parse-install-flags": - assertParsedFlags(t, ParseInstallFlags(InstallFlagValues{ - Scope: stringValue(opts["scope"]), - ScopeSet: boolValue(opts["scopeSet"]) || opts["scope"] != nil, - Agents: stringSlice(opts["agents"]), - Yes: boolValue(opts["yes"]), - DryRun: boolValue(opts["dryRun"]), - Force: boolValue(opts["force"]), - }), tc.Expected["parsed"].(map[string]any)) + parsed := ParseInstallFlags(InstallFlagValues{ + Scope: stringValue(opts["scope"]), ScopeSet: boolValue(opts["scopeSet"]) || opts["scope"] != nil, + Agents: stringSlice(opts["agents"]), Yes: boolValue(opts["yes"]), DryRun: boolValue(opts["dryRun"]), Force: install.Force, + }) + ids := append([]string{}, parsed.Agents.IDs...) + return map[string]any{"parsed": map[string]any{ + "scope": parsed.Scope, "scopeSet": parsed.ScopeSet, "agentKind": parsed.Agents.Kind, "agentIds": ids, + "yes": parsed.Yes, "dryRun": parsed.DryRun, "force": parsed.Force, "errors": parsed.Errors, + }}, nil case "resolve-install-selection": selection, err := ResolveInstallSelection(InstallSelectionOptions{ - BaseOptions: caseBaseOptions(tc, home, workspace), - Scope: Scope(opts["scope"].(string)), - Agents: agentSelector(opts["agents"]), - Yes: boolValue(opts["yes"]), - StdinTTY: boolValue(opts["stdinTTY"]), + BaseOptions: base, Scope: lifecycle.Scope, Agents: lifecycle.Agents, + Yes: boolValue(opts["yes"]), StdinTTY: boolValue(opts["stdinTTY"]), }) - must(t, err) - assertSelection(t, normalize(selection).(map[string]any), tc.Expected["selection"].(map[string]any)) + return map[string]any{"selection": selection}, err case "resolve-install-targets": - targets, _, _, err := ResolveInstallTargets( - caseBaseOptions(tc, home, workspace), - agentSelector(opts["agents"]), - Scope(opts["scope"].(string)), - opts["skillName"].(string), - ) - must(t, err) - actual := []map[string]any{} + targets, _, _, err := ResolveInstallTargets(base, lifecycle.Agents, lifecycle.Scope, lifecycle.SkillName) + result := []map[string]any{} for _, target := range targets { - actual = append(actual, map[string]any{ - "hostIds": target.HostIDs, "skillName": target.SkillName, "targetDir": target.TargetDir, - }) + result = append(result, map[string]any{"hostIds": target.HostIDs, "skillName": target.SkillName, "targetDir": target.TargetDir}) } - equal(t, actual, expandValue(tc.Expected["targets"], home, workspace)) + return map[string]any{"targets": result}, err case "run-install-workflow": var out bytes.Buffer report, err := RunBundledSkillInstall(InstallWorkflowOptions{ - InstallOptions: InstallOptions{ - BaseOptions: caseBaseOptions(tc, home, workspace), - AppID: opts["appId"].(string), - SkillBundle: skillBundleFromOptions(opts), - Scope: Scope(stringValue(opts["scope"])), - Agents: agentSelector(opts["agents"]), - Force: boolValue(opts["force"]), - }, - Yes: boolValue(opts["yes"]), - DryRun: boolValue(opts["dryRun"]), - StdinTTY: boolValue(opts["stdinTTY"]), - DefaultScope: Scope(stringValue(opts["defaultScope"])), - ScopeSet: boolValue(opts["scopeSet"]) || opts["scope"] != nil, - PromptScope: boolValue(opts["promptScope"]), - In: strings.NewReader(stringValue(opts["input"])), - Out: &out, + InstallOptions: install, Yes: boolValue(opts["yes"]), DryRun: boolValue(opts["dryRun"]), StdinTTY: boolValue(opts["stdinTTY"]), + DefaultScope: Scope(stringValue(opts["defaultScope"])), ScopeSet: boolValue(opts["scopeSet"]) || opts["scope"] != nil, + PromptScope: boolValue(opts["promptScope"]), In: strings.NewReader(stringValue(opts["input"])), Out: &out, }) - must(t, err) - assertWorkflow(t, normalize(report).(map[string]any), tc.Expected["workflow"]) - if expected, ok := tc.Expected["exit"]; ok { - equal(t, ClassifyInstallWorkflowExit(report), expected) - } - assertOutput(t, out.String(), tc.Expected["output"]) - assertOutputContains(t, out.String(), tc.Expected["outputContains"]) - if expected, ok := tc.Expected["report"]; ok { - equal(t, report.Report, expandValue(expected, home, workspace)) - } - assertExpectedFiles(t, tc, home, workspace) - assertExpectedFileModes(t, tc, home, workspace) - assertExpectedMetadata(t, tc, home, workspace) - default: - base := caseBaseOptions(tc, home, workspace) - if expected, ok := tc.Expected["detectedHosts"]; ok { - hosts, err := DetectHosts(base, Scope(opts["scope"].(string))) - must(t, err) - equal(t, hostIDs(hosts), expected) - } - report, err := runReportCase(t, tc, opts, base) - if throws, ok := tc.Expected["throws"].(bool); ok && throws { - if err == nil { - t.Fatal("expected operation to throw") - } - assertExpectedFiles(t, tc, home, workspace) - return - } - must(t, err) - if expected, ok := tc.Expected["report"]; ok { - equal(t, report, expandValue(expected, home, workspace)) + return map[string]any{"workflow": report, "report": report.Report, "exit": ClassifyInstallWorkflowExit(report), "output": out.String()}, err + } + result := map[string]any{} + if request.Detect { + hosts, err := DetectHosts(base, lifecycle.Scope) + if err != nil { + return result, err } - assertExpectedWriteCounts(t, tc, report, home, workspace) - assertExpectedFiles(t, tc, home, workspace) - assertExpectedFileModes(t, tc, home, workspace) - assertExpectedMetadata(t, tc, home, workspace) + result["detectedHosts"] = hostIDList(hosts) } -} - -func runReportCase(t *testing.T, tc goldenCase, opts map[string]any, base BaseOptions) (any, error) { - switch tc.Operation { + var err error + switch request.Operation { case "status": - return StatusBundledSkill(StatusOptions{ - BaseOptions: base, - AppID: opts["appId"].(string), - SkillName: opts["skillName"].(string), - Scope: Scope(opts["scope"].(string)), - Agents: agentSelector(opts["agents"]), - }) + result["report"], err = StatusBundledSkill(StatusOptions(lifecycle)) case "uninstall": - return UninstallBundledSkill(UninstallOptions{ - BaseOptions: base, - AppID: opts["appId"].(string), - SkillName: opts["skillName"].(string), - Scope: Scope(opts["scope"].(string)), - Agents: agentSelector(opts["agents"]), - }) - case "install", "update", "plan": - fn := InstallBundledSkill - if tc.Operation == "update" { - fn = UpdateBundledSkill - } - if tc.Operation == "plan" { - fn = PlanBundledSkill - } - return fn(InstallOptions{ - BaseOptions: base, - AppID: opts["appId"].(string), - SkillBundle: skillBundleFromOptions(opts), - Scope: Scope(opts["scope"].(string)), - Agents: agentSelector(opts["agents"]), - Force: boolValue(opts["force"]), - }) - default: - t.Fatalf("unsupported operation: %s", tc.Operation) - return nil, nil - } -} - -func baseOptions(home, workspace string) BaseOptions { - return BaseOptions{Home: home, CWD: workspace, HostsFile: repoPathFromCase("spec/hosts.json")} -} - -func caseBaseOptions(tc goldenCase, home, workspace string) BaseOptions { - base := baseOptions(home, workspace) - if hostsFile, ok := tc.Options["hostsFile"].(string); ok && hostsFile != "" { - expanded := expandString(hostsFile, home, workspace) - if filepath.IsAbs(expanded) { - base.HostsFile = expanded - } else { - base.HostsFile = repoPathFromCase(expanded) - } - } - return base -} - -func setupGiven(t *testing.T, tc goldenCase, home, workspace string) { - for _, dir := range stringSlice(tc.Given["dirs"]) { - must(t, os.MkdirAll(expandString(dir, home, workspace), 0o755)) - } - if files, ok := tc.Given["files"].(map[string]any); ok { - for path, value := range files { - writeFixtureFile(t, expandString(path, home, workspace), value) - } - } - if target, ok := tc.Given["copySkillBundleTo"].(string); ok { - must(t, os.RemoveAll(expandString(target, home, workspace))) - bundle, err := readSkillBundle(DirectoryBundle(caseSkillBundleDir(tc))) - must(t, err) - must(t, copySkillBundle(bundle, expandString(target, home, workspace))) - } - if modes, ok := tc.Given["fileModes"].(map[string]any); ok { - for path, mode := range modes { - value, err := strconv.ParseUint(mode.(string), 8, 32) - must(t, err) - must(t, os.Chmod(expandString(path, home, workspace), os.FileMode(value))) - } - } - if meta, ok := tc.Given["metadata"].(map[string]any); ok { - writeMetadataFixture(t, tc, home, workspace, meta) - } - if github, ok := tc.Given["github"].(map[string]any); ok { - startGitHubFixture(t, github) - } -} - -func assertExpectedFiles(t *testing.T, tc goldenCase, home, workspace string) { - for _, path := range stringSlice(tc.Expected["filesPresent"]) { - if !exists(expandString(path, home, workspace)) { - t.Fatalf("expected file to exist: %s", expandString(path, home, workspace)) - } - } - for _, path := range stringSlice(tc.Expected["filesAbsent"]) { - if exists(expandString(path, home, workspace)) { - t.Fatalf("expected file to be absent: %s", expandString(path, home, workspace)) - } - } -} - -func assertExpectedFileModes(t *testing.T, tc goldenCase, home, workspace string) { - modes, ok := tc.Expected["fileModes"].(map[string]any) - if !ok { - return - } - for path, expected := range modes { - info, err := os.Stat(expandString(path, home, workspace)) - must(t, err) - equal(t, modeString(info.Mode().Perm()), expected) - } -} - -func assertExpectedMetadata(t *testing.T, tc goldenCase, home, workspace string) { - meta, ok := tc.Expected["metadata"].(map[string]any) - if !ok { - return - } - path := expandString(meta["path"].(string), home, workspace) - var actual map[string]any - readJSON(t, path, &actual) - for key, value := range meta["fields"].(map[string]any) { - equal(t, actual[key], value) - } - hash := expectedBundleHash(t, tc, meta["hash"].(string)) - equal(t, actual["hash"], hash) -} - -func modeString(mode os.FileMode) string { - return strings.TrimLeft("000"+strconv.FormatUint(uint64(mode), 8), "0") -} - -func assertExpectedWriteCounts(t *testing.T, tc goldenCase, report any, home, workspace string) { - expected, ok := tc.Expected["writeCountByTargetDir"] - if !ok { - return - } - actual := map[string]any{} - reportMap := normalize(report).(map[string]any) - for _, key := range []string{"installed", "updated"} { - for _, item := range reportMap[key].([]any) { - targetDir := item.(map[string]any)["targetDir"].(string) - if actual[targetDir] == nil { - actual[targetDir] = float64(0) - } - actual[targetDir] = actual[targetDir].(float64) + 1 - } - } - equal(t, actual, expandValue(expected, home, workspace)) -} - -func writeMetadataFixture(t *testing.T, tc goldenCase, home, workspace string, meta map[string]any) { - hash := expectedBundleHash(t, tc, meta["hash"].(string)) - fields := map[string]any{} - for key, value := range meta["fields"].(map[string]any) { - fields[key] = value - } - fields["hash"] = hash - writeFixtureFile(t, expandString(meta["path"].(string), home, workspace), fields) -} - -func expectedBundleHash(t *testing.T, tc goldenCase, marker string) string { - switch marker { - case "from-skill-bundle-dir": - hash, err := ComputeBundleContentHash(DirectoryBundle(caseSkillBundleDir(tc))) - must(t, err) - return hash - case "from-skill-files": - hash, err := ComputeBundleContentHash(FilesBundle(skillFiles(tc.Options["skillFiles"].([]any)))) - must(t, err) - return hash - case "from-github-bundle": - hash, err := ComputeBundleContentHash(FilesBundle(githubSkillFiles(tc))) - must(t, err) - return hash - default: - return marker - } -} - -func assertSelection(t *testing.T, actual, expected map[string]any) { - if expectedCount, ok := expected["selectedCount"]; ok { - equal(t, float64(len(actual["selectedHostIds"].([]any))), expectedCount) - delete(actual, "selectedHostIds") - } - if expectedCount, ok := expected["candidateCount"]; ok { - equal(t, float64(len(actual["candidateHostIds"].([]any))), expectedCount) - delete(actual, "candidateHostIds") - } - delete(expected, "selectedCount") - delete(expected, "candidateCount") - equal(t, actual, expected) -} - -func assertWorkflow(t *testing.T, actual map[string]any, expected any) { - expectedMap, ok := expected.(map[string]any) - if !ok { - return - } - for key, value := range expectedMap { - equal(t, actual[key], value) - } -} - -func assertParsedFlags(t *testing.T, actual ParsedInstallFlags, expected map[string]any) { - agentKind := actual.Agents.Kind - agentIDs := actual.Agents.IDs - if agentIDs == nil { - agentIDs = []string{} - } - equal(t, map[string]any{ - "scope": string(actual.Scope), - "scopeSet": actual.ScopeSet, - "agentKind": agentKind, - "agentIds": agentIDs, - "yes": actual.Yes, - "dryRun": actual.DryRun, - "force": actual.Force, - "errors": actual.Errors, - }, expected) -} - -func assertOutputContains(t *testing.T, actual string, expected any) { - for _, value := range stringSlice(expected) { - if !strings.Contains(actual, value) { - t.Fatalf("expected output to contain %q, got:\n%s", value, actual) - } - } -} - -func assertOutput(t *testing.T, actual string, expected any) { - if expected == nil { - return - } - equal(t, actual, expected) -} - -func writeFixtureFile(t *testing.T, path string, value any) { - must(t, os.MkdirAll(filepath.Dir(path), 0o755)) - switch value := value.(type) { - case string: - must(t, os.WriteFile(path, []byte(value), 0o644)) + result["report"], err = UninstallBundledSkill(lifecycle) default: - data, err := json.MarshalIndent(value, "", " ") - must(t, err) - must(t, os.WriteFile(path, append(data, '\n'), 0o644)) + operation := map[string]func(InstallOptions) (InstallReport, error){"install": InstallBundledSkill, "update": UpdateBundledSkill, "plan": PlanBundledSkill}[request.Operation] + result["report"], err = operation(install) } + return result, err } func agentSelector(value any) AgentSelector { - if value == nil { - return AutoAgents() + if value == "*" { + return AllAgents() } - if text, ok := value.(string); ok { - if text == "*" { - return AllAgents() - } - return AutoAgents() + if _, ok := value.([]any); ok { + return ExplicitAgents(stringSlice(value)...) } - return ExplicitAgents(stringSlice(value)...) + return AutoAgents() } func skillBundleFromOptions(opts map[string]any) SkillBundle { var bundle SkillBundle if files, ok := opts["skillFiles"].([]any); ok { - bundle = FilesBundle(skillFiles(files)) + items := make([]SkillFile, 0, len(files)) + for _, raw := range files { + item := raw.(map[string]any) + mode, _ := item["mode"].(float64) + items = append(items, SkillFile{Path: item["path"].(string), Contents: []byte(item["contents"].(string)), Mode: os.FileMode(mode)}) + } + bundle = FilesBundle(items) } else if dir, ok := opts["skillBundleDir"].(string); ok { - bundle = DirectoryBundle(repoPathFromCase(dir)) + bundle = DirectoryBundle(dir) } else if github, ok := opts["githubBundle"].(map[string]any); ok { bundle = GitHubBundle(GitHubBundleOptions{ - Owner: github["owner"].(string), - Repo: github["repo"].(string), - Path: github["path"].(string), - Ref: github["ref"].(string), + Owner: github["owner"].(string), Repo: github["repo"].(string), Path: github["path"].(string), Ref: github["ref"].(string), }) } if meta, ok := opts["bundleMetadata"].(map[string]any); ok { @@ -446,91 +171,13 @@ func skillBundleFromOptions(opts map[string]any) SkillBundle { } } bundle = WithBundleMetadata(bundle, BundledSkillMetadata{ - SourceID: stringValue(meta["sourceId"]), - CLIVersion: stringValue(meta["cliVersion"]), - CLIRevision: stringValue(meta["cliRevision"]), - Provenance: provenance, + SourceID: stringValue(meta["sourceId"]), CLIVersion: stringValue(meta["cliVersion"]), + CLIRevision: stringValue(meta["cliRevision"]), Provenance: provenance, }) } return bundle } -func skillFiles(values []any) []SkillFile { - files := make([]SkillFile, 0, len(values)) - for _, value := range values { - item := value.(map[string]any) - mode := os.FileMode(0) - if raw, ok := item["mode"].(float64); ok { - mode = os.FileMode(raw) - } - files = append(files, SkillFile{ - Path: item["path"].(string), - Contents: []byte(item["contents"].(string)), - Mode: mode, - }) - } - return files -} - -func githubSkillFiles(tc goldenCase) []SkillFile { - bundle := tc.Options["githubBundle"].(map[string]any) - root := strings.Trim(bundle["path"].(string), "/") + "/" - github := tc.Given["github"].(map[string]any) - rawFiles := github["files"].(map[string]any) - files := []SkillFile{} - for path, contents := range rawFiles { - if strings.HasPrefix(path, root) { - files = append(files, SkillFile{Path: strings.TrimPrefix(path, root), Contents: []byte(contents.(string))}) - } - } - return files -} - -func startGitHubFixture(t *testing.T, github map[string]any) { - owner := github["owner"].(string) - repo := github["repo"].(string) - ref := github["ref"].(string) - commit := github["commit"].(string) - treeSha := github["treeSha"].(string) - files := github["files"].(map[string]any) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/repos/" + owner + "/" + repo + "/commits/" + ref: - writeResponseJSON(t, w, map[string]any{"sha": commit, "commit": map[string]any{"tree": map[string]any{"sha": treeSha}}}) - case "/repos/" + owner + "/" + repo + "/git/trees/" + treeSha: - tree := []map[string]string{} - for path := range files { - mode := "100644" - if strings.HasSuffix(path, ".sh") { - mode = "100755" - } - tree = append(tree, map[string]string{"path": path, "type": "blob", "mode": mode}) - } - writeResponseJSON(t, w, map[string]any{"tree": tree}) - default: - prefix := "/" + owner + "/" + repo + "/" + commit + "/" - if strings.HasPrefix(r.URL.Path, prefix) { - path := strings.TrimPrefix(r.URL.Path, prefix) - if contents, ok := files[path]; ok { - _, _ = w.Write([]byte(contents.(string))) - return - } - } - http.NotFound(w, r) - } - })) - t.Setenv("KITUP_GITHUB_API_BASE_URL", server.URL) - t.Setenv("KITUP_GITHUB_RAW_BASE_URL", server.URL) - t.Cleanup(server.Close) -} - -func writeResponseJSON(t *testing.T, w http.ResponseWriter, value any) { - w.Header().Set("content-type", "application/json") - data, err := json.Marshal(value) - must(t, err) - _, _ = w.Write(data) -} - func boolValue(value any) bool { boolean, _ := value.(bool) return boolean @@ -541,87 +188,13 @@ func stringValue(value any) string { return text } -func caseSkillBundleDir(tc goldenCase) string { - if dir, ok := tc.Options["skillBundleDir"].(string); ok { - return repoPathFromCase(dir) - } - return repoPathFromCase("testdata/skills/" + tc.Options["skillName"].(string)) -} - -func repoPathFromCase(path string) string { - if filepath.IsAbs(path) { - return path - } - return filepath.Join(os.Getenv(testRepoRootEnv), path) -} - -func hostIDs(hosts []Host) []string { - ids := []string{} - for _, host := range hosts { - ids = append(ids, host.ID) - } - return ids -} - func stringSlice(value any) []string { - if value == nil { - return nil + items, _ := value.([]any) + result := make([]string, 0, len(items)) + for _, item := range items { + result = append(result, item.(string)) } - var out []string - for _, item := range value.([]any) { - out = append(out, item.(string)) - } - return out -} - -func expandValue(value any, home, workspace string) any { - switch value := value.(type) { - case string: - return expandString(value, home, workspace) - case []any: - out := make([]any, len(value)) - for i, item := range value { - out[i] = expandValue(item, home, workspace) - } - return out - case map[string]any: - out := map[string]any{} - for key, item := range value { - out[expandString(key, home, workspace)] = expandValue(item, home, workspace) - } - return out - default: - return value - } -} - -func expandString(value, home, workspace string) string { - value = strings.ReplaceAll(value, "$HOME", home) - return strings.ReplaceAll(value, "$WORKSPACE", workspace) -} - -func equal(t *testing.T, got, want any) { - t.Helper() - got = normalize(got) - want = normalize(want) - if !reflect.DeepEqual(got, want) { - g, _ := json.MarshalIndent(got, "", " ") - w, _ := json.MarshalIndent(want, "", " ") - t.Fatalf("got:\n%s\nwant:\n%s", g, w) - } -} - -func normalize(value any) any { - data, _ := json.Marshal(value) - var out any - _ = json.Unmarshal(data, &out) - return out -} - -func readJSON(t *testing.T, path string, out any) { - data, err := os.ReadFile(path) - must(t, err) - must(t, json.Unmarshal(data, out)) + return result } func must(t *testing.T, err error) { diff --git a/ts/src/bundle.ts b/ts/src/bundle.ts new file mode 100644 index 0000000..91d888e --- /dev/null +++ b/ts/src/bundle.ts @@ -0,0 +1,319 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + SkillBundle, + SkillFile, + SkillInfo, + GitHubBundleOptions, + BundledSkillMetadata, + InstalledMetadata, +} from "./types.js"; + +const skillNamePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +interface BundleFile { + path: string; + bytes: Uint8Array; + mode: number; +} + +export type NormalizedSkillBundle = BundleFile[]; + +export interface ResolvedBundleMetadata extends Omit< + InstalledMetadata, + "schemaVersion" | "appId" | "skillName" | "hash" +> { + explicit?: boolean; +} + +export function directoryBundle(path: string): SkillBundle { + return { kind: "directory", path }; +} + +export function filesBundle(files: SkillFile[]): SkillBundle { + return { kind: "files", files }; +} + +export async function moduleDirBundle( + importMetaUrl: string | URL, + relativePath: string, +): Promise { + const root = fileURLToPath(new URL(relativePath, importMetaUrl)); + return filesBundle(await readDirectoryBundleFiles(root)); +} + +export function githubBundle(options: GitHubBundleOptions): SkillBundle { + return { kind: "github", options }; +} + +export function withBundleMetadata( + bundle: SkillBundle, + metadata: BundledSkillMetadata, +): SkillBundle { + return { kind: "metadata", bundle, metadata }; +} + +export function isValidSkillName(skillName: string) { + return skillNamePattern.test(skillName); +} + +export async function validateSkillBundle( + bundle: SkillBundle, + cwd = process.cwd(), +): Promise { + try { + return validateNormalizedSkill(await readSkillBundle(bundle, cwd)); + } catch { + return { valid: false, errorCode: "invalid-skill-bundle" }; + } +} + +export function validateNormalizedSkill( + bundle: NormalizedSkillBundle, +): SkillInfo { + const skillFile = bundle.find((file) => file.path === "SKILL.md"); + if (!skillFile) return { valid: false, errorCode: "missing-skill-md" }; + const content = Buffer.from(skillFile.bytes).toString("utf8"); + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); + if (!match) return { valid: false, errorCode: "invalid-frontmatter" }; + const frontmatter = parseFrontmatter(match[1]); + const name = frontmatter.get("name") ?? ""; + const description = frontmatter.get("description") ?? ""; + if (!isValidSkillName(name)) { + return { valid: false, errorCode: "invalid-frontmatter" }; + } + if (description.length < 1 || description.length > 1024) { + return { valid: false, errorCode: "invalid-frontmatter" }; + } + return { valid: true, skillName: name, description }; +} + +export async function computeBundleContentHash( + bundle: SkillBundle, + cwd = process.cwd(), +): Promise { + return contentHash(await readSkillBundle(bundle, cwd)); +} + +export function contentHash(bundle: NormalizedSkillBundle) { + const hash = createHash("sha256"); + for (const file of bundle) { + hash.update(file.path).update("\0").update(file.bytes).update("\0"); + } + return `sha256:${hash.digest("hex")}`; +} + +async function readSkillBundle( + bundle: SkillBundle, + cwd = process.cwd(), +): Promise { + return (await resolveSkillBundle(bundle, cwd)).bundle; +} + +export async function resolveSkillBundle( + bundle: SkillBundle, + cwd = process.cwd(), +): Promise<{ + bundle: NormalizedSkillBundle; + metadata: ResolvedBundleMetadata; +}> { + if (bundle.kind === "metadata") { + const resolved = await resolveSkillBundle(bundle.bundle, cwd); + return { + bundle: resolved.bundle, + metadata: mergeBundleMetadata(resolved.metadata, bundle.metadata), + }; + } + if (bundle.kind === "github") return resolveGitHubBundle(bundle.options); + const files = + bundle.kind === "files" + ? bundle.files + : await readDirectoryBundleFiles(resolve(cwd, bundle.path)); + return { + bundle: normalizeSkillFiles(files), + metadata: { source: "bundled" }, + }; +} + +async function resolveGitHubBundle(options: GitHubBundleOptions): Promise<{ + bundle: NormalizedSkillBundle; + metadata: ResolvedBundleMetadata; +}> { + const root = options.path.replace(/^\/+|\/+$/g, ""); + if (!options.owner || !options.repo || !root || !options.ref) { + throw new Error("invalid github bundle"); + } + const apiBase = envBaseUrl( + "KITUP_GITHUB_API_BASE_URL", + "https://api.github.com", + ); + const rawBase = envBaseUrl( + "KITUP_GITHUB_RAW_BASE_URL", + "https://raw.githubusercontent.com", + ); + const commit: any = await ( + await githubResponse( + `${apiBase}/repos/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}/commits/${encodeURIComponent(options.ref)}`, + "request", + ) + ).json(); + const resolvedCommit = String(commit.sha ?? ""); + const treeSha = String(commit.commit?.tree?.sha ?? ""); + if (!resolvedCommit || !treeSha) throw new Error("invalid github commit"); + + const tree: any = await ( + await githubResponse( + `${apiBase}/repos/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, + "request", + ) + ).json(); + const files: SkillFile[] = []; + const prefix = `${root}/`; + for (const item of tree.tree ?? []) { + const path = String(item.path ?? ""); + if (item.type !== "blob" || !path.startsWith(prefix)) continue; + const relativePath = path.slice(prefix.length); + const url = `${rawBase}/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}/${encodeURIComponent(resolvedCommit)}/${path.split("/").map(encodeURIComponent).join("/")}`; + files.push({ + path: relativePath, + contents: new Uint8Array( + await (await githubResponse(url, "download")).arrayBuffer(), + ), + mode: item.mode === "100755" ? 0o755 : 0o644, + }); + } + if (files.length === 0) throw new Error("github bundle path not found"); + const bundle = normalizeSkillFiles(files); + return { + bundle, + metadata: { + source: "github", + sourceId: `github:${options.owner}/${options.repo}/${root}`, + version: options.ref, + provenance: { + owner: options.owner, + repo: options.repo, + path: root, + ref: options.ref, + resolvedCommit, + }, + }, + }; +} + +function mergeBundleMetadata( + resolved: ResolvedBundleMetadata, + supplied: BundledSkillMetadata, +): ResolvedBundleMetadata { + return { + ...resolved, + sourceId: supplied.sourceId || resolved.sourceId, + cliVersion: supplied.cliVersion, + cliRevision: supplied.cliRevision, + provenance: + resolved.provenance || supplied.provenance + ? { ...resolved.provenance, ...supplied.provenance } + : undefined, + explicit: true, + }; +} + +export function isGitHubBundle(bundle: SkillBundle): boolean { + return ( + bundle.kind === "github" || + (bundle.kind === "metadata" && isGitHubBundle(bundle.bundle)) + ); +} + +function envBaseUrl(name: string, fallback: string) { + return (process.env[name] ?? fallback).replace(/\/+$/, ""); +} + +async function githubResponse(url: string, operation: string) { + const response = await fetch(url, { + headers: { "User-Agent": "kitup" }, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`github ${operation} failed: ${url}`); + return response; +} + +async function readDirectoryBundleFiles( + dir: string, + base = dir, +): Promise { + const files: SkillFile[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (skipName(entry.name)) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await readDirectoryBundleFiles(full, base))); + } else if (entry.isFile()) { + files.push({ + path: relative(base, full).split(sep).join("/"), + contents: await readFile(full), + mode: (await stat(full)).mode & 0o777, + }); + } + } + return files; +} + +function normalizeSkillFiles(files: SkillFile[]): NormalizedSkillBundle { + const byPath = new Map(); + for (const file of files) { + const normalizedPath = normalizeBundlePath(file.path); + if (!normalizedPath) continue; + if (byPath.has(normalizedPath)) { + throw new Error(`duplicate skill file: ${normalizedPath}`); + } + byPath.set(normalizedPath, { + path: normalizedPath, + bytes: + typeof file.contents === "string" + ? Buffer.from(file.contents) + : file.contents, + mode: + file.mode === undefined + ? normalizedPath.startsWith("scripts/") + ? 0o755 + : 0o644 + : file.mode, + }); + } + return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path)); +} + +function normalizeBundlePath(path: string) { + const parts = path.split("/"); + if ( + path.includes("\\") || + /^[A-Za-z]:/.test(path) || + parts.some((part) => !part || part === "." || part === "..") + ) { + throw new Error(`invalid skill file path: ${path}`); + } + if (parts.some(skipName)) return undefined; + return path; +} + +function parseFrontmatter(content: string) { + const values = new Map(); + for (const line of content.split(/\r?\n/)) { + const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (match) values.set(match[1], match[2].trim()); + } + return values; +} + +function skipName(name: string) { + return ( + name === ".git" || + name === ".kitup.json" || + name === ".DS_Store" || + name.endsWith(".swp") || + name.endsWith("~") + ); +} diff --git a/ts/src/hosts.ts b/ts/src/hosts.ts new file mode 100644 index 0000000..fe2d6da --- /dev/null +++ b/ts/src/hosts.ts @@ -0,0 +1,277 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { defaultHostsSpecJson } from "./hosts.generated.js"; +import type { + Scope, + AgentSelector, + Host, + HostSpec, + BaseOptions, + TargetGroup, + TargetError, + UnknownHostError, +} from "./types.js"; +import { isValidSkillName } from "./bundle.js"; +import { exists, isDirectory, readMetadata } from "./storage.js"; + +type TargetOptions = BaseOptions & { + agents?: AgentSelector; + scope: Scope; + skillName: string; +}; +type TargetResolution = { + targets: TargetGroup[]; + errors: TargetError[]; + detectedHostIds: string[]; +}; + +export async function loadHostSpec(hostsFile?: string): Promise { + const spec = JSON.parse( + hostsFile ? await readFile(hostsFile, "utf8") : defaultHostsSpecJson, + ) as HostSpec; + validateHostSpec(spec); + return spec; +} + +function validateHostSpec(spec: HostSpec) { + for (const host of spec.hosts ?? []) { + const installDirs = new Set([ + ...(host.projectSkillsDirs ?? []), + ...(host.userSkillsDirs ?? []), + ]); + for (const [kind, paths, valid] of [ + ["project", host.projectSkillsDirs, isProjectHostPath], + ["user", host.userSkillsDirs, isHomeHostPath], + [ + "detect", + host.detect, + (path: string) => isHomeHostPath(path) || isProjectHostPath(path), + ], + ] as const) { + for (const path of paths ?? []) { + if (!valid(path)) + throw new Error( + `invalid ${kind} path ${JSON.stringify(path)} for host ${host.id}`, + ); + if ( + kind === "detect" && + !isGenericDetectPath(path) && + installDirs.has(path) + ) + throw new Error( + `detect path is an install target for host ${host.id}: ${JSON.stringify(path)}`, + ); + } + } + } +} + +function isProjectHostPath(path: string) { + return ( + !!path && + !path.startsWith("/") && + !path.startsWith("~") && + isSafeHostPath(path) + ); +} + +function isHomeHostPath(path: string) { + return path.startsWith("~/") && isSafeHostPath(path.slice(2)); +} + +function isSafeHostPath(path: string) { + return ( + !path.includes("\0") && + !path.includes("\\") && + !path.includes(":") && + !path + .split("/") + .some((segment) => segment === "." || segment === ".." || segment === "") + ); +} + +export async function resolveHosts(options: { + agents: AgentSelector; + hostsFile?: string; + hosts?: Host[]; +}): Promise<{ hosts: Host[]; errors: UnknownHostError[] }> { + const hosts = options.hosts ?? (await loadHostSpec(options.hostsFile)).hosts; + if (options.agents === "*") return { hosts, errors: [] }; + if (options.agents === "auto") return { hosts: [], errors: [] }; + + const byName = new Map(); + for (const host of hosts) { + byName.set(host.id, host); + for (const alias of host.aliases ?? []) byName.set(alias, host); + } + + const resolvedHosts = new Map(); + const errors: UnknownHostError[] = []; + for (const agent of options.agents) { + const host = byName.get(agent); + if (!host) { + errors.push({ agent, reason: "unknown-host" }); + } else if (!resolvedHosts.has(host.id)) { + resolvedHosts.set(host.id, host); + } + } + return { hosts: [...resolvedHosts.values()], errors }; +} + +export async function detectHosts( + options: BaseOptions & { scope?: Scope } = {}, +): Promise { + const spec = await loadHostSpec(options.hostsFile); + const home = options.home ?? homedir(); + const cwd = options.cwd ?? process.cwd(); + const detected: Host[] = []; + + for (const host of spec.hosts) { + for (const detectPath of host.detect) { + if (isGenericDetectPath(detectPath)) continue; + if (await exists(expandHostPath(detectPath, home, cwd))) { + detected.push(host); + break; + } + } + } + + const scope = options.scope; + if (!scope) return detected; + return detected.sort((a, b) => { + const aPath = canonicalScopePath(a, scope, home, cwd) ?? ""; + const bPath = canonicalScopePath(b, scope, home, cwd) ?? ""; + return aPath.localeCompare(bPath) || a.id.localeCompare(b.id); + }); +} + +export async function resolveInstallTargets( + options: TargetOptions, +): Promise { + return resolveInstallTargetsForLifecycle(options); +} + +export async function resolveInstallTargetsForLifecycle( + options: TargetOptions, + uninstallAppId?: string, +): Promise { + if (!isValidSkillName(options.skillName)) { + return { + targets: [], + errors: [{ skillName: options.skillName, reason: "invalid-skill-name" }], + detectedHostIds: [], + }; + } + + const spec = await loadHostSpec(options.hostsFile); + const home = options.home ?? homedir(); + const cwd = options.cwd ?? process.cwd(); + const agents = options.agents ?? "auto"; + const resolved = + agents === "auto" + ? undefined + : await resolveHosts({ agents, hosts: spec.hosts }); + const selected = + agents === "auto" + ? await detectHosts({ ...options, scope: options.scope }) + : resolved!.hosts; + const errors: TargetError[] = agents === "auto" ? [] : [...resolved!.errors]; + const byTarget = new Map(); + + for (const host of selected) { + const paths = scopePaths(host, options.scope).map((path) => + expandHostPath(path, home, cwd), + ); + const roots = await chooseScopePaths( + paths, + options.skillName, + uninstallAppId, + ); + if (roots.length === 0) { + errors.push({ + hostId: host.id, + skillName: options.skillName, + scope: options.scope, + reason: "unsupported-scope", + }); + continue; + } + for (const root of roots) { + const targetDir = join(root, options.skillName); + const group = byTarget.get(targetDir) ?? { + hostIds: [], + skillName: options.skillName, + targetDir, + }; + if (!group.hostIds.includes(host.id)) group.hostIds.push(host.id); + byTarget.set(targetDir, group); + } + } + + const targets = [...byTarget.values()].sort((a, b) => + a.targetDir.localeCompare(b.targetDir), + ); + return { + targets, + errors, + detectedHostIds: targets.flatMap((target) => target.hostIds), + }; +} + +function canonicalScopePath( + host: Host, + scope: Scope, + home: string, + cwd: string, +) { + const paths = scopePaths(host, scope); + return paths[0] ? expandHostPath(paths[0], home, cwd) : undefined; +} + +async function chooseScopePaths( + paths: string[], + skillName: string, + appId?: string, +): Promise { + if (appId) { + const owned: string[] = []; + for (const root of paths) { + const { value } = await readMetadata(join(root, skillName)); + if (value?.skillName === skillName && value.appId === appId) + owned.push(root); + } + if (owned.length) return owned; + } + const existing: string[] = []; + for (const root of paths) if (await isDirectory(root)) existing.push(root); + for (const root of existing) { + if ( + (await readMetadata(join(root, skillName))).value?.skillName === skillName + ) + return [root]; + } + const fallback = existing[0] ?? paths[0]; + return fallback ? [fallback] : []; +} + +function scopePaths(host: Host, scope: Scope) { + return scope === "user" ? host.userSkillsDirs : host.projectSkillsDirs; +} + +function expandHostPath(path: string, home: string, cwd: string) { + return path.startsWith("~/") ? join(home, path.slice(2)) : join(cwd, path); +} + +const GENERIC_DETECT_PATHS = new Set([ + "~/.agents", + "~/.agents/skills", + "~/.config/agents", + ".agents", + ".agents/skills", + "package.json", +]); + +function isGenericDetectPath(path: string) { + return GENERIC_DETECT_PATHS.has(path); +} diff --git a/ts/src/index.ts b/ts/src/index.ts index bd647fb..bd30b5d 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -1,1990 +1,35 @@ -import { createHash } from "node:crypto"; -import { - chmod, - mkdir, - mkdtemp, - lstat, - readdir, - readFile, - rename, - rm, - stat, - writeFile, -} from "node:fs/promises"; -import { homedir } from "node:os"; -import { basename, dirname, join, relative, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; -import { defaultHostsSpecJson } from "./hosts.generated.js"; - -export type Scope = "user" | "project"; -export type AgentSelector = "*" | "auto" | string[]; - -export const installUxText = { - skillUse: "skill", - skillShort: "Manage bundled Agent Skill", - installUse: "install", - installShort: "Install bundled Agent Skill", - scopeFlag: "Install scope: user or project", - agentFlag: "Target agent id. Repeat for multiple agents. Use '*' for all.", - dryRunFlag: "Show install plan without writing", - yesFlag: "Skip prompts and accept policy-selected targets", - forceFlag: "Overwrite unsafe target conflicts", - selectScope: "Select install scope:", - scopePrompt: "Scope (user/project)", - invalidScopeSelection: "Invalid scope selection.", - selectAgents: "Select agents:", - agentsPrompt: "Agents (numbers, ids, comma-separated, empty cancels)", - invalidAgentSelection: "Invalid agent selection.", - proceed: "Proceed? [y/N] ", - installSummary: "Install summary:", - errorPrefix: "kitup:", - canceled: "Installation canceled.", - selectionError: "Agent selection failed.", - conflict: "Installation has conflicts.", - failed: "Installation failed.", - invalidFlags: "Invalid install flags.", -} as const; - -export interface Host { - id: string; - displayName: string; - aliases?: string[]; - projectSkillsDirs: string[]; - userSkillsDirs: string[]; - detect: string[]; - status: "verified" | "documented" | "community" | "experimental"; - notes?: string[]; -} - -export interface HostSpec { - schemaVersion: 1; - hosts: Host[]; -} - -export interface BaseOptions { - home?: string; - cwd?: string; - hostsFile?: string; -} - -export interface SkillFile { - path: string; - contents: string | Uint8Array; - mode?: number; -} - -export type SkillBundle = - | { kind: "directory"; path: string } - | { kind: "files"; files: SkillFile[] } - | { kind: "github"; options: GitHubBundleOptions } - | { - kind: "metadata"; - bundle: SkillBundle; - metadata: BundledSkillMetadata; - }; - -export interface BundledSkillMetadata { - sourceId?: string; - cliVersion?: string; - cliRevision?: string; - provenance?: Record; -} - -export interface GitHubBundleOptions { - owner: string; - repo: string; - path: string; - ref: string; -} - -export interface InstallOptions extends BaseOptions { - appId: string; - skillBundle: SkillBundle; - scope: Scope; - agents?: AgentSelector; - force?: boolean; -} - -export interface UninstallOptions extends BaseOptions { - appId: string; - skillName: string; - scope: Scope; - agents?: AgentSelector; -} - -export interface StatusOptions extends UninstallOptions {} - -export interface InstallSelectionOptions extends BaseOptions { - scope: Scope; - agents?: AgentSelector; - yes?: boolean; - stdinTTY?: boolean; - currentAgent?: string; -} - -export interface InstallWorkflowOptions extends InstallOptions { - yes?: boolean; - dryRun?: boolean; - stdinTTY?: boolean; - currentAgent?: string; - defaultScope?: Scope; - scopeSet?: boolean; - promptScope?: boolean; - input?: AsyncIterable; - output?: { write(chunk: string): unknown }; -} - -export interface InstallFlagValues { - scope?: string; - scopeSet?: boolean; - agents?: string[]; - yes?: boolean; - dryRun?: boolean; - force?: boolean; -} - -export interface InstallFlagError { - flag: string; - reason: string; - value?: string; -} - -export interface ParsedInstallFlags { - scope: Scope; - scopeSet: boolean; - agents: AgentSelector; - yes: boolean; - dryRun: boolean; - force: boolean; - errors: InstallFlagError[]; -} - -export interface TargetGroup { - hostIds: string[]; - skillName: string; - targetDir: string; -} - -export type TargetResult = - | { - hostId: string; - skillName: string; - targetDir: string; - } - | { - hostIds: string[]; - skillName: string; - targetDir: string; - }; - -export type SkipReason = "unchanged" | "missing"; -export type ConflictReason = "unmanaged" | "owner-mismatch"; -export type TargetConflict = TargetResult & { reason: ConflictReason }; -export type TargetSkip = TargetResult & { reason: SkipReason }; -export type UnknownHostError = { agent: string; reason: "unknown-host" }; -export type UnsupportedScopeError = { - hostId: string; - skillName: string; - scope: Scope; - reason: "unsupported-scope"; -}; -export type SkillError = { reason: SkillInfo["errorCode"] }; -export type BundleError = { - reason: "bundle-resolve-failed"; -}; -export type InvalidSkillNameError = { - skillName: string; - reason: "invalid-skill-name"; -}; -export type InvalidAppIdError = { reason: "invalid-app-id" }; -export type TargetError = - | UnknownHostError - | UnsupportedScopeError - | SkillError - | BundleError - | InvalidSkillNameError - | InvalidAppIdError; - -const skillNamePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; - -export interface InstallReport { - installed: TargetResult[]; - updated: TargetResult[]; - skipped: TargetSkip[]; - conflicts: TargetConflict[]; - errors: TargetError[]; -} - -export interface UninstallReport { - removed: TargetResult[]; - skipped: TargetSkip[]; - conflicts: TargetConflict[]; - errors: TargetError[]; -} - -export interface InstalledMetadata { - schemaVersion: 1; - appId: string; - skillName: string; - source: "bundled" | "github"; - hash: string; - sourceId?: string; - version?: string; - cliVersion?: string; - cliRevision?: string; - provenance?: Record; -} - -export type InstalledTarget = TargetResult & { - metadata: InstalledMetadata; -}; - -export interface StatusReport { - installed: InstalledTarget[]; - missing: TargetResult[]; - conflicts: TargetConflict[]; - errors: TargetError[]; -} - -export type InstallSelectionAction = "install" | "select-agents" | "error"; - -export interface InstallSelection { - action: InstallSelectionAction; - selectedHostIds: string[]; - candidateHostIds: string[]; - detectedHostIds: string[]; - needsConfirmation: boolean; - errors: Array<{ reason: string; agent?: string }>; -} - -export interface InstallWorkflowReport { - selection: InstallSelection; - scope: Scope | ""; - plan: InstallReport; - report: InstallReport; - canceled: boolean; - dryRun: boolean; -} - -export type InstallWorkflowExitCode = - "ok" | "canceled" | "selection-error" | "conflict" | "error"; - -export interface InstallWorkflowExit { - ok: boolean; - code: InstallWorkflowExitCode; - message: string; -} - -export interface SkillInfo { - valid: boolean; - skillName?: string; - description?: string; - errorCode?: - "missing-skill-md" | "invalid-frontmatter" | "invalid-skill-bundle"; -} - -type InstallMetadata = InstalledMetadata; - -const defaultAgents: AgentSelector = "auto"; - -interface BundleFile { - path: string; - bytes: Uint8Array; - mode: number; -} - -interface NormalizedSkillBundle { - files: BundleFile[]; - byPath: Map; -} - -interface ResolvedBundleMetadata { - source: InstallMetadata["source"]; - sourceId?: string; - version?: string; - cliVersion?: string; - cliRevision?: string; - provenance?: Record; - explicit?: boolean; -} - -export function directoryBundle(path: string): SkillBundle { - return { kind: "directory", path }; -} - -export function filesBundle(files: SkillFile[]): SkillBundle { - return { kind: "files", files }; -} - -export async function moduleDirBundle( - importMetaUrl: string | URL, - relativePath: string, -): Promise { - const root = fileURLToPath(new URL(relativePath, importMetaUrl)); - return filesBundle(await readDirectoryBundleFiles(root)); -} - -export function githubBundle(options: GitHubBundleOptions): SkillBundle { - return { kind: "github", options }; -} - -export function withBundleMetadata( - bundle: SkillBundle, - metadata: BundledSkillMetadata, -): SkillBundle { - return { kind: "metadata", bundle, metadata }; -} - -export function parseInstallFlags( - flags: InstallFlagValues, -): ParsedInstallFlags { - const errors: InstallFlagError[] = []; - const scope = parseScopeFlag(flags.scope, errors); - const agents = agentSelectorFromFlags(flags.agents ?? [], errors); - return { - scope, - scopeSet: flags.scopeSet ?? flags.scope !== undefined, - agents, - yes: Boolean(flags.yes), - dryRun: Boolean(flags.dryRun), - force: Boolean(flags.force), - errors, - }; -} - -export function agentSelectorFromFlags( - values: string[], - errors: InstallFlagError[] = [], -): AgentSelector { - const agents = splitFlagValues(values); - if (agents.length === 0) return "auto"; - if (agents.includes("*")) { - if (agents.length > 1) { - errors.push({ - flag: "agent", - reason: "agent-star-must-be-alone", - value: agents.join(","), - }); - } - return "*"; - } - return [...new Set(agents)]; -} - -export function parseScopeFlag( - value: string | undefined, - errors: InstallFlagError[] = [], -): Scope { - if (!value || value === "user") return "user"; - if (value === "project") return "project"; - errors.push({ flag: "scope", reason: "invalid-scope", value }); - return "user"; -} - -function splitFlagValues(values: string[]) { - return values - .flatMap((value) => value.split(/[,\s]+/)) - .map((value) => value.trim()) - .filter(Boolean); -} - -export function classifyInstallWorkflowExit( - workflow: InstallWorkflowReport, -): InstallWorkflowExit { - if (workflow.canceled) { - return { ok: false, code: "canceled", message: installUxText.canceled }; - } - if (workflow.selection.errors.length > 0) { - return { - ok: false, - code: "selection-error", - message: installUxText.selectionError, - }; - } - if (workflow.report.conflicts.length > 0) { - return { ok: false, code: "conflict", message: installUxText.conflict }; - } - if (workflow.report.errors.length > 0) { - return { ok: false, code: "error", message: installUxText.failed }; - } - return { ok: true, code: "ok", message: "" }; -} - -export function installWorkflowError( - workflow: InstallWorkflowReport, -): Error | undefined { - const exit = classifyInstallWorkflowExit(workflow); - return exit.ok || exit.code === "canceled" - ? undefined - : new Error(exit.message); -} - -export function installFlagError( - errors: InstallFlagError[], -): Error | undefined { - return errors.length === 0 - ? undefined - : new Error(installUxText.invalidFlags); -} - -export async function loadHostSpec(hostsFile?: string): Promise { - const spec = JSON.parse( - hostsFile ? await readFile(hostsFile, "utf8") : defaultHostsSpecJson, - ) as HostSpec; - validateHostSpec(spec); - return spec; -} - -function validateHostSpec(spec: HostSpec) { - for (const host of spec.hosts ?? []) { - const installDirs = new Set([ - ...(host.projectSkillsDirs ?? []), - ...(host.userSkillsDirs ?? []), - ]); - for (const path of host.projectSkillsDirs ?? []) { - if (!isProjectHostPath(path)) { - throw new Error( - `invalid project path ${JSON.stringify(path)} for host ${host.id}`, - ); - } - } - for (const path of host.userSkillsDirs ?? []) { - if (!isHomeHostPath(path)) { - throw new Error( - `invalid user path ${JSON.stringify(path)} for host ${host.id}`, - ); - } - } - for (const path of host.detect ?? []) { - if (!isHomeHostPath(path) && !isProjectHostPath(path)) { - throw new Error( - `invalid detect path ${JSON.stringify(path)} for host ${host.id}`, - ); - } - if (!isGenericDetectPath(path) && installDirs.has(path)) { - throw new Error( - `detect path is an install target for host ${host.id}: ${JSON.stringify(path)}`, - ); - } - } - } -} - -function isProjectHostPath(path: string) { - return ( - !!path && - !path.startsWith("/") && - !path.startsWith("~") && - isSafeHostPath(path) - ); -} - -function isHomeHostPath(path: string) { - return path.startsWith("~/") && isSafeHostPath(path.slice(2)); -} - -function isSafeHostPath(path: string) { - return ( - !path.includes("\0") && - !path.includes("\\") && - !path.includes(":") && - !path - .split("/") - .some((segment) => segment === "." || segment === ".." || segment === "") - ); -} - -function isValidSkillName(skillName: string) { - return skillNamePattern.test(skillName); -} - -export async function resolveHosts(options: { - agents: AgentSelector; - hostsFile?: string; - hosts?: Host[]; -}): Promise<{ hosts: Host[]; errors: UnknownHostError[] }> { - const hosts = options.hosts ?? (await loadHostSpec(options.hostsFile)).hosts; - if (options.agents === "*") return { hosts, errors: [] }; - if (options.agents === "auto") return { hosts: [], errors: [] }; - - const byName = new Map(); - for (const host of hosts) { - byName.set(host.id, host); - for (const alias of host.aliases ?? []) byName.set(alias, host); - } - - const resolvedHosts: Host[] = []; - const errors: UnknownHostError[] = []; - const seen = new Set(); - for (const agent of options.agents) { - const host = byName.get(agent); - if (!host) { - errors.push({ agent, reason: "unknown-host" }); - } else if (!seen.has(host.id)) { - seen.add(host.id); - resolvedHosts.push(host); - } - } - return { hosts: resolvedHosts, errors }; -} - -export async function detectHosts( - options: BaseOptions & { scope?: Scope } = {}, -): Promise { - const spec = await loadHostSpec(options.hostsFile); - const home = options.home ?? homedir(); - const cwd = options.cwd ?? process.cwd(); - const detected: Host[] = []; - - for (const host of spec.hosts) { - for (const detectPath of host.detect) { - if (isGenericDetectPath(detectPath)) continue; - if (await exists(expandHostPath(detectPath, home, cwd))) { - detected.push(host); - break; - } - } - } - - const scope = options.scope; - if (!scope) return detected; - return detected.sort((a, b) => { - const aPath = canonicalScopePath(a, scope, home, cwd) ?? ""; - const bPath = canonicalScopePath(b, scope, home, cwd) ?? ""; - return aPath.localeCompare(bPath) || a.id.localeCompare(b.id); - }); -} - -export async function resolveInstallSelection( - options: InstallSelectionOptions, -): Promise { - const hosts = (await loadHostSpec(options.hostsFile)).hosts; - const stdinTTY = options.stdinTTY ?? Boolean(process.stdin.isTTY); - const explicitAgents = - options.agents !== undefined && options.agents !== "auto"; - - if (options.currentAgent && !explicitAgents) { - const { hosts: selected, errors } = await resolveHosts({ - agents: [options.currentAgent], - hosts, - }); - const withUniversal = addUniversalHost(selected, hosts); - return installSelection( - withUniversal.map((host) => host.id), - [], - !options.yes && stdinTTY, - errors.map((error) => ({ ...error })), - ); - } - - if (explicitAgents) { - if (options.agents === "*") { - return installSelection( - hosts.map((host) => host.id), - [], - !options.yes && stdinTTY, - ); - } - const resolved = await resolveHosts({ agents: options.agents!, hosts }); - if (resolved.errors.length > 0) { - return errorSelection( - resolved.errors.map((error) => ({ - reason: error.reason, - agent: error.agent, - })), - ); - } - return installSelection( - resolved.hosts.map((host) => host.id), - [], - !options.yes && stdinTTY, - ); - } - - const detected = await detectHosts({ ...options, scope: options.scope }); - const detectedHostIds = detected.map((host) => host.id); - - if (!stdinTTY && !options.yes) { - return errorSelection( - [{ reason: "agent-selection-required" }], - detectedHostIds, - ); - } - - if (options.yes) { - if (detected.length === 0) { - return errorSelection([{ reason: "no-detected-hosts" }], detectedHostIds); - } - return installSelection(detectedHostIds, detectedHostIds, false); - } - - if (detected.length === 0) { - return selectAgentsSelection( - hosts.map((host) => host.id), - detectedHostIds, - [], - ); - } - if (detected.length === 1) { - return installSelection(detectedHostIds, detectedHostIds, true); - } - return selectAgentsSelection(detectedHostIds, detectedHostIds, []); -} - -export async function runBundledSkillInstall( - options: InstallWorkflowOptions, -): Promise { - const stdinTTY = options.stdinTTY ?? Boolean(process.stdin.isTTY); - const output = options.output ?? process.stdout; - const input = - options.input ?? - (process.stdin as unknown as AsyncIterable); - const reader = new LineReader(input); - const scopeResult = await resolveWorkflowScope( - reader, - output, - options.scope, - options.scopeSet ?? options.scope !== undefined, - Boolean(options.promptScope), - options.defaultScope, - Boolean(options.yes), - stdinTTY, - ); - if (scopeResult.selection) { - renderSelectionErrors(output, scopeResult.selection); - return { - selection: scopeResult.selection, - scope: scopeResult.scope, - plan: emptyInstallReport(), - report: emptyInstallReport(), - canceled: false, - dryRun: Boolean(options.dryRun), - }; - } - const scope = scopeResult.scope as Scope; - let selection = await resolveInstallSelection({ - ...options, - scope, - stdinTTY, - yes: options.yes, - currentAgent: options.currentAgent, - }); - - if (selection.action === "error") { - renderSelectionErrors(output, selection); - return { - selection, - scope, - plan: emptyInstallReport(), - report: emptyInstallReport(), - canceled: false, - dryRun: Boolean(options.dryRun), - }; - } - - if (selection.action === "select-agents") { - const hosts = (await loadHostSpec(options.hostsFile)).hosts; - const selectedHostIds = await promptAgentSelection( - reader, - output, - selection, - hosts, - ); - selection = installSelection( - selectedHostIds, - selection.detectedHostIds, - !options.yes && stdinTTY, - ); - if (selectedHostIds.length === 0) { - return { - selection, - scope, - plan: emptyInstallReport(), - report: emptyInstallReport(), - canceled: true, - dryRun: Boolean(options.dryRun), - }; - } - } - - const installOptions: InstallOptions = { - ...options, - scope, - agents: selection.selectedHostIds, - }; - const plan = await planBundledSkill(installOptions); - if ( - plan.installed.length + - plan.updated.length + - plan.conflicts.length + - plan.errors.length === - 0 - ) { - return { - selection, - scope, - plan, - report: plan, - canceled: false, - dryRun: Boolean(options.dryRun), - }; - } - if (options.dryRun) { - renderInstallSummary(output, plan); - return { - selection, - scope, - plan, - report: plan, - canceled: false, - dryRun: true, - }; - } - - if (plan.conflicts.length + plan.errors.length > 0) { - return { - selection, - scope, - plan, - report: { ...plan, installed: [], updated: [] }, - canceled: false, - dryRun: false, - }; - } - renderInstallSummary(output, plan); - - if (selection.needsConfirmation) { - const confirmed = await promptConfirmation(reader, output); - if (!confirmed) { - return { - selection, - scope, - plan, - report: emptyInstallReport(), - canceled: true, - dryRun: false, - }; - } - } - - const report = await installBundledSkill(installOptions); - return { selection, scope, plan, report, canceled: false, dryRun: false }; -} - -export async function resolveInstallTargets( - options: BaseOptions & { - agents?: AgentSelector; - scope: Scope; - skillName: string; - }, -): Promise<{ - targets: TargetGroup[]; - errors: TargetError[]; - detectedHostIds: string[]; -}> { - return resolveInstallTargetsForLifecycle(options); -} - -async function resolveInstallTargetsForLifecycle( - options: BaseOptions & { - agents?: AgentSelector; - scope: Scope; - skillName: string; - }, - uninstallAppId?: string, -): Promise<{ - targets: TargetGroup[]; - errors: TargetError[]; - detectedHostIds: string[]; -}> { - if (!isValidSkillName(options.skillName)) { - return { - targets: [], - errors: [{ skillName: options.skillName, reason: "invalid-skill-name" }], - detectedHostIds: [], - }; - } - - const spec = await loadHostSpec(options.hostsFile); - const home = options.home ?? homedir(); - const cwd = options.cwd ?? process.cwd(); - const agents = options.agents ?? defaultAgents; - const resolved = - agents === "auto" - ? undefined - : await resolveHosts({ agents, hosts: spec.hosts }); - const selected = - agents === "auto" - ? await detectHosts({ ...options, scope: options.scope }) - : resolved!.hosts; - const errors: TargetError[] = agents === "auto" ? [] : [...resolved!.errors]; - const byTarget = new Map(); - - for (const host of selected) { - const roots = uninstallAppId - ? await uninstallScopePaths( - host, - options.scope, - home, - cwd, - options.skillName, - uninstallAppId, - ) - : [ - await chooseScopePath( - host, - options.scope, - home, - cwd, - options.skillName, - ), - ].filter((root): root is string => Boolean(root)); - if (roots.length === 0) { - errors.push({ - hostId: host.id, - skillName: options.skillName, - scope: options.scope, - reason: "unsupported-scope", - }); - continue; - } - for (const root of roots) { - const targetDir = join(root, options.skillName); - const group = byTarget.get(targetDir) ?? { - hostIds: [], - skillName: options.skillName, - targetDir, - }; - if (!group.hostIds.includes(host.id)) group.hostIds.push(host.id); - byTarget.set(targetDir, group); - } - } - - const targets = [...byTarget.values()].sort((a, b) => - a.targetDir.localeCompare(b.targetDir), - ); - return { - targets, - errors, - detectedHostIds: targets.flatMap((target) => target.hostIds), - }; -} - -function addUniversalHost(selected: Host[], hosts: Host[]) { - const result = [...selected]; - const universal = hosts.find((host) => host.id === "universal"); - if (universal && !result.some((host) => host.id === universal.id)) { - result.push(universal); - } - return result; -} - -function installSelection( - selectedHostIds: string[], - detectedHostIds: string[] = [], - needsConfirmation: boolean, - errors: InstallSelection["errors"] = [], -): InstallSelection { - return { - action: errors.length > 0 ? "error" : "install", - selectedHostIds, - candidateHostIds: [], - detectedHostIds, - needsConfirmation: errors.length > 0 ? false : needsConfirmation, - errors, - }; -} - -function selectAgentsSelection( - candidateHostIds: string[], - detectedHostIds: string[], - selectedHostIds: string[], -): InstallSelection { - return { - action: "select-agents", - selectedHostIds, - candidateHostIds, - detectedHostIds, - needsConfirmation: true, - errors: [], - }; -} - -function errorSelection( - errors: InstallSelection["errors"], - detectedHostIds: string[] = [], -): InstallSelection { - return { - action: "error", - selectedHostIds: [], - candidateHostIds: [], - detectedHostIds, - needsConfirmation: false, - errors, - }; -} - -export async function validateSkillBundle( - bundle: SkillBundle, - cwd = process.cwd(), -): Promise { - try { - return validateNormalizedSkill(await readSkillBundle(bundle, cwd)); - } catch { - return { valid: false, errorCode: "invalid-skill-bundle" }; - } -} - -function validateNormalizedSkill(bundle: NormalizedSkillBundle): SkillInfo { - const skillFile = bundle.byPath.get("SKILL.md"); - if (!skillFile) return { valid: false, errorCode: "missing-skill-md" }; - const content = Buffer.from(skillFile.bytes).toString("utf8"); - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); - if (!match) return { valid: false, errorCode: "invalid-frontmatter" }; - const frontmatter = parseFrontmatter(match[1]); - const name = frontmatter.get("name") ?? ""; - const description = frontmatter.get("description") ?? ""; - if (!isValidSkillName(name)) { - return { valid: false, errorCode: "invalid-frontmatter" }; - } - if (description.length < 1 || description.length > 1024) { - return { valid: false, errorCode: "invalid-frontmatter" }; - } - return { valid: true, skillName: name, description }; -} - -export async function computeBundleContentHash( - bundle: SkillBundle, - cwd = process.cwd(), -): Promise { - return contentHash(await readSkillBundle(bundle, cwd)); -} - -function contentHash(bundle: NormalizedSkillBundle) { - const hash = createHash("sha256"); - for (const file of bundle.files) { - const bytes = file.bytes; - hash.update(file.path); - hash.update("\0"); - hash.update(bytes); - hash.update("\0"); - } - return `sha256:${hash.digest("hex")}`; -} - -async function readSkillBundle( - bundle: SkillBundle, - cwd = process.cwd(), -): Promise { - return (await resolveSkillBundle(bundle, cwd)).bundle; -} - -async function resolveSkillBundle( - bundle: SkillBundle, - cwd = process.cwd(), -): Promise<{ - bundle: NormalizedSkillBundle; - metadata: ResolvedBundleMetadata; -}> { - if (bundle.kind === "metadata") { - const resolved = await resolveSkillBundle(bundle.bundle, cwd); - return { - bundle: resolved.bundle, - metadata: mergeBundleMetadata(resolved.metadata, bundle.metadata), - }; - } - if (bundle.kind === "directory") { - const dir = resolve(cwd, bundle.path); - return { - bundle: normalizeSkillFiles(await readDirectoryBundleFiles(dir)), - metadata: { source: "bundled" }, - }; - } - if (bundle.kind === "files") { - return { - bundle: normalizeSkillFiles(bundle.files), - metadata: { source: "bundled" }, - }; - } - return resolveGitHubBundle(bundle.options); -} - -async function resolveGitHubBundle(options: GitHubBundleOptions): Promise<{ - bundle: NormalizedSkillBundle; - metadata: ResolvedBundleMetadata; -}> { - const root = trimGitHubPath(options.path); - if (!options.owner || !options.repo || !root || !options.ref) { - throw new Error("invalid github bundle"); - } - const apiBase = envBaseUrl( - "KITUP_GITHUB_API_BASE_URL", - "https://api.github.com", - ); - const rawBase = envBaseUrl( - "KITUP_GITHUB_RAW_BASE_URL", - "https://raw.githubusercontent.com", - ); - const commit: any = await getJson( - `${apiBase}/repos/${encodePathPart(options.owner)}/${encodePathPart(options.repo)}/commits/${encodePathPart(options.ref)}`, - ); - const resolvedCommit = String(commit.sha ?? ""); - const treeSha = String(commit.commit?.tree?.sha ?? ""); - if (!resolvedCommit || !treeSha) throw new Error("invalid github commit"); - - const tree: any = await getJson( - `${apiBase}/repos/${encodePathPart(options.owner)}/${encodePathPart(options.repo)}/git/trees/${encodePathPart(treeSha)}?recursive=1`, - ); - const files: SkillFile[] = []; - const prefix = `${root}/`; - for (const item of tree.tree ?? []) { - const path = String(item.path ?? ""); - if (item.type !== "blob" || !path.startsWith(prefix)) continue; - const relativePath = path.slice(prefix.length); - const url = `${rawBase}/${encodePathPart(options.owner)}/${encodePathPart(options.repo)}/${encodePathPart(resolvedCommit)}/${encodePath(path)}`; - files.push({ - path: relativePath, - contents: await getBytes(url), - mode: item.mode === "100755" ? 0o755 : 0o644, - }); - } - if (files.length === 0) throw new Error("github bundle path not found"); - const bundle = normalizeSkillFiles(files); - return { - bundle, - metadata: { - source: "github", - sourceId: `github:${options.owner}/${options.repo}/${root}`, - version: options.ref, - provenance: { - owner: options.owner, - repo: options.repo, - path: root, - ref: options.ref, - resolvedCommit, - }, - }, - }; -} - -function mergeBundleMetadata( - resolved: ResolvedBundleMetadata, - supplied: BundledSkillMetadata, -): ResolvedBundleMetadata { - return { - ...resolved, - sourceId: supplied.sourceId || resolved.sourceId, - cliVersion: supplied.cliVersion, - cliRevision: supplied.cliRevision, - provenance: - resolved.provenance || supplied.provenance - ? { ...resolved.provenance, ...supplied.provenance } - : undefined, - explicit: true, - }; -} - -function isGitHubBundle(bundle: SkillBundle): boolean { - return bundle.kind === "github" - ? true - : bundle.kind === "metadata" && isGitHubBundle(bundle.bundle); -} - -function envBaseUrl(name: string, fallback: string) { - return (process.env[name] ?? fallback).replace(/\/+$/, ""); -} - -async function getJson(url: string) { - const response = await fetch(url, { - headers: { "User-Agent": "kitup" }, - signal: AbortSignal.timeout(30_000), - }); - if (!response.ok) throw new Error(`github request failed: ${url}`); - return response.json(); -} - -async function getBytes(url: string) { - const response = await fetch(url, { - headers: { "User-Agent": "kitup" }, - signal: AbortSignal.timeout(30_000), - }); - if (!response.ok) throw new Error(`github download failed: ${url}`); - return new Uint8Array(await response.arrayBuffer()); -} - -function trimGitHubPath(path: string) { - return path.replace(/^\/+|\/+$/g, ""); -} - -function encodePath(path: string) { - return path.split("/").map(encodePathPart).join("/"); -} - -function encodePathPart(part: string) { - return encodeURIComponent(part); -} - -async function readDirectoryBundleFiles( - dir: string, - base = dir, -): Promise { - const files: SkillFile[] = []; - for (const entry of await readdir(dir, { withFileTypes: true })) { - if (skipName(entry.name)) continue; - const full = join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...(await readDirectoryBundleFiles(full, base))); - } else if (entry.isFile()) { - files.push({ - path: relative(base, full).split(sep).join("/"), - contents: await readFile(full), - mode: (await stat(full)).mode & 0o777, - }); - } - } - return files; -} - -function normalizeSkillFiles(files: SkillFile[]): NormalizedSkillBundle { - const byPath = new Map(); - for (const file of files) { - const normalizedPath = normalizeBundlePath(file.path); - if (!normalizedPath) continue; - if (byPath.has(normalizedPath)) { - throw new Error(`duplicate skill file: ${normalizedPath}`); - } - byPath.set(normalizedPath, { - path: normalizedPath, - bytes: - typeof file.contents === "string" - ? Buffer.from(file.contents) - : file.contents, - mode: bundleFileMode(normalizedPath, file.mode), - }); - } - const normalizedFiles = [...byPath.values()].sort((a, b) => - a.path.localeCompare(b.path), - ); - return { files: normalizedFiles, byPath }; -} - -function bundleFileMode(path: string, mode: number | undefined) { - if (mode !== undefined) return mode; - return path.startsWith("scripts/") ? 0o755 : 0o644; -} - -function normalizeBundlePath(path: string) { - if (!path || path.includes("\\") || path.startsWith("/")) { - throw new Error(`invalid skill file path: ${path}`); - } - if (/^[A-Za-z]:/.test(path)) { - throw new Error(`invalid skill file path: ${path}`); - } - const parts = path.split("/"); - if (parts.some((part) => !part || part === "." || part === "..")) { - throw new Error(`invalid skill file path: ${path}`); - } - if (parts.some(skipName)) return undefined; - return parts.join("/"); -} - -export async function installBundledSkill( - options: InstallOptions, -): Promise { - return installOrPlan(options, true); -} - -export async function planBundledSkill( - options: InstallOptions, -): Promise { - return installOrPlan(options, false); -} - -async function installOrPlan( - options: InstallOptions, - write: boolean, -): Promise { - if (!options.appId) return emptyInstallReport([{ reason: "invalid-app-id" }]); - - const cwd = options.cwd ?? process.cwd(); - let bundle: NormalizedSkillBundle; - let bundleMetadata: ResolvedBundleMetadata; - try { - ({ bundle, metadata: bundleMetadata } = await resolveSkillBundle( - options.skillBundle, - cwd, - )); - } catch { - return emptyInstallReport([ - { - reason: isGitHubBundle(options.skillBundle) - ? "bundle-resolve-failed" - : "invalid-skill-bundle", - }, - ]); - } - const skill = validateNormalizedSkill(bundle); - if (!skill.valid || !skill.skillName) { - return emptyInstallReport([{ reason: skill.errorCode }]); - } - - const hash = contentHash(bundle); - const { targets, errors } = await resolveInstallTargets({ - ...options, - skillName: skill.skillName, - }); - const report = emptyInstallReport(errors); - - for (const target of targets) { - const result = targetResult(target); - const metadata = await readMetadata(target.targetDir); - if (!metadata.exists) { - if (write) { - await copyManagedSkill( - bundle, - target.targetDir, - options.appId, - skill.skillName, - hash, - bundleMetadata, - ); - } - report.installed.push(result); - } else if ( - !metadata.value || - metadata.value.skillName !== skill.skillName - ) { - if (options.force) { - if (write) { - await replaceManagedSkill( - bundle, - target.targetDir, - options.appId, - skill.skillName, - hash, - bundleMetadata, - ); - } - report.updated.push(result); - continue; - } - report.conflicts.push({ ...result, reason: "unmanaged" }); - } else if (metadata.value.appId !== options.appId) { - if (options.force) { - if (write) { - await replaceManagedSkill( - bundle, - target.targetDir, - options.appId, - skill.skillName, - hash, - bundleMetadata, - ); - } - report.updated.push(result); - continue; - } - report.conflicts.push({ ...result, reason: "owner-mismatch" }); - } else if (metadata.value.hash === hash) { - const repaired = await repairSkillBundleModes( - bundle, - target.targetDir, - write, - ); - const metadataChanged = - Boolean(bundleMetadata.explicit) && - !installedMetadataEqual( - metadata.value, - installedMetadata( - options.appId, - skill.skillName, - hash, - bundleMetadata, - ), - ); - if (repaired || metadataChanged) { - if (write) - await writeMetadata( - target.targetDir, - options.appId, - skill.skillName, - hash, - bundleMetadata, - ); - report.updated.push(result); - } else { - report.skipped.push({ ...result, reason: "unchanged" }); - } - } else { - if (write) { - await replaceManagedSkill( - bundle, - target.targetDir, - options.appId, - skill.skillName, - hash, - bundleMetadata, - ); - } - report.updated.push(result); - } - } - - return report; -} - -export async function updateBundledSkill( - options: InstallOptions, -): Promise { - return installBundledSkill(options); -} - -export async function uninstallBundledSkill( - options: UninstallOptions, -): Promise { - if (!options.appId) { - return { - removed: [], - skipped: [], - conflicts: [], - errors: [{ reason: "invalid-app-id" }], - }; - } - - const { targets, errors } = await resolveInstallTargetsForLifecycle( - { ...options, skillName: options.skillName }, - options.appId, - ); - const report: UninstallReport = { - removed: [], - skipped: [], - conflicts: [], - errors, - }; - - for (const target of targets) { - const result = targetResult(target); - const metadata = await readMetadata(target.targetDir); - if (!metadata.exists) { - report.skipped.push({ ...result, reason: "missing" }); - } else if ( - !metadata.value || - metadata.value.skillName !== options.skillName - ) { - report.conflicts.push({ ...result, reason: "unmanaged" }); - } else if (metadata.value.appId !== options.appId) { - report.conflicts.push({ ...result, reason: "owner-mismatch" }); - } else { - const reason = await removeManagedSkill( - target.targetDir, - options.appId, - options.skillName, - ); - if (reason) { - report.conflicts.push({ ...result, reason }); - continue; - } - report.removed.push(result); - } - } - - return report; -} - -export async function statusBundledSkill( - options: StatusOptions, -): Promise { - if (!options.appId) { - return emptyStatusReport([{ reason: "invalid-app-id" }]); - } - const { targets, errors } = await resolveInstallTargetsForLifecycle( - options, - options.appId, - ); - const report = emptyStatusReport(errors); - for (const target of targets) { - const result = targetResult(target); - const metadata = await readMetadata(target.targetDir); - if (!metadata.exists) { - report.missing.push(result); - } else if ( - !metadata.value || - metadata.value.skillName !== options.skillName - ) { - report.conflicts.push({ ...result, reason: "unmanaged" }); - } else if (metadata.value.appId !== options.appId) { - report.conflicts.push({ ...result, reason: "owner-mismatch" }); - } else { - report.installed.push({ ...result, metadata: metadata.value }); - } - } - return report; -} - -export async function readInstalledMetadata( - targetDir: string, -): Promise { - const metadata = await readMetadata(targetDir); - if (!metadata.exists) return undefined; - if (!metadata.value) throw new Error("unmanaged install metadata"); - return metadata.value; -} - -async function copyManagedSkill( - bundle: NormalizedSkillBundle, - targetDir: string, - appId: string, - skillName: string, - hash: string, - metadata: ResolvedBundleMetadata, -) { - const tmp = await makeStagingDir(targetDir); - try { - await copySkillBundle(bundle, tmp); - await writeMetadata(tmp, appId, skillName, hash, metadata); - await rename(tmp, targetDir); - } catch (error) { - await rm(tmp, { recursive: true, force: true }); - throw error; - } -} - -async function removeManagedSkill( - targetDir: string, - appId: string, - skillName: string, -): Promise { - const quarantine = await mkdtemp( - join(dirname(targetDir), `.${basename(targetDir)}.kitup-uninstall-`), - ); - await rm(quarantine, { recursive: true }); - await rename(targetDir, quarantine); - const metadata = await readMetadata(quarantine); - const reason = - !metadata.value || metadata.value.skillName !== skillName - ? "unmanaged" - : metadata.value.appId !== appId - ? "owner-mismatch" - : undefined; - if (reason) { - if (await exists(targetDir)) { - throw new Error(`cannot restore changed install: ${targetDir}`); - } - await rename(quarantine, targetDir); - return reason; - } - await rm(quarantine, { recursive: true }); - return undefined; -} - -async function replaceManagedSkill( - bundle: NormalizedSkillBundle, - targetDir: string, - appId: string, - skillName: string, - hash: string, - metadata: ResolvedBundleMetadata, -) { - const tmp = await makeStagingDir(targetDir); - const backup = `${tmp}-backup`; - try { - await copySkillBundle(bundle, tmp); - await writeMetadata(tmp, appId, skillName, hash, metadata); - await rename(targetDir, backup); - await rename(tmp, targetDir); - await rm(backup, { recursive: true, force: true }); - } catch (error) { - await rm(tmp, { recursive: true, force: true }); - if ((await exists(backup)) && !(await exists(targetDir))) - await rename(backup, targetDir); - throw error; - } -} - -async function makeStagingDir(targetDir: string) { - const parent = dirname(targetDir); - await mkdir(parent, { recursive: true }); - const tmp = await mkdtemp(join(parent, `.${basename(targetDir)}.kitup-`)); - try { - await chmod(tmp, 0o755); - return tmp; - } catch (error) { - await rm(tmp, { recursive: true, force: true }); - throw error; - } -} - -async function copySkillBundle(bundle: NormalizedSkillBundle, dest: string) { - await mkdir(dest, { recursive: true }); - for (const file of bundle.files) { - const target = join(dest, file.path); - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, file.bytes, { mode: file.mode }); - await chmod(target, file.mode); - } -} - -async function repairSkillBundleModes( - bundle: NormalizedSkillBundle, - dest: string, - write: boolean, -) { - let repaired = false; - for (const file of bundle.files) { - const target = join(dest, file.path); - let info; - try { - info = await lstat(target); - } catch (error: any) { - if (error.code === "ENOENT") continue; - throw error; - } - if (info.isFile() && (info.mode & 0o777) !== file.mode) { - repaired = true; - if (write) await chmod(target, file.mode); - } - } - return repaired; -} - -async function writeMetadata( - targetDir: string, - appId: string, - skillName: string, - hash: string, - metadata: ResolvedBundleMetadata, -) { - const value = installedMetadata(appId, skillName, hash, metadata); - await writeFile( - join(targetDir, ".kitup.json"), - `${JSON.stringify(value, null, 2)}\n`, - ); -} - -async function readMetadata( - targetDir: string, -): Promise<{ exists: boolean; value?: InstallMetadata }> { - if (!(await exists(targetDir))) return { exists: false }; - try { - const raw = JSON.parse( - await readFile(join(targetDir, ".kitup.json"), "utf8"), - ); - const value = parseOwnedMetadata(raw); - return value ? { exists: true, value } : { exists: true }; - } catch { - return { exists: true }; - } -} - -function parseOwnedMetadata(raw: unknown): InstallMetadata | undefined { - if (!raw || typeof raw !== "object") return undefined; - const value = raw as Record; - if (value.schemaVersion !== 1) return undefined; - if (typeof value.appId !== "string" || value.appId.length === 0) - return undefined; - if (typeof value.skillName !== "string" || !isValidSkillName(value.skillName)) - return undefined; - if (value.source !== "bundled" && value.source !== "github") return undefined; - if (typeof value.hash !== "string" || value.hash.length === 0) - return undefined; - const metadata: InstallMetadata = { - schemaVersion: 1, - appId: value.appId, - skillName: value.skillName, - source: value.source, - hash: value.hash, - }; - for (const key of ["sourceId", "version", "cliVersion", "cliRevision"]) { - if (key in value && typeof value[key] !== "string") return undefined; - } - if (typeof value.sourceId === "string" && value.sourceId) - metadata.sourceId = value.sourceId; - if (typeof value.version === "string" && value.version) - metadata.version = value.version; - if (typeof value.cliVersion === "string" && value.cliVersion) - metadata.cliVersion = value.cliVersion; - if (typeof value.cliRevision === "string" && value.cliRevision) - metadata.cliRevision = value.cliRevision; - if ("provenance" in value) { - if ( - !value.provenance || - typeof value.provenance !== "object" || - Array.isArray(value.provenance) || - !Object.values(value.provenance).every((item) => typeof item === "string") - ) { - return undefined; - } - if (Object.keys(value.provenance).length > 0) - metadata.provenance = { - ...(value.provenance as Record), - }; - } - return metadata; -} - -function installedMetadata( - appId: string, - skillName: string, - hash: string, - metadata: ResolvedBundleMetadata, -): InstalledMetadata { - const value: InstalledMetadata = { - schemaVersion: 1, - appId, - skillName, - source: metadata.source, - hash, - }; - if (metadata.sourceId) value.sourceId = metadata.sourceId; - if (metadata.version) value.version = metadata.version; - if (metadata.cliVersion) value.cliVersion = metadata.cliVersion; - if (metadata.cliRevision) value.cliRevision = metadata.cliRevision; - if (metadata.provenance) value.provenance = { ...metadata.provenance }; - return value; -} - -function installedMetadataEqual( - left: InstalledMetadata, - right: InstalledMetadata, -) { - return ( - left.schemaVersion === right.schemaVersion && - left.appId === right.appId && - left.skillName === right.skillName && - left.source === right.source && - left.hash === right.hash && - left.sourceId === right.sourceId && - left.version === right.version && - left.cliVersion === right.cliVersion && - left.cliRevision === right.cliRevision && - recordEqual(left.provenance, right.provenance) - ); -} - -function recordEqual( - left: Record | undefined, - right: Record | undefined, -) { - const leftEntries = Object.entries(left ?? {}).sort(); - const rightEntries = Object.entries(right ?? {}).sort(); - return JSON.stringify(leftEntries) === JSON.stringify(rightEntries); -} - -function targetResult(target: TargetGroup): TargetResult { - const base = { skillName: target.skillName, targetDir: target.targetDir }; - return target.hostIds.length === 1 - ? { hostId: target.hostIds[0], ...base } - : { hostIds: target.hostIds, ...base }; -} - -function emptyInstallReport(errors: TargetError[] = []): InstallReport { - return { installed: [], updated: [], skipped: [], conflicts: [], errors }; -} - -function emptyStatusReport(errors: TargetError[] = []): StatusReport { - return { installed: [], missing: [], conflicts: [], errors }; -} - -class LineReader { - private buffer = ""; - private done = false; - private readonly iterator: AsyncIterator; - - constructor(input: AsyncIterable) { - this.iterator = input[Symbol.asyncIterator](); - } - - async readLine() { - while (true) { - const newline = this.buffer.indexOf("\n"); - if (newline >= 0) { - const line = this.buffer.slice(0, newline).replace(/\r$/, ""); - this.buffer = this.buffer.slice(newline + 1); - return line; - } - if (this.done) { - if (!this.buffer) return undefined; - const line = this.buffer.replace(/\r$/, ""); - this.buffer = ""; - return line; - } - const next = await this.iterator.next(); - if (next.done) { - this.done = true; - } else { - this.buffer += - typeof next.value === "string" - ? next.value - : Buffer.from(next.value).toString("utf8"); - } - } - } -} - -async function resolveWorkflowScope( - reader: LineReader, - output: { write(chunk: string): unknown }, - requested: Scope | undefined, - scopeSet: boolean, - promptScope: boolean, - configuredDefault: Scope | undefined, - yes: boolean, - stdinTTY: boolean, -): Promise<{ scope: Scope | ""; selection?: InstallSelection }> { - const defaultScope = configuredDefault ?? "user"; - const scope = requested ?? defaultScope; - if (scopeSet || !promptScope) return { scope }; - if (yes) return { scope: defaultScope }; - if (!stdinTTY) { - return { - scope: "", - selection: errorSelection([{ reason: "scope-selection-required" }]), - }; - } - return { scope: await promptScopeSelection(reader, output, defaultScope) }; -} - -async function promptScopeSelection( - reader: LineReader, - output: { write(chunk: string): unknown }, - defaultScope: Scope, -) { - while (true) { - writeLine(output, installUxText.selectScope); - writeLine(output, " 1. user"); - writeLine(output, " 2. project"); - output.write(`${installUxText.scopePrompt} [${defaultScope}]: `); - const selected = parseScopeSelection( - (await reader.readLine()) ?? "", - defaultScope, - ); - if (selected) return selected; - writeLine(output, installUxText.invalidScopeSelection); - } -} - -function parseScopeSelection( - line: string, - defaultScope: Scope, -): Scope | undefined { - switch (line.trim().toLowerCase()) { - case "": - return defaultScope; - case "1": - case "u": - case "user": - return "user"; - case "2": - case "p": - case "project": - return "project"; - default: - return undefined; - } -} - -async function promptAgentSelection( - reader: LineReader, - output: { write(chunk: string): unknown }, - selection: InstallSelection, - hosts: Host[], -) { - const candidates = selection.candidateHostIds - .map((id) => hosts.find((host) => host.id === id)) - .filter((host): host is Host => Boolean(host)); - while (true) { - writeLine(output, installUxText.selectAgents); - candidates.forEach((host, index) => { - writeLine(output, ` ${index + 1}. ${host.displayName} (${host.id})`); - }); - const current = selection.selectedHostIds.join(","); - const suffix = current ? ` [${current}]` : ""; - output.write(`${installUxText.agentsPrompt}${suffix}: `); - const line = await reader.readLine(); - const selected = parseAgentSelection(line ?? "", selection, candidates); - if (selected) return selected; - writeLine(output, installUxText.invalidAgentSelection); - } -} - -function parseAgentSelection( - line: string, - selection: InstallSelection, - candidates: Host[], -) { - const trimmed = line.trim(); - if (!trimmed) return selection.selectedHostIds; - if (trimmed === "*") return candidates.map((host) => host.id); - - const byName = new Map(); - candidates.forEach((host, index) => { - byName.set(String(index + 1), host.id); - byName.set(host.id, host.id); - for (const alias of host.aliases ?? []) byName.set(alias, host.id); - }); - - const selected: string[] = []; - const seen = new Set(); - for (const part of trimmed.split(/[,\s]+/)) { - const id = byName.get(part); - if (!id) return undefined; - if (!seen.has(id)) { - seen.add(id); - selected.push(id); - } - } - return selected; -} - -async function promptConfirmation( - reader: LineReader, - output: { write(chunk: string): unknown }, -) { - output.write(installUxText.proceed); - const line = (await reader.readLine()) ?? ""; - return ( - line.trim().toLowerCase() === "y" || line.trim().toLowerCase() === "yes" - ); -} - -function renderInstallSummary( - output: { write(chunk: string): unknown }, - report: InstallReport, -) { - for (const item of [...report.installed, ...report.updated]) { - for (const host of summaryHosts(item)) { - writeLine(output, ` - ${item.skillName} -> ${item.targetDir} (${host})`); - } - } -} - -function summaryHosts(item: TargetResult) { - return "hostId" in item ? [item.hostId] : item.hostIds; -} - -function renderSelectionErrors( - output: { write(chunk: string): unknown }, - selection: InstallSelection, -) { - for (const error of selection.errors) { - writeLine(output, `${installUxText.errorPrefix} ${error.reason}`); - } -} - -function writeLine(output: { write(chunk: string): unknown }, line: string) { - output.write(`${line}\n`); -} - -function canonicalScopePath( - host: Host, - scope: Scope, - home: string, - cwd: string, -) { - const paths = scopePaths(host, scope); - return paths[0] ? expandHostPath(paths[0], home, cwd) : undefined; -} - -async function chooseScopePath( - host: Host, - scope: Scope, - home: string, - cwd: string, - skillName: string, -) { - const paths = scopePaths(host, scope); - const existing: string[] = []; - for (const path of paths) { - const expanded = expandHostPath(path, home, cwd); - if (await isDirectory(expanded)) existing.push(expanded); - } - for (const root of existing) { - const metadata = await readMetadata(join(root, skillName)); - if (metadata.value?.skillName === skillName) { - return root; - } - } - if (existing[0]) return existing[0]; - return paths[0] ? expandHostPath(paths[0], home, cwd) : undefined; -} - -async function uninstallScopePaths( - host: Host, - scope: Scope, - home: string, - cwd: string, - skillName: string, - appId: string, -) { - const owned: string[] = []; - for (const path of scopePaths(host, scope)) { - const root = expandHostPath(path, home, cwd); - const metadata = await readMetadata(join(root, skillName)); - if ( - metadata.value?.skillName === skillName && - metadata.value.appId === appId - ) { - owned.push(root); - } - } - if (owned.length > 0) return owned; - const fallback = await chooseScopePath(host, scope, home, cwd, skillName); - return fallback ? [fallback] : []; -} - -function scopePaths(host: Host, scope: Scope) { - return scope === "user" ? host.userSkillsDirs : host.projectSkillsDirs; -} - -function expandHostPath(path: string, home: string, cwd: string) { - return path.startsWith("~/") ? join(home, path.slice(2)) : join(cwd, path); -} - -function parseFrontmatter(content: string) { - const values = new Map(); - for (const line of content.split(/\r?\n/)) { - const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); - if (match) values.set(match[1], match[2].trim()); - } - return values; -} - -function skipName(name: string) { - return ( - name === ".git" || - name === ".kitup.json" || - name === ".DS_Store" || - name.endsWith(".swp") || - name.endsWith("~") - ); -} - -const GENERIC_DETECT_PATHS = new Set([ - "~/.agents", - "~/.agents/skills", - "~/.config/agents", - ".agents", - ".agents/skills", - "package.json", -]); - -function isGenericDetectPath(path: string) { - return GENERIC_DETECT_PATHS.has(path); -} - -async function exists(path: string) { - try { - await stat(path); - return true; - } catch { - return false; - } -} - -async function isDirectory(path: string) { - try { - return (await stat(path)).isDirectory(); - } catch { - return false; - } -} +export { + directoryBundle, + filesBundle, + moduleDirBundle, + githubBundle, + withBundleMetadata, + validateSkillBundle, + computeBundleContentHash, +} from "./bundle.js"; +export { readInstalledMetadata } from "./storage.js"; +export { + loadHostSpec, + resolveHosts, + detectHosts, + resolveInstallTargets, +} from "./hosts.js"; +export { + installBundledSkill, + planBundledSkill, + updateBundledSkill, + uninstallBundledSkill, + statusBundledSkill, +} from "./installer.js"; +export { + installUxText, + parseInstallFlags, + agentSelectorFromFlags, + parseScopeFlag, + classifyInstallWorkflowExit, + installWorkflowError, + installFlagError, + resolveInstallSelection, + runBundledSkillInstall, +} from "./workflow.js"; +export type * from "./types.js"; diff --git a/ts/src/installer.ts b/ts/src/installer.ts new file mode 100644 index 0000000..456a167 --- /dev/null +++ b/ts/src/installer.ts @@ -0,0 +1,206 @@ +import type { + InstallOptions, + InstallReport, + UninstallOptions, + UninstallReport, + StatusOptions, + StatusReport, + TargetGroup, + TargetResult, + TargetError, +} from "./types.js"; +import { + resolveSkillBundle, + validateNormalizedSkill, + contentHash, + isGitHubBundle, + type NormalizedSkillBundle, + type ResolvedBundleMetadata, +} from "./bundle.js"; +import { + resolveInstallTargets, + resolveInstallTargetsForLifecycle, +} from "./hosts.js"; +import { + readMetadata, + writeManagedSkill, + ownershipConflict, + removeManagedSkill, + repairSkillBundleModes, + installedMetadata, + installedMetadataEqual, + writeMetadata, +} from "./storage.js"; + +export async function installBundledSkill( + options: InstallOptions, +): Promise { + return installOrPlan(options, true); +} + +export async function planBundledSkill( + options: InstallOptions, +): Promise { + return installOrPlan(options, false); +} + +async function installOrPlan( + options: InstallOptions, + write: boolean, +): Promise { + if (!options.appId) return emptyInstallReport([{ reason: "invalid-app-id" }]); + + const cwd = options.cwd ?? process.cwd(); + let bundle: NormalizedSkillBundle; + let bundleMetadata: ResolvedBundleMetadata; + try { + ({ bundle, metadata: bundleMetadata } = await resolveSkillBundle( + options.skillBundle, + cwd, + )); + } catch { + return emptyInstallReport([ + { + reason: isGitHubBundle(options.skillBundle) + ? "bundle-resolve-failed" + : "invalid-skill-bundle", + }, + ]); + } + const skill = validateNormalizedSkill(bundle); + if (!skill.valid || !skill.skillName) { + return emptyInstallReport([{ reason: skill.errorCode }]); + } + + const hash = contentHash(bundle); + const { targets, errors } = await resolveInstallTargets({ + ...options, + skillName: skill.skillName, + }); + const report = emptyInstallReport(errors); + + const expectedMetadata = installedMetadata( + options.appId, + skill.skillName, + hash, + bundleMetadata, + ); + for (const target of targets) { + const result = targetResult(target); + const { exists, value } = await readMetadata(target.targetDir); + const conflict = exists + ? ownershipConflict(value, options.appId, skill.skillName) + : undefined; + if (conflict && !options.force) { + report.conflicts.push({ ...result, reason: conflict }); + continue; + } + if (exists && !conflict && value!.hash === hash) { + const repaired = await repairSkillBundleModes( + bundle, + target.targetDir, + write, + ); + const metadataChanged = + bundleMetadata.explicit && + !installedMetadataEqual(value!, expectedMetadata); + if (!repaired && !metadataChanged) { + report.skipped.push({ ...result, reason: "unchanged" }); + continue; + } + if (write) await writeMetadata(target.targetDir, expectedMetadata); + } else if (write) { + await writeManagedSkill( + bundle, + target.targetDir, + expectedMetadata, + exists, + ); + } + report[exists ? "updated" : "installed"].push(result); + } + + return report; +} + +export async function updateBundledSkill( + options: InstallOptions, +): Promise { + return installBundledSkill(options); +} + +export async function uninstallBundledSkill( + options: UninstallOptions, +): Promise { + const report: UninstallReport = { + removed: [], + skipped: [], + conflicts: [], + errors: [], + }; + for await (const { target, metadata } of ownedTargets(options, report)) { + if (!metadata) { + report.skipped.push({ ...target, reason: "missing" }); + continue; + } + const reason = await removeManagedSkill( + target.targetDir, + options.appId, + options.skillName, + ); + if (reason) report.conflicts.push({ ...target, reason }); + else report.removed.push(target); + } + return report; +} + +export async function statusBundledSkill( + options: StatusOptions, +): Promise { + const report: StatusReport = { + installed: [], + missing: [], + conflicts: [], + errors: [], + }; + for await (const { target, metadata } of ownedTargets(options, report)) { + if (metadata) report.installed.push({ ...target, metadata }); + else report.missing.push(target); + } + return report; +} + +async function* ownedTargets( + options: UninstallOptions, + report: Pick, +) { + if (!options.appId) { + report.errors.push({ reason: "invalid-app-id" }); + return; + } + const { targets, errors } = await resolveInstallTargetsForLifecycle( + options, + options.appId, + ); + report.errors.push(...errors); + for (const group of targets) { + const target = targetResult(group); + const { exists, value } = await readMetadata(target.targetDir); + const reason = exists + ? ownershipConflict(value, options.appId, options.skillName) + : undefined; + if (reason) report.conflicts.push({ ...target, reason }); + else yield { target, metadata: value }; + } +} + +function targetResult(target: TargetGroup): TargetResult { + const base = { skillName: target.skillName, targetDir: target.targetDir }; + return target.hostIds.length === 1 + ? { hostId: target.hostIds[0], ...base } + : { hostIds: target.hostIds, ...base }; +} + +export function emptyInstallReport(errors: TargetError[] = []): InstallReport { + return { installed: [], updated: [], skipped: [], conflicts: [], errors }; +} diff --git a/ts/src/storage.ts b/ts/src/storage.ts new file mode 100644 index 0000000..ec49dff --- /dev/null +++ b/ts/src/storage.ts @@ -0,0 +1,262 @@ +import { + chmod, + mkdir, + mkdtemp, + lstat, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import type { InstalledMetadata, ConflictReason } from "./types.js"; +import { + isValidSkillName, + type NormalizedSkillBundle, + type ResolvedBundleMetadata, +} from "./bundle.js"; + +type InstallMetadata = InstalledMetadata; +const metadataStrings = [ + "sourceId", + "version", + "cliVersion", + "cliRevision", +] as const; + +export async function readInstalledMetadata( + targetDir: string, +): Promise { + const metadata = await readMetadata(targetDir); + if (!metadata.exists) return undefined; + if (!metadata.value) throw new Error("unmanaged install metadata"); + return metadata.value; +} + +export async function writeManagedSkill( + bundle: NormalizedSkillBundle, + targetDir: string, + metadata: InstalledMetadata, + replace: boolean, +) { + const tmp = await makeStagingDir(targetDir); + const backup = `${tmp}-backup`; + try { + await copySkillBundle(bundle, tmp); + await writeMetadata(tmp, metadata); + if (replace) await rename(targetDir, backup); + await rename(tmp, targetDir); + if (replace) await rm(backup, { recursive: true, force: true }); + } catch (error) { + await rm(tmp, { recursive: true, force: true }); + if (replace && (await exists(backup)) && !(await exists(targetDir))) + await rename(backup, targetDir); + throw error; + } +} + +export async function removeManagedSkill( + targetDir: string, + appId: string, + skillName: string, +): Promise { + const quarantine = await mkdtemp( + join(dirname(targetDir), `.${basename(targetDir)}.kitup-uninstall-`), + ); + await rm(quarantine, { recursive: true }); + await rename(targetDir, quarantine); + const metadata = await readMetadata(quarantine); + const reason = ownershipConflict(metadata.value, appId, skillName); + if (reason) { + if (await exists(targetDir)) { + throw new Error(`cannot restore changed install: ${targetDir}`); + } + await rename(quarantine, targetDir); + return reason; + } + await rm(quarantine, { recursive: true }); + return undefined; +} + +async function makeStagingDir(targetDir: string) { + const parent = dirname(targetDir); + await mkdir(parent, { recursive: true }); + const tmp = await mkdtemp(join(parent, `.${basename(targetDir)}.kitup-`)); + try { + await chmod(tmp, 0o755); + return tmp; + } catch (error) { + await rm(tmp, { recursive: true, force: true }); + throw error; + } +} + +async function copySkillBundle(bundle: NormalizedSkillBundle, dest: string) { + await mkdir(dest, { recursive: true }); + for (const file of bundle) { + const target = join(dest, file.path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, file.bytes, { mode: file.mode }); + await chmod(target, file.mode); + } +} + +export async function repairSkillBundleModes( + bundle: NormalizedSkillBundle, + dest: string, + write: boolean, +) { + let repaired = false; + for (const file of bundle) { + const target = join(dest, file.path); + let info; + try { + info = await lstat(target); + } catch (error: any) { + if (error.code === "ENOENT") continue; + throw error; + } + if (info.isFile() && (info.mode & 0o777) !== file.mode) { + repaired = true; + if (write) await chmod(target, file.mode); + } + } + return repaired; +} + +export async function writeMetadata( + targetDir: string, + metadata: InstalledMetadata, +) { + await writeFile( + join(targetDir, ".kitup.json"), + `${JSON.stringify(metadata, null, 2)}\n`, + ); +} + +export function ownershipConflict( + metadata: InstalledMetadata | undefined, + appId: string, + skillName: string, +): ConflictReason | undefined { + if (!metadata || metadata.skillName !== skillName) return "unmanaged"; + if (metadata.appId !== appId) return "owner-mismatch"; +} + +export async function readMetadata( + targetDir: string, +): Promise<{ exists: boolean; value?: InstallMetadata }> { + if (!(await exists(targetDir))) return { exists: false }; + try { + const raw = JSON.parse( + await readFile(join(targetDir, ".kitup.json"), "utf8"), + ); + const value = parseOwnedMetadata(raw); + return value ? { exists: true, value } : { exists: true }; + } catch { + return { exists: true }; + } +} + +function parseOwnedMetadata(raw: unknown): InstallMetadata | undefined { + if (!raw || typeof raw !== "object") return undefined; + const value = raw as Record; + if (value.schemaVersion !== 1) return undefined; + if (typeof value.appId !== "string" || value.appId.length === 0) + return undefined; + if (typeof value.skillName !== "string" || !isValidSkillName(value.skillName)) + return undefined; + if (value.source !== "bundled" && value.source !== "github") return undefined; + if (typeof value.hash !== "string" || value.hash.length === 0) + return undefined; + const metadata: InstallMetadata = { + schemaVersion: 1, + appId: value.appId, + skillName: value.skillName, + source: value.source, + hash: value.hash, + }; + for (const key of metadataStrings) { + if (key in value && typeof value[key] !== "string") return undefined; + if (value[key]) metadata[key] = value[key] as string; + } + if ("provenance" in value) { + if ( + !value.provenance || + typeof value.provenance !== "object" || + Array.isArray(value.provenance) || + !Object.values(value.provenance).every((item) => typeof item === "string") + ) { + return undefined; + } + if (Object.keys(value.provenance).length > 0) + metadata.provenance = { + ...(value.provenance as Record), + }; + } + return metadata; +} + +export function installedMetadata( + appId: string, + skillName: string, + hash: string, + metadata: ResolvedBundleMetadata, +): InstalledMetadata { + const value: InstalledMetadata = { + schemaVersion: 1, + appId, + skillName, + source: metadata.source, + hash, + }; + for (const key of metadataStrings) + if (metadata[key]) value[key] = metadata[key]; + if (metadata.provenance) value.provenance = { ...metadata.provenance }; + return value; +} + +export function installedMetadataEqual( + left: InstalledMetadata, + right: InstalledMetadata, +) { + const keys = [ + "schemaVersion", + "appId", + "skillName", + "source", + "hash", + ...metadataStrings, + ] as const; + return ( + keys.every((key) => left[key] === right[key]) && + recordEqual(left.provenance, right.provenance) + ); +} + +function recordEqual( + left: Record | undefined, + right: Record | undefined, +) { + const leftEntries = Object.entries(left ?? {}).sort(); + const rightEntries = Object.entries(right ?? {}).sort(); + return JSON.stringify(leftEntries) === JSON.stringify(rightEntries); +} + +export async function exists(path: string) { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +export async function isDirectory(path: string) { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} diff --git a/ts/src/types.ts b/ts/src/types.ts new file mode 100644 index 0000000..9e7c15b --- /dev/null +++ b/ts/src/types.ts @@ -0,0 +1,243 @@ +export type Scope = "user" | "project"; + +export type AgentSelector = "*" | "auto" | string[]; + +export interface Host { + id: string; + displayName: string; + aliases?: string[]; + projectSkillsDirs: string[]; + userSkillsDirs: string[]; + detect: string[]; + status: "verified" | "documented" | "community" | "experimental"; + notes?: string[]; +} + +export interface HostSpec { + schemaVersion: 1; + hosts: Host[]; +} + +export interface BaseOptions { + home?: string; + cwd?: string; + hostsFile?: string; +} + +export interface SkillFile { + path: string; + contents: string | Uint8Array; + mode?: number; +} + +export type SkillBundle = + | { kind: "directory"; path: string } + | { kind: "files"; files: SkillFile[] } + | { kind: "github"; options: GitHubBundleOptions } + | { + kind: "metadata"; + bundle: SkillBundle; + metadata: BundledSkillMetadata; + }; + +export interface BundledSkillMetadata { + sourceId?: string; + cliVersion?: string; + cliRevision?: string; + provenance?: Record; +} + +export interface GitHubBundleOptions { + owner: string; + repo: string; + path: string; + ref: string; +} + +export interface InstallOptions extends BaseOptions { + appId: string; + skillBundle: SkillBundle; + scope: Scope; + agents?: AgentSelector; + force?: boolean; +} + +export interface UninstallOptions extends BaseOptions { + appId: string; + skillName: string; + scope: Scope; + agents?: AgentSelector; +} + +export interface StatusOptions extends UninstallOptions {} + +export interface InstallSelectionOptions extends BaseOptions { + scope: Scope; + agents?: AgentSelector; + yes?: boolean; + stdinTTY?: boolean; + currentAgent?: string; +} + +export interface InstallWorkflowOptions + extends InstallOptions, InstallSelectionOptions { + dryRun?: boolean; + defaultScope?: Scope; + scopeSet?: boolean; + promptScope?: boolean; + input?: AsyncIterable; + output?: { write(chunk: string): unknown }; +} + +export interface InstallFlagValues { + scope?: string; + scopeSet?: boolean; + agents?: string[]; + yes?: boolean; + dryRun?: boolean; + force?: boolean; +} + +export interface InstallFlagError { + flag: string; + reason: string; + value?: string; +} + +export interface ParsedInstallFlags { + scope: Scope; + scopeSet: boolean; + agents: AgentSelector; + yes: boolean; + dryRun: boolean; + force: boolean; + errors: InstallFlagError[]; +} + +export interface TargetGroup { + hostIds: string[]; + skillName: string; + targetDir: string; +} + +export type TargetResult = + | { + hostId: string; + skillName: string; + targetDir: string; + } + | { + hostIds: string[]; + skillName: string; + targetDir: string; + }; + +export type SkipReason = "unchanged" | "missing"; + +export type ConflictReason = "unmanaged" | "owner-mismatch"; + +export type TargetConflict = TargetResult & { reason: ConflictReason }; + +export type TargetSkip = TargetResult & { reason: SkipReason }; + +export type UnknownHostError = { agent: string; reason: "unknown-host" }; + +export type UnsupportedScopeError = { + hostId: string; + skillName: string; + scope: Scope; + reason: "unsupported-scope"; +}; + +export type SkillError = { reason: SkillInfo["errorCode"] }; + +export type BundleError = { + reason: "bundle-resolve-failed"; +}; + +export type InvalidSkillNameError = { + skillName: string; + reason: "invalid-skill-name"; +}; + +export type InvalidAppIdError = { reason: "invalid-app-id" }; + +export type TargetError = + | UnknownHostError + | UnsupportedScopeError + | SkillError + | BundleError + | InvalidSkillNameError + | InvalidAppIdError; + +export interface InstallReport { + installed: TargetResult[]; + updated: TargetResult[]; + skipped: TargetSkip[]; + conflicts: TargetConflict[]; + errors: TargetError[]; +} + +export interface UninstallReport { + removed: TargetResult[]; + skipped: TargetSkip[]; + conflicts: TargetConflict[]; + errors: TargetError[]; +} + +export interface InstalledMetadata extends BundledSkillMetadata { + schemaVersion: 1; + appId: string; + skillName: string; + source: "bundled" | "github"; + hash: string; + version?: string; +} + +export type InstalledTarget = TargetResult & { + metadata: InstalledMetadata; +}; + +export interface StatusReport { + installed: InstalledTarget[]; + missing: TargetResult[]; + conflicts: TargetConflict[]; + errors: TargetError[]; +} + +export type InstallSelectionAction = "install" | "select-agents" | "error"; + +export interface InstallSelection { + action: InstallSelectionAction; + selectedHostIds: string[]; + candidateHostIds: string[]; + detectedHostIds: string[]; + needsConfirmation: boolean; + errors: Array<{ reason: string; agent?: string }>; +} + +export interface InstallWorkflowReport { + selection: InstallSelection; + scope: Scope | ""; + plan: InstallReport; + report: InstallReport; + canceled: boolean; + dryRun: boolean; +} + +export type InstallWorkflowExitCode = + "ok" | "canceled" | "selection-error" | "conflict" | "error"; + +export interface InstallWorkflowExit { + ok: boolean; + code: InstallWorkflowExitCode; + message: string; +} + +export interface SkillInfo { + valid: boolean; + skillName?: string; + description?: string; + errorCode?: + "missing-skill-md" | "invalid-frontmatter" | "invalid-skill-bundle"; +} diff --git a/ts/src/workflow.ts b/ts/src/workflow.ts new file mode 100644 index 0000000..0873f91 --- /dev/null +++ b/ts/src/workflow.ts @@ -0,0 +1,468 @@ +import type { + Scope, + AgentSelector, + Host, + InstallFlagValues, + InstallFlagError, + ParsedInstallFlags, + InstallSelectionOptions, + InstallSelection, + InstallWorkflowOptions, + InstallWorkflowReport, + InstallWorkflowExit, + InstallOptions, + InstallReport, + TargetResult, +} from "./types.js"; +import { loadHostSpec, resolveHosts, detectHosts } from "./hosts.js"; +import { + planBundledSkill, + installBundledSkill, + emptyInstallReport, +} from "./installer.js"; + +export const installUxText = { + skillUse: "skill", + skillShort: "Manage bundled Agent Skill", + installUse: "install", + installShort: "Install bundled Agent Skill", + scopeFlag: "Install scope: user or project", + agentFlag: "Target agent id. Repeat for multiple agents. Use '*' for all.", + dryRunFlag: "Show install plan without writing", + yesFlag: "Skip prompts and accept policy-selected targets", + forceFlag: "Overwrite unsafe target conflicts", + selectScope: "Select install scope:", + scopePrompt: "Scope (user/project)", + invalidScopeSelection: "Invalid scope selection.", + selectAgents: "Select agents:", + agentsPrompt: "Agents (numbers, ids, comma-separated, empty cancels)", + invalidAgentSelection: "Invalid agent selection.", + proceed: "Proceed? [y/N] ", + installSummary: "Install summary:", + errorPrefix: "kitup:", + canceled: "Installation canceled.", + selectionError: "Agent selection failed.", + conflict: "Installation has conflicts.", + failed: "Installation failed.", + invalidFlags: "Invalid install flags.", +} as const; + +export function parseInstallFlags( + flags: InstallFlagValues, +): ParsedInstallFlags { + const errors: InstallFlagError[] = []; + const scope = parseScopeFlag(flags.scope, errors); + const agents = agentSelectorFromFlags(flags.agents ?? [], errors); + return { + scope, + scopeSet: flags.scopeSet ?? flags.scope !== undefined, + agents, + yes: Boolean(flags.yes), + dryRun: Boolean(flags.dryRun), + force: Boolean(flags.force), + errors, + }; +} + +export function agentSelectorFromFlags( + values: string[], + errors: InstallFlagError[] = [], +): AgentSelector { + const agents = splitFlagValues(values); + if (agents.length === 0) return "auto"; + if (agents.includes("*")) { + if (agents.length > 1) { + errors.push({ + flag: "agent", + reason: "agent-star-must-be-alone", + value: agents.join(","), + }); + } + return "*"; + } + return [...new Set(agents)]; +} + +export function parseScopeFlag( + value: string | undefined, + errors: InstallFlagError[] = [], +): Scope { + if (!value || value === "user") return "user"; + if (value === "project") return "project"; + errors.push({ flag: "scope", reason: "invalid-scope", value }); + return "user"; +} + +function splitFlagValues(values: string[]) { + return values.flatMap((value) => value.split(/[,\s]+/)).filter(Boolean); +} + +export function classifyInstallWorkflowExit( + workflow: InstallWorkflowReport, +): InstallWorkflowExit { + const failures = [ + [workflow.canceled, "canceled", installUxText.canceled], + [ + workflow.selection.errors.length, + "selection-error", + installUxText.selectionError, + ], + [workflow.report.conflicts.length, "conflict", installUxText.conflict], + [workflow.report.errors.length, "error", installUxText.failed], + ] as const; + const failure = failures.find(([present]) => present); + return failure + ? { ok: false, code: failure[1], message: failure[2] } + : { ok: true, code: "ok", message: "" }; +} + +export function installWorkflowError( + workflow: InstallWorkflowReport, +): Error | undefined { + const exit = classifyInstallWorkflowExit(workflow); + return exit.ok || exit.code === "canceled" + ? undefined + : new Error(exit.message); +} + +export function installFlagError( + errors: InstallFlagError[], +): Error | undefined { + return errors.length === 0 + ? undefined + : new Error(installUxText.invalidFlags); +} + +export async function resolveInstallSelection( + options: InstallSelectionOptions, +): Promise { + const hosts = (await loadHostSpec(options.hostsFile)).hosts; + const stdinTTY = options.stdinTTY ?? Boolean(process.stdin.isTTY); + const explicitAgents = + options.agents !== undefined && options.agents !== "auto"; + + const requested = explicitAgents + ? options.agents! + : options.currentAgent + ? [options.currentAgent] + : undefined; + if (requested) { + const resolved = await resolveHosts({ agents: requested, hosts }); + if (explicitAgents && resolved.errors.length) + return errorSelection(resolved.errors); + const selected = explicitAgents + ? resolved.hosts + : addUniversalHost(resolved.hosts, hosts); + return installSelection( + selected.map((host) => host.id), + [], + !options.yes && stdinTTY, + resolved.errors, + ); + } + + const detected = await detectHosts({ ...options, scope: options.scope }); + const detectedHostIds = detected.map((host) => host.id); + + if (!stdinTTY && !options.yes) { + return errorSelection( + [{ reason: "agent-selection-required" }], + detectedHostIds, + ); + } + + if (options.yes) { + if (detected.length === 0) { + return errorSelection([{ reason: "no-detected-hosts" }], detectedHostIds); + } + return installSelection(detectedHostIds, detectedHostIds, false); + } + + if (detected.length === 1) + return installSelection(detectedHostIds, detectedHostIds, true); + return { + ...installSelection([], detectedHostIds, true), + action: "select-agents", + candidateHostIds: detected.length + ? detectedHostIds + : hosts.map((host) => host.id), + }; +} + +export async function runBundledSkillInstall( + options: InstallWorkflowOptions, +): Promise { + const stdinTTY = options.stdinTTY ?? Boolean(process.stdin.isTTY); + const output = options.output ?? process.stdout; + const reader = readLines(options.input ?? process.stdin); + const { scope, selection: scopeError } = await resolveWorkflowScope( + reader, + output, + options, + stdinTTY, + ); + const workflow: InstallWorkflowReport = { + selection: + scopeError ?? + (await resolveInstallSelection({ + ...options, + scope: scope as Scope, + stdinTTY, + })), + scope, + plan: emptyInstallReport(), + report: emptyInstallReport(), + canceled: false, + dryRun: Boolean(options.dryRun), + }; + if (workflow.selection.action === "error") { + renderSelectionErrors(output, workflow.selection); + return workflow; + } + if (workflow.selection.action === "select-agents") { + const hosts = (await loadHostSpec(options.hostsFile)).hosts; + const selected = await promptAgentSelection( + reader, + output, + workflow.selection, + hosts, + ); + workflow.selection = installSelection( + selected, + workflow.selection.detectedHostIds, + !options.yes && stdinTTY, + ); + if (selected.length === 0) { + workflow.canceled = true; + return workflow; + } + } + const installOptions: InstallOptions = { + ...options, + scope: scope as Scope, + agents: workflow.selection.selectedHostIds, + }; + const plan = await planBundledSkill(installOptions); + workflow.plan = workflow.report = plan; + if ( + plan.installed.length + + plan.updated.length + + plan.conflicts.length + + plan.errors.length === + 0 + ) + return workflow; + if (options.dryRun) { + renderInstallSummary(output, plan); + return workflow; + } + if (plan.conflicts.length + plan.errors.length > 0) { + workflow.report = { ...plan, installed: [], updated: [] }; + return workflow; + } + renderInstallSummary(output, plan); + if ( + workflow.selection.needsConfirmation && + !(await promptConfirmation(reader, output)) + ) { + workflow.report = emptyInstallReport(); + workflow.canceled = true; + return workflow; + } + workflow.report = await installBundledSkill(installOptions); + return workflow; +} + +function addUniversalHost(selected: Host[], hosts: Host[]) { + const result = [...selected]; + const universal = hosts.find((host) => host.id === "universal"); + if (universal && !result.some((host) => host.id === universal.id)) { + result.push(universal); + } + return result; +} + +function installSelection( + selectedHostIds: string[], + detectedHostIds: string[] = [], + needsConfirmation: boolean, + errors: InstallSelection["errors"] = [], +): InstallSelection { + return { + action: errors.length > 0 ? "error" : "install", + selectedHostIds, + candidateHostIds: [], + detectedHostIds, + needsConfirmation: errors.length > 0 ? false : needsConfirmation, + errors, + }; +} + +function errorSelection( + errors: InstallSelection["errors"], + detectedHostIds: string[] = [], +): InstallSelection { + return { + ...installSelection([], detectedHostIds, false, errors), + action: "error", + }; +} + +async function* readLines( + input: AsyncIterable, +): AsyncGenerator { + let buffer = ""; + for await (const chunk of input) { + buffer += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + let newline; + while ((newline = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + yield line; + } + } + if (buffer) yield buffer.replace(/\r$/, ""); +} + +async function resolveWorkflowScope( + reader: AsyncGenerator, + output: { write(chunk: string): unknown }, + options: InstallWorkflowOptions, + stdinTTY: boolean, +): Promise<{ scope: Scope | ""; selection?: InstallSelection }> { + const defaultScope = options.defaultScope ?? "user"; + const scope = options.scope ?? defaultScope; + if ((options.scopeSet ?? options.scope !== undefined) || !options.promptScope) + return { scope }; + if (options.yes) return { scope: defaultScope }; + if (!stdinTTY) + return { + scope: "", + selection: errorSelection([{ reason: "scope-selection-required" }]), + }; + return { scope: await promptScopeSelection(reader, output, defaultScope) }; +} + +async function promptScopeSelection( + reader: AsyncGenerator, + output: { write(chunk: string): unknown }, + defaultScope: Scope, +) { + while (true) { + writeLine(output, installUxText.selectScope); + writeLine(output, " 1. user"); + writeLine(output, " 2. project"); + output.write(`${installUxText.scopePrompt} [${defaultScope}]: `); + const selected = parseScopeSelection( + (await reader.next()).value ?? "", + defaultScope, + ); + if (selected) return selected; + writeLine(output, installUxText.invalidScopeSelection); + } +} + +function parseScopeSelection( + line: string, + defaultScope: Scope, +): Scope | undefined { + const choices: Record = { + "": defaultScope, + "1": "user", + u: "user", + user: "user", + "2": "project", + p: "project", + project: "project", + }; + return Object.hasOwn(choices, line.trim().toLowerCase()) + ? choices[line.trim().toLowerCase()] + : undefined; +} + +async function promptAgentSelection( + reader: AsyncGenerator, + output: { write(chunk: string): unknown }, + selection: InstallSelection, + hosts: Host[], +) { + const candidates = selection.candidateHostIds + .map((id) => hosts.find((host) => host.id === id)) + .filter((host): host is Host => Boolean(host)); + while (true) { + writeLine(output, installUxText.selectAgents); + candidates.forEach((host, index) => { + writeLine(output, ` ${index + 1}. ${host.displayName} (${host.id})`); + }); + const current = selection.selectedHostIds.join(","); + const suffix = current ? ` [${current}]` : ""; + output.write(`${installUxText.agentsPrompt}${suffix}: `); + const line = (await reader.next()).value; + const selected = parseAgentSelection(line ?? "", selection, candidates); + if (selected) return selected; + writeLine(output, installUxText.invalidAgentSelection); + } +} + +function parseAgentSelection( + line: string, + selection: InstallSelection, + candidates: Host[], +) { + const trimmed = line.trim(); + if (!trimmed) return selection.selectedHostIds; + if (trimmed === "*") return candidates.map((host) => host.id); + + const byName = new Map(); + candidates.forEach((host, index) => { + byName.set(String(index + 1), host.id); + byName.set(host.id, host.id); + for (const alias of host.aliases ?? []) byName.set(alias, host.id); + }); + + const selected = new Set(); + for (const part of trimmed.split(/[,\s]+/)) { + const id = byName.get(part); + if (!id) return undefined; + selected.add(id); + } + return [...selected]; +} + +async function promptConfirmation( + reader: AsyncGenerator, + output: { write(chunk: string): unknown }, +) { + output.write(installUxText.proceed); + const line = (await reader.next()).value ?? ""; + return ["y", "yes"].includes(line.trim().toLowerCase()); +} + +function renderInstallSummary( + output: { write(chunk: string): unknown }, + report: InstallReport, +) { + for (const item of [...report.installed, ...report.updated]) { + for (const host of summaryHosts(item)) { + writeLine(output, ` - ${item.skillName} -> ${item.targetDir} (${host})`); + } + } +} + +function summaryHosts(item: TargetResult) { + return "hostId" in item ? [item.hostId] : item.hostIds; +} + +function renderSelectionErrors( + output: { write(chunk: string): unknown }, + selection: InstallSelection, +) { + for (const error of selection.errors) { + writeLine(output, `${installUxText.errorPrefix} ${error.reason}`); + } +} + +function writeLine(output: { write(chunk: string): unknown }, line: string) { + output.write(`${line}\n`); +} diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 2a74796..bc5e413 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -1,75 +1,100 @@ import assert from "node:assert/strict"; -import { createServer } from "node:http"; -import { - mkdtemp, - mkdir, - readFile, - readdir, - rm, - stat, - chmod, - writeFile, -} from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { Readable } from "node:stream"; import { fileURLToPath } from "node:url"; -import { - classifyInstallWorkflowExit, - computeBundleContentHash, - detectHosts, - directoryBundle, - filesBundle, - githubBundle, - installBundledSkill, - loadHostSpec, - planBundledSkill, - parseInstallFlags, - resolveInstallSelection, - resolveInstallTargets, - resolveHosts, - runBundledSkillInstall, - statusBundledSkill, - uninstallBundledSkill, - updateBundledSkill, - validateSkillBundle, - withBundleMetadata, -} from "../dist/index.js"; +import * as kitup from "../dist/index.js"; +import { runGoldenCases } from "../../scripts/golden.mjs"; const repo = fileURLToPath(new URL("../../", import.meta.url)); -const casesFile = join(repo, "testdata/cases/bundled-skill-install.json"); const defaultHostsFile = join(repo, "spec/hosts.json"); -const cases = JSON.parse(await readFile(casesFile, "utf8")).cases; -let passed = 0; -for (const testCase of cases) { - const root = await mkdtemp(join(tmpdir(), `kitup-${testCase.id}-`)); - const home = join(root, "home"); - const workspace = join(root, "workspace"); - await mkdir(home, { recursive: true }); - await mkdir(workspace, { recursive: true }); - - const cleanups: Array<() => Promise | void> = []; - try { - await setupGiven(testCase, home, workspace, cleanups); - await runCase(testCase, home, workspace); - passed++; - } finally { - for (const cleanup of cleanups.reverse()) await cleanup(); - await rm(root, { recursive: true, force: true }); - } -} +await runGoldenCases( + async ({ operation, options, given, detect }: any) => { + options = { ...options }; + if (options.skillBundleDir) + options.skillBundle = kitup.directoryBundle(options.skillBundleDir); + if (options.skillFiles) + options.skillBundle = kitup.filesBundle(options.skillFiles); + if (options.githubBundle) + options.skillBundle = kitup.githubBundle(options.githubBundle); + if (options.bundleMetadata) + options.skillBundle = kitup.withBundleMetadata( + options.skillBundle, + options.bundleMetadata, + ); + switch (operation) { + case "resolve-hosts": { + const { hosts } = await kitup.loadHostSpec(given.hostsFile); + const result = await kitup.resolveHosts({ + agents: options.agents, + hosts, + }); + const hostIds = result.hosts.map((host) => host.id); + return { + count: hostIds.length, + hostIds, + resolvedHostIds: hostIds, + errors: result.errors, + }; + } + case "validate": + return kitup.validateSkillBundle(options.skillBundle, options.cwd); + case "parse-install-flags": { + const { agents, ...flags } = kitup.parseInstallFlags(options); + return { + parsed: { + ...flags, + agentKind: Array.isArray(agents) ? "explicit" : agents, + agentIds: Array.isArray(agents) ? agents : [], + }, + }; + } + case "resolve-install-selection": + return { selection: await kitup.resolveInstallSelection(options) }; + case "resolve-install-targets": + return { + targets: (await kitup.resolveInstallTargets(options)).targets, + }; + case "run-install-workflow": { + let output = ""; + const workflow = await kitup.runBundledSkillInstall({ + ...options, + input: Readable.from([options.input ?? ""]), + output: { + write(chunk: string) { + output += chunk; + }, + }, + }); + return { + workflow, + report: workflow.report, + exit: kitup.classifyInstallWorkflowExit(workflow), + output, + }; + } + default: { + const operations: Record Promise> = { + install: kitup.installBundledSkill, + update: kitup.updateBundledSkill, + plan: kitup.planBundledSkill, + status: kitup.statusBundledSkill, + uninstall: kitup.uninstallBundledSkill, + }; + const detectedHosts = detect + ? (await kitup.detectHosts(options)).map((host) => host.id) + : undefined; + return { report: await operations[operation](options), detectedHosts }; + } + } + }, + (a, b) => a.localeCompare(b), +); await assertConcurrentInitialInstallIsolation(); -function caseHostsFile(options: any, home: string, workspace: string) { - if (!options.hostsFile) return defaultHostsFile; - const expanded = expandValue(options.hostsFile, home, workspace); - return expanded.startsWith("/") ? expanded : resolveRepoPath(expanded); -} - -console.log(`ok: ${passed} TypeScript golden cases`); - async function assertConcurrentInitialInstallIsolation() { const root = await mkdtemp(join(tmpdir(), "kitup-concurrent-install-")); const home = join(root, "home"); @@ -78,7 +103,7 @@ async function assertConcurrentInitialInstallIsolation() { await mkdir(workspace, { recursive: true }); const bundles = ["A", "B"].map((payload) => - filesBundle([ + kitup.filesBundle([ { path: "SKILL.md", contents: @@ -92,7 +117,7 @@ async function assertConcurrentInitialInstallIsolation() { try { const results = await Promise.allSettled( bundles.map((skillBundle, index) => - installBundledSkill({ + kitup.installBundledSkill({ appId: `app-${index}`, skillBundle, scope: "user", @@ -127,463 +152,3 @@ async function assertConcurrentInitialInstallIsolation() { await rm(root, { recursive: true, force: true }); } } - -async function runCase(testCase: any, home: string, workspace: string) { - const options = expandOptions(testCase.options, home, workspace); - const hostsFile = caseHostsFile(testCase.options, home, workspace); - - if (testCase.operation === "resolve-hosts") { - const spec = await loadHostSpec(resolveRepoPath(testCase.given.hostsFile)); - const result = await resolveHosts({ - agents: options.agents, - hosts: spec.hosts, - }); - if (testCase.expected.count !== undefined) - assert.equal(result.hosts.length, testCase.expected.count); - if (testCase.expected.hostIds) - assert.deepEqual( - result.hosts.map((host) => host.id), - testCase.expected.hostIds, - ); - if (testCase.expected.resolvedHostIds) { - assert.deepEqual( - result.hosts.map((host) => host.id), - testCase.expected.resolvedHostIds, - ); - } - if (testCase.expected.errors) - assert.deepEqual(result.errors, testCase.expected.errors); - return; - } - - if (testCase.operation === "validate") { - const result = await validateSkillBundle(options.skillBundle, options.cwd); - assert.equal(result.valid, testCase.expected.valid); - assert.equal(result.errorCode, testCase.expected.errorCode); - return; - } - - if (testCase.operation === "parse-install-flags") { - assert.deepEqual( - normalizeParsedFlags(parseInstallFlags(options)), - testCase.expected.parsed, - ); - return; - } - - if (testCase.operation === "resolve-install-selection") { - const selection = await resolveInstallSelection({ - ...options, - hostsFile, - }); - assertSelection(selection, testCase.expected.selection); - return; - } - - if (testCase.operation === "resolve-install-targets") { - const result = await resolveInstallTargets({ - home, - cwd: workspace, - hostsFile, - agents: options.agents, - scope: options.scope, - skillName: options.skillName, - }); - assert.deepEqual( - result.targets, - expandValue(testCase.expected.targets, home, workspace), - ); - return; - } - - if (testCase.operation === "run-install-workflow") { - const output = { - text: "", - write(chunk: string) { - this.text += chunk; - }, - }; - const workflow = await runBundledSkillInstall({ - ...options, - hostsFile, - input: Readable.from([options.input ?? ""]), - output, - }); - assertWorkflow(workflow, testCase.expected.workflow); - if (testCase.expected.exit) - assert.deepEqual( - classifyInstallWorkflowExit(workflow), - testCase.expected.exit, - ); - assertOutput(output.text, testCase.expected.output); - assertOutputContains(output.text, testCase.expected.outputContains); - if (testCase.expected.report) - assert.deepEqual( - workflow.report, - expandValue(testCase.expected.report, home, workspace), - ); - await assertExpectedFiles(testCase, home, workspace); - await assertExpectedFileModes(testCase, home, workspace); - await assertExpectedMetadata(testCase, home, workspace); - return; - } - - if (testCase.expected.detectedHosts) { - const detected = await detectHosts({ - home, - cwd: workspace, - hostsFile, - scope: options.scope, - }); - assert.deepEqual( - detected.map((host) => host.id), - testCase.expected.detectedHosts, - ); - } - - const reportPromise = - testCase.operation === "status" - ? statusBundledSkill({ ...options, hostsFile }) - : testCase.operation === "uninstall" - ? uninstallBundledSkill({ ...options, hostsFile }) - : testCase.operation === "plan" - ? planBundledSkill({ ...options, hostsFile }) - : testCase.operation === "update" - ? updateBundledSkill({ ...options, hostsFile }) - : installBundledSkill({ ...options, hostsFile }); - - if (testCase.expected.throws) { - await assert.rejects(reportPromise); - await assertExpectedFiles(testCase, home, workspace); - return; - } - - const report = await reportPromise; - - if (testCase.expected.report) - assert.deepEqual( - report, - expandValue(testCase.expected.report, home, workspace), - ); - assertExpectedWriteCounts(testCase, report, home, workspace); - await assertExpectedFiles(testCase, home, workspace); - await assertExpectedFileModes(testCase, home, workspace); - await assertExpectedMetadata(testCase, home, workspace); -} - -async function setupGiven( - testCase: any, - home: string, - workspace: string, - cleanups: Array<() => Promise | void>, -) { - for (const dir of testCase.given.dirs ?? []) { - await mkdir(expandValue(dir, home, workspace), { recursive: true }); - } - - for (const [path, value] of Object.entries(testCase.given.files ?? {})) { - await writeFixtureFile(expandValue(path, home, workspace), value); - } - - if (testCase.given.copySkillBundleTo) { - await copyFixtureSkill( - caseSkillBundleDir(testCase), - expandValue(testCase.given.copySkillBundleTo, home, workspace), - ); - } - - for (const [path, mode] of Object.entries(testCase.given.fileModes ?? {})) { - await chmod( - expandValue(path, home, workspace), - Number.parseInt(String(mode), 8), - ); - } - - if (testCase.given.metadata) - await writeMetadataFixture( - testCase, - home, - workspace, - testCase.given.metadata, - ); - - if (testCase.given.github) - await startGitHubFixture(testCase.given.github, cleanups); -} - -async function assertExpectedFiles( - testCase: any, - home: string, - workspace: string, -) { - for (const path of testCase.expected.filesPresent ?? []) { - await assertFileExists(expandValue(path, home, workspace)); - } - for (const path of testCase.expected.filesAbsent ?? []) { - await assertFileMissing(expandValue(path, home, workspace)); - } -} - -async function assertExpectedFileModes( - testCase: any, - home: string, - workspace: string, -) { - for (const [path, mode] of Object.entries( - testCase.expected.fileModes ?? {}, - )) { - const actual = - (await stat(expandValue(path, home, workspace))).mode & 0o777; - assert.equal(actual.toString(8), mode); - } -} - -async function assertExpectedMetadata( - testCase: any, - home: string, - workspace: string, -) { - const expected = testCase.expected.metadata; - if (!expected) return; - const path = expandValue(expected.path, home, workspace); - const actual = JSON.parse(await readFile(path, "utf8")); - for (const [key, value] of Object.entries(expected.fields)) - assert.deepEqual(actual[key], value); - const expectedHash = await expectedBundleHash(testCase, expected.hash); - assert.equal(actual.hash, expectedHash); -} - -function assertExpectedWriteCounts( - testCase: any, - report: any, - home: string, - workspace: string, -) { - if (!testCase.expected.writeCountByTargetDir) return; - const actual: Record = {}; - for (const item of [...(report.installed ?? []), ...(report.updated ?? [])]) { - actual[item.targetDir] = (actual[item.targetDir] ?? 0) + 1; - } - assert.deepEqual( - actual, - expandValue(testCase.expected.writeCountByTargetDir, home, workspace), - ); -} - -async function writeMetadataFixture( - testCase: any, - home: string, - workspace: string, - metadata: any, -) { - const path = expandValue(metadata.path, home, workspace); - const hash = await expectedBundleHash(testCase, metadata.hash); - await writeFixtureFile(path, { ...metadata.fields, hash }); -} - -async function expectedBundleHash(testCase: any, marker: string) { - if (marker === "from-skill-bundle-dir") - return computeBundleContentHash( - directoryBundle(caseSkillBundleDir(testCase)), - ); - if (marker === "from-skill-files") - return computeBundleContentHash(filesBundle(testCase.options.skillFiles)); - if (marker === "from-github-bundle") - return computeBundleContentHash(filesBundle(githubSkillFiles(testCase))); - return marker; -} - -async function startGitHubFixture( - github: any, - cleanups: Array<() => Promise | void>, -) { - const previousApi = process.env.KITUP_GITHUB_API_BASE_URL; - const previousRaw = process.env.KITUP_GITHUB_RAW_BASE_URL; - const server = createServer((request, response) => { - const url = new URL(request.url ?? "/", "http://127.0.0.1"); - const path = decodeURIComponent(url.pathname); - const commitPath = `/repos/${github.owner}/${github.repo}/commits/${github.ref}`; - const treePath = `/repos/${github.owner}/${github.repo}/git/trees/${github.treeSha}`; - if (path === commitPath) { - writeJson(response, { - sha: github.commit, - commit: { tree: { sha: github.treeSha } }, - }); - return; - } - if (path === treePath) { - writeJson(response, { - tree: Object.keys(github.files).map((file) => ({ - path: file, - type: "blob", - mode: file.endsWith(".sh") ? "100755" : "100644", - })), - }); - return; - } - const rawPrefix = `/${github.owner}/${github.repo}/${github.commit}/`; - if (path.startsWith(rawPrefix)) { - const file = path.slice(rawPrefix.length); - if (github.files[file] !== undefined) { - response.writeHead(200, { "content-type": "application/octet-stream" }); - response.end(github.files[file]); - return; - } - } - response.writeHead(404); - response.end("not found"); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("bad server"); - const base = `http://127.0.0.1:${address.port}`; - process.env.KITUP_GITHUB_API_BASE_URL = base; - process.env.KITUP_GITHUB_RAW_BASE_URL = base; - cleanups.push(async () => { - restoreEnv("KITUP_GITHUB_API_BASE_URL", previousApi); - restoreEnv("KITUP_GITHUB_RAW_BASE_URL", previousRaw); - await new Promise((resolve) => server.close(() => resolve())); - }); -} - -function writeJson(response: any, value: any) { - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify(value)); -} - -function restoreEnv(name: string, value: string | undefined) { - if (value === undefined) delete process.env[name]; - else process.env[name] = value; -} - -async function writeFixtureFile(path: string, value: any) { - await mkdir(dirname(path), { recursive: true }); - await writeFile( - path, - typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`, - ); -} - -async function copyFixtureSkill(src: string, dest: string) { - const { cp } = await import("node:fs/promises"); - await rm(dest, { recursive: true, force: true }); - await cp(src, dest, { recursive: true }); -} - -async function assertFileExists(path: string) { - await stat(path).catch((error) => { - throw new Error(`expected file to exist: ${path}\n${error.message}`); - }); -} - -async function assertFileMissing(path: string) { - try { - await stat(path); - } catch (error: any) { - if (error.code === "ENOENT") return; - throw error; - } - throw new Error(`expected file to be absent: ${path}`); -} - -function expandOptions(options: any, home: string, workspace: string) { - const expanded = expandValue(options, home, workspace); - if (expanded.skillBundleDir) - expanded.skillBundle = directoryBundle( - resolveRepoPath(expanded.skillBundleDir), - ); - if (expanded.skillFiles) - expanded.skillBundle = filesBundle(expanded.skillFiles); - if (expanded.githubBundle) - expanded.skillBundle = githubBundle(expanded.githubBundle); - if (expanded.bundleMetadata) - expanded.skillBundle = withBundleMetadata( - expanded.skillBundle, - expanded.bundleMetadata, - ); - return expanded; -} - -function expandValue(value: any, home: string, workspace: string): any { - if (typeof value === "string") - return value.replaceAll("$HOME", home).replaceAll("$WORKSPACE", workspace); - if (Array.isArray(value)) - return value.map((item) => expandValue(item, home, workspace)); - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [ - expandValue(key, home, workspace), - expandValue(item, home, workspace), - ]), - ); - } - return value; -} - -function resolveRepoPath(path: string) { - return path.startsWith("/") ? path : join(repo, path); -} - -function caseSkillBundleDir(testCase: any) { - return resolveRepoPath( - testCase.options.skillBundleDir ?? - `testdata/skills/${testCase.options.skillName}`, - ); -} - -function githubSkillFiles(testCase: any) { - const root = testCase.options.githubBundle.path.replace(/^\/+|\/+$/g, ""); - const prefix = `${root}/`; - return Object.entries(testCase.given.github.files) - .filter(([path]) => path.startsWith(prefix)) - .map(([path, contents]) => ({ - path: path.slice(prefix.length), - contents: contents as string, - })); -} - -function assertSelection(actual: any, expected: any) { - const normalized = { ...actual }; - if (expected.selectedCount !== undefined) { - assert.equal(normalized.selectedHostIds.length, expected.selectedCount); - delete normalized.selectedHostIds; - } - if (expected.candidateCount !== undefined) { - assert.equal(normalized.candidateHostIds.length, expected.candidateCount); - delete normalized.candidateHostIds; - } - const normalizedExpected = { ...expected }; - delete normalizedExpected.selectedCount; - delete normalizedExpected.candidateCount; - assert.deepEqual(normalized, normalizedExpected); -} - -function assertWorkflow(actual: any, expected: any) { - if (!expected) return; - for (const [key, value] of Object.entries(expected)) { - assert.deepEqual(actual[key], value); - } -} - -function assertOutputContains(actual: string, expected: string[] = []) { - for (const value of expected) { - assert.ok(actual.includes(value), `expected output to contain ${value}`); - } -} - -function assertOutput(actual: string, expected: string | undefined) { - if (expected !== undefined) assert.equal(actual, expected); -} - -function normalizeParsedFlags(parsed: any) { - return { - scope: parsed.scope, - scopeSet: parsed.scopeSet, - agentKind: Array.isArray(parsed.agents) ? "explicit" : parsed.agents, - agentIds: Array.isArray(parsed.agents) ? parsed.agents : [], - yes: parsed.yes, - dryRun: parsed.dryRun, - force: parsed.force, - errors: parsed.errors, - }; -}