@husar.ai/cli 0.4.3 → 0.4.5

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,227 @@ 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
+ ]);
237
+
238
+ process.exit(0);
152
239
  }
153
240
 
154
241
  // ═══════════════════════════════════════════════════════════════════════════
155
242
  // PROMPTS
156
243
  // ═══════════════════════════════════════════════════════════════════════════
157
244
 
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
245
  /**
207
246
  * Prompt for project selection from list
208
247
  */
209
248
  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
- });
249
+ const choice = await select<string>(
250
+ 'Select a project',
251
+ projects.map((p) => ({
252
+ name: p.name,
253
+ message: p.name,
254
+ hint: p.adminURL,
255
+ })),
256
+ );
257
+
258
+ return projects.find((p) => p.name === choice)!;
267
259
  }
268
260
 
269
261
  // ═══════════════════════════════════════════════════════════════════════════
@@ -279,23 +271,25 @@ async function ensureCloudLogin(): Promise<{ accessToken: string; email?: string
279
271
 
280
272
  if (existingAuth) {
281
273
  // Verify the token is still valid
282
- console.log('šŸ” Checking existing cloud session...');
274
+ const spin = spinner('Checking existing session...');
283
275
  const isValid = await verifyAccessToken(existingAuth.accessToken);
284
276
 
285
277
  if (isValid) {
286
- console.log(`āœ… Logged in as: ${existingAuth.email ?? 'user'}`);
278
+ spin.succeed(`Logged in as ${theme.info(existingAuth.email ?? 'user')}`);
287
279
  return existingAuth;
288
280
  }
289
281
 
290
- console.log('āš ļø Session expired, need to re-authenticate...');
282
+ spin.warn('Session expired, need to re-authenticate');
291
283
  }
292
284
 
293
285
  // Need to login
294
- console.log('\nšŸ” Please log in to Husar.ai...\n');
286
+ log.info('Please log in to Husar.ai...');
287
+ log.blank();
288
+
295
289
  const result = await startCloudLoginFlow();
296
290
 
297
291
  if (result.success && result.accessToken) {
298
- console.log(`\nāœ… Logged in as: ${result.email ?? 'user'}`);
292
+ log.success(`Logged in as ${theme.info(result.email ?? 'user')}`);
299
293
  return {
300
294
  accessToken: result.accessToken,
301
295
  email: result.email,
@@ -309,11 +303,63 @@ async function ensureCloudLogin(): Promise<{ accessToken: string; email?: string
309
303
  // FRAMEWORK CLI
310
304
  // ═══════════════════════════════════════════════════════════════════════════
311
305
 
306
+ /**
307
+ * Initialize a clean project (npm init only)
308
+ */
309
+ async function initCleanProject(projectName: string): Promise<boolean> {
310
+ const projectPath = resolve(process.cwd(), projectName);
311
+
312
+ return new Promise((done) => {
313
+ // Create directory
314
+ const mkdirChild = spawn('mkdir', ['-p', projectName], {
315
+ stdio: 'pipe',
316
+ shell: true,
317
+ });
318
+
319
+ mkdirChild.on('close', (mkdirCode) => {
320
+ if (mkdirCode !== 0) {
321
+ done(false);
322
+ return;
323
+ }
324
+
325
+ // Run npm init -y
326
+ const initChild = spawn('npm', ['init', '-y'], {
327
+ cwd: projectPath,
328
+ stdio: 'pipe',
329
+ shell: true,
330
+ });
331
+
332
+ initChild.on('close', async (initCode: number | null) => {
333
+ if (initCode !== 0) {
334
+ done(false);
335
+ return;
336
+ }
337
+
338
+ // Create src directory
339
+ try {
340
+ await fs.mkdir(join(projectPath, 'src'), { recursive: true });
341
+ done(true);
342
+ } catch {
343
+ done(false);
344
+ }
345
+ });
346
+
347
+ initChild.on('error', () => {
348
+ done(false);
349
+ });
350
+ });
351
+
352
+ mkdirChild.on('error', () => {
353
+ done(false);
354
+ });
355
+ });
356
+ }
357
+
312
358
  /**
313
359
  * Run the framework-specific CLI
314
360
  */
315
361
  async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string): Promise<boolean> {
316
- return new Promise((resolve) => {
362
+ return new Promise((resolvePromise) => {
317
363
  let command: string;
318
364
  let args: string[];
319
365
 
@@ -337,7 +383,8 @@ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string
337
383
  args = ['create', 'vite@latest', projectName, '--', '--template', 'react-ts'];
338
384
  }
339
385
 
340
- console.log(`Running: ${command} ${args.join(' ')}\n`);
386
+ log.dim(`Running: ${command} ${args.join(' ')}`);
387
+ log.blank();
341
388
 
342
389
  const child = spawn(command, args, {
343
390
  stdio: 'inherit',
@@ -345,12 +392,11 @@ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string
345
392
  });
346
393
 
347
394
  child.on('close', (code) => {
348
- resolve(code === 0);
395
+ resolvePromise(code === 0);
349
396
  });
350
397
 
351
- child.on('error', (err) => {
352
- console.error('Failed to start framework CLI:', err.message);
353
- resolve(false);
398
+ child.on('error', () => {
399
+ resolvePromise(false);
354
400
  });
355
401
  });
356
402
  }
@@ -359,23 +405,153 @@ async function runFrameworkCli(framework: 'nextjs' | 'vite', projectName: string
359
405
  // CONFIGURATION
360
406
  // ═══════════════════════════════════════════════════════════════════════════
361
407
 
408
+ /**
409
+ * MCP command template for .opencode/command/husar.md
410
+ */
411
+ const HUSAR_COMMAND_TEMPLATE = `# /husar - Husar.ai CMS Operations
412
+
413
+ Use the **husar.ai** MCP server (configured in \`opencode.jsonc\`) for all CMS operations.
414
+
415
+ ## Available MCP Tools
416
+
417
+ ### Discovery & Schema
418
+
419
+ - \`listModels\` — List all model definitions with their field structures
420
+ - \`listViews\` — List all view definitions with their field structures
421
+ - \`listShapes\` — List all shape definitions
422
+ - \`graphQLTypes\` — Get the generated GraphQL SDL schema (understand exact input/output types)
423
+ - \`rootParams\` — Get root parameters (e.g. available \`_language\` options)
424
+ - \`links\` — List internal links
425
+
426
+ ### Shape Inspection
427
+
428
+ - \`shape.getWithDefinition\` — Get complete shape info including definition, fieldSet, and GraphQL types
429
+ - \`shape.model\` — Get full shape configuration
430
+ - \`shape.previewFields\` — Get expanded fields for a shape
431
+ - \`shape.fieldSet\` — Get admin page fieldset for a shape
432
+
433
+ ### Model Operations
434
+
435
+ - \`model.getWithContent\` — **Use before upsert** to understand exact structure and get current content
436
+ - \`model.list\` — List all documents for a model
437
+ - \`model.one\` — Get a single document by slug
438
+ - \`model.upsert\` — Create or update a model document (provide slug + args matching field definitions)
439
+ - \`model.remove\` — Remove a document by slug
440
+
441
+ ### View Operations (singletons)
442
+
443
+ - \`view.getWithContent\` — **Use before upsert** to understand exact structure and get current content
444
+ - \`view.one\` — Get view content
445
+ - \`view.upsert\` — Create or update view content
446
+ - \`view.remove\` — Remove a view entry
447
+
448
+ ### Definition Management
449
+
450
+ - \`upsertModel\` / \`removeModel\` — Manage model definitions (schema)
451
+ - \`upsertView\` / \`removeView\` — Manage view definitions (schema)
452
+ - \`upsertShape\` / \`removeShape\` — Manage shape definitions (schema)
453
+ - \`removeModelWithDocuments\` — Remove a model and all its documents
454
+
455
+ ### Styling
456
+
457
+ - \`style.tailwind.css.get\` — Fetch current Tailwind CSS source and compiled variants
458
+ - \`style.tailwind.css.set\` — Set Tailwind CSS source content (Tailwind v4 compiles it after save)
459
+
460
+ ### AI Generation
461
+
462
+ - \`generateContent\` — Generate content using AI
463
+ - \`generateImage\` — Generate an image using AI
464
+ - \`translateDocument\` — Translate a model document to another language
465
+ - \`translateView\` — Translate a view to another language
466
+
467
+ ### Links
468
+
469
+ - \`upsertLink\` — Create or update an internal link
470
+ - \`removeLink\` — Remove an internal link
471
+
472
+ ### Files
473
+
474
+ - \`uploadFile\` — Get a signed S3 PUT URL for uploading a file
475
+
476
+ ## Workflow Guidelines
477
+
478
+ 1. **Always call \`getWithContent\` before any upsert** — This gives you the exact field structure and current data so you can construct valid input.
479
+ 2. **Use \`graphQLTypes\`** when you need to understand the precise GraphQL input/output types expected by upsert operations.
480
+ 3. **Use \`listModels\` / \`listViews\` / \`listShapes\`** for discovery — find out what content types exist before operating on them.
481
+ 4. **For SHAPE fields**, provide nested objects matching the shape's field structure.
482
+ 5. **For RELATION fields**, provide \`_id\` strings.
483
+ 6. **Root params** (like \`_language\`) can be passed as a \`filter\` parameter to scope operations by locale.
484
+ 7. **Tailwind styles** are managed through the CMS — fetch with \`style.tailwind.css.get\` and update with \`style.tailwind.css.set\`.
485
+ 8. **Draft versions** can be saved by setting \`draft_version: true\` on upsert calls.
486
+
487
+ ## Example Workflows
488
+
489
+ ### Update view content
490
+
491
+ \`\`\`
492
+ 1. view.getWithContent (understand structure + current data)
493
+ 2. view.upsert (provide updated args)
494
+ \`\`\`
495
+
496
+ ### Create a new model document
497
+
498
+ \`\`\`
499
+ 1. model.getWithContent (understand field structure)
500
+ 2. model.upsert (provide slug + args)
501
+ \`\`\`
502
+
503
+ ### Modify CMS schema
504
+
505
+ \`\`\`
506
+ 1. listModels / listViews / listShapes (see what exists)
507
+ 2. graphQLTypes (understand current schema)
508
+ 3. upsertModel / upsertView / upsertShape (modify definitions)
509
+ \`\`\`
510
+
511
+ ### Update Tailwind styles
512
+
513
+ \`\`\`
514
+ 1. style.tailwind.css.get (see current styles)
515
+ 2. style.tailwind.css.set (update with new content)
516
+ \`\`\`
517
+ `;
518
+
519
+ /**
520
+ * Create .opencode/command/husar.md with MCP documentation
521
+ */
522
+ async function createOpencodeCommandFolder(projectPath: string): Promise<void> {
523
+ const commandDir = join(projectPath, '.opencode', 'command');
524
+
525
+ try {
526
+ await fs.mkdir(commandDir, { recursive: true });
527
+ await fs.writeFile(join(commandDir, 'husar.md'), HUSAR_COMMAND_TEMPLATE);
528
+ } catch {
529
+ // Silent fail - not critical
530
+ }
531
+ }
532
+
362
533
  /**
363
534
  * Add Husar configuration files to the project
364
535
  * Uses HYBRID storage: husar.json (committal) + .env.local (secrets)
365
536
  */
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';
537
+ async function addHusarConfig(
538
+ projectPath: string,
539
+ project: ProjectAuth,
540
+ framework: 'nextjs' | 'vite' | 'clean',
541
+ ): Promise<void> {
542
+ // Use appropriate env var prefix based on framework
543
+ const hostEnvVar =
544
+ framework === 'nextjs' ? 'NEXT_PUBLIC_HUSAR_HOST' : framework === 'vite' ? 'VITE_HUSAR_HOST' : 'HUSAR_HOST';
368
545
 
369
546
  // 1. Create husar.json (committal - NO adminToken!)
370
547
  const husarConfig = {
371
548
  host: project.host,
372
549
  hostEnvironmentVariable: hostEnvVar,
373
550
  authenticationEnvironmentVariable: 'HUSAR_API_KEY',
374
- adminTokenEnv: 'HUSAR_ADMIN_TOKEN', // Points to env var, not actual token
551
+ adminTokenEnv: 'HUSAR_ADMIN_TOKEN',
375
552
  overrideHost: false,
376
553
  };
377
554
  await fs.writeFile(join(projectPath, 'husar.json'), JSON.stringify(husarConfig, null, 2));
378
- console.log(' āœ“ Created husar.json');
379
555
 
380
556
  // 2. Create .env.local (secrets - NOT committed)
381
557
  const envContent = `# Husar CMS Configuration
@@ -391,7 +567,6 @@ HUSAR_API_KEY=
391
567
  HUSAR_ADMIN_TOKEN=${project.adminToken}
392
568
  `;
393
569
  await fs.writeFile(join(projectPath, '.env.local'), envContent);
394
- console.log(' āœ“ Created .env.local (contains admin credentials)');
395
570
 
396
571
  // 3. Create opencode.jsonc for MCP integration
397
572
  const opencodeConfig = {
@@ -405,9 +580,11 @@ HUSAR_ADMIN_TOKEN=${project.adminToken}
405
580
  },
406
581
  };
407
582
  await fs.writeFile(join(projectPath, 'opencode.jsonc'), JSON.stringify(opencodeConfig, null, 2));
408
- console.log(' āœ“ Created opencode.jsonc (MCP configuration)');
409
583
 
410
- // 4. Update .gitignore
584
+ // 4. Create .opencode/command/husar.md (MCP command documentation)
585
+ await createOpencodeCommandFolder(projectPath);
586
+
587
+ // 5. Update .gitignore
411
588
  const gitignorePath = join(projectPath, '.gitignore');
412
589
  try {
413
590
  let gitignore = await fs.readFile(gitignorePath, 'utf-8');
@@ -423,12 +600,10 @@ HUSAR_ADMIN_TOKEN=${project.adminToken}
423
600
  if (additions.length > 0) {
424
601
  gitignore += '\n# Local environment files (contain secrets)\n' + additions.join('\n') + '\n';
425
602
  await fs.writeFile(gitignorePath, gitignore);
426
- console.log(' āœ“ Updated .gitignore');
427
603
  }
428
604
  } catch {
429
605
  // .gitignore doesn't exist, create it
430
606
  await fs.writeFile(gitignorePath, '# Local environment files (contain secrets)\n.env.local\n.env*.local\n');
431
- console.log(' āœ“ Created .gitignore');
432
607
  }
433
608
  }
434
609
 
@@ -436,24 +611,21 @@ HUSAR_ADMIN_TOKEN=${project.adminToken}
436
611
  * Install Husar packages
437
612
  */
438
613
  async function installHusarPackages(projectPath: string): Promise<void> {
439
- return new Promise((resolve) => {
614
+ return new Promise((resolvePromise) => {
440
615
  const packages = ['@husar.ai/ssr', '@husar.ai/render'];
441
616
 
442
617
  const child = spawn('npm', ['install', ...packages], {
443
618
  cwd: projectPath,
444
- stdio: 'inherit',
619
+ stdio: 'pipe',
445
620
  shell: true,
446
621
  });
447
622
 
448
623
  child.on('close', () => {
449
- console.log(' āœ“ Installed @husar.ai/ssr and @husar.ai/render');
450
- resolve();
624
+ resolvePromise();
451
625
  });
452
626
 
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();
627
+ child.on('error', () => {
628
+ resolvePromise();
457
629
  });
458
630
  });
459
631
  }
@@ -461,29 +633,23 @@ async function installHusarPackages(projectPath: string): Promise<void> {
461
633
  /**
462
634
  * Generate CMS client files using husar generate
463
635
  */
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';
636
+ async function generateCmsClient(projectPath: string, framework: 'nextjs' | 'vite' | 'clean'): Promise<void> {
637
+ // All frameworks use ./src
638
+ const srcFolder = './src';
467
639
 
468
- return new Promise((resolve) => {
640
+ return new Promise((resolvePromise) => {
469
641
  const child = spawn('npx', ['@husar.ai/cli', 'generate', srcFolder], {
470
642
  cwd: projectPath,
471
- stdio: 'inherit',
643
+ stdio: 'pipe',
472
644
  shell: true,
473
645
  });
474
646
 
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();
647
+ child.on('close', () => {
648
+ resolvePromise();
482
649
  });
483
650
 
484
651
  child.on('error', () => {
485
- console.log(' ⚠ Could not generate CMS client - run "husar generate" manually');
486
- resolve();
652
+ resolvePromise();
487
653
  });
488
654
  });
489
655
  }