Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,15 @@ let result = kitup::run_bundled_skill_install(&kitup::InstallWorkflowOptions {
skill_bundle: kitup::directory_bundle("./skills/mycli"),
scope: kitup::Scope::User,
agents: kitup::AgentSelector::Auto,
force: false,
},
yes: false,
dry_run: false,
stdin_tty: stdin_tty,
current_agent: None,
default_scope: None,
scope_set: true,
prompt_scope: false,
})?;
```

Expand Down
9 changes: 7 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ let report = kitup::install_bundled_skill(&kitup::InstallOptions {
skill_bundle: kitup::directory_bundle("./skills/mycli"),
scope: kitup::Scope::User,
agents: kitup::AgentSelector::Auto,
force: false,
})?;
```

Expand All @@ -180,6 +181,7 @@ let report = kitup::install_bundled_skill(&kitup::InstallOptions {
}),
scope: kitup::Scope::User,
agents: kitup::AgentSelector::Auto,
force: false,
})?;
```

Expand Down Expand Up @@ -217,6 +219,7 @@ Install options use the same concepts across languages:
- `skillBundle` / `SkillBundle` / `skill_bundle`: local directory, embedded files, or public GitHub bundle
- `scope`: `user` or `project`
- `agents`: `"auto"`, `"*"`, or explicit host ids
- `force`: overwrite unmanaged or different-owner target directories instead of reporting conflicts
- `home`, `cwd`, `hostsFile`: optional test and embedding overrides

Bundle file paths must use root-relative POSIX paths. SDKs reject empty paths, absolute paths, `..`, duplicate files, and backslash paths. SDKs exclude `.kitup.json`, `.git`, `.DS_Store`, swap files, and editor backups before validation, hashing, and copy.
Expand Down Expand Up @@ -248,8 +251,9 @@ Standard install flags:
- `--agent`: repeatable target agent id; comma-separated values are accepted; `*` means all hosts and must be the only agent value
- `--dry-run`: render the plan without writing
- `--yes` / `-y`: skip prompts and accept policy-selected targets
- `--force`: overwrite unmanaged or different-owner target directories

Flag parsing returns a normalized scope, `scopeSet`, normalized agent selector, `yes`, `dryRun`, and structured flag errors. An empty agent list maps to `auto`; `*` maps to all hosts; explicit ids are deduplicated in input order.
Flag parsing returns a normalized scope, `scopeSet`, normalized agent selector, `yes`, `dryRun`, `force`, and structured flag errors. An empty agent list maps to `auto`; `*` maps to all hosts; explicit ids are deduplicated in input order.

For CLI workflows, pass `promptScope: true`. If `scopeSet` is false, TTY mode prompts for scope before agent selection and planning. Enter uses `defaultScope` or `user`. Non-TTY without explicit scope and without `yes` returns `scope-selection-required`. `yes` with no explicit scope uses `defaultScope` or `user`.

Expand All @@ -267,6 +271,7 @@ Recommended CLI behavior:
mycli skill install
mycli skill install --scope user --agent codex
mycli skill install --scope project --agent codex --agent claude-code
mycli skill install --scope user --agent codex --force
```

