feat: gated project experience & prompt intelligence
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

Implements the full gated session flow and prompt intelligence system:

- Prisma schema: add gated, priority, summary, chapters, linkTarget fields
- Session gate: state machine (gated → begin_session → ungated) with LLM-powered
  tool selection based on prompt index
- Tag matcher: intelligent prompt-to-tool matching with project/server/action tags
- LLM selector: tiered provider selection (fast for gating, heavy for complex tasks)
- Link resolver: cross-project MCP resource references (project/server:uri format)
- Prompt summary service: LLM-generated summaries and chapter extraction
- System project bootstrap: ensures default project exists on startup
- Structural link health checks: enrichWithLinkStatus on prompt GET endpoints
- CLI: create prompt --priority/--link, create project --gated/--no-gated,
  describe project shows prompts section, get prompts shows PRI/LINK/STATUS
- Apply/edit: priority, linkTarget, gated fields supported
- Shell completions: fish updated with new flags
- 1,253 tests passing across all packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-25 23:22:42 +00:00
parent 62647a7f90
commit 705df06996
46 changed files with 4946 additions and 105 deletions

View File

@@ -1,5 +1,5 @@
import { Command } from 'commander';
import { readFileSync } from 'node:fs';
import { readFileSync, readSync } from 'node:fs';
import yaml from 'js-yaml';
import { z } from 'zod';
import type { ApiClient } from '../api-client.js';
@@ -108,6 +108,8 @@ const PromptSpecSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
content: z.string().min(1).max(50000),
projectId: z.string().optional(),
priority: z.number().int().min(1).max(10).optional(),
linkTarget: z.string().optional(),
});
const ProjectSpecSchema = z.object({
@@ -115,6 +117,7 @@ const ProjectSpecSchema = z.object({
description: z.string().default(''),
prompt: z.string().max(10000).default(''),
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
gated: z.boolean().default(true),
llmProvider: z.string().optional(),
llmModel: z.string().optional(),
servers: z.array(z.string()).default([]),
@@ -175,11 +178,27 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
});
}
function readStdin(): string {
const chunks: Buffer[] = [];
const buf = Buffer.alloc(4096);
try {
// eslint-disable-next-line no-constant-condition
while (true) {
const bytesRead = readSync(0, buf, 0, buf.length, null);
if (bytesRead === 0) break;
chunks.push(buf.subarray(0, bytesRead));
}
} catch {
// EOF or closed pipe
}
return Buffer.concat(chunks).toString('utf-8');
}
function loadConfigFile(path: string): ApplyConfig {
const raw = readFileSync(path, 'utf-8');
const raw = path === '-' ? readStdin() : readFileSync(path, 'utf-8');
let parsed: unknown;
if (path.endsWith('.json')) {
if (path === '-' ? raw.trimStart().startsWith('{') : path.endsWith('.json')) {
parsed = JSON.parse(raw);
} else {
parsed = yaml.load(raw);
@@ -308,7 +327,9 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
try {
const existing = await findByName(client, 'prompts', prompt.name);
if (existing) {
await client.put(`/api/v1/prompts/${(existing as { id: string }).id}`, { content: prompt.content });
const updateData: Record<string, unknown> = { content: prompt.content };
if (prompt.priority !== undefined) updateData.priority = prompt.priority;
await client.put(`/api/v1/prompts/${(existing as { id: string }).id}`, updateData);
log(`Updated prompt: ${prompt.name}`);
} else {
await client.post('/api/v1/prompts', prompt);

View File

@@ -197,6 +197,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('-d, --description <text>', 'Project description', '')
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
.option('--gated', 'Enable gated sessions (default: true)')
.option('--no-gated', 'Disable gated sessions')
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
@@ -206,6 +208,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
proxyMode: opts.proxyMode ?? 'direct',
};
if (opts.prompt) body.prompt = opts.prompt;
if (opts.gated !== undefined) body.gated = opts.gated as boolean;
if (opts.server.length > 0) body.servers = opts.server;
try {
@@ -352,6 +355,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--project <name>', 'Project name to scope the prompt to')
.option('--content <text>', 'Prompt content text')
.option('--content-file <path>', 'Read prompt content from file')
.option('--priority <number>', 'Priority 1-10 (default: 5, higher = more important)')
.option('--link <target>', 'Link to MCP resource (format: project/server:uri)')
.action(async (name: string, opts) => {
let content = opts.content as string | undefined;
if (opts.contentFile) {
@@ -370,6 +375,16 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
if (!project) throw new Error(`Project '${opts.project as string}' not found`);
body.projectId = project.id;
}
if (opts.priority) {
const priority = Number(opts.priority);
if (isNaN(priority) || priority < 1 || priority > 10) {
throw new Error('--priority must be a number between 1 and 10');
}
body.priority = priority;
}
if (opts.link) {
body.linkTarget = opts.link;
}
const prompt = await client.post<{ id: string; name: string }>('/api/v1/prompts', body);
log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
@@ -379,9 +394,10 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
cmd.command('promptrequest')
.description('Create a prompt request (pending proposal that needs approval)')
.argument('<name>', 'Prompt request name (lowercase alphanumeric with hyphens)')
.requiredOption('--project <name>', 'Project name (required)')
.option('--project <name>', 'Project name to scope the prompt request to')
.option('--content <text>', 'Prompt content text')
.option('--content-file <path>', 'Read prompt content from file')
.option('--priority <number>', 'Priority 1-10 (default: 5, higher = more important)')
.action(async (name: string, opts) => {
let content = opts.content as string | undefined;
if (opts.contentFile) {
@@ -392,10 +408,21 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
throw new Error('--content or --content-file is required');
}
const projectName = opts.project as string;
const body: Record<string, unknown> = { name, content };
if (opts.project) {
body.project = opts.project;
}
if (opts.priority) {
const priority = Number(opts.priority);
if (isNaN(priority) || priority < 1 || priority > 10) {
throw new Error('--priority must be a number between 1 and 10');
}
body.priority = priority;
}
const pr = await client.post<{ id: string; name: string }>(
`/api/v1/projects/${encodeURIComponent(projectName)}/promptrequests`,
{ name, content },
'/api/v1/promptrequests',
body,
);
log(`prompt request '${pr.name}' created (id: ${pr.id})`);
log(` approve with: mcpctl approve promptrequest ${pr.name}`);

View File

@@ -133,11 +133,15 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
return lines.join('\n');
}
function formatProjectDetail(project: Record<string, unknown>): string {
function formatProjectDetail(
project: Record<string, unknown>,
prompts: Array<{ name: string; priority: number; linkTarget: string | null }> = [],
): string {
const lines: string[] = [];
lines.push(`=== Project: ${project.name} ===`);
lines.push(`${pad('Name:')}${project.name}`);
if (project.description) lines.push(`${pad('Description:')}${project.description}`);
lines.push(`${pad('Gated:')}${project.gated ? 'yes' : 'no'}`);
// Proxy config section
const proxyMode = project.proxyMode as string | undefined;
@@ -162,6 +166,18 @@ function formatProjectDetail(project: Record<string, unknown>): string {
}
}
// Prompts section
if (prompts.length > 0) {
lines.push('');
lines.push('Prompts:');
const nameW = Math.max(4, ...prompts.map((p) => p.name.length)) + 2;
lines.push(` ${'NAME'.padEnd(nameW)}${'PRI'.padEnd(6)}TYPE`);
for (const p of prompts) {
const type = p.linkTarget ? 'link' : 'local';
lines.push(` ${p.name.padEnd(nameW)}${String(p.priority).padEnd(6)}${type}`);
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${project.id}`);
@@ -586,9 +602,13 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
case 'templates':
deps.log(formatTemplateDetail(item));
break;
case 'projects':
deps.log(formatProjectDetail(item));
case 'projects': {
const projectPrompts = await deps.client
.get<Array<{ name: string; priority: number; linkTarget: string | null }>>(`/api/v1/prompts?projectId=${item.id as string}`)
.catch(() => []);
deps.log(formatProjectDetail(item, projectPrompts));
break;
}
case 'users': {
// Fetch RBAC definitions and groups to show permissions
const [rbacDefsForUser, allGroupsForUser] = await Promise.all([

View File

@@ -6,6 +6,7 @@ import { execSync } from 'node:child_process';
import yaml from 'js-yaml';
import type { ApiClient } from '../api-client.js';
import { resolveResource, resolveNameOrId, stripInternalFields } from './shared.js';
import { reorderKeys } from '../formatters/output.js';
export interface EditCommandDeps {
client: ApiClient;
@@ -47,7 +48,7 @@ export function createEditCommand(deps: EditCommandDeps): Command {
return;
}
const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac'];
const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests'];
if (!validResources.includes(resource)) {
log(`Error: unknown resource type '${resourceArg}'`);
process.exitCode = 1;
@@ -61,7 +62,7 @@ export function createEditCommand(deps: EditCommandDeps): Command {
const current = await client.get<Record<string, unknown>>(`/api/v1/${resource}/${id}`);
// Strip read-only fields for editor
const editable = stripInternalFields(current);
const editable = reorderKeys(stripInternalFields(current)) as Record<string, unknown>;
// Serialize to YAML
const singular = resource.replace(/s$/, '');

View File

@@ -5,7 +5,7 @@ import type { Column } from '../formatters/table.js';
import { resolveResource, stripInternalFields } from './shared.js';
export interface GetCommandDeps {
fetchResource: (resource: string, id?: string) => Promise<unknown[]>;
fetchResource: (resource: string, id?: string, opts?: { project?: string; all?: boolean }) => Promise<unknown[]>;
log: (...args: string[]) => void;
}
@@ -22,6 +22,7 @@ interface ProjectRow {
name: string;
description: string;
proxyMode: string;
gated: boolean;
ownerId: string;
servers?: Array<{ server: { name: string } }>;
}
@@ -83,6 +84,7 @@ interface RbacRow {
const projectColumns: Column<ProjectRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
{ header: 'GATED', key: (r) => r.gated ? 'yes' : 'no', width: 6 },
{ header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
{ header: 'DESCRIPTION', key: 'description', width: 30 },
{ header: 'ID', key: 'id' },
@@ -134,6 +136,10 @@ interface PromptRow {
id: string;
name: string;
projectId: string | null;
project?: { name: string } | null;
priority: number;
linkTarget: string | null;
linkStatus: 'alive' | 'dead' | null;
createdAt: string;
}
@@ -141,20 +147,24 @@ interface PromptRequestRow {
id: string;
name: string;
projectId: string | null;
project?: { name: string } | null;
createdBySession: string | null;
createdAt: string;
}
const promptColumns: Column<PromptRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 },
{ header: 'PROJECT', key: (r) => r.project?.name ?? (r.projectId ? r.projectId : '(global)'), width: 20 },
{ header: 'PRI', key: (r) => String(r.priority), width: 4 },
{ header: 'LINK', key: (r) => r.linkTarget ? r.linkTarget.split(':')[0]! : '-', width: 20 },
{ header: 'STATUS', key: (r) => r.linkStatus ?? '-', width: 6 },
{ header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 },
{ header: 'ID', key: 'id' },
];
const promptRequestColumns: Column<PromptRequestRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 },
{ header: 'PROJECT', key: (r) => r.project?.name ?? (r.projectId ? r.projectId : '(global)'), width: 20 },
{ header: 'SESSION', key: (r) => r.createdBySession ? r.createdBySession.slice(0, 12) : '-', width: 14 },
{ header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 },
{ header: 'ID', key: 'id' },
@@ -216,9 +226,14 @@ export function createGetCommand(deps: GetCommandDeps): Command {
.argument('<resource>', 'resource type (servers, projects, instances)')
.argument('[id]', 'specific resource ID or name')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {
.option('--project <name>', 'Filter by project')
.option('-A, --all', 'Show all (including project-scoped) resources')
.action(async (resourceArg: string, id: string | undefined, opts: { output: string; project?: string; all?: true }) => {
const resource = resolveResource(resourceArg);
const items = await deps.fetchResource(resource, id);
const fetchOpts: { project?: string; all?: boolean } = {};
if (opts.project) fetchOpts.project = opts.project;
if (opts.all) fetchOpts.all = true;
const items = await deps.fetchResource(resource, id, Object.keys(fetchOpts).length > 0 ? fetchOpts : undefined);
if (opts.output === 'json') {
// Apply-compatible JSON wrapped in resource key

View File

@@ -52,13 +52,12 @@ export function createApproveCommand(deps: ProjectOpsDeps): Command {
return new Command('approve')
.description('Approve a pending prompt request (atomic: delete request, create prompt)')
.argument('<resource>', 'Resource type (promptrequest)')
.argument('<name>', 'Prompt request name or ID')
.argument('<name>', 'Resource name or ID')
.action(async (resourceArg: string, nameOrId: string) => {
const resource = resolveResource(resourceArg);
if (resource !== 'promptrequests') {
throw new Error(`approve is only supported for 'promptrequest', got '${resourceArg}'`);
}
const id = await resolveNameOrId(client, 'promptrequests', nameOrId);
const prompt = await client.post<{ id: string; name: string }>(`/api/v1/promptrequests/${id}/approve`, {});
log(`prompt request approved → prompt '${prompt.name}' created (id: ${prompt.id})`);

View File

@@ -61,8 +61,21 @@ export async function resolveNameOrId(
/** Strip internal/read-only fields from an API response to make it apply-compatible. */
export function stripInternalFields(obj: Record<string, unknown>): Record<string, unknown> {
const result = { ...obj };
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId']) {
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId', 'summary', 'chapters']) {
delete result[key];
}
// Strip relationship joins that aren't part of the resource spec (like k8s namespaces don't list deployments)
if ('servers' in result && Array.isArray(result.servers)) {
delete result.servers;
}
if ('owner' in result && typeof result.owner === 'object') {
delete result.owner;
}
if ('members' in result && Array.isArray(result.members)) {
delete result.members;
}
if ('project' in result && typeof result.project === 'object' && result.project !== null) {
delete result.project;
}
return result;
}

View File

@@ -6,6 +6,29 @@ export function formatJson(data: unknown): string {
return JSON.stringify(data, null, 2);
}
export function formatYaml(data: unknown): string {
return yaml.dump(data, { lineWidth: 120, noRefs: true }).trimEnd();
/**
* Reorder object keys so that long text fields (like `content`, `prompt`)
* come last. This makes YAML output more readable when content spans
* multiple lines.
*/
export function reorderKeys(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(reorderKeys);
if (obj !== null && typeof obj === 'object') {
const rec = obj as Record<string, unknown>;
const lastKeys = ['content', 'prompt'];
const ordered: Record<string, unknown> = {};
for (const key of Object.keys(rec)) {
if (!lastKeys.includes(key)) ordered[key] = reorderKeys(rec[key]);
}
for (const key of lastKeys) {
if (key in rec) ordered[key] = rec[key];
}
return ordered;
}
return obj;
}
export function formatYaml(data: unknown): string {
const reordered = reorderKeys(data);
return yaml.dump(reordered, { lineWidth: 120, noRefs: true }).trimEnd();
}

View File

@@ -14,6 +14,7 @@ import { createBackupCommand, createRestoreCommand } from './commands/backup.js'
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js';
import { createMcpCommand } from './commands/mcp.js';
import { createPatchCommand } from './commands/patch.js';
import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js';
@@ -54,8 +55,8 @@ export function createProgram(): Command {
log: (...args) => console.log(...args),
}));
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
const projectName = program.opts().project as string | undefined;
const fetchResource = async (resource: string, nameOrId?: string, opts?: { project?: string; all?: boolean }): Promise<unknown[]> => {
const projectName = opts?.project ?? program.opts().project as string | undefined;
// --project scoping for servers and instances
if (projectName && !nameOrId && (resource === 'servers' || resource === 'instances')) {
@@ -70,6 +71,17 @@ export function createProgram(): Command {
return allInstances.filter((inst) => serverIds.has(inst.serverId));
}
// --project scoping for prompts and promptrequests
if (!nameOrId && (resource === 'prompts' || resource === 'promptrequests')) {
if (projectName) {
return client.get<unknown[]>(`/api/v1/${resource}?project=${encodeURIComponent(projectName)}`);
}
// Default: global-only. --all (-A) shows everything.
if (!opts?.all) {
return client.get<unknown[]>(`/api/v1/${resource}?scope=global`);
}
}
if (nameOrId) {
// Glob pattern — use query param filtering
if (nameOrId.includes('*')) {
@@ -134,6 +146,11 @@ export function createProgram(): Command {
log: (...args) => console.log(...args),
}));
program.addCommand(createPatchCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createBackupCommand({
client,
log: (...args) => console.log(...args),

View File

@@ -447,4 +447,114 @@ describe('create command', () => {
});
});
});
describe('create prompt', () => {
it('creates a prompt with content', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'p-1', name: 'test-prompt' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['prompt', 'test-prompt', '--content', 'Hello world'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/prompts', {
name: 'test-prompt',
content: 'Hello world',
});
expect(output.join('\n')).toContain("prompt 'test-prompt' created");
});
it('requires content or content-file', async () => {
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['prompt', 'no-content'], { from: 'user' }),
).rejects.toThrow('--content or --content-file is required');
});
it('--priority sets prompt priority', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'p-1', name: 'pri-prompt' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['prompt', 'pri-prompt', '--content', 'x', '--priority', '8'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/prompts', expect.objectContaining({
priority: 8,
}));
});
it('--priority validates range 1-10', async () => {
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['prompt', 'bad', '--content', 'x', '--priority', '15'], { from: 'user' }),
).rejects.toThrow('--priority must be a number between 1 and 10');
});
it('--priority rejects zero', async () => {
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['prompt', 'bad', '--content', 'x', '--priority', '0'], { from: 'user' }),
).rejects.toThrow('--priority must be a number between 1 and 10');
});
it('--link sets linkTarget', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'p-1', name: 'linked' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['prompt', 'linked', '--content', 'x', '--link', 'proj/srv:docmost://pages/abc'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/prompts', expect.objectContaining({
linkTarget: 'proj/srv:docmost://pages/abc',
}));
});
it('--project resolves project name to ID', async () => {
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-project' }] as never);
vi.mocked(client.post).mockResolvedValueOnce({ id: 'p-1', name: 'scoped' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['prompt', 'scoped', '--content', 'x', '--project', 'my-project'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/prompts', expect.objectContaining({
projectId: 'proj-1',
}));
});
it('--project throws when project not found', async () => {
vi.mocked(client.get).mockResolvedValueOnce([] as never);
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['prompt', 'bad', '--content', 'x', '--project', 'nope'], { from: 'user' }),
).rejects.toThrow("Project 'nope' not found");
});
});
describe('create promptrequest', () => {
it('creates a prompt request with priority', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'r-1', name: 'req' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['promptrequest', 'req', '--content', 'proposal', '--priority', '7'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/promptrequests', expect.objectContaining({
name: 'req',
content: 'proposal',
priority: 7,
}));
});
});
describe('create project', () => {
it('creates a project with --gated', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'proj-1', name: 'gated-proj' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'gated-proj', '--gated'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
gated: true,
}));
});
it('creates a project with --no-gated', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'proj-1', name: 'open-proj' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'open-proj', '--no-gated'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
gated: false,
}));
});
});
});

View File

@@ -20,7 +20,7 @@ describe('get command', () => {
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined, undefined);
expect(deps.output[0]).toContain('NAME');
expect(deps.output[0]).toContain('TRANSPORT');
expect(deps.output.join('\n')).toContain('slack');
@@ -31,14 +31,14 @@ describe('get command', () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'srv']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined, undefined);
});
it('passes ID when provided', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', 'srv-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1', undefined);
});
it('outputs apply-compatible JSON format', async () => {
@@ -94,7 +94,7 @@ describe('get command', () => {
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'users']);
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined, undefined);
const text = deps.output.join('\n');
expect(text).toContain('EMAIL');
expect(text).toContain('NAME');
@@ -110,7 +110,7 @@ describe('get command', () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'user']);
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined, undefined);
});
it('lists groups with correct columns', async () => {
@@ -126,7 +126,7 @@ describe('get command', () => {
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'groups']);
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined, undefined);
const text = deps.output.join('\n');
expect(text).toContain('NAME');
expect(text).toContain('MEMBERS');
@@ -141,7 +141,7 @@ describe('get command', () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'group']);
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined, undefined);
});
it('lists rbac definitions with correct columns', async () => {
@@ -156,7 +156,7 @@ describe('get command', () => {
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined, undefined);
const text = deps.output.join('\n');
expect(text).toContain('NAME');
expect(text).toContain('SUBJECTS');
@@ -170,7 +170,7 @@ describe('get command', () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac-definition']);
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined, undefined);
});
it('lists projects with new columns', async () => {
@@ -251,4 +251,87 @@ describe('get command', () => {
await cmd.parseAsync(['node', 'test', 'rbac']);
expect(deps.output[0]).toContain('No rbac found');
});
it('lists prompts with project name column', async () => {
const deps = makeDeps([
{ id: 'p-1', name: 'debug-guide', projectId: 'proj-1', project: { name: 'smart-home' }, createdAt: '2025-01-01T00:00:00Z' },
{ id: 'p-2', name: 'global-rules', projectId: null, project: null, createdAt: '2025-01-01T00:00:00Z' },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'prompts']);
const text = deps.output.join('\n');
expect(text).toContain('NAME');
expect(text).toContain('PROJECT');
expect(text).toContain('debug-guide');
expect(text).toContain('smart-home');
expect(text).toContain('global-rules');
expect(text).toContain('(global)');
});
it('lists promptrequests with project name column', async () => {
const deps = makeDeps([
{ id: 'pr-1', name: 'new-rule', projectId: 'proj-1', project: { name: 'my-project' }, createdBySession: 'sess-abc123def456', createdAt: '2025-01-01T00:00:00Z' },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'promptrequests']);
const text = deps.output.join('\n');
expect(text).toContain('new-rule');
expect(text).toContain('my-project');
expect(text).toContain('sess-abc123d');
});
it('passes --project option to fetchResource', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'prompts', '--project', 'smart-home']);
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, { project: 'smart-home' });
});
it('does not pass project when --project is not specified', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'prompts']);
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, undefined);
});
it('passes --all flag to fetchResource', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'prompts', '-A']);
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, { all: true });
});
it('passes both --project and --all when both given', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'prompts', '--project', 'my-proj', '-A']);
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, { project: 'my-proj', all: true });
});
it('resolves prompt alias', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'prompt']);
expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, undefined);
});
it('resolves pr alias to promptrequests', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'pr']);
expect(deps.fetchResource).toHaveBeenCalledWith('promptrequests', undefined, undefined);
});
it('shows no results message for empty prompts list', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'prompts']);
expect(deps.output[0]).toContain('No prompts found');
});
});

View File

@@ -47,7 +47,7 @@ describe('CLI command registration (e2e)', () => {
expect(subcommands).toContain('reset');
});
it('create command has user, group, rbac subcommands', () => {
it('create command has user, group, rbac, prompt, promptrequest subcommands', () => {
const program = createProgram();
const create = program.commands.find((c) => c.name() === 'create');
expect(create).toBeDefined();
@@ -59,6 +59,24 @@ describe('CLI command registration (e2e)', () => {
expect(subcommands).toContain('user');
expect(subcommands).toContain('group');
expect(subcommands).toContain('rbac');
expect(subcommands).toContain('prompt');
expect(subcommands).toContain('promptrequest');
});
it('get command accepts --project option', () => {
const program = createProgram();
const get = program.commands.find((c) => c.name() === 'get');
expect(get).toBeDefined();
const projectOpt = get!.options.find((o) => o.long === '--project');
expect(projectOpt).toBeDefined();
expect(projectOpt!.description).toContain('project');
});
it('program-level --project option is defined', () => {
const program = createProgram();
const projectOpt = program.options.find((o) => o.long === '--project');
expect(projectOpt).toBeDefined();
});
it('displays version', () => {