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
43 changes: 39 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,14 @@ permissions:
issues: write

jobs:
# macOS ARM is a standalone job so publish-npm can depend on it alone,
# without being blocked by the Windows leg.
# macOS ARM is a standalone job so downstream sync jobs (sync-homebrew-cask,
# sync-skill-repo) can depend on it alone, without being blocked by the
# Windows leg. (An earlier publish-npm job used to be the reason this was
# split out; it was removed before ever running -- npm publishing was
# dropped as a distribution path in favor of the public
# NodeSpaceAI/nodespace-skill repo sync-skill-repo pushes to, so there is no
# publish-npm job to depend on it anymore. The split itself stayed useful
# for the reason above and was kept.)
build-tauri-macos-arm:
name: Build Tauri (macOS ARM)
runs-on: macos-latest
Expand Down Expand Up @@ -544,6 +550,35 @@ jobs:
# loudly here and notify-failure below opens an issue.
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}

sync-skill-repo:
name: Publish NodeSpace skill to nodespace-skill
runs-on: ubuntu-22.04
# Unlike sync-homebrew-cask/sync-homebrew-formula, this job needs no
# release asset -- scripts/publish-skill-repo.ts reads packages/skill/
# SKILL.md and references/cli.md straight from the checked-out repo at
# the release tag, so it doesn't wait on any build-* job and cannot be
# blocked by one of them failing.
if: github.event_name == 'release'

steps:
- name: Checkout repository
uses: actions/checkout@v7

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.11"

- name: Publish skill to nodespace-skill
run: bun run scripts/publish-skill-repo.ts ${{ github.event.release.tag_name }} --push
env:
# PAT (or fine-grained token) with contents:write on
# NodeSpaceAI/nodespace-skill -- secrets.GITHUB_TOKEN is scoped to
# this repo only and cannot push cross-repo. Must be added as a
# repo secret before this step can succeed; until then it fails
# loudly here and notify-failure below opens an issue.
SKILL_REPO_TOKEN: ${{ secrets.SKILL_REPO_TOKEN }}

sync-install-script:
name: Pin nodespace-website's install.sh to this release
runs-on: ubuntu-22.04
Expand Down Expand Up @@ -629,7 +664,7 @@ jobs:
# before this job existed there was only ever one writer per release.
# Running them concurrently would let either `git push` lose a
# non-fast-forward race against the other with no retry (see
# homebrew-tap-push.ts) -- ordering this job after sync-homebrew-cask
# push-to-external-repo.ts) -- ordering this job after sync-homebrew-cask
# finishes (success OR failure, hence `always()` below rather than
# gating on its result) removes that race entirely: this job's own
# fresh `git clone` always sees whatever sync-homebrew-cask already
Expand Down Expand Up @@ -661,7 +696,7 @@ jobs:
notify-failure:
name: Notify on build failure
runs-on: ubuntu-22.04
needs: [build-tauri-macos-arm, build-tauri, build-pkg-macos, build-headless, sync-homebrew-cask, sync-install-script, sync-homebrew-formula]
needs: [build-tauri-macos-arm, build-tauri, build-pkg-macos, build-headless, sync-homebrew-cask, sync-skill-repo, sync-install-script, sync-homebrew-formula]
if: failure()

steps:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 37 additions & 24 deletions packages/skill/README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,43 @@
# @nodespaceai/skill

Install the NodeSpace skill into PTY agents (Claude Code, Codex, Gemini CLI, OpenCode) with a single command.

## Installation

```bash
npx @nodespaceai/skill install
```

Detects which agents are present on your machine and installs the NodeSpace `SKILL.md` into each agent's skills directory.

## Usage
Installs the NodeSpace Agent Skill into PTY agents (Claude Code, Codex, Gemini CLI, OpenCode).

**This package is not published to npm.** `@nodespaceai/skill` never existed
on the npm registry, and it is not going to: the NodeSpace desktop app is the
one thing that installs this package's output, and it does so by running the
built installer directly with `bun` (see
`packages/desktop-app/src-tauri/src/skill_setup.rs`) — never `npx`/`npm`, so
publishing to npm was never actually required for the app's own install path.

