From d2a330c53a93bd2270a3221d373eb6fff50cc730 Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 1 Jul 2026 06:45:43 -0400 Subject: [PATCH] feat(sdk): add force install option Signed-off-by: samzong --- README.md | 4 + docs/API.md | 9 +- examples/rust/src/main.rs | 1 + go-cobra/skill.go | 4 + go-cobra/skill_test.go | 27 +++ go/kitup.go | 46 +++-- go/kitup_test.go | 3 + rust/src/lib.rs | 68 +++++-- rust/tests/golden.rs | 13 ++ testdata/cases/bundled-skill-install.json | 210 +++++++++++++++++++++- ts/src/index.ts | 72 ++++++-- ts/test/golden.test.ts | 1 + 12 files changed, 412 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 57f5563..516d579 100644 --- a/README.md +++ b/README.md @@ -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, })?; ``` diff --git a/docs/API.md b/docs/API.md index bd7bd02..ff806bb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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, })?; ``` @@ -180,6 +181,7 @@ let report = kitup::install_bundled_skill(&kitup::InstallOptions { }), scope: kitup::Scope::User, agents: kitup::AgentSelector::Auto, + force: false, })?; ``` @@ -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. @@ -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`. @@ -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: @@ -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. diff --git a/examples/rust/src/main.rs b/examples/rust/src/main.rs index ebd4444..9fb917c 100644 --- a/examples/rust/src/main.rs +++ b/examples/rust/src/main.rs @@ -9,6 +9,7 @@ fn main() -> Result<(), Box> { skill_bundle: directory_bundle("../../skills/kitup"), scope: Scope::User, agents: AgentSelector::Auto, + force: false, })?; println!("{}", serde_json::to_string(&report)?); diff --git a/go-cobra/skill.go b/go-cobra/skill.go index 06e1629..de77bed 100644 --- a/go-cobra/skill.go +++ b/go-cobra/skill.go @@ -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, @@ -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 @@ -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, @@ -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 } diff --git a/go-cobra/skill_test.go b/go-cobra/skill_test.go index c61d9ea..053f329 100644 --- a/go-cobra/skill_test.go +++ b/go-cobra/skill_test.go @@ -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", diff --git a/go/kitup.go b/go/kitup.go index 0b181af..7315cbb 100644 --- a/go/kitup.go +++ b/go/kitup.go @@ -37,6 +37,7 @@ type InstallUXText struct { AgentFlag string DryRunFlag string YesFlag string + ForceFlag string SelectScope string ScopePrompt string InvalidScopeSelection string @@ -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.", @@ -89,6 +91,7 @@ type InstallFlagValues struct { Agents []string Yes bool DryRun bool + Force bool } type ParsedInstallFlags struct { @@ -97,6 +100,7 @@ type ParsedInstallFlags struct { Agents AgentSelector Yes bool DryRun bool + Force bool Errors []map[string]any } @@ -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) { @@ -226,6 +230,7 @@ type InstallOptions struct { SkillBundle SkillBundle Scope Scope Agents AgentSelector + Force bool } type UninstallOptions struct { @@ -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 { @@ -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")) @@ -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 diff --git a/go/kitup_test.go b/go/kitup_test.go index 87e0930..4b19d26 100644 --- a/go/kitup_test.go +++ b/go/kitup_test.go @@ -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{ @@ -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"]), @@ -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) } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 8f636b9..2c7111a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -24,6 +24,7 @@ pub struct InstallUxText { pub agent_flag: &'static str, pub dry_run_flag: &'static str, pub yes_flag: &'static str, + pub force_flag: &'static str, pub select_scope: &'static str, pub scope_prompt: &'static str, pub invalid_scope_selection: &'static str, @@ -49,6 +50,7 @@ pub const INSTALL_UX: InstallUxText = InstallUxText { agent_flag: "Target agent id. Repeat for multiple agents. Use '*' for all.", dry_run_flag: "Show install plan without writing", yes_flag: "Skip prompts and accept policy-selected targets", + force_flag: "Overwrite unsafe target conflicts", select_scope: "Select install scope:", scope_prompt: "Scope (user/project)", invalid_scope_selection: "Invalid scope selection.", @@ -79,6 +81,7 @@ pub struct InstallFlagValues { pub agents: Vec, pub yes: bool, pub dry_run: bool, + pub force: bool, } #[derive(Clone, Debug)] @@ -88,6 +91,7 @@ pub struct ParsedInstallFlags { pub agents: AgentSelector, pub yes: bool, pub dry_run: bool, + pub force: bool, pub errors: Vec, } @@ -105,6 +109,7 @@ pub struct InstallOptions { pub skill_bundle: SkillBundle, pub scope: Scope, pub agents: AgentSelector, + pub force: bool, } #[derive(Clone, Debug)] @@ -331,6 +336,7 @@ pub fn parse_install_flags(flags: InstallFlagValues) -> ParsedInstallFlags { agents, yes: flags.yes, dry_run: flags.dry_run, + force: flags.force, errors, } } @@ -817,7 +823,7 @@ pub fn run_bundled_skill_install_with_io( install.agents = AgentSelector::Explicit(selection.selected_host_ids.clone()); install.scope = scope; let plan = plan_bundled_skill(&install)?; - if !has_visible_install_plan(&plan) { + if plan.installed.len() + plan.updated.len() + plan.conflicts.len() + plan.errors.len() == 0 { return Ok(InstallWorkflowReport { selection, scope: workflow_scope, @@ -827,8 +833,8 @@ pub fn run_bundled_skill_install_with_io( dry_run: options.dry_run, }); } - render_install_summary(output, &plan)?; if options.dry_run { + render_install_summary(output, &plan)?; return Ok(InstallWorkflowReport { selection, scope: workflow_scope, @@ -838,7 +844,21 @@ pub fn run_bundled_skill_install_with_io( dry_run: true, }); } - if !has_install_writes(&plan) { + if !plan.conflicts.is_empty() || !plan.errors.is_empty() { + let mut report = plan.clone(); + report.installed.clear(); + report.updated.clear(); + return Ok(InstallWorkflowReport { + selection, + scope: workflow_scope, + plan: plan.clone(), + report, + canceled: false, + dry_run: options.dry_run, + }); + } + render_install_summary(output, &plan)?; + if plan.installed.len() + plan.updated.len() == 0 { return Ok(InstallWorkflowReport { selection, scope: workflow_scope, @@ -939,9 +959,39 @@ fn install_or_plan(options: &InstallOptions, write: bool) -> io::Result report.conflicts.push(with_reason(result, "unmanaged")), + MetadataState::Unmanaged => { + if options.force { + if write { + replace_managed_skill( + &bundle, + &target.target_dir, + &options.app_id, + &skill_name, + &hash, + &bundle_metadata, + )?; + } + report.updated.push(result); + } else { + report.conflicts.push(with_reason(result, "unmanaged")); + } + } MetadataState::Managed(meta) if meta.app_id != options.app_id => { - report.conflicts.push(with_reason(result, "owner-mismatch")) + if options.force { + if write { + replace_managed_skill( + &bundle, + &target.target_dir, + &options.app_id, + &skill_name, + &hash, + &bundle_metadata, + )?; + } + report.updated.push(result); + } else { + report.conflicts.push(with_reason(result, "owner-mismatch")); + } } MetadataState::Managed(meta) if meta.hash == hash => { report.skipped.push(with_reason(result, "unchanged")) @@ -1106,14 +1156,6 @@ fn install_report(errors: Vec) -> InstallReport { } } -fn has_visible_install_plan(report: &InstallReport) -> bool { - report.installed.len() + report.updated.len() + report.conflicts.len() + report.errors.len() > 0 -} - -fn has_install_writes(report: &InstallReport) -> bool { - report.installed.len() + report.updated.len() > 0 -} - fn resolve_workflow_scope( input: &mut R, output: &mut W, diff --git a/rust/tests/golden.rs b/rust/tests/golden.rs index 9a214a6..3c85a7b 100644 --- a/rust/tests/golden.rs +++ b/rust/tests/golden.rs @@ -99,6 +99,10 @@ fn run_case(case: &GoldenCase, home: &Path, workspace: &Path) { .get("dryRun") .and_then(Value::as_bool) .unwrap_or(false), + force: options + .get("force") + .and_then(Value::as_bool) + .unwrap_or(false), }); assert_json_eq( &normalized_parsed_flags(&parsed), @@ -160,6 +164,10 @@ fn run_case(case: &GoldenCase, home: &Path, workspace: &Path) { .get("agents") .map(agent_selector) .unwrap_or(AgentSelector::Auto), + force: options + .get("force") + .and_then(Value::as_bool) + .unwrap_or(false), }, yes: options.get("yes").and_then(Value::as_bool).unwrap_or(false), dry_run: options @@ -270,6 +278,10 @@ fn run_report_case( skill_bundle: skill_bundle_from_options(options), scope: scope(options["scope"].as_str().unwrap()), agents: agent_selector(&options["agents"]), + force: options + .get("force") + .and_then(Value::as_bool) + .unwrap_or(false), }; match case.operation.as_str() { "update" => serde_json::to_value(update_bundled_skill(&options).unwrap()).unwrap(), @@ -385,6 +397,7 @@ fn normalized_parsed_flags(parsed: &ParsedInstallFlags) -> Value { "agentIds": agent_ids, "yes": parsed.yes, "dryRun": parsed.dry_run, + "force": parsed.force, "errors": parsed.errors }) } diff --git a/testdata/cases/bundled-skill-install.json b/testdata/cases/bundled-skill-install.json index 06b67d8..66df65c 100644 --- a/testdata/cases/bundled-skill-install.json +++ b/testdata/cases/bundled-skill-install.json @@ -165,6 +165,7 @@ "agentIds": [], "yes": false, "dryRun": false, + "force": false, "errors": [] } } @@ -180,7 +181,8 @@ "codex" ], "yes": true, - "dryRun": true + "dryRun": true, + "force": true }, "given": { "dirs": [], @@ -197,6 +199,7 @@ ], "yes": true, "dryRun": true, + "force": true, "errors": [] } } @@ -222,6 +225,7 @@ "agentIds": [], "yes": false, "dryRun": false, + "force": false, "errors": [] } } @@ -249,6 +253,7 @@ "agentIds": [], "yes": false, "dryRun": false, + "force": false, "errors": [ { "flag": "scope", @@ -904,6 +909,209 @@ } } }, + { + "id": "workflow-conflict-blocks-writes", + "operation": "run-install-workflow", + "description": "Does not write any target when the plan mixes writable targets with conflicts.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex", + "claude-code" + ], + "stdinTTY": true, + "yes": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/SKILL.md": "---\nname: basic\ndescription: Unmanaged fixture.\n---\n\n# Basic\n" + } + }, + "expected": { + "workflow": { + "canceled": false, + "dryRun": false + }, + "exit": { + "ok": false, + "code": "conflict", + "message": "Installation has conflicts." + }, + "output": "", + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unmanaged" + } + ], + "errors": [] + }, + "filesAbsent": [ + "$HOME/.claude/skills/basic/SKILL.md" + ] + } + }, + { + "id": "workflow-dry-run-conflict-renders-plan", + "operation": "run-install-workflow", + "description": "Dry-run renders writable plan items even when another target conflicts.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex", + "claude-code" + ], + "stdinTTY": true, + "yes": true, + "dryRun": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/SKILL.md": "---\nname: basic\ndescription: Unmanaged fixture.\n---\n\n# Basic\n" + } + }, + "expected": { + "workflow": { + "canceled": false, + "dryRun": true + }, + "exit": { + "ok": false, + "code": "conflict", + "message": "Installation has conflicts." + }, + "outputContains": [ + " - basic -> ", + "(claude-code)" + ], + "report": { + "installed": [ + { + "hostId": "claude-code", + "skillName": "basic", + "targetDir": "$HOME/.claude/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unmanaged" + } + ], + "errors": [] + }, + "filesAbsent": [ + "$HOME/.claude/skills/basic/SKILL.md" + ] + } + }, + { + "id": "workflow-force-overwrites-conflicts", + "operation": "run-install-workflow", + "description": "Force turns unmanaged and different-owner targets into explicit updates.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex", + "claude-code" + ], + "stdinTTY": true, + "yes": true, + "force": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic", + "$HOME/.claude/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/SKILL.md": "---\nname: basic\ndescription: Unmanaged fixture.\n---\n\n# Basic\n", + "$HOME/.claude/skills/basic/SKILL.md": "---\nname: basic\ndescription: Other owner fixture.\n---\n\n# Basic\n", + "$HOME/.claude/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "other-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:old" + } + } + }, + "expected": { + "workflow": { + "canceled": false, + "dryRun": false + }, + "exit": { + "ok": true, + "code": "ok", + "message": "" + }, + "outputContains": [ + "(codex)", + "(claude-code)" + ], + "report": { + "installed": [], + "updated": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + }, + { + "hostId": "claude-code", + "skillName": "basic", + "targetDir": "$HOME/.claude/skills/basic" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/SKILL.md", + "$HOME/.claude/skills/basic/SKILL.md" + ], + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "hash": "from-skill-bundle-dir", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled" + } + } + } + }, { "id": "changed-update", "operation": "update", diff --git a/ts/src/index.ts b/ts/src/index.ts index 91c890e..ce2da3e 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -23,6 +23,7 @@ export const 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.", @@ -84,6 +85,7 @@ export interface InstallOptions extends BaseOptions { skillBundle: SkillBundle; scope: Scope; agents?: AgentSelector; + force?: boolean; } export interface UninstallOptions extends BaseOptions { @@ -119,6 +121,7 @@ export interface InstallFlagValues { agents?: string[]; yes?: boolean; dryRun?: boolean; + force?: boolean; } export interface InstallFlagError { @@ -133,6 +136,7 @@ export interface ParsedInstallFlags { agents: AgentSelector; yes: boolean; dryRun: boolean; + force: boolean; errors: InstallFlagError[]; } @@ -280,6 +284,7 @@ export function parseInstallFlags( agents, yes: Boolean(flags.yes), dryRun: Boolean(flags.dryRun), + force: Boolean(flags.force), errors, }; } @@ -581,7 +586,13 @@ export async function runBundledSkillInstall( agents: selection.selectedHostIds, }; const plan = await planBundledSkill(installOptions); - if (!hasVisibleInstallPlan(plan)) { + if ( + plan.installed.length + + plan.updated.length + + plan.conflicts.length + + plan.errors.length === + 0 + ) { return { selection, scope, @@ -591,9 +602,8 @@ export async function runBundledSkillInstall( dryRun: Boolean(options.dryRun), }; } - renderInstallSummary(output, plan); - if (options.dryRun) { + renderInstallSummary(output, plan); return { selection, scope, @@ -604,7 +614,19 @@ export async function runBundledSkillInstall( }; } - if (!hasInstallWrites(plan)) { + if (plan.conflicts.length + plan.errors.length > 0) { + return { + selection, + scope, + plan, + report: { ...plan, installed: [], updated: [] }, + canceled: false, + dryRun: false, + }; + } + renderInstallSummary(output, plan); + + if (plan.installed.length + plan.updated.length === 0) { return { selection, scope, @@ -1034,8 +1056,36 @@ async function installOrPlan( } report.installed.push(result); } else if (!metadata.value) { + if (options.force) { + if (write) { + await replaceManagedSkill( + bundle, + target.targetDir, + options.appId, + skill.skillName, + hash, + bundleMetadata, + ); + } + report.updated.push(result); + continue; + } report.conflicts.push({ ...result, reason: "unmanaged" }); } else if (metadata.value.appId !== options.appId) { + if (options.force) { + if (write) { + await replaceManagedSkill( + bundle, + target.targetDir, + options.appId, + skill.skillName, + hash, + bundleMetadata, + ); + } + report.updated.push(result); + continue; + } report.conflicts.push({ ...result, reason: "owner-mismatch" }); } else if (metadata.value.hash === hash) { report.skipped.push({ ...result, reason: "unchanged" }); @@ -1191,20 +1241,6 @@ function emptyInstallReport(errors: TargetError[] = []): InstallReport { return { installed: [], updated: [], skipped: [], conflicts: [], errors }; } -function hasVisibleInstallPlan(report: InstallReport) { - return ( - report.installed.length + - report.updated.length + - report.conflicts.length + - report.errors.length > - 0 - ); -} - -function hasInstallWrites(report: InstallReport) { - return report.installed.length + report.updated.length > 0; -} - class LineReader { private buffer = ""; private done = false; diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index e9ae0d7..876a7fe 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -457,6 +457,7 @@ function normalizeParsedFlags(parsed: any) { agentIds: Array.isArray(parsed.agents) ? parsed.agents : [], yes: parsed.yes, dryRun: parsed.dryRun, + force: parsed.force, errors: parsed.errors, }; }