From 9deec591b72dc527240fdb6ede0c2138e990aa5b Mon Sep 17 00:00:00 2001 From: Willie Date: Sat, 30 May 2026 00:46:34 +0800 Subject: [PATCH 1/7] feat(adopt): add `skillshare adopt` to claim CLI-bundled ~/.agents skills External CLI tools (firecrawl/cli, googleworkspace/cli) install skills directly into ~/.agents/skills and track them in their own .skill-lock.json, bypassing skillshare's source-of-truth model: they show up as unmanaged, only reach the agents the installer detected, and get re-clobbered when moved by hand. adopt detects these, migrates the canonical files into source (reusing the collect/pull machinery), trashes the originals (restorable), prunes the external orphan symlinks, and re-syncs to all targets. The tool's lockfile is read-only to skillshare: adopt warns the user to release the entry from the owning tool rather than editing a file it does not own. Dual-mode (global + project). Conflicts are skipped without --force; a bare run in a non-interactive shell refuses rather than silently migrating and trashing files. Refs #135 --- cmd/skillshare/adopt.go | 157 ++++++++++++++++ cmd/skillshare/adopt_handlers.go | 229 +++++++++++++++++++++++ cmd/skillshare/adopt_project.go | 54 ++++++ cmd/skillshare/adopt_render.go | 144 +++++++++++++++ cmd/skillshare/adopt_test.go | 179 ++++++++++++++++++ cmd/skillshare/main.go | 2 + internal/adopt/apply.go | 177 ++++++++++++++++++ internal/adopt/apply_test.go | 204 +++++++++++++++++++++ internal/adopt/detect.go | 99 ++++++++++ internal/adopt/detect_test.go | 154 ++++++++++++++++ internal/adopt/lockfile.go | 114 ++++++++++++ internal/adopt/lockfile_test.go | 102 +++++++++++ tests/integration/adopt_test.go | 304 +++++++++++++++++++++++++++++++ 13 files changed, 1919 insertions(+) create mode 100644 cmd/skillshare/adopt.go create mode 100644 cmd/skillshare/adopt_handlers.go create mode 100644 cmd/skillshare/adopt_project.go create mode 100644 cmd/skillshare/adopt_render.go create mode 100644 cmd/skillshare/adopt_test.go create mode 100644 internal/adopt/apply.go create mode 100644 internal/adopt/apply_test.go create mode 100644 internal/adopt/detect.go create mode 100644 internal/adopt/detect_test.go create mode 100644 internal/adopt/lockfile.go create mode 100644 internal/adopt/lockfile_test.go create mode 100644 tests/integration/adopt_test.go diff --git a/cmd/skillshare/adopt.go b/cmd/skillshare/adopt.go new file mode 100644 index 00000000..faeefdb8 --- /dev/null +++ b/cmd/skillshare/adopt.go @@ -0,0 +1,157 @@ +package main + +import ( + "fmt" + "os" + "strings" + "time" + + "skillshare/internal/config" + "skillshare/internal/sync" + "skillshare/internal/trash" +) + +// adoptAgentsTargetNames are the canonical name + alias of the universal target +// (~/.agents/skills) that external CLI tools write into. +var adoptAgentsTargetNames = []string{"universal", "agents"} + +func parseAdoptOptions(args []string) adoptOptions { + opts := adoptOptions{} + for _, arg := range args { + switch arg { + case "--dry-run", "-n": + opts.dryRun = true + case "--force", "-f": + opts.force = true + case "--all", "-a": + opts.all = true + case "--json": + opts.jsonOutput = true + default: + if opts.targetName == "" && !strings.HasPrefix(arg, "-") { + opts.targetName = arg + } + } + } + if opts.jsonOutput { + opts.force = true + } + return opts +} + +func cmdAdopt(args []string) error { + if wantsHelp(args) { + printAdoptHelp() + return nil + } + + start := time.Now() + + mode, rest, err := parseModeArgs(args) + if err != nil { + return err + } + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("cannot determine working directory: %w", err) + } + + if mode == modeAuto { + if projectConfigExists(cwd) { + mode = modeProject + } else { + mode = modeGlobal + } + } + + applyModeLabel(mode) + + opts := parseAdoptOptions(rest) + + if mode == modeProject { + return cmdAdoptProject(opts, cwd, start) + } + return cmdAdoptGlobal(opts, start) +} + +// cmdAdoptGlobal builds the adoptContext from the global config and runs adopt. +func cmdAdoptGlobal(opts adoptOptions, start time.Time) error { + cfg, err := config.Load() + if err != nil { + return adoptCommandError(err, opts.jsonOutput) + } + + agentsTarget, ok := findAgentsTarget(cfg.Targets) + if !ok { + return adoptCommandError(fmt.Errorf("universal/agents target not configured; nothing to adopt"), opts.jsonOutput) + } + sc := agentsTarget.SkillsConfig() + + allTargets := make(map[string]string, len(cfg.Targets)) + for name, t := range cfg.Targets { + allTargets[name] = t.SkillsConfig().Path + } + + actx := adoptContext{ + agentsPath: sc.Path, + sourcePath: cfg.EffectiveSkillsSource(), + syncMode: adoptSyncMode(sc.Mode, cfg.Mode), + allTargets: allTargets, + targets: cfg.Targets, + trashBase: trash.TrashDir(), + configPath: config.ConfigPath(), + } + + return runAdoptCommand(actx, opts, start) +} + +// findAgentsTarget locates the universal/agents target in a target map. +func findAgentsTarget(targets map[string]config.TargetConfig) (config.TargetConfig, bool) { + for _, name := range adoptAgentsTargetNames { + if t, ok := targets[name]; ok { + return t, true + } + } + return config.TargetConfig{}, false +} + +// adoptSyncMode resolves the effective sync mode for the agents target. +func adoptSyncMode(targetMode, globalMode string) string { + mode := sync.EffectiveMode(targetMode) + if targetMode == "" && globalMode != "" { + mode = globalMode + } + return mode +} + +func printAdoptHelp() { + fmt.Println(`Usage: skillshare adopt [target] [options] + +Adopt CLI-bundled skills that external tools (e.g. firecrawl/cli, +googleworkspace/cli) drop into the universal target (~/.agents/skills), +bypassing skillshare's source-of-truth model. + +Adopt migrates the canonical files into skillshare's source, removes the +external tool's orphan symlinks, re-syncs to all targets, and warns about any +lingering entries in the tool's lockfile (~/.agents/.skill-lock.json). The +lockfile is never modified — release those entries from the owning tool. + +Arguments: + [target] Reserved for future per-target scoping (optional) + +Options: + --all, -a Adopt all detected skills + --dry-run, -n Preview changes without applying + --force, -f Overwrite same-name skills in source and skip confirmation + --json Output results as JSON (implies --force) + --project, -p Use project-level config (.agents/skills) + --global, -g Use global config (~/.agents/skills) + --help, -h Show this help + +Examples: + skillshare adopt Detect and interactively adopt skills + skillshare adopt --all --force Adopt everything without prompting + skillshare adopt --dry-run Preview what would be adopted + skillshare adopt --json Adopt and emit JSON`) +} diff --git a/cmd/skillshare/adopt_handlers.go b/cmd/skillshare/adopt_handlers.go new file mode 100644 index 00000000..e8a6e114 --- /dev/null +++ b/cmd/skillshare/adopt_handlers.go @@ -0,0 +1,229 @@ +package main + +import ( + "fmt" + "time" + + "skillshare/internal/adopt" + "skillshare/internal/config" + "skillshare/internal/oplog" + "skillshare/internal/ui" +) + +// adoptContext carries the mode-specific inputs for the adopt flow so that the +// global and project handlers share a single orchestration core. +type adoptContext struct { + agentsPath string // universal/agents target skills dir (~/.agents/skills) + sourcePath string // skillshare source of truth + syncMode string // agents target sync mode + allTargets map[string]string // name -> skills dir, for orphan-link pruning + re-sync + targets map[string]config.TargetConfig // resolved targets, for re-sync (optional) + trashBase string // trash dir (global or project) + configPath string // config path for oplog +} + +// runAdoptCommand wires an adoptContext through detection, confirmation, +// migration, and rendering. It owns the oplog entry. +func runAdoptCommand(actx adoptContext, opts adoptOptions, start time.Time) error { + candidates, err := adopt.DetectAdoptable(actx.agentsPath, actx.sourcePath, actx.syncMode, actx.allTargets) + if err != nil { + logAdoptOp(actx.configPath, start, err, nil) + return adoptCommandError(err, opts.jsonOutput) + } + + // Annotate provenance from the lockfile (read-only). + lockEntries, _ := adopt.ReadLock(actx.agentsPath) + for i := range candidates { + candidates[i].SourceTool = adopt.Provenance(lockEntries, candidates[i].Name) + } + + if len(candidates) == 0 { + if opts.jsonOutput { + err = adoptOutputJSON(newAdoptResult(opts.dryRun), start, nil) + logAdoptOp(actx.configPath, start, err, nil) + return err + } + ui.Header(ui.WithModeLabel("Adopt")) + ui.Info("No adoptable skills found in %s", actx.agentsPath) + logAdoptOp(actx.configPath, start, nil, nil) + return nil + } + + if !opts.jsonOutput { + ui.Header(ui.WithModeLabel("Adopt")) + renderAdoptPreview(candidates) + } + + // Selection: confirm interactively unless --all + --force (or JSON). + selected, cancelled := selectAdoptCandidates(candidates, opts) + if cancelled { + ui.Info("Cancelled") + logAdoptOp(actx.configPath, start, nil, nil) + return nil + } + if len(selected) == 0 { + ui.Info("Nothing selected") + logAdoptOp(actx.configPath, start, nil, nil) + return nil + } + + res, err := applyAdopt(actx, selected, opts) + if opts.jsonOutput { + jsonErr := adoptOutputJSON(res, start, err) + logAdoptOp(actx.configPath, start, err, res) + if err != nil { + return jsonErr + } + return jsonErr + } + logAdoptOp(actx.configPath, start, err, res) + if err != nil { + return err + } + return renderAdoptResult(res, actx.sourcePath) +} + +// selectAdoptCandidates resolves which candidates to adopt. With --all+--force +// (or JSON output) all are selected without prompting; otherwise an interactive +// checklist is shown. Returns (selected, cancelled). +func selectAdoptCandidates(candidates []adopt.Candidate, opts adoptOptions) ([]adopt.Candidate, bool) { + // Explicit non-interactive opt-in: JSON output or --all --force. + if opts.jsonOutput || (opts.all && opts.force) { + return candidates, false + } + // Non-interactive terminal (CI, pipe): an explicit selection flag (--all, + // --dry-run) proceeds — with --all but no --force, conflicts are still + // skipped downstream. A bare run must NOT silently migrate + trash + // originals, so refuse and point at an explicit flag. Preview already shown. + if !shouldLaunchTUI(false, nil) { + if opts.all || opts.dryRun { + return candidates, false + } + ui.Warning("Non-interactive terminal: pass --all to adopt (add --force to overwrite conflicts), or --dry-run to preview.") + return nil, true + } + + items := make([]checklistItemData, len(candidates)) + for i, c := range candidates { + desc := c.Path + if c.SourceTool != "" { + desc = fmt.Sprintf("[%s] %s", c.SourceTool, c.Path) + } + if c.Conflict { + desc = "conflict — overwrites source · " + desc + } + items[i] = checklistItemData{label: c.Name, desc: desc, preSelected: !c.Conflict} + } + + idxs, err := runChecklistTUI(checklistConfig{ + title: "Adopt skills into skillshare", + itemName: "skill", + items: items, + }) + if err != nil || idxs == nil { + return nil, true + } + + picked := make([]adopt.Candidate, 0, len(idxs)) + for _, i := range idxs { + picked = append(picked, candidates[i]) + } + return picked, false +} + +// applyAdopt performs the migration via the shared adopt.Apply orchestration +// (copy into source, trash the original, prune orphan symlinks, re-sync to all +// targets), then adapts the result to the CLI's render/oplog shape. +func applyAdopt(actx adoptContext, selected []adopt.Candidate, opts adoptOptions) (*adoptResult, error) { + names := make([]string, len(selected)) + for i, c := range selected { + names[i] = c.Name + } + out, err := adopt.Apply(selected, adopt.Request{ + AgentsPath: actx.agentsPath, + SourcePath: actx.sourcePath, + SyncMode: actx.syncMode, + TrashBase: actx.trashBase, + AllTargets: actx.allTargets, + Targets: actx.targets, + DryRun: opts.dryRun, + Force: opts.force, + Selected: names, + }) + return adoptResultFromApply(out, opts.dryRun), err +} + +// adoptResultFromApply converts a shared adopt.Result into the CLI's adoptResult. +func adoptResultFromApply(out *adopt.Result, dryRun bool) *adoptResult { + res := newAdoptResult(dryRun) + if out == nil { + return res + } + res.Adopted = out.Adopted + res.Skipped = out.Skipped + res.Trashed = out.Trashed + res.Pruned = out.PrunedLinks + res.DryRun = out.DryRun + for k, v := range out.Failed { + res.Failed[k] = v + } + for _, w := range out.LockWarnings { + res.LockWarnings = append(res.LockWarnings, lockWarning{Name: w.Name, SourceTool: w.SourceTool}) + } + return res +} + +// runAdopt is a thin testable wrapper around the migration core (no UI/oplog). +func runAdopt(actx adoptContext, opts adoptOptions) (*adoptResult, error) { + candidates, err := adopt.DetectAdoptable(actx.agentsPath, actx.sourcePath, actx.syncMode, actx.allTargets) + if err != nil { + return newAdoptResult(opts.dryRun), err + } + lockEntries, _ := adopt.ReadLock(actx.agentsPath) + for i := range candidates { + candidates[i].SourceTool = adopt.Provenance(lockEntries, candidates[i].Name) + } + if len(candidates) == 0 { + return newAdoptResult(opts.dryRun), nil + } + return applyAdopt(actx, candidates, opts) +} + +func adoptCommandError(err error, jsonOutput bool) error { + if err == nil { + return nil + } + if jsonOutput { + return writeJSONError(err) + } + return err +} + +func adoptOutputJSON(res *adoptResult, start time.Time, adoptErr error) error { + return writeJSONResult(adoptResultToJSON(res, start), adoptErr) +} + +func logAdoptOp(cfgPath string, start time.Time, cmdErr error, res *adoptResult) { + status := statusFromErr(cmdErr) + if cmdErr == nil && res != nil && len(res.Failed) > 0 { + status = "partial" + } + + e := oplog.NewEntry("adopt", status, time.Since(start)) + args := map[string]any{ + "adopted": 0, + "trashed": 0, + "pruned": 0, + } + if res != nil { + args["adopted"] = len(res.Adopted) + args["trashed"] = res.Trashed + args["pruned"] = res.Pruned + args["dry_run"] = res.DryRun + } + e.Args = args + if cmdErr != nil { + e.Message = cmdErr.Error() + } + oplog.WriteWithLimit(cfgPath, oplog.OpsFile, e, logMaxEntries()) //nolint:errcheck +} diff --git a/cmd/skillshare/adopt_project.go b/cmd/skillshare/adopt_project.go new file mode 100644 index 00000000..879d1384 --- /dev/null +++ b/cmd/skillshare/adopt_project.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "time" + + "skillshare/internal/config" + "skillshare/internal/install" + "skillshare/internal/trash" +) + +// cmdAdoptProject builds the adoptContext from the project runtime and runs +// adopt, then reconciles ProjectConfig.Skills[] so adopted skills are tracked. +func cmdAdoptProject(opts adoptOptions, root string, start time.Time) error { + runtime, err := loadProjectRuntime(root) + if err != nil { + return adoptCommandError(err, opts.jsonOutput) + } + + agentsTarget, ok := findAgentsTarget(runtime.targets) + if !ok { + return adoptCommandError(fmt.Errorf("universal/agents target not configured in project; nothing to adopt"), opts.jsonOutput) + } + sc := agentsTarget.SkillsConfig() + + allTargets := make(map[string]string, len(runtime.targets)) + for name, t := range runtime.targets { + allTargets[name] = t.SkillsConfig().Path + } + + actx := adoptContext{ + agentsPath: sc.Path, + sourcePath: runtime.sourcePath, + syncMode: adoptSyncMode(sc.Mode, ""), + allTargets: allTargets, + targets: runtime.targets, + trashBase: trash.ProjectTrashDir(root), + configPath: config.ProjectConfigPath(root), + } + + if err := runAdoptCommand(actx, opts, start); err != nil { + return err + } + + if opts.dryRun { + return nil + } + + // Reload metadata then reconcile project config so adopted skills are tracked. + if freshStore, loadErr := install.LoadMetadata(runtime.sourcePath); loadErr == nil { + runtime.skillsStore = freshStore + } + return reconcileProjectRemoteSkills(runtime) +} diff --git a/cmd/skillshare/adopt_render.go b/cmd/skillshare/adopt_render.go new file mode 100644 index 00000000..16c963b9 --- /dev/null +++ b/cmd/skillshare/adopt_render.go @@ -0,0 +1,144 @@ +package main + +import ( + "fmt" + "time" + + "github.com/pterm/pterm" + + "skillshare/internal/adopt" + "skillshare/internal/ui" +) + +// adoptOptions holds parsed CLI flags for `skillshare adopt`. +type adoptOptions struct { + dryRun bool + force bool + all bool + jsonOutput bool + targetName string +} + +// lockWarning describes a skill that was adopted but is still referenced in the +// external tool's lockfile. We never write the lockfile — we only warn. +type lockWarning struct { + Name string `json:"name"` + SourceTool string `json:"source_tool"` +} + +// adoptResult is the outcome of an adopt run, used for rendering and oplog. +type adoptResult struct { + Adopted []string // skill names migrated into source + Skipped []string // skills skipped (conflict, no --force) + Failed map[string]string + Trashed int // originals moved to trash + Pruned int // orphan symlinks removed across targets + LockWarnings []lockWarning // adopted skills still present in the lockfile + DryRun bool +} + +// adoptJSONOutput is the JSON shape for `skillshare adopt --json`. +type adoptJSONOutput struct { + Adopted []string `json:"adopted"` + Skipped []string `json:"skipped"` + Failed map[string]string `json:"failed"` + Trashed int `json:"trashed"` + Pruned int `json:"pruned"` + LockWarnings []lockWarning `json:"lock_warnings"` + DryRun bool `json:"dry_run"` + Duration string `json:"duration"` +} + +func newAdoptResult(dryRun bool) *adoptResult { + return &adoptResult{Failed: make(map[string]string), DryRun: dryRun} +} + +// renderAdoptPreview lists the detected candidates before any changes are made. +func renderAdoptPreview(candidates []adopt.Candidate) { + ui.Header(ui.WithModeLabel("Adoptable skills found")) + for _, c := range candidates { + detail := c.Path + if c.SourceTool != "" { + detail = fmt.Sprintf("[%s] %s", c.SourceTool, c.Path) + } + status := "info" + if c.Conflict { + status = "warning" + detail = "conflict: already in source — use --force to overwrite · " + detail + } + ui.ListItem(status, c.Name, detail) + if n := len(c.ExternalLinks); n > 0 { + ui.ListItem("info", "", fmt.Sprintf(" %d orphan symlink(s) to clean", n)) + } + } +} + +// renderAdoptResult prints the outcome of an adopt run in human-readable form. +func renderAdoptResult(res *adoptResult, source string) error { + ui.Header(ui.WithModeLabel("Adopting skills")) + + for _, name := range res.Adopted { + ui.StepDone(name, "migrated to source") + } + for _, name := range res.Skipped { + ui.StepSkip(name, "already exists in source, use --force to overwrite") + } + for name, msg := range res.Failed { + ui.StepFail(name, msg) + } + + ui.OperationSummary("Adopt", 0, + ui.Metric{Label: "adopted", Count: len(res.Adopted), HighlightColor: pterm.Green}, + ui.Metric{Label: "trashed", Count: res.Trashed, HighlightColor: pterm.Yellow}, + ui.Metric{Label: "pruned", Count: res.Pruned, HighlightColor: pterm.Yellow}, + ui.Metric{Label: "failed", Count: len(res.Failed), HighlightColor: pterm.Red}, + ) + + renderLockWarnings(res.LockWarnings) + + if len(res.Adopted) > 0 { + fmt.Println() + ui.Info("Synced to all targets. Source of truth: %s", source) + } + + return nil +} + +// renderLockWarnings warns about lingering lockfile entries. We never touch the +// lockfile; the owning tool must release them. +func renderLockWarnings(warnings []lockWarning) { + if len(warnings) == 0 { + return + } + fmt.Println() + ui.Warning("Adopted skills are still tracked in %s:", adopt.LockFileName()) + for _, w := range warnings { + if w.SourceTool != "" { + ui.ListItem("warning", w.Name, fmt.Sprintf("owned by %s — run its uninstall to release", w.SourceTool)) + } else { + ui.ListItem("warning", w.Name, "run the owning tool's uninstall to release the lock") + } + } + ui.Info("skillshare never modifies the lockfile; release entries from the owning tool.") +} + +// adoptResultToJSON converts an adoptResult to its JSON output form. +func adoptResultToJSON(res *adoptResult, start time.Time) *adoptJSONOutput { + out := &adoptJSONOutput{ + Failed: make(map[string]string), + LockWarnings: []lockWarning{}, + DryRun: res.DryRun, + Duration: formatDuration(start), + } + out.Adopted = res.Adopted + out.Skipped = res.Skipped + out.Trashed = res.Trashed + out.Pruned = res.Pruned + if res.LockWarnings != nil { + out.LockWarnings = res.LockWarnings + } + for k, v := range res.Failed { + out.Failed[k] = v + } + return out +} diff --git a/cmd/skillshare/adopt_test.go b/cmd/skillshare/adopt_test.go new file mode 100644 index 00000000..ccf756a4 --- /dev/null +++ b/cmd/skillshare/adopt_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "skillshare/internal/adopt" +) + +// writeSkill creates a minimal skill directory with a SKILL.md file. +func writeSkill(t *testing.T, base, name string) string { + t.Helper() + dir := filepath.Join(base, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir skill: %v", err) + } + body := "---\nname: " + name + "\ndescription: test\n---\n# " + name + "\n" + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil { + t.Fatalf("write SKILL.md: %v", err) + } + return dir +} + +// newAdoptTestContext builds an adoptContext rooted in a temp dir, with an +// agents target dir, a (separate) target dir to host orphan symlinks, a source +// dir, and a trash dir. +func newAdoptTestContext(t *testing.T) (adoptContext, string) { + t.Helper() + root := t.TempDir() + agentsPath := filepath.Join(root, "agents", "skills") + sourcePath := filepath.Join(root, "source") + otherTarget := filepath.Join(root, "claude", "skills") + trashBase := filepath.Join(root, "trash") + + for _, d := range []string{agentsPath, sourcePath, otherTarget} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + + actx := adoptContext{ + agentsPath: agentsPath, + sourcePath: sourcePath, + syncMode: "merge", + allTargets: map[string]string{ + "universal": agentsPath, + "claude": otherTarget, + }, + trashBase: trashBase, + configPath: filepath.Join(root, "config.yaml"), + } + return actx, root +} + +func TestRunAdopt_MigratesSkillAndTrashesOriginal(t *testing.T) { + actx, _ := newAdoptTestContext(t) + + skillPath := writeSkill(t, actx.agentsPath, "firecrawl") + // Orphan symlink in the claude target pointing into the agents dir. + link := filepath.Join(actx.allTargets["claude"], "firecrawl") + if err := os.Symlink(skillPath, link); err != nil { + t.Fatalf("symlink: %v", err) + } + + res, err := runAdopt(actx, adoptOptions{all: true, force: true}) + if err != nil { + t.Fatalf("runAdopt: %v", err) + } + + if len(res.Adopted) != 1 || res.Adopted[0] != "firecrawl" { + t.Fatalf("adopted = %v, want [firecrawl]", res.Adopted) + } + + // Canonical file copied into source. + if _, err := os.Stat(filepath.Join(actx.sourcePath, "firecrawl", "SKILL.md")); err != nil { + t.Fatalf("skill not copied to source: %v", err) + } + + // Original removed from agents dir (moved to trash). + if _, err := os.Stat(filepath.Join(actx.agentsPath, "firecrawl")); !os.IsNotExist(err) { + t.Fatalf("original still present in agents dir: err=%v", err) + } + + // Trash contains the skill. + entries, _ := os.ReadDir(actx.trashBase) + if len(entries) == 0 { + t.Fatalf("trash is empty, expected trashed skill") + } + + if res.Trashed != 1 { + t.Errorf("trashed = %d, want 1", res.Trashed) + } +} + +func TestRunAdopt_DryRunMakesNoChanges(t *testing.T) { + actx, _ := newAdoptTestContext(t) + writeSkill(t, actx.agentsPath, "gws") + + res, err := runAdopt(actx, adoptOptions{all: true, force: true, dryRun: true}) + if err != nil { + t.Fatalf("runAdopt: %v", err) + } + + if len(res.Adopted) != 1 { + t.Fatalf("adopted = %v, want 1 entry", res.Adopted) + } + // Source untouched. + if _, err := os.Stat(filepath.Join(actx.sourcePath, "gws")); !os.IsNotExist(err) { + t.Errorf("dry-run copied to source: %v", err) + } + // Original untouched. + if _, err := os.Stat(filepath.Join(actx.agentsPath, "gws")); err != nil { + t.Errorf("dry-run removed original: %v", err) + } + if res.Trashed != 0 { + t.Errorf("dry-run trashed = %d, want 0", res.Trashed) + } +} + +func TestRunAdopt_WarnsOnLingeringLockfile(t *testing.T) { + actx, _ := newAdoptTestContext(t) + writeSkill(t, actx.agentsPath, "firecrawl") + + // Lockfile still references the adopted skill. + lock := map[string]any{ + "skills": map[string]any{ + "firecrawl": map[string]any{"sourceTool": "firecrawl"}, + }, + } + data, _ := json.Marshal(lock) + if err := os.WriteFile(filepath.Join(actx.agentsPath, ".skill-lock.json"), data, 0o644); err != nil { + t.Fatalf("write lock: %v", err) + } + + res, err := runAdopt(actx, adoptOptions{all: true, force: true}) + if err != nil { + t.Fatalf("runAdopt: %v", err) + } + + if len(res.LockWarnings) != 1 || res.LockWarnings[0].Name != "firecrawl" { + t.Fatalf("LockWarnings = %v, want one entry for firecrawl", res.LockWarnings) + } + if res.LockWarnings[0].SourceTool != "firecrawl" { + t.Errorf("SourceTool = %q, want firecrawl", res.LockWarnings[0].SourceTool) + } + + // Lockfile must NOT be modified. + raw, _ := os.ReadFile(filepath.Join(actx.agentsPath, ".skill-lock.json")) + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("lockfile became unreadable: %v", err) + } + if _, ok := got["skills"]; !ok { + t.Errorf("lockfile was mutated: %s", raw) + } +} + +func TestRunAdopt_NoCandidates(t *testing.T) { + actx, _ := newAdoptTestContext(t) + res, err := runAdopt(actx, adoptOptions{all: true, force: true}) + if err != nil { + t.Fatalf("runAdopt: %v", err) + } + if len(res.Adopted) != 0 { + t.Errorf("adopted = %v, want empty", res.Adopted) + } +} + +// Ensures Provenance wiring uses the adopt package as the source of truth. +func TestAdoptProvenance(t *testing.T) { + entries := map[string]adopt.LockEntry{ + "x": {Name: "x", SourceTool: "firecrawl"}, + } + if got := adopt.Provenance(entries, "x"); got != "firecrawl" { + t.Errorf("Provenance = %q, want firecrawl", got) + } +} diff --git a/cmd/skillshare/main.go b/cmd/skillshare/main.go index fc9cc880..2cbf5195 100644 --- a/cmd/skillshare/main.go +++ b/cmd/skillshare/main.go @@ -28,6 +28,7 @@ var commands = map[string]func([]string) error{ "backup": cmdBackup, "restore": cmdRestore, "collect": cmdCollect, + "adopt": cmdAdopt, "pull": cmdPull, "push": cmdPush, "commit": cmdCommit, @@ -240,6 +241,7 @@ func printUsage() { // Sync & Backup fmt.Println("SYNC & BACKUP") cmd("collect", "[agents] [target]", "Collect local skills/agents from target(s)") + cmd("adopt", "[target] [--all]", "Adopt CLI-bundled skills from ~/.agents into skillshare") cmd("backup", "", "Create backup of target(s)") cmd("restore", "", "Restore target from latest backup") cmd("trash", "[agents] list", "List trashed skills/agents") diff --git a/internal/adopt/apply.go b/internal/adopt/apply.go new file mode 100644 index 00000000..3f14b045 --- /dev/null +++ b/internal/adopt/apply.go @@ -0,0 +1,177 @@ +package adopt + +import ( + "skillshare/internal/config" + "skillshare/internal/sync" + "skillshare/internal/trash" +) + +// LockWarning describes a skill that was adopted but is still referenced in the +// external tool's lockfile. The lockfile is read-only — we only warn. +type LockWarning struct { + Name string `json:"name"` + SourceTool string `json:"source_tool"` +} + +// Request carries the inputs for the destructive adopt flow. It is the shared +// contract between the CLI handler and the server handler. +type Request struct { + // AgentsPath is the universal/agents target skills dir (e.g. ~/.agents/skills). + AgentsPath string + // SourcePath is skillshare's source of truth skills dir. + SourcePath string + // SyncMode is the agents target's sync mode (reserved for parity with detect). + SyncMode string + // TrashBase is the trash dir (global or project) for soft-deleting originals. + TrashBase string + // AllTargets maps target name -> skills dir, for orphan-link pruning. + AllTargets map[string]string + // Targets maps target name -> resolved config, for re-sync after migration. + Targets map[string]config.TargetConfig + // DryRun previews without mutating anything. + DryRun bool + // Force overwrites conflicting skills already present in source. + Force bool + // Selected lists candidate names to adopt. Empty means "all detected". + Selected []string +} + +// Result is the outcome of an adopt run. +type Result struct { + Adopted []string // skill names migrated into source + Skipped []string // skills skipped (conflict, no Force) + Failed map[string]string // skill name -> error message + Trashed int // originals moved to trash + PrunedLinks int // orphan symlinks removed across targets + LockWarnings []LockWarning // adopted skills still present in the lockfile + DryRun bool +} + +func newResult(dryRun bool) *Result { + return &Result{Failed: make(map[string]string), DryRun: dryRun} +} + +// Apply performs the destructive adopt flow: copy canonical files into source, +// trash the originals, prune orphan symlinks, re-sync to all targets, and warn +// about lingering lockfile entries. +// +// Safety semantics (must not regress): +// - copy happens before trash (originals only trashed after a successful copy) +// - DryRun performs zero mutation +// - conflicting skills are skipped unless Force is set +// - the lockfile is never written, only read for warnings +// +// candidates are the already-detected candidates (with provenance annotated). +// req.Selected filters them by name; an empty Selected adopts all candidates. +func Apply(candidates []Candidate, req Request) (*Result, error) { + res := newResult(req.DryRun) + + selected := filterSelected(candidates, req.Selected) + if len(selected) == 0 { + return res, nil + } + + // 1. Migrate canonical files into source via PullSkills. + locals := make([]sync.LocalSkillInfo, len(selected)) + for i, c := range selected { + locals[i] = sync.LocalSkillInfo{Name: c.Name, Path: c.Path} + } + pull, err := sync.PullSkills(locals, req.SourcePath, sync.PullOptions{DryRun: req.DryRun, Force: req.Force}) + if err != nil { + return res, err + } + res.Adopted = pull.Pulled + res.Skipped = pull.Skipped + for k, v := range pull.Failed { + res.Failed[k] = v.Error() + } + + // Set of names successfully copied (only these get trashed/pruned). + adopted := make(map[string]bool, len(res.Adopted)) + for _, n := range res.Adopted { + adopted[n] = true + } + + if req.DryRun { + // Still surface lockfile warnings in dry-run. + res.LockWarnings = lockWarningsFor(req.AgentsPath, res.Adopted) + return res, nil + } + + // 2. Trash the originals in the agents dir (only after a successful copy). + for _, c := range selected { + if !adopted[c.Name] { + continue + } + if _, terr := trash.MoveToTrash(c.Path, c.Name, req.TrashBase); terr != nil { + res.Failed[c.Name] = terr.Error() + continue + } + res.Trashed++ + } + + // 3. Prune orphan symlinks across all targets (source now owns the skills). + for name, targetPath := range req.AllTargets { + naming := targetNaming(req.Targets, name) + prune, perr := sync.PruneOrphanLinks(targetPath, req.SourcePath, nil, nil, name, naming, false, false) + if perr != nil || prune == nil { + continue + } + res.PrunedLinks += len(prune.Removed) + } + + // 4. Re-sync from source to all targets. + for name, target := range req.Targets { + // Best-effort; individual target failures must not abort the flow. + _, _ = sync.SyncTargetMerge(name, target, req.SourcePath, false, false, "") + } + + // 5. Warn about lingering lockfile entries (never write the lockfile). + res.LockWarnings = lockWarningsFor(req.AgentsPath, res.Adopted) + + return res, nil +} + +// filterSelected returns candidates whose name appears in names. An empty names +// slice returns all candidates unchanged. +func filterSelected(candidates []Candidate, names []string) []Candidate { + if len(names) == 0 { + return candidates + } + want := make(map[string]bool, len(names)) + for _, n := range names { + want[n] = true + } + picked := make([]Candidate, 0, len(names)) + for _, c := range candidates { + if want[c.Name] { + picked = append(picked, c) + } + } + return picked +} + +// targetNaming resolves the naming scheme for prune, falling back to the empty +// string (default flat naming) when the target config is unknown. +func targetNaming(targets map[string]config.TargetConfig, name string) string { + if t, ok := targets[name]; ok { + return t.SkillsConfig().TargetNaming + } + return "" +} + +// lockWarningsFor returns lock warnings for any adopted skill still present in +// the agents-dir lockfile. The lockfile is read-only. +func lockWarningsFor(agentsPath string, adopted []string) []LockWarning { + entries, _ := ReadLock(agentsPath) + if len(entries) == 0 { + return nil + } + var warnings []LockWarning + for _, name := range adopted { + if _, ok := entries[name]; ok { + warnings = append(warnings, LockWarning{Name: name, SourceTool: Provenance(entries, name)}) + } + } + return warnings +} diff --git a/internal/adopt/apply_test.go b/internal/adopt/apply_test.go new file mode 100644 index 00000000..a52ca4eb --- /dev/null +++ b/internal/adopt/apply_test.go @@ -0,0 +1,204 @@ +package adopt + +import ( + "os" + "path/filepath" + "testing" +) + +// applyEnv builds an agents dir + source dir + trash base and returns the +// detected candidates ready to feed into Apply. +func applyEnv(t *testing.T) (agents, source, trashBase string) { + t.Helper() + tmp := t.TempDir() + agents = filepath.Join(tmp, "agents") + source = filepath.Join(tmp, "source") + trashBase = filepath.Join(tmp, "trash") + if err := os.MkdirAll(agents, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(source, 0755); err != nil { + t.Fatal(err) + } + return agents, source, trashBase +} + +func detect(t *testing.T, agents, source string) []Candidate { + t.Helper() + cands, err := DetectAdoptable(agents, source, "merge", nil) + if err != nil { + t.Fatalf("detect: %v", err) + } + return cands +} + +func TestApply_DryRunNoMutation(t *testing.T) { + agents, source, trashBase := applyEnv(t) + skillDir := mkSkill(t, agents, "web-scraper") + cands := detect(t, agents, source) + + res, err := Apply(cands, Request{ + AgentsPath: agents, + SourcePath: source, + TrashBase: trashBase, + DryRun: true, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if res.Trashed != 0 || res.PrunedLinks != 0 { + t.Errorf("dry-run mutated: trashed=%d pruned=%d", res.Trashed, res.PrunedLinks) + } + // Original must still exist; source must NOT have received a copy. + if _, err := os.Stat(skillDir); err != nil { + t.Errorf("original removed in dry-run: %v", err) + } + if _, err := os.Stat(filepath.Join(source, "web-scraper")); !os.IsNotExist(err) { + t.Errorf("source mutated in dry-run: %v", err) + } +} + +func TestApply_MigrateAndTrash(t *testing.T) { + agents, source, trashBase := applyEnv(t) + skillDir := mkSkill(t, agents, "web-scraper") + cands := detect(t, agents, source) + + res, err := Apply(cands, Request{ + AgentsPath: agents, + SourcePath: source, + TrashBase: trashBase, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if len(res.Adopted) != 1 || res.Adopted[0] != "web-scraper" { + t.Fatalf("adopted = %v, want [web-scraper]", res.Adopted) + } + if res.Trashed != 1 { + t.Errorf("trashed = %d, want 1", res.Trashed) + } + // Source now owns the skill; original is gone. + if _, err := os.Stat(filepath.Join(source, "web-scraper", "SKILL.md")); err != nil { + t.Errorf("skill not migrated to source: %v", err) + } + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Errorf("original not trashed: %v", err) + } +} + +func TestApply_ConflictSkippedWithoutForce(t *testing.T) { + agents, source, trashBase := applyEnv(t) + skillDir := mkSkill(t, agents, "web-scraper") + mkSkill(t, source, "web-scraper") // conflict in source + cands := detect(t, agents, source) + + res, err := Apply(cands, Request{ + AgentsPath: agents, + SourcePath: source, + TrashBase: trashBase, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if len(res.Adopted) != 0 { + t.Errorf("adopted = %v, want none (conflict)", res.Adopted) + } + if len(res.Skipped) != 1 { + t.Errorf("skipped = %v, want 1", res.Skipped) + } + if res.Trashed != 0 { + t.Errorf("trashed = %d, want 0 (skipped conflict must not be trashed)", res.Trashed) + } + // Original must survive — never trashed without a successful copy. + if _, err := os.Stat(skillDir); err != nil { + t.Errorf("conflicting original removed: %v", err) + } +} + +func TestApply_ConflictAdoptedWithForce(t *testing.T) { + agents, source, trashBase := applyEnv(t) + skillDir := mkSkill(t, agents, "web-scraper") + mkSkill(t, source, "web-scraper") + cands := detect(t, agents, source) + + res, err := Apply(cands, Request{ + AgentsPath: agents, + SourcePath: source, + TrashBase: trashBase, + Force: true, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if len(res.Adopted) != 1 { + t.Fatalf("adopted = %v, want 1 (force)", res.Adopted) + } + if res.Trashed != 1 { + t.Errorf("trashed = %d, want 1", res.Trashed) + } + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Errorf("original not trashed under force: %v", err) + } +} + +func TestApply_LockfileUntouchedAndWarned(t *testing.T) { + agents, source, trashBase := applyEnv(t) + mkSkill(t, agents, "web-scraper") + + lockPath := filepath.Join(agents, LockFileName()) + lockData := []byte(`{"web-scraper":{"sourceTool":"firecrawl"}}`) + if err := os.WriteFile(lockPath, lockData, 0644); err != nil { + t.Fatal(err) + } + + cands := detect(t, agents, source) + res, err := Apply(cands, Request{ + AgentsPath: agents, + SourcePath: source, + TrashBase: trashBase, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + + // Lockfile must be byte-for-byte unchanged (READ-ONLY). + after, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("lockfile gone: %v", err) + } + if string(after) != string(lockData) { + t.Errorf("lockfile mutated: %s", after) + } + + // Adopted skill still in lockfile => a warning. + if len(res.LockWarnings) != 1 { + t.Fatalf("lock warnings = %v, want 1", res.LockWarnings) + } + if res.LockWarnings[0].Name != "web-scraper" || res.LockWarnings[0].SourceTool != "firecrawl" { + t.Errorf("unexpected warning: %+v", res.LockWarnings[0]) + } +} + +func TestApply_SelectedFilter(t *testing.T) { + agents, source, trashBase := applyEnv(t) + mkSkill(t, agents, "alpha") + mkSkill(t, agents, "beta") + cands := detect(t, agents, source) + + res, err := Apply(cands, Request{ + AgentsPath: agents, + SourcePath: source, + TrashBase: trashBase, + Selected: []string{"alpha"}, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if len(res.Adopted) != 1 || res.Adopted[0] != "alpha" { + t.Fatalf("adopted = %v, want [alpha]", res.Adopted) + } + // beta must remain untouched in agents. + if _, err := os.Stat(filepath.Join(agents, "beta")); err != nil { + t.Errorf("unselected beta was touched: %v", err) + } +} diff --git a/internal/adopt/detect.go b/internal/adopt/detect.go new file mode 100644 index 00000000..32deba98 --- /dev/null +++ b/internal/adopt/detect.go @@ -0,0 +1,99 @@ +package adopt + +import ( + "os" + "path/filepath" + "sort" + + "skillshare/internal/sync" + "skillshare/internal/utils" +) + +// Candidate is a real (non-symlinked) skill living in the agents/universal +// target that can be adopted into skillshare's source of truth. +type Candidate struct { + // Name is the skill directory name. + Name string + // Path is the absolute path of the skill directory in the agents target. + Path string + // SourceTool is the owning external tool, if recorded in the lockfile. + SourceTool string + // Conflict is true when a directory of the same name already exists in source. + Conflict bool + // ExternalLinks are symlinks in other target dirs that point into the + // agents target for this skill (orphan symlinks the external tool created). + ExternalLinks []string +} + +// DetectAdoptable scans the agents/universal target for real skill directories +// that bypass skillshare's source-of-truth model. +// +// - agentsPath: the universal target skills dir (e.g. ~/.agents/skills) +// - sourcePath: skillshare's source skills dir +// - syncMode: the agents target's sync mode ("merge", "copy", "symlink") +// - allTargets: name -> skills dir for every configured target; used to find +// orphan symlinks. The agents target itself (and its alias) is skipped. +// +// A missing agentsPath yields an empty slice and nil error. Conflict is marked +// when the skill name already exists in source (existence check, v1). +func DetectAdoptable(agentsPath, sourcePath, syncMode string, allTargets map[string]string) ([]Candidate, error) { + locals, err := sync.FindLocalSkills(agentsPath, sourcePath, syncMode) + if err != nil { + return nil, err + } + + absAgents, _ := filepath.Abs(agentsPath) + + candidates := make([]Candidate, 0, len(locals)) + for _, local := range locals { + c := Candidate{ + Name: local.Name, + Path: local.Path, + } + + // Conflict: same-name dir already present in source (v1: existence check). + if _, statErr := os.Stat(filepath.Join(sourcePath, local.Name)); statErr == nil { + c.Conflict = true + } + + c.ExternalLinks = findExternalLinks(local.Name, absAgents, allTargets) + candidates = append(candidates, c) + } + + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].Name < candidates[j].Name + }) + + return candidates, nil +} + +// findExternalLinks scans every target dir (except the agents target itself) +// for a symlink named skillName that resolves into absAgents. Missing target +// dirs are skipped. +func findExternalLinks(skillName, absAgents string, allTargets map[string]string) []string { + var links []string + for _, targetPath := range allTargets { + absTarget, _ := filepath.Abs(targetPath) + // Skip the agents/universal target itself (any alias mapping to it). + if utils.PathsEqual(absTarget, absAgents) { + continue + } + + linkPath := filepath.Join(targetPath, skillName) + info, err := os.Lstat(linkPath) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + continue + } + + resolved, err := utils.ResolveLinkTarget(linkPath) + if err != nil { + continue + } + // The symlink targets this skill inside the agents dir. + if utils.PathHasPrefix(resolved, absAgents) { + links = append(links, linkPath) + } + } + sort.Strings(links) + return links +} diff --git a/internal/adopt/detect_test.go b/internal/adopt/detect_test.go new file mode 100644 index 00000000..32ddef65 --- /dev/null +++ b/internal/adopt/detect_test.go @@ -0,0 +1,154 @@ +package adopt + +import ( + "os" + "path/filepath" + "testing" +) + +// mkSkill creates a real skill directory with a SKILL.md file. +func mkSkill(t *testing.T, base, name string) string { + t.Helper() + dir := filepath.Join(base, name) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: "+name+"\n---\n# "+name), 0644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestDetectAdoptable_MissingAgentsPath(t *testing.T) { + tmp := t.TempDir() + agents := filepath.Join(tmp, "does-not-exist") + source := filepath.Join(tmp, "source") + + cands, err := DetectAdoptable(agents, source, "merge", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cands) != 0 { + t.Fatalf("expected empty candidates, got %d", len(cands)) + } +} + +func TestDetectAdoptable_RealDirSelected_SymlinkSkipped(t *testing.T) { + tmp := t.TempDir() + agents := filepath.Join(tmp, "agents") + source := filepath.Join(tmp, "source") + if err := os.MkdirAll(agents, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(source, 0755); err != nil { + t.Fatal(err) + } + + // Real local skill dir. + mkSkill(t, agents, "web-scraper") + + // A symlink that points back to source (synced) must be skipped. + srcSkill := mkSkill(t, source, "managed") + if err := os.Symlink(srcSkill, filepath.Join(agents, "managed")); err != nil { + t.Fatal(err) + } + + cands, err := DetectAdoptable(agents, source, "merge", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cands) != 1 { + t.Fatalf("expected 1 candidate, got %d: %+v", len(cands), cands) + } + if cands[0].Name != "web-scraper" { + t.Errorf("candidate name = %q, want web-scraper", cands[0].Name) + } + if cands[0].Conflict { + t.Errorf("expected no conflict for web-scraper") + } +} + +func TestDetectAdoptable_ConflictWhenSameNameInSource(t *testing.T) { + tmp := t.TempDir() + agents := filepath.Join(tmp, "agents") + source := filepath.Join(tmp, "source") + mkSkill(t, agents, "web-scraper") + mkSkill(t, source, "web-scraper") + + cands, err := DetectAdoptable(agents, source, "merge", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cands) != 1 { + t.Fatalf("expected 1 candidate, got %d", len(cands)) + } + if !cands[0].Conflict { + t.Errorf("expected conflict=true for web-scraper (exists in source)") + } +} + +func TestDetectAdoptable_ExternalLinksDiscovered(t *testing.T) { + tmp := t.TempDir() + agents := filepath.Join(tmp, "agents") + source := filepath.Join(tmp, "source") + claude := filepath.Join(tmp, "claude") + cursor := filepath.Join(tmp, "cursor") + if err := os.MkdirAll(claude, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(cursor, 0755); err != nil { + t.Fatal(err) + } + + skillDir := mkSkill(t, agents, "web-scraper") + + // External tool symlinked the agents skill into claude (orphan symlink). + if err := os.Symlink(skillDir, filepath.Join(claude, "web-scraper")); err != nil { + t.Fatal(err) + } + // cursor has no link. + + allTargets := map[string]string{ + "universal": agents, // must be skipped + "agents": agents, // alias, also skipped + "claude": claude, + "cursor": cursor, + } + + cands, err := DetectAdoptable(agents, source, "merge", allTargets) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cands) != 1 { + t.Fatalf("expected 1 candidate, got %d", len(cands)) + } + links := cands[0].ExternalLinks + if len(links) != 1 { + t.Fatalf("expected 1 external link, got %d: %v", len(links), links) + } + if links[0] != filepath.Join(claude, "web-scraper") { + t.Errorf("external link = %q, want %q", links[0], filepath.Join(claude, "web-scraper")) + } +} + +func TestDetectAdoptable_ExternalLinksMissingTargetDirsOK(t *testing.T) { + tmp := t.TempDir() + agents := filepath.Join(tmp, "agents") + source := filepath.Join(tmp, "source") + mkSkill(t, agents, "web-scraper") + + allTargets := map[string]string{ + "claude": filepath.Join(tmp, "no-such-claude"), + } + + cands, err := DetectAdoptable(agents, source, "merge", allTargets) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cands) != 1 { + t.Fatalf("expected 1 candidate, got %d", len(cands)) + } + if len(cands[0].ExternalLinks) != 0 { + t.Errorf("expected no external links when target dir missing, got %v", cands[0].ExternalLinks) + } +} diff --git a/internal/adopt/lockfile.go b/internal/adopt/lockfile.go new file mode 100644 index 00000000..6eb81f30 --- /dev/null +++ b/internal/adopt/lockfile.go @@ -0,0 +1,114 @@ +// Package adopt detects and migrates skills that external CLI tools (e.g. +// firecrawl/cli, googleworkspace/cli) drop into skillshare's "universal" target +// (~/.agents/skills) and track in ~/.agents/.skill-lock.json, bypassing the +// source-of-truth model. The lockfile is treated as READ-ONLY: we detect and +// warn, but never write or prune it. +package adopt + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// lockFileName is the lockfile external tools maintain in the agents dir. +const lockFileName = ".skill-lock.json" + +// LockFileName returns the name of the external tool lockfile we detect. +func LockFileName() string { return lockFileName } + +// LockEntry describes a single skill recorded in the external tool's lockfile. +// SourceTool is the owning tool (firecrawl, googleworkspace, ...) used for +// provenance reporting. Raw preserves the original fields so callers can +// inspect anything else the lockfile carried without us guessing its schema. +type LockEntry struct { + Name string + SourceTool string + Raw map[string]any +} + +// The exact lockfile schema is not standardized across tools, so we parse +// defensively. Two shapes are supported: +// +// (a) nested: {"skills": {"": {}}} +// (b) flat: {"": {}} +// +// For each entry the source tool is read from the first present of: +// "sourceTool", "source", "tool", "owner". Unknown shapes degrade gracefully +// to an empty SourceTool rather than failing. +func sourceToolFromRaw(raw map[string]any) string { + for _, key := range []string{"sourceTool", "source", "tool", "owner"} { + if v, ok := raw[key]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + } + return "" +} + +// ReadLock reads agentsDir/.skill-lock.json and returns its entries keyed by +// skill name. The lockfile is never modified. +// +// Behavior: +// - file does not exist => empty map, nil error +// - malformed JSON => empty (non-nil) map + non-fatal error (caller may ignore) +// - valid => populated map, nil error +func ReadLock(agentsDir string) (map[string]LockEntry, error) { + entries := make(map[string]LockEntry) + + data, err := os.ReadFile(filepath.Join(agentsDir, lockFileName)) + if err != nil { + if os.IsNotExist(err) { + return entries, nil + } + return entries, err + } + + // Parse into a tolerant top-level map first. + var top map[string]json.RawMessage + if err := json.Unmarshal(data, &top); err != nil { + return entries, errors.New("malformed lockfile: " + err.Error()) + } + + // Shape (a): nested "skills" object. Shape (b): flat top-level map. + if skillsRaw, ok := top["skills"]; ok { + var nested map[string]map[string]any + if err := json.Unmarshal(skillsRaw, &nested); err != nil { + return entries, errors.New("malformed lockfile skills: " + err.Error()) + } + for name, raw := range nested { + entries[name] = LockEntry{ + Name: name, + SourceTool: sourceToolFromRaw(raw), + Raw: raw, + } + } + return entries, nil + } + + for name, rawMsg := range top { + var raw map[string]any + if err := json.Unmarshal(rawMsg, &raw); err != nil { + // Not an object value (e.g. metadata field) — skip leniently. + continue + } + entries[name] = LockEntry{ + Name: name, + SourceTool: sourceToolFromRaw(raw), + Raw: raw, + } + } + + return entries, nil +} + +// Provenance returns the source tool recorded for a skill name, or "" if the +// name is not present in the lockfile entries. +func Provenance(entries map[string]LockEntry, name string) string { + if e, ok := entries[name]; ok { + return e.SourceTool + } + return "" +} diff --git a/internal/adopt/lockfile_test.go b/internal/adopt/lockfile_test.go new file mode 100644 index 00000000..bd410eff --- /dev/null +++ b/internal/adopt/lockfile_test.go @@ -0,0 +1,102 @@ +package adopt + +import ( + "os" + "path/filepath" + "testing" +) + +func writeLock(t *testing.T, dir, content string) { + t.Helper() + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, lockFileName), []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestReadLock_Missing(t *testing.T) { + dir := t.TempDir() + entries, err := ReadLock(dir) + if err != nil { + t.Fatalf("expected nil error for missing lockfile, got %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected empty map, got %d entries", len(entries)) + } +} + +func TestReadLock_NestedSkillsObject(t *testing.T) { + dir := t.TempDir() + writeLock(t, dir, `{ + "skills": { + "web-scraper": {"source": "firecrawl", "version": "1.0.0"}, + "gmail": {"sourceTool": "googleworkspace"} + } + }`) + + entries, err := ReadLock(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries["web-scraper"].SourceTool != "firecrawl" { + t.Errorf("web-scraper source tool = %q, want firecrawl", entries["web-scraper"].SourceTool) + } + if entries["web-scraper"].Name != "web-scraper" { + t.Errorf("web-scraper name = %q, want web-scraper", entries["web-scraper"].Name) + } + if entries["gmail"].SourceTool != "googleworkspace" { + t.Errorf("gmail source tool = %q, want googleworkspace", entries["gmail"].SourceTool) + } +} + +func TestReadLock_FlatMap(t *testing.T) { + dir := t.TempDir() + writeLock(t, dir, `{ + "web-scraper": {"tool": "firecrawl"}, + "gmail": {"source": "googleworkspace"} + }`) + + entries, err := ReadLock(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries["web-scraper"].SourceTool != "firecrawl" { + t.Errorf("web-scraper source tool = %q, want firecrawl", entries["web-scraper"].SourceTool) + } +} + +func TestReadLock_Malformed(t *testing.T) { + dir := t.TempDir() + writeLock(t, dir, `{not valid json`) + + entries, err := ReadLock(dir) + if err == nil { + t.Fatalf("expected non-fatal error for malformed JSON") + } + if entries == nil { + t.Fatalf("expected non-nil (empty) map even on malformed JSON") + } + if len(entries) != 0 { + t.Fatalf("expected empty map on malformed JSON, got %d entries", len(entries)) + } +} + +func TestProvenance(t *testing.T) { + entries := map[string]LockEntry{ + "web-scraper": {Name: "web-scraper", SourceTool: "firecrawl"}, + } + if got := Provenance(entries, "web-scraper"); got != "firecrawl" { + t.Errorf("Provenance = %q, want firecrawl", got) + } + if got := Provenance(entries, "unknown"); got != "" { + t.Errorf("Provenance for unknown = %q, want empty", got) + } +} diff --git a/tests/integration/adopt_test.go b/tests/integration/adopt_test.go new file mode 100644 index 00000000..b300d153 --- /dev/null +++ b/tests/integration/adopt_test.go @@ -0,0 +1,304 @@ +//go:build !online + +package integration + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "skillshare/internal/testutil" +) + +// adoptWriteSkill creates a real skill directory (with SKILL.md) at base/name. +func adoptWriteSkill(t *testing.T, base, name string) string { + t.Helper() + dir := filepath.Join(base, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir skill %s: %v", name, err) + } + body := "---\nname: " + name + "\ndescription: external skill\n---\n# " + name + "\n" + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil { + t.Fatalf("write SKILL.md: %v", err) + } + return dir +} + +// adoptWriteLock writes a fake ~/.agents/.skill-lock.json claiming the given +// skill names, each owned by sourceTool. +func adoptWriteLock(t *testing.T, agentsDir, sourceTool string, names ...string) { + t.Helper() + skills := map[string]any{} + for _, n := range names { + skills[n] = map[string]any{"sourceTool": sourceTool} + } + lock := map[string]any{"skills": skills} + data, err := json.Marshal(lock) + if err != nil { + t.Fatalf("marshal lock: %v", err) + } + if err := os.WriteFile(filepath.Join(agentsDir, ".skill-lock.json"), data, 0o644); err != nil { + t.Fatalf("write lock: %v", err) + } +} + +// setupAdoptGlobal wires a sandbox where the universal/agents target +// (~/.agents/skills) holds a real external skill , the claude target has an +// orphan symlink to it, and a lockfile claims . Returns (agentsPath, claudePath). +func setupAdoptGlobal(t *testing.T, sb *testutil.Sandbox, skillName, sourceTool string) (string, string) { + t.Helper() + + agentsPath := filepath.Join(sb.Home, ".agents", "skills") + if err := os.MkdirAll(agentsPath, 0o755); err != nil { + t.Fatalf("mkdir agents: %v", err) + } + claudePath := sb.CreateTarget("claude") + + // Real external skill dropped by the CLI tool. + skillPath := adoptWriteSkill(t, agentsPath, skillName) + + // External symlink in the claude target pointing into the agents dir. + link := filepath.Join(claudePath, skillName) + if err := os.Symlink(skillPath, link); err != nil { + t.Fatalf("symlink: %v", err) + } + + // Lockfile claims the skill. + adoptWriteLock(t, agentsPath, sourceTool, skillName) + + sb.WriteConfig(`source: ` + sb.SourcePath + ` +targets: + universal: + path: ` + agentsPath + ` + claude: + path: ` + claudePath + ` +`) + + return agentsPath, claudePath +} + +func TestAdopt_DryRun_NoChanges(t *testing.T) { + sb := testutil.NewSandbox(t) + defer sb.Cleanup() + + agentsPath, claudePath := setupAdoptGlobal(t, sb, "firecrawl", "firecrawl") + + result := sb.RunCLI("adopt", "-g", "--all", "--dry-run") + result.AssertSuccess(t) + result.AssertOutputContains(t, "firecrawl") + + // Source untouched. + if sb.FileExists(filepath.Join(sb.SourcePath, "firecrawl")) { + t.Error("dry-run copied skill into source") + } + // Original untouched. + if !sb.FileExists(filepath.Join(agentsPath, "firecrawl", "SKILL.md")) { + t.Error("dry-run removed original from agents dir") + } + // Orphan symlink untouched. + if !sb.FileExists(filepath.Join(claudePath, "firecrawl")) { + t.Error("dry-run removed orphan symlink") + } +} + +func TestAdopt_Apply_MigratesAndResyncs(t *testing.T) { + sb := testutil.NewSandbox(t) + defer sb.Cleanup() + + agentsPath, claudePath := setupAdoptGlobal(t, sb, "firecrawl", "firecrawl") + + result := sb.RunCLI("adopt", "-g", "--all", "--force") + result.AssertSuccess(t) + + // Canonical files now in source. + if !sb.FileExists(filepath.Join(sb.SourcePath, "firecrawl", "SKILL.md")) { + t.Errorf("skill not migrated into source\n%s", result.Output()) + } + + // The original real directory was moved out of the agents dir; whatever + // remains at that path must be a symlink into source (re-synced), not the + // original real directory. + agentsEntry := filepath.Join(agentsPath, "firecrawl") + if sb.FileExists(agentsEntry) && !sb.IsSymlink(agentsEntry) { + t.Error("original real directory still present in agents dir after adopt") + } + + // Trash holds the migrated original (restorable). + trashBase := filepath.Join(sb.Home, ".local", "share", "skillshare", "trash") + entries, _ := os.ReadDir(trashBase) + if len(entries) == 0 { + t.Error("trash is empty, expected the migrated original") + } + + // After re-sync the claude target has a symlink for firecrawl pointing into + // source (the old orphan symlink into the agents dir is gone). + claudeLink := filepath.Join(claudePath, "firecrawl") + if !sb.IsSymlink(claudeLink) { + t.Fatalf("expected a symlink for firecrawl in claude target\n%s", result.Output()) + } + tgt := sb.SymlinkTarget(claudeLink) + if want := filepath.Join(sb.SourcePath, "firecrawl"); tgt != want { + t.Errorf("claude symlink target = %q, want %q (should point into source, not agents)", tgt, want) + } +} + +func TestAdopt_LockfileUnchanged_WithWarning(t *testing.T) { + sb := testutil.NewSandbox(t) + defer sb.Cleanup() + + agentsPath, _ := setupAdoptGlobal(t, sb, "firecrawl", "firecrawl") + + lockPath := filepath.Join(agentsPath, ".skill-lock.json") + before, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("read lock before: %v", err) + } + + result := sb.RunCLI("adopt", "-g", "--all", "--force") + result.AssertSuccess(t) + + // Output warns about the lingering lock entry. + result.AssertAnyOutputContains(t, ".skill-lock.json") + + // Lockfile is byte-for-byte unchanged on disk. + after, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("read lock after: %v", err) + } + if string(before) != string(after) { + t.Errorf("lockfile was modified:\nbefore: %s\nafter: %s", before, after) + } +} + +func TestAdopt_SameNameConflict_NotOverwrittenWithoutForce(t *testing.T) { + sb := testutil.NewSandbox(t) + defer sb.Cleanup() + + // Source already has a skill with the same name. + sb.CreateSkill("firecrawl", map[string]string{"SKILL.md": "# Source Version"}) + + agentsPath, _ := setupAdoptGlobal(t, sb, "firecrawl", "firecrawl") + + // --all without --force must not silently overwrite the source copy. + result := sb.RunCLI("adopt", "-g", "--all") + result.AssertSuccess(t) + + // Source content preserved. + content, err := os.ReadFile(filepath.Join(sb.SourcePath, "firecrawl", "SKILL.md")) + if err != nil { + t.Fatalf("read source skill: %v", err) + } + if string(content) != "# Source Version" { + t.Errorf("source overwritten without --force, got: %q", string(content)) + } + + // Original must remain in the agents dir since it was not adopted. + if !sb.FileExists(filepath.Join(agentsPath, "firecrawl", "SKILL.md")) { + t.Error("conflicting original was trashed even though it was skipped") + } +} + +func TestAdopt_MissingAgentsDir_NoOp(t *testing.T) { + sb := testutil.NewSandbox(t) + defer sb.Cleanup() + + // Universal target configured, but the agents skills dir does not exist. + agentsPath := filepath.Join(sb.Home, ".agents", "skills") + claudePath := sb.CreateTarget("claude") + sb.WriteConfig(`source: ` + sb.SourcePath + ` +targets: + universal: + path: ` + agentsPath + ` + claude: + path: ` + claudePath + ` +`) + + result := sb.RunCLI("adopt", "-g", "--all", "--force") + result.AssertSuccess(t) + result.AssertAnyOutputContains(t, "No adoptable skills") +} + +func TestAdopt_NonInteractive_BareRefuses(t *testing.T) { + sb := testutil.NewSandbox(t) + defer sb.Cleanup() + + agentsPath, claudePath := setupAdoptGlobal(t, sb, "firecrawl", "firecrawl") + + // A bare run in a non-interactive terminal (the test harness has no TTY) + // must NOT migrate or trash anything: it refuses and points at a flag. + result := sb.RunCLI("adopt", "-g") + result.AssertSuccess(t) + result.AssertAnyOutputContains(t, "Non-interactive terminal") + + // Nothing migrated into source. + if sb.FileExists(filepath.Join(sb.SourcePath, "firecrawl")) { + t.Error("bare non-interactive adopt migrated a skill") + } + // Original left untouched in the agents dir. + if !sb.FileExists(filepath.Join(agentsPath, "firecrawl", "SKILL.md")) { + t.Error("bare non-interactive adopt trashed the original") + } + // Orphan symlink left untouched. + if !sb.FileExists(filepath.Join(claudePath, "firecrawl")) { + t.Error("bare non-interactive adopt removed the orphan symlink") + } +} + +func TestAdoptProject_MigratesAndResyncs(t *testing.T) { + sb := testutil.NewSandbox(t) + defer sb.Cleanup() + + // Project with claude + universal targets (both resolve under the project). + projectRoot := sb.SetupProjectDir("claude") + sb.WriteProjectConfig(projectRoot, "targets:\n - claude\n - universal\n") + + // Project-level agents skills dir (.agents/skills) with a real external skill. + agentsPath := filepath.Join(projectRoot, ".agents", "skills") + if err := os.MkdirAll(agentsPath, 0o755); err != nil { + t.Fatalf("mkdir project agents: %v", err) + } + skillPath := adoptWriteSkill(t, agentsPath, "firecrawl") + + // Orphan symlink in the claude target. + claudeDir := filepath.Join(projectRoot, ".claude", "skills") + os.MkdirAll(claudeDir, 0o755) + if err := os.Symlink(skillPath, filepath.Join(claudeDir, "firecrawl")); err != nil { + t.Fatalf("symlink: %v", err) + } + + // Lockfile claims the skill. + adoptWriteLock(t, agentsPath, "firecrawl", "firecrawl") + + result := sb.RunCLIInDir(projectRoot, "adopt", "-p", "--all", "--force") + result.AssertSuccess(t) + + // Migrated into the project source. + projectSource := filepath.Join(projectRoot, ".skillshare", "skills") + if !sb.FileExists(filepath.Join(projectSource, "firecrawl", "SKILL.md")) { + t.Errorf("skill not migrated into project source\n%s", result.Output()) + } + + // The original real directory was moved out of the project agents dir; any + // remaining entry must be a re-synced symlink into source, not the original. + agentsEntry := filepath.Join(agentsPath, "firecrawl") + if sb.FileExists(agentsEntry) && !sb.IsSymlink(agentsEntry) { + t.Error("original real directory still present in project agents dir after adopt") + } + + // Trashed into the project trash dir (ProjectTrashDir). + projectTrash := filepath.Join(projectRoot, ".skillshare", "trash") + if entries, _ := os.ReadDir(projectTrash); len(entries) == 0 { + t.Error("project trash is empty, expected the migrated original") + } + + // After re-sync the claude target has a symlink for firecrawl into source. + claudeLink := filepath.Join(claudeDir, "firecrawl") + if !sb.IsSymlink(claudeLink) { + t.Errorf("expected re-synced symlink for firecrawl in claude target\n%s", result.Output()) + } else if tgt := sb.SymlinkTarget(claudeLink); tgt != filepath.Join(projectSource, "firecrawl") { + t.Errorf("claude symlink target = %q, want into project source", tgt) + } + + result.AssertAnyOutputContains(t, "firecrawl") +} From 1bafd74bb3be540ae58472b88accafce40430b5b Mon Sep 17 00:00:00 2001 From: Willie Date: Sat, 30 May 2026 00:46:45 +0800 Subject: [PATCH 2/7] feat(adopt): add web dashboard UI for adopt Expose adopt over REST (GET /api/adopt/preview, POST /api/adopt/apply), reusing the shared internal/adopt.Apply orchestration so the CLI and the server run one implementation of the destructive flow. Add an Adopt dashboard page: auto-loaded preview, multi-select with conflict/external-link badges, dry-run and force toggles, a result summary, and prominent lockfile warnings. Dual-mode aware (nav visible in both global and project). Refs #135 --- internal/server/handler_adopt.go | 218 ++++++++++++++ internal/server/server.go | 4 + ui/src/App.tsx | 2 + ui/src/api/client.ts | 37 +++ ui/src/components/Layout.tsx | 2 + ui/src/i18n/locales/en.json | 38 +++ ui/src/lib/queryKeys.ts | 1 + ui/src/pages/AdoptPage.tsx | 485 +++++++++++++++++++++++++++++++ 8 files changed, 787 insertions(+) create mode 100644 internal/server/handler_adopt.go create mode 100644 ui/src/pages/AdoptPage.tsx diff --git a/internal/server/handler_adopt.go b/internal/server/handler_adopt.go new file mode 100644 index 00000000..26e6dd86 --- /dev/null +++ b/internal/server/handler_adopt.go @@ -0,0 +1,218 @@ +package server + +import ( + "encoding/json" + "net/http" + "time" + + "skillshare/internal/adopt" + "skillshare/internal/config" + ssync "skillshare/internal/sync" + "skillshare/internal/trash" +) + +// adoptAgentsTargetNames are the canonical name + alias of the universal target +// (~/.agents/skills) that external CLI tools write into. Mirrors the CLI. +var adoptAgentsTargetNames = []string{"universal", "agents"} + +// findAgentsTarget locates the universal/agents target in a target map. +func findAgentsTarget(targets map[string]config.TargetConfig) (config.TargetConfig, bool) { + for _, name := range adoptAgentsTargetNames { + if t, ok := targets[name]; ok { + return t, true + } + } + return config.TargetConfig{}, false +} + +// adoptCandidate is the JSON shape of a detected adoptable skill. +type adoptCandidate struct { + Name string `json:"name"` + Path string `json:"path"` + SourceTool string `json:"sourceTool"` + Conflict bool `json:"conflict"` + ExternalLinks []string `json:"externalLinks"` +} + +// handleAdoptPreview detects adoptable skills in the agents/universal target for +// the current mode and returns them (with lockfile provenance) without mutating. +// GET /api/adopt/preview -> { candidates: [...], lockPresent: bool } +func (s *Server) handleAdoptPreview(w http.ResponseWriter, r *http.Request) { + // Snapshot config under RLock, then release before I/O. + s.mu.RLock() + source := s.skillsSource() + globalMode := s.cfg.Mode + targets := s.cloneTargets() + s.mu.RUnlock() + + agentsTarget, ok := findAgentsTarget(targets) + if !ok { + writeError(w, http.StatusBadRequest, "universal/agents target not configured; nothing to adopt") + return + } + sc := agentsTarget.SkillsConfig() + + allTargets := make(map[string]string, len(targets)) + for name, t := range targets { + allTargets[name] = t.SkillsConfig().Path + } + + syncMode := ssync.EffectiveMode(sc.Mode) + if sc.Mode == "" && globalMode != "" { + syncMode = globalMode + } + + candidates, err := adopt.DetectAdoptable(sc.Path, source, syncMode, allTargets) + if err != nil { + writeError(w, http.StatusInternalServerError, "adopt preview failed: "+err.Error()) + return + } + + // Annotate provenance from the lockfile (read-only). + lockEntries, _ := adopt.ReadLock(sc.Path) + for i := range candidates { + candidates[i].SourceTool = adopt.Provenance(lockEntries, candidates[i].Name) + } + + out := make([]adoptCandidate, 0, len(candidates)) + for _, c := range candidates { + links := c.ExternalLinks + if links == nil { + links = []string{} + } + out = append(out, adoptCandidate{ + Name: c.Name, + Path: c.Path, + SourceTool: c.SourceTool, + Conflict: c.Conflict, + ExternalLinks: links, + }) + } + + writeJSON(w, map[string]any{ + "candidates": out, + "lockPresent": len(lockEntries) > 0, + }) +} + +// handleAdoptApply migrates selected adoptable skills into source, trashes the +// originals, prunes orphan symlinks, re-syncs to all targets, and warns about +// lingering lockfile entries. +// POST /api/adopt/apply { names: []string (empty = all), force: bool, dryRun: bool } +func (s *Server) handleAdoptApply(w http.ResponseWriter, r *http.Request) { + start := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + + var body struct { + Names []string `json:"names"` + Force bool `json:"force"` + DryRun bool `json:"dryRun"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + source := s.skillsSource() + globalMode := s.cfg.Mode + targets := s.cloneTargets() + + agentsTarget, ok := findAgentsTarget(targets) + if !ok { + writeError(w, http.StatusBadRequest, "universal/agents target not configured; nothing to adopt") + return + } + sc := agentsTarget.SkillsConfig() + + allTargets := make(map[string]string, len(targets)) + for name, t := range targets { + allTargets[name] = t.SkillsConfig().Path + } + + syncMode := ssync.EffectiveMode(sc.Mode) + if sc.Mode == "" && globalMode != "" { + syncMode = globalMode + } + + trashBase := trash.TrashDir() + if s.IsProjectMode() { + trashBase = trash.ProjectTrashDir(s.projectRoot) + } + + // Detect first, then annotate provenance, then apply (mirror of the CLI). + candidates, err := adopt.DetectAdoptable(sc.Path, source, syncMode, allTargets) + if err != nil { + s.writeOpsLog("adopt", "error", start, map[string]any{"scope": "ui"}, err.Error()) + writeError(w, http.StatusInternalServerError, "adopt detect failed: "+err.Error()) + return + } + lockEntries, _ := adopt.ReadLock(sc.Path) + for i := range candidates { + candidates[i].SourceTool = adopt.Provenance(lockEntries, candidates[i].Name) + } + + res, err := adopt.Apply(candidates, adopt.Request{ + AgentsPath: sc.Path, + SourcePath: source, + SyncMode: syncMode, + TrashBase: trashBase, + AllTargets: allTargets, + Targets: targets, + DryRun: body.DryRun, + Force: body.Force, + Selected: body.Names, + }) + if err != nil { + s.writeOpsLog("adopt", "error", start, map[string]any{"scope": "ui"}, err.Error()) + writeError(w, http.StatusInternalServerError, "adopt failed: "+err.Error()) + return + } + + status := "ok" + msg := "" + if len(res.Failed) > 0 { + status = "partial" + msg = "some skills failed to adopt" + } + s.writeOpsLog("adopt", status, start, map[string]any{ + "adopted": len(res.Adopted), + "trashed": res.Trashed, + "pruned": res.PrunedLinks, + "dry_run": res.DryRun, + "force": body.Force, + "scope": "ui", + }, msg) + + writeJSON(w, adoptResultJSON(res)) +} + +// adoptResultJSON converts an adopt.Result into a stable JSON payload, +// normalising nil slices/maps to their empty forms. +func adoptResultJSON(res *adopt.Result) map[string]any { + adopted := res.Adopted + if adopted == nil { + adopted = []string{} + } + skipped := res.Skipped + if skipped == nil { + skipped = []string{} + } + failed := res.Failed + if failed == nil { + failed = map[string]string{} + } + warnings := res.LockWarnings + if warnings == nil { + warnings = []adopt.LockWarning{} + } + return map[string]any{ + "adopted": adopted, + "skipped": skipped, + "failed": failed, + "trashed": res.Trashed, + "prunedLinks": res.PrunedLinks, + "lockWarnings": warnings, + "dryRun": res.DryRun, + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 7a5d342d..a0d854c2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -405,6 +405,10 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("GET /api/collect/scan", s.handleCollectScan) s.mux.HandleFunc("POST /api/collect", s.handleCollect) + // Adopt + s.mux.HandleFunc("GET /api/adopt/preview", s.handleAdoptPreview) + s.mux.HandleFunc("POST /api/adopt/apply", s.handleAdoptApply) + // Hub s.mux.HandleFunc("GET /api/hub/index", s.handleHubIndex) s.mux.HandleFunc("GET /api/hub/saved", s.handleGetHubSaved) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 12d81da9..c4d44adc 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -20,6 +20,7 @@ const TargetsPage = lazy(() => import('./pages/TargetsPage')); const ExtrasPage = lazy(() => import('./pages/ExtrasPage')); const SyncPage = lazy(() => import('./pages/SyncPage')); const CollectPage = lazy(() => import('./pages/CollectPage')); +const AdoptPage = lazy(() => import('./pages/AdoptPage')); const BackupPage = lazy(() => import('./pages/BackupPage')); const GitSyncPage = lazy(() => import('./pages/GitSyncPage')); const SearchPage = lazy(() => import('./pages/SearchPage')); @@ -64,6 +65,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 597b7fbb..52db64b1 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -457,6 +457,14 @@ export const api = { body: JSON.stringify(opts), }), + // Adopt + getAdoptPreview: () => apiFetch('/adopt/preview'), + postAdoptApply: (body: { names?: string[]; force?: boolean; dryRun?: boolean }) => + apiFetch('/adopt/apply', { + method: 'POST', + body: JSON.stringify(body), + }), + // Version check / app lifecycle getVersionCheck: () => apiFetch('/version'), upgradeApp: () => apiFetch<{ ok: boolean; updated: boolean; devMode?: boolean; latestVersion?: string; output?: string }>('/upgrade', { method: 'POST' }), @@ -995,6 +1003,35 @@ export interface CollectResult { failed: Record; } +// Adopt types +export interface AdoptCandidate { + name: string; + path: string; + sourceTool: string; // lockfile provenance, may be "" + conflict: boolean; // same-name dir already in source + externalLinks: string[]; // orphan symlinks in other targets; never null +} + +export interface AdoptPreview { + candidates: AdoptCandidate[]; // never null + lockPresent: boolean; +} + +export interface AdoptLockWarning { + name: string; + source_tool: string; // snake_case: serializes adopt.LockWarning directly +} + +export interface AdoptApplyResult { + adopted: string[]; // never null + skipped: string[]; // never null + failed: Record; // never null + trashed: number; // originals soft-deleted to trash + prunedLinks: number; // orphan symlinks removed across targets + lockWarnings: AdoptLockWarning[]; // never null + dryRun: boolean; +} + // Trash types export interface TrashedSkill { name: string; diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index a47bd9bb..b92a8dd9 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -7,6 +7,7 @@ import { FolderPlus, RefreshCw, ArrowDownToLine, + PackagePlus, Archive, Trash2, GitBranch, @@ -68,6 +69,7 @@ const navGroups: NavGroup[] = [ items: [ { to: '/sync', icon: RefreshCw, labelKey: 'layout.nav.sync' }, { to: '/collect', icon: ArrowDownToLine, labelKey: 'layout.nav.collect' }, + { to: '/adopt', icon: PackagePlus, labelKey: 'layout.nav.adopt' }, { to: '/install', icon: Download, labelKey: 'layout.nav.install' }, { to: '/update', icon: ArrowUpCircle, labelKey: 'layout.nav.update' }, { to: '/uninstall', icon: Trash2, labelKey: 'layout.nav.uninstall' }, diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json index 53462cac..21a3358b 100644 --- a/ui/src/i18n/locales/en.json +++ b/ui/src/i18n/locales/en.json @@ -439,6 +439,43 @@ "install.example.githubShorthand": "GitHub shorthand", "install.subtitle": "Install skills or agents from any git repository or local path", "install.title": "Install Resources", + "adopt.title": "Adopt", + "adopt.subtitle": "Detect skills dropped into the agents target by external tools and migrate them into source", + "adopt.button.scanning": "Scanning...", + "adopt.button.rescan": "Re-scan", + "adopt.button.applying": "Adopting...", + "adopt.button.adoptCount": "Adopt {count}", + "adopt.button.previewCount": "Preview {count}", + "adopt.control.dryRun": "Dry run (preview only, no changes)", + "adopt.control.force": "Force (overwrite existing in source)", + "adopt.empty.title": "Nothing to adopt", + "adopt.empty.description": "No external skills were found in the agents target. All skills are managed by skillshare.", + "adopt.foundCount": "Found {count} adoptable skills", + "adopt.selectAll": "Select All", + "adopt.selectNone": "Select None", + "adopt.badge.conflict": "conflict", + "adopt.badge.externalLinks": "{count} external", + "adopt.conflict.hint": "A skill with this name already exists in source. Enable Force to overwrite it.", + "adopt.lock.title": "Lockfile detected", + "adopt.lock.previewHint": "A .skill-lock.json was found in the agents directory. After adopting, lingering lockfile entries will be reported (the lockfile itself is never modified).", + "adopt.lock.warningTitle": "Lingering lockfile entries", + "adopt.lock.warningHint": "These adopted skills are still listed in the agents .skill-lock.json. Remove them with the originating tool to avoid re-creation.", + "adopt.confirm.title": "Confirm Adopt", + "adopt.confirm.dryRunTitle": "Preview Adopt", + "adopt.confirm.message": "Migrate {count} skills into source, trash the originals, and re-sync{forceSuffix}?", + "adopt.confirm.dryRunMessage": "Preview adopting {count} skills (no changes will be made)?", + "adopt.confirm.forceOverwrite": " (force overwrite)", + "adopt.results.title": "Adopt Results", + "adopt.results.dryRunTitle": "Adopt Preview", + "adopt.results.adopted": "Adopted", + "adopt.results.skipped": "Skipped", + "adopt.results.trashed": "Trashed", + "adopt.results.pruned": "Pruned links", + "adopt.results.failed": "Failed", + "adopt.postAdopt.message": "Skills adopted into source! Run Sync to distribute them to all targets.", + "adopt.postAdopt.goToSync": "Go to Sync", + "adopt.toast.complete": "Adopt complete! {adopted} adopted, {trashed} trashed, {pruned} links pruned.", + "adopt.toast.dryRun": "Dry run complete: {adopted} skills would be adopted.", "analyze.chart.basedOn": "Based on description token count across {count} skills", "analyze.chart.noSkills": "No skills to display", "analyze.chart.title": "Top-10 Heaviest Skills", @@ -872,6 +909,7 @@ "layout.nav.analyze": "Analyze", "layout.nav.audit": "Audit", "layout.nav.backup": "Backup", + "layout.nav.adopt": "Adopt", "layout.nav.collect": "Collect", "layout.nav.config": "Config", "layout.nav.dashboard": "Dashboard", diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 2d1dced4..0085c301 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -14,6 +14,7 @@ export const queryKeys = { diff: (target?: string) => ['diff', target ?? '__all'] as const, collectScan: (target?: string) => ['collect-scan', target ?? '__all'] as const, + adoptPreview: ['adopt-preview'] as const, backups: ['backups'] as const, trash: ['trash'] as const, diff --git a/ui/src/pages/AdoptPage.tsx b/ui/src/pages/AdoptPage.tsx new file mode 100644 index 00000000..48f505c0 --- /dev/null +++ b/ui/src/pages/AdoptPage.tsx @@ -0,0 +1,485 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import { + PackagePlus, + Folder, + Zap, + Eye, + Link2Off, + CheckCircle, + AlertCircle, + AlertTriangle, + RefreshCw, + SkipForward, + XCircle, + Trash2, + Lock, +} from 'lucide-react'; +import Card from '../components/Card'; +import PageHeader from '../components/PageHeader'; +import Badge from '../components/Badge'; +import Button from '../components/Button'; +import { Checkbox } from '../components/Input'; +import EmptyState from '../components/EmptyState'; +import ConfirmDialog from '../components/ConfirmDialog'; +import { PageSkeleton } from '../components/Skeleton'; +import { useToast } from '../components/Toast'; +import { api, type AdoptCandidate, type AdoptApplyResult } from '../api/client'; +import { queryKeys } from '../lib/queryKeys'; +import { radius } from '../design'; +import { useT } from '../i18n'; + +type Phase = 'loading' | 'loaded' | 'applying' | 'done'; + +export default function AdoptPage() { + const t = useT(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const [phase, setPhase] = useState('loading'); + const [candidates, setCandidates] = useState([]); + const [lockPresent, setLockPresent] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [force, setForce] = useState(false); + const [dryRun, setDryRun] = useState(false); + const [result, setResult] = useState(null); + const [confirming, setConfirming] = useState(false); + + const loadPreview = async () => { + setPhase('loading'); + setResult(null); + try { + const res = await api.getAdoptPreview(); + const list = res.candidates ?? []; + setCandidates(list); + setLockPresent(res.lockPresent); + // Auto-select all non-conflicting candidates. + setSelected(new Set(list.filter((c) => !c.conflict).map((c) => c.name))); + setPhase('loaded'); + } catch (e: unknown) { + toast((e as Error).message, 'error'); + setPhase('loaded'); + setCandidates([]); + } + }; + + // Initial load. + useEffect(() => { + loadPreview(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleApply = async () => { + setPhase('applying'); + try { + const res = await api.postAdoptApply({ + names: Array.from(selected), + force, + dryRun, + }); + setResult(res); + const adoptedCount = res.adopted?.length ?? 0; + if (res.dryRun) { + toast(t('adopt.toast.dryRun', { adopted: adoptedCount }), 'info'); + } else { + toast( + t('adopt.toast.complete', { + adopted: adoptedCount, + trashed: res.trashed, + pruned: res.prunedLinks, + }), + adoptedCount > 0 ? 'success' : 'info', + ); + } + setPhase('done'); + if (!res.dryRun) { + queryClient.invalidateQueries({ queryKey: queryKeys.skills.all }); + queryClient.invalidateQueries({ queryKey: queryKeys.overview }); + queryClient.invalidateQueries({ queryKey: queryKeys.targets.all }); + queryClient.invalidateQueries({ queryKey: queryKeys.trash }); + queryClient.invalidateQueries({ queryKey: queryKeys.diff() }); + } + } catch (e: unknown) { + toast((e as Error).message, 'error'); + setPhase('loaded'); + } + }; + + const toggle = (name: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }; + + const selectableNames = candidates + .filter((c) => force || !c.conflict) + .map((c) => c.name); + + const toggleAll = (selectAll: boolean) => { + setSelected(selectAll ? new Set(selectableNames) : new Set()); + }; + + // Conflicting candidates need Force to be adoptable. + const isSelectable = (c: AdoptCandidate) => force || !c.conflict; + + return ( +
+ } + title={t('adopt.title')} + subtitle={t('adopt.subtitle')} + /> + + {/* Controls */} + +
+
+ +
+ + {(phase === 'loaded' || phase === 'done') && candidates.length > 0 && ( +
+
+ + +
+
+ + +
+
+ )} +
+
+ + {/* Loading */} + {phase === 'loading' && } + + {/* Empty */} + {phase !== 'loading' && candidates.length === 0 && ( + + )} + + {/* Candidate list */} + {phase !== 'loading' && candidates.length > 0 && ( +
+
+

+ {t('adopt.foundCount', { count: candidates.length })} +

+ {phase !== 'done' && ( +
+ + +
+ )} +
+ +
+ {candidates.map((c) => { + const selectable = isSelectable(c); + const isSelected = selected.has(c.name); + return ( + +
{ + if (selectable && phase !== 'applying' && phase !== 'done') toggle(c.name); + }} + > + e.stopPropagation()}> + toggle(c.name)} + size="sm" + disabled={!selectable || phase === 'applying' || phase === 'done'} + /> + + + {c.name} + +
+ {c.sourceTool && ( + {c.sourceTool} + )} + {c.conflict && ( + + + {t('adopt.badge.conflict')} + + )} + {c.externalLinks.length > 0 && ( + + + {t('adopt.badge.externalLinks', { count: c.externalLinks.length })} + + )} +
+
+ + {c.conflict && ( +

+ {t('adopt.conflict.hint')} +

+ )} + {c.externalLinks.length > 0 && ( +
+ {c.externalLinks.map((link) => ( +

+ {link} +

+ ))} +
+ )} +
+ ); + })} +
+ + {/* Apply button */} + {phase !== 'done' && ( +
+ +
+ )} +
+ )} + + {/* Lockfile warning (preview-time hint) */} + {phase === 'loaded' && lockPresent && candidates.length > 0 && ( + +
+ +
+

{t('adopt.lock.title')}

+

{t('adopt.lock.previewHint')}

+
+
+
+ )} + + {/* Results */} + {phase === 'done' && result && } + + {/* Post-adopt suggestion */} + {phase === 'done' && result && !result.dryRun && (result.adopted?.length ?? 0) > 0 && ( + +
+

{t('adopt.postAdopt.message')}

+ + + +
+
+ )} + + {/* Confirm dialog */} + +