If you're using an external agent harness yourself (not launched via the
NodeSpace app) and want the skill without installing NodeSpace first, import
the generated public repo instead:
**[NodeSpaceAI/nodespace-skill](https://github.com/NodeSpaceAI/nodespace-skill)**.
It carries a spec-compliant `skills/nodespace/` folder, regenerated and
pushed by this repo's release pipeline (`scripts/publish-skill-repo.ts`) on
every release — never hand-edited.

## What's in this package

This package's build output (`dist/`, `shims/`, `SKILL.md`, `references/`) is
consumed two ways, both inside this monorepo's own tooling:

1. **Bundled into the desktop app.** `scripts/build-skill.ts` stages it into
`packages/desktop-app/src-tauri/resources/skill/` as a Tauri resource. On
first launch, the app runs `bun dist/install.js install` to detect which
agents are present and copy `SKILL.md` (with the right frontmatter
prepended) into each one's skills directory.
2. **Published to `NodeSpaceAI/nodespace-skill`.** The release pipeline runs
`scripts/publish-skill-repo.ts`, which renders the same frontmatter this
package builds (via `buildSkillFrontmatter` in `src/agents.ts`) plus
`SKILL.md`'s body and `references/cli.md`, and pushes them to the public
repo — the channel for a harness the app didn't launch.

## Manual usage (from a source checkout)

```bash
# Install for all detected agents
npx @nodespaceai/skill install

# Install for a specific agent
npx @nodespaceai/skill install claude-code
npx @nodespaceai/skill install codex
npx @nodespaceai/skill install gemini
npx @nodespaceai/skill install opencode

# Uninstall
npx @nodespaceai/skill uninstall
npx @nodespaceai/skill uninstall claude-code
bun run --cwd packages/skill build
bun packages/skill/dist/install.js install
```

## Supported Agents
Expand All @@ -43,7 +56,7 @@ The `nodespace` CLI must be on `$PATH`. Install it via the [NodeSpace desktop ap
## Programmatic API

```ts
import { install, uninstall } from '@nodespaceai/skill';
import { install, uninstall } from './installer.js';

// Install for all detected agents
const results = install();
Expand Down
29 changes: 29 additions & 0 deletions packages/skill/src/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,32 @@ export const AGENTS: AgentConfig[] = [

/** The frontmatter block every target installs. Exported for tests. */
export const SHARED_SKILL_FRONTMATTER = SKILL_FRONTMATTER;

/**
* Builds the shared frontmatter block, optionally stamping the optional
* `compatibility` field (max 500 chars per the spec) in before the closing
* `---`.
*
* The installer path above never needs this — the app installs whatever
* `packages/skill` was built with, and SHARED_SKILL_FRONTMATTER already
* covers it — so this stays a separate, additive export rather than a
* change to AGENTS. `scripts/publish-skill-repo.ts` is the one caller: it
* passes the released NodeSpace app version (from the same release tag
* `scripts/update-homebrew-cask.ts` takes, itself derived from the
* `tauri.conf.json` canonical version `scripts/check-version-sync.ts`
* enforces) so a user importing the published skill can tell which app
* version a given revision targets.
*/
export function buildSkillFrontmatter(opts: { compatibility?: string } = {}): string {
const { compatibility } = opts;
if (!compatibility) return SKILL_FRONTMATTER;
if (compatibility.length > 500) {
throw new Error(
`compatibility exceeds the Agent Skills spec's 500-character limit ` +
`(${compatibility.length}): ${compatibility}`,
);
}
// Insert as the last field, right before the closing `---`, so every
// other field (and its ordering) stays identical to SHARED_SKILL_FRONTMATTER.
return SKILL_FRONTMATTER.replace(/\n---\n$/, `\ncompatibility: ${compatibility}\n---\n`);
}
17 changes: 12 additions & 5 deletions packages/skill/src/install.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
#!/usr/bin/env node
// Invoked directly via `bun <path-to-this-file> <command> [agent]` — the
// desktop app's bundled installer runs it this way (see
// packages/desktop-app/src-tauri/src/skill_setup.rs), and it's the same
// invocation for a manual run from a source checkout. Never `npx`/`npm`:
// `@nodespaceai/skill` is not published to npm (the public
// NodeSpaceAI/nodespace-skill repo is the distribution channel for external
// harnesses that want the skill outside the app — see packages/skill/README.md).
import { install, uninstall } from './installer.js';
import type { AgentName } from './types.js';
import { AGENTS } from './agents.js';
Expand All @@ -13,7 +20,7 @@ function isValidAgent(name: string): name is AgentName {
}

function printUsage(): void {
console.log(`Usage: npx @nodespaceai/skill <command> [agent]
console.log(`Usage: bun install.js <command> [agent]

Commands:
install [agent] Install NodeSpace skill for detected (or specified) agents
Expand All @@ -22,9 +29,9 @@ Commands:
Agents: ${validAgents.join(', ')}

Examples:
npx @nodespaceai/skill install
npx @nodespaceai/skill install claude-code
npx @nodespaceai/skill uninstall`);
bun install.js install
bun install.js install claude-code
bun install.js uninstall`);
}

if (!command || command === '--help' || command === '-h') {
Expand All @@ -46,7 +53,7 @@ if (command === 'install') {
if (results.length === 0) {
console.log('No supported agents detected.');
console.log(`Checked: ${validAgents.join(', ')}`);
console.log('To install manually, specify an agent: npx @nodespaceai/skill install <agent>');
console.log('To install manually, specify an agent: bun install.js install <agent>');
process.exit(0);
}

Expand Down
36 changes: 36 additions & 0 deletions packages/skill/src/tests/agents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { SHARED_SKILL_FRONTMATTER, buildSkillFrontmatter } from '../agents.js';

// buildSkillFrontmatter() is the escape hatch scripts/publish-skill-repo.ts uses
// to stamp a `compatibility` line onto the frontmatter it pushes to
// NodeSpaceAI/nodespace-skill, without touching the installer path (AGENTS still
// installs the plain SHARED_SKILL_FRONTMATTER, unchanged).
describe('buildSkillFrontmatter', () => {
it('returns SHARED_SKILL_FRONTMATTER unchanged when called with no options', () => {
expect(buildSkillFrontmatter()).toBe(SHARED_SKILL_FRONTMATTER);
});

it('returns SHARED_SKILL_FRONTMATTER unchanged when compatibility is omitted', () => {
expect(buildSkillFrontmatter({})).toBe(SHARED_SKILL_FRONTMATTER);
});

it('inserts a compatibility field right before the closing "---"', () => {
const result = buildSkillFrontmatter({ compatibility: 'Targets NodeSpace app v0.2.2' });
expect(result).toContain('\ncompatibility: Targets NodeSpace app v0.2.2\n---\n');
// Every other field from the shared block must still be present, unchanged.
expect(result).toContain('name: nodespace');
expect(result).toContain('allowed-tools: Bash(nodespace:*)');
// Exactly one "---\n" pair still closes the block (opening + closing).
expect(result.match(/^---$/gm)).toHaveLength(2);
});

it('rejects a compatibility string over the spec\'s 500-character limit', () => {
const tooLong = 'x'.repeat(501);
expect(() => buildSkillFrontmatter({ compatibility: tooLong })).toThrow(/500-character limit/);
});

it('accepts a compatibility string at exactly the 500-character limit', () => {
const atLimit = 'x'.repeat(500);
expect(() => buildSkillFrontmatter({ compatibility: atLimit })).not.toThrow();
});
});
128 changes: 128 additions & 0 deletions scripts/publish-skill-repo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Covers the offline, deterministic parts of scripts/publish-skill-repo.ts:
// version normalization and file rendering. pushSkillUpdate (the
// GitHub-talking function) is intentionally not exercised here -- this suite
// runs as part of `bun run test:scripts` / `test:all` (the pre-push gate),
// which must stay fast and deterministic, not depend on network or a real
// SKILL_REPO_TOKEN.
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import {
normalizeVersion,
readSkillSource,
renderPublishFiles,
sharedShimPaths,
SKILL_REPO,
} from "./publish-skill-repo";

const REPO_ROOT = join(dirname(new URL(import.meta.url).pathname), "..");

describe("normalizeVersion", () => {
test("strips a leading v", () => {
expect(normalizeVersion("v0.2.2")).toBe("0.2.2");
});

test("leaves a bare version untouched", () => {
expect(normalizeVersion("0.2.2")).toBe("0.2.2");
});
});

describe("SKILL_REPO", () => {
test("targets the public generated-only skill repo", () => {
expect(SKILL_REPO).toBe("NodeSpaceAI/nodespace-skill");
});
});

describe("readSkillSource", () => {
test("reads the live packages/skill/SKILL.md and references/cli.md off disk", () => {
const { body, referenceCli } = readSkillSource();
// Read independently (not via the function under test) so this actually
// catches the function reading a stale/wrong path, not just echoing it.
const expectedBody = readFileSync(
join(REPO_ROOT, "packages", "skill", "SKILL.md"),
"utf8",
);
const expectedReferenceCli = readFileSync(
join(REPO_ROOT, "packages", "skill", "references", "cli.md"),
"utf8",
);
expect(body).toBe(expectedBody);
expect(referenceCli).toBe(expectedReferenceCli);
});

// The checked-in SKILL.md body carries no frontmatter (renderPublishFiles
// is the one place that adds it) -- a body that already starts with `---`
// would double up frontmatter blocks in the published file.
test("the body has no baked-in frontmatter", () => {
const { body } = readSkillSource();
expect(body.startsWith("---")).toBe(false);
});
});

describe("sharedShimPaths", () => {
// Guards the drift class packages/skill/src/tests/installer.test.ts's
// "publishes every directory the agents install from" test guards
// elsewhere: this derives the published file set from AGENTS instead of a
// hardcoded list, so a shared file added to (or removed from) every
// agent's shims here gets picked up automatically -- and this test fails
// loudly if that derivation ever stops matching what today's AGENTS
// actually declares as shared.
test("is exactly SKILL.md and references/cli.md today", () => {
expect(sharedShimPaths().sort()).toEqual(["SKILL.md", "references/cli.md"]);
});

test("excludes every harness-specific shim", () => {
const shared = new Set(sharedShimPaths());
expect(shared.has("shims/claude-code/nodespace-hook.ts")).toBe(false);
expect(shared.has("shims/codex/nodespace-plugin.ts")).toBe(false);
expect(shared.has("shims/gemini/nodespace-handler.ts")).toBe(false);
expect(shared.has("shims/opencode/nodespace-plugin.ts")).toBe(false);
});
});

describe("renderPublishFiles", () => {
test("publishes exactly SKILL.md and references/cli.md under skills/nodespace/", () => {
const files = renderPublishFiles("v0.2.2");
expect(files.map((f) => f.relPath).sort()).toEqual([
"skills/nodespace/SKILL.md",
"skills/nodespace/references/cli.md",
]);
});

test("SKILL.md is spec-compliant frontmatter + the unmodified body", () => {
const files = renderPublishFiles("v0.2.2");
const skillMd = files.find((f) => f.relPath === "skills/nodespace/SKILL.md")!;
const { body } = readSkillSource();

expect(skillMd.content.startsWith("---\nname: nodespace\n")).toBe(true);
expect(skillMd.content).toContain(body);
// name must match the directory it publishes into.
expect(skillMd.relPath.split("/")[1]).toBe("nodespace");
});

test("stamps a compatibility field with the released app version, within the spec's 500-char limit", () => {
const files = renderPublishFiles("v0.2.2");
const skillMd = files.find((f) => f.relPath === "skills/nodespace/SKILL.md")!;
const m = /^compatibility:\s*(.+)$/m.exec(skillMd.content);
expect(m).toBeTruthy();
expect(m![1]).toContain("v0.2.2");
expect(m![1].length).toBeLessThanOrEqual(500);
});

test("normalizes a leading v the same way for the compatibility field", () => {
const withV = renderPublishFiles("v0.2.2").find(
(f) => f.relPath === "skills/nodespace/SKILL.md",
)!;
const withoutV = renderPublishFiles("0.2.2").find(
(f) => f.relPath === "skills/nodespace/SKILL.md",
)!;
expect(withV.content).toBe(withoutV.content);
});

test("references/cli.md is copied through verbatim", () => {
const files = renderPublishFiles("v0.2.2");
const referenceCli = files.find((f) => f.relPath === "skills/nodespace/references/cli.md")!;
const { referenceCli: expected } = readSkillSource();
expect(referenceCli.content).toBe(expected);
});
});
Loading