diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0950aaf..207b31b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,6 +77,31 @@ jobs: - name: Build Python package if: steps.published.outputs.pypi != 'true' run: uv build --directory python + - name: Publish Go core module tag + run: | + version="${GITHUB_REF_NAME#v}" + go_tag="go/v${version}" + if git rev-parse -q --verify "refs/tags/${go_tag}" >/dev/null; then + test "$(git rev-list -n 1 "${go_tag}")" = "$GITHUB_SHA" + else + git tag "$go_tag" "$GITHUB_SHA" + git push origin "refs/tags/${go_tag}" + fi + - name: Verify Go Cobra against published core + run: node scripts/check-go-modules.mjs --published-core + env: + GOPROXY: direct + GONOSUMDB: github.com/lathe-cli/kitup/go + - name: Publish Go Cobra module tag + run: | + version="${GITHUB_REF_NAME#v}" + go_tag="go-cobra/v${version}" + if git rev-parse -q --verify "refs/tags/${go_tag}" >/dev/null; then + test "$(git rev-list -n 1 "${go_tag}")" = "$GITHUB_SHA" + else + git tag "$go_tag" "$GITHUB_SHA" + git push origin "refs/tags/${go_tag}" + fi - name: Publish npm package if: steps.published.outputs.npm != 'true' run: | @@ -94,18 +119,6 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: python/dist - - name: Publish Go module tags - run: | - version="${GITHUB_REF_NAME#v}" - for module in go go-cobra; do - go_tag="${module}/v${version}" - if git rev-parse -q --verify "refs/tags/${go_tag}" >/dev/null; then - test "$(git rev-list -n 1 "${go_tag}")" = "$GITHUB_SHA" - else - git tag "$go_tag" "$GITHUB_SHA" - git push origin "refs/tags/${go_tag}" - fi - done - name: Create GitHub release run: | gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes diff --git a/README.md b/README.md index a9d51fb..82e2252 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,9 @@ mycli skill install - validate bundled skills - install from a local directory, embedded bundle tree, or public GitHub bundle directory - copy, update, and uninstall kitup-owned installs +- inspect installed ownership and CLI build metadata without parsing files directly - refuse unsafe overwrite conflicts -- return structured install reports +- return structured install, status, and uninstall reports ## What it is not @@ -91,6 +92,8 @@ const githubSkillBundle = githubBundle({ }); ``` +Attach CLI build identity to a bundled or embedded skill with `withBundleMetadata`. The SDK records it in the backward-compatible `.kitup.json` document and exposes it through `readInstalledMetadata` and `statusBundledSkill`. + ### Go Install: @@ -143,11 +146,14 @@ import ( ) root.AddCommand(kitupcobra.NewSkillCommand(kitupcobra.Options{ - AppID: "mycli", - Bundle: kitup.FSBundle(embeddedSkills, "skills/mycli"), + AppID: "mycli", + SkillName: "mycli", + Bundle: kitup.FSBundle(embeddedSkills, "skills/mycli"), })) ``` +The adapter mounts `skill install`, `skill status`, and `skill uninstall`. Status and uninstall accept `--json`; non-interactive uninstall requires `--yes`, while interactive uninstall confirms before removing any target. + ### Rust Install: @@ -225,7 +231,7 @@ from kitup import resources_bundle bundle = resources_bundle(files("mycli.skills") / "mycli") ``` -For non-interactive or embedding scenarios, call `install_bundled_skill`, `plan_bundled_skill`, `update_bundled_skill`, or `uninstall_bundled_skill` directly. +For non-interactive or embedding scenarios, call `install_bundled_skill`, `plan_bundled_skill`, `update_bundled_skill`, `status_bundled_skill`, `read_installed_metadata`, or `uninstall_bundled_skill` directly. ## Docs diff --git a/docs/API.md b/docs/API.md index 7e4dce6..50661b4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -30,10 +30,13 @@ import { parseInstallFlags, classifyInstallWorkflowExit, resolveInstallSelection, + readInstalledMetadata, runBundledSkillInstall, + statusBundledSkill, uninstallBundledSkill, updateBundledSkill, validateSkillBundle, + withBundleMetadata, } from "@kitup/sdk"; ``` @@ -81,6 +84,7 @@ Implemented functions: - `filesBundle(files)` - `moduleDirBundle(importMetaUrl, relativePath)` - `githubBundle(options)` +- `withBundleMetadata(bundle, metadata)` - `parseInstallFlags(flags)` - `agentSelectorFromFlags(values)` - `parseScopeFlag(value)` @@ -91,6 +95,8 @@ Implemented functions: - `planBundledSkill(options)` - `installBundledSkill(options)` - `updateBundledSkill(options)` +- `statusBundledSkill(options)` +- `readInstalledMetadata(targetDir)` - `uninstallBundledSkill(options)` - `installUxText` @@ -140,6 +146,7 @@ Implemented functions: - `FSBundle(fsys, root)` - `FilesBundle(files)` - `GitHubBundle(opts)` +- `WithBundleMetadata(bundle, meta)` - `ParseInstallFlags(flags)` - `AgentSelectorFromFlags(values)` - `ParseScopeFlag(value)` @@ -150,6 +157,8 @@ Implemented functions: - `PlanBundledSkill(opts)` - `InstallBundledSkill(opts)` - `UpdateBundledSkill(opts)` +- `StatusBundledSkill(opts)` +- `ReadInstalledMetadata(targetDir)` - `UninstallBundledSkill(opts)` - `InstallUX` @@ -157,6 +166,10 @@ Optional Cobra adapter module: `github.com/lathe-cli/kitup/go-cobra` - `NewSkillCommand(opts)` - `NewInstallCommand(opts)` +- `NewStatusCommand(opts)` +- `NewUninstallCommand(opts)` + +Set `Options.SkillName` when mounting the lifecycle commands. Both commands accept `--scope` and repeatable `--agent`; status also accepts `--json`, while uninstall accepts `--json` and `--yes`. A non-TTY uninstall without `--yes` fails before mutation. In TTY mode, uninstall renders the owned targets and confirms before calling the core API. JSON output keeps prompts off stdout. ## Rust @@ -217,6 +230,7 @@ Implemented functions: - `files_bundle(files)` - `include_dir_bundle(dir)` with the `include-dir` feature - `github_bundle(options)` +- `with_bundle_metadata(bundle, metadata)` - `parse_install_flags(flags)` - `agent_selector_from_flags(values, errors)` - `parse_scope_flag(value, errors)` @@ -228,6 +242,8 @@ Implemented functions: - `plan_bundled_skill(options)` - `install_bundled_skill(options)` - `update_bundled_skill(options)` +- `status_bundled_skill(options)` +- `read_installed_metadata(target_dir)` - `uninstall_bundled_skill(options)` - `INSTALL_UX` @@ -257,10 +273,13 @@ from kitup import ( resolve_install_selection, resolve_install_targets, resources_bundle, + read_installed_metadata, run_bundled_skill_install, + status_bundled_skill, uninstall_bundled_skill, update_bundled_skill, validate_skill_bundle, + with_bundle_metadata, ) ``` @@ -326,6 +345,7 @@ Implemented functions: - `files_bundle(files)` - `resources_bundle(root)` - `github_bundle(options)` +- `with_bundle_metadata(bundle, metadata)` - `parse_install_flags(flags)` - `agent_selector_from_flags(values, errors)` - `parse_scope_flag(value, errors)` @@ -337,6 +357,8 @@ Implemented functions: - `plan_bundled_skill(options)` - `install_bundled_skill(options)` - `update_bundled_skill(options)` +- `status_bundled_skill(options)` +- `read_installed_metadata(target_dir)` - `uninstall_bundled_skill(options)` - `INSTALL_UX` @@ -361,6 +383,23 @@ The first non-local bundle constructor is GitHub only: GitHub bundle resolution downloads only files under the configured directory path, requires `SKILL.md` at that bundle root, records the requested ref and resolved commit, and writes GitHub provenance into `.kitup.json`. It does not search GitHub, install dependencies, execute scripts, handle private auth, or install whole repositories by default. +## Installed metadata and lifecycle status + +Bundled and embedded inputs can attach optional build identity without changing the skill content hash: + +- `sourceId` / `SourceID` / `source_id`: stable caller-defined bundle identity +- `cliVersion` / `CLIVersion` / `cli_version`: embedding CLI release version +- `cliRevision` / `CLIRevision` / `cli_revision`: embedding CLI source or build revision +- `provenance`: string-to-string build provenance + +Use `withBundleMetadata` / `WithBundleMetadata` / `with_bundle_metadata` with the language's `BundledSkillMetadata` type. Explicit metadata changes refresh `.kitup.json` even when skill bytes are unchanged. The existing schema stays at `schemaVersion: 1`; all new fields are optional, so metadata written by older kitup versions remains readable. The existing `version` field remains the source version or GitHub ref and is not overloaded with the embedding CLI version. + +`InstalledMetadata` is the normalized public type returned by the reader API and included in each installed status entry. A missing target returns no metadata. An existing directory with missing or malformed ownership metadata returns an error from the reader and an `unmanaged` conflict from status. + +Status is local and offline. `StatusReport` contains `installed`, `missing`, `conflicts`, and `errors`, using the same host selection and compatible-path rules as install and uninstall. It does not resolve or fetch a GitHub bundle. + +Core uninstall revalidates `appId` and `skillName` after atomically moving the target to a same-parent quarantine path. A changed, malformed, or mismatched target is restored and reported as a conflict instead of being deleted. There is no implicit uninstall force mode. + The embedding CLI owns command names and framework attachment. `kitup` owns standard install flag semantics, selector mapping, user-facing workflow text, summary rendering, confirmation, dry-run planning, workflow exit classification, and execution. For user-facing commands, call `runBundledSkillInstall` / `RunBundledSkillInstall` / `run_bundled_skill_install` with values from the shared flag parsing helpers. Workflow-only options: diff --git a/docs/RELEASE.md b/docs/RELEASE.md index a67d090..2966b7f 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -53,7 +53,7 @@ git push origin vX.Y.Z Do not tag the release branch. Do not publish packages by hand during the normal flow. -The release workflow publishes npm, PyPI, and crates.io packages, creates the `go/vX.Y.Z` and `go-cobra/vX.Y.Z` tags, creates the GitHub Release, and runs the public install smoke check. +The release workflow creates `go/vX.Y.Z`, verifies the Cobra adapter against that published core version, creates `go-cobra/vX.Y.Z`, publishes npm, PyPI, and crates.io packages, creates the GitHub Release, and runs the public install smoke check. Both Go modules keep the same release version; only their publication steps are ordered by dependency. ## First npm Release @@ -76,6 +76,7 @@ The release workflow is resumable: - If PyPI already has the version, Python build and publish are skipped. - If crates.io already has the version, crate publish is skipped. - If `go/vX.Y.Z` or `go-cobra/vX.Y.Z` already exists, the workflow verifies that it points at the release commit. +- If Cobra verification fails after the core tag is published, rerunning the workflow reuses the verified core tag and does not create the Cobra tag until its declared core dependency passes. Do not delete and recreate a release tag after any registry has accepted the version unless the tag points at the wrong commit and the recovery plan is explicit. diff --git a/docs/architecture.mmd b/docs/architecture.mmd index 7ec066c..da74e1a 100644 --- a/docs/architecture.mmd +++ b/docs/architecture.mmd @@ -3,12 +3,12 @@ flowchart TB subgraph SDK["SDK (ts / go / rust / python)"] direction TB - WORKFLOW["Install Workflow\nRunBundledSkillInstall"]:::execution + WORKFLOW["Install Workflow and Cobra Adapter\ninstall · status · uninstall"]:::execution BUNDLE["Bundle Resolver\nlocal · embedded · GitHub"]:::execution VALIDATE["Validator\nSKILL.md frontmatter"]:::execution HOST["Host Resolver\nids · aliases · detection · targets"]:::execution - INSTALL["Installer\nplan · conflict policy · copy · update · uninstall"]:::execution - REPORT["Reports\nInstallReport · UninstallReport"]:::execution + INSTALL["Lifecycle Core\nplan · status · copy · update · safe uninstall"]:::execution + REPORT["Reports\nInstallReport · StatusReport · UninstallReport"]:::execution end HOSTSPEC["Host Spec\nspec/hosts.json"]:::contract @@ -21,7 +21,7 @@ flowchart TB TARGETS["Agent Host\nDirectory State"]:::state METADATA[".kitup.json"]:::state - AUTHOR -->|"provides flags"| WORKFLOW + AUTHOR -->|"provides flags and optional build metadata"| WORKFLOW WORKFLOW --> BUNDLE WORKFLOW --> HOST BUNDLE --> VALIDATE @@ -31,7 +31,7 @@ flowchart TB VALIDATE --> INSTALL HOST --> INSTALL INSTALL -->|"copies, updates, removes"| TARGETS - INSTALL -->|"writes .kitup.json"| METADATA + INSTALL -->|"reads and writes .kitup.json"| METADATA INSTALL -->|"returns report"| REPORT SCHEMAS -.-> HOSTSPEC diff --git a/go-cobra/skill.go b/go-cobra/skill.go index d7dee5d..bf0a28f 100644 --- a/go-cobra/skill.go +++ b/go-cobra/skill.go @@ -1,7 +1,13 @@ package kitupcobra import ( + "bufio" + "encoding/json" + "errors" + "fmt" "io" + "os" + "strings" kitup "github.com/lathe-cli/kitup/go" "github.com/spf13/cobra" @@ -9,6 +15,7 @@ import ( type Options struct { AppID string + SkillName string Bundle kitup.SkillBundle DefaultScope kitup.Scope Home string @@ -27,7 +34,151 @@ func NewSkillCommand(opts Options) *cobra.Command { Short: kitup.InstallUX.SkillShort, SilenceUsage: true, } - cmd.AddCommand(NewInstallCommand(opts)) + cmd.AddCommand(NewInstallCommand(opts), NewStatusCommand(opts), NewUninstallCommand(opts)) + return cmd +} + +func NewStatusCommand(opts Options) *cobra.Command { + scope := defaultScope(opts) + var agents []string + var jsonOutput bool + cmd := &cobra.Command{ + Use: "status", + Short: "Show bundled Agent Skill status", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + parsed := kitup.ParseInstallFlags(kitup.InstallFlagValues{Scope: scope, ScopeSet: true, Agents: agents}) + if err := kitup.InstallFlagError(parsed.Errors); err != nil { + return err + } + selected, err := lifecycleAgents(opts, parsed.Scope, parsed.Agents, len(agents) > 0) + if err != nil { + return err + } + report, err := kitup.StatusBundledSkill(kitup.StatusOptions{ + BaseOptions: baseOptions(opts), + AppID: opts.AppID, + SkillName: opts.SkillName, + Scope: parsed.Scope, + Agents: selected, + }) + if err != nil { + return err + } + if jsonOutput { + if err := writeJSON(output(cmd, opts), report); err != nil { + return err + } + } else { + renderStatusReport(output(cmd, opts), report) + } + return statusReportError(report) + }, + } + cmd.Flags().StringVar(&scope, "scope", scope, kitup.InstallUX.ScopeFlag) + cmd.Flags().StringArrayVar(&agents, "agent", nil, kitup.InstallUX.AgentFlag) + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Write a structured JSON report") + return cmd +} + +func NewUninstallCommand(opts Options) *cobra.Command { + scope := defaultScope(opts) + var agents []string + var yes bool + var jsonOutput bool + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Uninstall bundled Agent Skill", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + parsed := kitup.ParseInstallFlags(kitup.InstallFlagValues{Scope: scope, ScopeSet: true, Agents: agents, Yes: yes}) + if err := kitup.InstallFlagError(parsed.Errors); err != nil { + return err + } + in := input(cmd, opts) + if !parsed.Yes && !isTTY(in, opts.StdinTTY) { + return errors.New("kitup: uninstall requires --yes when stdin is not a TTY") + } + selected, err := lifecycleAgents(opts, parsed.Scope, parsed.Agents, len(agents) > 0) + if err != nil { + return err + } + status, err := kitup.StatusBundledSkill(kitup.StatusOptions{ + BaseOptions: baseOptions(opts), + AppID: opts.AppID, + SkillName: opts.SkillName, + Scope: parsed.Scope, + Agents: selected, + }) + if err != nil { + return err + } + 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) + } + 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 + } + promptOut := output(cmd, opts) + if jsonOutput { + promptOut = errOutput(cmd, opts) + } + if !jsonOutput { + renderStatusReport(promptOut, status) + } + if !parsed.Yes { + confirmed, err := confirmUninstall(in, promptOut, len(status.Installed)) + if err != nil { + return err + } + if !confirmed { + _, _ = fmt.Fprintln(promptOut, "Uninstall canceled.") + if jsonOutput { + return writeJSON(output(cmd, opts), uninstallReportFromStatus(status)) + } + return nil + } + } + report, err := kitup.UninstallBundledSkill(kitup.UninstallOptions{ + BaseOptions: baseOptions(opts), + AppID: opts.AppID, + SkillName: opts.SkillName, + Scope: parsed.Scope, + Agents: selected, + }) + 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 len(report.Conflicts)+len(report.Errors) > 0 { + return errors.New("kitup: uninstall failed") + } + return nil + }, + } + cmd.Flags().StringVar(&scope, "scope", scope, kitup.InstallUX.ScopeFlag) + cmd.Flags().StringArrayVar(&agents, "agent", nil, kitup.InstallUX.AgentFlag) + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip uninstall confirmation") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Write a structured JSON report") return cmd } @@ -92,6 +243,125 @@ func NewInstallCommand(opts Options) *cobra.Command { return cmd } +func baseOptions(opts Options) kitup.BaseOptions { + return kitup.BaseOptions{Home: opts.Home, CWD: opts.CWD, HostsFile: opts.HostsFile} +} + +func defaultScope(opts Options) string { + if opts.DefaultScope == kitup.ProjectScope { + return string(kitup.ProjectScope) + } + return string(kitup.UserScope) +} + +func lifecycleAgents(opts Options, scope kitup.Scope, parsed kitup.AgentSelector, explicit bool) (kitup.AgentSelector, error) { + if explicit || opts.CurrentAgent == "" { + return parsed, nil + } + selection, err := kitup.ResolveInstallSelection(kitup.InstallSelectionOptions{ + BaseOptions: baseOptions(opts), + Scope: scope, + Agents: kitup.AutoAgents(), + Yes: true, + CurrentAgent: opts.CurrentAgent, + }) + if err != nil { + return kitup.AgentSelector{}, err + } + if len(selection.Errors) > 0 { + return kitup.AgentSelector{}, errors.New("kitup: agent selection failed") + } + return kitup.ExplicitAgents(selection.SelectedHostIDs...), nil +} + +func statusReportError(report kitup.StatusReport) error { + if len(report.Conflicts) > 0 { + return errors.New("kitup: status has conflicts") + } + if len(report.Errors) > 0 { + return errors.New("kitup: status failed") + } + return nil +} + +func uninstallReportFromStatus(status kitup.StatusReport) kitup.UninstallReport { + report := kitup.UninstallReport{ + Removed: []kitup.TargetResult{}, + Skipped: []kitup.TargetStatus{}, + Conflicts: append([]kitup.TargetStatus{}, status.Conflicts...), + Errors: append([]kitup.ReportError{}, status.Errors...), + } + for _, target := range status.Missing { + report.Skipped = append(report.Skipped, kitup.TargetStatus{TargetResult: target, Reason: "missing"}) + } + return report +} + +func confirmUninstall(in io.Reader, out io.Writer, count int) (bool, error) { + if _, err := fmt.Fprintf(out, "Remove %d installed target(s)? [y/N] ", count); err != nil { + return false, err + } + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return false, err + } + answer := strings.ToLower(strings.TrimSpace(line)) + return answer == "y" || answer == "yes", nil +} + +func isTTY(in io.Reader, configured bool) bool { + if configured { + return true + } + file, ok := in.(*os.File) + if !ok { + return false + } + info, err := file.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} + +func writeJSON(out io.Writer, value any) error { + return json.NewEncoder(out).Encode(value) +} + +func renderStatusReport(out io.Writer, report kitup.StatusReport) { + for _, item := range report.Installed { + _, _ = fmt.Fprintf(out, "installed\t%s\t%s\n", targetHosts(item.TargetResult), item.TargetDir) + } + 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) + } +} + +func renderUninstallReport(out io.Writer, report kitup.UninstallReport) { + for _, item := range report.Removed { + _, _ = fmt.Fprintf(out, "removed\t%s\t%s\n", targetHosts(item), item.TargetDir) + } + 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 { + _, _ = 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) + } +} + +func targetHosts(target kitup.TargetResult) string { + if target.HostID != "" { + return target.HostID + } + return strings.Join(target.HostIDs, ",") +} + func input(cmd *cobra.Command, opts Options) io.Reader { if opts.In != nil { return opts.In diff --git a/go-cobra/skill_test.go b/go-cobra/skill_test.go index ae072da..4423134 100644 --- a/go-cobra/skill_test.go +++ b/go-cobra/skill_test.go @@ -2,6 +2,7 @@ package kitupcobra import ( "bytes" + "encoding/json" "os" "path/filepath" "strings" @@ -100,6 +101,99 @@ func TestInstallCommandReturnsCoreFlagError(t *testing.T) { } } +func TestSkillCommandStatusJSON(t *testing.T) { + home := t.TempDir() + installBasic(t, home) + var out bytes.Buffer + cmd := NewSkillCommand(Options{ + AppID: "example-cli", + SkillName: "basic", + Bundle: basicBundle(), + Home: home, + Out: &out, + }) + cmd.SetArgs([]string{"status", "--agent", "codex", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var report kitup.StatusReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatal(err) + } + if len(report.Installed) != 1 || report.Installed[0].Metadata.AppID != "example-cli" { + t.Fatalf("unexpected report: %+v", report) + } +} + +func TestUninstallCommandRequiresYesWithoutTTY(t *testing.T) { + home := t.TempDir() + installBasic(t, home) + cmd := NewUninstallCommand(Options{ + AppID: "example-cli", + SkillName: "basic", + Home: home, + In: strings.NewReader(""), + }) + cmd.SetArgs([]string{"--agent", "codex"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected non-interactive uninstall to require confirmation bypass") + } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "basic", ".kitup.json")); err != nil { + t.Fatal(err) + } +} + +func TestUninstallCommandJSONKeepsPromptOffStdout(t *testing.T) { + home := t.TempDir() + installBasic(t, home) + var out bytes.Buffer + var stderr bytes.Buffer + cmd := NewUninstallCommand(Options{ + AppID: "example-cli", + SkillName: "basic", + Home: home, + StdinTTY: true, + In: strings.NewReader("y\n"), + Out: &out, + Err: &stderr, + }) + cmd.SetArgs([]string{"--agent", "codex", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var report kitup.UninstallReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatal(err) + } + if len(report.Removed) != 1 { + t.Fatalf("unexpected report: %+v", report) + } + if strings.Contains(out.String(), "Remove ") || !strings.Contains(stderr.String(), "Remove 1 installed target") { + t.Fatalf("stdout=%q stderr=%q", out.String(), stderr.String()) + } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "basic")); !os.IsNotExist(err) { + t.Fatalf("expected target removed, got %v", err) + } +} + +func installBasic(t *testing.T, home string) { + t.Helper() + report, err := kitup.InstallBundledSkill(kitup.InstallOptions{ + BaseOptions: kitup.BaseOptions{Home: home}, + AppID: "example-cli", + SkillBundle: basicBundle(), + Scope: kitup.UserScope, + Agents: kitup.ExplicitAgents("codex"), + }) + if err != nil { + t.Fatal(err) + } + if len(report.Installed) != 1 { + t.Fatalf("unexpected install report: %+v", report) + } +} + func basicBundle() kitup.SkillBundle { return kitup.FilesBundle([]kitup.SkillFile{{ Path: "SKILL.md", diff --git a/go/kitup.go b/go/kitup.go index 6da2146..ac12bb9 100644 --- a/go/kitup.go +++ b/go/kitup.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "io/fs" + "maps" "net/http" "net/url" "os" @@ -242,6 +243,14 @@ type UninstallOptions struct { Agents AgentSelector } +type StatusOptions struct { + BaseOptions + AppID string + SkillName string + Scope Scope + Agents AgentSelector +} + type InstallSelectionOptions struct { BaseOptions Scope Scope @@ -279,12 +288,21 @@ type SkillFile struct { } type SkillBundle struct { - kind string - dir string - fsys fs.FS - root string - files []SkillFile - github GitHubBundleOptions + 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 { @@ -310,6 +328,17 @@ 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 @@ -353,6 +382,31 @@ type UninstallReport struct { 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"` @@ -371,22 +425,16 @@ type InstallWorkflowReport struct { DryRun bool `json:"dryRun"` } -type metadata 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"` - Provenance map[string]string `json:"provenance,omitempty"` -} +type metadata = InstalledMetadata type bundleMetadata struct { - Source string - SourceID string - Version string - Provenance map[string]string + Source string + SourceID string + Version string + CLIVersion string + CLIRevision string + Provenance map[string]string + Explicit bool } type bundleFile struct { @@ -879,15 +927,57 @@ func UninstallBundledSkill(opts UninstallOptions) (UninstallReport, error) { case meta.AppID != opts.AppID: report.Conflicts = append(report.Conflicts, withReason(result, "owner-mismatch")) default: - if err := os.RemoveAll(target.TargetDir); err != nil { + 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 @@ -948,7 +1038,8 @@ func installOrPlan(opts InstallOptions, write bool) (InstallReport, error) { if err != nil { return report, err } - if repaired { + 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 @@ -1034,6 +1125,37 @@ func makeStagingDir(targetDir string) (string, error) { 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 @@ -1078,19 +1200,7 @@ func repairSkillBundleModes(bundle normalizedSkillBundle, dest string, write boo } func writeMetadata(targetDir, appID, skillName, hash string, bundleMeta bundleMetadata) error { - meta := metadata{ - SchemaVersion: 1, - AppID: appID, - SkillName: skillName, - Source: bundleMeta.Source, - Hash: hash, - SourceID: bundleMeta.SourceID, - Version: bundleMeta.Version, - Provenance: bundleMeta.Provenance, - } - if meta.Source == "" { - meta.Source = "bundled" - } + meta := newInstalledMetadata(appID, skillName, hash, bundleMeta) data, err := json.MarshalIndent(meta, "", " ") if err != nil { return err @@ -1119,6 +1229,8 @@ func readMetadata(targetDir string) (metadata, bool, bool) { "hash": &meta.Hash, "sourceId": &meta.SourceID, "version": &meta.Version, + "cliVersion": &meta.CLIVersion, + "cliRevision": &meta.CLIRevision, "provenance": &meta.Provenance, } for name, destination := range fields { @@ -1136,6 +1248,38 @@ func readMetadata(targetDir string) (metadata, bool, bool) { 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 != "" && @@ -1177,6 +1321,15 @@ func emptyUninstallReport(errs []map[string]any) UninstallReport { } } +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{} @@ -1450,16 +1603,35 @@ func parseFrontmatter(content string) map[string]string { } func resolveSkillBundle(bundle SkillBundle) (normalizedSkillBundle, bundleMetadata, error) { + var normalized normalizedSkillBundle + var meta bundleMetadata + var err error switch bundle.kind { case "github": - return resolveGitHubBundle(bundle.github) + normalized, meta, err = resolveGitHubBundle(bundle.github) default: - normalized, err := readSkillBundle(bundle) - if err != nil { - return normalizedSkillBundle{}, bundleMetadata{}, err + 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 } - return normalized, bundleMetadata{Source: "bundled"}, nil } + meta.Explicit = bundle.metaSet + return normalized, meta, nil } func resolveGitHubBundle(opts GitHubBundleOptions) (normalizedSkillBundle, bundleMetadata, error) { diff --git a/python/src/kitup/__init__.py b/python/src/kitup/__init__.py index d6d1893..8be6879 100644 --- a/python/src/kitup/__init__.py +++ b/python/src/kitup/__init__.py @@ -5,12 +5,15 @@ github_bundle, resources_bundle, validate_skill_bundle, + with_bundle_metadata, ) from .hosts import detect_hosts, load_host_spec, resolve_hosts from .install import ( install_bundled_skill, plan_bundled_skill, resolve_install_targets, + read_installed_metadata, + status_bundled_skill, uninstall_bundled_skill, update_bundled_skill, ) @@ -27,6 +30,7 @@ ) from .types import ( BaseOptions, + BundledSkillMetadata, GitHubBundleOptions, Host, HostSpec, @@ -38,9 +42,13 @@ InstallWorkflowExit, InstallWorkflowOptions, InstallWorkflowReport, + InstalledMetadata, + InstalledTarget, KitupError, ParsedInstallFlags, SkillFile, + StatusOptions, + StatusReport, TargetError, TargetGroup, TargetResult, @@ -51,6 +59,7 @@ __all__ = [ "BaseOptions", + "BundledSkillMetadata", "GitHubBundleOptions", "Host", "HostSpec", @@ -62,9 +71,13 @@ "InstallWorkflowExit", "InstallWorkflowOptions", "InstallWorkflowReport", + "InstalledMetadata", + "InstalledTarget", "KitupError", "ParsedInstallFlags", "SkillFile", + "StatusOptions", + "StatusReport", "TargetError", "TargetGroup", "TargetResult", @@ -77,6 +90,7 @@ "files_bundle", "github_bundle", "resources_bundle", + "with_bundle_metadata", "agent_selector_from_flags", "classify_install_workflow_exit", "install_bundled_skill", @@ -89,6 +103,8 @@ "resolve_hosts", "resolve_install_selection", "resolve_install_targets", + "read_installed_metadata", + "status_bundled_skill", "run_bundled_skill_install", "run_bundled_skill_install_with_io", "uninstall_bundled_skill", diff --git a/python/src/kitup/_metadata.py b/python/src/kitup/_metadata.py index 31dfbb2..a3d9053 100644 --- a/python/src/kitup/_metadata.py +++ b/python/src/kitup/_metadata.py @@ -20,7 +20,9 @@ def write_install_metadata( source: str, source_id: str | None = None, version: str | None = None, - provenance: dict[str, object] | None = None, + cli_version: str | None = None, + cli_revision: str | None = None, + provenance: dict[str, str] | None = None, ) -> None: payload = { "schemaVersion": 1, @@ -33,6 +35,10 @@ def write_install_metadata( 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 (target_dir / ".kitup.json").write_text( @@ -63,7 +69,7 @@ def is_owned_metadata(payload: dict[str, object]) -> bool: skill_name = payload.get("skillName") source = payload.get("source") digest = payload.get("hash") - return ( + required_valid = ( isinstance(app_id, str) and bool(app_id) and isinstance(skill_name, str) @@ -72,3 +78,16 @@ def is_owned_metadata(payload: dict[str, object]) -> bool: and isinstance(digest, str) and bool(digest) ) + if not required_valid: + return False + for key in ("sourceId", "version", "cliVersion", "cliRevision"): + if key in payload and not isinstance(payload[key], str): + return False + provenance = payload.get("provenance") + return provenance is None or ( + isinstance(provenance, dict) + and all( + isinstance(key, str) and isinstance(value, str) + for key, value in provenance.items() + ) + ) diff --git a/python/src/kitup/bundle.py b/python/src/kitup/bundle.py index b0cae0a..79dc8c4 100644 --- a/python/src/kitup/bundle.py +++ b/python/src/kitup/bundle.py @@ -16,6 +16,7 @@ from ._paths import normalize_bundle_path, resolve_path, skip_name from .types import ( BundleFile, + BundledSkillMetadata, GitHubBundleOptions, KitupError, NormalizedSkillBundle, @@ -39,7 +40,13 @@ class GitHubBundle: options: GitHubBundleOptions -SkillBundle = DirectoryBundle | FilesBundle | GitHubBundle +@dataclass(frozen=True) +class MetadataBundle: + bundle: object + metadata: BundledSkillMetadata + + +SkillBundle = DirectoryBundle | FilesBundle | GitHubBundle | MetadataBundle def directory_bundle(path: str) -> DirectoryBundle: @@ -60,6 +67,12 @@ def github_bundle(options: GitHubBundleOptions) -> GitHubBundle: return GitHubBundle(options=options) +def with_bundle_metadata( + bundle: SkillBundle, metadata: BundledSkillMetadata +) -> MetadataBundle: + return MetadataBundle(bundle=bundle, metadata=metadata) + + def _collect_resource_files( node: Traversable, prefix: str, files: list[SkillFile] ) -> None: @@ -141,6 +154,8 @@ def normalize_skill_bundle( return normalize_files_bundle(bundle.files) if isinstance(bundle, GitHubBundle): return normalize_files_bundle(fetch_github_directory(bundle.options)) + if isinstance(bundle, MetadataBundle): + return normalize_skill_bundle(bundle.bundle, cwd=cwd) raise KitupError(f"unsupported bundle: {type(bundle)!r}") diff --git a/python/src/kitup/install.py b/python/src/kitup/install.py index 3c754e4..d7e9835 100644 --- a/python/src/kitup/install.py +++ b/python/src/kitup/install.py @@ -15,6 +15,7 @@ DirectoryBundle, FilesBundle, GitHubBundle, + MetadataBundle, copy_normalized_bundle, compute_normalized_bundle_content_hash, normalize_directory_bundle, @@ -26,9 +27,14 @@ BaseOptions, BundleFile, Host, + InstalledMetadata, + InstalledTarget, InstallOptions, InstallReport, + KitupError, Scope, + StatusOptions, + StatusReport, TargetError, TargetGroup, TargetResult, @@ -121,6 +127,10 @@ def empty_uninstall_report(errors: list[TargetError] | None = None) -> Uninstall 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( @@ -217,7 +227,7 @@ def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: except Exception: reason = ( "bundle-resolve-failed" - if isinstance(options.skill_bundle, GitHubBundle) + if _is_github_bundle(options.skill_bundle) else "invalid-skill-bundle" ) return empty_install_report([TargetError(reason=reason)]) @@ -288,7 +298,16 @@ def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: report.conflicts.append(target_status(target, "owner-mismatch")) continue if metadata.get("hash") == digest: - if repair_bundle_modes(normalized.files, target_dir, write=write): + 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, @@ -350,6 +369,8 @@ def write_bundle_metadata( 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), ) @@ -381,15 +402,88 @@ def uninstall_bundled_skill(options: UninstallOptions) -> UninstallReport: report.conflicts.append(target_status(target, "owner-mismatch")) continue - shutil.rmtree(target_dir) + reason = _remove_managed_skill( + target_dir, + app_id=options.app_id, + skill_name=options.skill_name, + ) + if reason is not None: + report.conflicts.append(target_status(target, reason)) + continue report.removed.append(result) return report +def status_bundled_skill(options: StatusOptions) -> StatusReport: + if not options.app_id: + return empty_status_report([TargetError(reason="invalid-app-id")]) + targets, errors = _resolve_install_targets_with_errors( + options.base, + options.agents, + options.scope, + options.skill_name, + uninstall_app_id=options.app_id, + ) + report = empty_status_report(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")) + 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, + metadata=_installed_metadata(metadata), + ) + ) + return report + + +def read_installed_metadata( + target_dir: str | Path, +) -> InstalledMetadata | None: + target = Path(target_dir) + if not target.exists(): + return None + metadata = read_install_metadata(target) + if metadata is None: + raise KitupError("unmanaged install 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" @@ -402,6 +496,80 @@ def _resolve_bundle_and_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: + quarantine = Path( + tempfile.mkdtemp( + prefix=f".{target_dir.name}.kitup-uninstall-", + dir=target_dir.parent, + ) + ) + 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" + if reason is not None: + if target_dir.exists(): + raise KitupError(f"cannot restore changed install: {target_dir}") + quarantine.replace(target_dir) + 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, @@ -473,6 +641,11 @@ def _metadata_text(metadata: dict[str, object], key: str) -> str | None: 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/types.py b/python/src/kitup/types.py index b14eda6..a10a6dc 100644 --- a/python/src/kitup/types.py +++ b/python/src/kitup/types.py @@ -71,6 +71,14 @@ class GitHubBundleOptions: ref: str +@dataclass(frozen=True) +class BundledSkillMetadata: + source_id: str | None = None + cli_version: str | None = None + cli_revision: str | None = None + provenance: dict[str, str] = field(default_factory=dict) + + @dataclass(frozen=True) class BundleFile: path: str @@ -104,6 +112,15 @@ class UninstallOptions: agents: str | list[str] = "auto" +@dataclass(frozen=True) +class StatusOptions: + base: BaseOptions + app_id: str + skill_name: str + scope: Scope + agents: str | list[str] = "auto" + + @dataclass(frozen=True) class TargetResult: skill_name: str @@ -143,6 +160,37 @@ class UninstallReport: errors: list[TargetError] = field(default_factory=list) +@dataclass(frozen=True) +class InstalledMetadata: + schema_version: int + app_id: str + skill_name: str + source: Literal["bundled", "github"] + hash: 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 + + +@dataclass(frozen=True) +class InstalledTarget: + skill_name: str + target_dir: str + metadata: InstalledMetadata + host_id: str | None = None + host_ids: list[str] | None = None + + +@dataclass(frozen=True) +class StatusReport: + installed: list[InstalledTarget] = field(default_factory=list) + missing: list[TargetResult] = field(default_factory=list) + conflicts: list[TargetStatus] = field(default_factory=list) + errors: list[TargetError] = field(default_factory=list) + + @dataclass class InstallSelection: action: Literal["install", "select-agents", "error"] diff --git a/python/tests/golden_test.py b/python/tests/golden_test.py index a72b85f..d1a337d 100644 --- a/python/tests/golden_test.py +++ b/python/tests/golden_test.py @@ -12,10 +12,12 @@ from kitup import ( BaseOptions, + BundledSkillMetadata, InstallOptions, InstallSelectionOptions, InstallWorkflowOptions, ParsedInstallFlags, + StatusOptions, UninstallOptions, classify_install_workflow_exit, compute_bundle_content_hash, @@ -31,9 +33,11 @@ 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 @@ -183,6 +187,8 @@ def run_case(case, home: Path, workspace: Path) -> None: 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) @@ -438,6 +444,20 @@ def uninstall_options_from_case(case, home: Path, workspace: Path) -> UninstallO ) +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: @@ -471,13 +491,14 @@ def workflow_options_from_case( def skill_bundle_from_case(case) -> object: + bundle: object if "skillFiles" in case["options"]: - return files_bundle(skill_files(case["options"]["skillFiles"])) - if "skillBundleDir" in case["options"]: - return directory_bundle(str(repo_path(case["options"]["skillBundleDir"]))) - if "githubBundle" 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"] - return github_bundle( + bundle = github_bundle( GitHubBundleOptions( owner=bundle["owner"], repo=bundle["repo"], @@ -485,7 +506,20 @@ def skill_bundle_from_case(case) -> object: ref=bundle["ref"], ) ) - raise AssertionError(f"missing skill bundle for case {case['id']}") + 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]: diff --git a/rust/src/lib.rs b/rust/src/lib.rs index f391ad0..5176a1d 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -122,6 +122,15 @@ pub struct UninstallOptions { 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, @@ -184,6 +193,15 @@ 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)] @@ -232,6 +250,10 @@ 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, @@ -295,6 +317,43 @@ pub struct UninstallReport { 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 { @@ -325,17 +384,7 @@ pub struct InstallWorkflowExit { pub message: String, } -#[derive(Deserialize)] -struct Metadata { - #[serde(rename = "schemaVersion")] - schema_version: u32, - #[serde(rename = "appId")] - app_id: String, - #[serde(rename = "skillName")] - skill_name: String, - source: String, - hash: String, -} +type Metadata = InstalledMetadata; #[derive(Clone, Debug)] struct BundleFile { @@ -355,7 +404,10 @@ 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 { @@ -1043,7 +1095,12 @@ pub fn uninstall_bundled_skill(options: &UninstallOptions) -> io::Result { - fs::remove_dir_all(&target.target_dir)?; + 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); } } @@ -1051,6 +1108,49 @@ pub fn uninstall_bundled_skill(options: &UninstallOptions) -> io::Result 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!({ @@ -1060,7 +1160,7 @@ fn install_or_plan(options: &InstallOptions, write: bool) -> io::Result value, Err(_) => { - let reason = if matches!(options.skill_bundle, SkillBundle::GitHub(_)) { + let reason = if is_github_bundle(&options.skill_bundle) { "bundle-resolve-failed" } else { "invalid-skill-bundle" @@ -1149,7 +1249,16 @@ fn install_or_plan(options: &InstallOptions, write: bool) -> io::Result { - if repair_skill_bundle_modes(&bundle, &target.target_dir, write)? { + 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, @@ -1185,7 +1294,7 @@ fn install_or_plan(options: &InstallOptions, write: bool) -> io::Result), } fn copy_managed_skill( @@ -1272,6 +1381,39 @@ fn make_staging_dir(target_dir: &Path) -> io::Result { } } +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.files { @@ -1319,28 +1461,33 @@ fn write_metadata( hash: &str, bundle_metadata: &BundleMetadata, ) -> io::Result<()> { - let mut value = json!({ - "schemaVersion": 1, - "appId": app_id, - "skillName": skill_name, - "source": bundle_metadata.source, - "hash": hash - }); - if let Some(source_id) = &bundle_metadata.source_id { - value["sourceId"] = json!(source_id); - } - if let Some(version) = &bundle_metadata.version { - value["version"] = json!(version); - } - if !bundle_metadata.provenance.is_empty() { - value["provenance"] = json!(bundle_metadata.provenance); - } + 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; @@ -1349,7 +1496,21 @@ fn read_metadata(target_dir: &Path) -> MetadataState { return MetadataState::Unmanaged; }; match serde_json::from_slice::(&data) { - Ok(meta) if is_owned_metadata(&meta) => MetadataState::Managed(meta), + 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, } } @@ -1591,6 +1752,15 @@ fn uninstall_report(errors: Vec) -> UninstallReport { } } +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() @@ -1737,13 +1907,35 @@ fn resolve_skill_bundle( ) -> 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, }, )), } @@ -1840,7 +2032,10 @@ fn resolve_github_bundle( options.owner, options.repo, root )), version: Some(options.ref_name.clone()), + cli_version: None, + cli_revision: None, provenance, + explicit: false, }, )) } @@ -1906,6 +2101,15 @@ fn read_skill_bundle(bundle: &SkillBundle) -> io::Result 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, } } diff --git a/rust/tests/golden.rs b/rust/tests/golden.rs index 6ad17a3..65f2657 100644 --- a/rust/tests/golden.rs +++ b/rust/tests/golden.rs @@ -2,10 +2,11 @@ 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, uninstall_bundled_skill, update_bundled_skill, - validate_skill_bundle, AgentSelector, BaseOptions, GitHubBundleOptions, InstallFlagValues, - InstallOptions, InstallSelectionOptions, InstallWorkflowOptions, ParsedInstallFlags, Scope, - SkillBundle, SkillFile, UninstallOptions, + 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}; @@ -342,6 +343,15 @@ fn run_report_case( 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, @@ -645,21 +655,47 @@ fn agent_selector(value: &Value) -> AgentSelector { } fn skill_bundle_from_options(options: &Map) -> SkillBundle { - if let Some(files) = options.get("skillFiles").and_then(Value::as_array) { - return files_bundle(skill_files(files)); - } - if let Some(dir) = options.get("skillBundleDir").and_then(Value::as_str) { - return directory_bundle(repo_path(dir)); - } - if let Some(bundle) = options.get("githubBundle").and_then(Value::as_object) { - return github_bundle(GitHubBundleOptions { + 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) { + 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(), - }); - } - files_bundle(Vec::new()) + }) + } else { + files_bundle(Vec::new()) + }; + let Some(metadata) = options.get("bundleMetadata").and_then(Value::as_object) 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) + .into_iter() + .flatten() + .map(|(key, value)| (key.clone(), value.as_str().unwrap().to_string())) + .collect(), + }, + ) } fn skill_files(values: &[Value]) -> Vec { diff --git a/scripts/check-go-modules.mjs b/scripts/check-go-modules.mjs index caf6a7d..67b865e 100644 --- a/scripts/check-go-modules.mjs +++ b/scripts/check-go-modules.mjs @@ -13,6 +13,13 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; const root = new URL("../", import.meta.url); +const args = process.argv.slice(2); +if (args.some((arg) => arg !== "--published-core") || args.length > 1) { + throw new Error( + "Usage: node scripts/check-go-modules.mjs [--published-core]", + ); +} +const publishedCore = args.includes("--published-core"); const scratch = mkdtempSync(join(tmpdir(), "kitup-go-modules-")); const core = join(scratch, "go"); const cobra = join(scratch, "go-cobra"); @@ -21,40 +28,43 @@ const env = { ...process.env, GOWORK: "off" }; delete env.KITUP_TEST_REPO_ROOT; try { - cpSync(new URL("go/", root), core, { recursive: true }); cpSync(new URL("go-cobra/", root), cobra, { recursive: true }); - if (existsSync(join(core, "testdata"))) { - throw new Error( - "standalone Go module must not contain copied repository testdata", - ); - } - - run("go", ["test", "-count=1", "./..."], core); + if (publishedCore) { + run("go", ["mod", "download", "github.com/lathe-cli/kitup/go"], cobra); + run("go", ["test", "-mod=readonly", "-count=1", "./..."], cobra); + } else { + cpSync(new URL("go/", root), core, { recursive: true }); - cpSync( - new URL("tests/go-golden/golden_test.go", root), - join(core, "golden_test.go"), - ); - run("go", ["test", "-count=1", "./..."], core, { - ...env, - KITUP_TEST_REPO_ROOT: fileURLToPath(root), - }); - console.log("ok: repository Go golden parity"); + if (existsSync(join(core, "testdata"))) { + throw new Error( + "standalone Go module must not contain copied repository testdata", + ); + } - run("go", ["test", "-mod=readonly", "-count=1", "./..."], cobra); + run("go", ["test", "-count=1", "./..."], core); - run( - "go", - ["mod", "edit", "-replace=github.com/lathe-cli/kitup/go=../go"], - cobra, - ); - run("go", ["test", "-count=1", "./..."], cobra); + cpSync( + new URL("tests/go-golden/golden_test.go", root), + join(core, "golden_test.go"), + ); + run("go", ["test", "-count=1", "./..."], core, { + ...env, + KITUP_TEST_REPO_ROOT: fileURLToPath(root), + }); + console.log("ok: repository Go golden parity"); + + run( + "go", + ["mod", "edit", "-replace=github.com/lathe-cli/kitup/go=../go"], + cobra, + ); + run("go", ["test", "-count=1", "./..."], cobra); - mkdirSync(consumer); - writeFileSync( - join(consumer, "go.mod"), - `module kitup-module-smoke + mkdirSync(consumer); + writeFileSync( + join(consumer, "go.mod"), + `module kitup-module-smoke go 1.23 @@ -64,10 +74,10 @@ replace github.com/lathe-cli/kitup/go-cobra => ../go-cobra replace github.com/lathe-cli/kitup/go => ../go `, - ); - writeFileSync( - join(consumer, "main.go"), - `package main + ); + writeFileSync( + join(consumer, "main.go"), + `package main import ( "io" @@ -87,34 +97,39 @@ func main() { }) } `, - ); - run("go", ["mod", "tidy"], consumer); - const modules = output( - "go", - [ - "list", - "-m", - "-f", - "{{if .Replace}}{{.Path}}=>{{.Replace.Path}}{{end}}", - "all", - ], - consumer, - ); - for (const expected of [ - "github.com/lathe-cli/kitup/go=>../go", - "github.com/lathe-cli/kitup/go-cobra=>../go-cobra", - ]) { - if (!modules.split("\n").includes(expected)) { - throw new Error(`go list did not resolve ${expected}`); + ); + run("go", ["mod", "tidy"], consumer); + const modules = output( + "go", + [ + "list", + "-m", + "-f", + "{{if .Replace}}{{.Path}}=>{{.Replace.Path}}{{end}}", + "all", + ], + consumer, + ); + for (const expected of [ + "github.com/lathe-cli/kitup/go=>../go", + "github.com/lathe-cli/kitup/go-cobra=>../go-cobra", + ]) { + if (!modules.split("\n").includes(expected)) { + throw new Error(`go list did not resolve ${expected}`); + } } + run("go", ["test", "./..."], consumer); + run("go", ["build", "."], consumer); } - run("go", ["test", "./..."], consumer); - run("go", ["build", "."], consumer); } finally { rmSync(scratch, { recursive: true, force: true }); } -console.log("ok: standalone Go modules"); +console.log( + publishedCore + ? "ok: Go Cobra published core dependency" + : "ok: Go module source integration", +); function run(command, args, cwd, commandEnv = env) { const result = spawnSync(command, args, { diff --git a/scripts/check.mjs b/scripts/check.mjs index 00c6787..952aefb 100755 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -251,6 +251,13 @@ function validateCases(cases, hosts) { "github-bundle-dry-run", "github-bundle-resolve-failure", "github-bundle-unchanged", + "bundled-build-metadata-install", + "bundled-build-metadata-refresh", + "bundled-empty-build-metadata-noop", + "status-owned-skill", + "status-missing-skill", + "status-malformed-metadata", + "status-owner-mismatch", ]) { assert(caseIds.has(id), `missing golden case: ${id}`); } @@ -355,6 +362,26 @@ function validateReleaseWorkflow() { !workflow.includes("https://pypi.org/pypi/kitup/"), "release workflow still checks the old PyPI package name", ); + const coreTag = workflow.indexOf('go_tag="go/v${version}"'); + const publishedCoreCheck = workflow.indexOf( + "node scripts/check-go-modules.mjs --published-core", + ); + const cobraTag = workflow.indexOf('go_tag="go-cobra/v${version}"'); + const registryPublishes = [ + "- name: Publish npm package", + "- name: Publish crate", + "- name: Publish Python package", + ].map((step) => workflow.indexOf(step)); + assert( + coreTag >= 0 && + publishedCoreCheck > coreTag && + cobraTag > publishedCoreCheck, + "release workflow must publish core before verifying and tagging Go Cobra", + ); + assert( + registryPublishes.every((step) => step > cobraTag), + "release workflow must verify Go modules before registry publication", + ); const smoke = readText("scripts/smoke-release.sh"); assert( smoke.includes('"kitup-sdk==$version"'), diff --git a/testdata/cases.schema.json b/testdata/cases.schema.json index 69f1610..1e28488 100644 --- a/testdata/cases.schema.json +++ b/testdata/cases.schema.json @@ -47,6 +47,7 @@ "parse-install-flags", "plan", "run-install-workflow", + "status", "resolve-install-selection", "resolve-install-targets", "resolve-hosts", diff --git a/testdata/cases/bundled-skill-install.json b/testdata/cases/bundled-skill-install.json index c57f941..569c73d 100644 --- a/testdata/cases/bundled-skill-install.json +++ b/testdata/cases/bundled-skill-install.json @@ -3633,6 +3633,398 @@ ] } }, + { + "id": "bundled-build-metadata-install", + "operation": "install", + "description": "Persists caller-supplied CLI identity and provenance for a bundled skill.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "bundleMetadata": { + "sourceId": "example-cli:basic", + "cliVersion": "1.2.3", + "cliRevision": "abc123", + "provenance": { + "channel": "stable", + "platform": "test" + } + }, + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents" + ], + "files": {} + }, + "expected": { + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "hash": "from-skill-bundle-dir", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "sourceId": "example-cli:basic", + "cliVersion": "1.2.3", + "cliRevision": "abc123", + "provenance": { + "channel": "stable", + "platform": "test" + } + } + } + } + }, + { + "id": "bundled-build-metadata-refresh", + "operation": "update", + "description": "Refreshes explicitly supplied CLI metadata without requiring skill content changes.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "bundleMetadata": { + "sourceId": "example-cli:basic", + "cliVersion": "1.2.4", + "cliRevision": "def456", + "provenance": { + "channel": "stable" + } + }, + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents" + ], + "files": {}, + "copySkillBundleTo": "$HOME/.agents/skills/basic", + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "hash": "from-skill-bundle-dir", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "sourceId": "example-cli:basic", + "cliVersion": "1.2.3", + "cliRevision": "abc123", + "provenance": { + "channel": "stable" + } + } + } + }, + "expected": { + "report": { + "installed": [], + "updated": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "hash": "from-skill-bundle-dir", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "sourceId": "example-cli:basic", + "cliVersion": "1.2.4", + "cliRevision": "def456", + "provenance": { + "channel": "stable" + } + } + } + } + }, + { + "id": "bundled-empty-build-metadata-noop", + "operation": "install", + "description": "Treats empty optional CLI metadata as absent so an unchanged install remains skipped.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "bundleMetadata": { + "sourceId": "", + "cliVersion": "", + "cliRevision": "" + }, + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents" + ], + "files": {}, + "copySkillBundleTo": "$HOME/.agents/skills/basic", + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "hash": "from-skill-bundle-dir", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled" + } + } + }, + "expected": { + "report": { + "installed": [], + "updated": [], + "skipped": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unchanged" + } + ], + "conflicts": [], + "errors": [] + }, + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "hash": "from-skill-bundle-dir", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled" + } + } + } + }, + { + "id": "status-owned-skill", + "operation": "status", + "description": "Returns normalized installed metadata for a matching kitup-owned target.", + "options": { + "appId": "example-cli", + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "sourceId": "example-cli:basic", + "cliVersion": "1.2.3", + "cliRevision": "abc123", + "provenance": { + "channel": "stable" + } + } + } + }, + "expected": { + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "metadata": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "sourceId": "example-cli:basic", + "cliVersion": "1.2.3", + "cliRevision": "abc123", + "provenance": { + "channel": "stable" + } + } + } + ], + "missing": [], + "conflicts": [], + "errors": [] + } + } + }, + { + "id": "status-missing-skill", + "operation": "status", + "description": "Reports a missing lifecycle target as state rather than an execution error.", + "options": { + "appId": "example-cli", + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents" + ], + "files": {} + }, + "expected": { + "report": { + "installed": [], + "missing": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "conflicts": [], + "errors": [] + } + } + }, + { + "id": "status-malformed-metadata", + "operation": "status", + "description": "Fails closed when lifecycle status encounters malformed ownership metadata.", + "options": { + "appId": "example-cli", + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled" + } + } + }, + "expected": { + "report": { + "installed": [], + "missing": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unmanaged" + } + ], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/.kitup.json" + ] + } + }, + { + "id": "status-owner-mismatch", + "operation": "status", + "description": "Reports matching skill metadata owned by another app without treating it as installed.", + "options": { + "appId": "example-cli", + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "other-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc" + } + } + }, + "expected": { + "report": { + "installed": [], + "missing": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "owner-mismatch" + } + ], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/.kitup.json" + ] + } + }, { "id": "initial-install-copy-failure-is-atomic", "operation": "install", diff --git a/tests/go-golden/golden_test.go b/tests/go-golden/golden_test.go index a2c2f83..61a648b 100644 --- a/tests/go-golden/golden_test.go +++ b/tests/go-golden/golden_test.go @@ -165,6 +165,14 @@ func runCase(t *testing.T, tc goldenCase, home, workspace string) { func runReportCase(t *testing.T, tc goldenCase, opts map[string]any, base BaseOptions) (any, error) { switch tc.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"]), + }) case "uninstall": return UninstallBundledSkill(UninstallOptions{ BaseOptions: base, @@ -415,21 +423,34 @@ func agentSelector(value any) AgentSelector { } func skillBundleFromOptions(opts map[string]any) SkillBundle { + var bundle SkillBundle if files, ok := opts["skillFiles"].([]any); ok { - return FilesBundle(skillFiles(files)) - } - if dir, ok := opts["skillBundleDir"].(string); ok { - return DirectoryBundle(repoPathFromCase(dir)) + bundle = FilesBundle(skillFiles(files)) + } else if dir, ok := opts["skillBundleDir"].(string); ok { + bundle = DirectoryBundle(repoPathFromCase(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), + }) } - if bundle, ok := opts["githubBundle"].(map[string]any); ok { - return GitHubBundle(GitHubBundleOptions{ - Owner: bundle["owner"].(string), - Repo: bundle["repo"].(string), - Path: bundle["path"].(string), - Ref: bundle["ref"].(string), + if meta, ok := opts["bundleMetadata"].(map[string]any); ok { + provenance := map[string]string{} + if values, ok := meta["provenance"].(map[string]any); ok { + for key, value := range values { + provenance[key] = value.(string) + } + } + bundle = WithBundleMetadata(bundle, BundledSkillMetadata{ + SourceID: stringValue(meta["sourceId"]), + CLIVersion: stringValue(meta["cliVersion"]), + CLIRevision: stringValue(meta["cliRevision"]), + Provenance: provenance, }) } - return SkillBundle{} + return bundle } func skillFiles(values []any) []SkillFile { diff --git a/ts/src/index.ts b/ts/src/index.ts index a9fe366..99c1ffa 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -76,7 +76,19 @@ export interface SkillFile { export type SkillBundle = | { kind: "directory"; path: string } | { kind: "files"; files: SkillFile[] } - | { kind: "github"; options: GitHubBundleOptions }; + | { 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; @@ -100,6 +112,8 @@ export interface UninstallOptions extends BaseOptions { agents?: AgentSelector; } +export interface StatusOptions extends UninstallOptions {} + export interface InstallSelectionOptions extends BaseOptions { scope: Scope; agents?: AgentSelector; @@ -208,6 +222,30 @@ export interface UninstallReport { 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 { @@ -245,16 +283,7 @@ export interface SkillInfo { "missing-skill-md" | "invalid-frontmatter" | "invalid-skill-bundle"; } -interface InstallMetadata { - schemaVersion: 1; - appId: string; - skillName: string; - source: "bundled" | "github"; - hash: string; - sourceId?: string; - version?: string; - provenance?: Record; -} +type InstallMetadata = InstalledMetadata; const defaultAgents: AgentSelector = "auto"; @@ -270,11 +299,14 @@ interface NormalizedSkillBundle { byPath: Map; } -interface BundleMetadata { +interface ResolvedBundleMetadata { source: InstallMetadata["source"]; sourceId?: string; version?: string; + cliVersion?: string; + cliRevision?: string; provenance?: Record; + explicit?: boolean; } export function directoryBundle(path: string): SkillBundle { @@ -297,6 +329,13 @@ 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 { @@ -955,7 +994,17 @@ async function readSkillBundle( async function resolveSkillBundle( bundle: SkillBundle, cwd = process.cwd(), -): Promise<{ bundle: NormalizedSkillBundle; metadata: BundleMetadata }> { +): 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 = resolvePath(bundle.path, cwd); return { @@ -972,9 +1021,10 @@ async function resolveSkillBundle( return resolveGitHubBundle(bundle.options); } -async function resolveGitHubBundle( - options: GitHubBundleOptions, -): Promise<{ bundle: NormalizedSkillBundle; metadata: BundleMetadata }> { +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"); @@ -1029,6 +1079,29 @@ async function resolveGitHubBundle( }; } +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(/\/+$/, ""); } @@ -1150,7 +1223,7 @@ async function installOrPlan( const cwd = options.cwd ?? process.cwd(); let bundle: NormalizedSkillBundle; - let bundleMetadata: BundleMetadata; + let bundleMetadata: ResolvedBundleMetadata; try { ({ bundle, metadata: bundleMetadata } = await resolveSkillBundle( options.skillBundle, @@ -1159,10 +1232,9 @@ async function installOrPlan( } catch { return emptyInstallReport([ { - reason: - options.skillBundle.kind === "github" - ? "bundle-resolve-failed" - : "invalid-skill-bundle", + reason: isGitHubBundle(options.skillBundle) + ? "bundle-resolve-failed" + : "invalid-skill-bundle", }, ]); } @@ -1229,7 +1301,23 @@ async function installOrPlan( } report.conflicts.push({ ...result, reason: "owner-mismatch" }); } else if (metadata.value.hash === hash) { - if (await repairSkillBundleModes(bundle, target.targetDir, write)) { + 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, @@ -1302,7 +1390,15 @@ export async function uninstallBundledSkill( } else if (metadata.value.appId !== options.appId) { report.conflicts.push({ ...result, reason: "owner-mismatch" }); } else { - await rm(target.targetDir, { recursive: true, force: true }); + const reason = await removeManagedSkill( + target.targetDir, + options.appId, + options.skillName, + ); + if (reason) { + report.conflicts.push({ ...result, reason }); + continue; + } report.removed.push(result); } } @@ -1310,13 +1406,52 @@ export async function uninstallBundledSkill( 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: BundleMetadata, + metadata: ResolvedBundleMetadata, ) { const tmp = await makeStagingDir(targetDir); try { @@ -1329,13 +1464,41 @@ async function copyManagedSkill( } } +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: BundleMetadata, + metadata: ResolvedBundleMetadata, ) { const tmp = await makeStagingDir(targetDir); const backup = `${tmp}-backup`; @@ -1404,18 +1567,9 @@ async function writeMetadata( appId: string, skillName: string, hash: string, - metadata: BundleMetadata, + metadata: ResolvedBundleMetadata, ) { - const value: InstallMetadata = { - schemaVersion: 1, - appId, - skillName, - source: metadata.source, - hash, - }; - if (metadata.sourceId) value.sourceId = metadata.sourceId; - if (metadata.version) value.version = metadata.version; - if (metadata.provenance) value.provenance = metadata.provenance; + const value = installedMetadata(appId, skillName, hash, metadata); await writeFile( join(targetDir, ".kitup.json"), `${JSON.stringify(value, null, 2)}\n`, @@ -1455,14 +1609,82 @@ function parseOwnedMetadata(raw: unknown): InstallMetadata | undefined { source: value.source, hash: value.hash, }; - if (typeof value.sourceId === "string") metadata.sourceId = value.sourceId; - if (typeof value.version === "string") metadata.version = value.version; - if (value.provenance && typeof value.provenance === "object") { - metadata.provenance = value.provenance as Record; + 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 @@ -1474,6 +1696,10 @@ 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; diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 371e797..2a74796 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -29,9 +29,11 @@ import { resolveInstallTargets, resolveHosts, runBundledSkillInstall, + statusBundledSkill, uninstallBundledSkill, updateBundledSkill, validateSkillBundle, + withBundleMetadata, } from "../dist/index.js"; const repo = fileURLToPath(new URL("../../", import.meta.url)); @@ -240,13 +242,15 @@ async function runCase(testCase: any, home: string, workspace: string) { } const reportPromise = - testCase.operation === "uninstall" - ? uninstallBundledSkill({ ...options, hostsFile }) - : testCase.operation === "plan" - ? planBundledSkill({ ...options, hostsFile }) - : testCase.operation === "update" - ? updateBundledSkill({ ...options, hostsFile }) - : installBundledSkill({ ...options, hostsFile }); + 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); @@ -492,6 +496,11 @@ function expandOptions(options: any, home: string, workspace: string) { 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; }