From 6aa867fb522f13fb50bea41920453a8bd97c180e Mon Sep 17 00:00:00 2001 From: Emanuele <107957269+emanuelefaja@users.noreply.github.com> Date: Tue, 5 May 2026 12:24:19 +0700 Subject: [PATCH] Add `disco llm` command for LLM coding agents Emits a single markdown bundle (narrative + auto-generated command reference) suitable for piping into a skill file. Supports `--save` to write `./DISCO.md`, `--install ` to install as an agent skill at the user-global path, and `--url` to print the hosted llms.txt URL. The narrative is bundled at build time from disco.cloud/llms.txt (refreshed via `npm run sync:llms-narrative`); the command reference is generated at runtime from `this.config.commands` so it always matches the installed binary. Closes #115 --- RELEASE.md | 10 +++ package.json | 3 +- scripts/sync-llms-narrative.js | 33 +++++++ src/commands/llm.ts | 105 ++++++++++++++++++++++ src/llm/narrative.ts | 160 +++++++++++++++++++++++++++++++++ src/llm/render.ts | 101 +++++++++++++++++++++ test/commands/llm.test.ts | 108 ++++++++++++++++++++++ 7 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 scripts/sync-llms-narrative.js create mode 100644 src/commands/llm.ts create mode 100644 src/llm/narrative.ts create mode 100644 src/llm/render.ts create mode 100644 test/commands/llm.test.ts diff --git a/RELEASE.md b/RELEASE.md index 2d4c1c3..6260b1f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,3 +1,13 @@ +### Before tagging a release + +Refresh the bundled `llms.txt` snapshot used by `disco llm`: + +```bash +npm run sync:llms-narrative +``` + +If `git diff src/llm/narrative.ts` shows changes, commit them before tagging. + ### How to release **Option 1: Auto-bump patch** diff --git a/package.json b/package.json index d83f034..e3096e8 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,8 @@ "posttest": "npm run lint", "prepack": "oclif manifest", "test": "mocha --forbid-only \"test/**/*.test.ts\"", - "readme": "node scripts/generate-readme.js" + "readme": "node scripts/generate-readme.js", + "sync:llms-narrative": "node scripts/sync-llms-narrative.js" }, "types": "dist/index.d.ts" } diff --git a/scripts/sync-llms-narrative.js b/scripts/sync-llms-narrative.js new file mode 100644 index 0000000..97d3802 --- /dev/null +++ b/scripts/sync-llms-narrative.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Refresh src/llm/narrative.ts from https://disco.cloud/llms.txt. +// Run before tagging a release — see RELEASE.md. + +import {writeFileSync} from 'node:fs' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const projectRoot = join(__dirname, '..') +const outPath = join(projectRoot, 'src', 'llm', 'narrative.ts') + +const url = 'https://disco.cloud/llms.txt' +console.log(`Fetching ${url}...`) + +const res = await fetch(url) +if (!res.ok) { + console.error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`) + process.exit(1) +} + +const text = await res.text() +const escaped = text.replaceAll('\\', '\\\\').replaceAll('`', '\\`').replaceAll('${', '\\${') + +const tsContent = `// Auto-generated by scripts/sync-llms-narrative.js — do not edit by hand. +// Source: ${url} +// Last synced: ${new Date().toISOString()} + +export const NARRATIVE = \`${escaped}\` +` + +writeFileSync(outPath, tsContent) +console.log(`Wrote ${outPath} (${text.length} chars)`) diff --git a/src/commands/llm.ts b/src/commands/llm.ts new file mode 100644 index 0000000..457b094 --- /dev/null +++ b/src/commands/llm.ts @@ -0,0 +1,105 @@ +import {Command, Flags} from '@oclif/core' +import {existsSync, mkdirSync, writeFileSync} from 'node:fs' +import {homedir} from 'node:os' +import {dirname, resolve} from 'node:path' + +import {NARRATIVE} from '../llm/narrative.js' +import { + expandTargets, + installPath, + renderCommands, + renderFrontmatter, + type CommandLike, + type InstallTarget, + type InstallTargetOrAll, +} from '../llm/render.js' + +const LLMS_TXT_URL = 'https://disco.cloud/llms.txt' + +export default class Llm extends Command { + static description = + 'print a markdown bundle (narrative + command reference) for use with LLM coding agents' + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --save', + '<%= config.bin %> <%= command.id %> --install claude', + '<%= config.bin %> <%= command.id %> --install all', + '<%= config.bin %> <%= command.id %> --url', + ] + + static flags = { + save: Flags.boolean({ + description: 'write the markdown bundle to ./DISCO.md', + exclusive: ['install', 'url'], + }), + install: Flags.string({ + description: 'install as an agent skill at the target path', + exclusive: ['save', 'url'], + options: ['claude', 'codex', 'all'], + }), + url: Flags.boolean({ + description: 'print the URL of the hosted llms.txt and exit', + exclusive: ['save', 'install'], + }), + force: Flags.boolean({ + description: 'overwrite existing files when installing', + }), + } + + public async run(): Promise { + const {flags} = await this.parse(Llm) + + if (flags.url) { + this.log(LLMS_TXT_URL) + return + } + + const body = renderBundle(this.config.commands as unknown as CommandLike[]) + + if (flags.install) { + this.runInstall(flags.install as InstallTargetOrAll, body, flags.force) + return + } + + if (flags.save) { + const target = resolve(process.cwd(), 'DISCO.md') + writeFileSync(target, body) + this.log(`Wrote ${target}`) + return + } + + this.log(body) + } + + private runInstall(target: InstallTargetOrAll, body: string, force: boolean): void { + const targets = expandTargets(target) + const home = homedir() + const plans: Array<{path: string; target: InstallTarget}> = targets.map((t) => ({ + path: installPath(t, home), + target: t, + })) + + if (!force) { + for (const {path} of plans) { + if (existsSync(path)) { + this.error( + `File already exists: ${path}\n\nPass --force to overwrite, or back up the existing file first.`, + {exit: 1}, + ) + } + } + } + + const content = renderFrontmatter() + body + for (const {path, target: t} of plans) { + mkdirSync(dirname(path), {recursive: true}) + writeFileSync(path, content) + this.log(`Installed ${t} skill: ${path}`) + } + } +} + +function renderBundle(commands: ReadonlyArray): string { + return [NARRATIVE.trimEnd(), '', renderCommands(commands), ''].join('\n') +} diff --git a/src/llm/narrative.ts b/src/llm/narrative.ts new file mode 100644 index 0000000..1e52866 --- /dev/null +++ b/src/llm/narrative.ts @@ -0,0 +1,160 @@ +// Auto-generated by scripts/sync-llms-narrative.js — do not edit by hand. +// Source: https://disco.cloud/llms.txt +// Last synced: 2026-05-05T05:09:54.331Z + +export const NARRATIVE = `# what is disco + +disco turns any ubuntu 24.04 linux server you control into a private paas, with zero‑downtime deployments and a hosted dashboard and cli that manage the server over a secure api. your application traffic and github webhooks go directly to your server, not through disco’s servers. + +disco can deploy all sorts of fully static sites (with no backend - such as a frontend site), ssg sites (that are generated on each git push, and then served fully statically), and backend sites (python, node, go, etc.) + +# tldr how to + +- the easiest way to install disco is to ssh into your server, then run \`curl -fsSL https://disco.cloud/install.sh | sh\` +- after installing everything, the curl sh script above will print a url. follow it, login with github (oauth login only - no permissions are asked) +- create a self-hosted github app (the UI will guide the user) and authorize the repos you want to deploy with disco +- create a new project (i.e. web app) in the UI by picking a repo - make sure it has a disco.json and Dockerfile. creating a project will make a first deployment and point a new domain name to the deployed site +- from this point on, simply git push to the repo to deploy new changes - the deployments will be done using zero downtime +- to edit/add env vars, see build logs, or realtime logs, use the web UI +- to optionally connect a CLI to control this server (which would allow LLMs to control the server, see logs, deploy projects, etc.), go to "Setup CLI Access" in the web UI sidebar + - the instructions will be to: install the CLI on your local development machine, and then run a CLI command to accept an invite which will let you control the same server & projects from the CLI + +# prerequisites + +- an Ubuntu 24.04 LTS server with SSH access +- a GitHub account (for repo access + optionally to access the UI) + +# how to get started + +there are two main ways of getting started: + +## installing disco on a server via curl sh and managing it via the UI + +1) the first way is less error-prone, but more user (not LLM) centric as it directs the user to a web UI. + +ssh into a fresh server, and then run the install script: + +\`\`\`bash +curl -fsSL https://disco.cloud/install.sh | sh +\`\`\` + +at the end of the installation, you will get an installation URL: + +\`\`\`text +-> [6/7] Generating invite link... +-> [7/7] Installation complete! + +Visit this URL to complete setup: + + https://dashboard.disco.cloud/i/... +\`\`\` + +this invitation url will bring you to a web dashboard. + +you will first need to login using github (this is a a pure oauth login that doesn't ask for any permissions) + +then you will accept the invitation - meaning that your Disco UI account (to which you login to using the github oauth login) - will now store a reference to your server's disco daemon + +from this point on, all of the setup is done using the web ui. + +to add the ability to talk to this server using the disco CLI, the following steps should be followed: + +- in the dashboard, find the "Setup CLI Access" item in the left sidebar + +- per the instructions on that page, install the disco CLI by running the following on the user's development machine (NOT the server): + +\`\`\`bash +curl https://cli-assets.letsdisco.dev/install.sh | sh +\`\`\` + +- follow the instructions on the "Setup CLI Access" page to accept an invite to this server. this will look like the following: + +\`\`\`bash +disco invite:accept https://disco.example.com/api-key-invites/hexhexhexhexhex +\`\`\` + +i.e. running this using the locally installed CLI will also give you access to the same server as you have access to using the web UI. from this point on, you can issue CLI commands to this server. + +## installing disco and managing it using the CLI + +2) the alternative way is to install the disco CLI first to the development machine, and then run the \`disco init\` command. + +install the CLI by running the following on the user's development machine (NOT the server): + +\`\`\`bash +curl https://cli-assets.letsdisco.dev/install.sh | sh +\`\`\` + +the simplest way to use disco init is to pass it an ssh connection string - however! the connection string MUST have a domain name, not an ip, ie you must call it like this: + +\`\`\`bash +disco init root@disco.example.com +\`\`\` + +note as well that \`disco.example.com\` must be a dedicated A record pointing to the server, and that this domain name can never be re-used to deploy a project. + +ie your disco server will have its own dedicated domain name - so it's better to not use something like "example.com" if you also want to deploy frontend/backend sites to example.com. the best is a subdomain like disco.example.com and then you can deploy web projects to the apex example.com , www.example.com , or any other domain + +after running \`disco init\`, an API key will be written locally to ~/.disco/config.json that will allow the disco CLI to talk to the disco server/daemon. from this point on, all disco CLI commands will work + +# github setup before deploying any project (using the CLI) + +the very first step before creating/deploying any project is to create a self-hosted github app. the UI will guide the user to do this. if you are using the CLI, run: + +\`\`\`bash +disco github:apps:add +\`\`\` + +this will show an explanation prompt and ask if the CLI can open the url in a browser. in the browser, the user will be asked to confirm the new self hosted github app (which will be given a random name), and then the user will be asked to confirm which repo(s) they want to authorize - repos must be authorized so that they can be deployed using disco + +after the github app is created, you can check that it does exist on the daemon by running \`disco github:apps:list\` + +you can also run \`disco github:repos:list\` to see the list of auth'd repos that the disco daemon has access to via the github app + +# deploying a project (using the CLI) + +it is assumed that: +- you have a server running disco +- you have the CLI running locally (on your own development machine) +- your CLI can manage your disco server ie if you run \`disco meta:info\` that returns information from your server +- you have setup a github app and authorized the repo you want to deploy - running \`disco github:repos:list\` shows you a list containing that repo +- the repo you want to deploy has a disco.json and a Dockerfile + +the first step is to setup a domain name (by setting up an ANAME, CNAME or A record) for the project, like \`app.example.com\` which points to the server (either to its IP (not recommended) or a CNAME pointing to the disco server domain name such as app.example.com -> disco.example.com) + +then, you can run: + +\`\`\`bash +disco projects:add --name NAME --github USER/REPO --domain DOMAIN +\`\`\` + +replacing the values for NAME (a project name that will be used for other CLI commands), USER/REPO and DOMAIN + +this will deploy the project - any information on sucess/failure will appear here (ie you will see the build logs in real time) + +to deploy again (for example, if there was an error in the Dockerfile), edit the files locally, and then git add + commit + push. + +calling git push will lead to a webhook being sent from github directly to the disco server daemon, and this will lead to a new deployment being created. + +# example projects + +node https://github.com/letsdiscodev/example-node-site +flask/python https://github.com/letsdiscodev/example-flask-site +static https://github.com/letsdiscodev/example-static-site +vite (static + build on git push) https://github.com/letsdiscodev/example-vite +go https://github.com/letsdiscodev/example-go-site +rust https://github.com/letsdiscodev/example-rust-site + +# notes + +Q: how to talk to multiple disco servers using the CLI +A: if the CLI can talk to a single server, simply running commands such as \`disco meta:info\` will work. otherwise, if the CLI has access to multiple servers, add the \`--disco (SERVER)\` option to your CLI commands. for example: + +\`\`\`bash +disco meta:info --disco disco.example2.com +\`\`\` + +# contact + +the disco developers hang out on discord - https://discord.gg/7J4vb5uUwU +` diff --git a/src/llm/render.ts b/src/llm/render.ts new file mode 100644 index 0000000..43c1a92 --- /dev/null +++ b/src/llm/render.ts @@ -0,0 +1,101 @@ +export type InstallTarget = 'claude' | 'codex' +export type InstallTargetOrAll = 'all' | InstallTarget + +export function expandTargets(target: InstallTargetOrAll): InstallTarget[] { + return target === 'all' ? ['claude', 'codex'] : [target] +} + +export function installPath(target: InstallTarget, home: string): string { + const dir = target === 'claude' ? '.claude' : '.codex' + return `${home}/${dir}/skills/disco/SKILL.md` +} + +export function renderFrontmatter(): string { + return [ + '---', + 'name: disco', + 'description: Disco CLI — deploy and manage projects on a server you control, with zero-downtime deployments. Use these commands to drive disco from a coding agent.', + '---', + '', + ].join('\n') +} + +interface FlagDef { + description?: string + required?: boolean + options?: readonly string[] + char?: string + type?: string +} + +interface ArgDef { + description?: string + required?: boolean +} + +export interface CommandLike { + id: string + description?: string + summary?: string + examples?: ReadonlyArray<{command: string; description?: string} | string> + flags?: Record + args?: Record + hidden?: boolean +} + +export function renderCommands(commands: ReadonlyArray): string { + const visible = commands.filter((c) => !c.hidden && c.id !== 'llm').sort((a, b) => a.id.localeCompare(b.id)) + + return ['# Commands', '', ...visible.map((cmd) => renderCommand(cmd))].join('\n') +} + +function renderCommand(cmd: CommandLike): string { + const heading = `## disco ${cmd.id.replaceAll(':', ' ')}` + const lines: string[] = [heading, ''] + + const desc = cmd.summary ?? cmd.description + if (desc) { + lines.push(desc, '') + } + + const flagNames = Object.keys(cmd.flags ?? {}).sort() + if (flagNames.length > 0) { + lines.push('**Flags:**') + for (const name of flagNames) { + const f = (cmd.flags as Record)[name] + const req = f.required ? ' (required)' : '' + const opts = f.options && f.options.length > 0 ? ` [${f.options.join('|')}]` : '' + const description = f.description ? ` — ${f.description}` : '' + lines.push(`- \`--${name}\`${opts}${req}${description}`) + } + + lines.push('') + } + + const argNames = Object.keys(cmd.args ?? {}) + if (argNames.length > 0) { + lines.push('**Args:**') + for (const name of argNames) { + const a = (cmd.args as Record)[name] + const req = a.required ? ' (required)' : '' + const description = a.description ? ` — ${a.description}` : '' + lines.push(`- \`${name}\`${req}${description}`) + } + + lines.push('') + } + + const examples = cmd.examples ?? [] + if (examples.length > 0) { + lines.push('**Examples:**') + for (const ex of examples) { + const cmdText = typeof ex === 'string' ? ex : ex.command + const resolved = cmdText.replaceAll('<%= config.bin %>', 'disco').replaceAll('<%= command.id %>', cmd.id) + lines.push(`- \`${resolved}\``) + } + + lines.push('') + } + + return lines.join('\n') +} diff --git a/test/commands/llm.test.ts b/test/commands/llm.test.ts new file mode 100644 index 0000000..ead5f65 --- /dev/null +++ b/test/commands/llm.test.ts @@ -0,0 +1,108 @@ +import {expect} from '@oclif/test' + +import { + expandTargets, + installPath, + renderCommands, + renderFrontmatter, + type CommandLike, +} from '../../src/llm/render.js' + +describe('llm render', () => { + describe('expandTargets', () => { + it('expands "all" to claude + codex', () => { + expect(expandTargets('all')).to.deep.equal(['claude', 'codex']) + }) + + it('returns single-element array for a specific target', () => { + expect(expandTargets('claude')).to.deep.equal(['claude']) + expect(expandTargets('codex')).to.deep.equal(['codex']) + }) + }) + + describe('installPath', () => { + it('resolves the claude skill path under HOME', () => { + expect(installPath('claude', '/tmp/h')).to.equal('/tmp/h/.claude/skills/disco/SKILL.md') + }) + + it('resolves the codex skill path under HOME', () => { + expect(installPath('codex', '/tmp/h')).to.equal('/tmp/h/.codex/skills/disco/SKILL.md') + }) + }) + + describe('renderFrontmatter', () => { + it('emits YAML frontmatter with name and description', () => { + const out = renderFrontmatter() + expect(out).to.match(/^---\n/) + expect(out).to.include('name: disco') + expect(out).to.include('description:') + expect(out).to.match(/---\n$/) + }) + }) + + describe('renderCommands', () => { + const sampleCommands: CommandLike[] = [ + { + description: 'deploy a project', + examples: ['<%= config.bin %> <%= command.id %> --project mysite'], + flags: { + commit: {description: 'git commit sha'}, + project: {description: 'project name', required: true}, + }, + id: 'deploy', + }, + { + description: 'fetch logs', + flags: {project: {required: false}}, + id: 'logs', + }, + { + description: 'should be skipped', + hidden: true, + id: 'secret', + }, + { + description: 'self — should be skipped', + id: 'llm', + }, + { + description: 'add a project', + id: 'projects:add', + }, + ] + + it('emits a top-level Commands header', () => { + expect(renderCommands(sampleCommands)).to.match(/^# Commands\n/) + }) + + it('skips hidden commands and the llm command itself', () => { + const out = renderCommands(sampleCommands) + expect(out).to.not.include('secret') + expect(out).to.not.include('## disco llm') + }) + + it('renders commands sorted alphabetically by id', () => { + const out = renderCommands(sampleCommands) + const deployIdx = out.indexOf('## disco deploy') + const logsIdx = out.indexOf('## disco logs') + const projectsIdx = out.indexOf('## disco projects add') + expect(deployIdx).to.be.greaterThan(-1) + expect(logsIdx).to.be.greaterThan(deployIdx) + expect(projectsIdx).to.be.greaterThan(logsIdx) + }) + + it('renders topic separators as spaces in headings', () => { + expect(renderCommands(sampleCommands)).to.include('## disco projects add') + }) + + it('marks required flags', () => { + const out = renderCommands(sampleCommands) + expect(out).to.include('`--project` (required)') + }) + + it('resolves oclif example template tokens', () => { + const out = renderCommands(sampleCommands) + expect(out).to.include('`disco deploy --project mysite`') + }) + }) +})