The lower-level selection resolver remains available for custom shells. It returns one of:
Expand Down Expand Up @@ -309,4 +314,4 @@ TypeScript returns typed report objects. Go exposes `InstallReport`, `UninstallR

The serialized JSON report shape is the same across TypeScript, Go, and Rust. `installed`, `updated`, and `removed` contain target results. `skipped` and `conflicts` contain target results plus `reason`.

Conflict is the safe default. A target directory without matching `.kitup.json` ownership metadata is reported as a conflict, not overwritten.
Conflict is the safe default. A target directory without matching `.kitup.json` ownership metadata is reported as a conflict, not overwritten unless `force` / `--force` is explicit.
1 change: 1 addition & 0 deletions examples/rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
skill_bundle: directory_bundle("../../skills/kitup"),
scope: Scope::User,
agents: AgentSelector::Auto,
force: false,
})?;

println!("{}", serde_json::to_string(&report)?);
Expand Down
4 changes: 4 additions & 0 deletions go-cobra/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func NewInstallCommand(opts Options) *cobra.Command {
var agents []string
var yes bool
var dryRun bool
var force bool

cmd := &cobra.Command{
Use: kitup.InstallUX.InstallUse,
Expand All @@ -48,6 +49,7 @@ func NewInstallCommand(opts Options) *cobra.Command {
Agents: agents,
Yes: yes,
DryRun: dryRun,
Force: force,
})
if err := kitup.InstallFlagError(parsed.Errors); err != nil {
return err
Expand All @@ -63,6 +65,7 @@ func NewInstallCommand(opts Options) *cobra.Command {
SkillBundle: opts.Bundle,
Scope: parsed.Scope,
Agents: parsed.Agents,
Force: parsed.Force,
},
Yes: parsed.Yes,
DryRun: parsed.DryRun,
Expand All @@ -85,6 +88,7 @@ func NewInstallCommand(opts Options) *cobra.Command {
cmd.Flags().StringArrayVar(&agents, "agent", nil, kitup.InstallUX.AgentFlag)
cmd.Flags().BoolVar(&dryRun, "dry-run", false, kitup.InstallUX.DryRunFlag)
cmd.Flags().BoolVarP(&yes, "yes", "y", false, kitup.InstallUX.YesFlag)
cmd.Flags().BoolVar(&force, "force", false, kitup.InstallUX.ForceFlag)
return cmd
}

Expand Down
27 changes: 27 additions & 0 deletions go-cobra/skill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,33 @@ func TestInstallCommandPromptsForScopeBeforeInstall(t *testing.T) {
}
}

func TestInstallCommandForceOverwritesUnmanaged(t *testing.T) {
home := t.TempDir()
target := filepath.Join(home, ".agents", "skills", "basic")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("---\nname: basic\ndescription: unmanaged\n---\n"), 0o644); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
cmd := NewSkillCommand(Options{
AppID: "example-cli",
Bundle: kitup.DirectoryBundle(filepath.Join("..", "testdata", "skills", "basic")),
Home: home,
Out: &out,
})
cmd.SetArgs([]string{"install", "--agent", "codex", "--yes", "--force"})

if err := cmd.Execute(); err != nil {
t.Fatal(err)
}

if _, err := os.Stat(filepath.Join(target, ".kitup.json")); err != nil {
t.Fatal(err)
}
}

func TestInstallCommandReturnsCoreFlagError(t *testing.T) {
cmd := NewInstallCommand(Options{
AppID: "example-cli",
Expand Down
46 changes: 34 additions & 12 deletions go/kitup.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type InstallUXText struct {
AgentFlag string
DryRunFlag string
YesFlag string
ForceFlag string
SelectScope string
ScopePrompt string
InvalidScopeSelection string
Expand All @@ -62,6 +63,7 @@ var InstallUX = InstallUXText{
AgentFlag: "Target agent id. Repeat for multiple agents. Use '*' for all.",
DryRunFlag: "Show install plan without writing",
YesFlag: "Skip prompts and accept policy-selected targets",
ForceFlag: "Overwrite unsafe target conflicts",
SelectScope: "Select install scope:",
ScopePrompt: "Scope (user/project)",
InvalidScopeSelection: "Invalid scope selection.",
Expand Down Expand Up @@ -89,6 +91,7 @@ type InstallFlagValues struct {
Agents []string
Yes bool
DryRun bool
Force bool
}

type ParsedInstallFlags struct {
Expand All @@ -97,6 +100,7 @@ type ParsedInstallFlags struct {
Agents AgentSelector
Yes bool
DryRun bool
Force bool
Errors []map[string]any
}

Expand All @@ -118,7 +122,7 @@ func ParseInstallFlags(flags InstallFlagValues) ParsedInstallFlags {
errs = append(errs, scopeErrs...)
agents, agentErrs := AgentSelectorFromFlags(flags.Agents)
errs = append(errs, agentErrs...)
return ParsedInstallFlags{Scope: scope, ScopeSet: flags.ScopeSet || flags.Scope != "", Agents: agents, Yes: flags.Yes, DryRun: flags.DryRun, Errors: errs}
return ParsedInstallFlags{Scope: scope, ScopeSet: flags.ScopeSet || flags.Scope != "", Agents: agents, Yes: flags.Yes, DryRun: flags.DryRun, Force: flags.Force, Errors: errs}
}

func AgentSelectorFromFlags(values []string) (AgentSelector, []map[string]any) {
Expand Down Expand Up @@ -226,6 +230,7 @@ type InstallOptions struct {
SkillBundle SkillBundle
Scope Scope
Agents AgentSelector
Force bool
}

type UninstallOptions struct {
Expand Down Expand Up @@ -740,14 +745,21 @@ func RunBundledSkillInstall(opts InstallWorkflowOptions) (InstallWorkflowReport,
if err != nil {
return InstallWorkflowReport{}, err
}
if !hasVisibleInstallPlan(plan) {
if len(plan.Installed)+len(plan.Updated)+len(plan.Conflicts)+len(plan.Errors) == 0 {
return InstallWorkflowReport{Selection: selection, Scope: scope, Plan: plan, Report: plan, DryRun: opts.DryRun}, nil
}
renderInstallSummary(out, plan)
if opts.DryRun {
renderInstallSummary(out, plan)
return InstallWorkflowReport{Selection: selection, Scope: scope, Plan: plan, Report: plan, DryRun: true}, nil
}
if !hasInstallWrites(plan) {
if len(plan.Conflicts)+len(plan.Errors) > 0 {
report := plan
report.Installed = []TargetResult{}
report.Updated = []TargetResult{}
return InstallWorkflowReport{Selection: selection, Scope: scope, Plan: plan, Report: report, DryRun: opts.DryRun}, nil
}
renderInstallSummary(out, plan)
if len(plan.Installed)+len(plan.Updated) == 0 {
return InstallWorkflowReport{Selection: selection, Scope: scope, Plan: plan, Report: plan}, nil
}
if selection.NeedsConfirmation {
Expand Down Expand Up @@ -827,8 +839,26 @@ func installOrPlan(opts InstallOptions, write bool) (InstallReport, error) {
}
report.Installed = append(report.Installed, result)
case !managed:
if opts.Force {
if write {
if err := replaceManagedSkill(bundle, target.TargetDir, opts.AppID, skill.SkillName, hash, bundleMeta); err != nil {
return report, err
}
}
report.Updated = append(report.Updated, result)
continue
}
report.Conflicts = append(report.Conflicts, withReason(result, "unmanaged"))
case meta.AppID != opts.AppID:
if opts.Force {
if write {
if err := replaceManagedSkill(bundle, target.TargetDir, opts.AppID, skill.SkillName, hash, bundleMeta); err != nil {
return report, err
}
}
report.Updated = append(report.Updated, result)
continue
}
report.Conflicts = append(report.Conflicts, withReason(result, "owner-mismatch"))
case meta.Hash == hash:
report.Skipped = append(report.Skipped, withReason(result, "unchanged"))
Expand Down Expand Up @@ -995,14 +1025,6 @@ func stringField(value map[string]any, key string) string {
return ""
}

func hasVisibleInstallPlan(report InstallReport) bool {
return len(report.Installed)+len(report.Updated)+len(report.Conflicts)+len(report.Errors) > 0
}

func hasInstallWrites(report InstallReport) bool {
return len(report.Installed)+len(report.Updated) > 0
}

func resolveWorkflowScope(reader *bufio.Reader, out io.Writer, requested Scope, scopeSet, promptScope bool, defaultScope Scope, yes, stdinTTY bool) (Scope, InstallSelection, error) {
if defaultScope == "" {
defaultScope = UserScope
Expand Down
3 changes: 3 additions & 0 deletions go/kitup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func runCase(t *testing.T, tc goldenCase, home, workspace string) {
Agents: stringSlice(opts["agents"]),
Yes: boolValue(opts["yes"]),
DryRun: boolValue(opts["dryRun"]),
Force: boolValue(opts["force"]),
}), tc.Expected["parsed"].(map[string]any))
case "resolve-install-selection":
selection, err := ResolveInstallSelection(InstallSelectionOptions{
Expand All @@ -91,6 +92,7 @@ func runCase(t *testing.T, tc goldenCase, home, workspace string) {
SkillBundle: skillBundleFromOptions(opts),
Scope: Scope(stringValue(opts["scope"])),
Agents: agentSelector(opts["agents"]),
Force: boolValue(opts["force"]),
},
Yes: boolValue(opts["yes"]),
DryRun: boolValue(opts["dryRun"]),
Expand Down Expand Up @@ -303,6 +305,7 @@ func assertParsedFlags(t *testing.T, actual ParsedInstallFlags, expected map[str
"agentIds": agentIDs,
"yes": actual.Yes,
"dryRun": actual.DryRun,
"force": actual.Force,
"errors": actual.Errors,
}, expected)
}
Expand Down
Loading
Loading