+ {dryRun + ? t('adopt.confirm.dryRunMessage', { count: selected.size }) + : t('adopt.confirm.message', { + count: selected.size, + forceSuffix: force ? t('adopt.confirm.forceOverwrite') : '', + })} +

+
    + {Array.from(selected).map((name) => ( +
  • + + {name} +
  • + ))} +
+
+ } + confirmText={dryRun ? t('adopt.button.previewCount', { count: selected.size }) : t('adopt.button.adoptCount', { count: selected.size })} + onConfirm={() => { + setConfirming(false); + handleApply(); + }} + onCancel={() => setConfirming(false)} + /> + + ); +} + +/** Adopt result summary */ +function AdoptResults({ result }: { result: AdoptApplyResult }) { + const t = useT(); + const adopted = result.adopted ?? []; + const skipped = result.skipped ?? []; + const failed = result.failed ?? {}; + const failedEntries = Object.entries(failed); + const lockWarnings = result.lockWarnings ?? []; + + return ( +
+

+ {result.dryRun ? t('adopt.results.dryRunTitle') : t('adopt.results.title')} +

+ +
+ + + + +
+ + {adopted.length > 0 && ( + +

+ + {t('adopt.results.adopted')} +

+
+ {adopted.map((name) => ( +

{name}

+ ))} +
+
+ )} + + {skipped.length > 0 && ( + +

+ + {t('adopt.results.skipped')} +

+
+ {skipped.map((name) => ( +

{name}

+ ))} +
+
+ )} + + {failedEntries.length > 0 && ( + +

+ + {t('adopt.results.failed')} +

+
+ {failedEntries.map(([name, err]) => ( +
+ {name} + {err} +
+ ))} +
+
+ )} + + {/* Lingering lockfile warnings — rendered prominently */} + {lockWarnings.length > 0 && ( + +
+ +
+

{t('adopt.lock.warningTitle')}

+

{t('adopt.lock.warningHint')}

+
+ {lockWarnings.map((w) => ( +
+ + {w.name} + {w.source_tool && ← {w.source_tool}} +
+ ))} +
+
+
+
+ )} +
+ ); +} + +function ResultStat({ + label, + count, + icon: Icon, + variant, +}: { + label: string; + count: number; + icon: React.ComponentType<{ size?: number; strokeWidth?: number; className?: string }>; + variant: 'success' | 'warning' | 'danger' | 'info'; +}) { + const bgMap = { + success: 'bg-success-light', + warning: 'bg-warning-light', + danger: 'bg-danger-light', + info: 'bg-info-light', + }; + const colorMap = { + success: 'text-success', + warning: 'text-warning', + danger: 'text-danger', + info: 'text-blue', + }; + + return ( +
0 ? bgMap[variant] : 'bg-muted/30'}`} + style={{ borderRadius: radius.sm }} + > + 0 ? colorMap[variant] : 'text-muted-dark'} /> +
+

0 ? colorMap[variant] : 'text-muted-dark'}`}> + {count} +

