rmhcode CLI Implementation Plan¶
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build the rmhcode CLI with 5 commands (login, whoami, push-build, list-builds, logout) that connect to the rmhstudios.com API, integrated alongside existing Claude Code functionality.
Architecture: Standalone cli/ directory with zero external dependencies. Entry point intercepts RMH-specific commands and passes everything else through to Claude Code. Token stored at ~/.rmhcode/config.json.
Tech Stack: Node.js 18+ (built-in fetch, readline, http, fs, crypto), TypeScript, compiled via tsc.
Task 1: Server-side fix — Add CLI token auth to GET /api/user-builds¶
Files:
Modify:
app/api/user-builds/route.ts:58-65
Step 1: Add CLI token fallback to GET handler
In app/api/user-builds/route.ts, replace lines 58-65:
// Get current user session (optional, for liked status)
let currentUserId: string | null = null;
try {
const session = await auth.api.getSession({ headers: await headers() });
currentUserId = session?.user?.id ?? null;
} catch {
// Not logged in
}
With:
// Get current user — session OR CLI token
let currentUserId: string | null = null;
try {
const session = await auth.api.getSession({ headers: await headers() });
currentUserId = session?.user?.id ?? null;
} catch {
// Not logged in via session
}
// Fall back to CLI token auth
if (!currentUserId) {
const cliUser = await getAuthenticatedUser(req, null);
if (cliUser) currentUserId = cliUser.id;
}
Note: getAuthenticatedUser is already imported at line 16.
Step 2: Verify the build still compiles
Run: cd /Users/kaikaidu/Documents/GitHub/rmhstudios && npx tsc --noEmit --pretty 2>&1 | head -20
Expected: No new errors related to user-builds/route.ts
Step 3: Commit
git add app/api/user-builds/route.ts
git commit -m "feat(api): add CLI token auth to GET /api/user-builds"
Task 2: Scaffold CLI package¶
Files:
Create:
cli/package.jsonCreate:
cli/tsconfig.json
Step 1: Create cli/package.json
{
"name": "rmhcode",
"version": "0.1.0",
"description": "AI-powered coding assistant with RMH integrations",
"type": "module",
"bin": {
"rmhcode": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"@types/node": "^20.0.0"
}
}
Step 2: Create cli/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": false
},
"include": ["src/**/*"]
}
Step 3: Install dependencies
Run: cd /Users/kaikaidu/Documents/GitHub/rmhstudios/cli && npm install
Expected: node_modules created, no errors
Step 4: Commit
git add cli/package.json cli/tsconfig.json cli/package-lock.json
git commit -m "feat(cli): scaffold rmhcode CLI package"
Task 3: Core library — config, api, output¶
Files:
Create:
cli/src/lib/config.tsCreate:
cli/src/lib/api.tsCreate:
cli/src/lib/output.tsCreate:
cli/src/types.ts
Step 1: Create types
Create cli/src/types.ts:
export interface UserInfo {
id: string;
name: string;
username: string;
email?: string;
image?: string;
}
export interface RmhConfig {
token: string;
user: UserInfo;
}
export interface BuildItem {
id: string;
slug: string;
title: string;
description: string;
status: string;
visibility: string;
technologies: string[];
likeCount: number;
viewCount: number;
createdAt: string;
publishedAt: string | null;
}
export interface ApiError {
error: string;
}
Step 2: Create config module
Create cli/src/lib/config.ts:
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import type { RmhConfig } from '../types.js';
const CONFIG_DIR = join(homedir(), '.rmhcode');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
export function readConfig(): RmhConfig | null {
try {
const data = readFileSync(CONFIG_FILE, 'utf-8');
return JSON.parse(data) as RmhConfig;
} catch {
return null;
}
}
export function writeConfig(config: RmhConfig): void {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
}
export function deleteConfig(): boolean {
if (existsSync(CONFIG_FILE)) {
unlinkSync(CONFIG_FILE);
return true;
}
return false;
}
export function requireAuth(): RmhConfig {
const config = readConfig();
if (!config) {
console.error('\x1b[31mNot logged in. Run `rmhcode login` first.\x1b[0m');
process.exit(1);
}
return config;
}
Step 3: Create API client
Create cli/src/lib/api.ts:
import type { ApiError } from '../types.js';
const API_BASE = process.env.RMHCODE_API_URL || 'https://rmhstudios.com';
interface RequestOptions {
method?: string;
token?: string;
body?: Record<string, unknown>;
params?: Record<string, string>;
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', token, body, params } = options;
let url = `${API_BASE}${path}`;
if (params) {
const search = new URLSearchParams(params);
url += `?${search.toString()}`;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['X-RMHCode-Token'] = token;
}
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) {
const err = data as ApiError;
throw new Error(err.error || `API error: ${res.status}`);
}
return data as T;
}
export { API_BASE };
Step 4: Create output helpers
Create cli/src/lib/output.ts:
const ESC = '\x1b';
export const color = {
green: (s: string) => `${ESC}[32m${s}${ESC}[0m`,
red: (s: string) => `${ESC}[31m${s}${ESC}[0m`,
yellow: (s: string) => `${ESC}[33m${s}${ESC}[0m`,
cyan: (s: string) => `${ESC}[36m${s}${ESC}[0m`,
dim: (s: string) => `${ESC}[2m${s}${ESC}[0m`,
bold: (s: string) => `${ESC}[1m${s}${ESC}[0m`,
violet: (s: string) => `${ESC}[35m${s}${ESC}[0m`,
};
export function success(msg: string): void {
console.log(color.green(`✓ ${msg}`));
}
export function error(msg: string): void {
console.error(color.red(`✗ ${msg}`));
}
export function info(msg: string): void {
console.log(color.cyan(`→ ${msg}`));
}
export function padEnd(str: string, len: number): string {
return str.length >= len ? str.slice(0, len) : str + ' '.repeat(len - str.length);
}
Step 5: Verify compilation
Run: cd /Users/kaikaidu/Documents/GitHub/rmhstudios/cli && npx tsc --noEmit
Expected: No errors
Step 6: Commit
git add cli/src/types.ts cli/src/lib/config.ts cli/src/lib/api.ts cli/src/lib/output.ts
git commit -m "feat(cli): add core library — config, api client, output helpers"
Task 4: Login command¶
Files:
Create:
cli/src/commands/login.ts
Step 1: Create login command
Create cli/src/commands/login.ts:
import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto';
import { writeConfig } from '../lib/config.js';
import { apiRequest, API_BASE } from '../lib/api.js';
import { success, error, info } from '../lib/output.js';
import type { UserInfo } from '../types.js';
function openBrowser(url: string): void {
const { platform } = process;
const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
import('node:child_process').then(({ execFile }) => {
execFile(cmd, [url], (err) => {
if (err) {
info(`Open this URL in your browser:\n ${url}`);
}
});
});
}
async function loginWithToken(token: string): Promise<void> {
try {
info('Validating token...');
const data = await apiRequest<{ valid: boolean; user: UserInfo }>(
'/api/rmhcode/auth/validate',
{ method: 'POST', body: { token } }
);
writeConfig({ token, user: data.user });
success(`Logged in as ${data.user.username || data.user.name}`);
} catch (e) {
error(e instanceof Error ? e.message : 'Login failed');
process.exit(1);
}
}
async function loginWithBrowser(): Promise<void> {
const sessionId = randomBytes(16).toString('hex');
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const url = new URL(req.url || '/', `http://localhost`);
const token = url.searchParams.get('token');
const errorParam = url.searchParams.get('error');
const userParam = url.searchParams.get('user');
// Send response to browser
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body style="background:#0a0a0a;color:#e4e4e7;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
<div style="text-align:center">
<h2>${token ? '✓ Authorized!' : '✗ Authorization failed'}</h2>
<p>You can close this tab and return to the terminal.</p>
</div>
</body>
</html>
`);
// Process result
if (errorParam) {
error('Authorization denied by user');
server.close();
process.exit(1);
}
if (token && userParam) {
try {
const user = JSON.parse(userParam) as UserInfo;
writeConfig({ token, user });
success(`Logged in as ${user.username || user.name}`);
} catch {
error('Failed to parse user info from callback');
}
server.close();
resolve();
}
});
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (!addr || typeof addr === 'string') {
error('Failed to start local server');
process.exit(1);
}
const callbackUrl = `http://127.0.0.1:${addr.port}`;
const authUrl = `${API_BASE}/rmhcode/auth?callback=${encodeURIComponent(callbackUrl)}&session=${sessionId}`;
info('Opening browser for authentication...');
openBrowser(authUrl);
info('Waiting for authorization...');
});
// Timeout after 5 minutes
setTimeout(() => {
error('Login timed out after 5 minutes');
server.close();
reject(new Error('timeout'));
}, 5 * 60 * 1000);
});
}
export async function login(args: string[]): Promise<void> {
const tokenIdx = args.indexOf('--token');
if (tokenIdx !== -1) {
const token = args[tokenIdx + 1];
if (!token) {
error('Missing token value. Usage: rmhcode login --token YOUR_TOKEN');
process.exit(1);
}
await loginWithToken(token);
} else {
await loginWithBrowser();
}
}
Step 2: Verify compilation
Run: cd /Users/kaikaidu/Documents/GitHub/rmhstudios/cli && npx tsc --noEmit
Expected: No errors
Step 3: Commit
git add cli/src/commands/login.ts
git commit -m "feat(cli): add login command with token and browser auth"
Task 5: Whoami and Logout commands¶
Files:
Create:
cli/src/commands/whoami.tsCreate:
cli/src/commands/logout.ts
Step 1: Create whoami command
Create cli/src/commands/whoami.ts:
import { requireAuth } from '../lib/config.js';
import { apiRequest } from '../lib/api.js';
import { success, error, color } from '../lib/output.js';
import type { UserInfo } from '../types.js';
export async function whoami(): Promise<void> {
const config = requireAuth();
try {
const data = await apiRequest<{ valid: boolean; user: UserInfo }>(
'/api/rmhcode/auth/validate',
{ method: 'POST', body: { token: config.token } }
);
const u = data.user;
console.log('');
console.log(color.bold(' Logged in as:'));
console.log(` Name: ${u.name}`);
if (u.username) console.log(` Username: ${color.violet(`@${u.username}`)}`);
if (u.email) console.log(` Email: ${color.dim(u.email)}`);
console.log('');
} catch (e) {
error('Token is invalid or expired. Run `rmhcode login` to re-authenticate.');
process.exit(1);
}
}
Step 2: Create logout command
Create cli/src/commands/logout.ts:
import { deleteConfig } from '../lib/config.js';
import { success, info } from '../lib/output.js';
export function logout(): void {
const deleted = deleteConfig();
if (deleted) {
success('Logged out successfully');
} else {
info('Already logged out');
}
}
Step 3: Verify compilation
Run: cd /Users/kaikaidu/Documents/GitHub/rmhstudios/cli && npx tsc --noEmit
Expected: No errors
Step 4: Commit
git add cli/src/commands/whoami.ts cli/src/commands/logout.ts
git commit -m "feat(cli): add whoami and logout commands"
Task 6: Push-build command¶
Files:
Create:
cli/src/commands/push-build.ts
Step 1: Create push-build command
Create cli/src/commands/push-build.ts:
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { requireAuth } from '../lib/config.js';
import { apiRequest, API_BASE } from '../lib/api.js';
import { success, error, info, color } from '../lib/output.js';
interface BuildResponse {
id: string;
slug: string;
title: string;
status: string;
}
async function prompt(rl: ReturnType<typeof createInterface>, question: string, defaultValue?: string): Promise<string> {
const suffix = defaultValue ? ` ${color.dim(`(${defaultValue})`)}` : '';
const answer = await rl.question(`${color.cyan('?')} ${question}${suffix}: `);
return answer.trim() || defaultValue || '';
}
export async function pushBuild(): Promise<void> {
const config = requireAuth();
const rl = createInterface({ input: stdin, output: stdout });
try {
console.log('');
console.log(color.bold(' Publish a new build to RMH User Builds'));
console.log(color.dim(' Fields marked with * are required'));
console.log('');
// Required fields
let title = '';
while (title.length < 5 || title.length > 100) {
title = await prompt(rl, 'Title *');
if (title.length < 5) info('Title must be at least 5 characters');
if (title.length > 100) info('Title must be at most 100 characters');
}
let description = '';
while (description.length < 10 || description.length > 500) {
description = await prompt(rl, 'Description *');
if (description.length < 10) info('Description must be at least 10 characters');
if (description.length > 500) info('Description must be at most 500 characters');
}
// Optional fields
const repoUrl = await prompt(rl, 'Repository URL');
const demoUrl = await prompt(rl, 'Demo URL');
const techInput = await prompt(rl, 'Technologies (comma-separated)');
const technologies = techInput ? techInput.split(',').map(t => t.trim()).filter(Boolean) : [];
const tagInput = await prompt(rl, 'Tags (comma-separated)');
const tags = tagInput ? tagInput.split(',').map(t => t.trim()).filter(Boolean) : [];
const visibilityInput = await prompt(rl, 'Visibility (public/unlisted/private)', 'public');
const visibility = visibilityInput.toUpperCase() as 'PUBLIC' | 'UNLISTED' | 'PRIVATE';
const publishInput = await prompt(rl, 'Publish now? (y/n)', 'y');
const publish = publishInput.toLowerCase() !== 'n';
// Check for README.md in current directory
let readme: string | undefined;
const readmePath = join(process.cwd(), 'README.md');
if (existsSync(readmePath)) {
const includeReadme = await prompt(rl, 'Include README.md from current directory? (y/n)', 'y');
if (includeReadme.toLowerCase() !== 'n') {
readme = readFileSync(readmePath, 'utf-8');
}
}
rl.close();
info('Publishing build...');
const data = await apiRequest<BuildResponse>('/api/user-builds', {
method: 'POST',
token: config.token,
body: {
title,
description,
repoUrl: repoUrl || undefined,
demoUrl: demoUrl || undefined,
technologies,
tags,
visibility,
publish,
readme,
},
});
console.log('');
success(`Build "${data.title}" ${publish ? 'published' : 'saved as draft'}!`);
console.log(` ${color.dim('View at:')} ${API_BASE}/user-builds/${data.slug}`);
console.log('');
} catch (e) {
rl.close();
error(e instanceof Error ? e.message : 'Failed to publish build');
process.exit(1);
}
}
Step 2: Verify compilation
Run: cd /Users/kaikaidu/Documents/GitHub/rmhstudios/cli && npx tsc --noEmit
Expected: No errors
Step 3: Commit
git add cli/src/commands/push-build.ts
git commit -m "feat(cli): add push-build command with interactive prompts"
Task 7: List-builds command¶
Files:
Create:
cli/src/commands/list-builds.ts
Step 1: Create list-builds command
Create cli/src/commands/list-builds.ts:
import { requireAuth } from '../lib/config.js';
import { apiRequest } from '../lib/api.js';
import { error, color, padEnd } from '../lib/output.js';
import type { BuildItem } from '../types.js';
interface ListResponse {
items: BuildItem[];
hasMore: boolean;
}
function statusColor(status: string): string {
switch (status) {
case 'PUBLISHED': return color.green(status);
case 'DRAFT': return color.yellow(status);
case 'ARCHIVED': return color.dim(status);
default: return status;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
export async function listBuilds(): Promise<void> {
const config = requireAuth();
try {
const data = await apiRequest<ListResponse>('/api/user-builds', {
token: config.token,
params: { userId: config.user.id, limit: '50' },
});
if (data.items.length === 0) {
console.log('');
console.log(color.dim(' No builds found. Run `rmhcode push-build` to publish one.'));
console.log('');
return;
}
console.log('');
console.log(
color.bold(
` ${padEnd('TITLE', 30)} ${padEnd('STATUS', 12)} ${padEnd('VISIBILITY', 12)} ${padEnd('LIKES', 6)} ${padEnd('VIEWS', 6)} DATE`
)
);
console.log(color.dim(` ${'─'.repeat(90)}`));
for (const build of data.items) {
const title = padEnd(build.title.slice(0, 28), 30);
const status = padEnd(statusColor(build.status), 12 + 9); // +9 for ANSI codes
const vis = padEnd(build.visibility, 12);
const likes = padEnd(String(build.likeCount), 6);
const views = padEnd(String(build.viewCount), 6);
const date = formatDate(build.publishedAt || build.createdAt);
console.log(` ${title} ${status} ${vis} ${likes} ${views} ${date}`);
}
if (data.hasMore) {
console.log(color.dim(`\n ... and more. View all at rmhstudios.com/user-builds`));
}
console.log('');
} catch (e) {
error(e instanceof Error ? e.message : 'Failed to fetch builds');
process.exit(1);
}
}
Step 2: Verify compilation
Run: cd /Users/kaikaidu/Documents/GitHub/rmhstudios/cli && npx tsc --noEmit
Expected: No errors
Step 3: Commit
git add cli/src/commands/list-builds.ts
git commit -m "feat(cli): add list-builds command with table output"
Task 8: Entry point — command router¶
Files:
Create:
cli/src/index.ts
Step 1: Create entry point
Create cli/src/index.ts:
#!/usr/bin/env node
import { login } from './commands/login.js';
import { whoami } from './commands/whoami.js';
import { pushBuild } from './commands/push-build.js';
import { listBuilds } from './commands/list-builds.js';
import { logout } from './commands/logout.js';
import { color } from './lib/output.js';
const RMH_COMMANDS: Record<string, (args: string[]) => void | Promise<void>> = {
login,
whoami,
'push-build': pushBuild,
'list-builds': listBuilds,
logout,
};
async function main() {
const args = process.argv.slice(2);
const command = args[0];
// If first arg is an RMH command, handle it
if (command && command in RMH_COMMANDS) {
try {
await RMH_COMMANDS[command](args.slice(1));
} catch (e) {
console.error(color.red(e instanceof Error ? e.message : 'Unknown error'));
process.exit(1);
}
return;
}
// Otherwise, pass through to Claude Code
const { execFile } = await import('node:child_process');
const claudePath = process.env.CLAUDE_CODE_PATH || 'claude';
const child = execFile(claudePath, args, {
stdio: 'inherit' as unknown as undefined,
env: process.env,
});
child.on('exit', (code) => {
process.exit(code ?? 0);
});
child.on('error', (err) => {
// If claude binary isn't found, show help
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
console.log('');
console.log(color.bold(` rmh${color.violet('code')}`));
console.log(color.dim(' AI-powered coding assistant with RMH integrations'));
console.log('');
console.log(' RMH Commands:');
console.log(` ${color.cyan('login')} Authenticate with your RMH account`);
console.log(` ${color.cyan('whoami')} Show current authenticated user`);
console.log(` ${color.cyan('push-build')} Publish a project to User Builds`);
console.log(` ${color.cyan('list-builds')} List your published builds`);
console.log(` ${color.cyan('logout')} Sign out and remove stored token`);
console.log('');
console.log(color.dim(' All other arguments are passed to Claude Code.'));
console.log(color.dim(' Set CLAUDE_CODE_PATH if claude is not in PATH.'));
console.log('');
} else {
console.error(color.red(`Failed to start Claude Code: ${err.message}`));
process.exit(1);
}
});
}
main();
Step 2: Build the CLI
Run: cd /Users/kaikaidu/Documents/GitHub/rmhstudios/cli && npx tsc
Expected: dist/ directory created with compiled JS files
Step 3: Test the help output
Run: node /Users/kaikaidu/Documents/GitHub/rmhstudios/cli/dist/index.js 2>&1
Expected: Shows the rmhcode help text with RMH Commands list (since claude binary likely not in path in test)
Step 4: Commit
git add cli/src/index.ts
git commit -m "feat(cli): add entry point with command router and Claude Code passthrough"
Task 9: Build, test end-to-end, add to .gitignore¶
Files:
Modify:
.gitignore(addcli/dist/andcli/node_modules/)
Step 1: Add cli build artifacts to .gitignore
Append to the root .gitignore:
# CLI build artifacts
cli/dist/
cli/node_modules/
Step 2: Full build
Run: cd /Users/kaikaidu/Documents/GitHub/rmhstudios/cli && npm run build
Expected: Clean compilation with no errors
Step 3: Smoke test — help output
Run: node /Users/kaikaidu/Documents/GitHub/rmhstudios/cli/dist/index.js
Expected: Shows help with login, whoami, push-build, list-builds, logout
Step 4: Smoke test — login with bad token
Run: RMHCODE_API_URL=https://rmhstudios.com node /Users/kaikaidu/Documents/GitHub/rmhstudios/cli/dist/index.js login --token badtoken123
Expected: Error message about invalid token format or invalid token
Step 5: Smoke test — whoami without auth
Run: node /Users/kaikaidu/Documents/GitHub/rmhstudios/cli/dist/index.js whoami
Expected: “Not logged in. Run rmhcode login first.”
Step 6: Smoke test — list-builds without auth
Run: node /Users/kaikaidu/Documents/GitHub/rmhstudios/cli/dist/index.js list-builds
Expected: “Not logged in. Run rmhcode login first.”
Step 7: Smoke test — logout when already logged out
Run: node /Users/kaikaidu/Documents/GitHub/rmhstudios/cli/dist/index.js logout
Expected: “Already logged out”
Step 8: Final commit
git add .gitignore
git commit -m "chore: add cli build artifacts to .gitignore"