Skip to content

Commit ae2a633

Browse files
roblevintennisRob Levin
andauthored
feat(cli): add --sdui flag to ag init (#423) (#431)
Adds --sdui to the init command so users can go from impressed by the SDUI demo to a working scaffold in a single command: npx agnosticui-cli init -f react --sdui When the flag is set, after standard init completes, scaffoldSdui(): - Creates src/sdui/fixture.ts with a minimal 4-node contact form - Creates src/sdui/SduiDemo.{tsx|vue|ts} wired to AgDynamicRenderer and a simple dark-mode toggle, for all three frameworks - Installs @agnosticui/render-{framework} + @agnosticui/schema (gracefully degrades with a manual-install hint if packages are not yet on npm) - Prints a "SDUI Scaffold Ready" box with import/usage instructions Closes #423 Co-authored-by: Rob Levin <roblevinillustration@gmail.com>
1 parent a75e43d commit ae2a633

File tree

5 files changed

+271
-2
lines changed

5 files changed

+271
-2
lines changed

v2/cli/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

v2/cli/src/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ program
7272
"--force",
7373
"Re-initialize even if already initialized (reuses existing framework and path)",
7474
)
75+
.option(
76+
"--sdui",
77+
"Scaffold a minimal Schema-Driven UI app (installs renderer, writes fixture + demo component)",
78+
)
7579
.action(async (options) => {
7680
await init({
7781
framework: options.framework as Framework | undefined,
@@ -80,6 +84,7 @@ program
8084
tag: options.tag,
8185
skipPrompts: options.skipPrompts,
8286
force: options.force,
87+
sdui: options.sdui,
8388
});
8489
});
8590

v2/cli/src/commands/init.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,11 @@ export async function init(options: InitOptions = {}): Promise<void> {
265265
' ' + logger.code(exampleImport),
266266
]);
267267

268+
// SDUI scaffolding (when --sdui flag is set)
269+
if (options.sdui) {
270+
await scaffoldSdui(framework, options.skipPrompts);
271+
}
272+
268273
// Clean up temporary download directory if it exists
269274
await cleanupTempDownload();
270275
} catch (error) {
@@ -505,6 +510,209 @@ function showTypeScriptNote(wasUpdated: boolean = false): void {
505510
}
506511
}
507512

513+
/**
514+
* Scaffold a minimal Schema-Driven UI app for the given framework.
515+
* Creates src/sdui/fixture.ts and src/sdui/SduiDemo.{tsx|vue|ts}, then
516+
* installs the appropriate renderer package.
517+
*/
518+
async function scaffoldSdui(framework: Framework, skipPrompts: boolean = false): Promise<void> {
519+
logger.newline();
520+
logger.info(pc.cyan('Schema-Driven UI') + ' — scaffolding starter files...');
521+
522+
const sduiDir = path.join(process.cwd(), 'src', 'sdui');
523+
await ensureDir(sduiDir);
524+
525+
// Write shared fixture file
526+
const fixtureContent = `import type { AgNode } from '@agnosticui/schema';
527+
528+
export const fixture: AgNode[] = [
529+
{ id: 'f-name', component: 'AgInput', label: 'Full name', type: 'text', placeholder: 'Jane Smith', required: true, rounded: true },
530+
{ id: 'f-email', component: 'AgInput', label: 'Email', type: 'email', placeholder: 'jane@example.com', required: true, rounded: true },
531+
{ id: 'f-submit', component: 'AgButton', variant: 'primary', type: 'submit', shape: 'rounded', on_click: 'SUBMIT', children: ['f-submit-label'] },
532+
{ id: 'f-submit-label', component: 'AgText', text: 'Send message' },
533+
];
534+
`;
535+
await writeFile(path.join(sduiDir, 'fixture.ts'), fixtureContent);
536+
logger.info(pc.green('✓') + ' Created ' + pc.dim('src/sdui/fixture.ts'));
537+
538+
// Write framework-specific demo component
539+
if (framework === 'react') {
540+
const demoContent = `import { useState } from 'react';
541+
import { AgDynamicRenderer } from '@agnosticui/render-react';
542+
import type { AgNode } from '@agnosticui/schema';
543+
import { fixture } from './fixture';
544+
545+
function SkinToggle() {
546+
const toggle = () => {
547+
const root = document.documentElement;
548+
root.setAttribute('data-theme', root.getAttribute('data-theme') === 'dark' ? '' : 'dark');
549+
};
550+
return (
551+
<button
552+
onClick={toggle}
553+
style={{ position: 'fixed', bottom: '1rem', right: '1rem', padding: '0.5rem 1rem', cursor: 'pointer' }}
554+
>
555+
Toggle dark
556+
</button>
557+
);
558+
}
559+
560+
export function SduiDemo() {
561+
const [nodes] = useState<AgNode[]>(fixture);
562+
return (
563+
<div style={{ maxWidth: '600px', margin: '2rem auto', padding: '0 1rem' }}>
564+
<h1>Schema-Driven UI</h1>
565+
<AgDynamicRenderer nodes={nodes} actions={{}} />
566+
<SkinToggle />
567+
</div>
568+
);
569+
}
570+
`;
571+
await writeFile(path.join(sduiDir, 'SduiDemo.tsx'), demoContent);
572+
logger.info(pc.green('✓') + ' Created ' + pc.dim('src/sdui/SduiDemo.tsx'));
573+
} else if (framework === 'vue') {
574+
const demoContent = `<script setup lang="ts">
575+
import { ref } from 'vue';
576+
import { AgDynamicRenderer } from '@agnosticui/render-vue';
577+
import type { AgNode } from '@agnosticui/schema';
578+
import { fixture } from './fixture';
579+
580+
const nodes = ref<AgNode[]>(fixture);
581+
582+
function toggleDark() {
583+
const root = document.documentElement;
584+
root.setAttribute('data-theme', root.getAttribute('data-theme') === 'dark' ? '' : 'dark');
585+
}
586+
</script>
587+
588+
<template>
589+
<div style="max-width: 600px; margin: 2rem auto; padding: 0 1rem">
590+
<h1>Schema-Driven UI</h1>
591+
<AgDynamicRenderer :nodes="nodes" :actions="{}" />
592+
<button
593+
@click="toggleDark"
594+
style="position: fixed; bottom: 1rem; right: 1rem; padding: 0.5rem 1rem; cursor: pointer"
595+
>
596+
Toggle dark
597+
</button>
598+
</div>
599+
</template>
600+
`;
601+
await writeFile(path.join(sduiDir, 'SduiDemo.vue'), demoContent);
602+
logger.info(pc.green('✓') + ' Created ' + pc.dim('src/sdui/SduiDemo.vue'));
603+
} else {
604+
// Lit (and other web-component-based frameworks)
605+
const demoContent = `import { LitElement, html, css } from 'lit';
606+
import { state } from 'lit/decorators.js';
607+
import '@agnosticui/render-lit';
608+
import type { AgNode } from '@agnosticui/schema';
609+
import { fixture } from './fixture';
610+
611+
export class SduiDemo extends LitElement {
612+
static styles = css\`
613+
:host { display: block; }
614+
.container { max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
615+
.skin-toggle { position: fixed; bottom: 1rem; right: 1rem; padding: 0.5rem 1rem; cursor: pointer; }
616+
\`;
617+
618+
@state() private nodes: AgNode[] = fixture;
619+
620+
private toggleDark() {
621+
const root = document.documentElement;
622+
root.setAttribute('data-theme', root.getAttribute('data-theme') === 'dark' ? '' : 'dark');
623+
}
624+
625+
render() {
626+
return html\`
627+
<div class="container">
628+
<h1>Schema-Driven UI</h1>
629+
<ag-dynamic-renderer .nodes=\${this.nodes} .actions=\${{}}></ag-dynamic-renderer>
630+
<button class="skin-toggle" @click=\${this.toggleDark}>Toggle dark</button>
631+
</div>
632+
\`;
633+
}
634+
}
635+
636+
customElements.define('ag-sdui-demo', SduiDemo);
637+
`;
638+
await writeFile(path.join(sduiDir, 'SduiDemo.ts'), demoContent);
639+
logger.info(pc.green('✓') + ' Created ' + pc.dim('src/sdui/SduiDemo.ts'));
640+
}
641+
642+
// Install renderer package
643+
const rendererPkg =
644+
framework === 'react' ? '@agnosticui/render-react' :
645+
framework === 'vue' ? '@agnosticui/render-vue' :
646+
'@agnosticui/render-lit';
647+
const sduiDeps = [rendererPkg, '@agnosticui/schema'];
648+
const packageManager = detectPackageManager();
649+
650+
if (checkDependenciesInstalled(sduiDeps)) {
651+
logger.info('SDUI renderer already installed: ' + pc.dim(sduiDeps.join(', ')));
652+
} else {
653+
let shouldInstall = true;
654+
655+
if (!skipPrompts) {
656+
logger.newline();
657+
logger.info('SDUI requires the following packages:');
658+
sduiDeps.forEach(dep => console.log(' ' + pc.cyan(dep)));
659+
logger.newline();
660+
661+
const answer = await p.confirm({
662+
message: `Install using ${pc.cyan(packageManager)}?`,
663+
initialValue: true,
664+
});
665+
666+
if (p.isCancel(answer) || !answer) {
667+
shouldInstall = false;
668+
logger.warn('Skipped SDUI dependency installation.');
669+
logger.info(`Install manually: ${pc.cyan(`${packageManager} ${packageManager === 'npm' ? 'install' : 'add'} ${sduiDeps.join(' ')}`)}`);
670+
}
671+
}
672+
673+
if (shouldInstall) {
674+
const spinner = p.spinner();
675+
spinner.start('Installing SDUI renderer...');
676+
try {
677+
installDependencies(sduiDeps);
678+
spinner.stop(pc.green('✓') + ' SDUI renderer installed!');
679+
} catch (error) {
680+
spinner.stop(pc.red('✖') + ' Failed to install SDUI renderer');
681+
logger.error(`Installation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
682+
logger.info(`Install manually: ${pc.cyan(`${packageManager} ${packageManager === 'npm' ? 'install' : 'add'} ${sduiDeps.join(' ')}`)}`);
683+
}
684+
}
685+
}
686+
687+
const demoFile =
688+
framework === 'react' ? 'SduiDemo.tsx' :
689+
framework === 'vue' ? 'SduiDemo.vue' :
690+
'SduiDemo.ts';
691+
692+
const importSnippet =
693+
framework === 'react' ? `import { SduiDemo } from './sdui/SduiDemo'` :
694+
framework === 'vue' ? `import SduiDemo from './sdui/SduiDemo.vue'` :
695+
`import './sdui/SduiDemo'`;
696+
697+
const useSnippet =
698+
framework === 'react' ? `<SduiDemo />` :
699+
framework === 'vue' ? `<SduiDemo />` :
700+
`<ag-sdui-demo></ag-sdui-demo>`;
701+
702+
logger.newline();
703+
logger.box('SDUI Scaffold Ready:', [
704+
pc.dim('Files created:'),
705+
' ' + pc.cyan('src/sdui/fixture.ts') + pc.dim(' — edit this to change the rendered UI'),
706+
' ' + pc.cyan(`src/sdui/${demoFile}`) + pc.dim(' — AgDynamicRenderer wired to fixture'),
707+
'',
708+
pc.dim('Wire it into your App:'),
709+
' ' + logger.code(importSnippet),
710+
' ' + logger.code(useSnippet),
711+
'',
712+
pc.dim('Learn more: https://www.agnosticui.com/sdui.html'),
713+
]);
714+
}
715+
508716
/**
509717
* Strip comments from JSON content
510718
*

v2/cli/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface InitOptions {
6363
tag?: string; // NPM dist-tag or version (e.g., 'alpha', 'latest', '2.0.0-alpha.21')
6464
skipPrompts?: boolean; // Skip all interactive prompts (non-interactive mode)
6565
force?: boolean; // Re-initialize even if already initialized
66+
sdui?: boolean; // Scaffold a minimal SDUI app after init
6667
}
6768

6869
export interface SyncOptions {

v2/cli/test/init.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,59 @@ describe('ag init', () => {
7474
await init({ tarball: tarballFile, skipPrompts: true, force: true });
7575
expect(existsSync(path.join(tmpDir, 'agnosticui.config.json'))).toBe(true);
7676
});
77+
78+
describe('--sdui flag', () => {
79+
it('creates src/sdui/fixture.ts for react', async () => {
80+
await initPackageJson(tmpDir);
81+
await init({ framework: 'react', tarball: tarballFile, skipPrompts: true, sdui: true });
82+
expect(existsSync(path.join(tmpDir, 'src', 'sdui', 'fixture.ts'))).toBe(true);
83+
});
84+
85+
it('creates src/sdui/SduiDemo.tsx for react', async () => {
86+
await initPackageJson(tmpDir);
87+
await init({ framework: 'react', tarball: tarballFile, skipPrompts: true, sdui: true });
88+
expect(existsSync(path.join(tmpDir, 'src', 'sdui', 'SduiDemo.tsx'))).toBe(true);
89+
});
90+
91+
it('fixture.ts contains AgNode import and fixture export', async () => {
92+
await initPackageJson(tmpDir);
93+
await init({ framework: 'react', tarball: tarballFile, skipPrompts: true, sdui: true });
94+
const content = await readFile(path.join(tmpDir, 'src', 'sdui', 'fixture.ts'), 'utf-8');
95+
expect(content).toContain("from '@agnosticui/schema'");
96+
expect(content).toContain('export const fixture');
97+
expect(content).toContain('AgInput');
98+
expect(content).toContain('AgButton');
99+
});
100+
101+
it('SduiDemo.tsx imports AgDynamicRenderer from render-react', async () => {
102+
await initPackageJson(tmpDir);
103+
await init({ framework: 'react', tarball: tarballFile, skipPrompts: true, sdui: true });
104+
const content = await readFile(path.join(tmpDir, 'src', 'sdui', 'SduiDemo.tsx'), 'utf-8');
105+
expect(content).toContain("from '@agnosticui/render-react'");
106+
expect(content).toContain('AgDynamicRenderer');
107+
});
108+
109+
it('creates src/sdui/SduiDemo.vue for vue', async () => {
110+
await initPackageJson(tmpDir);
111+
await init({ framework: 'vue', tarball: tarballFile, skipPrompts: true, sdui: true });
112+
expect(existsSync(path.join(tmpDir, 'src', 'sdui', 'SduiDemo.vue'))).toBe(true);
113+
const content = await readFile(path.join(tmpDir, 'src', 'sdui', 'SduiDemo.vue'), 'utf-8');
114+
expect(content).toContain("from '@agnosticui/render-vue'");
115+
});
116+
117+
it('creates src/sdui/SduiDemo.ts for lit', async () => {
118+
await initPackageJson(tmpDir);
119+
await init({ framework: 'lit', tarball: tarballFile, skipPrompts: true, sdui: true });
120+
expect(existsSync(path.join(tmpDir, 'src', 'sdui', 'SduiDemo.ts'))).toBe(true);
121+
const content = await readFile(path.join(tmpDir, 'src', 'sdui', 'SduiDemo.ts'), 'utf-8');
122+
expect(content).toContain("'@agnosticui/render-lit'");
123+
expect(content).toContain('ag-dynamic-renderer');
124+
});
125+
126+
it('does not create sdui files when --sdui flag is absent', async () => {
127+
await initPackageJson(tmpDir);
128+
await init({ framework: 'react', tarball: tarballFile, skipPrompts: true });
129+
expect(existsSync(path.join(tmpDir, 'src', 'sdui'))).toBe(false);
130+
});
131+
});
77132
});

0 commit comments

Comments
 (0)