Skip to content

feat(init): sync installed skills with the manifest - #8556

Open
domitriusclark wants to merge 7 commits into
claude/cli-ax-netlify-init-3ed87efrom
claude/ex-3055-skills-sync-manifest
Open

domitriusclark wants to merge 7 commits into
claude/cli-ax-netlify-init-3ed87efrom
claude/ex-3055-skills-sync-manifest

Conversation

@domitriusclark

@domitriusclark domitriusclark commented Oct 2, 2026 •

Copy link
Copy Markdown

Summary

EX-3055. Stacked on #8555, so review that first.

#8555 makes netlify init install the Netlify skills and refresh outdated copies, but it leaves copies under deprecated or prior names in place and gives the user no way to put an edited skill back to the shipped release. This PR adds the rest of the sync: re-running init deletes an unedited deprecated skill, migrates an unedited copy under a prior name to its current name, and cleans up what an interrupted run left behind. Anything the user edited is kept and listed, and --reset-context replaces, migrates or deletes it.

This is the first path that deletes directories in a user's repo. A directory is removed only when its tree hash matches a release we shipped, re-hashed right before the rm, or under --reset-context when it sits at a manifest name. Nothing else is touched: unknown directories, symlinks, and a directory that differs from a skill name only by case on macOS are all left alone, with or without the flag.

Changes
  • src/utils/init/agent-skills.ts: syncSkills takes reset. New rules per directory:
    • deprecated, unedited: removed. Edited: kept and reported, removed with --reset-context.
    • prior name, unedited: the current name is installed if absent and the old directory removed. If either copy is edited, both stay until --reset-context.
    • modified: kept, replaced with --reset-context. A symlink or file at the skill's own name is replaced too; only the link is removed, never its target.
    • leftovers: .netlify-skill-<name>-* staging directories older than ten minutes and <name>.old-* backups that hash to a shipped release are removed when <name> is in the manifest. Younger staging directories belong to a run that may still be writing them.
  • Guards: before any forced replace, every entry in the skills directory is checked by inode against the target name, so a case twin (Netlify-Deploy next to netlify-deploy) is never renamed away, with or without SKILL.md. The assembled staging tree must hash to the manifest tree_hash before it is swapped in. A directory without SKILL.md under a prior or deprecated name is ignored. Every hash call passes the manifest's executable set, so the Windows path from feat(init): install Netlify agent skills by default #8555 holds here.
  • Output: the summary line counts reset, renamed and removed alongside added, updated and failed. Kept and failed copies are listed, with a --reset-context hint only when reset would act on one of them.
  • src/commands/init/index.ts, init.ts: the --reset-context flag, passed through InitExtraOptions; resetContext is added to the analytics payload and the sites_agentSkillsSetup event. dev and watch are unchanged.
  • docs/commands/init.md: regenerated with the new flag.

Testing

  • tests/unit/utils/init/agent-skills.test.ts (54 tests, fetch stubbed): deprecated removed by exact and by prior name, edited deprecated kept then removed on reset, an edit made mid-run (while another skill downloads) keeps the deprecated copy; prior name migrated, removed as superseded, kept when either copy is edited, two prior names in one run; modified kept then reset, symlink reset without touching its target; leftover cleanup incl. a fresh staging directory left alone, an aged staging directory under a non-manifest name left alone, an edited backup kept; staged tree-hash mismatch refused; case twin with and without SKILL.md never replaced, with assertions for both case-sensitive and case-insensitive filesystems; an exact-name directory without SKILL.md left alone on reset; a backup kept while its skill is missing and removed once it is back; a prior name differing only by case renamed in place; a failed download recorded next to what landed; summary and hint lines.
  • tests/integration/commands/init/init.test.ts: the mock skills host now takes a manifest spec; shared init helpers hoisted. New test runs init against a stale, an edited and a deprecated copy (1 updated, 1 removed, 1 kept), then init --reset-context (1 reset). 9 tests pass.
  • npm run test:unit (717 tests), npm run typecheck, npm run lint and npm run format:check are clean.

Known gaps

  • The live manifest has no deprecated or renamed skill yet, so those paths are exercised by tests only.
  • Two netlify init runs in the same repo at once are not serialized. The age guard and the staged hash check turn that race into a failed install rather than a corrupt one; the next run completes it.

  • Open a bug/issue before writing your code 🧑‍💻 (tracked in Linear as EX-3055)
  • Read the contribution guidelines 📖
  • Update or add tests (if any source code was changed or added) 🧪
  • Update or add documentation (if features were changed or added) 📝
  • Make sure the status checks below are successful ✅

