Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ npx reasonix code --dir /path/to/project # or use a relative path

Mid-session switching isn't supported by design (the message log + memory paths get tangled with stale roots). Quit and relaunch with a new `--dir` to retarget. `/status` always shows the current pinned workspace.

**Author your first skill:** Skills are markdown playbooks the model can invoke (`/skill <name>`). There's no remote registry yet — you author them directly:

```bash
/skill new my-skill # scaffolds <project>/.reasonix/skills/my-skill.md
/skill new my-skill --global # or under ~/.reasonix/skills for cross-project use
```

Edit the file (`description:` frontmatter + body), then `/skill list` to see it. Add `runAs: subagent` to the frontmatter to spawn an isolated subagent loop instead of inlining the body.

<br/>

## What makes Reasonix different
Expand Down
9 changes: 9 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ npx reasonix code --dir /path/to/project # 也可以用相对路径

中途切换工作区是有意不支持的(消息日志和 memory 路径会和旧的根目录混在一起,状态错乱)。退出后用新的 `--dir` 重新启动来切换。`/status` 始终显示当前锁定的工作区。

**写第一个 Skill:** Skills 是模型可以调用的 markdown 剧本(`/skill <name>`)。暂无在线市场 —— 自己写:

```bash
/skill new my-skill # 在 <project>/.reasonix/skills/my-skill.md 生成模板
/skill new my-skill --global # 或者放到 ~/.reasonix/skills,跨项目共用
```

编辑文件(`description:` frontmatter + 正文),然后 `/skill list` 就能看到。frontmatter 里加 `runAs: subagent` 会以独立 subagent 跑,而不是把正文内联进父 prompt。

<br/>

## Reasonix 的不同之处
Expand Down
4 changes: 2 additions & 2 deletions src/cli/ui/slash/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
},
{
cmd: "skill",
argsHint: "[list|show <name>|<name> [args]]",
summary: "list / run user skills (<project>/.reasonix/skills + ~/.reasonix/skills)",
argsHint: "[list|show <name>|new <name>|<name> [args]]",
summary: "list / run / scaffold user skills (<project>/.reasonix/skills + ~/.reasonix/skills)",
},
{
cmd: "hooks",
Expand Down
19 changes: 18 additions & 1 deletion src/cli/ui/slash/handlers/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ const skill: SlashHandler = (args, _loop, ctx) => {
const store = new SkillStore({ projectRoot: ctx.codeRoot });
const sub = (args[0] ?? "").toLowerCase();

if (sub === "new" || sub === "init") {
const name = args[1];
if (!name) return { info: t("handlers.skill.newUsage") };
const wantsGlobal = args.slice(2).includes("--global") || !ctx.codeRoot;
const result = store.create(name, wantsGlobal ? "global" : "project");
if ("error" in result) {
return { info: t("handlers.skill.newError", { reason: result.error }) };
}
return { info: t("handlers.skill.newCreated", { name, path: result.path }) };
}

if (sub === "" || sub === "list" || sub === "ls") {
const skills = store.list();
if (skills.length === 0) {
Expand All @@ -17,7 +28,13 @@ const skill: SlashHandler = (args, _loop, ctx) => {
if (!store.hasProjectScope()) {
lines.push(t("handlers.skill.listProjectOnly"));
}
lines.push("", t("handlers.skill.listFrontmatter"), t("handlers.skill.listInvoke"));
lines.push(
"",
t("handlers.skill.listFrontmatter"),
t("handlers.skill.listInvoke"),
"",
t("handlers.skill.listEmptyNewHint"),
);
return { info: lines.join("\n") };
}
const lines = [t("handlers.skill.listHeader", { count: skills.length })];
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/EN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,11 +827,16 @@ export const EN: TranslationSchema = {
listInvoke:
"Invoke a skill with `/skill <name> [args]` or by asking the model to call `run_skill`.",
listHeader: "User skills ({count}):",
listFooter: "View body: /skill show <name> Run: /skill <name> [args]",
listFooter: "View: /skill show <name> Run: /skill <name> [args] New: /skill new <name>",
listEmptyNewHint:
"Scaffold one with: /skill new <name> (project scope) — there's no remote registry yet; you author skills directly.",
showUsage: "usage: /skill show <name>",
showNotFound: "no skill found: {name}",
runNotFound: "no skill found: {name} (try /skill list)",
runInfo: "▸ running skill: {name}{args}",
newUsage: "usage: /skill new <name> [--global]",
newCreated: "▸ created skill: {name}\n {path}\n edit it, then `/skill {name}` to invoke",
newError: "▲ /skill new failed: {reason}",
},
},
};
7 changes: 6 additions & 1 deletion src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,11 +762,16 @@ export const zhCN: TranslationSchema = {
listFrontmatter: "每个文件的 frontmatter 至少需要 `name` 和 `description`。",
listInvoke: "使用 `/skill <name> [args]` 调用技能,或让模型调用 `run_skill`。",
listHeader: "用户技能({count}):",
listFooter: "查看正文:/skill show <name> 运行:/skill <name> [args]",
listFooter: "查看:/skill show <name> 运行:/skill <name> [args] 新建:/skill new <name>",
listEmptyNewHint:
"用 `/skill new <name>` 在项目范围下生成一个空白模板 — 暂无在线市场,技能需要自己写。",
showUsage: "用法:/skill show <name>",
showNotFound: "未找到技能:{name}",
runNotFound: "未找到技能:{name} (尝试 /skill list)",
runInfo: "▸ 正在运行技能:{name}{args}",
newUsage: "用法:/skill new <name> [--global]",
newCreated: "▸ 已创建技能:{name}\n {path}\n 编辑后用 `/skill {name}` 调用",
newError: "▲ /skill new 失败:{reason}",
},
},
};
51 changes: 49 additions & 2 deletions src/skills.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/** Project scope wins over global. Only names+descriptions enter the prefix; bodies load lazily into the append-only log. */

