Skip to content

feat: adopt v2#181

Draft
runkids wants to merge 7 commits into
mainfrom
adopt
Draft

feat: adopt v2#181
runkids wants to merge 7 commits into
mainfrom
adopt

Conversation

@runkids
Copy link
Copy Markdown
Owner

@runkids runkids commented May 31, 2026

ref #135

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the 'Adopt CLI-bundled skills' feature, adding a new adopt command to the CLI and a corresponding page to the web dashboard. This feature allows users to claim unmanaged skills installed by external tools, migrating them to the source of truth, cleaning up orphan symlinks, and re-syncing targets. The review feedback highlights a critical concurrency issue in the server handler where a write lock is held during disk I/O, a parsing edge case in the lockfile reader when handling flat structures with a skill named 'skills', and unsafe type assertions on caught exceptions in the React frontend.

Comment on lines +103 to +141
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)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Holding the write lock s.mu.Lock() during the entire handleAdoptApply handler is a significant performance bottleneck and a potential Denial of Service (DoS) vulnerability. The lock is held while decoding the request body from the network socket (which can be slow) and during the entire adopt.Apply operation, which performs heavy disk I/O (copying files, trashing files, pruning symlinks, and re-syncing targets). Since adopt.Apply does not modify any in-memory server state, it does not need to run under the write lock.

We should decode the request body first, then acquire a read lock (s.mu.RLock()) to safely snapshot the required configuration and targets, and finally release the lock before executing the adopt flow.

	start := time.Now()

	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
	}

	s.mu.RLock()
	source := s.skillsSource()
	globalMode := s.cfg.Mode
	targets := s.cloneTargets()
	isProject := s.IsProjectMode()
	projectRoot := s.projectRoot
	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
	}

	trashBase := trash.TrashDir()
	if isProject {
		trashBase = trash.ProjectTrashDir(projectRoot)
	}

Comment on lines +88 to +101
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
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the lockfile uses a flat structure (Shape b) but happens to contain a skill named "skills", the current implementation will attempt to parse its value as a nested map (map[string]map[string]any). This will fail with an unmarshaling error and cause the entire lockfile reading to fail, even though the lockfile is perfectly valid.

Instead of returning an error immediately when nested unmarshaling fails, we should degrade gracefully and fall back to treating the lockfile as a flat map.

	if skillsRaw, ok := top["skills"]; ok {
		var nested map[string]map[string]any
		if err := json.Unmarshal(skillsRaw, &nested); err == nil {
			for name, raw := range nested {
				entries[name] = LockEntry{
					Name:       name,
					SourceTool: sourceToolFromRaw(raw),
					Raw:        raw,
				}
			}
			return entries, nil
		}
	}

Comment on lines +61 to +65
} catch (e: unknown) {
toast((e as Error).message, 'error');
setPhase('loaded');
setCandidates([]);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Casting e as Error directly and accessing .message is unsafe because the caught exception e can be of any type (e.g., a string or a custom error object). If e is not an instance of Error, this can lead to runtime type errors or display "undefined". We should safely extract the error message by checking if e is an instance of Error.

Suggested change
} catch (e: unknown) {
toast((e as Error).message, 'error');
setPhase('loaded');
setCandidates([]);
}
} catch (e: unknown) {
toast(e instanceof Error ? e.message : String(e), 'error');
setPhase('loaded');
setCandidates([]);
}

Comment on lines +106 to +109
} catch (e: unknown) {
toast((e as Error).message, 'error');
setPhase('loaded');
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Casting e as Error directly and accessing .message is unsafe because the caught exception e can be of any type. We should safely extract the error message by checking if e is an instance of Error.

Suggested change
} catch (e: unknown) {
toast((e as Error).message, 'error');
setPhase('loaded');
}
} catch (e: unknown) {
toast(e instanceof Error ? e.message : String(e), 'error');
setPhase('loaded');
}

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 500a98b3b7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread internal/adopt/apply.go
for name, target := range req.Targets {
switch effectiveMode(target, req.DefaultMode) {
case "copy":
_, _ = sync.SyncTargetCopy(name, target, req.SourcePath, false, req.Force)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not forward adopt force to copy target sync

When skillshare adopt --force is used with any copy-mode target, this re-sync passes the adopt force flag into SyncTargetCopy; in that function, force means overwriting every unmanaged local directory/non-directory that collides with any source skill, not just the skills being adopted. A user using --force to resolve an adopt source conflict can therefore unexpectedly wipe unrelated local copies in copy-mode targets, while the adopt help only advertises overwriting same-name skills in source.

Useful? React with 👍 / 👎.

return nil
}

res, err := applyAdopt(actx, selected, opts)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress sync diagnostics before emitting JSON

In --json mode this call can run the prune/re-sync path before the JSON payload is written, and those sync helpers write diagnostics to sync.DiagOutput, which defaults to stdout (for example naming-validation or collision messages from copy/merge sync). In those target/source configurations, skillshare adopt --json produces non-JSON text before the JSON object, breaking consumers that parse stdout as JSON; mirror the sync command’s behavior by discarding diagnostics while JSON mode is active.

Useful? React with 👍 / 👎.

runkids added 7 commits May 31, 2026 22:49
…ills

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
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
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
- 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
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.
- 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
@runkids runkids marked this pull request as draft May 31, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant