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
25 changes: 23 additions & 2 deletions packages/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

NodeSpace is a local-first knowledge graph that stores notes, tasks, and structured data on your machine. Use it to persist information across sessions, build personal knowledge bases, and retrieve context from previous work.

## Preflight Check

**Before starting any multi-step NodeSpace operation**, run these two commands to confirm the tooling is present and healthy:

```bash
nodespace --version
nodespace diagnostics
```

Run this preflight once per session or task, not before every individual command.

### Failure recovery

| Symptom | Cause | Recovery |
|---------|-------|----------|
| `command not found: nodespace` | CLI not installed or not on `$PATH` | Tell the user: NodeSpace CLI is not installed. They need to install it (e.g. via the NodeSpace DMG or `cargo install nodespace-cli`). Do not proceed. |
| `Could not connect to nodespaced` | Daemon not running | Surface the CLI's own message to the user: start the daemon with `nodespaced`. Do not retry automatically — wait for confirmation. |
| `diagnostics` shows entries in `errors` | Database issues | Report the specific error messages to the user before continuing. |

## When to Use NodeSpace

- **Store notes or findings**: Save research, decisions, or summaries you'll want later
Expand All @@ -13,7 +32,7 @@ NodeSpace is a local-first knowledge graph that stores notes, tasks, and structu

NodeSpace daemon must be running. The `nodespace` CLI communicates with `nodespaced` over a Unix socket. If the daemon is not running, CLI commands will fail with a connection error.

Start the daemon: `nodespace daemon start` (or it starts automatically on login if installed via DMG).
Start the daemon: `nodespaced` (or it starts automatically on login if installed via DMG).

## CLI Reference

Expand Down Expand Up @@ -123,7 +142,9 @@ nodespace node create --type note --content "Decision: use REST not GraphQL" --p
### Build a knowledge graph session

```bash
# At session start: search for relevant context
# At session start: preflight check, then search for relevant context
nodespace --version
nodespace diagnostics
nodespace node search --query "previous work on this codebase"

# During session: save discoveries
Expand Down
2 changes: 1 addition & 1 deletion packages/skill/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { install, uninstall } from './installer.js';
export { install, uninstall, isNodespaceBinaryOnPath } from './installer.js';
export type { AgentName, AgentConfig, InstallResult, UninstallResult } from './types.js';
export { AGENTS } from './agents.js';
24 changes: 24 additions & 0 deletions packages/skill/src/installer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, copyFileSync, rmSync, readdirSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { AGENTS } from './agents.js';
import type { AgentName, InstallResult, UninstallResult } from './types.js';

Expand All @@ -14,7 +15,30 @@ function detectAgents(): AgentName[] {
.map(agent => agent.name);
}

/**
* Check whether `nodespace` resolves on $PATH by running `nodespace --version`.
* Returns true if the binary is found and exits 0, false otherwise.
* Safe to call without a running daemon — `--version` is handled by clap
* before any socket connection is attempted.
*/
export function isNodespaceBinaryOnPath(): boolean {
try {
execFileSync('nodespace', ['--version'], { stdio: 'ignore', timeout: 3000 });
return true;
} catch {
return false;
}
}

export function install(targetAgents?: AgentName[], packageRoot = PACKAGE_ROOT): InstallResult[] {
if (!isNodespaceBinaryOnPath()) {
process.stderr.write(
'WARNING: `nodespace` is not on $PATH. The skill will be installed, but the CLI\n' +
'must be installed and on $PATH before agents can use NodeSpace.\n' +
'Install it via the NodeSpace DMG or `cargo install nodespace-cli`.\n',
);
}

const detected = targetAgents ?? detectAgents();
const results: InstallResult[] = [];

Expand Down
62 changes: 61 additions & 1 deletion packages/skill/src/tests/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ vi.mock('node:os', async (importOriginal) => {
return { ...actual, homedir: () => TMP };
});

const { install, uninstall } = await import('../installer.js');
const { install, uninstall, isNodespaceBinaryOnPath } = await import('../installer.js');
const { AGENTS } = await import('../agents.js');

const SKILL_MD_CONTENT = '# NodeSpace Skill\nTest content';
Expand Down Expand Up @@ -180,3 +180,63 @@ describe('uninstall', () => {
}
});
});

describe('isNodespaceBinaryOnPath', () => {
it('returns true when execFileSync exits 0', async () => {
vi.resetModules();
vi.doMock('node:child_process', () => ({
execFileSync: () => Buffer.from('nodespace 0.1.0\n'),
}));
const { isNodespaceBinaryOnPath: check } = await import('../installer.js');
expect(check()).toBe(true);
vi.doUnmock('node:child_process');
vi.resetModules();
});

it('returns false when execFileSync throws (binary not found)', async () => {
// Patch child_process on the installer module's live reference by re-importing
// after hoisting the mock. vi.doMock + resetModules lets us control this.
vi.resetModules();
vi.doMock('node:child_process', () => ({
execFileSync: () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); },
}));
const { isNodespaceBinaryOnPath: check } = await import('../installer.js');
expect(check()).toBe(false);
vi.doUnmock('node:child_process');
vi.resetModules();
});
});

describe('install PATH warning', () => {
it('writes a warning to stderr when nodespace is not on PATH', async () => {
vi.resetModules();
vi.doMock('node:child_process', () => ({
execFileSync: () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); },
}));
const { install: freshInstall } = await import('../installer.js');

const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
freshInstall([], FAKE_PKG_ROOT);
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('nodespace` is not on $PATH'));

stderrSpy.mockRestore();
vi.doUnmock('node:child_process');
vi.resetModules();
});

it('does not write a warning when nodespace is on PATH', async () => {
vi.resetModules();
vi.doMock('node:child_process', () => ({
execFileSync: () => Buffer.from('nodespace 0.1.0\n'),
}));
const { install: freshInstall } = await import('../installer.js');

const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
freshInstall([], FAKE_PKG_ROOT);
expect(stderrSpy).not.toHaveBeenCalled();

stderrSpy.mockRestore();
vi.doUnmock('node:child_process');
vi.resetModules();
});
});