openvibers/openvibe.link Commits

skills sync: the skills and rules you chose in the hub, in each tool's own folders (#123)

OP OpenVibers committed 6 days ago · 52cd3d4202 · 10 files, +1625 −1

* skills sync: write the skills and rules you chose in the hub into each tool

openvibe-node skills sync reads the person's picks from openvibe.codes with
their access token and writes them into Claude Code, Codex, OpenCode, Command
Code and Aider's own folders, inside one marked block. A skill keeps its
frontmatter above the block; an existing file keeps its own header and the
person's text outside the block. The first write shows a diff and asks;
--dry-run changes nothing, --disable and --undo take the blocks out and remove
a file or folder the Node created. The state holds paths and versions, never a
skill's text.

MCP servers, commands and hooks stay hub records for now: each tool keeps them
in a different JSON or TOML config, so writing them needs a per-tool adapter.

* Merge origin/main into feat/node-skills-sync

Keep both the skills sync and the Exchange serve command in the usage and the
dispatcher.

* The sync state test skips Unix mode bits on Windows

Windows has no 0600 permission bits; the path is in the owner's config directory
and the mode assertion is Unix-only. The Windows job caught this.

Changes

ddiff --git a/README.md b/README.md
iindex 7077493..13a41e1 100644
---- a/README.md
++++ b/README.md
@@@ -52,6 +52,7 @@ openvibe-node tray # the same in your notification area, with Approve/Deny
openvibe-node memory scan # what your coding tools remember: read-only, local, secrets removed
openvibe-node memory upload # send what they remember to your own memory store, after you say yes
openvibe-node memory sync # write your profile and rules back into the tools, one marked block per file, opt-in per tool
++openvibe-node skills sync # write the skills and rules you chose at openvibe.codes/skills into each tool, with a dry run and undo
openvibe-node exchange serve --model qwen3:32b --public-url https://gpu.example.com/v1 # sell a local model
```
@@@ -102,7 +103,8 @@ turns on a feature you turned off here.
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
[memory](docs/memory.md) (what the coding tools on a machine remember, how the Node reads it and how it is uploaded to
--your own memory store).
++your own memory store), [skills](docs/skills.md) (the skills hub: which folders each tool reads, the marked block, the
++dry run and the undo).
Decisions: [ADR-043](docs/ADR-043-bot-devices-and-control.md).
## Development
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex 5b68d88..ccd2a94 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -14,6 +14,8 @@
// openvibe-node plugins list plugins and their capabilities
// openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
// read the memory files of the coding tools (read-only, local)
++// openvibe-node skills sync [--tools a,b] [--project DIR …] [--dry-run]
++// write the skills you chose in the hub into each tool's own folders
// openvibe-node version
package main
@@@ -84,6 +86,10 @@ Usage:
openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
read the memory files of the coding tools on this machine
(read-only, local, secrets removed)
++ openvibe-node skills sync [--tools a,b] [--project DIR …] [--dry-run] [--yes]
++ openvibe-node skills sync --undo
++ write the skills and rules you chose at openvibe.codes/skills
++ into each tool's own folders, inside one marked block
openvibe-node exchange serve --model <id> [--public-url URL]
serve a local model as an OpenVibe.Exchange seller offer
openvibe-node stream --probe --whip-url URL [--seconds N] [--size WxH] [--json]
@@@ -150,6 +156,10 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
// The memory scan has its own flags, so it parses its own arguments.
return cmdMemory(rest, stdin, stdout, stderr)
}
++ if cmd == "skills" {
++ // The sync has its own flags, so it parses its own arguments.
++ return cmdSkills(rest, stdin, stdout, stderr)
++ }
if cmd == "exchange" { // its own flags: serve, and the seller offer's
return cmdExchange(rest, stdout, stderr)
}
ddiff --git a/cmd/openvibe-node/skills_sync.go b/cmd/openvibe-node/skills_sync.go
nnew file mode 100644
iindex 0000000..b4238cc
---- /dev/null
++++ b/cmd/openvibe-node/skills_sync.go
@@@ -0,0 +1,515 @@
++package main
++
++import (
++ "bufio"
++ "context"
++ "errors"
++ "flag"
++ "fmt"
++ "io"
++ "os"
++ "path/filepath"
++ "sort"
++ "strings"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++ "github.com/OpenVibers/OpenVibe.Node/internal/memoryupload"
++ "github.com/OpenVibers/OpenVibe.Node/internal/skillsync"
++)
++
++// skillsSyncUsage is what "openvibe-node skills sync" prints.
++const skillsSyncUsage = "openvibe-node skills sync [--tools a,b] [--project DIR …] [--dry-run] [--yes]\n" +
++ "openvibe-node skills sync --disable a,b\n" +
++ "openvibe-node skills sync --undo\n" +
++ "\n" +
++ " Writes the skills and rules you chose in the OpenVibe skills hub\n" +
++ " (openvibe.codes/skills) into the folders each coding tool reads:\n" +
++ " ~/.claude/skills, ~/.codex/skills, ~/.config/opencode/skills,\n" +
++ " ~/.commandcode/skills, and each tool's CLAUDE.md/AGENTS.md/CONVENTIONS.md.\n" +
++ " It writes only inside its own marked block; your own text in those files\n" +
++ " is never changed. An existing SKILL.md keeps its frontmatter.\n" +
++ "\n" +
++ " Every tool is off until you turn it on. The first write to a file shows\n" +
++ " the diff and asks. --disable takes the blocks out again and turns the tool\n" +
++ " off; --undo does that for every tool that is on.\n" +
++ "\n" +
++ " --tools a,b turn these tools on: claude-code, codex, opencode,\n" +
++ " command-code, aider (with no --tools, the tools already on sync)\n" +
++ " --disable a,b turn these tools off and remove their blocks\n" +
++ " --undo remove every block this command wrote and turn the tools off\n" +
++ " --project DIR write this project's skills and rules as well; may be given more than once\n" +
++ " --dry-run show the diffs and write nothing\n" +
++ " --yes do not ask on the first write\n" +
++ " --state FILE the opt-in file (default: the Node's state directory)\n" +
++ " --endpoint URL the skills hub (default " + skillsync.DefaultEndpoint + ")\n" +
++ " --token-env NAME the variable the access token is read from (default " + skillsync.DefaultTokenEnv + ")\n" +
++ " --user-home DIR use this directory as the person's home (default: yours)\n"
++
++// cmdSkills runs the skills commands.
++func cmdSkills(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
++ if len(args) == 0 {
++ fmt.Fprint(stderr, skillsSyncUsage)
++ return 2
++ }
++ switch args[0] {
++ case "sync":
++ return cmdSkillsSync(args[1:], stdin, stdout, stderr)
++ default:
++ fmt.Fprintf(stderr, "unknown skills command %q\n\n", args[0])
++ fmt.Fprint(stderr, skillsSyncUsage)
++ return 2
++ }
++}
++
++func cmdSkillsSync(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
++ fs := flag.NewFlagSet("skills sync", flag.ContinueOnError)
++ fs.SetOutput(stderr)
++ fs.Usage = func() { fmt.Fprint(stderr, skillsSyncUsage) }
++ var tools stringList
++ var disable stringList
++ var projectFlags stringList
++ fs.Var(&tools, "tools", "turn these tools on")
++ fs.Var(&disable, "disable", "turn these tools off and remove their blocks")
++ fs.Var(&projectFlags, "project", "write this project as well")
++ dryRun := fs.Bool("dry-run", false, "show the diffs and write nothing")
++ assumeYes := fs.Bool("yes", false, "do not ask on the first write")
++ undo := fs.Bool("undo", false, "remove every block and turn the tools off")
++ stateFile := fs.String("state", "", "the opt-in file")
++ endpoint := fs.String("endpoint", skillsync.DefaultEndpoint, "the skills hub")
++ tokenEnv := fs.String("token-env", skillsync.DefaultTokenEnv, "the variable the access token is read from")
++ userHome := fs.String("user-home", "", "use this directory as the person's home")
++ if err := fs.Parse(args); err != nil {
++ return 2
++ }
++ if fs.NArg() > 0 {
++ fmt.Fprintf(stderr, "unknown argument %q\n\n", fs.Arg(0))
++ fmt.Fprint(stderr, skillsSyncUsage)
++ return 2
++ }
++ for _, id := range append(append([]string{}, tools...), disable...) {
++ if !skillsync.KnownTool(id) {
++ fmt.Fprintf(stderr, "error: no such tool %q. The tools are: %s\n", id, strings.Join(skillsync.ToolIDs(), ", "))
++ return 2
++ }
++ }
++ if *undo && len(tools) > 0 {
++ fmt.Fprintln(stderr, "error: --undo and --tools are not used together; --undo removes every block")
++ return 2
++ }
++ if len(tools) > 0 && len(disable) > 0 {
++ fmt.Fprintln(stderr, "error: --tools and --disable are not used together; run them in two steps")
++ return 2
++ }
++ home, code := memoryHome(*userHome, stderr)
++ if code != 0 {
++ return code
++ }
++ statePath := *stateFile
++ if statePath == "" {
++ statePath = filepath.Join(config.DefaultPaths("").StateDir, "skills-sync.json")
++ }
++ state, err := skillsync.LoadState(statePath)
++ if err != nil {
++ fmt.Fprintf(stderr, "error: the sync state %s cannot be read: %v\n", statePath, err)
++ return 1
++ }
++ projects, err := absProjects(projectFlags)
++ if err != nil {
++ fmt.Fprintln(stderr, "error:", err)
++ return 1
++ }
++
++ if *undo || len(disable) > 0 {
++ turnOff := disable
++ if *undo {
++ turnOff = state.EnabledTools()
++ if len(turnOff) == 0 {
++ fmt.Fprintln(stdout, "No tool is on, so there is no block to remove.")
++ return 0
++ }
++ }
++ return skillsRemove(home, projects, state, statePath, turnOff, *dryRun, stdout, stderr)
++ }
++
++ on := state.EnabledTools()
++ if len(tools) > 0 {
++ on = append([]string{}, tools...)
++ }
++ if len(on) == 0 {
++ fmt.Fprintln(stdout, "No tool is on: nothing was written.")
++ fmt.Fprintf(stdout, "Turn one on with --tools (%s). The first write shows its diff and asks.\n", strings.Join(skillsync.ToolIDs(), ", "))
++ return 0
++ }
++ sort.Strings(on)
++
++ person, byProject, err := fetchSkills(*endpoint, *tokenEnv, projects)
++ if err != nil {
++ fmt.Fprintln(stderr, "error:", err)
++ return 1
++ }
++ return skillsWrite(home, projects, person, byProject, state, statePath, on, *dryRun, *assumeYes, stdin, stdout, stderr)
++}
++
++// fetchSkills reads the person's own picks and each project's picks. It returns
++// the person's manifest and one manifest per project directory.
++func fetchSkills(endpoint, tokenEnv string, projects []string) ([]skillsync.Skill, map[string][]skillsync.Skill, error) {
++ token := strings.TrimSpace(os.Getenv(tokenEnv))
++ if token == "" {
++ return nil, nil, fmt.Errorf("%s is not set: it must hold your OpenVibe access token (sign in at openvibe.codes and copy it from the browser's cookies)", tokenEnv)
++ }
++ client, err := skillsync.NewClient(endpoint, token)
++ if err != nil {
++ return nil, nil, err
++ }
++ ctx := context.Background()
++ person, err := client.Manifest(ctx, "")
++ if err != nil {
++ return nil, nil, err
++ }
++ byProject := map[string][]skillsync.Skill{}
++ for _, project := range projects {
++ slug := memoryupload.Slug(project)
++ if slug == "" {
++ return nil, nil, fmt.Errorf("the folder %s has no name the hub can use", project)
++ }
++ found, err := client.Manifest(ctx, slug)
++ if err != nil {
++ return nil, nil, err
++ }
++ byProject[project] = found
++ }
++ return person, byProject, nil
++}
++
++// skillFile is one file the sync is about to look at.
++type skillFile struct {
++ target skillsync.Target
++ path string
++ kind string // "skill" or "rules"
++ slug string
++ frontmatter string
++ block string
++ tool string
++}
++
++// planFiles gives every file of the given tools for the home and the projects.
++// A file several tools share (a project's AGENTS.md is read by Codex, OpenCode
++// and Command Code) is planned once, with the rules that apply to any of them.
++func planFiles(home string, projects []string, person []skillsync.Skill,
++ byProject map[string][]skillsync.Skill, tools []string) []skillFile {
++ out := []skillFile{}
++ seenRules := map[string]bool{}
++ for _, id := range tools {
++ for _, target := range skillsync.TargetsFor(id) {
++ if target.Root != skillsync.RootHome {
++ continue
++ }
++ out = append(out, planFor(target, home, person, id, []string{id}, seenRules)...)
++ }
++ }
++ for _, project := range projects {
++ // Which enabled tools write this project's rules file: their rule
++ // choices all belong in the one file.
++ ruleTools := map[string][]string{}
++ for _, id := range tools {
++ for _, target := range skillsync.TargetsFor(id) {
++ if target.Root != skillsync.RootProject || target.RulesFile == "" {
++ continue
++ }
++ key := filepath.Join(project, filepath.FromSlash(target.RulesFile))
++ ruleTools[key] = append(ruleTools[key], id)
++ }
++ }
++ for _, id := range tools {
++ for _, target := range skillsync.TargetsFor(id) {
++ if target.Root != skillsync.RootProject {
++ continue
++ }
++ applicable := []string{id}
++ if target.RulesFile != "" {
++ key := filepath.Join(project, filepath.FromSlash(target.RulesFile))
++ if list := ruleTools[key]; len(list) > 0 {
++ applicable = list
++ }
++ }
++ out = append(out, planFor(target, project, byProject[project], id, applicable, seenRules)...)
++ }
++ }
++ }
++ return out
++}
++
++func planFor(target skillsync.Target, root string, manifest []skillsync.Skill, tool string,
++ ruleTools []string, seenRules map[string]bool) []skillFile {
++ out := []skillFile{}
++ rules := []string{}
++ for _, skill := range manifest {
++ switch skill.Kind {
++ case "skill":
++ if !skill.AppliesTo(tool) || target.SkillsDir == "" {
++ continue
++ }
++ block := skillsync.Block(skill)
++ if block == "" {
++ continue
++ }
++ out = append(out, skillFile{
++ target: target, tool: tool, kind: "skill", slug: skill.Slug,
++ path: filepath.Join(root, filepath.FromSlash(target.SkillsDir), skill.Slug, "SKILL.md"),
++ frontmatter: skillsync.Frontmatter(skill),
++ block: block,
++ })
++ case "rule":
++ if target.RulesFile == "" || !skill.AppliesToAny(ruleTools) {
++ continue
++ }
++ rules = append(rules, skill.Body)
++ }
++ }
++ if target.RulesFile != "" {
++ key := filepath.Join(root, filepath.FromSlash(target.RulesFile))
++ if seenRules[key] {
++ return out
++ }
++ seenRules[key] = true
++ out = append(out, skillFile{
++ target: target, tool: tool, kind: "rules",
++ path: key,
++ block: skillsync.RulesBlock(rules),
++ })
++ }
++ return out
++}
++
++// skillsWrite shows the diff of every changed file and writes it inside the
++// marked block, after the first-write gate.
++func skillsWrite(home string, projects []string, person []skillsync.Skill,
++ byProject map[string][]skillsync.Skill, state *skillsync.State, statePath string, tools []string,
++ dryRun, assumeYes bool, stdin io.Reader, stdout, stderr io.Writer) int {
++ fmt.Fprintf(stdout, "Skills sync of %s\n", home)
++ if len(projects) == 0 {
++ fmt.Fprintln(stdout, "No project given: only the user-level skill and rule folders are written.")
++ } else {
++ fmt.Fprintf(stdout, "Projects: %s\n", strings.Join(projects, ", "))
++ }
++ fmt.Fprintf(stdout, "Tools on: %s\n", strings.Join(tools, ", "))
++ planned := planFiles(home, projects, person, byProject, tools)
++ byTool := map[string][]skillFile{}
++ for _, p := range planned {
++ byTool[p.tool] = append(byTool[p.tool], p)
++ }
++
++ written, skipped, unchanged, removed := 0, 0, 0, 0
++ reader := bufio.NewReader(stdin)
++ paths := map[string]bool{}
++ for _, p := range planned {
++ paths[p.path] = true
++ }
++ for _, id := range tools {
++ // Files this tool wrote before but the manifest no longer names are stale:
++ // take their block out here, before the new files are written.
++ if t := state.Tools[id]; t != nil {
++ for path := range t.Files {
++ if paths[path] {
++ continue
++ }
++ n, err := removeBlockFile(home, state, id, path, dryRun, stdout)
++ if err != nil {
++ fmt.Fprintf(stderr, "error: %v\n", err)
++ skipped++
++ continue
++ }
++ if n {
++ removed++
++ }
++ }
++ }
++
++ for _, p := range byTool[id] {
++ existing, existed, err := readFile(p.path)
++ if err != nil {
++ fmt.Fprintf(stderr, "error: %v\n", err)
++ skipped++
++ continue
++ }
++ fileState := state.File(id, p.path)
++ var next []byte
++ var sep string
++ if p.block == "" {
++ next, _, err = skillsync.Remove(existing, fileState.Sep)
++ if err != nil {
++ fmt.Fprintf(stderr, "error: %s: %v\n", displayPath(home, p.path), err)
++ skipped++
++ continue
++ }
++ if !existed {
++ // Nothing to remove: do not create an empty rules file.
++ if fileState.Sep != "" || fileState.Created {
++ delete(state.Tool(id).Files, p.path)
++ }
++ continue
++ }
++ } else {
++ if !existed {
++ existing = []byte(p.frontmatter)
++ }
++ next, sep, _, err = skillsync.Replace(existing, p.block)
++ if err != nil {
++ fmt.Fprintf(stderr, "error: %s: %v\n", displayPath(home, p.path), err)
++ skipped++
++ continue
++ }
++ }
++ if string(next) == string(existing) {
++ unchanged++
++ continue
++ }
++ fmt.Fprintf(stdout, "\n%s\n", displayPath(home, p.path))
++ if p.target.Root == skillsync.RootProject {
++ fmt.Fprintln(stdout, " (this is a project file: it may be shared or committed)")
++ }
++ fmt.Fprint(stdout, skillsync.Diff(displayPath(home, p.path), existing, next))
++ if dryRun {
++ skipped++
++ continue
++ }
++ if !fileState.Confirmed {
++ if !assumeYes && !ask(reader, stdout, "Write this file for the first time? [y/N] ") {
++ fmt.Fprintln(stdout, "Not written: no confirmation.")
++ skipped++
++ continue
++ }
++ fileState.Confirmed = true
++ }
++ if err := writeFile(p.path, next); err != nil {
++ fmt.Fprintf(stderr, "error: %v\n", err)
++ return 1
++ }
++ fileState.Kind = p.kind
++ fileState.Slug = p.slug
++ fileState.Frontmatter = p.frontmatter
++ if !existed {
++ fileState.Created = true
++ }
++ if sep != "" {
++ fileState.Sep = sep
++ }
++ if err := state.Save(statePath); err != nil {
++ fmt.Fprintf(stderr, "error: the sync state %s cannot be saved: %v\n", statePath, err)
++ return 1
++ }
++ fmt.Fprintln(stdout, "Written, inside the OpenVibe skills block.")
++ written++
++ }
++ }
++
++ if !dryRun {
++ for _, id := range tools {
++ state.Tool(id).Enabled = true
++ }
++ if err := state.Save(statePath); err != nil {
++ fmt.Fprintf(stderr, "error: the sync state %s cannot be saved: %v\n", statePath, err)
++ return 1
++ }
++ }
++ fmt.Fprintf(stdout, "\n%d file(s) written, %d unchanged, %d not written, %d stale block(s) removed.\n", written, unchanged, skipped, removed)
++ if dryRun {
++ fmt.Fprintln(stdout, "Nothing was written: this was a dry run.")
++ }
++ return 0
++}
++
++// removeBlockFile takes one file's block out, removes the file when the Node
++// created it and nothing else is left, and forgets it in the state.
++func removeBlockFile(home string, state *skillsync.State, tool, path string, dryRun bool, stdout io.Writer) (bool, error) {
++ existing, existed, err := readFile(path)
++ if err != nil || !existed {
++ return false, err
++ }
++ fileState := state.FileStateOf(tool, path)
++ sep, created, frontmatter := "", false, ""
++ if fileState != nil {
++ sep, created, frontmatter = fileState.Sep, fileState.Created, fileState.Frontmatter
++ }
++ next, found, err := skillsync.Remove(existing, sep)
++ if err != nil || !found {
++ return false, err
++ }
++ fmt.Fprintf(stdout, "\n%s: removed the OpenVibe skills block\n", displayPath(home, path))
++ if dryRun {
++ return true, nil
++ }
++ if created && skillsync.OnlyOurContent(next, frontmatter) {
++ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
++ return false, err
++ }
++ // A skill folder the Node made is removed too when it is empty.
++ _ = os.Remove(filepath.Dir(path))
++ fmt.Fprintf(stdout, "%s: removed (the Node created it)\n", displayPath(home, path))
++ } else if err := writeFile(path, next); err != nil {
++ return false, err
++ }
++ delete(state.Tool(tool).Files, path)
++ return true, nil
++}
++
++// skillsRemove takes the block out of every file of the given tools and turns
++// them off.
++func skillsRemove(home string, projects []string, state *skillsync.State, statePath string, tools []string,
++ dryRun bool, stdout, stderr io.Writer) int {
++ fmt.Fprintf(stdout, "Skills sync undo of %s\n", home)
++ removed, missing, failed := 0, 0, 0
++ for _, id := range tools {
++ paths := map[string]bool{}
++ if t := state.Tools[id]; t != nil {
++ for path := range t.Files {
++ paths[path] = true
++ }
++ }
++ // Files that match a target but are not in the state are still tried:
++ // the state is a convenience, not the permission.
++ for _, target := range skillsync.TargetsFor(id) {
++ if target.Root == skillsync.RootHome {
++ if target.RulesFile != "" {
++ paths[filepath.Join(home, filepath.FromSlash(target.RulesFile))] = true
++ }
++ continue
++ }
++ for _, project := range projects {
++ if target.RulesFile != "" {
++ paths[filepath.Join(project, filepath.FromSlash(target.RulesFile))] = true
++ }
++ }
++ }
++ for path := range paths {
++ n, err := removeBlockFile(home, state, id, path, dryRun, stdout)
++ if err != nil {
++ fmt.Fprintf(stderr, "error: %v\n", err)
++ failed++
++ continue
++ }
++ if n {
++ removed++
++ } else {
++ missing++
++ }
++ }
++ if !dryRun {
++ state.Tool(id).Enabled = false
++ }
++ }
++ if !dryRun {
++ if err := state.Save(statePath); err != nil {
++ fmt.Fprintf(stderr, "error: the sync state %s cannot be saved: %v\n", statePath, err)
++ return 1
++ }
++ }
++ fmt.Fprintf(stdout, "\n%d block(s) removed, %d file(s) had none or were not there, %d failed.\n", removed, missing, failed)
++ if dryRun {
++ fmt.Fprintln(stdout, "Nothing was changed: this was a dry run.")
++ }
++ if failed > 0 {
++ return 1
++ }
++ return 0
++}
ddiff --git a/cmd/openvibe-node/skills_sync_test.go b/cmd/openvibe-node/skills_sync_test.go
nnew file mode 100644
iindex 0000000..ce0d79d
---- /dev/null
++++ b/cmd/openvibe-node/skills_sync_test.go
@@@ -0,0 +1,190 @@
++package main
++
++import (
++ "bytes"
++ "encoding/json"
++ "net/http"
++ "net/http/httptest"
++ "os"
++ "path/filepath"
++ "strings"
++ "sync/atomic"
++ "testing"
++)
++
++// skillManifest is the hub's answer; phase 1 has the demo skill, phase 2 does not.
++func skillManifest(project string, phase int32) map[string]any {
++ skills := []map[string]any{
++ {"id": "skl_1", "slug": "demo", "kind": "skill", "title": "Demo", "summary": "A demo skill", "version": 2,
++ "tools": []string{"claude-code"}, "body": "# Demo\n\nDo the thing.\n"},
++ {"id": "skl_2", "slug": "no-force-push", "kind": "rule", "title": "No force push", "version": 1,
++ "tools": []string{}, "body": "Never force-push a shared branch."},
++ {"id": "skl_3", "slug": "an-mcp", "kind": "mcp", "title": "An MCP server", "version": 1,
++ "tools": []string{}, "body": `{"command":"x"}`},
++ }
++ if project != "" {
++ skills = append(skills,
++ map[string]any{"id": "skl_4", "slug": "project-only", "kind": "skill", "title": "Project only", "version": 1,
++ "tools": []string{"claude-code"}, "body": "# Project only\n"},
++ map[string]any{"id": "skl_5", "slug": "project-rule", "kind": "rule", "title": "Project rule", "version": 1,
++ "tools": []string{}, "body": "Use tabs in this project."},
++ map[string]any{"id": "skl_6", "slug": "opencode-only", "kind": "rule", "title": "OpenCode only", "version": 1,
++ "tools": []string{"opencode"}, "body": "Only OpenCode needs this."},
++ )
++ }
++ if phase >= 2 {
++ skills = skills[1:]
++ }
++ return map[string]any{"generated_at": "2026-11-07T00:00:00Z", "skills": skills}
++}
++
++func TestSkillsSyncDryRunWriteAndUndo(t *testing.T) {
++ var phase atomic.Int32
++ var seenProject atomic.Value
++ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ if r.Header.Get("Authorization") != "Bearer test-token" {
++ w.WriteHeader(http.StatusUnauthorized)
++ return
++ }
++ project := r.URL.Query().Get("project")
++ seenProject.Store(project)
++ _ = json.NewEncoder(w).Encode(skillManifest(project, phase.Load()))
++ }))
++ defer srv.Close()
++
++ home := t.TempDir()
++ project := t.TempDir()
++ state := filepath.Join(t.TempDir(), "skills-sync.json")
++ t.Setenv("OV_TEST_SKILLS_TOKEN", "test-token")
++ args := []string{"--tools", "claude-code,codex,opencode", "--project", project, "--yes",
++ "--user-home", home, "--state", state, "--endpoint", srv.URL, "--token-env", "OV_TEST_SKILLS_TOKEN"}
++
++ // The person's own text in a rules file must survive every run and the undo.
++ notes := filepath.Join(home, ".claude", "CLAUDE.md")
++ if err := os.MkdirAll(filepath.Dir(notes), 0o755); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.WriteFile(notes, []byte("# My own notes\n\nKeep me.\n"), 0o644); err != nil {
++ t.Fatal(err)
++ }
++
++ // A dry run reads the hub and writes nothing at all.
++ var out, errOut bytes.Buffer
++ if code := cmdSkillsSync(append(args, "--dry-run"), strings.NewReader(""), &out, &errOut); code != 0 {
++ t.Fatalf("dry run exited %d: %s%s", code, out.String(), errOut.String())
++ }
++ if !strings.Contains(out.String(), "dry run") {
++ t.Fatalf("dry run did not say so: %s", out.String())
++ }
++ if _, err := os.Stat(filepath.Join(home, ".claude", "skills")); !os.IsNotExist(err) {
++ t.Fatalf("dry run created a skill folder: %v", err)
++ }
++ if _, err := os.Stat(state); !os.IsNotExist(err) {
++ t.Fatalf("dry run wrote the state: %v", err)
++ }
++ if seenProject.Load() == "" {
++ // The temp project's slug is its directory name; it only has to have been asked for.
++ t.Fatal("the project manifest was not asked for")
++ }
++
++ out.Reset()
++ errOut.Reset()
++ if code := cmdSkillsSync(args, strings.NewReader(""), &out, &errOut); code != 0 {
++ t.Fatalf("sync exited %d: %s%s", code, out.String(), errOut.String())
++ }
++ skill := filepath.Join(home, ".claude", "skills", "demo", "SKILL.md")
++ raw, err := os.ReadFile(skill)
++ if err != nil {
++ t.Fatalf("the skill file was not written: %v", err)
++ }
++ for _, want := range []string{"---\nname: \"demo\"", "A demo skill", skillsyncMarker(), "# Demo", "Do the thing."} {
++ if !strings.Contains(string(raw), want) {
++ t.Fatalf("skill file misses %q:\n%s", want, raw)
++ }
++ }
++ if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "demo", "SKILL.md")); !os.IsNotExist(err) {
++ t.Fatalf("a claude-code-only skill was written for Codex: %v", err)
++ }
++ codexRules := filepath.Join(home, ".codex", "AGENTS.md")
++ codexRaw, err := os.ReadFile(codexRules)
++ if err != nil || !strings.Contains(string(codexRaw), "Never force-push") {
++ t.Fatalf("the rule was not written for Codex: %v\n%s", err, codexRaw)
++ }
++ projectSkill := filepath.Join(project, ".claude", "skills", "project-only", "SKILL.md")
++ if _, err := os.Stat(projectSkill); err != nil {
++ t.Fatalf("the project skill was not written: %v", err)
++ }
++ projectRules := filepath.Join(project, "CLAUDE.md")
++ if raw, err := os.ReadFile(projectRules); err != nil || !strings.Contains(string(raw), "Use tabs in this project.") {
++ t.Fatalf("the project rule was not written: %v\n%s", err, raw)
++ }
++ // AGENTS.md is shared by Codex, OpenCode and Command Code: one block, and the rules for all of them.
++ shared, err := os.ReadFile(filepath.Join(project, "AGENTS.md"))
++ if err != nil || strings.Count(string(shared), skillsyncMarker()) != 1 {
++ t.Fatalf("the shared project AGENTS.md is not one block: %v\n%s", err, shared)
++ }
++ if !strings.Contains(string(shared), "Use tabs in this project.") || !strings.Contains(string(shared), "Only OpenCode needs this.") {
++ t.Fatalf("the shared project rules lost a tool's rule:\n%s", shared)
++ }
++ if strings.Count(string(shared), "openvibe-skills:start") != 1 {
++ t.Fatalf("the shared project AGENTS.md holds more than one block:\n%s", shared)
++ }
++ // The MCP record is kept in the hub but this sync does not write MCP configs.
++ if strings.Contains(out.String(), "an-mcp") {
++ t.Fatalf("the MCP record was reported as written: %s", out.String())
++ }
++ if raw, _ := os.ReadFile(notes); !strings.Contains(string(raw), "Keep me.") {
++ t.Fatalf("the person's notes were changed: %s", raw)
++ }
++
++ // A stale skill is removed when the manifest no longer names it.
++ phase.Store(2)
++ out.Reset()
++ errOut.Reset()
++ if code := cmdSkillsSync(args, strings.NewReader(""), &out, &errOut); code != 0 {
++ t.Fatalf("second sync exited %d: %s%s", code, out.String(), errOut.String())
++ }
++ if _, err := os.Stat(filepath.Join(home, ".claude", "skills", "demo")); !os.IsNotExist(err) {
++ t.Fatalf("the stale skill folder is still there: %v\nout: %s\nerr: %s", err, out.String(), errOut.String())
++ }
++
++ // Undo puts the person's file back exactly and removes what the Node made.
++ out.Reset()
++ errOut.Reset()
++ if code := cmdSkillsSync([]string{"--undo", "--user-home", home, "--project", project, "--state", state}, strings.NewReader(""), &out, &errOut); code != 0 {
++ t.Fatalf("undo exited %d: %s%s", code, out.String(), errOut.String())
++ }
++ if raw, _ := os.ReadFile(notes); string(raw) != "# My own notes\n\nKeep me.\n" {
++ t.Fatalf("undo did not restore the rules file: %q", raw)
++ }
++ for _, path := range []string{
++ filepath.Join(home, ".claude", "skills", "demo", "SKILL.md"),
++ filepath.Join(home, ".codex", "AGENTS.md"),
++ filepath.Join(project, ".claude", "skills", "project-only", "SKILL.md"),
++ filepath.Join(project, "CLAUDE.md"),
++ filepath.Join(project, "AGENTS.md"),
++ filepath.Join(home, ".config", "opencode", "AGENTS.md"),
++ } {
++ if _, err := os.Stat(path); !os.IsNotExist(err) {
++ t.Fatalf("undo left %s: %v", path, err)
++ }
++ }
++}
++
++func skillsyncMarker() string { return "<!-- openvibe-skills:start -->" }
++
++func TestSkillsSyncRefusesAnUnknownToolAndAMissingToken(t *testing.T) {
++ var out, errOut bytes.Buffer
++ if code := cmdSkillsSync([]string{"--tools", "nope"}, strings.NewReader(""), &out, &errOut); code != 2 {
++ t.Fatalf("unknown tool exited %d, want 2", code)
++ }
++ t.Setenv("OV_TEST_SKILLS_TOKEN", "")
++ out.Reset()
++ errOut.Reset()
++ if code := cmdSkillsSync([]string{"--tools", "claude-code", "--token-env", "OV_TEST_SKILLS_TOKEN", "--yes", "--user-home", t.TempDir(), "--state", filepath.Join(t.TempDir(), "s.json")}, strings.NewReader(""), &out, &errOut); code != 1 {
++ t.Fatalf("missing token exited %d, want 1\nout: %s\nerr: %s", code, out.String(), errOut.String())
++ }
++ if !strings.Contains(errOut.String(), "OV_TEST_SKILLS_TOKEN") {
++ t.Fatalf("missing token was not named: %s", errOut.String())
++ }
++}
ddiff --git a/docs/skills.md b/docs/skills.md
nnew file mode 100644
iindex 0000000..82186a6
---- /dev/null
++++ b/docs/skills.md
@@@ -0,0 +1,62 @@
++# Skills: what OpenVibe writes into your coding tools
++
++OpenVibe.Codes keeps skills, rules and MCP server records per person and per project
++([openvibe.codes/skills](https://openvibe.codes/skills)). This command copies the ones you chose into the folders the
++tools already read, on this machine, inside one marked block.
++
++```sh
++openvibe-node skills sync --tools claude-code,codex --project ~/code/my-project
++openvibe-node skills sync --tools claude-code --dry-run # the diffs, nothing written
++openvibe-node skills sync --undo # take every block out again
++```
++
++`OPENVIBE_TOKEN` must hold your OpenVibe access token (the same one `openvibe-node memory sync` uses). The sync asks
++`GET /api/v1/me/skills` on openvibe.codes, with `?project=<slug>` for a project, so it only ever sees what you chose
++there.
++
++## Where each tool reads
++
++| Tool | Skills | Rules |
++|---|---|---|
++| Claude Code | `~/.claude/skills/<slug>/SKILL.md` | `~/.claude/CLAUDE.md` |
++| Codex | `~/.codex/skills/<slug>/SKILL.md` | `~/.codex/AGENTS.md` |
++| OpenCode | `~/.config/opencode/skills/<slug>/SKILL.md` | `~/.config/opencode/AGENTS.md` |
++| Command Code | `~/.commandcode/skills/<slug>/SKILL.md` | `~/.commandcode/AGENTS.md` |
++| Aider | — | `<project>/CONVENTIONS.md` |
++
++In a project the sync writes `.claude/skills/`, `.codex/skills/` (or `.opencode/skills/`, `.commandcode/skills/`) and
++that project's `CLAUDE.md`/`AGENTS.md`/`CONVENTIONS.md`. A project file may be committed, so the command says so.
++
++## The marked block
++
++```
++---
++name: "ship-a-change"
++description: "Open a PR, wait for CI, and merge only when it is green."
++---
++<!-- openvibe-skills:start -->
++# Ship a change
++...
++<!-- openvibe-skills:end -->
++```
++
++The frontmatter is written only when the Node creates the file; an existing `SKILL.md` keeps its own header. Everything
++else the Node writes is inside the block, and the block's markers are scrubbed out of content before it is written, so a
++record cannot open or close a block. The rest of the file — your own notes above or below — is never changed.
++
++Every tool is off until you pass `--tools`. The first write to a file shows a line diff and asks; `--yes` skips the
++question. `--dry-run` reads the hub, shows the same diffs and writes nothing (not even the state file). `--undo` removes
++every block the Node wrote and deletes a file or skill folder the Node created when nothing else was in it; `--disable
++a,b` does that for named tools.
++
++## What is not written yet
++
++The hub stores MCP servers, slash commands and hooks too (`codes.skill@1` kind `mcp`, `command`, `hook`), and they can
++be published, versioned and assigned like a skill. This sync writes skills and rules only: each tool keeps MCP servers
++in a different JSON or TOML config (`~/.codex/config.toml`, OpenCode's `opencode.jsonc`, …), and writing those safely
++needs a per-tool adapter and its own dry run, so it is a follow-up rather than a half-written config here.
++
++## State
++
++The opt-in choices and what was written live in the Node's state directory, `skills-sync.json`, mode 0600. It holds
++paths and versions, never the text of a skill. `--state FILE` and `--user-home DIR` are for tests and unusual installs.
ddiff --git a/internal/skillsync/client.go b/internal/skillsync/client.go
nnew file mode 100644
iindex 0000000..fac94fa
---- /dev/null
++++ b/internal/skillsync/client.go
@@@ -0,0 +1,106 @@
++package skillsync
++
++import (
++ "context"
++ "encoding/json"
++ "fmt"
++ "io"
++ "net/http"
++ "net/url"
++ "regexp"
++ "strings"
++ "time"
++)
++
++// DefaultEndpoint is where the skills hub lives: OpenVibe.Codes.
++const DefaultEndpoint = "https://openvibe.codes"
++
++// DefaultTokenEnv is the environment variable holding the person's OpenVibe
++// access token (the same one OpenVibe Memory sync uses).
++const DefaultTokenEnv = "OPENVIBE_TOKEN"
++
++// MaxResponseBytes bounds the manifest a hostile or broken hub can make the
++// Node read: one skill is at most 128 KiB and a person syncs a handful.
++const MaxResponseBytes = 4 << 20
++
++var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
++
++// Client reads one person's sync manifest from OpenVibe.Codes.
++type Client struct {
++ base string
++ token string
++ http *http.Client
++}
++
++// NewClient checks the endpoint and keeps the token for the Authorization
++// header only; it is never logged or written.
++func NewClient(endpoint, token string) (*Client, error) {
++ endpoint = strings.TrimSpace(endpoint)
++ if endpoint == "" {
++ endpoint = DefaultEndpoint
++ }
++ u, err := url.Parse(endpoint)
++ if err != nil || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) {
++ return nil, fmt.Errorf("the skills endpoint must be an https origin (or localhost in development)")
++ }
++ if strings.TrimSpace(token) == "" {
++ return nil, fmt.Errorf("the access token is empty")
++ }
++ return &Client{base: strings.TrimRight(endpoint, "/"), token: token, http: &http.Client{Timeout: 20 * time.Second}}, nil
++}
++
++// Manifest reads what this person chose to sync plus this project's picks. An
++// empty project is the person's own scope.
++func (c *Client) Manifest(ctx context.Context, project string) ([]Skill, error) {
++ u := c.base + "/api/v1/me/skills"
++ if project != "" {
++ u += "?project=" + url.QueryEscape(project)
++ }
++ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
++ if err != nil {
++ return nil, err
++ }
++ req.Header.Set("Accept", "application/json")
++ req.Header.Set("Authorization", "Bearer "+c.token)
++ res, err := c.http.Do(req)
++ if err != nil {
++ return nil, fmt.Errorf("the skills hub could not be reached: %w", err)
++ }
++ defer res.Body.Close()
++ raw, err := io.ReadAll(io.LimitReader(res.Body, MaxResponseBytes+1))
++ if err != nil {
++ return nil, err
++ }
++ if len(raw) > MaxResponseBytes {
++ return nil, fmt.Errorf("the skills hub sent more than %d bytes; refusing it", MaxResponseBytes)
++ }
++ if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden {
++ return nil, fmt.Errorf("the skills hub refused the access token (%d): set %s to a current OpenVibe access token", res.StatusCode, DefaultTokenEnv)
++ }
++ if res.StatusCode != http.StatusOK {
++ detail := strings.TrimSpace(string(raw))
++ if len(detail) > 200 {
++ detail = detail[:200]
++ }
++ return nil, fmt.Errorf("the skills hub answered %d: %s", res.StatusCode, detail)
++ }
++ var body struct {
++ Skills []Skill `json:"skills"`
++ }
++ if err := json.Unmarshal(raw, &body); err != nil {
++ return nil, fmt.Errorf("the skills hub answered a body that is not a manifest: %w", err)
++ }
++ for i := range body.Skills {
++ s := &body.Skills[i]
++ if !slugPattern.MatchString(s.Slug) {
++ return nil, fmt.Errorf("the hub sent a skill with an unsafe folder name %q; refusing the whole manifest", s.Slug)
++ }
++ if s.Kind == "" {
++ return nil, fmt.Errorf("the hub sent skill %s without a kind", s.Slug)
++ }
++ if len([]rune(s.Body)) > MaxBodyRunes {
++ s.Body = string([]rune(s.Body)[:MaxBodyRunes])
++ }
++ }
++ return body.Skills, nil
++}
ddiff --git a/internal/skillsync/state.go b/internal/skillsync/state.go
nnew file mode 100644
iindex 0000000..67409c0
---- /dev/null
++++ b/internal/skillsync/state.go
@@@ -0,0 +1,148 @@
++package skillsync
++
++import (
++ "encoding/json"
++ "errors"
++ "os"
++ "path/filepath"
++)
++
++// State is what the person has turned on, kept on this machine only. It holds
++// paths and choices, never a skill's text.
++type State struct {
++ // Version is 1.
++ Version int `json:"version"`
++ // Tools is the per-tool opt-in and what was written for it.
++ Tools map[string]*ToolState `json:"tools,omitempty"`
++}
++
++// ToolState is one tool's opt-in state.
++type ToolState struct {
++ // Enabled is the person's choice for this tool. Off unless they said so.
++ Enabled bool `json:"enabled,omitempty"`
++ // Files are the files the tool's blocks were written into.
++ Files map[string]*FileState `json:"files,omitempty"`
++}
++
++// FileState is what the Node needs to undo one file.
++type FileState struct {
++ // Confirmed is true once the person has seen the diff and said yes.
++ Confirmed bool `json:"confirmed,omitempty"`
++ // Created is true when the Node created the file.
++ Created bool `json:"created,omitempty"`
++ // Sep is the separator the Node added before a block in a file that
++ // already had text (or "" when it added none). Undo removes exactly it.
++ Sep string `json:"sep,omitempty"`
++ // Kind is "skill" or "rules".
++ Kind string `json:"kind,omitempty"`
++ // Slug is the skill's folder name for a skill file.
++ Slug string `json:"slug,omitempty"`
++ // Frontmatter is the header the Node wrote when it created a skill file;
++ // undo uses it to tell a file that held only our content from one that did
++ // not.
++ Frontmatter string `json:"frontmatter,omitempty"`
++}
++
++// NewState is an empty state.
++func NewState() *State { return &State{Version: 1, Tools: map[string]*ToolState{}} }
++
++// LoadState reads the state file. A missing file is an empty state, never an
++// error: the first run has nothing turned on.
++func LoadState(path string) (*State, error) {
++ raw, err := os.ReadFile(path)
++ if errors.Is(err, os.ErrNotExist) {
++ return NewState(), nil
++ }
++ if err != nil {
++ return nil, err
++ }
++ st := NewState()
++ if err := json.Unmarshal(raw, st); err != nil {
++ return nil, err
++ }
++ if st.Tools == nil {
++ st.Tools = map[string]*ToolState{}
++ }
++ if st.Version == 0 {
++ st.Version = 1
++ }
++ return st, nil
++}
++
++// Save writes the state with owner-only permissions, through a temporary file
++// in the same directory so a crash never leaves half a state.
++func (s *State) Save(path string) error {
++ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
++ return err
++ }
++ raw, err := json.MarshalIndent(s, "", " ")
++ if err != nil {
++ return err
++ }
++ raw = append(raw, '\n')
++ tmp, err := os.CreateTemp(filepath.Dir(path), ".skills-sync-")
++ if err != nil {
++ return err
++ }
++ name := tmp.Name()
++ defer os.Remove(name)
++ if err := tmp.Chmod(0o600); err != nil {
++ tmp.Close()
++ return err
++ }
++ if _, err := tmp.Write(raw); err != nil {
++ tmp.Close()
++ return err
++ }
++ if err := tmp.Close(); err != nil {
++ return err
++ }
++ return os.Rename(name, path)
++}
++
++// Tool gives one tool's state, creating it when it is new.
++func (s *State) Tool(id string) *ToolState {
++ if s.Tools == nil {
++ s.Tools = map[string]*ToolState{}
++ }
++ t := s.Tools[id]
++ if t == nil {
++ t = &ToolState{Files: map[string]*FileState{}}
++ s.Tools[id] = t
++ }
++ if t.Files == nil {
++ t.Files = map[string]*FileState{}
++ }
++ return t
++}
++
++// File gives one file's state, creating it when it is new.
++func (s *State) File(tool, path string) *FileState {
++ t := s.Tool(tool)
++ f := t.Files[path]
++ if f == nil {
++ f = &FileState{}
++ t.Files[path] = f
++ }
++ return f
++}
++
++// FileStateOf gives one file's state without creating it.
++func (s *State) FileStateOf(tool, path string) *FileState {
++ t := s.Tools[tool]
++ if t == nil {
++ return nil
++ }
++ return t.Files[path]
++}
++
++// EnabledTools lists the tools the person turned on, in the fixed tool order.
++func (s *State) EnabledTools() []string {
++ out := []string{}
++ for _, id := range ToolIDs() {
++ if t := s.Tools[id]; t != nil && t.Enabled {
++ out = append(out, id)
++ }
++ }
++ return out
++}
ddiff --git a/internal/skillsync/sync.go b/internal/skillsync/sync.go
nnew file mode 100644
iindex 0000000..44a8961
---- /dev/null
++++ b/internal/skillsync/sync.go
@@@ -0,0 +1,286 @@
++package skillsync
++
++import (
++ "bytes"
++ "fmt"
++ "strings"
++)
++
++// Skill is one record the Node receives from the hub. Only Body, Summary,
++// Slug and Title are written; the rest tells the person what was chosen.
++type Skill struct {
++ ID string `json:"id"`
++ Slug string `json:"slug"`
++ Kind string `json:"kind"`
++ Title string `json:"title"`
++ Summary string `json:"summary"`
++ Audience string `json:"audience"`
++ Tools []string `json:"tools"`
++ Version int `json:"version"`
++ Body string `json:"body"`
++ UpdatedAt string `json:"updated_at"`
++}
++
++// AppliesTo says whether a record is written for this tool: an empty tools
++// list means every tool the Node knows.
++func (s Skill) AppliesTo(tool string) bool { return s.AppliesToAny([]string{tool}) }
++
++// AppliesToAny is AppliesTo for a file several tools share (a project's
++// AGENTS.md is read by Codex, OpenCode and Command Code): an empty tools list
++// or any one of them means the record belongs in that file.
++func (s Skill) AppliesToAny(tools []string) bool {
++ if len(s.Tools) == 0 {
++ return true
++ }
++ for _, t := range s.Tools {
++ for _, want := range tools {
++ if t == want {
++ return true
++ }
++ }
++ }
++ return false
++}
++
++// Frontmatter is the header a SKILL.md needs for the tool to find the skill.
++// It is written only when the Node creates the file; an existing file keeps
++// its own frontmatter untouched.
++func Frontmatter(s Skill) string {
++ description := strings.TrimSpace(s.Summary)
++ if description == "" {
++ description = strings.TrimSpace(s.Title)
++ }
++ if description == "" {
++ description = s.Slug
++ }
++ return "---\nname: " + yamlValue(s.Slug) + "\ndescription: " + yamlValue(description) + "\n---\n"
++}
++
++// yamlValue makes one YAML double-quoted scalar. A body is never put here and
++// a newline cannot escape a quoted scalar.
++func yamlValue(s string) string {
++ s = strings.Join(strings.Fields(s), " ")
++ s = strings.ReplaceAll(s, "\\", "\\\\")
++ s = strings.ReplaceAll(s, "\"", "\\\"")
++ return "\"" + s + "\""
++}
++
++// BodyOf strips a frontmatter block the body already carried and the markers,
++// leaving exactly what the block may hold.
++func BodyOf(s Skill) string {
++ body := strings.TrimSpace(s.Body)
++ if strings.HasPrefix(body, "---\n") {
++ rest := body[4:]
++ if i := strings.Index(rest, "\n---"); i >= 0 {
++ after := rest[i+4:]
++ body = strings.TrimPrefix(after, "\n")
++ }
++ }
++ body = Sanitize(strings.TrimSpace(body))
++ if len([]rune(body)) > MaxBodyRunes {
++ cut := string([]rune(body)[:MaxBodyRunes])
++ if i := strings.LastIndex(cut, "\n"); i > 0 {
++ cut = cut[:i]
++ }
++ body = cut + "\n\n_This skill is longer in OpenVibe; the block is shortened to fit._"
++ }
++ return body
++}
++
++// Block puts the markers around one skill's body. An empty body means the
++// block is not written (and an old one is removed).
++func Block(s Skill) string {
++ body := BodyOf(s)
++ if body == "" {
++ return ""
++ }
++ return StartMarker + "\n" + body + "\n" + EndMarker + "\n"
++}
++
++// RulesBlock is the one block a tool's rules file receives: every chosen rule,
++// one bullet each, in a stable order.
++func RulesBlock(rules []string) string {
++ clean := cleanRules(rules)
++ if len(clean) == 0 {
++ return ""
++ }
++ var b strings.Builder
++ b.WriteString("<!-- Managed by OpenVibe Node from your OpenVibe skills hub. Edit the rules in OpenVibe, not in this block. -->\n")
++ for _, rule := range clean {
++ b.WriteString("- " + rule + "\n")
++ }
++ return StartMarker + "\n" + strings.TrimRight(b.String(), "\n") + "\n" + EndMarker + "\n"
++}
++
++// Sanitize keeps content from opening or closing a block.
++func Sanitize(text string) string {
++ text = strings.ReplaceAll(text, StartMarker, "openvibe-skills start")
++ text = strings.ReplaceAll(text, EndMarker, "openvibe-skills end")
++ return strings.ReplaceAll(text, "openvibe-skills:", "openvibe skills")
++}
++
++func cleanRules(rules []string) []string {
++ out := make([]string, 0, len(rules))
++ seen := map[string]bool{}
++ for _, rule := range rules {
++ rule = strings.Join(strings.Fields(Sanitize(rule)), " ")
++ if len([]rune(rule)) > MaxRuleRunes {
++ rule = string([]rune(rule)[:MaxRuleRunes]) + "…"
++ }
++ if rule == "" || seen[rule] {
++ continue
++ }
++ seen[rule] = true
++ out = append(out, rule)
++ if len(out) == MaxRules {
++ break
++ }
++ }
++ return out
++}
++
++// find gives the block's bytes and where they are. It refuses a file with two
++// blocks or half a block: rewriting one of them could hide the person's text.
++func find(existing []byte) (start, end int, found bool, err error) {
++ start = bytes.Index(existing, []byte(StartMarker))
++ end = bytes.Index(existing, []byte(EndMarker))
++ switch {
++ case start < 0 && end < 0:
++ return 0, 0, false, nil
++ case start < 0 || end < 0:
++ return 0, 0, false, fmt.Errorf("the file holds half an OpenVibe skills block (a marker without its pair); leaving it alone")
++ case end < start:
++ return 0, 0, false, fmt.Errorf("the OpenVibe skills end marker comes before its start; leaving the file alone")
++ }
++ after := end + len(EndMarker)
++ if after < len(existing) && existing[after] == '\r' {
++ after++
++ }
++ if after < len(existing) && existing[after] == '\n' {
++ after++
++ }
++ if bytes.Contains(existing[after:], []byte(StartMarker)) || bytes.Contains(existing[after:], []byte(EndMarker)) {
++ return 0, 0, false, fmt.Errorf("the file holds more than one OpenVibe skills block; leaving it alone")
++ }
++ return start, after, true, nil
++}
++
++// Replace writes a block into a file: it replaces the one already there,
++// appends a new one, or leaves an empty block out. It gives back the file, the
++// separator it added before the block (for an exact undo), and whether the
++// bytes changed.
++func Replace(existing []byte, block string) (out []byte, sep string, changed bool, err error) {
++ start, end, found, err := find(existing)
++ if err != nil {
++ return nil, "", false, err
++ }
++ if block == "" {
++ return existing, "", false, fmt.Errorf("an empty block is not written; call Remove to take one out")
++ }
++ if found {
++ out = append(append(append([]byte{}, existing[:start]...), []byte(block)...), existing[end:]...)
++ return out, "", !bytes.Equal(out, existing), nil
++ }
++ var b bytes.Buffer
++ b.Write(existing)
++ switch {
++ case len(existing) == 0:
++ case bytes.HasSuffix(existing, []byte("\n\n")):
++ case bytes.HasSuffix(existing, []byte("\n")):
++ sep = "\n"
++ default:
++ sep = "\n\n"
++ }
++ b.WriteString(sep)
++ b.WriteString(block)
++ return b.Bytes(), sep, true, nil
++}
++
++// Remove takes the block out of a file and gives back the file, whether a block
++// was there, and the old separator it also removed. The rest of the file is
++// byte-identical.
++func Remove(existing []byte, sep string) (out []byte, found bool, err error) {
++ start, end, found, err := find(existing)
++ if err != nil || !found {
++ return existing, false, err
++ }
++ out = append(append([]byte{}, existing[:start]...), existing[end:]...)
++ if sep != "" && bytes.HasSuffix(out[:start], []byte(sep)) {
++ out = append(append([]byte{}, out[:start-len(sep)]...), out[start:]...)
++ }
++ return out, true, nil
++}
++
++// OnlyOurContent says whether a file holds nothing but the frontmatter this
++// package wrote and whitespace. Such a file is removed on undo.
++func OnlyOurContent(data []byte, frontmatter string) bool {
++ text := strings.TrimSpace(string(data))
++ if frontmatter != "" {
++ text = strings.TrimSpace(strings.TrimPrefix(text, strings.TrimSpace(frontmatter)))
++ }
++ return text == ""
++}
++
++// Diff is a small line diff for the person to read: three lines of context
++// around the part that changed. It is not a patch to apply; it is the gate
++// before the first write.
++func Diff(path string, before, after []byte) string {
++ oldLines := lines(before)
++ newLines := lines(after)
++ prefix := 0
++ for prefix < len(oldLines) && prefix < len(newLines) && oldLines[prefix] == newLines[prefix] {
++ prefix++
++ }
++ suffix := 0
++ for suffix < len(oldLines)-prefix && suffix < len(newLines)-prefix &&
++ oldLines[len(oldLines)-1-suffix] == newLines[len(newLines)-1-suffix] {
++ suffix++
++ }
++ context := 3
++ from := prefix - context
++ if from < 0 {
++ from = 0
++ }
++ to := prefix
++ oldEnd := len(oldLines) - suffix
++ newEnd := len(newLines) - suffix
++ oldTail := oldEnd + context
++ if oldTail > len(oldLines) {
++ oldTail = len(oldLines)
++ }
++ newTail := newEnd + context
++ if newTail > len(newLines) {
++ newTail = len(newLines)
++ }
++
++ var b strings.Builder
++ fmt.Fprintf(&b, "--- %s\n+++ %s\n", path, path)
++ fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", from+1, oldTail-from, from+1, newTail-from)
++ for _, line := range oldLines[from:to] {
++ b.WriteString(" " + line + "\n")
++ }
++ for _, line := range oldLines[to:oldEnd] {
++ b.WriteString("-" + line + "\n")
++ }
++ for _, line := range newLines[to:newEnd] {
++ b.WriteString("+" + line + "\n")
++ }
++ oldCtx := oldLines[oldEnd:oldTail]
++ newCtx := newLines[newEnd:newTail]
++ n := len(oldCtx)
++ if len(newCtx) < n {
++ n = len(newCtx)
++ }
++ for i := 0; i < n; i++ {
++ b.WriteString(" " + oldCtx[i] + "\n")
++ }
++ return b.String()
++}
++
++func lines(data []byte) []string {
++ if len(data) == 0 {
++ return nil
++ }
++ text := strings.TrimSuffix(string(data), "\n")
++ return strings.Split(text, "\n")
++}
ddiff --git a/internal/skillsync/sync_test.go b/internal/skillsync/sync_test.go
nnew file mode 100644
iindex 0000000..53b55e9
---- /dev/null
++++ b/internal/skillsync/sync_test.go
@@@ -0,0 +1,175 @@
++package skillsync
++
++import (
++ "context"
++ "encoding/json"
++ "net/http"
++ "net/http/httptest"
++ "os"
++ "path/filepath"
++ "runtime"
++ "strings"
++ "testing"
++)
++
++func TestBodyAndFrontmatter(t *testing.T) {
++ s := Skill{Slug: "ship-a-change", Title: "Ship a change", Summary: "Open a PR, wait for CI.\nBe careful.", Body: "---\nname: old\ndescription: old\n---\n\n# Ship\n\nDo it.\n"}
++ body := BodyOf(s)
++ if strings.Contains(body, "name: old") {
++ t.Fatalf("BodyOf kept the old frontmatter: %q", body)
++ }
++ if !strings.HasPrefix(body, "# Ship") {
++ t.Fatalf("BodyOf lost the body: %q", body)
++ }
++ fm := Frontmatter(s)
++ if !strings.HasPrefix(fm, "---\nname: \"ship-a-change\"\n") {
++ t.Fatalf("frontmatter = %q", fm)
++ }
++ if strings.Contains(fm, "\nBe careful.") || !strings.Contains(fm, "Be careful.") {
++ t.Fatalf("frontmatter is not one line: %q", fm)
++ }
++}
++
++func TestReplaceAndRemovePreserveThePerson(t *testing.T) {
++ user := []byte("# My own notes\n\nKeep me.\n")
++ block := Block(Skill{Slug: "x", Title: "X", Body: "The skill."})
++ next, sep, changed, err := Replace(user, block)
++ if err != nil || !changed {
++ t.Fatalf("Replace: changed=%v err=%v", changed, err)
++ }
++ if !strings.Contains(string(next), "Keep me.") || !strings.Contains(string(next), StartMarker) {
++ t.Fatalf("next = %q", next)
++ }
++ back, found, err := Remove(next, sep)
++ if err != nil || !found {
++ t.Fatalf("Remove: found=%v err=%v", found, err)
++ }
++ if string(back) != string(user) {
++ t.Fatalf("undo = %q, want %q", back, user)
++ }
++}
++
++func TestReplaceRejectsHalfABlock(t *testing.T) {
++ half := []byte("before\n" + StartMarker + "\ntext\n")
++ if _, _, _, err := Replace(half, Block(Skill{Slug: "x", Body: "y"})); err == nil {
++ t.Fatal("Replace accepted half a block")
++ }
++}
++
++func TestOnlyOurContent(t *testing.T) {
++ fm := Frontmatter(Skill{Slug: "x", Title: "X"})
++ block := Block(Skill{Slug: "x", Body: "body"})
++ if !OnlyOurContent([]byte(fm), fm) {
++ t.Fatal("a created skill file with only our content was not recognised")
++ }
++ if OnlyOurContent([]byte(fm+"\n"+block+"my notes\n"), fm) {
++ t.Fatal("a file with the person's text was treated as ours")
++ }
++}
++
++func TestRulesBlockSanitizesAndDeduplicates(t *testing.T) {
++ got := RulesBlock([]string{"never force-push", "never force-push", "a " + StartMarker + " b", "", "another"})
++ if strings.Count(got, StartMarker) != 1 || !strings.Contains(got, "never force-push") || !strings.Contains(got, "another") {
++ t.Fatalf("rules = %q", got)
++ }
++ if strings.Contains(got, "\n- never force-push") {
++ t.Fatalf("duplicate rules were not removed: %q", got)
++ }
++}
++
++func TestTargetsAndToolIDs(t *testing.T) {
++ found := map[string]bool{}
++ for _, target := range Targets() {
++ found[target.Tool+"/"+target.Root.String()] = true
++ }
++ for _, want := range []string{"claude-code/home", "claude-code/project", "codex/home", "opencode/home", "command-code/home", "aider/project"} {
++ if !found[want] {
++ t.Fatalf("missing target %s in %v", want, found)
++ }
++ }
++ for _, id := range ToolIDs() {
++ if !KnownTool(id) || len(TargetsFor(id)) == 0 {
++ t.Fatalf("tool %s is listed but has no target", id)
++ }
++ }
++ if _, ok := TargetForPath("/home/me/.claude/skills/demo/SKILL.md"); !ok {
++ t.Fatal("TargetForPath did not recognise a skill file")
++ }
++ if _, ok := TargetForPath("/home/me/project/CLAUDE.md"); !ok {
++ t.Fatal("TargetForPath did not recognise a project rules file")
++ }
++}
++
++func TestStateRoundTrip(t *testing.T) {
++ dir := t.TempDir()
++ path := filepath.Join(dir, "skills-sync.json")
++ st := NewState()
++ st.Tool("claude-code").Enabled = true
++ st.File("claude-code", "/home/me/.claude/CLAUDE.md").Sep = "\n"
++ if err := st.Save(path); err != nil {
++ t.Fatal(err)
++ }
++ info, err := os.Stat(path)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
++ // Windows has no Unix permission bits; the file is created in the owner's config directory.
++ t.Fatalf("state mode = %v", info.Mode().Perm())
++ }
++ back, err := LoadState(path)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if !back.Tool("claude-code").Enabled || back.FileStateOf("claude-code", "/home/me/.claude/CLAUDE.md").Sep != "\n" {
++ t.Fatalf("state did not round-trip: %+v", back)
++ }
++}
++
++func TestClientManifest(t *testing.T) {
++ var gotAuth, gotProject string
++ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ gotAuth = r.Header.Get("Authorization")
++ gotProject = r.URL.Query().Get("project")
++ _ = json.NewEncoder(w).Encode(map[string]any{
++ "skills": []map[string]any{
++ {"id": "skl_1", "slug": "demo", "kind": "skill", "title": "Demo", "body": "text", "version": 2, "tools": []string{"claude-code"}},
++ },
++ })
++ }))
++ defer srv.Close()
++ c, err := NewClient(srv.URL, "tok")
++ if err != nil {
++ t.Fatal(err)
++ }
++ skills, err := c.Manifest(context.Background(), "demo-project")
++ if err != nil {
++ t.Fatal(err)
++ }
++ if gotAuth != "Bearer tok" || gotProject != "demo-project" || len(skills) != 1 || skills[0].Version != 2 {
++ t.Fatalf("auth=%q project=%q skills=%+v", gotAuth, gotProject, skills)
++ }
++}
++
++func TestClientRefusesAnUnsafeSlug(t *testing.T) {
++ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ _ = json.NewEncoder(w).Encode(map[string]any{"skills": []map[string]any{{"slug": "../../etc", "kind": "skill", "body": "x"}}})
++ }))
++ defer srv.Close()
++ c, _ := NewClient(srv.URL, "tok")
++ if _, err := c.Manifest(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "unsafe folder name") {
++ t.Fatalf("an unsafe slug was accepted: %v", err)
++ }
++}
++
++func TestClientUnauthorizedIsExplained(t *testing.T) {
++ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ w.WriteHeader(http.StatusUnauthorized)
++ _, _ = w.Write([]byte(`{"code":"auth.required"}`))
++ }))
++ defer srv.Close()
++ c, _ := NewClient(srv.URL, "tok")
++ if _, err := c.Manifest(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "refused the access token") {
++ t.Fatalf("401 was not explained: %v", err)
++ }
++}
ddiff --git a/internal/skillsync/targets.go b/internal/skillsync/targets.go
nnew file mode 100644
iindex 0000000..fce7627
---- /dev/null
++++ b/internal/skillsync/targets.go
@@@ -0,0 +1,130 @@
++// Package skillsync writes the skills and rules a person chose in OpenVibe's
++// skills hub into the folders each coding tool already reads: Claude Code,
++// Codex, OpenCode and Command Code, with Aider's project conventions file.
++//
++// It writes only inside one clearly marked block, and it never touches the
++// person's hand-written text outside that block:
++//
++// <!-- openvibe-skills:start -->
++// ...
++// <!-- openvibe-skills:end -->
++//
++// A skill's SKILL.md keeps its frontmatter above the block, because the tools
++// read it there; the frontmatter is only written when the Node creates the
++// file, and never replaced in a file the person already had. Every file is
++// opt-in per tool, the first write shows a diff and asks, and undo puts the
++// file back the way it was (or removes it when it held only the Node's block).
++package skillsync
++
++import (
++ "path/filepath"
++ "strings"
++)
++
++// StartMarker and EndMarker delimit the block. Both are scrubbed from
++// everything written inside it, so content cannot open or close a block.
++const (
++ StartMarker = "<!-- openvibe-skills:start -->"
++ EndMarker = "<!-- openvibe-skills:end -->"
++)
++
++// MaxBodyRunes bounds one skill's body and one rules file, so a huge record
++// cannot freeze a tool reading its own instructions.
++const (
++ MaxBodyRunes = 131072
++ MaxRules = 200
++ MaxRuleRunes = 2000
++ MaxBlockRunes = 200000
++)
++
++// Root says where a target lives.
++type Root int
++
++const (
++ // RootHome is the person's home directory.
++ RootHome Root = iota
++ // RootProject is a project directory the person gave.
++ RootProject
++)
++
++// String is "home" or "project", for messages and tests.
++func (r Root) String() string {
++ if r == RootProject {
++ return "project"
++ }
++ return "home"
++}
++
++// Target is one tool's folders: where its skills go and which file its rules
++// are written into.
++type Target struct {
++ // Tool is the stable id of the tool: claude-code, codex, opencode,
++ // command-code, aider.
++ Tool string
++ // Name is how the tool is called.
++ Name string
++ // Root is where the folders live.
++ Root Root
++ // SkillsDir is the folder under that root holding one directory per skill,
++ // with forward slashes. Empty means the tool has no skills folder.
++ SkillsDir string
++ // RulesFile is the file under that root that receives one rules block,
++ // with forward slashes. Empty means the tool has no rules file.
++ RulesFile string
++}
++
++// Targets returns the files the sync may write, in a fixed order.
++func Targets() []Target {
++ return []Target{
++ {Tool: "claude-code", Name: "Claude Code", Root: RootHome, SkillsDir: ".claude/skills", RulesFile: ".claude/CLAUDE.md"},
++ {Tool: "claude-code", Name: "Claude Code", Root: RootProject, SkillsDir: ".claude/skills", RulesFile: "CLAUDE.md"},
++ {Tool: "codex", Name: "Codex", Root: RootHome, SkillsDir: ".codex/skills", RulesFile: ".codex/AGENTS.md"},
++ {Tool: "codex", Name: "Codex", Root: RootProject, SkillsDir: ".codex/skills", RulesFile: "AGENTS.md"},
++ {Tool: "opencode", Name: "OpenCode", Root: RootHome, SkillsDir: ".config/opencode/skills", RulesFile: ".config/opencode/AGENTS.md"},
++ {Tool: "opencode", Name: "OpenCode", Root: RootProject, SkillsDir: ".opencode/skills", RulesFile: "AGENTS.md"},
++ {Tool: "command-code", Name: "Command Code", Root: RootHome, SkillsDir: ".commandcode/skills", RulesFile: ".commandcode/AGENTS.md"},
++ {Tool: "command-code", Name: "Command Code", Root: RootProject, SkillsDir: ".commandcode/skills", RulesFile: "AGENTS.md"},
++ // Aider has no skills folder; its project instructions are CONVENTIONS.md.
++ {Tool: "aider", Name: "Aider", Root: RootProject, RulesFile: "CONVENTIONS.md"},
++ }
++}
++
++// ToolIDs are the tools a person may turn on.
++func ToolIDs() []string { return []string{"claude-code", "codex", "opencode", "command-code", "aider"} }
++
++// KnownTool says whether id names a tool this package writes.
++func KnownTool(id string) bool {
++ for _, tool := range ToolIDs() {
++ if tool == id {
++ return true
++ }
++ }
++ return false
++}
++
++// TargetsFor gives one tool's targets, home first.
++func TargetsFor(id string) []Target {
++ out := []Target{}
++ for _, t := range Targets() {
++ if t.Tool == id {
++ out = append(out, t)
++ }
++ }
++ return out
++}
++
++// TargetForPath finds the target a file belongs to, by the end of its path. It
++// is used on undo, where the state has the absolute path but the project may
++// not be named again on the command line.
++func TargetForPath(path string) (Target, bool) {
++ slash := filepath.ToSlash(path)
++ for _, t := range Targets() {
++ if t.RulesFile != "" && (slash == t.RulesFile || strings.HasSuffix(slash, "/"+t.RulesFile)) {
++ return t, true
++ }
++ if t.SkillsDir != "" && (strings.HasPrefix(slash, t.SkillsDir+"/") || strings.Contains(slash, "/"+t.SkillsDir+"/")) {
++ return t, true
++ }
++ }
++ return Target{}, false
++}