import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { NEGATIVE_CLAIM_RULE, TUI_FORMATTING_RULES } from "./prompt-fragments.js";

export const SKILLS_DIRNAME = "skills";
Expand Down Expand Up @@ -133,6 +133,35 @@ export class SkillStore {
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
}

/** Scaffold a new skill stub at the chosen scope. Refuses to overwrite. */
create(name: string, scope: "project" | "global"): { path: string } | { error: string } {
if (!isValidSkillName(name)) {
return { error: `invalid skill name: "${name}" — use letters, digits, _, -, .` };
}
if (scope === "project" && !this.projectRoot) {
return { error: "project scope requires a workspace — run from `reasonix code`" };
}
const root =
scope === "project"
? join(this.projectRoot ?? "", ".reasonix", SKILLS_DIRNAME)
: join(this.homeDir, ".reasonix", SKILLS_DIRNAME);
const flat = join(root, `${name}.md`);
const folder = join(root, name, SKILL_FILE);
if (existsSync(folder)) {
return { error: `skill "${name}" already exists at ${folder}` };
}
mkdirSync(dirname(flat), { recursive: true });
try {
writeFileSync(flat, skillStubBody(name), { encoding: "utf8", flag: "wx" });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
return { error: `skill "${name}" already exists at ${flat}` };
}
throw err;
}
return { path: flat };
}

/** Resolve one skill by name. Returns `null` if not found or malformed. */
read(name: string): Skill | null {
if (!isValidSkillName(name)) return null;
Expand Down Expand Up @@ -197,6 +226,24 @@ function parseRunAs(raw: string | undefined): SkillRunAs {
return raw?.trim() === "subagent" ? "subagent" : "inline";
}

/** Stub markdown for `/skill new` — minimal frontmatter + scaffolding the user fills in. */
function skillStubBody(name: string): string {
return `---
name: ${name}
description: One-liner — what does this skill do?
---

# ${name}

Replace this body with the playbook the model should follow when this skill is invoked.

Tips:
- Reference tools by name (run_command, edit_file, search_content, ...)
- Add \`runAs: subagent\` to frontmatter to spawn an isolated subagent loop
- Add \`allowed-tools: read_file, search_content\` to scope a subagent's tools
`;
}

/** Subagent tag goes AFTER the name in brackets — leading-marker tags get copied into `name` arg verbatim. */
function skillIndexLine(s: Pick<Skill, "name" | "description" | "runAs">): string {
const safeDesc = s.description.replace(/\n/g, " ").trim();
Expand Down
39 changes: 39 additions & 0 deletions tests/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,45 @@ describe("SkillStore", () => {
const list = new SkillStore({ homeDir: home, projectRoot, disableBuiltins: true }).list();
expect(list.map((s) => s.name)).toEqual(["ok"]);
});

describe("create() — /skill new scaffold (#366)", () => {
it("writes a project-scope stub when projectRoot is set", () => {
const store = new SkillStore({ homeDir: home, projectRoot, disableBuiltins: true });
const r = store.create("frontend-writer", "project");
expect("path" in r).toBe(true);
const list = store.list();
const made = list.find((s) => s.name === "frontend-writer");
expect(made?.scope).toBe("project");
expect(made?.description).toMatch(/one-liner/i);
});

it("falls back to global scope when projectRoot is absent", () => {
const store = new SkillStore({ homeDir: home, disableBuiltins: true });
const r = store.create("global-skill", "global");
expect("path" in r).toBe(true);
const list = store.list();
expect(list.find((s) => s.name === "global-skill")?.scope).toBe("global");
});

it("refuses to overwrite an existing skill", () => {
const store = new SkillStore({ homeDir: home, projectRoot, disableBuiltins: true });
store.create("dup", "project");
const second = store.create("dup", "project");
expect("error" in second).toBe(true);
});

it("rejects invalid skill names", () => {
const store = new SkillStore({ homeDir: home, projectRoot, disableBuiltins: true });
const r = store.create("../etc/passwd", "project");
expect("error" in r).toBe(true);
});

it("refuses project scope when no projectRoot is configured", () => {
const store = new SkillStore({ homeDir: home, disableBuiltins: true });
const r = store.create("nope", "project");
expect("error" in r).toBe(true);
});
});
});

describe("applySkillsIndex", () => {
Expand Down
Loading