+

{label}

+
+
+ ); +} From 4596a0483b28721d12a15481252ce73c71a32474 Mon Sep 17 00:00:00 2001 From: Willie Date: Sat, 30 May 2026 00:46:54 +0800 Subject: [PATCH 3/7] docs(adopt): document the adopt command Add reference/commands/adopt.md (when-to-use, flow diagram, flags, conflict and non-interactive behavior, JSON output, lockfile read-only note) and register it in the sidebar under Sync Operations. Refs #135 --- website/docs/reference/commands/adopt.md | 131 +++++++++++++++++++++++ website/sidebars.ts | 1 + 2 files changed, 132 insertions(+) create mode 100644 website/docs/reference/commands/adopt.md diff --git a/website/docs/reference/commands/adopt.md b/website/docs/reference/commands/adopt.md new file mode 100644 index 00000000..38e6287e --- /dev/null +++ b/website/docs/reference/commands/adopt.md @@ -0,0 +1,131 @@ +--- +sidebar_position: 2 +--- + +# adopt + +Adopt CLI-bundled skills that external tools dropped into the universal target (`~/.agents/skills`) so skillshare governs them. + +```bash +skillshare adopt # Detect and interactively adopt +skillshare adopt --dry-run # Preview what would be adopted +skillshare adopt --all --force # Adopt everything without prompting +skillshare adopt -p # Project mode (.agents/skills) +``` + +## When to Use + +Some CLI tools (for example `firecrawl/cli`, `googleworkspace/cli`) ship their own skills directly into the shared `~/.agents/skills/` directory and symlink them into agent directories, tracking them in their own `~/.agents/.skill-lock.json`. This bypasses skillshare's source-of-truth model, which causes: + +- `audit` flags them as unmanaged +- the tool's symlinks only reach the agents it detected — other targets are left uncovered +- moving them into skillshare by hand gets overwritten on the tool's next reinstall (the lockfile still claims them) + +Use `adopt` to bring those skills under skillshare's management: migrate the canonical files into your source, clean up the tool's orphan symlinks, and re-sync to **all** targets. + +## What Happens + +```mermaid +flowchart TD + CMD["skillshare adopt"] + DETECT["1. Detect skills in ~/.agents/skills"] + PREVIEW["2. Preview + confirm"] + MIGRATE["3. Copy canonical files to source"] + TRASH["4. Trash the originals"] + PRUNE["5. Clean orphan symlinks"] + SYNC["6. Re-sync to all targets"] + WARN["7. Warn about lockfile entries"] + CMD --> DETECT --> PREVIEW --> MIGRATE --> TRASH --> PRUNE --> SYNC --> WARN +``` + +:::tip +Originals are moved to skillshare's trash (soft-delete), not deleted — restore them with [`restore`](/docs/reference/commands/restore) if needed. Use `--dry-run` first to preview every change. +::: + +:::warning +The tool's lockfile (`~/.agents/.skill-lock.json`) is **never modified** — it belongs to the tool that created it. After adopting, `adopt` warns you to release the entry from the owning tool (e.g. `firecrawl uninstall `); otherwise that tool may re-create the skill on its next update. +::: + +## Options + +| Flag | Description | +|------|-------------| +| `--all, -a` | Adopt all detected skills | +| `--dry-run, -n` | Preview without making changes | +| `--force, -f` | Overwrite same-name skills in source and skip confirmation | +| `--json` | Output as JSON (implies `--force`) | +| `--project, -p` | Use project config (`.agents/skills`) | +| `--global, -g` | Use global config (`~/.agents/skills`) | + +:::note +In a non-interactive terminal (CI, pipes), a bare `skillshare adopt` refuses to run rather than silently migrate and trash files. Pass `--all` to adopt (add `--force` to overwrite conflicts), or `--dry-run` to preview. +::: + +## Conflicts + +If a skill of the same name already exists in source, `adopt` skips it unless `--force` is given — the original is left in place, untouched: + +```bash +$ skillshare adopt --all + +Adopt + ⚠ my-skill conflict: already in source — use --force to overwrite + +# To overwrite the source copy: +$ skillshare adopt --all --force +``` + +## JSON Output + +```bash +skillshare adopt --all --json +``` + +```json +{ + "adopted": ["firecrawl"], + "skipped": [], + "failed": {}, + "trashed": 1, + "pruned": 1, + "lock_warnings": [ + { "name": "firecrawl", "source_tool": "firecrawl" } + ], + "dry_run": false, + "duration": "0.042s" +} +``` + +Combine with `--dry-run` to preview without changes: + +```bash +skillshare adopt --json --dry-run +``` + +## Example Output + +```bash +$ skillshare adopt + +Adopt + ℹ firecrawl [firecrawl] ~/.agents/skills/firecrawl + +Adopt these skills into skillshare? [y/N]: y + +Adopting skills + ✓ firecrawl: migrated to source, original trashed + ✓ cleaned 1 orphan symlink + +⚠ firecrawl still claims 'firecrawl' in ~/.agents/.skill-lock.json + Release it with the owning tool (e.g. 'firecrawl uninstall firecrawl') + or it may be re-created on the tool's next update. + +Run 'skillshare sync' to confirm distribution to all targets +``` + +## See Also + +- [collect](/docs/reference/commands/collect) — Pull local (non-symlinked) skills from a target into source +- [sync](/docs/reference/commands/sync) — Distribute from source to targets +- [audit](/docs/reference/commands/audit) — Find unmanaged skills +- [restore](/docs/reference/commands/restore) — Restore a trashed original diff --git a/website/sidebars.ts b/website/sidebars.ts index 76c5d532..26e0e0a0 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -157,6 +157,7 @@ const sidebars: SidebarsConfig = { label: 'Sync Operations', items: [ 'reference/commands/collect', + 'reference/commands/adopt', 'reference/commands/extras', 'reference/commands/backup', 'reference/commands/restore', From 84ac4ab1a226e2f0ae45832aab92f970c4755259 Mon Sep 17 00:00:00 2001 From: Willie Date: Sat, 30 May 2026 00:56:53 +0800 Subject: [PATCH 4/7] docs(changelog): add adopt to 0.20.0 Refs #135 --- CHANGELOG.md | 11 +++++++++++ website/src/pages/changelog.md | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba5a404..34627ae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ ### New Features +#### Adopt CLI-bundled skills (`adopt`) + +- **`skillshare adopt`** — claim skills that external CLI tools (e.g. `firecrawl/cli`, `googleworkspace/cli`) install directly into `~/.agents/skills`, bypassing skillshare's source of truth. It migrates the canonical files into your source, removes the tool's orphan symlinks, and re-syncs to every target — so the skill is covered everywhere, not just the agents the original installer happened to detect: + ```bash + skillshare adopt --dry-run # preview what would be adopted + skillshare adopt --all --force # adopt everything without prompting + skillshare adopt -p # project mode (.agents/skills) + ``` + Originals are moved to trash (restorable), so the migration is reversible. Same-name conflicts are skipped unless `--force`, and a bare run in a non-interactive shell refuses rather than silently migrating and trashing files. The tool's `~/.agents/.skill-lock.json` is never modified — adopt warns you to release the entry from the owning tool instead. +- **Adopt dashboard page** — preview detected skills, multi-select, toggle dry-run/force, and apply from the web UI, with lockfile warnings surfaced inline. + #### Git scope control (`git_root`) - **`git_root` scope** — choose which directory `skillshare commit`, `push`, and `pull` version. The default stays your skills source, but you can point git at `agents`, `extras`, or `root` (skills + agents + extras together in a single repo). Set it during init, or switch later on an existing setup: diff --git a/website/src/pages/changelog.md b/website/src/pages/changelog.md index 462c0b2e..549fd337 100644 --- a/website/src/pages/changelog.md +++ b/website/src/pages/changelog.md @@ -20,6 +20,17 @@ All notable changes to skillshare are documented here. For the full commit histo ### New Features +#### Adopt CLI-bundled skills (`adopt`) + +- **`skillshare adopt`** — claim skills that external CLI tools (e.g. `firecrawl/cli`, `googleworkspace/cli`) install directly into `~/.agents/skills`, bypassing skillshare's source of truth. It migrates the canonical files into your source, removes the tool's orphan symlinks, and re-syncs to every target — so the skill is covered everywhere, not just the agents the original installer happened to detect: + ```bash + skillshare adopt --dry-run # preview what would be adopted + skillshare adopt --all --force # adopt everything without prompting + skillshare adopt -p # project mode (.agents/skills) + ``` + Originals are moved to trash (restorable), so the migration is reversible. Same-name conflicts are skipped unless `--force`, and a bare run in a non-interactive shell refuses rather than silently migrating and trashing files. The tool's `~/.agents/.skill-lock.json` is never modified — adopt warns you to release the entry from the owning tool instead. +- **Adopt dashboard page** — preview detected skills, multi-select, toggle dry-run/force, and apply from the web UI, with lockfile warnings surfaced inline. + #### Git scope control (`git_root`) - **`git_root` scope** — choose which directory `skillshare commit`, `push`, and `pull` version. The default stays your skills source, but you can point git at `agents`, `extras`, or `root` (skills + agents + extras together in a single repo). Set it during init, or switch later on an existing setup: From fc58a1839a8f2fafb15412bc2e8d55f9e62a4a1e Mon Sep 17 00:00:00 2001 From: Willie Date: Sat, 30 May 2026 01:22:24 +0800 Subject: [PATCH 5/7] fix(review): address adopt code-review findings - lockfile: read .skill-lock.json from the agents dir parent (~/.agents/.skill-lock.json), not inside the skills dir, so provenance and lingering-entry warnings resolve correctly - apply: re-sync honors each target's effective mode (copy/symlink/ merge) instead of forcing merge symlinks, so copy-mode targets get real copies after adoption - pull: dry-run classifies a same-name source skill as skipped (not pulled) unless --force, matching real apply - i18n: add the adopt.* and layout.nav.adopt keys to all 10 locales to restore key/placeholder parity --- cmd/skillshare/adopt.go | 15 +++++----- cmd/skillshare/adopt_handlers.go | 34 +++++++++++----------- cmd/skillshare/adopt_project.go | 15 +++++----- internal/adopt/apply.go | 32 +++++++++++++++++++-- internal/adopt/apply_test.go | 49 +++++++++++++++++++++++++++++++- internal/adopt/lockfile.go | 18 ++++++++++-- internal/adopt/lockfile_test.go | 16 ++++++----- internal/server/handler_adopt.go | 19 +++++++------ internal/sync/pull.go | 9 +++++- tests/integration/adopt_test.go | 6 ++-- ui/src/i18n/locales/de.json | 38 +++++++++++++++++++++++++ ui/src/i18n/locales/es.json | 38 +++++++++++++++++++++++++ ui/src/i18n/locales/fa.json | 38 +++++++++++++++++++++++++ ui/src/i18n/locales/fr.json | 38 +++++++++++++++++++++++++ ui/src/i18n/locales/id.json | 38 +++++++++++++++++++++++++ ui/src/i18n/locales/ja.json | 38 +++++++++++++++++++++++++ ui/src/i18n/locales/ko.json | 38 +++++++++++++++++++++++++ ui/src/i18n/locales/pt-BR.json | 38 +++++++++++++++++++++++++ ui/src/i18n/locales/zh-CN.json | 38 +++++++++++++++++++++++++ ui/src/i18n/locales/zh-TW.json | 38 +++++++++++++++++++++++++ 20 files changed, 537 insertions(+), 56 deletions(-) diff --git a/cmd/skillshare/adopt.go b/cmd/skillshare/adopt.go index faeefdb8..c92d2f2b 100644 --- a/cmd/skillshare/adopt.go +++ b/cmd/skillshare/adopt.go @@ -94,13 +94,14 @@ func cmdAdoptGlobal(opts adoptOptions, start time.Time) error { } actx := adoptContext{ - agentsPath: sc.Path, - sourcePath: cfg.EffectiveSkillsSource(), - syncMode: adoptSyncMode(sc.Mode, cfg.Mode), - allTargets: allTargets, - targets: cfg.Targets, - trashBase: trash.TrashDir(), - configPath: config.ConfigPath(), + agentsPath: sc.Path, + sourcePath: cfg.EffectiveSkillsSource(), + syncMode: adoptSyncMode(sc.Mode, cfg.Mode), + defaultMode: cfg.Mode, + allTargets: allTargets, + targets: cfg.Targets, + trashBase: trash.TrashDir(), + configPath: config.ConfigPath(), } return runAdoptCommand(actx, opts, start) diff --git a/cmd/skillshare/adopt_handlers.go b/cmd/skillshare/adopt_handlers.go index e8a6e114..c18712e3 100644 --- a/cmd/skillshare/adopt_handlers.go +++ b/cmd/skillshare/adopt_handlers.go @@ -13,13 +13,14 @@ import ( // adoptContext carries the mode-specific inputs for the adopt flow so that the // global and project handlers share a single orchestration core. type adoptContext struct { - agentsPath string // universal/agents target skills dir (~/.agents/skills) - sourcePath string // skillshare source of truth - syncMode string // agents target sync mode - allTargets map[string]string // name -> skills dir, for orphan-link pruning + re-sync - targets map[string]config.TargetConfig // resolved targets, for re-sync (optional) - trashBase string // trash dir (global or project) - configPath string // config path for oplog + agentsPath string // universal/agents target skills dir (~/.agents/skills) + sourcePath string // skillshare source of truth + syncMode string // agents target sync mode + defaultMode string // config-level sync mode (cfg.Mode); used when a target sets no mode + allTargets map[string]string // name -> skills dir, for orphan-link pruning + re-sync + targets map[string]config.TargetConfig // resolved targets, for re-sync (optional) + trashBase string // trash dir (global or project) + configPath string // config path for oplog } // runAdoptCommand wires an adoptContext through detection, confirmation, @@ -140,15 +141,16 @@ func applyAdopt(actx adoptContext, selected []adopt.Candidate, opts adoptOptions names[i] = c.Name } out, err := adopt.Apply(selected, adopt.Request{ - AgentsPath: actx.agentsPath, - SourcePath: actx.sourcePath, - SyncMode: actx.syncMode, - TrashBase: actx.trashBase, - AllTargets: actx.allTargets, - Targets: actx.targets, - DryRun: opts.dryRun, - Force: opts.force, - Selected: names, + AgentsPath: actx.agentsPath, + SourcePath: actx.sourcePath, + SyncMode: actx.syncMode, + DefaultMode: actx.defaultMode, + TrashBase: actx.trashBase, + AllTargets: actx.allTargets, + Targets: actx.targets, + DryRun: opts.dryRun, + Force: opts.force, + Selected: names, }) return adoptResultFromApply(out, opts.dryRun), err } diff --git a/cmd/skillshare/adopt_project.go b/cmd/skillshare/adopt_project.go index 879d1384..2874dd6b 100644 --- a/cmd/skillshare/adopt_project.go +++ b/cmd/skillshare/adopt_project.go @@ -29,13 +29,14 @@ func cmdAdoptProject(opts adoptOptions, root string, start time.Time) error { } actx := adoptContext{ - agentsPath: sc.Path, - sourcePath: runtime.sourcePath, - syncMode: adoptSyncMode(sc.Mode, ""), - allTargets: allTargets, - targets: runtime.targets, - trashBase: trash.ProjectTrashDir(root), - configPath: config.ProjectConfigPath(root), + agentsPath: sc.Path, + sourcePath: runtime.sourcePath, + syncMode: adoptSyncMode(sc.Mode, ""), + defaultMode: "", // project has no config-level mode; per-target mode resolves, falling back to merge + allTargets: allTargets, + targets: runtime.targets, + trashBase: trash.ProjectTrashDir(root), + configPath: config.ProjectConfigPath(root), } if err := runAdoptCommand(actx, opts, start); err != nil { diff --git a/internal/adopt/apply.go b/internal/adopt/apply.go index 3f14b045..1c440f0d 100644 --- a/internal/adopt/apply.go +++ b/internal/adopt/apply.go @@ -28,6 +28,11 @@ type Request struct { AllTargets map[string]string // Targets maps target name -> resolved config, for re-sync after migration. Targets map[string]config.TargetConfig + // DefaultMode is the config-level sync mode (cfg.Mode) used when a target + // does not set its own mode. Empty falls back to "merge". Re-sync honors + // each target's effective mode so copy/symlink targets are not forced to + // merge-mode symlinks. + DefaultMode string // DryRun previews without mutating anything. DryRun bool // Force overwrites conflicting skills already present in source. @@ -120,10 +125,17 @@ func Apply(candidates []Candidate, req Request) (*Result, error) { res.PrunedLinks += len(prune.Removed) } - // 4. Re-sync from source to all targets. + // 4. Re-sync from source to all targets, honoring each target's mode. + // Best-effort; individual target failures must not abort the flow. for name, target := range req.Targets { - // Best-effort; individual target failures must not abort the flow. - _, _ = sync.SyncTargetMerge(name, target, req.SourcePath, false, false, "") + switch effectiveMode(target, req.DefaultMode) { + case "copy": + _, _ = sync.SyncTargetCopy(name, target, req.SourcePath, false, req.Force) + case "symlink": + _ = sync.SyncTarget(name, target, req.SourcePath, false, "") + default: // merge + _, _ = sync.SyncTargetMerge(name, target, req.SourcePath, false, false, "") + } } // 5. Warn about lingering lockfile entries (never write the lockfile). @@ -151,6 +163,20 @@ func filterSelected(candidates []Candidate, names []string) []Candidate { return picked } +// effectiveMode resolves the sync mode for a target during re-sync: the +// target's own mode, then the config-level default, then "merge". Mirrors the +// dispatch in cmd/skillshare/sync.go so adopt re-sync matches `skillshare sync`. +func effectiveMode(target config.TargetConfig, defaultMode string) string { + mode := target.SkillsConfig().Mode + if mode == "" { + mode = defaultMode + } + if mode == "" { + mode = "merge" + } + return mode +} + // targetNaming resolves the naming scheme for prune, falling back to the empty // string (default flat naming) when the target config is unknown. func targetNaming(targets map[string]config.TargetConfig, name string) string { diff --git a/internal/adopt/apply_test.go b/internal/adopt/apply_test.go index a52ca4eb..2d7bdba6 100644 --- a/internal/adopt/apply_test.go +++ b/internal/adopt/apply_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "skillshare/internal/config" ) // applyEnv builds an agents dir + source dir + trash base and returns the @@ -145,7 +147,7 @@ func TestApply_LockfileUntouchedAndWarned(t *testing.T) { agents, source, trashBase := applyEnv(t) mkSkill(t, agents, "web-scraper") - lockPath := filepath.Join(agents, LockFileName()) + lockPath := LockPath(agents) // ~/.agents/.skill-lock.json — beside the skills dir, not inside it lockData := []byte(`{"web-scraper":{"sourceTool":"firecrawl"}}`) if err := os.WriteFile(lockPath, lockData, 0644); err != nil { t.Fatal(err) @@ -202,3 +204,48 @@ func TestApply_SelectedFilter(t *testing.T) { t.Errorf("unselected beta was touched: %v", err) } } + +// TestApply_ReSyncHonorsCopyMode guards the re-sync mode dispatch: a copy-mode +// target must receive a real directory copy, not a merge-mode symlink. +func TestApply_ReSyncHonorsCopyMode(t *testing.T) { + agents, source, trashBase := applyEnv(t) + mkSkill(t, agents, "web-scraper") + + // A separate target configured for copy mode, beside the agents dir. + copyTargetPath := filepath.Join(filepath.Dir(agents), "copytarget") + if err := os.MkdirAll(copyTargetPath, 0755); err != nil { + t.Fatal(err) + } + targets := map[string]config.TargetConfig{ + "copytarget": {Path: copyTargetPath, Mode: "copy"}, + } + + cands := detect(t, agents, source) + res, err := Apply(cands, Request{ + AgentsPath: agents, + SourcePath: source, + TrashBase: trashBase, + AllTargets: map[string]string{"copytarget": copyTargetPath}, + Targets: targets, + DefaultMode: "merge", // config default is merge; the target's own copy mode must win + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if len(res.Adopted) != 1 { + t.Fatalf("adopted = %v, want 1", res.Adopted) + } + + // Copy mode must produce a REAL entry in the target, never a symlink. + entry := filepath.Join(copyTargetPath, "web-scraper") + info, err := os.Lstat(entry) + if err != nil { + t.Fatalf("copy-mode target entry missing: %v", err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Error("copy mode produced a symlink; want a real copy") + } + if _, err := os.Stat(filepath.Join(entry, "SKILL.md")); err != nil { + t.Errorf("copied skill missing SKILL.md: %v", err) + } +} diff --git a/internal/adopt/lockfile.go b/internal/adopt/lockfile.go index 6eb81f30..aa2bb01d 100644 --- a/internal/adopt/lockfile.go +++ b/internal/adopt/lockfile.go @@ -48,17 +48,29 @@ func sourceToolFromRaw(raw map[string]any) string { return "" } -// ReadLock reads agentsDir/.skill-lock.json and returns its entries keyed by +// LockPath returns the absolute path of the external tool lockfile for a given +// agents *skills* directory. The lockfile lives one level up beside the skills +// dir (e.g. skillsDir ~/.agents/skills => ~/.agents/.skill-lock.json), matching +// the ~/.agents convention used by firecrawl/cli, googleworkspace/cli, etc. +func LockPath(skillsDir string) string { + return filepath.Join(filepath.Dir(skillsDir), lockFileName) +} + +// ReadLock reads the external tool lockfile and returns its entries keyed by // skill name. The lockfile is never modified. // +// skillsDir is the agents *skills* directory (e.g. ~/.agents/skills); the +// lockfile lives one level up beside it (~/.agents/.skill-lock.json) — NOT +// inside the skills directory. +// // Behavior: // - file does not exist => empty map, nil error // - malformed JSON => empty (non-nil) map + non-fatal error (caller may ignore) // - valid => populated map, nil error -func ReadLock(agentsDir string) (map[string]LockEntry, error) { +func ReadLock(skillsDir string) (map[string]LockEntry, error) { entries := make(map[string]LockEntry) - data, err := os.ReadFile(filepath.Join(agentsDir, lockFileName)) + data, err := os.ReadFile(LockPath(skillsDir)) if err != nil { if os.IsNotExist(err) { return entries, nil diff --git a/internal/adopt/lockfile_test.go b/internal/adopt/lockfile_test.go index bd410eff..8998ec0c 100644 --- a/internal/adopt/lockfile_test.go +++ b/internal/adopt/lockfile_test.go @@ -6,18 +6,20 @@ import ( "testing" ) -func writeLock(t *testing.T, dir, content string) { +// writeLock writes the lockfile at the location ReadLock expects for the given +// skills dir: one level up (the ~/.agents dir), beside the skills directory. +func writeLock(t *testing.T, skillsDir, content string) { t.Helper() - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(skillsDir, 0755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, lockFileName), []byte(content), 0644); err != nil { + if err := os.WriteFile(LockPath(skillsDir), []byte(content), 0644); err != nil { t.Fatal(err) } } func TestReadLock_Missing(t *testing.T) { - dir := t.TempDir() + dir := filepath.Join(t.TempDir(), "skills") entries, err := ReadLock(dir) if err != nil { t.Fatalf("expected nil error for missing lockfile, got %v", err) @@ -28,7 +30,7 @@ func TestReadLock_Missing(t *testing.T) { } func TestReadLock_NestedSkillsObject(t *testing.T) { - dir := t.TempDir() + dir := filepath.Join(t.TempDir(), "skills") writeLock(t, dir, `{ "skills": { "web-scraper": {"source": "firecrawl", "version": "1.0.0"}, @@ -55,7 +57,7 @@ func TestReadLock_NestedSkillsObject(t *testing.T) { } func TestReadLock_FlatMap(t *testing.T) { - dir := t.TempDir() + dir := filepath.Join(t.TempDir(), "skills") writeLock(t, dir, `{ "web-scraper": {"tool": "firecrawl"}, "gmail": {"source": "googleworkspace"} @@ -74,7 +76,7 @@ func TestReadLock_FlatMap(t *testing.T) { } func TestReadLock_Malformed(t *testing.T) { - dir := t.TempDir() + dir := filepath.Join(t.TempDir(), "skills") writeLock(t, dir, `{not valid json`) entries, err := ReadLock(dir) diff --git a/internal/server/handler_adopt.go b/internal/server/handler_adopt.go index 26e6dd86..5eaf7a0a 100644 --- a/internal/server/handler_adopt.go +++ b/internal/server/handler_adopt.go @@ -153,15 +153,16 @@ func (s *Server) handleAdoptApply(w http.ResponseWriter, r *http.Request) { } res, err := adopt.Apply(candidates, adopt.Request{ - AgentsPath: sc.Path, - SourcePath: source, - SyncMode: syncMode, - TrashBase: trashBase, - AllTargets: allTargets, - Targets: targets, - DryRun: body.DryRun, - Force: body.Force, - Selected: body.Names, + AgentsPath: sc.Path, + SourcePath: source, + SyncMode: syncMode, + DefaultMode: globalMode, + TrashBase: trashBase, + AllTargets: allTargets, + Targets: targets, + DryRun: body.DryRun, + Force: body.Force, + Selected: body.Names, }) if err != nil { s.writeOpsLog("adopt", "error", start, map[string]any{"scope": "ui"}, err.Error()) diff --git a/internal/sync/pull.go b/internal/sync/pull.go index 74f24963..e392fcc9 100644 --- a/internal/sync/pull.go +++ b/internal/sync/pull.go @@ -151,7 +151,14 @@ func PullSkills(skills []LocalSkillInfo, sourcePath string, opts PullOptions) (* for _, skill := range skills { if opts.DryRun { - result.Pulled = append(result.Pulled, skill.Name) + // Classify exactly as a real pull would: a same-name skill already + // in source is skipped unless --force, so the preview must not + // report it as pulled. + if _, statErr := os.Stat(filepath.Join(sourcePath, skill.Name)); statErr == nil && !opts.Force { + result.Skipped = append(result.Skipped, skill.Name) + } else { + result.Pulled = append(result.Pulled, skill.Name) + } continue } diff --git a/tests/integration/adopt_test.go b/tests/integration/adopt_test.go index b300d153..e90528c5 100644 --- a/tests/integration/adopt_test.go +++ b/tests/integration/adopt_test.go @@ -38,7 +38,9 @@ func adoptWriteLock(t *testing.T, agentsDir, sourceTool string, names ...string) if err != nil { t.Fatalf("marshal lock: %v", err) } - if err := os.WriteFile(filepath.Join(agentsDir, ".skill-lock.json"), data, 0o644); err != nil { + // The lockfile lives one level up beside the skills dir + // (~/.agents/.skill-lock.json), not inside ~/.agents/skills. + if err := os.WriteFile(filepath.Join(filepath.Dir(agentsDir), ".skill-lock.json"), data, 0o644); err != nil { t.Fatalf("write lock: %v", err) } } @@ -149,7 +151,7 @@ func TestAdopt_LockfileUnchanged_WithWarning(t *testing.T) { agentsPath, _ := setupAdoptGlobal(t, sb, "firecrawl", "firecrawl") - lockPath := filepath.Join(agentsPath, ".skill-lock.json") + lockPath := filepath.Join(filepath.Dir(agentsPath), ".skill-lock.json") before, err := os.ReadFile(lockPath) if err != nil { t.Fatalf("read lock before: %v", err) diff --git a/ui/src/i18n/locales/de.json b/ui/src/i18n/locales/de.json index 61b73c8a..62f295f1 100644 --- a/ui/src/i18n/locales/de.json +++ b/ui/src/i18n/locales/de.json @@ -1,4 +1,42 @@ { + "adopt.title": "Übernehmen", + "adopt.subtitle": "Erkennt Skills, die externe Tools im Agents-Ziel abgelegt haben, und übernimmt sie in die Quelle", + "adopt.button.scanning": "Suche läuft...", + "adopt.button.rescan": "Erneut suchen", + "adopt.button.applying": "Übernehme...", + "adopt.button.adoptCount": "{count} übernehmen", + "adopt.button.previewCount": "{count} in Vorschau", + "adopt.control.dryRun": "Probelauf (nur Vorschau, keine Änderungen)", + "adopt.control.force": "Erzwingen (vorhandene in der Quelle überschreiben)", + "adopt.empty.title": "Nichts zu übernehmen", + "adopt.empty.description": "Im Agents-Ziel wurden keine externen Skills gefunden. Alle Skills werden von skillshare verwaltet.", + "adopt.foundCount": "{count} übernehmbare Skills gefunden", + "adopt.selectAll": "Alle auswählen", + "adopt.selectNone": "Auswahl aufheben", + "adopt.badge.conflict": "Konflikt", + "adopt.badge.externalLinks": "{count} extern", + "adopt.conflict.hint": "Ein Skill mit diesem Namen existiert bereits in der Quelle. Aktiviere Erzwingen, um ihn zu überschreiben.", + "adopt.lock.title": "Lock-Datei erkannt", + "adopt.lock.previewHint": "Im Agents-Verzeichnis wurde eine .skill-lock.json gefunden. Nach dem Übernehmen werden verbleibende Lock-Datei-Einträge gemeldet (die Lock-Datei selbst wird nie verändert).", + "adopt.lock.warningTitle": "Verbleibende Lock-Datei-Einträge", + "adopt.lock.warningHint": "Diese übernommenen Skills sind weiterhin in der .skill-lock.json des Agents aufgeführt. Entferne sie mit dem ursprünglichen Tool, um eine erneute Erstellung zu vermeiden.", + "adopt.confirm.title": "Übernehmen bestätigen", + "adopt.confirm.dryRunTitle": "Übernehmen in Vorschau", + "adopt.confirm.message": "{count} Skills in die Quelle übernehmen, die Originale in den Papierkorb verschieben und neu synchronisieren{forceSuffix}?", + "adopt.confirm.dryRunMessage": "Übernahme von {count} Skills in der Vorschau anzeigen (es werden keine Änderungen vorgenommen)?", + "adopt.confirm.forceOverwrite": " (überschreiben erzwingen)", + "adopt.results.title": "Übernahme-Ergebnisse", + "adopt.results.dryRunTitle": "Übernahme-Vorschau", + "adopt.results.adopted": "Übernommen", + "adopt.results.skipped": "Übersprungen", + "adopt.results.trashed": "In Papierkorb verschoben", + "adopt.results.pruned": "Bereinigte Links", + "adopt.results.failed": "Fehlgeschlagen", + "adopt.postAdopt.message": "Skills in die Quelle übernommen! Führe Sync aus, um sie an alle Ziele zu verteilen.", + "adopt.postAdopt.goToSync": "Zu Sync wechseln", + "adopt.toast.complete": "Übernahme abgeschlossen! {adopted} übernommen, {trashed} in den Papierkorb verschoben, {pruned} Links bereinigt.", + "adopt.toast.dryRun": "Probelauf abgeschlossen: {adopted} Skills würden übernommen.", + "layout.nav.adopt": "Übernehmen", "analyze.breakdown.body": "Inhalt", "analyze.breakdown.description": "Beschreibung", "analyze.breakdown.total": "Gesamt", diff --git a/ui/src/i18n/locales/es.json b/ui/src/i18n/locales/es.json index 55b270fc..6d6f64f0 100644 --- a/ui/src/i18n/locales/es.json +++ b/ui/src/i18n/locales/es.json @@ -1,4 +1,42 @@ { + "adopt.title": "Adoptar", + "adopt.subtitle": "Detecta las skills que las herramientas externas dejaron en el destino de agents y migra al origen", + "adopt.button.scanning": "Analizando...", + "adopt.button.rescan": "Volver a analizar", + "adopt.button.applying": "Adoptando...", + "adopt.button.adoptCount": "Adoptar {count}", + "adopt.button.previewCount": "Previsualizar {count}", + "adopt.control.dryRun": "Simulación (solo vista previa, sin cambios)", + "adopt.control.force": "Forzar (sobrescribir las existentes en el origen)", + "adopt.empty.title": "Nada que adoptar", + "adopt.empty.description": "No se encontraron skills externas en el destino de agents. Todas las skills están gestionadas por skillshare.", + "adopt.foundCount": "Se encontraron {count} skills adoptables", + "adopt.selectAll": "Seleccionar todo", + "adopt.selectNone": "Deseleccionar todo", + "adopt.badge.conflict": "conflicto", + "adopt.badge.externalLinks": "{count} externas", + "adopt.conflict.hint": "Ya existe una skill con este nombre en el origen. Activa Forzar para sobrescribirla.", + "adopt.lock.title": "Archivo de bloqueo detectado", + "adopt.lock.previewHint": "Se encontró un .skill-lock.json en el directorio de agents. Tras la adopción, se informará de las entradas residuales del archivo de bloqueo (el propio archivo nunca se modifica).", + "adopt.lock.warningTitle": "Entradas residuales en el archivo de bloqueo", + "adopt.lock.warningHint": "Estas skills adoptadas siguen apareciendo en el .skill-lock.json de agents. Elimínalas con la herramienta de origen para evitar que se vuelvan a crear.", + "adopt.confirm.title": "Confirmar adopción", + "adopt.confirm.dryRunTitle": "Vista previa de adopción", + "adopt.confirm.message": "¿Migrar {count} skills al origen, enviar las originales a la papelera y volver a sincronizar{forceSuffix}?", + "adopt.confirm.dryRunMessage": "¿Previsualizar la adopción de {count} skills (no se realizará ningún cambio)?", + "adopt.confirm.forceOverwrite": " (sobrescritura forzada)", + "adopt.results.title": "Resultados de la adopción", + "adopt.results.dryRunTitle": "Vista previa de la adopción", + "adopt.results.adopted": "Adoptadas", + "adopt.results.skipped": "Omitidas", + "adopt.results.trashed": "Enviadas a la papelera", + "adopt.results.pruned": "Enlaces depurados", + "adopt.results.failed": "Con errores", + "adopt.postAdopt.message": "¡Skills adoptadas en el origen! Ejecuta Sync para distribuirlas a todos los destinos.", + "adopt.postAdopt.goToSync": "Ir a Sync", + "adopt.toast.complete": "¡Adopción completada! {adopted} adoptadas, {trashed} enviadas a la papelera, {pruned} enlaces depurados.", + "adopt.toast.dryRun": "Simulación completada: se adoptarían {adopted} skills.", + "layout.nav.adopt": "Adoptar", "analyze.breakdown.body": "Cuerpo", "analyze.breakdown.description": "Descripción", "analyze.breakdown.total": "Total", diff --git a/ui/src/i18n/locales/fa.json b/ui/src/i18n/locales/fa.json index 7b9cdf4c..3c884a7a 100644 --- a/ui/src/i18n/locales/fa.json +++ b/ui/src/i18n/locales/fa.json @@ -1,4 +1,42 @@ { + "adopt.title": "پذیرش", + "adopt.subtitle": "مهارت‌هایی که ابزارهای خارجی در مقصد agents رها کرده‌اند را شناسایی کرده و آن‌ها را به منبع منتقل کنید", + "adopt.button.scanning": "در حال پویش...", + "adopt.button.rescan": "پویش مجدد", + "adopt.button.applying": "در حال پذیرش...", + "adopt.button.adoptCount": "پذیرش {count}", + "adopt.button.previewCount": "پیش‌نمایش {count}", + "adopt.control.dryRun": "اجرای آزمایشی (فقط پیش‌نمایش، بدون تغییر)", + "adopt.control.force": "اجباری (بازنویسی موارد موجود در منبع)", + "adopt.empty.title": "چیزی برای پذیرش نیست", + "adopt.empty.description": "هیچ مهارت خارجی‌ای در مقصد agents یافت نشد. همهٔ مهارت‌ها توسط skillshare مدیریت می‌شوند.", + "adopt.foundCount": "{count} مهارت قابل‌پذیرش یافت شد", + "adopt.selectAll": "انتخاب همه", + "adopt.selectNone": "لغو انتخاب همه", + "adopt.badge.conflict": "تعارض", + "adopt.badge.externalLinks": "{count} خارجی", + "adopt.conflict.hint": "مهارتی با این نام از پیش در منبع وجود دارد. برای بازنویسی آن، حالت اجباری را فعال کنید.", + "adopt.lock.title": "فایل قفل شناسایی شد", + "adopt.lock.previewHint": "یک فایل ‎.skill-lock.json در پوشهٔ agents یافت شد. پس از پذیرش، ورودی‌های باقی‌مانده در فایل قفل گزارش می‌شوند (خودِ فایل قفل هرگز تغییر داده نمی‌شود).", + "adopt.lock.warningTitle": "ورودی‌های باقی‌مانده در فایل قفل", + "adopt.lock.warningHint": "این مهارت‌های پذیرفته‌شده همچنان در فایل ‎.skill-lock.json پوشهٔ agents فهرست شده‌اند. برای جلوگیری از ایجاد دوبارهٔ آن‌ها، با ابزار سازندهٔ اصلی حذفشان کنید.", + "adopt.confirm.title": "تأیید پذیرش", + "adopt.confirm.dryRunTitle": "پیش‌نمایش پذیرش", + "adopt.confirm.message": "{count} مهارت به منبع منتقل شوند، نسخه‌های اصلی به سطل زباله بروند و همگام‌سازی دوباره انجام شود{forceSuffix}؟", + "adopt.confirm.dryRunMessage": "پیش‌نمایش پذیرش {count} مهارت (هیچ تغییری اعمال نمی‌شود)؟", + "adopt.confirm.forceOverwrite": " (بازنویسی اجباری)", + "adopt.results.title": "نتایج پذیرش", + "adopt.results.dryRunTitle": "پیش‌نمایش پذیرش", + "adopt.results.adopted": "پذیرفته‌شده", + "adopt.results.skipped": "رد‌شده", + "adopt.results.trashed": "به سطل زباله منتقل‌شده", + "adopt.results.pruned": "پیوندهای حذف‌شده", + "adopt.results.failed": "ناموفق", + "adopt.postAdopt.message": "مهارت‌ها به منبع پذیرفته شدند! برای توزیع آن‌ها به همهٔ مقصدها، Sync را اجرا کنید.", + "adopt.postAdopt.goToSync": "رفتن به Sync", + "adopt.toast.complete": "پذیرش کامل شد! {adopted} پذیرفته شد، {trashed} به سطل زباله رفت، {pruned} پیوند حذف شد.", + "adopt.toast.dryRun": "اجرای آزمایشی کامل شد: {adopted} مهارت پذیرفته می‌شد.", + "layout.nav.adopt": "پذیرش", "analyze.breakdown.body": "بدنه", "analyze.breakdown.description": "توضیحات", "analyze.breakdown.total": "مجموع", diff --git a/ui/src/i18n/locales/fr.json b/ui/src/i18n/locales/fr.json index c0bd2919..de4ffeb1 100644 --- a/ui/src/i18n/locales/fr.json +++ b/ui/src/i18n/locales/fr.json @@ -1,4 +1,42 @@ { + "adopt.title": "Adopter", + "adopt.subtitle": "Détecter les skills déposés dans la cible agents par des outils externes et les migrer dans la source", + "adopt.button.scanning": "Analyse en cours...", + "adopt.button.rescan": "Relancer l'analyse", + "adopt.button.applying": "Adoption en cours...", + "adopt.button.adoptCount": "Adopter {count}", + "adopt.button.previewCount": "Aperçu de {count}", + "adopt.control.dryRun": "Simulation (aperçu seulement, aucune modification)", + "adopt.control.force": "Forcer (écraser l'existant dans la source)", + "adopt.empty.title": "Rien à adopter", + "adopt.empty.description": "Aucun skill externe n'a été trouvé dans la cible agents. Tous les skills sont gérés par skillshare.", + "adopt.foundCount": "{count} skills adoptables trouvés", + "adopt.selectAll": "Tout sélectionner", + "adopt.selectNone": "Tout désélectionner", + "adopt.badge.conflict": "conflit", + "adopt.badge.externalLinks": "{count} externes", + "adopt.conflict.hint": "Un skill portant ce nom existe déjà dans la source. Activez Forcer pour l'écraser.", + "adopt.lock.title": "Fichier de verrouillage détecté", + "adopt.lock.previewHint": "Un fichier .skill-lock.json a été trouvé dans le répertoire agents. Après l'adoption, les entrées résiduelles du fichier de verrouillage seront signalées (le fichier de verrouillage lui-même n'est jamais modifié).", + "adopt.lock.warningTitle": "Entrées résiduelles du fichier de verrouillage", + "adopt.lock.warningHint": "Ces skills adoptés figurent toujours dans le fichier .skill-lock.json des agents. Supprimez-les avec l'outil d'origine pour éviter qu'ils ne soient recréés.", + "adopt.confirm.title": "Confirmer l'adoption", + "adopt.confirm.dryRunTitle": "Aperçu de l'adoption", + "adopt.confirm.message": "Migrer {count} skills dans la source, mettre les originaux à la corbeille et resynchroniser{forceSuffix} ?", + "adopt.confirm.dryRunMessage": "Prévisualiser l'adoption de {count} skills (aucune modification ne sera effectuée) ?", + "adopt.confirm.forceOverwrite": " (écrasement forcé)", + "adopt.results.title": "Résultats de l'adoption", + "adopt.results.dryRunTitle": "Aperçu de l'adoption", + "adopt.results.adopted": "Adoptés", + "adopt.results.skipped": "Ignorés", + "adopt.results.trashed": "Mis à la corbeille", + "adopt.results.pruned": "Liens élagués", + "adopt.results.failed": "Échoués", + "adopt.postAdopt.message": "Skills adoptés dans la source ! Lancez Sync pour les distribuer à toutes les cibles.", + "adopt.postAdopt.goToSync": "Aller à Sync", + "adopt.toast.complete": "Adoption terminée ! {adopted} adoptés, {trashed} mis à la corbeille, {pruned} liens élagués.", + "adopt.toast.dryRun": "Simulation terminée : {adopted} skills seraient adoptés.", + "layout.nav.adopt": "Adopter", "analyze.breakdown.body": "Corps", "analyze.breakdown.description": "Description", "analyze.breakdown.total": "Total", diff --git a/ui/src/i18n/locales/id.json b/ui/src/i18n/locales/id.json index 3d8c07dd..edec4a6f 100644 --- a/ui/src/i18n/locales/id.json +++ b/ui/src/i18n/locales/id.json @@ -1,4 +1,42 @@ { + "adopt.title": "Adopsi", + "adopt.subtitle": "Deteksi skill yang dijatuhkan ke target agents oleh alat eksternal dan migrasikan ke source", + "adopt.button.scanning": "Memindai...", + "adopt.button.rescan": "Pindai Ulang", + "adopt.button.applying": "Mengadopsi...", + "adopt.button.adoptCount": "Adopsi {count}", + "adopt.button.previewCount": "Pratinjau {count}", + "adopt.control.dryRun": "Uji coba (hanya pratinjau, tanpa perubahan)", + "adopt.control.force": "Paksa (timpa yang sudah ada di source)", + "adopt.empty.title": "Tidak ada yang perlu diadopsi", + "adopt.empty.description": "Tidak ada skill eksternal yang ditemukan di target agents. Semua skill dikelola oleh skillshare.", + "adopt.foundCount": "Ditemukan {count} skill yang dapat diadopsi", + "adopt.selectAll": "Pilih Semua", + "adopt.selectNone": "Hapus Pilihan", + "adopt.badge.conflict": "konflik", + "adopt.badge.externalLinks": "{count} eksternal", + "adopt.conflict.hint": "Skill dengan nama ini sudah ada di source. Aktifkan Paksa untuk menimpanya.", + "adopt.lock.title": "Lockfile terdeteksi", + "adopt.lock.previewHint": "Sebuah .skill-lock.json ditemukan di direktori agents. Setelah diadopsi, entri lockfile yang tersisa akan dilaporkan (lockfile itu sendiri tidak pernah diubah).", + "adopt.lock.warningTitle": "Entri lockfile yang tersisa", + "adopt.lock.warningHint": "Skill yang diadopsi ini masih tercantum di .skill-lock.json agents. Hapus dengan alat asalnya untuk mencegah pembuatan ulang.", + "adopt.confirm.title": "Konfirmasi Adopsi", + "adopt.confirm.dryRunTitle": "Pratinjau Adopsi", + "adopt.confirm.message": "Migrasikan {count} skill ke source, buang yang asli ke tempat sampah, dan sinkronkan ulang{forceSuffix}?", + "adopt.confirm.dryRunMessage": "Pratinjau adopsi {count} skill (tidak ada perubahan yang akan dilakukan)?", + "adopt.confirm.forceOverwrite": " (paksa timpa)", + "adopt.results.title": "Hasil Adopsi", + "adopt.results.dryRunTitle": "Pratinjau Adopsi", + "adopt.results.adopted": "Diadopsi", + "adopt.results.skipped": "Dilewati", + "adopt.results.trashed": "Dibuang", + "adopt.results.pruned": "Tautan dipangkas", + "adopt.results.failed": "Gagal", + "adopt.postAdopt.message": "Skill berhasil diadopsi ke source! Jalankan Sync untuk mendistribusikannya ke semua target.", + "adopt.postAdopt.goToSync": "Buka Sync", + "adopt.toast.complete": "Adopsi selesai! {adopted} diadopsi, {trashed} dibuang, {pruned} tautan dipangkas.", + "adopt.toast.dryRun": "Uji coba selesai: {adopted} skill akan diadopsi.", + "layout.nav.adopt": "Adopsi", "analyze.breakdown.body": "Isi", "analyze.breakdown.description": "Deskripsi", "analyze.breakdown.total": "Total", diff --git a/ui/src/i18n/locales/ja.json b/ui/src/i18n/locales/ja.json index 32a98ac7..42d46966 100644 --- a/ui/src/i18n/locales/ja.json +++ b/ui/src/i18n/locales/ja.json @@ -1,4 +1,42 @@ { + "adopt.title": "取り込み", + "adopt.subtitle": "外部ツールが agents ターゲットに追加したスキルを検出し、source に移行します", + "adopt.button.scanning": "スキャン中...", + "adopt.button.rescan": "再スキャン", + "adopt.button.applying": "取り込み中...", + "adopt.button.adoptCount": "{count} 件を取り込む", + "adopt.button.previewCount": "{count} 件をプレビュー", + "adopt.control.dryRun": "ドライラン(プレビューのみ、変更なし)", + "adopt.control.force": "強制(source の既存を上書き)", + "adopt.empty.title": "取り込み対象なし", + "adopt.empty.description": "agents ターゲットに外部スキルは見つかりませんでした。すべてのスキルは skillshare で管理されています。", + "adopt.foundCount": "取り込み可能なスキルが {count} 件見つかりました", + "adopt.selectAll": "すべて選択", + "adopt.selectNone": "選択を解除", + "adopt.badge.conflict": "競合", + "adopt.badge.externalLinks": "外部 {count} 件", + "adopt.conflict.hint": "同名のスキルが source に既に存在します。上書きするには強制を有効にしてください。", + "adopt.lock.title": "ロックファイルを検出", + "adopt.lock.previewHint": "agents ディレクトリに .skill-lock.json が見つかりました。取り込み後、残存するロックファイルのエントリが報告されます(ロックファイル自体は変更されません)。", + "adopt.lock.warningTitle": "残存するロックファイルのエントリ", + "adopt.lock.warningHint": "取り込んだこれらのスキルは agents の .skill-lock.json にまだ記載されています。再生成を防ぐため、元のツールで削除してください。", + "adopt.confirm.title": "取り込みの確認", + "adopt.confirm.dryRunTitle": "取り込みのプレビュー", + "adopt.confirm.message": "{count} 件のスキルを source に移行し、元をゴミ箱に移動して、再同期しますか{forceSuffix}?", + "adopt.confirm.dryRunMessage": "{count} 件のスキルの取り込みをプレビューしますか(変更は行われません)?", + "adopt.confirm.forceOverwrite": "(強制上書き)", + "adopt.results.title": "取り込み結果", + "adopt.results.dryRunTitle": "取り込みプレビュー", + "adopt.results.adopted": "取り込み済み", + "adopt.results.skipped": "スキップ", + "adopt.results.trashed": "ゴミ箱へ移動", + "adopt.results.pruned": "削除したリンク", + "adopt.results.failed": "失敗", + "adopt.postAdopt.message": "スキルを source に取り込みました。Sync を実行してすべてのターゲットに配布してください。", + "adopt.postAdopt.goToSync": "Sync へ移動", + "adopt.toast.complete": "取り込み完了!{adopted} 件取り込み、{trashed} 件ゴミ箱へ移動、{pruned} 件のリンクを削除しました。", + "adopt.toast.dryRun": "ドライラン完了:{adopted} 件のスキルが取り込まれます。", + "layout.nav.adopt": "取り込み", "analyze.breakdown.body": "本文", "analyze.breakdown.description": "説明", "analyze.breakdown.total": "合計", diff --git a/ui/src/i18n/locales/ko.json b/ui/src/i18n/locales/ko.json index a306d50e..b04408d0 100644 --- a/ui/src/i18n/locales/ko.json +++ b/ui/src/i18n/locales/ko.json @@ -1,4 +1,42 @@ { + "adopt.title": "가져오기", + "adopt.subtitle": "외부 도구가 agents 대상 디렉터리에 넣은 스킬을 감지하여 소스로 가져옵니다", + "adopt.button.scanning": "검색 중...", + "adopt.button.rescan": "다시 검색", + "adopt.button.applying": "가져오는 중...", + "adopt.button.adoptCount": "{count}개 가져오기", + "adopt.button.previewCount": "{count}개 미리보기", + "adopt.control.dryRun": "시험 실행 (미리보기만, 변경 없음)", + "adopt.control.force": "강제 (소스의 기존 항목 덮어쓰기)", + "adopt.empty.title": "가져올 항목 없음", + "adopt.empty.description": "agents 대상에서 외부 스킬을 찾지 못했습니다. 모든 스킬이 skillshare로 관리되고 있습니다.", + "adopt.foundCount": "가져올 수 있는 스킬 {count}개 발견", + "adopt.selectAll": "전체 선택", + "adopt.selectNone": "선택 해제", + "adopt.badge.conflict": "충돌", + "adopt.badge.externalLinks": "외부 {count}개", + "adopt.conflict.hint": "이 이름의 스킬이 소스에 이미 있습니다. 덮어쓰려면 강제를 켜세요.", + "adopt.lock.title": "잠금 파일 감지됨", + "adopt.lock.previewHint": "agents 디렉터리에서 .skill-lock.json을 찾았습니다. 가져온 후 남은 잠금 파일 항목이 보고됩니다(잠금 파일 자체는 수정되지 않습니다).", + "adopt.lock.warningTitle": "남은 잠금 파일 항목", + "adopt.lock.warningHint": "가져온 이 스킬들이 agents의 .skill-lock.json에 여전히 등록되어 있습니다. 다시 생성되지 않도록 해당 도구로 제거하세요.", + "adopt.confirm.title": "가져오기 확인", + "adopt.confirm.dryRunTitle": "가져오기 미리보기", + "adopt.confirm.message": "스킬 {count}개를 소스로 가져오고, 원본을 휴지통으로 보낸 뒤 다시 동기화할까요{forceSuffix}?", + "adopt.confirm.dryRunMessage": "스킬 {count}개 가져오기를 미리보기할까요(변경 사항 없음)?", + "adopt.confirm.forceOverwrite": " (강제 덮어쓰기)", + "adopt.results.title": "가져오기 결과", + "adopt.results.dryRunTitle": "가져오기 미리보기", + "adopt.results.adopted": "가져옴", + "adopt.results.skipped": "건너뜀", + "adopt.results.trashed": "휴지통으로 이동", + "adopt.results.pruned": "정리된 링크", + "adopt.results.failed": "실패", + "adopt.postAdopt.message": "스킬을 소스로 가져왔습니다! Sync를 실행하여 모든 대상에 배포하세요.", + "adopt.postAdopt.goToSync": "Sync로 이동", + "adopt.toast.complete": "가져오기 완료! {adopted}개 가져옴, {trashed}개 휴지통으로 이동, 링크 {pruned}개 정리.", + "adopt.toast.dryRun": "시험 실행 완료: 스킬 {adopted}개를 가져올 예정입니다.", + "layout.nav.adopt": "가져오기", "analyze.breakdown.body": "본문", "analyze.breakdown.description": "설명", "analyze.breakdown.total": "합계", diff --git a/ui/src/i18n/locales/pt-BR.json b/ui/src/i18n/locales/pt-BR.json index 4fbdaf59..aca01f9e 100644 --- a/ui/src/i18n/locales/pt-BR.json +++ b/ui/src/i18n/locales/pt-BR.json @@ -1,4 +1,42 @@ { + "adopt.title": "Adotar", + "adopt.subtitle": "Detecta skills colocadas no alvo agents por ferramentas externas e migra-as para o source", + "adopt.button.scanning": "Escaneando...", + "adopt.button.rescan": "Escanear novamente", + "adopt.button.applying": "Adotando...", + "adopt.button.adoptCount": "Adotar {count}", + "adopt.button.previewCount": "Pré-visualizar {count}", + "adopt.control.dryRun": "Simulação (apenas pré-visualização, sem alterações)", + "adopt.control.force": "Forçar (sobrescrever existentes no source)", + "adopt.empty.title": "Nada para adotar", + "adopt.empty.description": "Nenhuma skill externa foi encontrada no alvo agents. Todas as skills são gerenciadas pelo skillshare.", + "adopt.foundCount": "{count} skills adotáveis encontradas", + "adopt.selectAll": "Selecionar tudo", + "adopt.selectNone": "Limpar seleção", + "adopt.badge.conflict": "conflito", + "adopt.badge.externalLinks": "{count} externos", + "adopt.conflict.hint": "Já existe uma skill com este nome no source. Ative Forçar para sobrescrevê-la.", + "adopt.lock.title": "Lockfile detectado", + "adopt.lock.previewHint": "Um .skill-lock.json foi encontrado no diretório agents. Após a adoção, entradas remanescentes do lockfile serão reportadas (o lockfile em si nunca é modificado).", + "adopt.lock.warningTitle": "Entradas remanescentes no lockfile", + "adopt.lock.warningHint": "Estas skills adotadas ainda constam no .skill-lock.json do agents. Remova-as com a ferramenta de origem para evitar que sejam recriadas.", + "adopt.confirm.title": "Confirmar adoção", + "adopt.confirm.dryRunTitle": "Pré-visualizar adoção", + "adopt.confirm.message": "Migrar {count} skills para o source, mover os originais para a lixeira e ressincronizar{forceSuffix}?", + "adopt.confirm.dryRunMessage": "Pré-visualizar a adoção de {count} skills (nenhuma alteração será feita)?", + "adopt.confirm.forceOverwrite": " (forçar sobrescrita)", + "adopt.results.title": "Resultados da adoção", + "adopt.results.dryRunTitle": "Pré-visualização da adoção", + "adopt.results.adopted": "Adotadas", + "adopt.results.skipped": "Ignoradas", + "adopt.results.trashed": "Movidas para a lixeira", + "adopt.results.pruned": "Links removidos", + "adopt.results.failed": "Falharam", + "adopt.postAdopt.message": "Skills adotadas para o source! Execute o Sync para distribuí-las a todos os alvos.", + "adopt.postAdopt.goToSync": "Ir para o Sync", + "adopt.toast.complete": "Adoção concluída! {adopted} adotadas, {trashed} movidas para a lixeira, {pruned} links removidos.", + "adopt.toast.dryRun": "Simulação concluída: {adopted} skills seriam adotadas.", + "layout.nav.adopt": "Adotar", "analyze.breakdown.body": "Corpo", "analyze.breakdown.description": "Descrição", "analyze.breakdown.total": "Total", diff --git a/ui/src/i18n/locales/zh-CN.json b/ui/src/i18n/locales/zh-CN.json index fa410fd8..6d03d49e 100644 --- a/ui/src/i18n/locales/zh-CN.json +++ b/ui/src/i18n/locales/zh-CN.json @@ -1,4 +1,42 @@ { + "adopt.title": "接管", + "adopt.subtitle": "检测外部工具放入 agents 目标目录的技能,并将其迁移到源", + "adopt.button.scanning": "扫描中...", + "adopt.button.rescan": "重新扫描", + "adopt.button.applying": "接管中...", + "adopt.button.adoptCount": "接管 {count} 个", + "adopt.button.previewCount": "预览 {count} 个", + "adopt.control.dryRun": "试运行(仅预览,不做更改)", + "adopt.control.force": "强制(覆盖源中已存在的技能)", + "adopt.empty.title": "没有可接管的内容", + "adopt.empty.description": "在 agents 目标目录中未发现外部技能。所有技能均由 skillshare 管理。", + "adopt.foundCount": "发现 {count} 个可接管的技能", + "adopt.selectAll": "全选", + "adopt.selectNone": "取消全选", + "adopt.badge.conflict": "冲突", + "adopt.badge.externalLinks": "{count} 个外部", + "adopt.conflict.hint": "源中已存在同名技能。启用“强制”可覆盖它。", + "adopt.lock.title": "检测到锁文件", + "adopt.lock.previewHint": "在 agents 目录中发现 .skill-lock.json。接管后将报告残留的锁文件条目(锁文件本身不会被修改)。", + "adopt.lock.warningTitle": "残留的锁文件条目", + "adopt.lock.warningHint": "这些已接管的技能仍列在 agents 的 .skill-lock.json 中。请用原工具将其移除,以免被重新创建。", + "adopt.confirm.title": "确认接管", + "adopt.confirm.dryRunTitle": "预览接管", + "adopt.confirm.message": "将 {count} 个技能迁移到源,把原文件移入回收站,并重新同步{forceSuffix}?", + "adopt.confirm.dryRunMessage": "预览接管 {count} 个技能(不会做任何更改)?", + "adopt.confirm.forceOverwrite": " (强制覆盖)", + "adopt.results.title": "接管结果", + "adopt.results.dryRunTitle": "接管预览", + "adopt.results.adopted": "已接管", + "adopt.results.skipped": "已跳过", + "adopt.results.trashed": "已移入回收站", + "adopt.results.pruned": "已清理链接", + "adopt.results.failed": "失败", + "adopt.postAdopt.message": "技能已接管到源!运行 Sync 即可将其分发到所有目标。", + "adopt.postAdopt.goToSync": "前往 Sync", + "adopt.toast.complete": "接管完成!已接管 {adopted} 个,移入回收站 {trashed} 个,清理 {pruned} 个链接。", + "adopt.toast.dryRun": "试运行完成:将接管 {adopted} 个技能。", + "layout.nav.adopt": "接管", "analyze.breakdown.body": "正文", "analyze.breakdown.description": "描述", "analyze.breakdown.total": "总计", diff --git a/ui/src/i18n/locales/zh-TW.json b/ui/src/i18n/locales/zh-TW.json index 3a691f41..4a310364 100644 --- a/ui/src/i18n/locales/zh-TW.json +++ b/ui/src/i18n/locales/zh-TW.json @@ -1,4 +1,42 @@ { + "adopt.title": "採用", + "adopt.subtitle": "偵測外部工具放入 agents 目標目錄的 skill,並將其遷移至 source", + "adopt.button.scanning": "掃描中...", + "adopt.button.rescan": "重新掃描", + "adopt.button.applying": "採用中...", + "adopt.button.adoptCount": "採用 {count} 個", + "adopt.button.previewCount": "預覽 {count} 個", + "adopt.control.dryRun": "試跑(僅預覽,不做任何變更)", + "adopt.control.force": "強制(覆寫 source 中既有的項目)", + "adopt.empty.title": "沒有可採用的項目", + "adopt.empty.description": "在 agents 目標目錄中找不到外部 skill。所有 skill 都已由 skillshare 管理。", + "adopt.foundCount": "找到 {count} 個可採用的 skill", + "adopt.selectAll": "全選", + "adopt.selectNone": "全不選", + "adopt.badge.conflict": "衝突", + "adopt.badge.externalLinks": "{count} 個外部", + "adopt.conflict.hint": "source 中已存在同名的 skill。啟用「強制」即可覆寫。", + "adopt.lock.title": "偵測到鎖定檔", + "adopt.lock.previewHint": "在 agents 目錄中發現 .skill-lock.json。採用後會回報殘留的鎖定檔項目(鎖定檔本身絕不會被修改)。", + "adopt.lock.warningTitle": "殘留的鎖定檔項目", + "adopt.lock.warningHint": "這些已採用的 skill 仍列在 agents 的 .skill-lock.json 中。請用原本的工具移除它們,以免被重新建立。", + "adopt.confirm.title": "確認採用", + "adopt.confirm.dryRunTitle": "預覽採用", + "adopt.confirm.message": "將 {count} 個 skill 遷移至 source、把原始檔移入垃圾桶,並重新同步{forceSuffix}?", + "adopt.confirm.dryRunMessage": "預覽採用 {count} 個 skill(不會做任何變更)?", + "adopt.confirm.forceOverwrite": "(強制覆寫)", + "adopt.results.title": "採用結果", + "adopt.results.dryRunTitle": "採用預覽", + "adopt.results.adopted": "已採用", + "adopt.results.skipped": "已略過", + "adopt.results.trashed": "已移入垃圾桶", + "adopt.results.pruned": "已清除連結", + "adopt.results.failed": "失敗", + "adopt.postAdopt.message": "skill 已採用至 source!執行 Sync 即可將它們分送到所有目標。", + "adopt.postAdopt.goToSync": "前往 Sync", + "adopt.toast.complete": "採用完成!已採用 {adopted} 個、移入垃圾桶 {trashed} 個、清除 {pruned} 個連結。", + "adopt.toast.dryRun": "試跑完成:將會採用 {adopted} 個 skill。", + "layout.nav.adopt": "採用", "analyze.breakdown.body": "主體", "analyze.breakdown.description": "描述", "analyze.breakdown.total": "總計", From 57fb1c654125460a717b0e22b048361fa7ee8edf Mon Sep 17 00:00:00 2001 From: Willie Date: Sat, 30 May 2026 01:25:54 +0800 Subject: [PATCH 6/7] test(adopt): write CLI lockfile fixture beside skills dir TestRunAdopt_WarnsOnLingeringLockfile still wrote .skill-lock.json inside the skills dir, but production ReadLock now reads it from the parent (~/.agents/.skill-lock.json). Use adopt.LockPath so the fixture matches, restoring the lingering-entry warning assertion. --- cmd/skillshare/adopt_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/skillshare/adopt_test.go b/cmd/skillshare/adopt_test.go index ccf756a4..184141d1 100644 --- a/cmd/skillshare/adopt_test.go +++ b/cmd/skillshare/adopt_test.go @@ -130,7 +130,10 @@ func TestRunAdopt_WarnsOnLingeringLockfile(t *testing.T) { }, } data, _ := json.Marshal(lock) - if err := os.WriteFile(filepath.Join(actx.agentsPath, ".skill-lock.json"), data, 0o644); err != nil { + // The lockfile lives beside the skills dir (~/.agents/.skill-lock.json), + // one level up — matching where production ReadLock looks. + lockPath := adopt.LockPath(actx.agentsPath) + if err := os.WriteFile(lockPath, data, 0o644); err != nil { t.Fatalf("write lock: %v", err) } @@ -147,7 +150,7 @@ func TestRunAdopt_WarnsOnLingeringLockfile(t *testing.T) { } // Lockfile must NOT be modified. - raw, _ := os.ReadFile(filepath.Join(actx.agentsPath, ".skill-lock.json")) + raw, _ := os.ReadFile(lockPath) var got map[string]any if err := json.Unmarshal(raw, &got); err != nil { t.Fatalf("lockfile became unreadable: %v", err) From 7e168cb56e64575874c8438b412c8c23c30b8739 Mon Sep 17 00:00:00 2001 From: Willie Date: Sat, 30 May 2026 01:53:58 +0800 Subject: [PATCH 7/7] refactor(ui): restyle adopt page to match the sync page - Add a visual pipeline (Agents target -> Adopt -> Source) with the same hand-drawn wavy connectors and pill stages the sync page uses - Move the primary action into a centered control card with a status summary line (N adoptable / M conflicts), mirroring sync's stat parts - Replace the dry-run/force checkboxes with a SplitButton: primary Adopt, with Preview (dry run) and Force adopt in the dropdown, matching sync's action affordance. Conflicting skills are now always selectable but default off; plain Adopt skips them, Force adopt overwrites - Show the all-managed state inline in the control card instead of a separate empty card - Add adopt.pipeline.*, adopt.stat.*, adopt.button.forceAdopt keys across all 11 locales --- ui/src/components/Layout.tsx | 2 - ui/src/i18n/locales/de.json | 10 + ui/src/i18n/locales/en.json | 10 + ui/src/i18n/locales/es.json | 10 + ui/src/i18n/locales/fa.json | 10 + ui/src/i18n/locales/fr.json | 10 + ui/src/i18n/locales/id.json | 10 + ui/src/i18n/locales/ja.json | 10 + ui/src/i18n/locales/ko.json | 10 + ui/src/i18n/locales/pt-BR.json | 10 + ui/src/i18n/locales/zh-CN.json | 10 + ui/src/i18n/locales/zh-TW.json | 10 + ui/src/pages/AdoptPage.tsx | 283 +++++++++++++--------- ui/src/pages/DoctorPage.tsx | 30 +++ website/docs/reference/commands/adopt.md | 7 +- website/docs/reference/commands/doctor.md | 2 +- 16 files changed, 317 insertions(+), 117 deletions(-) diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index b92a8dd9..a47bd9bb 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -7,7 +7,6 @@ import { FolderPlus, RefreshCw, ArrowDownToLine, - PackagePlus, Archive, Trash2, GitBranch, @@ -69,7 +68,6 @@ const navGroups: NavGroup[] = [ items: [ { to: '/sync', icon: RefreshCw, labelKey: 'layout.nav.sync' }, { to: '/collect', icon: ArrowDownToLine, labelKey: 'layout.nav.collect' }, - { to: '/adopt', icon: PackagePlus, labelKey: 'layout.nav.adopt' }, { to: '/install', icon: Download, labelKey: 'layout.nav.install' }, { to: '/update', icon: ArrowUpCircle, labelKey: 'layout.nav.update' }, { to: '/uninstall', icon: Trash2, labelKey: 'layout.nav.uninstall' }, diff --git a/ui/src/i18n/locales/de.json b/ui/src/i18n/locales/de.json index 62f295f1..a2e620b9 100644 --- a/ui/src/i18n/locales/de.json +++ b/ui/src/i18n/locales/de.json @@ -6,6 +6,12 @@ "adopt.button.applying": "Übernehme...", "adopt.button.adoptCount": "{count} übernehmen", "adopt.button.previewCount": "{count} in Vorschau", + "adopt.button.forceAdopt": "{count} erzwungen übernehmen", + "adopt.pipeline.agents": "Agents", + "adopt.pipeline.engine": "Übernehmen", + "adopt.pipeline.source": "Quelle", + "adopt.stat.adoptable": "übernehmbar", + "adopt.stat.conflicts": "Konflikte", "adopt.control.dryRun": "Probelauf (nur Vorschau, keine Änderungen)", "adopt.control.force": "Erzwingen (vorhandene in der Quelle überschreiben)", "adopt.empty.title": "Nichts zu übernehmen", @@ -481,6 +487,10 @@ "doctor.version.latest": "Neueste:", "doctor.version.title": "Version", "doctor.version.updateAvailable": "Update verfügbar", + "doctor.adopt.title": "Externe CLI-Skills", + "doctor.adopt.badge": "Reparieren", + "doctor.adopt.description": "Öffne eine Adopt-Vorschau, wenn eine andere CLI Skills im agents-Ziel abgelegt hat und skillshare sie übernehmen soll.", + "doctor.adopt.action": "Adopt öffnen", "extras.addExtra": "Extra hinzufügen", "extras.addExtraTitle": "Extra hinzufügen", "extras.addTarget": "Ziel hinzufügen", diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json index 21a3358b..3f2f3bce 100644 --- a/ui/src/i18n/locales/en.json +++ b/ui/src/i18n/locales/en.json @@ -446,6 +446,12 @@ "adopt.button.applying": "Adopting...", "adopt.button.adoptCount": "Adopt {count}", "adopt.button.previewCount": "Preview {count}", + "adopt.button.forceAdopt": "Force adopt {count}", + "adopt.pipeline.agents": "Agents", + "adopt.pipeline.engine": "Adopt", + "adopt.pipeline.source": "Source", + "adopt.stat.adoptable": "adoptable", + "adopt.stat.conflicts": "conflicts", "adopt.control.dryRun": "Dry run (preview only, no changes)", "adopt.control.force": "Force (overwrite existing in source)", "adopt.empty.title": "Nothing to adopt", @@ -541,6 +547,10 @@ "doctor.version.latest": "Latest:", "doctor.version.title": "Version", "doctor.version.updateAvailable": "Update available", + "doctor.adopt.title": "External CLI skills", + "doctor.adopt.badge": "Repair", + "doctor.adopt.description": "Preview Adopt when another CLI has dropped skills into the agents target and you need to bring them under skillshare.", + "doctor.adopt.action": "Open Adopt", "doctor.skillignore.ignoredSkills": "Ignored Skills", "doctor.suggestions": "Suggestions", "doctor.skillignore.patterns": "Patterns", diff --git a/ui/src/i18n/locales/es.json b/ui/src/i18n/locales/es.json index 6d6f64f0..a2212e62 100644 --- a/ui/src/i18n/locales/es.json +++ b/ui/src/i18n/locales/es.json @@ -6,6 +6,12 @@ "adopt.button.applying": "Adoptando...", "adopt.button.adoptCount": "Adoptar {count}", "adopt.button.previewCount": "Previsualizar {count}", + "adopt.button.forceAdopt": "Forzar adopción {count}", + "adopt.pipeline.agents": "Agents", + "adopt.pipeline.engine": "Adoptar", + "adopt.pipeline.source": "Origen", + "adopt.stat.adoptable": "adoptables", + "adopt.stat.conflicts": "conflictos", "adopt.control.dryRun": "Simulación (solo vista previa, sin cambios)", "adopt.control.force": "Forzar (sobrescribir las existentes en el origen)", "adopt.empty.title": "Nada que adoptar", @@ -481,6 +487,10 @@ "doctor.version.latest": "Más reciente:", "doctor.version.title": "Versión", "doctor.version.updateAvailable": "Actualización disponible", + "doctor.adopt.title": "Skills de CLI externas", + "doctor.adopt.badge": "Reparar", + "doctor.adopt.description": "Previsualiza Adopt cuando otra CLI dejó skills en el destino agents y necesitas ponerlas bajo skillshare.", + "doctor.adopt.action": "Abrir Adopt", "extras.addExtra": "Añadir extra", "extras.addExtraTitle": "Añadir extra", "extras.addTarget": "Añadir destino", diff --git a/ui/src/i18n/locales/fa.json b/ui/src/i18n/locales/fa.json index 3c884a7a..f0b242ce 100644 --- a/ui/src/i18n/locales/fa.json +++ b/ui/src/i18n/locales/fa.json @@ -6,6 +6,12 @@ "adopt.button.applying": "در حال پذیرش...", "adopt.button.adoptCount": "پذیرش {count}", "adopt.button.previewCount": "پیش‌نمایش {count}", + "adopt.button.forceAdopt": "پذیرش اجباری {count}", + "adopt.pipeline.agents": "Agents", + "adopt.pipeline.engine": "پذیرش", + "adopt.pipeline.source": "منبع", + "adopt.stat.adoptable": "قابل‌پذیرش", + "adopt.stat.conflicts": "تعارض", "adopt.control.dryRun": "اجرای آزمایشی (فقط پیش‌نمایش، بدون تغییر)", "adopt.control.force": "اجباری (بازنویسی موارد موجود در منبع)", "adopt.empty.title": "چیزی برای پذیرش نیست", @@ -481,6 +487,10 @@ "doctor.version.latest": "آخرین:", "doctor.version.title": "نسخه", "doctor.version.updateAvailable": "به‌روزرسانی موجود است", + "doctor.adopt.title": "Skillهای CLI خارجی", + "doctor.adopt.badge": "ترمیم", + "doctor.adopt.description": "وقتی یک CLI دیگر skills را در هدف agents گذاشته و باید آن‌ها را زیر مدیریت skillshare ببرید، Adopt را پیش‌نمایش کنید.", + "doctor.adopt.action": "باز کردن Adopt", "extras.addExtra": "افزودن مورد اضافی", "extras.addExtraTitle": "افزودن مورد اضافی", "extras.addTarget": "افزودن هدف", diff --git a/ui/src/i18n/locales/fr.json b/ui/src/i18n/locales/fr.json index de4ffeb1..c973cfa3 100644 --- a/ui/src/i18n/locales/fr.json +++ b/ui/src/i18n/locales/fr.json @@ -6,6 +6,12 @@ "adopt.button.applying": "Adoption en cours...", "adopt.button.adoptCount": "Adopter {count}", "adopt.button.previewCount": "Aperçu de {count}", + "adopt.button.forceAdopt": "Forcer l'adoption de {count}", + "adopt.pipeline.agents": "Agents", + "adopt.pipeline.engine": "Adopter", + "adopt.pipeline.source": "Source", + "adopt.stat.adoptable": "adoptables", + "adopt.stat.conflicts": "conflits", "adopt.control.dryRun": "Simulation (aperçu seulement, aucune modification)", "adopt.control.force": "Forcer (écraser l'existant dans la source)", "adopt.empty.title": "Rien à adopter", @@ -481,6 +487,10 @@ "doctor.version.latest": "Dernière :", "doctor.version.title": "Version", "doctor.version.updateAvailable": "Mise à jour disponible", + "doctor.adopt.title": "Skills de CLI externes", + "doctor.adopt.badge": "Réparer", + "doctor.adopt.description": "Prévisualisez Adopt lorsqu'une autre CLI a déposé des skills dans la cible agents et que vous devez les remettre sous skillshare.", + "doctor.adopt.action": "Ouvrir Adopt", "extras.addExtra": "Ajouter un extra", "extras.addExtraTitle": "Ajouter un extra", "extras.addTarget": "Ajouter une cible", diff --git a/ui/src/i18n/locales/id.json b/ui/src/i18n/locales/id.json index edec4a6f..39fcf814 100644 --- a/ui/src/i18n/locales/id.json +++ b/ui/src/i18n/locales/id.json @@ -6,6 +6,12 @@ "adopt.button.applying": "Mengadopsi...", "adopt.button.adoptCount": "Adopsi {count}", "adopt.button.previewCount": "Pratinjau {count}", + "adopt.button.forceAdopt": "Paksa adopsi {count}", + "adopt.pipeline.agents": "Agents", + "adopt.pipeline.engine": "Adopsi", + "adopt.pipeline.source": "Source", + "adopt.stat.adoptable": "dapat diadopsi", + "adopt.stat.conflicts": "konflik", "adopt.control.dryRun": "Uji coba (hanya pratinjau, tanpa perubahan)", "adopt.control.force": "Paksa (timpa yang sudah ada di source)", "adopt.empty.title": "Tidak ada yang perlu diadopsi", @@ -481,6 +487,10 @@ "doctor.version.latest": "Terbaru:", "doctor.version.title": "Versi", "doctor.version.updateAvailable": "Pembaruan tersedia", + "doctor.adopt.title": "Skill CLI eksternal", + "doctor.adopt.badge": "Perbaiki", + "doctor.adopt.description": "Pratinjau Adopt saat CLI lain menaruh skill di target agents dan Anda perlu membawanya ke pengelolaan skillshare.", + "doctor.adopt.action": "Buka Adopt", "extras.addExtra": "Tambah Extra", "extras.addExtraTitle": "Tambah Extra", "extras.addTarget": "Tambah Target", diff --git a/ui/src/i18n/locales/ja.json b/ui/src/i18n/locales/ja.json index 42d46966..eda23f1d 100644 --- a/ui/src/i18n/locales/ja.json +++ b/ui/src/i18n/locales/ja.json @@ -6,6 +6,12 @@ "adopt.button.applying": "取り込み中...", "adopt.button.adoptCount": "{count} 件を取り込む", "adopt.button.previewCount": "{count} 件をプレビュー", + "adopt.button.forceAdopt": "{count} 件を強制取り込み", + "adopt.pipeline.agents": "Agents", + "adopt.pipeline.engine": "取り込み", + "adopt.pipeline.source": "Source", + "adopt.stat.adoptable": "取り込み可能", + "adopt.stat.conflicts": "競合", "adopt.control.dryRun": "ドライラン(プレビューのみ、変更なし)", "adopt.control.force": "強制(source の既存を上書き)", "adopt.empty.title": "取り込み対象なし", @@ -481,6 +487,10 @@ "doctor.version.latest": "最新:", "doctor.version.title": "バージョン", "doctor.version.updateAvailable": "更新あり", + "doctor.adopt.title": "外部 CLI スキル", + "doctor.adopt.badge": "修復", + "doctor.adopt.description": "別の CLI が agents ターゲットにスキルを配置し、skillshare 管理へ戻す必要がある場合は Adopt をプレビューします。", + "doctor.adopt.action": "Adopt を開く", "extras.addExtra": "Extra を追加", "extras.addExtraTitle": "Extra を追加", "extras.addTarget": "ターゲットを追加", diff --git a/ui/src/i18n/locales/ko.json b/ui/src/i18n/locales/ko.json index b04408d0..f7f4ae13 100644 --- a/ui/src/i18n/locales/ko.json +++ b/ui/src/i18n/locales/ko.json @@ -6,6 +6,12 @@ "adopt.button.applying": "가져오는 중...", "adopt.button.adoptCount": "{count}개 가져오기", "adopt.button.previewCount": "{count}개 미리보기", + "adopt.button.forceAdopt": "{count}개 강제 가져오기", + "adopt.pipeline.agents": "Agents", + "adopt.pipeline.engine": "가져오기", + "adopt.pipeline.source": "소스", + "adopt.stat.adoptable": "가져올 수 있음", + "adopt.stat.conflicts": "충돌", "adopt.control.dryRun": "시험 실행 (미리보기만, 변경 없음)", "adopt.control.force": "강제 (소스의 기존 항목 덮어쓰기)", "adopt.empty.title": "가져올 항목 없음", @@ -481,6 +487,10 @@ "doctor.version.latest": "최신:", "doctor.version.title": "버전", "doctor.version.updateAvailable": "업데이트 가능", + "doctor.adopt.title": "외부 CLI Skills", + "doctor.adopt.badge": "복구", + "doctor.adopt.description": "다른 CLI가 agents 대상에 skills를 넣었고 skillshare 관리로 가져와야 할 때 Adopt를 미리 확인합니다.", + "doctor.adopt.action": "Adopt 열기", "extras.addExtra": "Extra 추가", "extras.addExtraTitle": "Extra 추가", "extras.addTarget": "대상 추가", diff --git a/ui/src/i18n/locales/pt-BR.json b/ui/src/i18n/locales/pt-BR.json index aca01f9e..53854f2e 100644 --- a/ui/src/i18n/locales/pt-BR.json +++ b/ui/src/i18n/locales/pt-BR.json @@ -6,6 +6,12 @@ "adopt.button.applying": "Adotando...", "adopt.button.adoptCount": "Adotar {count}", "adopt.button.previewCount": "Pré-visualizar {count}", + "adopt.button.forceAdopt": "Forçar adoção de {count}", + "adopt.pipeline.agents": "Agents", + "adopt.pipeline.engine": "Adotar", + "adopt.pipeline.source": "Source", + "adopt.stat.adoptable": "adotáveis", + "adopt.stat.conflicts": "conflitos", "adopt.control.dryRun": "Simulação (apenas pré-visualização, sem alterações)", "adopt.control.force": "Forçar (sobrescrever existentes no source)", "adopt.empty.title": "Nada para adotar", @@ -481,6 +487,10 @@ "doctor.version.latest": "Mais recente:", "doctor.version.title": "Versão", "doctor.version.updateAvailable": "Atualização disponível", + "doctor.adopt.title": "Skills de CLIs externas", + "doctor.adopt.badge": "Reparar", + "doctor.adopt.description": "Pré-visualize Adopt quando outra CLI colocou skills no alvo agents e você precisa trazê-las para o skillshare.", + "doctor.adopt.action": "Abrir Adopt", "extras.addExtra": "Adicionar Extra", "extras.addExtraTitle": "Adicionar Extra", "extras.addTarget": "Adicionar Destino", diff --git a/ui/src/i18n/locales/zh-CN.json b/ui/src/i18n/locales/zh-CN.json index 6d03d49e..6cf9d9e0 100644 --- a/ui/src/i18n/locales/zh-CN.json +++ b/ui/src/i18n/locales/zh-CN.json @@ -6,6 +6,12 @@ "adopt.button.applying": "接管中...", "adopt.button.adoptCount": "接管 {count} 个", "adopt.button.previewCount": "预览 {count} 个", + "adopt.button.forceAdopt": "强制采用 {count} 个", + "adopt.pipeline.agents": "Agents", + "adopt.pipeline.engine": "采用", + "adopt.pipeline.source": "Source", + "adopt.stat.adoptable": "个可采用", + "adopt.stat.conflicts": "个冲突", "adopt.control.dryRun": "试运行(仅预览,不做更改)", "adopt.control.force": "强制(覆盖源中已存在的技能)", "adopt.empty.title": "没有可接管的内容", @@ -481,6 +487,10 @@ "doctor.version.latest": "最新:", "doctor.version.title": "版本", "doctor.version.updateAvailable": "有可用更新", + "doctor.adopt.title": "外部 CLI Skills", + "doctor.adopt.badge": "修复", + "doctor.adopt.description": "当其他 CLI 将 skills 放入 agents 目标目录,并需要交由 skillshare 管理时,先预览 Adopt。", + "doctor.adopt.action": "打开 Adopt", "extras.addExtra": "添加 Extra", "extras.addExtraTitle": "添加 Extra", "extras.addTarget": "添加目标", diff --git a/ui/src/i18n/locales/zh-TW.json b/ui/src/i18n/locales/zh-TW.json index 4a310364..d626e3e7 100644 --- a/ui/src/i18n/locales/zh-TW.json +++ b/ui/src/i18n/locales/zh-TW.json @@ -6,6 +6,12 @@ "adopt.button.applying": "採用中...", "adopt.button.adoptCount": "採用 {count} 個", "adopt.button.previewCount": "預覽 {count} 個", + "adopt.button.forceAdopt": "強制採用 {count} 個", + "adopt.pipeline.agents": "Agents", + "adopt.pipeline.engine": "採用", + "adopt.pipeline.source": "來源", + "adopt.stat.adoptable": "個可採用", + "adopt.stat.conflicts": "個衝突", "adopt.control.dryRun": "試跑(僅預覽,不做任何變更)", "adopt.control.force": "強制(覆寫 source 中既有的項目)", "adopt.empty.title": "沒有可採用的項目", @@ -463,6 +469,10 @@ "doctor.version.latest": "最新:", "doctor.version.title": "版本", "doctor.version.updateAvailable": "有可用更新", + "doctor.adopt.title": "外部 CLI Skills", + "doctor.adopt.badge": "修復", + "doctor.adopt.description": "當其他 CLI 把 skills 放進 agents 目標,需要交由 skillshare 管理時,先預覽 Adopt。", + "doctor.adopt.action": "開啟 Adopt", "doctor.check.source": "來源目錄", "doctor.check.agents_source": "Agent 來源", "doctor.check.symlink_support": "符號連結支援", diff --git a/ui/src/pages/AdoptPage.tsx b/ui/src/pages/AdoptPage.tsx index 48f505c0..4a451c81 100644 --- a/ui/src/pages/AdoptPage.tsx +++ b/ui/src/pages/AdoptPage.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { PackagePlus, Folder, + FolderCheck, Zap, Eye, Link2Off, @@ -20,14 +21,15 @@ import Card from '../components/Card'; import PageHeader from '../components/PageHeader'; import Badge from '../components/Badge'; import Button from '../components/Button'; +import SplitButton from '../components/SplitButton'; +import Spinner from '../components/Spinner'; import { Checkbox } from '../components/Input'; -import EmptyState from '../components/EmptyState'; import ConfirmDialog from '../components/ConfirmDialog'; import { PageSkeleton } from '../components/Skeleton'; import { useToast } from '../components/Toast'; import { api, type AdoptCandidate, type AdoptApplyResult } from '../api/client'; import { queryKeys } from '../lib/queryKeys'; -import { radius } from '../design'; +import { radius, shadows } from '../design'; import { useT } from '../i18n'; type Phase = 'loading' | 'loaded' | 'applying' | 'done'; @@ -41,10 +43,9 @@ export default function AdoptPage() { const [candidates, setCandidates] = useState([]); const [lockPresent, setLockPresent] = useState(false); const [selected, setSelected] = useState>(new Set()); - const [force, setForce] = useState(false); - const [dryRun, setDryRun] = useState(false); const [result, setResult] = useState(null); - const [confirming, setConfirming] = useState(false); + // Pending destructive confirmation; null = closed, boolean = the force flag. + const [confirmForce, setConfirmForce] = useState(null); const loadPreview = async () => { setPhase('loading'); @@ -70,7 +71,9 @@ export default function AdoptPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleApply = async () => { + const handleApply = async (opts: { dryRun?: boolean; force?: boolean } = {}) => { + const dryRun = opts.dryRun ?? false; + const force = opts.force ?? false; setPhase('applying'); try { const res = await api.postAdoptApply({ @@ -115,16 +118,20 @@ export default function AdoptPage() { }); }; - const selectableNames = candidates - .filter((c) => force || !c.conflict) - .map((c) => c.name); - const toggleAll = (selectAll: boolean) => { - setSelected(selectAll ? new Set(selectableNames) : new Set()); + setSelected(selectAll ? new Set(candidates.map((c) => c.name)) : new Set()); }; - // Conflicting candidates need Force to be adoptable. - const isSelectable = (c: AdoptCandidate) => force || !c.conflict; + const conflictCount = candidates.filter((c) => c.conflict).length; + const hasCandidates = candidates.length > 0; + const busy = phase === 'applying'; + const rowsLocked = busy || phase === 'done'; + + // Status summary line, mirroring the Sync page's stat-parts pattern. + const statParts = [ + { n: candidates.length, label: t('adopt.stat.adoptable'), cls: 'text-info' }, + conflictCount > 0 && { n: conflictCount, label: t('adopt.stat.conflicts'), cls: 'text-danger' }, + ].filter((x): x is { n: number; label: string; cls: string } => !!x); return (
@@ -134,62 +141,140 @@ export default function AdoptPage() { subtitle={t('adopt.subtitle')} /> - {/* Controls */} + {/* Visual pipeline: agents target → adopt engine → source */} +
+
+ + {t('adopt.pipeline.agents')} +
+ + + +
+ {busy ? ( + + ) : ( + + )} + {t('adopt.pipeline.engine')} +
+ + + +
+ + {t('adopt.pipeline.source')} +
+
+ + {/* Control area */}
+ {/* Status indicator */} + {phase === 'loading' ? ( +

{t('adopt.button.scanning')}

+ ) : hasCandidates ? ( +

+ {statParts.map((p, i) => ( + + {i > 0 && ·} + {p.n}{' '} + {p.label} + + ))} +

+ ) : ( +
+ + {t('adopt.empty.title')} + · + {t('adopt.empty.description')} +
+ )} + + {/* Action bar */}
-
- {(phase === 'loaded' || phase === 'done') && candidates.length > 0 && ( -
-
- - -
-
- - -
-
- )} + {hasCandidates && phase !== 'done' && ( + setConfirmForce(false)} + loading={busy} + disabled={selected.size === 0} + variant="primary" + size="sm" + dropdownAlign="right" + items={[ + { + label: t('adopt.button.previewCount', { count: selected.size }), + icon: , + onClick: () => handleApply({ dryRun: true }), + }, + { + label: t('adopt.button.forceAdopt', { count: selected.size }), + icon: , + onClick: () => setConfirmForce(true), + confirm: true, + }, + ]} + > + {!busy && } + {busy + ? t('adopt.button.applying') + : t('adopt.button.adoptCount', { count: selected.size })} + + )} +
{/* Loading */} {phase === 'loading' && } - {/* Empty */} - {phase !== 'loading' && candidates.length === 0 && ( - + {/* Lockfile warning (preview-time hint) */} + {phase === 'loaded' && lockPresent && hasCandidates && ( + +
+ +
+

{t('adopt.lock.title')}

+

{t('adopt.lock.previewHint')}

+
+
+
)} {/* Candidate list */} - {phase !== 'loading' && candidates.length > 0 && ( -
-
-

+ {phase !== 'loading' && hasCandidates && ( +
+
+

{t('adopt.foundCount', { count: candidates.length })} -

+

{phase !== 'done' && (
- -
@@ -198,19 +283,13 @@ export default function AdoptPage() {
{candidates.map((c) => { - const selectable = isSelectable(c); const isSelected = selected.has(c.name); return ( - +
{ - if (selectable && phase !== 'applying' && phase !== 'done') toggle(c.name); + if (!rowsLocked) toggle(c.name); }} > e.stopPropagation()}> @@ -219,16 +298,14 @@ export default function AdoptPage() { checked={isSelected} onChange={() => toggle(c.name)} size="sm" - disabled={!selectable || phase === 'applying' || phase === 'done'} + disabled={rowsLocked} /> {c.name}
- {c.sourceTool && ( - {c.sourceTool} - )} + {c.sourceTool && {c.sourceTool}} {c.conflict && ( @@ -245,9 +322,7 @@ export default function AdoptPage() {
{c.conflict && ( -

- {t('adopt.conflict.hint')} -

+

{t('adopt.conflict.hint')}

)} {c.externalLinks.length > 0 && (
@@ -262,43 +337,9 @@ export default function AdoptPage() { ); })}
- - {/* Apply button */} - {phase !== 'done' && ( -
- -
- )}
)} - {/* Lockfile warning (preview-time hint) */} - {phase === 'loaded' && lockPresent && candidates.length > 0 && ( - -
- -
-

{t('adopt.lock.title')}

-

{t('adopt.lock.previewHint')}

-
-
-
- )} - {/* Results */} {phase === 'done' && result && } @@ -317,19 +358,17 @@ export default function AdoptPage() {
)} - {/* Confirm dialog */} + {/* Confirm dialog (destructive: trashes originals) */}

- {dryRun - ? t('adopt.confirm.dryRunMessage', { count: selected.size }) - : t('adopt.confirm.message', { - count: selected.size, - forceSuffix: force ? t('adopt.confirm.forceOverwrite') : '', - })} + {t('adopt.confirm.message', { + count: selected.size, + forceSuffix: confirmForce ? t('adopt.confirm.forceOverwrite') : '', + })}

    {Array.from(selected).map((name) => ( @@ -341,17 +380,37 @@ export default function AdoptPage() {
} - confirmText={dryRun ? t('adopt.button.previewCount', { count: selected.size }) : t('adopt.button.adoptCount', { count: selected.size })} + confirmText={t('adopt.button.adoptCount', { count: selected.size })} onConfirm={() => { - setConfirming(false); - handleApply(); + const force = confirmForce ?? false; + setConfirmForce(null); + handleApply({ force }); }} - onCancel={() => setConfirming(false)} + onCancel={() => setConfirmForce(null)} />
); } +/** Hand-drawn wavy connector between pipeline stages (mirrors the Sync page). */ +function WavyConnector({ active }: { active: boolean }) { + return ( +
+ + + +
+ ); +} + /** Adopt result summary */ function AdoptResults({ result }: { result: AdoptApplyResult }) { const t = useT(); @@ -362,12 +421,12 @@ function AdoptResults({ result }: { result: AdoptApplyResult }) { const lockWarnings = result.lockWarnings ?? []; return ( -
-

+
+

{result.dryRun ? t('adopt.results.dryRunTitle') : t('adopt.results.title')} -

+

-
+
@@ -375,7 +434,7 @@ function AdoptResults({ result }: { result: AdoptApplyResult }) {
{adopted.length > 0 && ( - +

{t('adopt.results.adopted')} @@ -389,7 +448,7 @@ function AdoptResults({ result }: { result: AdoptApplyResult }) { )} {skipped.length > 0 && ( - +

{t('adopt.results.skipped')} @@ -403,7 +462,7 @@ function AdoptResults({ result }: { result: AdoptApplyResult }) { )} {failedEntries.length > 0 && ( - +

{t('adopt.results.failed')} diff --git a/ui/src/pages/DoctorPage.tsx b/ui/src/pages/DoctorPage.tsx index 1cb5b99b..38092043 100644 --- a/ui/src/pages/DoctorPage.tsx +++ b/ui/src/pages/DoctorPage.tsx @@ -1,4 +1,5 @@ import { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Stethoscope, RefreshCw, @@ -9,6 +10,8 @@ import { ChevronDown, ChevronRight, ArrowUpCircle, + ArrowRight, + PackagePlus, PartyPopper, } from 'lucide-react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; @@ -162,6 +165,7 @@ function CheckDetails({ details, name }: { details: string[]; name: string }) { export default function DoctorPage() { const t = useT(); + const navigate = useNavigate(); const queryClient = useQueryClient(); const { data, isPending, error, isFetching, refetch } = useQuery({ queryKey: queryKeys.doctor, @@ -309,6 +313,32 @@ export default function DoctorPage() { )} + {/* Repair entry points */} + +
+
+ +
+
+
+ {t('doctor.adopt.title')} + {t('doctor.adopt.badge')} +
+

{t('doctor.adopt.description')}

+
+
+ +
+
+
+ {/* Filter toggles */} value={filter} diff --git a/website/docs/reference/commands/adopt.md b/website/docs/reference/commands/adopt.md index 38e6287e..58cebc73 100644 --- a/website/docs/reference/commands/adopt.md +++ b/website/docs/reference/commands/adopt.md @@ -4,7 +4,7 @@ sidebar_position: 2 # adopt -Adopt CLI-bundled skills that external tools dropped into the universal target (`~/.agents/skills`) so skillshare governs them. +Repair external CLI-bundled skills that were dropped into the universal target (`~/.agents/skills`) so skillshare can govern them. ```bash skillshare adopt # Detect and interactively adopt @@ -21,7 +21,9 @@ Some CLI tools (for example `firecrawl/cli`, `googleworkspace/cli`) ship their o - the tool's symlinks only reach the agents it detected — other targets are left uncovered - moving them into skillshare by hand gets overwritten on the tool's next reinstall (the lockfile still claims them) -Use `adopt` to bring those skills under skillshare's management: migrate the canonical files into your source, clean up the tool's orphan symlinks, and re-sync to **all** targets. +Use `adopt` as a repair flow when `doctor`, `audit`, or the dashboard's **Health Check** page shows skills that came from another CLI. Normal new installs should still use [`install`](/docs/reference/commands/install); `adopt` exists for already-present files that bypassed skillshare. + +`adopt` brings those skills under skillshare's management: migrate the canonical files into your source, clean up the tool's orphan symlinks in configured targets, and re-sync to all configured targets. ## What Happens @@ -126,6 +128,7 @@ Run 'skillshare sync' to confirm distribution to all targets ## See Also - [collect](/docs/reference/commands/collect) — Pull local (non-symlinked) skills from a target into source +- [doctor](/docs/reference/commands/doctor) — Diagnose setup issues and open the dashboard repair flow - [sync](/docs/reference/commands/sync) — Distribute from source to targets - [audit](/docs/reference/commands/audit) — Find unmanaged skills - [restore](/docs/reference/commands/restore) — Restore a trashed original diff --git a/website/docs/reference/commands/doctor.md b/website/docs/reference/commands/doctor.md index 6c736c45..a643ea08 100644 --- a/website/docs/reference/commands/doctor.md +++ b/website/docs/reference/commands/doctor.md @@ -291,7 +291,7 @@ skillshare doctor --json | jq '[.checks[] | select(.status == "warning")]' ``` :::tip Web Dashboard -The **Health Check** page in the web dashboard (`skillshare ui`) provides a visual version of `doctor --json` with filter toggles and expandable details. +The **Health Check** page in the web dashboard (`skillshare ui`) provides a visual version of `doctor --json` with filter toggles, expandable details, and a repair entry point for external CLI-bundled skills. Use that repair action to preview [`adopt`](/docs/reference/commands/adopt) when another CLI has written skills into the agents target. ::: ## See Also