🤖 Generated with Claude Code

domitriusclark and others added 3 commits October 2, 2026 14:43
Removes unedited deprecated skills, migrates unedited copies under a
prior name to the current name, and cleans up staging and backup
directories left by an interrupted install. Edited copies are kept
and reported; --reset-context replaces, migrates or deletes them.
A directory that stops early still reports what it finished.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The occupant check now scans the directory itself, so a case twin
without SKILL.md can no longer be migrated over on reset, and the
renamed path never forces. Staging directories younger than ten
minutes are left to the run that owns them, and the assembled tree
must match the manifest tree_hash before it is swapped in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Drops the duplicate classification (an unknown directory is left
alone either way), the restore-a-backup branch (a missing skill is
reinstalled and the backup then removed as an unedited release), and
scopes the --reset-context hint to copies reset would act on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@domitriusclark
domitriusclark requested review from a team as code owners October 2, 2026 20:08
@coderabbitai

coderabbitai Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 779e78ba-c62e-4707-86eb-f987775a0a1f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/netlify-cli@8556

commit: 7db6117

Comment thread src/utils/init/agent-skills.ts Outdated
await fs.mkdir(path.dirname(output), { recursive: true })
await fs.writeFile(output, bytes, { mode: executable.has(file) ? 0o755 : 0o644 })
}
const stagedHash = await hashSkillTree(staged)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[blocking] This branch sits on #8555 from before the Windows fix in 3536976. Rebased, this check and hashIfPossible still call hashSkillTree without the executable set, so on Windows a skill with an executable file fails here and reads as edited everywhere else.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 10c72c4. Merged #8555 in; classification, the staged tree check and the pre-delete re-hash all pass the manifest's executable set now.

}
return skill
}
const stillUnedited = async (name: string, skill: ManifestSkill): Promise<boolean> =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[consider] Nothing tests this re-check. Every edited fixture is already marked modified at classification and gets kept before this runs, so stillUnedited could always return true and the suite would still pass.

It's the guard behind "re-hashed right before the rm" in the description, so a regression here would go unnoticed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added in 10c72c4: a stale skill's download writes an edit into netlify-legacy mid-run, and the deprecated copy is kept with the edit intact. The record was unedited at classification, so only the re-hash can produce that outcome.

await writeSkill(skillsDir, '.netlify-skill-netlify-deploy-Ab12Cd', { 'SKILL.md': '# half written\n' })
await writeSkill(skillsDir, 'netlify-functions.old-123-0123456789ab', FUNCTIONS_V1)
await writeSkill(skillsDir, 'netlify-deploy.old-123-0123456789ab', DEPLOY.files, DEPLOY.executable)
await writeSkill(skillsDir, '.netlify-skill-someone-else-Ab12Cd', { 'SKILL.md': '# not ours\n' })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[consider] This one survives because it's fresh, not because someone-else isn't a skill name. The known-name check on staging leftovers could be dropped and this test would still pass.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 10c72c4. The someone-else staging dir is aged past the guard too, so the manifest-name check is what protects it.

const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest, reset: true })

await expect(readFile(join(skillsDir, 'Netlify-Deploy', 'notes.md'), 'utf8')).resolves.toBe('# keep me\n')
await expect(listDirectories(skillsDir)).resolves.toContain('netlify-cli-and-deploy')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[blocking] This only holds on a case-insensitive filesystem, so it fails both Linux unit jobs.

On Linux Netlify-Deploy isn't a twin, so reset installs netlify-deploy beside it and migrates the edited prior-name copy, which is what the description says reset does. The code looks right here; the assertion assumes macOS.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 10c72c4. The test probes the tmp filesystem first; on Linux it asserts the migrate path you described, on macOS the occupant path.

domitriusclark and others added 4 commits October 5, 2026 13:07
--reset-context no longer forces over an exact-name directory that
has no SKILL.md. A backup is removed only once its skill is present
again and it has aged past the staging guard. A prior name that
differs from the current name only by case is renamed in place
instead of being reported as an occupant every run. Inode 0 is no
longer treated as a match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Windows returns readdir entries in a different order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants