@sufleur/cli 0.1.0

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.
Files changed (4) hide show
  1. package/README.md +139 -0
  2. package/install.js +215 -0
  3. package/package.json +58 -0
  4. package/run.js +41 -0
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # @sufleur/cli
2
+
3
+ The CLI for [**Sufleur**](https://sufleur.com) — the registry where you author, version, and publish LLM prompts. This is the consumer side: it installs prompts from your Sufleur workspace into your project the way `npm` installs packages — declared in `sufleur.yaml`, locked to `sufleur-lock.yaml`, generated into one TypeScript file with full types and runtime helpers.
4
+
5
+ Create a workspace and start authoring prompts at <https://sufleur.com>.
6
+
7
+ ## What you call from your code
8
+
9
+ ```ts
10
+ import { getPrompt } from './generated/prompts';
11
+
12
+ const review = getPrompt('@my-workspace/code-review');
13
+
14
+ const { prompt } = review.render('en', {
15
+ diff: '...',
16
+ language: 'go',
17
+ });
18
+ // → ready-to-send prompt string
19
+
20
+ const result = review.parseOutput(llmResponseText);
21
+ if (result.success) {
22
+ result.data; // typed by the prompt's output schema (Zod-validated)
23
+ } else {
24
+ result.error;
25
+ }
26
+ ```
27
+
28
+ `'@my-workspace/code-review'` is checked at compile time: typos fail to type-check, the entrypoint name `'en'` is narrowed against the prompt's available entrypoints, and the input shape is the JSON Schema declared on that entrypoint. The version that resolves at codegen time is pinned in `sufleur-lock.yaml`.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ npm i -g @sufleur/cli
34
+ sufleur --help
35
+ ```
36
+
37
+ Or run on demand:
38
+
39
+ ```bash
40
+ npx -p @sufleur/cli sufleur --help
41
+ ```
42
+
43
+ The wrapper downloads the matching prebuilt binary on `npm install` and exposes it as `sufleur`. There's no JS in the hot path — the `sufleur` command is the native binary.
44
+
45
+ ## Quick start
46
+
47
+ ```bash
48
+ mkdir my-app && cd my-app
49
+ sufleur init # creates sufleur.yaml interactively
50
+ sufleur add @my-workspace/code-review ^1.0.0 # add + fetch + lock
51
+ sufleur generate # writes ./generated/prompts.ts
52
+ ```
53
+
54
+ The generated file imports two runtime peers — install them in your project:
55
+
56
+ ```bash
57
+ npm i mustache
58
+ npm i -D @types/mustache
59
+ # only if any prompt has an output schema:
60
+ npm i zod
61
+ ```
62
+
63
+ ## What `sufleur generate` emits
64
+
65
+ A single `.ts` file containing every prompt inlined (no runtime fetches). The header documents what's exported; the public API is `getPrompt(name)`, which returns:
66
+
67
+ - **`render(entrypoint, input)` → `{ prompt: string }`** — Mustache renders the entrypoint template against `input`. The input type is narrowed by entrypoint name; entrypoints with no input schema take no second argument.
68
+ - **`metadata`** — `{ version, ...your custom workspace metadata, outputSchema? }`. The pinned version comes from the lockfile; the rest comes from whatever metadata your registry assigned to that prompt version.
69
+ - **`parseOutput(raw)`** *(only present if the prompt has an output schema)* — strips ``` fences, JSON-parses, and validates with a Zod schema generated from the prompt's JSON Schema. Returns `{ success: true, data }` or `{ success: false, error }`.
70
+
71
+ Plus exported types per entrypoint:
72
+
73
+ ```ts
74
+ export type CodeReview_EnInput = { diff: string; language: string };
75
+ ```
76
+
77
+ Prompts published with `DRAFT` status emit a runtime `console.warn` when their `getPrompt` is called.
78
+
79
+ ## sufleur.yaml
80
+
81
+ The manifest. Looks like:
82
+
83
+ ```yaml
84
+ api_keys:
85
+ my-workspace: ${MY_WORKSPACE_API_KEY}
86
+
87
+ prompts:
88
+ '@my-workspace/greeting': '*'
89
+ '@my-workspace/code-review': '^2.0.0'
90
+ # alias: keep two pinned versions side-by-side under different names
91
+ '@my-workspace/code-review-strict': '@my-workspace/code-review@~1.4.0'
92
+
93
+ output:
94
+ language: typescript
95
+ file: ./generated/prompts.ts
96
+ ```
97
+
98
+ Constraints are npm-style semver ranges (`^`, `~`, `>=`, exact, `*`). The resolution is recorded in `sufleur-lock.yaml`. **Commit both files** — `sufleur.yaml` is the source of truth, `sufleur-lock.yaml` is the receipt.
99
+
100
+ ## CI usage
101
+
102
+ ```bash
103
+ sufleur install --frozen # fail if lockfile is stale
104
+ sufleur generate
105
+ ```
106
+
107
+ `--frozen` is the npm-`ci` equivalent: refuses to update the lockfile, hard-errors if the manifest and lockfile disagree.
108
+
109
+ ## Commands
110
+
111
+ | Command | Description |
112
+ | ------- | ----------- |
113
+ | `sufleur init` | Interactive scaffolding for `sufleur.yaml`. |
114
+ | `sufleur add @ws/name [range]` | Add a prompt, fetch it, update the lockfile. `--alias <name>` keeps multiple versions; `--force` overwrites an existing entry. |
115
+ | `sufleur remove @ws/name` | Remove a prompt from the manifest and prune its cache (kept if another alias still resolves to the same version). |
116
+ | `sufleur install` | Resolve the manifest, fetch what's missing, refresh the lockfile. `--frozen` for CI. |
117
+ | `sufleur update [@ws/name]` | Re-resolve constraints — one prompt or all. |
118
+ | `sufleur generate` | Regenerate the output file from the lockfile + cache. |
119
+
120
+ `-v` / `--verbose` enables HTTP request/response logs on any command. Variables in `.env` are loaded automatically; per-workspace API keys can be referenced as `${ENV_VAR_NAME}` in `sufleur.yaml`.
121
+
122
+ ## Supported platforms
123
+
124
+ | OS | Architectures |
125
+ | ------- | ------------------------------ |
126
+ | macOS | x64, arm64 |
127
+ | Linux | x64, arm64 |
128
+ | Windows | x64, arm64 (Windows 10 1803+) |
129
+
130
+ Alpine / musl libc is currently unsupported (no musllinux binary). Override the binary download URL with `SUFLEUR_BINARY_MIRROR`. Set `SUFLEUR_SKIP_POSTINSTALL=1` to defer the download (e.g. when building an image you'll rehydrate later with `npm rebuild @sufleur/cli`).
131
+
132
+ ## Links
133
+
134
+ - **Sufleur platform** — author and manage prompts: <https://sufleur.com>
135
+ - **Source code, issues, release notes**: <https://github.com/sufleur/cli>
136
+
137
+ ## License
138
+
139
+ MIT.
package/install.js ADDED
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const https = require('https');
8
+ const http = require('http');
9
+ const { URL } = require('url');
10
+ const { execFileSync, spawnSync } = require('child_process');
11
+
12
+ const PKG = require('./package.json');
13
+ const VERSION = PKG.version;
14
+
15
+ const PLATFORM_MAP = { linux: 'linux', darwin: 'darwin', win32: 'windows' };
16
+ const ARCH_MAP = { x64: 'amd64', arm64: 'arm64' };
17
+ const SUPPORTED = 'linux/x64, linux/arm64, darwin/x64, darwin/arm64, win32/x64, win32/arm64';
18
+
19
+ const DEFAULT_BASE = 'https://github.com/sufleur/cli/releases/download';
20
+ const PREFIX = '[@sufleur/cli postinstall]';
21
+
22
+ function fail(msg) {
23
+ console.error(`\n${PREFIX} ERROR: ${msg}\n`);
24
+ process.exit(1);
25
+ }
26
+
27
+ function info(msg) {
28
+ console.log(`${PREFIX} ${msg}`);
29
+ }
30
+
31
+ if (process.env.npm_config_offline === 'true') {
32
+ info('offline mode detected; skipping binary download. Run `npm rebuild @sufleur/cli` once online.');
33
+ process.exit(0);
34
+ }
35
+ if (process.env.SUFLEUR_SKIP_POSTINSTALL === '1') {
36
+ info('SUFLEUR_SKIP_POSTINSTALL=1; skipping binary download.');
37
+ process.exit(0);
38
+ }
39
+
40
+ const goos = PLATFORM_MAP[process.platform];
41
+ const goarch = ARCH_MAP[process.arch];
42
+ if (!goos || !goarch) {
43
+ fail(
44
+ `unsupported platform: ${process.platform}/${process.arch}.\n` +
45
+ ` Supported: ${SUPPORTED}.`
46
+ );
47
+ }
48
+
49
+ const isWindows = goos === 'windows';
50
+ const ext = isWindows ? 'zip' : 'tar.gz';
51
+ const archive = `sufleur_${VERSION}_${goos}_${goarch}.${ext}`;
52
+ const binaryName = isWindows ? 'sufleur.exe' : 'sufleur';
53
+
54
+ const base = (process.env.SUFLEUR_BINARY_MIRROR || DEFAULT_BASE).replace(/\/$/, '');
55
+ const archiveUrl = `${base}/v${VERSION}/${archive}`;
56
+ const checksumsUrl = `${base}/v${VERSION}/checksums.txt`;
57
+
58
+ const binDir = path.join(__dirname, 'bin');
59
+ const binTarget = path.join(binDir, binaryName);
60
+ const cacheMarker = path.join(binDir, '.sufleur.sha256');
61
+ fs.mkdirSync(binDir, { recursive: true });
62
+
63
+ function getFollowingRedirects(rawUrl, depth = 0) {
64
+ return new Promise((resolve, reject) => {
65
+ if (depth > 5) return reject(new Error(`too many redirects: ${rawUrl}`));
66
+ const lib = rawUrl.startsWith('https:') ? https : http;
67
+ const req = lib.get(
68
+ rawUrl,
69
+ { headers: { 'user-agent': '@sufleur/cli installer' } },
70
+ (res) => {
71
+ const status = res.statusCode;
72
+ if (status >= 300 && status < 400 && res.headers.location) {
73
+ res.resume();
74
+ const next = new URL(res.headers.location, rawUrl).toString();
75
+ return resolve(getFollowingRedirects(next, depth + 1));
76
+ }
77
+ if (status !== 200) {
78
+ res.resume();
79
+ return reject(new Error(`GET ${rawUrl} -> HTTP ${status}`));
80
+ }
81
+ resolve(res);
82
+ }
83
+ );
84
+ req.on('error', (err) => reject(new Error(`fetch failed: ${rawUrl}: ${err.message}`)));
85
+ });
86
+ }
87
+
88
+ async function fetchText(url) {
89
+ const res = await getFollowingRedirects(url);
90
+ return await new Promise((resolve, reject) => {
91
+ let buf = '';
92
+ res.setEncoding('utf8');
93
+ res.on('data', (chunk) => { buf += chunk; });
94
+ res.on('end', () => resolve(buf));
95
+ res.on('error', reject);
96
+ });
97
+ }
98
+
99
+ async function downloadAndHash(url, destPath) {
100
+ const res = await getFollowingRedirects(url);
101
+ return await new Promise((resolve, reject) => {
102
+ const hash = crypto.createHash('sha256');
103
+ const out = fs.createWriteStream(destPath);
104
+ res.on('data', (chunk) => { hash.update(chunk); });
105
+ res.on('error', reject);
106
+ out.on('error', reject);
107
+ out.on('finish', () => resolve(hash.digest('hex')));
108
+ res.pipe(out);
109
+ });
110
+ }
111
+
112
+ function lookupChecksum(body, filename) {
113
+ for (const line of body.split('\n')) {
114
+ const trimmed = line.trim();
115
+ if (!trimmed) continue;
116
+ const parts = trimmed.split(/\s+/);
117
+ if (parts.length < 2) continue;
118
+ const [hash, name] = parts;
119
+ if (name === filename || name === `*${filename}`) return hash;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function readCachedHash() {
125
+ try {
126
+ return fs.readFileSync(cacheMarker, 'utf8').trim();
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function extractArchive(archivePath, destDir, member) {
133
+ // Try the system `tar` first. Modern macOS, Linux, and Windows 10 1803+
134
+ // all ship a libarchive-based tar that handles both .tar.gz and .zip.
135
+ try {
136
+ execFileSync('tar', ['-xf', archivePath, '-C', destDir, member], { stdio: 'inherit' });
137
+ return;
138
+ } catch (err) {
139
+ if (!isWindows) throw err;
140
+ }
141
+ // Windows fallback: PowerShell Expand-Archive (extracts the whole zip;
142
+ // the binary we want is the only member of interest).
143
+ const ps = spawnSync(
144
+ 'powershell.exe',
145
+ [
146
+ '-NoProfile',
147
+ '-NonInteractive',
148
+ '-Command',
149
+ `Expand-Archive -Force -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${destDir.replace(/'/g, "''")}'`
150
+ ],
151
+ { stdio: 'inherit' }
152
+ );
153
+ if (ps.status !== 0) {
154
+ throw new Error('PowerShell Expand-Archive failed');
155
+ }
156
+ }
157
+
158
+ (async function main() {
159
+ let expectedHex;
160
+ try {
161
+ info(`fetching checksums: ${checksumsUrl}`);
162
+ const checksumsBody = await fetchText(checksumsUrl);
163
+ expectedHex = lookupChecksum(checksumsBody, archive);
164
+ if (!expectedHex) {
165
+ fail(`no checksum entry for ${archive} in ${checksumsUrl}`);
166
+ }
167
+ } catch (err) {
168
+ fail(err.message);
169
+ }
170
+
171
+ if (fs.existsSync(binTarget) && readCachedHash() === expectedHex) {
172
+ info(`${binaryName} already installed (sha256 matches); skipping download.`);
173
+ return;
174
+ }
175
+
176
+ const tmp = path.join(binDir, `${archive}.partial`);
177
+ try {
178
+ info(`downloading: ${archiveUrl}`);
179
+ const actualHex = await downloadAndHash(archiveUrl, tmp);
180
+ if (actualHex !== expectedHex) {
181
+ try { fs.unlinkSync(tmp); } catch {}
182
+ fail(
183
+ `SHA256 mismatch for ${archive}\n` +
184
+ ` expected: ${expectedHex}\n` +
185
+ ` actual: ${actualHex}\n` +
186
+ ` url: ${archiveUrl}`
187
+ );
188
+ }
189
+
190
+ info(`extracting ${binaryName} from ${archive}`);
191
+ try {
192
+ extractArchive(tmp, binDir, binaryName);
193
+ } catch (err) {
194
+ fail(`extraction failed for ${archive}: ${err.message}`);
195
+ }
196
+
197
+ if (!fs.existsSync(binTarget)) {
198
+ fail(
199
+ `extraction completed but ${binTarget} is missing. ` +
200
+ `The archive layout may have changed (e.g. wrap_in_directory).`
201
+ );
202
+ }
203
+
204
+ if (!isWindows) {
205
+ fs.chmodSync(binTarget, 0o755);
206
+ }
207
+
208
+ fs.writeFileSync(cacheMarker, `${expectedHex}\n`);
209
+ info(`installed ${binaryName} at ${binTarget}`);
210
+ } catch (err) {
211
+ fail(err.message);
212
+ } finally {
213
+ try { fs.unlinkSync(tmp); } catch {}
214
+ }
215
+ })();
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@sufleur/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for sufleur — type-safe codegen for versioned LLM prompts.",
5
+ "keywords": [
6
+ "sufleur",
7
+ "cli",
8
+ "llm",
9
+ "prompts",
10
+ "codegen",
11
+ "typescript"
12
+ ],
13
+ "homepage": "https://github.com/sufleur/cli#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/sufleur/cli/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/sufleur/cli.git",
20
+ "directory": "wrappers/npm"
21
+ },
22
+ "license": "MIT",
23
+ "bin": {
24
+ "sufleur": "run.js"
25
+ },
26
+ "files": [
27
+ "install.js",
28
+ "run.js",
29
+ "README.md"
30
+ ],
31
+ "scripts": {
32
+ "postinstall": "node install.js"
33
+ },
34
+ "engines": {
35
+ "node": ">=16"
36
+ },
37
+ "os": [
38
+ "darwin",
39
+ "linux",
40
+ "win32"
41
+ ],
42
+ "cpu": [
43
+ "x64",
44
+ "arm64"
45
+ ],
46
+ "peerDependencies": {
47
+ "mustache": "*",
48
+ "zod": "*"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "mustache": {
52
+ "optional": false
53
+ },
54
+ "zod": {
55
+ "optional": false
56
+ }
57
+ }
58
+ }
package/run.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const { spawnSync } = require('child_process');
8
+
9
+ const isWindows = process.platform === 'win32';
10
+ const binary = path.join(__dirname, 'bin', isWindows ? 'sufleur.exe' : 'sufleur');
11
+
12
+ if (!fs.existsSync(binary)) {
13
+ console.error(
14
+ `\n[@sufleur/cli] Binary not found at ${binary}.\n` +
15
+ `Postinstall did not run, or the download failed. Try one of:\n` +
16
+ ` npm rebuild @sufleur/cli\n` +
17
+ ` node ${path.join(__dirname, 'install.js')}\n`
18
+ );
19
+ process.exit(1);
20
+ }
21
+
22
+ const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' });
23
+
24
+ if (result.error) {
25
+ const code = result.error.code;
26
+ if (code === 'ENOENT') {
27
+ console.error(`[@sufleur/cli] could not exec ${binary}. Try: npm rebuild @sufleur/cli`);
28
+ } else if (code === 'EACCES') {
29
+ console.error(`[@sufleur/cli] permission denied executing ${binary}. Try: chmod +x ${binary}`);
30
+ } else {
31
+ console.error(`[@sufleur/cli] spawn error: ${result.error.message}`);
32
+ }
33
+ process.exit(1);
34
+ }
35
+
36
+ if (result.signal) {
37
+ const signo = os.constants.signals[result.signal] || 0;
38
+ process.exit(128 + signo);
39
+ }
40
+
41
+ process.exit(result.status === null ? 0 : result.status);