Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(skills): follow symlinked skill directories
`nullclaw skills list` skipped any skills/ entry whose kind was a symlink, so a shared canonical skill repo linked into each workspace was invisible. Directory walks now follow a symlink when the target is a directory and skip broken or non-directory targets. Archive installs still reject symlink entries inside downloaded archives.

Fixes #995
  • Loading branch information
vernonstinebaker committed Sep 24, 2026
commit 507f1230c88f239af2774eaf57f181190babf9a4
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ JK:- Gateway API: [`en/gateway-api.md`](./en/gateway-api.md) / [`zh/gateway-api.
KS:
## Topical Docs

- Skills: [`en/skills.md`](./en/skills.md) / [`zh/skills.md`](./zh/skills.md)
- External channel plugins: [`en/external-channels.md`](./en/external-channels.md) / [`zh/external-channels.md`](./zh/external-channels.md)
- Ops runbooks:
- [`en/ops/dingtalk-ops-readiness.md`](./en/ops/dingtalk-ops-readiness.md)
Expand Down
1 change: 1 addition & 0 deletions docs/en/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ QV:- [Beginner's Guide](./beginners-guide.md) ← start here if you are new to
- [Security](./security.md)
- [Gateway API](./gateway-api.md)
- [External Channel Plugins](./external-channels.md)
- [Skills](./skills.md)
- [Commands](./commands.md)
- [Development](./development.md)

Expand Down
97 changes: 97 additions & 0 deletions docs/en/skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Skills

NullClaw supports extensible skill packs — self-contained modules that add new capabilities to the agent.

## CLI Commands

```bash
nullclaw skills list # List installed skills
nullclaw skills install <url> # Install from GitHub URL
nullclaw skills remove <name> # Remove an installed skill
nullclaw skills info <name> # Show skill metadata
```

## Skill Structure

Skills live in `~/.nullclaw/workspace/skills/<name>/` and must contain a manifest:

```
skills/
my-skill/
SKILL.md # YAML frontmatter + description (preferred)
skill.json # or JSON manifest
build.zig # optional Zig build (enhanced compatibility scoring)
root.zig # optional Zig entry point
```

### SKILL.md Format

```markdown
---
name: my-skill
version: 1.0.0
description: Does something useful
---

Detailed skill documentation here.
```

### skill.json Format

```json
{
"name": "my-skill",
"version": "1.0.0",
"description": "Does something useful"
}
```

## Symlinked skill directories

A skill directory in the workspace `skills/` folder may be a symbolic link.
Keep one canonical copy of your skills in a git repo or synced folder, and
place a symlink per agent or host:

```bash
ln -s /srv/git/my-skills/git-helper ~/.nullclaw/workspace/skills/git-helper
```

`nullclaw skills list` and the agent follow such links like normal skill
directories; broken links are ignored. Because `SKILL.md` is a cross-runtime
format, the same canonical copy can also be linked into other agents that read
it (e.g. ZeroClaw or Hermes Agent workspaces). Skills installed from
web-downloaded archives are unaffected — the archive security audit still
rejects symlink entries inside archives.

## SkillForge (Auto-Discovery)

SkillForge can automatically discover and evaluate skills from GitHub.

```json
{
"skillforge": {
"enabled": true,
"auto_integrate": true,
"scan_interval_hours": 24,
"min_score": 0.7,
"output_dir": "./skills"
}
}
```

### Scoring

Skills are evaluated on a weighted scale:

| Factor | Weight | Scoring |
|--------|--------|---------|
| Compatibility | 30% | Zig/Rust = 1.0, Python/TS/JS = 0.6, others = 0.3 |
| Quality | 35% | Log-scale based on GitHub stars |
| Security | 35% | +0.3 for license, -0.5 for suspicious patterns |

Recommendations: `auto` (score >= 0.7), `manual` (0.4-0.7), `skip` (< 0.4).

## Related

- [Commands](./commands.md) — Full CLI reference
- [Configuration](./configuration.md) — Config reference
1 change: 1 addition & 0 deletions docs/zh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ BP:- [新手入门指南](./beginners-guide.md) ← 第一次接触 NullClaw,
- [安全机制](./security.md)
- [Gateway API](./gateway-api.md)
- [外部渠道插件](./external-channels.md)
- [技能包](./skills.md)
- [命令参考](./commands.md)
- [开发指南](./development.md)

Expand Down
54 changes: 54 additions & 0 deletions docs/zh/skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 技能包(Skills)

NullClaw 支持可扩展的技能包,为 agent 添加新能力。

## CLI 命令

```bash
nullclaw skills list # 列出已安装技能
nullclaw skills install <url> # 从 GitHub URL 安装
nullclaw skills remove <name> # 移除技能
nullclaw skills info <name> # 查看技能元数据
```

## 技能结构

技能位于 `~/.nullclaw/workspace/skills/<name>/`,需包含 manifest:

- `SKILL.md` — YAML frontmatter + 描述(推荐)
- `skill.json` — JSON manifest
- `build.zig` / `root.zig` — 可选 Zig 构建文件

### 跨 agent、跨主机共享技能(符号链接)

工作区 `skills/` 目录中的技能目录可以是**符号链接**。把技能的唯一权威副本
放在 git 仓库或同步目录中,然后为每个 agent / 主机放置一个链接:

```bash
ln -s /srv/git/my-skills/git-helper ~/.nullclaw/workspace/skills/git-helper
```

`nullclaw skills list` 和 agent 会像普通技能目录一样跟随这些链接;失效的
链接会被忽略。由于 `SKILL.md` 是跨运行时的格式,同一份权威副本也可以链接
到其他读取该格式的 agent(例如 ZeroClaw 或 Hermes Agent 的工作区)。从网络
安装包安装的技能不受影响 —— 安装包安全审计仍然拒绝包内的符号链接条目。

## SkillForge(自动发现)

```json
{
"skillforge": {
"enabled": true,
"auto_integrate": true,
"scan_interval_hours": 24,
"min_score": 0.7
}
}
```

自动从 GitHub 发现并评估技能,按兼容性(30%)、质量(35%)、安全性(35%)加权评分。

## 相关页面

- [命令参考](./commands.md)
- [配置指南](./configuration.md)
77 changes: 75 additions & 2 deletions src/skills.zig
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,21 @@ fn checkBinaryExists(allocator: std.mem.Allocator, bin_name: []const u8) bool {
/// Scan workspace_dir/skills/ for direct skill directories and one level of
/// category subdirectories, loading each discovered entry as a Skill.
/// Returns owned slice; caller must free with freeSkills().
/// True when a skills-directory entry names a directory, following a symlink
/// to its target. Skill directories may be symlinks so a single canonical
/// copy (e.g. a shared skills repo) can serve many agents and hosts — each
/// placing a link inside its own workspace (upstream #995). Broken or
/// non-directory targets resolve to false and are skipped. This covers only
/// user-placed entries: the archive audit still rejects symlink entries
/// inside web-installed skill archives.
fn entryResolvesToDirectory(parent_path: []const u8, name: []const u8) bool {
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ parent_path, name }) catch return false;
var d = fs_compat.openDirPath(path, .{}) catch return false;
d.close();
return true;
}

pub fn listSkills(allocator: std.mem.Allocator, workspace_dir: []const u8, observer: ?observability.Observer) ![]Skill {
const skills_dir_path = try std.fmt.allocPrint(allocator, "{s}/skills", .{workspace_dir});
defer allocator.free(skills_dir_path);
Expand All @@ -734,7 +749,12 @@ pub fn listSkills(allocator: std.mem.Allocator, workspace_dir: []const u8, obser

var it = dir_mut.iterate();
while (try it.next()) |entry| {
if (entry.kind != .directory) continue;
const is_skill_dir = switch (entry.kind) {
.directory => true,
.sym_link => entryResolvesToDirectory(skills_dir_path, entry.name),
else => false,
};
if (!is_skill_dir) continue;

const sub_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ skills_dir_path, entry.name });
defer allocator.free(sub_path);
Expand Down Expand Up @@ -769,7 +789,12 @@ fn scanCategoryDir(

var cat_it = cat_dir_mut.iterate();
while (try cat_it.next()) |entry| {
if (entry.kind != .directory) continue;
const is_skill_dir = switch (entry.kind) {
.directory => true,
.sym_link => entryResolvesToDirectory(category_path, entry.name),
else => false,
};
if (!is_skill_dir) continue;

const nested_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ category_path, entry.name });
defer allocator.free(nested_path);
Expand Down Expand Up @@ -3436,6 +3461,54 @@ test "listSkills discovers skills in subdirectories" {
try std.testing.expect(found_beta);
}

test "listSkills follows symlinked skill directories" {
// Regression (upstream #995): a symlinked skill directory was skipped by
// the kind != .directory filter, so a skill shared from a canonical repo
// (one symlink per agent) was invisible to `nullclaw skills list`.
if (@import("builtin").os.tag == .windows) return error.SkipZigTest; // symlink creation needs privileges

const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const wrap = @import("compat").fs.Dir.wrap(tmp.dir);

// Canonical skill kept outside the workspace skills dir (e.g. a shared repo).
try wrap.makePath("canonical-repo/git-helper");
{
const f = try wrap.createFile("canonical-repo/git-helper/skill.json", .{});
defer f.close();
try f.writeAll("{\"name\": \"git-helper\", \"version\": \"1.0.0\", \"description\": \"Shared skill\", \"author\": \"repo\"}");
}

// Workspace: one real skill (control) + one symlinked skill + one broken link.
try wrap.makePath("skills/local-only");
{
const f = try wrap.createFile("skills/local-only/skill.json", .{});
defer f.close();
try f.writeAll("{\"name\": \"local-only\", \"version\": \"1.0.0\", \"description\": \"Local skill\", \"author\": \"dev\"}");
}
const base = try wrap.realpathAlloc(allocator, ".");
defer allocator.free(base);
const canonical = try std.fmt.allocPrint(allocator, "{s}/canonical-repo/git-helper", .{base});
defer allocator.free(canonical);
try wrap.symLink(canonical, "skills/git-helper", .{});
try wrap.symLink("/nonexistent-skill-target", "skills/broken-link", .{});

const skills = try listSkills(allocator, base, null);
defer freeSkills(allocator, skills);

try std.testing.expectEqual(@as(usize, 2), skills.len);
var found_shared = false;
var found_local = false;
for (skills) |s| {
if (std.mem.eql(u8, s.name, "git-helper")) found_shared = true;
if (std.mem.eql(u8, s.name, "local-only")) found_local = true;
try std.testing.expect(!std.mem.eql(u8, s.name, "broken-link"));
}
try std.testing.expect(found_shared);
try std.testing.expect(found_local);
}

test "listSkills discovers skills nested inside a category directory" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
Expand Down
Loading