diff --git a/AGENTS.md b/AGENTS.md index f7abf04..9ea9122 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,7 +226,7 @@ Key points: ### Skill Name Validation -Names must match `^[a-z0-9]+(-[a-z0-9]+)*$` and be 1-64 characters. +Names must match `^[a-z0-9]+([.-][a-z0-9]+)*$` and be 1-64 characters. Dots and hyphens are both valid segment separators; dots enable namespace-style names (e.g. `myorg.bootstrap`). ### Registry Paths diff --git a/CHANGELOG.md b/CHANGELOG.md index 53789a3..e0a46aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Dot-namespaced skill names are now valid.** `ValidateName` accepts dots as + a segment separator in addition to hyphens, so names like `myorg.bootstrap` + and `codebase-intelligence.scan` pass validation. The hyphen-only form + (`code-review`) remains valid; this is a strict superset of the previous + regex, no migration required. Enables installers to preserve their + namespace prefix on platforms that derive slash commands from directory + names (e.g. GitHub Copilot maps `.copilot/skills/myorg.bootstrap/` to + `/myorg.bootstrap`). ([#92]) + +[#92]: https://github.com/devrimcavusoglu/skern/issues/92 + ## [v0.3.0] — 2026-05-07 Platform registry rewrite, five new adapters, agent-instruction snippet diff --git a/docs/concepts/skill-format.md b/docs/concepts/skill-format.md index 30e42c3..ce0c2d0 100644 --- a/docs/concepts/skill-format.md +++ b/docs/concepts/skill-format.md @@ -46,7 +46,7 @@ The main technique or pattern (before/after for techniques). | Field | Required | Description | |-------|----------|-------------| -| `name` | Yes | Skill name matching `[a-z0-9]+(-[a-z0-9]+)*`, 1-64 chars. Must equal the directory name. | +| `name` | Yes | Skill name matching `[a-z0-9]+([.-][a-z0-9]+)*`, 1-64 chars. Hyphens and dots are both valid separators (`code-review`, `myorg.bootstrap`). Must equal the directory name. | | `description` | Yes | What the skill does — start with "Use when…". Max 1024 chars. | | `tags` | No | List of classification tags | | `allowed-tools` | No | Tools the skill may use. No empty entries. | diff --git a/docs/reference/validation.md b/docs/reference/validation.md index 5f2446b..9d9546f 100644 --- a/docs/reference/validation.md +++ b/docs/reference/validation.md @@ -18,9 +18,9 @@ The summary line counts each separately, e.g. `Skill "x" has 1 error(s), 0 warni ### Name Format -Skill names must match `[a-z0-9]+(-[a-z0-9]+)*` and be 1–64 characters. +Skill names must match `[a-z0-9]+([.-][a-z0-9]+)*` and be 1–64 characters. Lowercase alphanumeric segments are joined by hyphens or dots; dots enable namespace-style names used by skill installers. -Valid: `code-review`, `lint-fix`, `deploy`. Invalid: `Code_Review`, `my skill`. +Valid: `code-review`, `lint-fix`, `deploy`, `myorg.bootstrap`, `codebase-intelligence.scan`. Invalid: `Code_Review`, `my skill`, `.bootstrap`, `myorg..bootstrap`. ### Description diff --git a/docs/writing-skills.md b/docs/writing-skills.md index 23d9651..09ec265 100644 --- a/docs/writing-skills.md +++ b/docs/writing-skills.md @@ -56,7 +56,7 @@ description: | - Prefer verb-first active voice: `creating-skills` not `skill-creation` - Be specific: `condition-based-waiting` not `async-test-helpers` -Names must match `^[a-z0-9]+(-[a-z0-9]+)*$` and be 1-64 characters. +Names must match `^[a-z0-9]+([.-][a-z0-9]+)*$` and be 1-64 characters. Hyphens are the conventional separator (`code-review`); dots are also accepted for namespace-style names used by skill installers (`myorg.bootstrap`). ## Recommended Body Structure diff --git a/internal/cli/e2e_test.go b/internal/cli/e2e_test.go index 1f39b02..5233206 100644 --- a/internal/cli/e2e_test.go +++ b/internal/cli/e2e_test.go @@ -251,6 +251,49 @@ func TestEndToEnd_MultiSkillWorkflow(t *testing.T) { require.NoError(t, err) } +// TestEndToEnd_DotNamespacedName is a regression test for #92: dot-namespaced +// skill names (e.g. `myorg.bootstrap`) must survive the full create → validate +// → install → uninstall → remove lifecycle, with the literal dot preserved in +// the on-disk directory name in the registry and on installed platforms. +func TestEndToEnd_DotNamespacedName(t *testing.T) { + cc, userDir, _ := testRegistryWithDirs(t) + home := t.TempDir() + project := t.TempDir() + withTestDetector(t, cc, home, project) + + const skillName = "myorg.bootstrap" + + _, err := runCmd(t, cc, "skill", "create", skillName, + "--description", "Use when bootstrapping a myorg project") + require.NoError(t, err) + + registryDir := filepath.Join(userDir, skillName) + _, err = os.Stat(filepath.Join(registryDir, "SKILL.md")) + require.NoError(t, err, "registry directory should contain literal dot in name") + + out, err := runCmd(t, cc, "skill", "validate", skillName, "--json") + require.NoError(t, err) + var validateResult output.SkillValidateResult + require.NoError(t, json.Unmarshal([]byte(out), &validateResult)) + assert.True(t, validateResult.Valid) + + _, err = runCmd(t, cc, "skill", "install", skillName, "--platform", "claude-code") + require.NoError(t, err) + installed := filepath.Join(home, ".claude", "skills", skillName, "SKILL.md") + _, err = os.Stat(installed) + require.NoError(t, err, "installed directory should contain literal dot in name") + + _, err = runCmd(t, cc, "skill", "uninstall", skillName, "--platform", "claude-code") + require.NoError(t, err) + _, err = os.Stat(installed) + assert.True(t, os.IsNotExist(err)) + + _, err = runCmd(t, cc, "skill", "remove", skillName) + require.NoError(t, err) + _, err = os.Stat(registryDir) + assert.True(t, os.IsNotExist(err)) +} + // TestEndToEnd_OverlapAndValidation tests the overlap detection and validation // flows work correctly across the full lifecycle. func TestEndToEnd_OverlapAndValidation(t *testing.T) { diff --git a/internal/skill/skill.go b/internal/skill/skill.go index 7606605..12a373a 100644 --- a/internal/skill/skill.go +++ b/internal/skill/skill.go @@ -18,8 +18,11 @@ const ( ScopeProject Scope = "project" ) -// nameRegex validates skill names: lowercase alphanumeric with hyphens, 1-64 chars. -var nameRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) +// nameRegex validates skill names: lowercase alphanumeric segments joined by +// hyphens or dots as separators, 1-64 chars. Dots enable namespace-style names +// (e.g. "myorg.bootstrap") so installers can preserve their prefix across +// platforms that map directory names to slash commands. +var nameRegex = regexp.MustCompile(`^[a-z0-9]+([.-][a-z0-9]+)*$`) // Author represents the creator of a skill. type Author struct { @@ -62,7 +65,7 @@ func ValidateName(name string) error { return fmt.Errorf("skill name cannot exceed 64 characters") } if !nameRegex.MatchString(name) { - return fmt.Errorf("skill name %q is invalid: must match [a-z0-9]+(-[a-z0-9]+)* (lowercase alphanumeric with hyphens)", name) + return fmt.Errorf("skill name %q is invalid: must match [a-z0-9]+([.-][a-z0-9]+)* (lowercase alphanumeric segments joined by hyphens or dots)", name) } return nil } diff --git a/internal/skill/skill_test.go b/internal/skill/skill_test.go index 08bf2a5..ed080d8 100644 --- a/internal/skill/skill_test.go +++ b/internal/skill/skill_test.go @@ -19,6 +19,10 @@ func TestValidateName(t *testing.T) { {"long hyphenated", "my-really-long-skill-name", false}, {"single char", "a", false}, {"max length 64", "a234567890123456789012345678901234567890123456789012345678901234", false}, + // Dot-namespaced names (per #92): dots are valid segment separators. + {"dot-namespaced", "myorg.bootstrap", false}, + {"dot and hyphen mixed", "codebase-intelligence.scan", false}, + {"multi-segment dotted", "a.b.c", false}, {"empty", "", true}, {"too long 65", "a2345678901234567890123456789012345678901234567890123456789012345", true}, {"uppercase", "MySkill", true}, @@ -28,7 +32,12 @@ func TestValidateName(t *testing.T) { {"trailing hyphen", "skill-", true}, {"double hyphen", "my--skill", true}, {"special chars", "skill@name", true}, - {"dot", "my.skill", true}, + // Dot-separator placement: same shape rules as hyphen. + {"consecutive dots", "myorg..bootstrap", true}, + {"leading dot", ".bootstrap", true}, + {"trailing dot", "bootstrap.", true}, + {"dot then hyphen", "myorg.-foo", true}, + {"hyphen then dot", "myorg-.foo", true}, } for _, tt := range tests {