@husar.ai/cli 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,17 +12,17 @@
12
12
  import { spawn } from 'node:child_process';
13
13
  import { promises as fs } from 'node:fs';
14
14
  import { join, resolve } from 'node:path';
15
- import * as readline from 'node:readline';
16
15
 
17
16
  import { getCloudAuth, ProjectAuth } from '../auth/config.js';
18
17
  import { startCloudLoginFlow, startPanelLoginFlow } from '../auth/login.js';
19
18
  import { getProjects, CloudProject, verifyAccessToken } from '../auth/api.js';
19
+ import { log, spinner, select, input, successBox, errorBox, nextSteps, header, theme, chalk } from '../ui.js';
20
20
 
21
21
  export interface CreateOptions {
22
22
  /** Project directory name */
23
23
  projectName?: string;
24
- /** Framework to use: nextjs or vite */
25
- framework: 'nextjs' | 'vite';
24
+ /** Framework to use: nextjs, vite, or clean */
25
+ framework?: 'nextjs' | 'vite' | 'clean';
26
26
  /** Skip npm install after scaffolding */
27
27
  skipInstall?: boolean;
28
28
  }
@@ -35,235 +35,225 @@ export interface CreateOptions {
35
35
  * Run the create command
36
36
  */
37
37
  export async function runCreateCommand(options: CreateOptions): Promise<void> {
38
- console.log('\nšŸš€ Husar.ai Project Creator\n');
39
-
40
38
  // ═══════════════════════════════════════════════════════════════════════
41
39
  // STEP 1: How to connect to CMS?
42
40
  // ═══════════════════════════════════════════════════════════════════════
43
41
 
44
- const connectionMethod = await promptConnectionMethod();
42
+ header('Step 1: Connect to CMS');
43
+
44
+ const connectionMethod = await select<'manual' | 'cloud'>('How do you want to connect?', [
45
+ {
46
+ name: 'cloud',
47
+ message: 'Login to husar.ai',
48
+ hint: 'Recommended - select from your projects',
49
+ },
50
+ {
51
+ name: 'manual',
52
+ message: 'Enter URL manually',
53
+ hint: 'For self-hosted or custom instances',
54
+ },
55
+ ]);
56
+
45
57
  let panelHost: string;
46
58
 
47
59
  if (connectionMethod === 'manual') {
48
- // User enters URL manually
49
- panelHost = await promptManualUrl();
60
+ panelHost = await input('Enter your CMS panel URL', 'https://');
61
+
62
+ // Basic validation
63
+ if (!panelHost.startsWith('http://') && !panelHost.startsWith('https://')) {
64
+ errorBox('URL must start with http:// or https://');
65
+ process.exit(1);
66
+ }
50
67
  } else {
51
68
  // Cloud login flow → select project
52
69
  const cloudAuth = await ensureCloudLogin();
53
70
  if (!cloudAuth) {
54
- console.error('\nāŒ Cloud authentication failed. Please try again.');
71
+ errorBox('Cloud authentication failed. Please try again.');
55
72
  process.exit(1);
56
73
  }
57
74
 
58
75
  // Fetch user's projects
59
- console.log('\nšŸ“¦ Fetching your projects...\n');
76
+ const spin = spinner('Fetching your projects...');
60
77
  const projects = await getProjects(cloudAuth.accessToken);
78
+ spin.stop();
61
79
 
62
80
  if (projects.length === 0) {
63
- console.log('āŒ No projects found in your account.');
64
- console.log(' Create a project at https://admin.husar.ai first.\n');
81
+ errorBox(
82
+ 'No projects found in your account.\n\n' + `Create a project at ${theme.link('https://admin.husar.ai')} first.`,
83
+ 'No Projects',
84
+ );
65
85
  process.exit(1);
66
86
  }
67
87
 
68
88
  // Let user select a project
69
89
  const selectedProject = await promptSelectProject(projects);
70
90
  panelHost = selectedProject.adminURL;
71
- console.log(`\nšŸ“Œ Selected: ${selectedProject.name}`);
91
+ log.done(`Selected: ${theme.info(selectedProject.name)}`);
72
92
  }
73
93
 
74
94
  // ═══════════════════════════════════════════════════════════════════════
75
95
  // STEP 2: Panel CMS login (get adminToken)
76
96
  // ═══════════════════════════════════════════════════════════════════════
77
97
 
78
- console.log('\nšŸ” Opening browser for CMS authentication...');
79
- console.log(" You'll need your SUPERADMIN credentials (from the welcome email)\n");
98
+ header('Step 2: CMS Authentication');
99
+
100
+ log.info('Opening browser for CMS authentication...');
101
+ log.dim("You'll need your SUPERADMIN credentials (from the welcome email)");
102
+ log.blank();
80
103
 
81
104
  const panelAuth = await startPanelLoginFlow(panelHost);
82
105
 
83
106
  if (!panelAuth.success || !panelAuth.project) {
84
- console.error('\nāŒ CMS authentication failed:', panelAuth.error ?? 'Unknown error');
107
+ errorBox(panelAuth.error ?? 'Unknown error', 'CMS Authentication Failed');
85
108
  process.exit(1);
86
109
  }
87
110
 
88
111
  const project = panelAuth.project;
89
- console.log(`\nāœ… Connected to: ${project.projectName}`);
90
- console.log(` Host: ${project.host}\n`);
112
+ log.success(`Connected to: ${theme.info(project.projectName)}`);
113
+ log.dim(`Host: ${project.host}`);
91
114
 
92
115
  // ═══════════════════════════════════════════════════════════════════════
93
116
  // STEP 3: Get project name (directory)
94
117
  // ═══════════════════════════════════════════════════════════════════════
95
118
 
96
- const projectName = options.projectName || (await promptProjectName(project.projectName));
119
+ header('Step 3: Project Setup');
120
+
121
+ const defaultName = project.projectName.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase();
122
+ const projectName = options.projectName || (await input('Project directory name', defaultName));
123
+
97
124
  if (!projectName) {
98
- console.error('āŒ Project name is required.');
125
+ errorBox('Project name is required.');
99
126
  process.exit(1);
100
127
  }
101
128
 
102
129
  const projectPath = resolve(process.cwd(), projectName);
103
130
 
104
131
  // ═══════════════════════════════════════════════════════════════════════
105
- // STEP 4: Run framework CLI
132
+ // STEP 4: Select framework
106
133
  // ═══════════════════════════════════════════════════════════════════════
107
134
 
108
- console.log(`\nšŸ“¦ Creating ${options.framework === 'nextjs' ? 'Next.js' : 'Vite + React'} project...\n`);
135
+ const framework =
136
+ options.framework ||
137
+ (await select<'nextjs' | 'vite' | 'clean'>('Which template would you like?', [
138
+ {
139
+ name: 'nextjs',
140
+ message: 'Next.js',
141
+ hint: 'Recommended - Full-stack React with SSR',
142
+ },
143
+ {
144
+ name: 'vite',
145
+ message: 'Vite + React',
146
+ hint: 'Fast build tool with React & TypeScript',
147
+ },
148
+ {
149
+ name: 'clean',
150
+ message: 'Clean (no framework)',
151
+ hint: 'Just npm init - bring your own setup',
152
+ },
153
+ ]));
109
154
 
110
- const frameworkSuccess = await runFrameworkCli(options.framework, projectName);
111
- if (!frameworkSuccess) {
112
- console.error('\nāŒ Framework CLI failed. Please check the output above.');
113
- process.exit(1);
155
+ // ═══════════════════════════════════════════════════════════════════════
156
+ // STEP 5: Run framework CLI or init clean project
157
+ // ═══════════════════════════════════════════════════════════════════════
158
+
159
+ header('Step 4: Creating Project');
160
+
161
+ if (framework === 'clean') {
162
+ const spin = spinner('Initializing clean project...');
163
+ const initSuccess = await initCleanProject(projectName);
164
+ if (!initSuccess) {
165
+ spin.fail('Project initialization failed');
166
+ process.exit(1);
167
+ }
168
+ spin.succeed('Project initialized');
169
+ } else {
170
+ const frameworkLabel = framework === 'nextjs' ? 'Next.js' : 'Vite + React';
171
+ log.step(`Creating ${frameworkLabel} project...`);
172
+ log.blank();
173
+
174
+ const frameworkSuccess = await runFrameworkCli(framework, projectName);
175
+ if (!frameworkSuccess) {
176
+ errorBox('Framework CLI failed. Please check the output above.');
177
+ process.exit(1);
178
+ }
114
179
  }
115
180
 
116
181
  // ═══════════════════════════════════════════════════════════════════════
117
- // STEP 5: Add Husar configuration (with hybrid storage)
182
+ // STEP 6: Add Husar configuration (with hybrid storage)
118
183
  // ═══════════════════════════════════════════════════════════════════════
119
184
 
120
- console.log('\nāš™ļø Adding Husar configuration...\n');
121
- await addHusarConfig(projectPath, project, options.framework);
185
+ header('Step 5: Configuring Husar');
186
+
187
+ const configSpin = spinner('Adding configuration...');
188
+ await addHusarConfig(projectPath, project, framework);
189
+ configSpin.succeed('Configuration added');
190
+
191
+ log.substep('husar.json - CMS configuration');
192
+ log.substep('.env.local - Credentials (keep secret!)');
193
+ log.substep('opencode.jsonc - MCP integration');
194
+ log.substep('.opencode/command/husar.md - MCP docs');
122
195
 
123
196
  // ═══════════════════════════════════════════════════════════════════════
124
- // STEP 6: Install Husar packages
197
+ // STEP 7: Install Husar packages
125
198
  // ═══════════════════════════════════════════════════════════════════════
126
199
 
127
200
  if (!options.skipInstall) {
128
- console.log('\nšŸ“„ Installing Husar packages...\n');
201
+ log.blank();
202
+ const installSpin = spinner('Installing Husar packages...');
129
203
  await installHusarPackages(projectPath);
204
+ installSpin.succeed('Packages installed');
205
+ log.substep('@husar.ai/ssr');
206
+ log.substep('@husar.ai/render');
130
207
  }
131
208
 
132
209
  // ═══════════════════════════════════════════════════════════════════════
133
- // STEP 7: Generate CMS files
210
+ // STEP 8: Generate CMS files
134
211
  // ═══════════════════════════════════════════════════════════════════════
135
212
 
136
- console.log('\nšŸ”§ Generating CMS client...\n');
137
- await generateCmsClient(projectPath, options.framework);
213
+ log.blank();
214
+ const genSpin = spinner('Generating CMS client...');
215
+ await generateCmsClient(projectPath, framework);
216
+ genSpin.succeed('CMS client generated');
217
+ log.substep('cms/zeus/ - GraphQL client');
218
+ log.substep('cms/ssr.ts - Server client');
219
+ log.substep('cms/react.ts - React components');
138
220
 
139
221
  // ═══════════════════════════════════════════════════════════════════════
140
222
  // SUCCESS!
141
223
  // ═══════════════════════════════════════════════════════════════════════
142
224
 
143
- console.log('\n' + '═'.repeat(50));
144
- console.log('✨ Project created successfully!');
145
- console.log('═'.repeat(50));
146
- console.log(`\nšŸ“ Project location: ${projectPath}`);
147
- console.log('\nšŸŽÆ Next steps:');
148
- console.log(` cd ${projectName}`);
149
- console.log(' npm run dev\n');
150
- console.log('šŸ“š Documentation: https://docs.husar.ai');
151
- console.log('šŸ’¬ Need help? Join our Discord: https://discord.gg/husar\n');
225
+ successBox(
226
+ `Project created at ${theme.info(projectPath)}\n\n` +
227
+ `Framework: ${theme.muted(framework)}\n` +
228
+ `CMS Host: ${theme.muted(project.host)}`,
229
+ 'Project Ready!',
230
+ );
231
+
232
+ nextSteps([
233
+ `cd ${chalk.cyan(projectName)}`,
234
+ `${chalk.cyan('npm run dev')} to start development`,
235
+ `Open ${theme.link('https://docs.husar.ai')} for documentation`,
236
+ ]);
152
237
  }
153
238
 
154
239
  // ═══════════════════════════════════════════════════════════════════════════
155
240
  // PROMPTS
156
241
  // ═══════════════════════════════════════════════════════════════════════════
157
242
 
158
- /**
159
- * Prompt for connection method
160
- */
161
- async function promptConnectionMethod(): Promise<'manual' | 'cloud'> {
162
- console.log('How do you want to connect to your CMS?\n');
163
- console.log(' 1. Enter project URL manually');
164
- console.log(' 2. Login to husar.ai and select from list\n');
165
-
166
- const choice = await promptChoice(2);
167
- return choice === 1 ? 'manual' : 'cloud';
168
- }
169
-
170
- /**
171
- * Prompt for manual URL entry
172
- */
173
- async function promptManualUrl(): Promise<string> {
174
- return new Promise((resolve) => {
175
- const rl = readline.createInterface({
176
- input: process.stdin,
177
- output: process.stdout,
178
- });
179
-
180
- const ask = () => {
181
- rl.question('\n🌐 Enter your CMS panel URL: ', (answer) => {
182
- const url = answer.trim();
183
-
184
- // Basic validation
185
- if (!url) {
186
- console.log(' URL is required.');
187
- ask();
188
- return;
189
- }
190
-
191
- if (!url.startsWith('http://') && !url.startsWith('https://')) {
192
- console.log(' URL must start with http:// or https://');
193
- ask();
194
- return;
195
- }
196
-
197
- rl.close();
198
- resolve(url);
199
- });
200
- };
201
-
202
- ask();
203
- });
204
- }
205
-
206
243
  /**
207
244
  * Prompt for project selection from list
208
245
  */
209
246
  async function promptSelectProject(projects: CloudProject[]): Promise<CloudProject> {
210
- console.log('Select a project:\n');
211
- projects.forEach((p, i) => {
212
- console.log(` ${i + 1}. ${p.name}`);
213
- console.log(` ${p.adminURL}\n`);
214
- });
215
-
216
- const choice = await promptChoice(projects.length);
217
- return projects[choice - 1]!;
218
- }
219
-
220
- /**
221
- * Prompt for project directory name
222
- */
223
- async function promptProjectName(suggestedName?: string): Promise<string> {
224
- return new Promise((resolve) => {
225
- const rl = readline.createInterface({
226
- input: process.stdin,
227
- output: process.stdout,
228
- });
229
-
230
- const defaultName = suggestedName ? suggestedName.replace(/[^a-zA-Z0-9-_]/g, '-') : '';
231
- const prompt = defaultName
232
- ? `šŸ“ Enter project directory name [${defaultName}]: `
233
- : 'šŸ“ Enter project directory name: ';
234
-
235
- rl.question(prompt, (answer) => {
236
- rl.close();
237
- const name = answer.trim() || defaultName;
238
- resolve(name);
239
- });
240
- });
241
- }
242
-
243
- /**
244
- * Prompt for numeric choice
245
- */
246
- async function promptChoice(max: number): Promise<number> {
247
- return new Promise((resolvePrompt) => {
248
- const rl = readline.createInterface({
249
- input: process.stdin,
250
- output: process.stdout,
251
- });
252
-
253
- const ask = () => {
254
- rl.question(`Enter choice (1-${max}): `, (answer) => {
255
- const num = parseInt(answer, 10);
256
- if (num >= 1 && num <= max) {
257
- rl.close();
258
- resolvePrompt(num);
259
- } else {
260
- console.log(`Please enter a number between 1 and ${max}`);
261
- ask();
262
- }
263
- });
264
- };
265
- ask();
266
- });
247
+ const choice = await select<string>(
248
+ 'Select a project',
249
+ projects.map((p) => ({
250
+ name: p.name,
251
+ message: p.name,
252
+ hint: p.adminURL,
253
+ })),
254
+ );
255
+
256
+ return projects.find((p) => p.name === choice)!;
267
257
  }
268
258
 
269
259
  // ═══════════════════════════════════════════════════════════════════════════
@@ -279,23 +269,25 @@ async function ensureCloudLogin(): Promise<{ accessToken: string; email?: string
279
269
 
280
270
  if (existingAuth) {
281
271
  // Verify the token is still valid
282
- console.log('šŸ” Checking existing cloud session...');
272
+ const spin = spinner('Checking existing session...');
283
273
  const isValid = await verifyAccessToken(existingAuth.accessToken);
284
274
 
285
275
  if (isValid) {
286
- console.log(`āœ… Logged in as: ${existingAuth.email ?? 'user'}`);
276
+ spin.succeed(`Logged in as ${theme.info(existingAuth.email ?? 'user')}`);
287
277
  return existingAuth;
288
278
  }
289
279
 
290
- console.log('āš ļø Session expired, need to re-authenticate...');
280
+ spin.warn('Session expired, need to re-authenticate');
291
281
  }
292
282
 
293
283
  // Need to login
294
- console.log('\nšŸ” Please log in to Husar.ai...\n');
284
+ log.info('Please log in to Husar.ai...');
285
+ log.blank();
286
+
295
287
  const result = await startCloudLoginFlow();
296
288
 
297
289
  if (result.success && result.accessToken) {
298
- console.log(`\nāœ… Logged in as: ${result.email ?? 'user'}`);
290
+ log.success(`Logged in as ${theme.info(result.email ?? 'user')}`);
299
291
  return {
300
292
  accessToken: result.accessToken,
301
293
  email: result.email,
@@ -309,11 +301,63 @@ async function ensureCloudLogin(): Promise<{ accessToken: string; email?: string
309
301
  // FRAMEWORK CLI
310
302
  // ═══════════════════════════════════════════════════════════════════════════
311
303
 
304
+ /**
305
+ * Initialize a clean project (npm init only)
306
+ */
307
+ async function initCleanProject(projectName: string): Promise<boolean> {
308
+ const projectPath = resolve(process.cwd(), projectName);
309
+
310
+ return new Promise((done) => {
311
+ // Create directory
312
+ const mkdirChild = spawn('mkdir', ['-p', projectName], {
313
+ stdio: 'pipe',
314
+ shell: true,
315
+ });
316
+
317
+ mkdirChild.on('close', (mkdirCode) => {
318
+ if (mkdirCode !== 0) {
319
+ done(false);
320
+ return;
321
+ }
322
+
323
+ // Run npm init -y
324
+ const initChild = spawn('npm', ['init', '-y'], {
325
+ cwd: projectPath,
326
+ stdio: 'pipe',
327
+ shell: true,
328
+ });
329
+
330
+ initChild.on('close', async (initCode: number | null) => {
331
+ if (initCode !== 0) {
332
+ done(false);
333
+ return;
334
+ }
335
+
336
+ // Create src directory
337
+ try {
338
+ await fs.mkdir(join(projectPath, 'src'), { recursive: true });
339
+ done(true);
340
+ } catch {
341
+ done(false);
342
+ }
343
+ });
344
+
345
+ initChild.on('error', () => {
346
+ done(false);
347
+ });
348
+ });
349
+
350
+ mkdirChild.on('error', () => {
351
+ done(false);
352
+ });
353
+ });
354
+ }
355
+
312
356
  /**
313
357
  * Run the framework-specific CLI
314
358
  */
315
359
  async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string): Promise<boolean> {
316
- return new Promise((resolve) => {
360
+ return new Promise((resolvePromise) => {
317
361
  let command: string;
318
362
  let args: string[];
319
363
 
@@ -337,7 +381,8 @@ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string
337
381
  args = ['create', 'vite@latest', projectName, '--', '--template', 'react-ts'];
338
382
  }
339
383
 
340
- console.log(`Running: ${command} ${args.join(' ')}\n`);
384
+ log.dim(`Running: ${command} ${args.join(' ')}`);
385
+ log.blank();
341
386
 
342
387
  const child = spawn(command, args, {
343
388
  stdio: 'inherit',
@@ -345,12 +390,11 @@ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string
345
390
  });
346
391
 
347
392
  child.on('close', (code) => {
348
- resolve(code === 0);
393
+ resolvePromise(code === 0);
349
394
  });
350
395
 
351
- child.on('error', (err) => {
352
- console.error('Failed to start framework CLI:', err.message);
353
- resolve(false);
396
+ child.on('error', () => {
397
+ resolvePromise(false);
354
398
  });
355
399
  });
356
400
  }
@@ -359,23 +403,153 @@ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string
359
403
  // CONFIGURATION
360
404
  // ═══════════════════════════════════════════════════════════════════════════
361
405
 
406
+ /**
407
+ * MCP command template for .opencode/command/husar.md
408
+ */
409
+ const HUSAR_COMMAND_TEMPLATE = `# /husar - Husar.ai CMS Operations
410
+
411
+ Use the **husar.ai** MCP server (configured in \`opencode.jsonc\`) for all CMS operations.
412
+
413
+ ## Available MCP Tools
414
+
415
+ ### Discovery & Schema
416
+
417
+ - \`listModels\` — List all model definitions with their field structures
418
+ - \`listViews\` — List all view definitions with their field structures
419
+ - \`listShapes\` — List all shape definitions
420
+ - \`graphQLTypes\` — Get the generated GraphQL SDL schema (understand exact input/output types)
421
+ - \`rootParams\` — Get root parameters (e.g. available \`_language\` options)
422
+ - \`links\` — List internal links
423
+
424
+ ### Shape Inspection
425
+
426
+ - \`shape.getWithDefinition\` — Get complete shape info including definition, fieldSet, and GraphQL types
427
+ - \`shape.model\` — Get full shape configuration
428
+ - \`shape.previewFields\` — Get expanded fields for a shape
429
+ - \`shape.fieldSet\` — Get admin page fieldset for a shape
430
+
431
+ ### Model Operations
432
+
433
+ - \`model.getWithContent\` — **Use before upsert** to understand exact structure and get current content
434
+ - \`model.list\` — List all documents for a model
435
+ - \`model.one\` — Get a single document by slug
436
+ - \`model.upsert\` — Create or update a model document (provide slug + args matching field definitions)
437
+ - \`model.remove\` — Remove a document by slug
438
+
439
+ ### View Operations (singletons)
440
+
441
+ - \`view.getWithContent\` — **Use before upsert** to understand exact structure and get current content
442
+ - \`view.one\` — Get view content
443
+ - \`view.upsert\` — Create or update view content
444
+ - \`view.remove\` — Remove a view entry
445
+
446
+ ### Definition Management
447
+
448
+ - \`upsertModel\` / \`removeModel\` — Manage model definitions (schema)
449
+ - \`upsertView\` / \`removeView\` — Manage view definitions (schema)
450
+ - \`upsertShape\` / \`removeShape\` — Manage shape definitions (schema)
451
+ - \`removeModelWithDocuments\` — Remove a model and all its documents
452
+
453
+ ### Styling
454
+
455
+ - \`style.tailwind.css.get\` — Fetch current Tailwind CSS source and compiled variants
456
+ - \`style.tailwind.css.set\` — Set Tailwind CSS source content (Tailwind v4 compiles it after save)
457
+
458
+ ### AI Generation
459
+
460
+ - \`generateContent\` — Generate content using AI
461
+ - \`generateImage\` — Generate an image using AI
462
+ - \`translateDocument\` — Translate a model document to another language
463
+ - \`translateView\` — Translate a view to another language
464
+
465
+ ### Links
466
+
467
+ - \`upsertLink\` — Create or update an internal link
468
+ - \`removeLink\` — Remove an internal link
469
+
470
+ ### Files
471
+
472
+ - \`uploadFile\` — Get a signed S3 PUT URL for uploading a file
473
+
474
+ ## Workflow Guidelines
475
+
476
+ 1. **Always call \`getWithContent\` before any upsert** — This gives you the exact field structure and current data so you can construct valid input.
477
+ 2. **Use \`graphQLTypes\`** when you need to understand the precise GraphQL input/output types expected by upsert operations.
478
+ 3. **Use \`listModels\` / \`listViews\` / \`listShapes\`** for discovery — find out what content types exist before operating on them.
479
+ 4. **For SHAPE fields**, provide nested objects matching the shape's field structure.
480
+ 5. **For RELATION fields**, provide \`_id\` strings.
481
+ 6. **Root params** (like \`_language\`) can be passed as a \`filter\` parameter to scope operations by locale.
482
+ 7. **Tailwind styles** are managed through the CMS — fetch with \`style.tailwind.css.get\` and update with \`style.tailwind.css.set\`.
483
+ 8. **Draft versions** can be saved by setting \`draft_version: true\` on upsert calls.
484
+
485
+ ## Example Workflows
486
+
487
+ ### Update view content
488
+
489
+ \`\`\`
490
+ 1. view.getWithContent (understand structure + current data)
491
+ 2. view.upsert (provide updated args)
492
+ \`\`\`
493
+
494
+ ### Create a new model document
495
+
496
+ \`\`\`
497
+ 1. model.getWithContent (understand field structure)
498
+ 2. model.upsert (provide slug + args)
499
+ \`\`\`
500
+
501
+ ### Modify CMS schema
502
+
503
+ \`\`\`
504
+ 1. listModels / listViews / listShapes (see what exists)
505
+ 2. graphQLTypes (understand current schema)
506
+ 3. upsertModel / upsertView / upsertShape (modify definitions)
507
+ \`\`\`
508
+
509
+ ### Update Tailwind styles
510
+
511
+ \`\`\`
512
+ 1. style.tailwind.css.get (see current styles)
513
+ 2. style.tailwind.css.set (update with new content)
514
+ \`\`\`
515
+ `;
516
+
517
+ /**
518
+ * Create .opencode/command/husar.md with MCP documentation
519
+ */
520
+ async function createOpencodeCommandFolder(projectPath: string): Promise<void> {
521
+ const commandDir = join(projectPath, '.opencode', 'command');
522
+
523
+ try {
524
+ await fs.mkdir(commandDir, { recursive: true });
525
+ await fs.writeFile(join(commandDir, 'husar.md'), HUSAR_COMMAND_TEMPLATE);
526
+ } catch {
527
+ // Silent fail - not critical
528
+ }
529
+ }
530
+
362
531
  /**
363
532
  * Add Husar configuration files to the project
364
533
  * Uses HYBRID storage: husar.json (committal) + .env.local (secrets)
365
534
  */
366
- async function addHusarConfig(projectPath: string, project: ProjectAuth, framework: 'nextjs' | 'vite'): Promise<void> {
367
- const hostEnvVar = framework === 'nextjs' ? 'NEXT_PUBLIC_HUSAR_HOST' : 'VITE_HUSAR_HOST';
535
+ async function addHusarConfig(
536
+ projectPath: string,
537
+ project: ProjectAuth,
538
+ framework: 'nextjs' | 'vite' | 'clean',
539
+ ): Promise<void> {
540
+ // Use appropriate env var prefix based on framework
541
+ const hostEnvVar =
542
+ framework === 'nextjs' ? 'NEXT_PUBLIC_HUSAR_HOST' : framework === 'vite' ? 'VITE_HUSAR_HOST' : 'HUSAR_HOST';
368
543
 
369
544
  // 1. Create husar.json (committal - NO adminToken!)
370
545
  const husarConfig = {
371
546
  host: project.host,
372
547
  hostEnvironmentVariable: hostEnvVar,
373
548
  authenticationEnvironmentVariable: 'HUSAR_API_KEY',
374
- adminTokenEnv: 'HUSAR_ADMIN_TOKEN', // Points to env var, not actual token
549
+ adminTokenEnv: 'HUSAR_ADMIN_TOKEN',
375
550
  overrideHost: false,
376
551
  };
377
552
  await fs.writeFile(join(projectPath, 'husar.json'), JSON.stringify(husarConfig, null, 2));
378
- console.log(' āœ“ Created husar.json');
379
553
 
380
554
  // 2. Create .env.local (secrets - NOT committed)
381
555
  const envContent = `# Husar CMS Configuration
@@ -391,7 +565,6 @@ HUSAR_API_KEY=
391
565
  HUSAR_ADMIN_TOKEN=${project.adminToken}
392
566
  `;
393
567
  await fs.writeFile(join(projectPath, '.env.local'), envContent);
394
- console.log(' āœ“ Created .env.local (contains admin credentials)');
395
568
 
396
569
  // 3. Create opencode.jsonc for MCP integration
397
570
  const opencodeConfig = {
@@ -405,9 +578,11 @@ HUSAR_ADMIN_TOKEN=${project.adminToken}
405
578
  },
406
579
  };
407
580
  await fs.writeFile(join(projectPath, 'opencode.jsonc'), JSON.stringify(opencodeConfig, null, 2));
408
- console.log(' āœ“ Created opencode.jsonc (MCP configuration)');
409
581
 
410
- // 4. Update .gitignore
582
+ // 4. Create .opencode/command/husar.md (MCP command documentation)
583
+ await createOpencodeCommandFolder(projectPath);
584
+
585
+ // 5. Update .gitignore
411
586
  const gitignorePath = join(projectPath, '.gitignore');
412
587
  try {
413
588
  let gitignore = await fs.readFile(gitignorePath, 'utf-8');
@@ -423,12 +598,10 @@ HUSAR_ADMIN_TOKEN=${project.adminToken}
423
598
  if (additions.length > 0) {
424
599
  gitignore += '\n# Local environment files (contain secrets)\n' + additions.join('\n') + '\n';
425
600
  await fs.writeFile(gitignorePath, gitignore);
426
- console.log(' āœ“ Updated .gitignore');
427
601
  }
428
602
  } catch {
429
603
  // .gitignore doesn't exist, create it
430
604
  await fs.writeFile(gitignorePath, '# Local environment files (contain secrets)\n.env.local\n.env*.local\n');
431
- console.log(' āœ“ Created .gitignore');
432
605
  }
433
606
  }
434
607
 
@@ -436,24 +609,21 @@ HUSAR_ADMIN_TOKEN=${project.adminToken}
436
609
  * Install Husar packages
437
610
  */
438
611
  async function installHusarPackages(projectPath: string): Promise<void> {
439
- return new Promise((resolve) => {
612
+ return new Promise((resolvePromise) => {
440
613
  const packages = ['@husar.ai/ssr', '@husar.ai/render'];
441
614
 
442
615
  const child = spawn('npm', ['install', ...packages], {
443
616
  cwd: projectPath,
444
- stdio: 'inherit',
617
+ stdio: 'pipe',
445
618
  shell: true,
446
619
  });
447
620
 
448
621
  child.on('close', () => {
449
- console.log(' āœ“ Installed @husar.ai/ssr and @husar.ai/render');
450
- resolve();
622
+ resolvePromise();
451
623
  });
452
624
 
453
- child.on('error', (err) => {
454
- console.error('Warning: Failed to install packages:', err.message);
455
- console.log(' Run manually: npm install @husar.ai/ssr @husar.ai/render');
456
- resolve();
625
+ child.on('error', () => {
626
+ resolvePromise();
457
627
  });
458
628
  });
459
629
  }
@@ -461,29 +631,23 @@ async function installHusarPackages(projectPath: string): Promise<void> {
461
631
  /**
462
632
  * Generate CMS client files using husar generate
463
633
  */
464
- async function generateCmsClient(projectPath: string, framework: 'nextjs' | 'vite'): Promise<void> {
465
- // Determine src folder based on framework
466
- const srcFolder = framework === 'nextjs' ? './src' : './src';
634
+ async function generateCmsClient(projectPath: string, framework: 'nextjs' | 'vite' | 'clean'): Promise<void> {
635
+ // All frameworks use ./src
636
+ const srcFolder = './src';
467
637
 
468
- return new Promise((resolve) => {
638
+ return new Promise((resolvePromise) => {
469
639
  const child = spawn('npx', ['@husar.ai/cli', 'generate', srcFolder], {
470
640
  cwd: projectPath,
471
- stdio: 'inherit',
641
+ stdio: 'pipe',
472
642
  shell: true,
473
643
  });
474
644
 
475
- child.on('close', (code) => {
476
- if (code === 0) {
477
- console.log(' āœ“ Generated cms/ folder with Zeus client');
478
- } else {
479
- console.log(' ⚠ CMS generation may have failed - you can run "husar generate" later');
480
- }
481
- resolve();
645
+ child.on('close', () => {
646
+ resolvePromise();
482
647
  });
483
648
 
484
649
  child.on('error', () => {
485
- console.log(' ⚠ Could not generate CMS client - run "husar generate" manually');
486
- resolve();
650
+ resolvePromise();
487
651
  });
488
652
  });
489
653
  }