openvibers/openvibe.link Commits
skills sync: the skills and rules you chose in the hub, in each tool's own folders (#123)
Changes
@@@ -52,6 +52,7 @@ openvibe-node tray # the same in your notification area, with Approve/Denyopenvibe-node memory scan # what your coding tools remember: read-only, local, secrets removedopenvibe-node memory upload # send what they remember to your own memory store, after you say yesopenvibe-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 undoopenvibe-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 driverprotocol, 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@@@ -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 versionpackage 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 blockopenvibe-node exchange serve --model <id> [--public-url URL]serve a local model as an OpenVibe.Exchange seller offeropenvibe-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'sreturn cmdExchange(rest, stdout, stderr)}nnew file mode 100644@@@ -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++}nnew file mode 100644@@@ -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())++ }++}nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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++}nnew file mode 100644@@@ -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++}nnew file mode 100644@@@ -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")++}nnew file mode 100644@@@ -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)++ }++}nnew file mode 100644@@@ -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++}
@@ -52,6 +52,7 @@ openvibe-node tray # the same in your notification area, with Approve/Deny
@@ -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 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 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 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
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.
@@ -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
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),
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
[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).
Decisions: [ADR-043](docs/ADR-043-bot-devices-and-control.md).
## Development
## Development
@@ -14,6 +14,8 @@
@@ -14,6 +14,8 @@
// openvibe-node plugins list plugins and their capabilities
// openvibe-node plugins list plugins and their capabilities
// openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
// openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
// read the memory files of the coding tools (read-only, local)
// 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
// openvibe-node version
package main
package main
@@ -84,6 +86,10 @@ Usage:
@@ -84,6 +86,10 @@ Usage:
openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
read the memory files of the coding tools on this machine
read the memory files of the coding tools on this machine
(read-only, local, secrets removed)
(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]
openvibe-node exchange serve --model <id> [--public-url URL]
serve a local model as an OpenVibe.Exchange seller offer
serve a local model as an OpenVibe.Exchange seller offer
openvibe-node stream --probe --whip-url URL [--seconds N] [--size WxH] [--json]
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
@@ -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.
// The memory scan has its own flags, so it parses its own arguments.
return cmdMemory(rest, stdin, stdout, stderr)
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
if cmd == "exchange" { // its own flags: serve, and the seller offer's
return cmdExchange(rest, stdout, stderr)
return cmdExchange(rest, stdout, stderr)
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,515 @@
@@ -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
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -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())
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -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
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -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
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,286 @@
@@ -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")
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -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)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -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
+}