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
4 changes: 2 additions & 2 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"packages/core": "0.1.0",
"packages/cli": "0.1.0",
"packages/core": "0.1.7",
"packages/cli": "0.1.7",
"packages/vscode": "0.1.7"
}
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ packages/

**Core** is ESM (`"type": "module"`). **CLI** is ESM. **VS Code extension** is CommonJS (esbuild bundles to CJS). Core uses `sql.js` (ASM.js build, not WASM) for cross-IDE compatibility — no native modules.

**File watcher uses path-routing**: Chokidar watches multiple source directories; `path-router.ts` pattern-matches each changed file to its specific parser (skill, agent, command, plugin, rule, hook) — avoids full rescans on file change events.

## Build, Test, and Development Commands

```bash
Expand Down Expand Up @@ -56,10 +58,14 @@ pnpm --filter @commandvault/core test -- src/__tests__/search.test.ts

## Commit & Pull Request Guidelines

Conventional commits: `<type>(<scope>): <description>`. Types: `feat`, `fix`, `refactor`, `perf`, `chore`, `docs`, `test`, `ci`, `build`. Scope is the package (`core`, `cli`, `vscode`) or area.
Conventional commits: `<type>(<scope>): <description>`. Types: `feat`, `fix`, `refactor`, `perf`, `chore`, `docs`, `test`, `ci`, `build`. Scope is the package (`core`, `cli`, `vscode`, `deps`, `ci`) or area.

**Commitlint** enforces format via `.commitlintrc.json` — invalid types or scopes will be rejected.

Branches from `develop`: `feat/<name>` or `fix/<name>`. PRs target `develop`, not `main`. Do not include `Co-Authored-By` lines.

**PR template** (`.github/PULL_REQUEST_TEMPLATE.md`): requires Summary, Changes list, type checkbox, and a checklist gate — `pnpm typecheck`, `pnpm test`, `pnpm format:check` must pass. CHANGELOG update required for user-facing changes.

## CI

GitHub Actions on push to `develop`/`main` and PRs. Matrix: Node 20 + 22. Steps: install → build → test core → typecheck → package VSIX. pnpm version is read from `packageManager` field in root `package.json` (do not set `version` in the workflow).
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "commandvault",
"version": "0.1.0",
"version": "0.1.7",
"private": true,
"description": "Universal AI command manager — browse, search, and organize slash commands, skills, agents, plugins, rules, and hooks across all AI coding assistants",
"author": "nothumanslabs",
Expand Down Expand Up @@ -28,6 +28,13 @@
"format": "prettier --write 'packages/*/src/**/*.ts'",
"format:check": "prettier --check 'packages/*/src/**/*.ts'"
},
"pnpm": {
"overrides": {
"fast-uri": ">=3.1.2",
"ws": ">=8.20.1",
"brace-expansion": ">=5.0.6"
}
},
"devDependencies": {
"@commitlint/cli": "^20.5.3",
"@commitlint/config-conventional": "^20.5.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@commandvault/cli",
"version": "0.1.0",
"version": "0.1.7",
"description": "Terminal companion for CommandVault — list, search, and inspect AI commands",
"author": "nothumanslabs",
"license": "MIT",
Expand Down
159 changes: 159 additions & 0 deletions packages/cli/src/__tests__/commands/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MOCK_ENTRIES } from '../fixtures/mock-vault.js';

const _testHomeDir = vi.hoisted(() => ({ value: '' }));

vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os');
return {
...actual,
homedir: () => _testHomeDir.value,
};
});

vi.mock('../../helpers.js', async () => {
const actual = await vi.importActual<typeof import('../../helpers.js')>('../../helpers.js');
return {
...actual,
createVaultInstance: vi.fn(),
};
});

import { createVaultInstance } from '../../helpers.js';
import { createDoctorCommand } from '../../commands/doctor.js';
import { Command } from 'commander';

function buildProgram() {
const program = new Command();
program.option('--json', 'JSON output');
program.addCommand(createDoctorCommand());
return program;
}

function createMockVault(entries = MOCK_ENTRIES) {
return {
getAllEntries: () => entries,
dispose: vi.fn().mockResolvedValue(undefined),
};
}

describe('doctor command', () => {
let tmpDir: string;
let consoleSpy: ReturnType<typeof vi.spyOn>;

beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'vault-doctor-test-'));
_testHomeDir.value = tmpDir;
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(async () => {
consoleSpy.mockRestore();
vi.clearAllMocks();
await rm(tmpDir, { recursive: true, force: true });
});

it('reports healthy status when all checks pass', async () => {
// Create all expected directories and files
const claudeDir = join(tmpDir, '.claude');
await mkdir(join(claudeDir, 'skills'), { recursive: true });
await mkdir(join(claudeDir, 'agents'), { recursive: true });
await mkdir(join(claudeDir, 'commands'), { recursive: true });
await mkdir(join(claudeDir, 'plugins'), { recursive: true });
await mkdir(join(tmpDir, '.commandvault'), { recursive: true });

await writeFile(join(claudeDir, 'plugins', 'installed_plugins.json'), JSON.stringify([]));
await writeFile(join(claudeDir, 'settings.json'), JSON.stringify({ hooks: {} }));
await writeFile(join(tmpDir, '.commandvault', 'vault.db'), '');

const vault = createMockVault();
vi.mocked(createVaultInstance).mockResolvedValue(vault as any);

const program = buildProgram();
await program.parseAsync(['node', 'vault', 'doctor']);

const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('checks passed');
});

it('detects missing ~/.claude directory', async () => {
// Don't create .claude directory
await mkdir(join(tmpDir, '.commandvault'), { recursive: true });
await writeFile(join(tmpDir, '.commandvault', 'vault.db'), '');

const vault = createMockVault();
vi.mocked(createVaultInstance).mockResolvedValue(vault as any);

const program = buildProgram();
await program.parseAsync(['node', 'vault', 'doctor']);

const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('~/.claude/ directory');
expect(output).toContain('not found');
});

it('detects missing vault.db', async () => {
const claudeDir = join(tmpDir, '.claude');
await mkdir(claudeDir, { recursive: true });
// Don't create .commandvault directory or vault.db

const vault = createMockVault();
vi.mocked(createVaultInstance).mockResolvedValue(vault as any);

const program = buildProgram();
await program.parseAsync(['node', 'vault', 'doctor']);

const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('vault.db');
expect(output).toContain('Not found');
});

it('reports scan pipeline failure when vault throws', async () => {
const claudeDir = join(tmpDir, '.claude');
await mkdir(claudeDir, { recursive: true });

vi.mocked(createVaultInstance).mockRejectedValue(new Error('DB corrupted'));

const program = buildProgram();
await program.parseAsync(['node', 'vault', 'doctor']);

const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('Vault scan pipeline');
expect(output).toContain('DB corrupted');
});

it('detects invalid plugins JSON', async () => {
const claudeDir = join(tmpDir, '.claude');
await mkdir(join(claudeDir, 'plugins'), { recursive: true });
await writeFile(join(claudeDir, 'plugins', 'installed_plugins.json'), '{broken json!!!');
await mkdir(join(tmpDir, '.commandvault'), { recursive: true });
await writeFile(join(tmpDir, '.commandvault', 'vault.db'), '');

const vault = createMockVault();
vi.mocked(createVaultInstance).mockResolvedValue(vault as any);

const program = buildProgram();
await program.parseAsync(['node', 'vault', 'doctor']);

const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('installed_plugins.json');
expect(output).toContain('invalid JSON');
});

it('reports scan entry count on success', async () => {
const claudeDir = join(tmpDir, '.claude');
await mkdir(claudeDir, { recursive: true });

const vault = createMockVault();
vi.mocked(createVaultInstance).mockResolvedValue(vault as any);

const program = buildProgram();
await program.parseAsync(['node', 'vault', 'doctor']);

const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain(`${MOCK_ENTRIES.length} entries successfully`);
});
});
126 changes: 126 additions & 0 deletions packages/cli/src/__tests__/commands/favorite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { makeMockEntry } from '../fixtures/mock-vault.js';
import type { SearchResult } from '@commandvault/core';

vi.mock('../../helpers.js', async () => {
const actual = await vi.importActual<typeof import('../../helpers.js')>('../../helpers.js');
return {
...actual,
createVaultInstance: vi.fn(),
};
});

import { createVaultInstance } from '../../helpers.js';
import { createFavoriteCommand } from '../../commands/favorite.js';
import { Command } from 'commander';

function buildProgram() {
const program = new Command();
program.option('--json', 'JSON output');
program.addCommand(createFavoriteCommand());
return program;
}

const TEST_ENTRY = makeMockEntry({
id: 'fav-test-1',
name: 'browse',
type: 'skill',
source: 'gstack',
favorite: false,
});

function createMockVault(options?: { searchResult?: SearchResult[]; toggleResult?: boolean }) {
const searchResult = options?.searchResult ?? [
{ entry: TEST_ENTRY, score: 1, matchedFields: ['name'] },
];
const toggleResult = options?.toggleResult ?? true;

return {
quickSearch: vi.fn().mockReturnValue(searchResult),
toggleFavorite: vi.fn().mockReturnValue(toggleResult),
dispose: vi.fn().mockResolvedValue(undefined),
};
}

describe('favorite command', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
consoleSpy.mockRestore();
vi.clearAllMocks();
});

it('toggles favorite on an entry (adds favorite)', async () => {
const vault = createMockVault({ toggleResult: true });
vi.mocked(createVaultInstance).mockResolvedValue(vault as any);

const program = buildProgram();
await program.parseAsync(['node', 'vault', 'favorite', 'browse']);

expect(vault.toggleFavorite).toHaveBeenCalledWith('fav-test-1');
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('Favorited');
expect(output).toContain('browse');
});

it('toggles favorite off an entry (removes favorite)', async () => {
const vault = createMockVault({ toggleResult: false });
vi.mocked(createVaultInstance).mockResolvedValue(vault as any);

const program = buildProgram();
await program.parseAsync(['node', 'vault', 'favorite', 'browse']);

expect(vault.toggleFavorite).toHaveBeenCalledWith('fav-test-1');
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('Unfavorited');
expect(output).toContain('browse');
});

it('reports error for non-existent entry', async () => {
const vault = createMockVault({ searchResult: [] });
vi.mocked(createVaultInstance).mockResolvedValue(vault as any);

const program = buildProgram();
await program.parseAsync(['node', 'vault', 'favorite', 'nonexistent']);

const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
expect(output).toContain('No entry found');
expect(output).toContain('nonexistent');
expect(vault.toggleFavorite).not.toHaveBeenCalled();
});

it('uses fuzzy search to match entry by name', async () => {
const vault = createMockVault();
vi.mocked(createVaultInstance).mockResolvedValue(vault as any);

const program = buildProgram();
await program.parseAsync(['node', 'vault', 'favorite', 'brows']);

// quickSearch should be called with the fuzzy input
expect(vault.quickSearch).toHaveBeenCalledWith('brows', 1);
});

it('disposes vault after operation completes', async () => {
const vault = createMockVault();
vi.mocked(createVaultInstance).mockResolvedValue(vault as any);

const program = buildProgram();
await program.parseAsync(['node', 'vault', 'favorite', 'browse']);

expect(vault.dispose).toHaveBeenCalledOnce();
});

it('disposes vault even when entry is not found', async () => {
const vault = createMockVault({ searchResult: [] });
vi.mocked(createVaultInstance).mockResolvedValue(vault as any);

const program = buildProgram();
await program.parseAsync(['node', 'vault', 'favorite', 'nothing']);

expect(vault.dispose).toHaveBeenCalledOnce();
});
});
Loading
Loading