-
Notifications
You must be signed in to change notification settings - Fork 0
Coding-agent surfaces: kit HTML and choices kinds, CLI, self-hosted hardening, token bench #108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 20 commits
18b3b32
2061858
0cb68b4
c55be58
621386f
6700f05
7b48ca0
289c54c
8a6e055
b6214b9
fc6e3bf
bd0189e
0dcfee2
de10986
5d64c29
b293a73
2b42171
0505f46
4d5d9ad
1249900
4501eed
81b9fc0
f40b696
3025aa3
2340ef4
4b15d35
17c4056
e0c5f47
358ecb2
c352fed
aa2d73b
321ee50
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { build } from "esbuild"; | ||
| import { chmod, copyFile, rm } from "node:fs/promises"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| await rm("dist", { recursive: true, force: true }); | ||
|
|
||
| await build({ | ||
| entryPoints: ["src/index.ts"], | ||
| outfile: "dist/index.cjs", | ||
| bundle: true, | ||
| platform: "node", | ||
| format: "cjs", | ||
| target: "node20", | ||
| sourcemap: true, | ||
| banner: { js: "#!/usr/bin/env node" }, | ||
| loader: { ".wasm": "file" }, | ||
| alias: { | ||
| "@": fileURLToPath(new URL("../src", import.meta.url)), | ||
| "brotli-wasm": fileURLToPath(new URL("../node_modules/brotli-wasm/index.node.js", import.meta.url)), | ||
| }, | ||
| }); | ||
|
|
||
| await copyFile( | ||
| fileURLToPath(new URL("../node_modules/brotli-wasm/pkg.node/brotli_wasm_bg.wasm", import.meta.url)), | ||
| "dist/brotli_wasm_bg.wasm", | ||
| ); | ||
| await chmod("dist/index.cjs", 0o755); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| { | ||
| "name": "@agent-render/cli", | ||
| "version": "0.1.0", | ||
| "description": "Create agent-render artifact links from the command line.", | ||
| "license": "MIT", | ||
| "type": "module", | ||
| "bin": { | ||
| "agent-render": "./dist/index.cjs" | ||
| }, | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "engines": { | ||
| "node": ">=20" | ||
| }, | ||
| "scripts": { | ||
| "build": "node build.mjs", | ||
| "prepack": "npm run build", | ||
| "test": "vitest run", | ||
| "typecheck": "tsc --noEmit" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^26.0.0", | ||
| "esbuild": "^0.27.3", | ||
| "typescript": "^5.8.2", | ||
| "vitest": "^4.1.9" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| import { readFile } from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import { stdin as defaultStdin } from "node:process"; | ||
| import { getConfigValue, resolveConfig, setConfigValue } from "./config"; | ||
| import { buildPayloadEnvelope, type ArtifactInput } from "./envelope"; | ||
| import { assertEnvelopeWithinBudget, assertFragmentBudget, createFragmentUrl, encodePayloadEnvelope } from "./encoding"; | ||
| import { formatArtifactOutput, type OutputFormat } from "./format"; | ||
| import { createInstanceArtifact } from "./instance"; | ||
| import type { RequestedKind } from "./kind"; | ||
|
|
||
| type Mode = "auto" | "instance" | "fragment"; | ||
|
|
||
| type CreateOptions = { | ||
| files: string[]; | ||
| kind: RequestedKind; | ||
| title?: string; | ||
| mode: Mode; | ||
| format: OutputFormat; | ||
| stdin: boolean; | ||
| json: boolean; | ||
| instanceUrl?: string; | ||
| token?: string; | ||
| }; | ||
|
|
||
| // `.html` files auto-detect as code (source view); kit rendering is an explicit `--kind html` so | ||
| // arbitrary HTML files are never silently reinterpreted. | ||
| const KINDS = new Set<RequestedKind>(["auto", "markdown", "code", "diff", "csv", "json", "html"]); | ||
| const MODES = new Set<Mode>(["auto", "instance", "fragment"]); | ||
| const FORMATS = new Set<OutputFormat>(["url", "markdown", "discord", "slack", "plain"]); | ||
| const DEFAULT_VIEWER_URL = "https://agent-render.com/"; | ||
|
|
||
| function requireOptionValue(args: string[], index: number, option: string): string { | ||
| const value = args[index + 1]; | ||
| if (!value || value.startsWith("--")) throw new Error(`${option} requires a value.`); | ||
| return value; | ||
| } | ||
|
|
||
| function parseChoice<T extends string>(value: string, choices: Set<T>, option: string): T { | ||
| if (!choices.has(value as T)) { | ||
| throw new Error(`Invalid ${option} value "${value}". Expected one of: ${[...choices].join(", ")}.`); | ||
| } | ||
| return value as T; | ||
| } | ||
|
|
||
| function parseCreateOptions(args: string[]): CreateOptions { | ||
| const options: CreateOptions = { | ||
| files: [], | ||
| kind: "auto", | ||
| mode: "auto", | ||
| format: "url", | ||
| stdin: false, | ||
| json: false, | ||
| }; | ||
|
|
||
| for (let index = 0; index < args.length; index += 1) { | ||
| const arg = args[index]!; | ||
| if (!arg.startsWith("--")) { | ||
| options.files.push(arg); | ||
| continue; | ||
| } | ||
| if (arg === "--stdin") options.stdin = true; | ||
| else if (arg === "--json") options.json = true; | ||
| else if (arg === "--kind") options.kind = parseChoice(requireOptionValue(args, index++, arg), KINDS, arg); | ||
| else if (arg === "--title") options.title = requireOptionValue(args, index++, arg); | ||
| else if (arg === "--mode") options.mode = parseChoice(requireOptionValue(args, index++, arg), MODES, arg); | ||
| else if (arg === "--format") options.format = parseChoice(requireOptionValue(args, index++, arg), FORMATS, arg); | ||
| else if (arg === "--instance-url") options.instanceUrl = requireOptionValue(args, index++, arg); | ||
| else if (arg === "--token") options.token = requireOptionValue(args, index++, arg); | ||
| else throw new Error(`Unknown option "${arg}".`); | ||
| } | ||
|
|
||
| if (options.stdin && options.files.length > 0) throw new Error("--stdin cannot be combined with file paths."); | ||
| if (!options.stdin && options.files.length === 0) throw new Error("Provide at least one file or use --stdin."); | ||
| if (options.stdin && options.kind === "auto") throw new Error("--kind is required with --stdin."); | ||
| return options; | ||
| } | ||
|
|
||
| async function readStdin(): Promise<string> { | ||
| const chunks: Buffer[] = []; | ||
| for await (const chunk of defaultStdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | ||
| return Buffer.concat(chunks).toString("utf8"); | ||
| } | ||
|
|
||
| async function readInputs(options: CreateOptions): Promise<ArtifactInput[]> { | ||
| if (options.stdin) { | ||
| return [{ filename: options.title?.trim() || "stdin", content: await readStdin() }]; | ||
| } | ||
| return Promise.all(options.files.map(async (filename) => ({ | ||
| filename, | ||
| content: await readFile(filename, "utf8"), | ||
| }))); | ||
| } | ||
|
|
||
| function getOutputLabel(options: CreateOptions, inputs: ArtifactInput[]): string { | ||
| return options.title?.trim() || (inputs.length === 1 ? path.basename(inputs[0]!.filename) : `${inputs.length} artifacts`); | ||
| } | ||
|
|
||
| async function runCreate(args: string[]): Promise<void> { | ||
| const options = parseCreateOptions(args); | ||
| const inputs = await readInputs(options); | ||
| const envelope = buildPayloadEnvelope(inputs, options.kind, options.title); | ||
| assertEnvelopeWithinBudget(envelope); | ||
| const config = await resolveConfig({ instanceUrl: options.instanceUrl, token: options.token }); | ||
| const mode: Exclude<Mode, "auto"> = options.mode === "auto" | ||
| ? (config.instanceUrl ? "instance" : "fragment") | ||
| : options.mode; | ||
| const label = getOutputLabel(options, inputs); | ||
| let url: string; | ||
| let markdownUrl: string; | ||
|
|
||
| if (mode === "instance") { | ||
| if (!config.instanceUrl) throw new Error("Instance mode requires INSTANCE_URL configuration."); | ||
| url = await createInstanceArtifact(envelope, config.instanceUrl, config.token); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Instance mode sends the encoded envelope without checking the decoded JSON budget. A highly compressible input over 200,000 characters can therefore be accepted by the server's 500,000-character encoded-payload limit and return a UUID successfully, but AGENTS.md reference: AGENTS.md:L91-L92 Useful? React with 👍 / 👎. |
||
| markdownUrl = url; | ||
| } else { | ||
| const encoded = await encodePayloadEnvelope(envelope); | ||
| assertFragmentBudget(encoded.fragmentBody); | ||
| url = createFragmentUrl(DEFAULT_VIEWER_URL, encoded.fragmentBody); | ||
| // The markdown surface is a different candidate (selected by percent-escaped length), so it | ||
| // carries its own visible length and needs its own budget check: the viewer enforces the visible | ||
| // budget on whatever fragment it opens, so an unchecked link here could render "too-large" for a | ||
| // payload whose --format url link works. | ||
| if (encoded.transportFragmentBody === encoded.fragmentBody) { | ||
| markdownUrl = url; | ||
| } else { | ||
| assertFragmentBudget(encoded.transportFragmentBody); | ||
| markdownUrl = createFragmentUrl(DEFAULT_VIEWER_URL, encoded.transportFragmentBody); | ||
| } | ||
| } | ||
|
|
||
| const formatted = formatArtifactOutput(options.format, label, url, markdownUrl); | ||
| if (formatted.warning) process.stderr.write(`${formatted.warning}\n`); | ||
| if (options.json) { | ||
| const bytes = inputs.reduce((total, input) => total + Buffer.byteLength(input.content), 0); | ||
| process.stdout.write(`${JSON.stringify({ url, mode, bytes, warning: formatted.warning })}\n`); | ||
| } else { | ||
| process.stdout.write(`${formatted.text}\n`); | ||
| } | ||
| } | ||
|
|
||
| async function runConfig(args: string[]): Promise<void> { | ||
| const [operation, key, value, ...rest] = args; | ||
| if (rest.length > 0 || !operation || !key) { | ||
| throw new Error("Usage: agent-render config set KEY VALUE | agent-render config get KEY"); | ||
| } | ||
| if (operation === "set") { | ||
| if (value === undefined) throw new Error("Usage: agent-render config set KEY VALUE"); | ||
| const configPath = await setConfigValue(key, value); | ||
| process.stderr.write(`Updated ${configPath}\n`); | ||
| return; | ||
| } | ||
| if (operation === "get") { | ||
| if (value !== undefined) throw new Error("Usage: agent-render config get KEY"); | ||
| const stored = await getConfigValue(key); | ||
| if (stored === undefined) throw new Error(`Config key "${key}" is not set.`); | ||
| process.stdout.write(`${stored}\n`); | ||
| return; | ||
| } | ||
| throw new Error(`Unknown config operation "${operation}". Expected set or get.`); | ||
| } | ||
|
|
||
| /** Runs the agent-render command-line interface. */ | ||
| export async function runCli(args: string[]): Promise<void> { | ||
| const [command, ...rest] = args; | ||
| if (command === "create") return runCreate(rest); | ||
| if (command === "config") return runConfig(rest); | ||
| throw new Error("Usage: agent-render create [files...] [options] | agent-render config set|get ..."); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
|
|
||
| export type ConfigKey = "INSTANCE_URL" | "TOKEN"; | ||
|
|
||
| export type StoredConfig = { | ||
| instanceUrl?: string; | ||
| token?: string; | ||
| }; | ||
|
|
||
| export type ResolvedConfig = StoredConfig & { | ||
| configPath: string; | ||
| }; | ||
|
|
||
| /** The env shape these helpers read: a bag of optional string vars, not the framework-augmented ProcessEnv. */ | ||
| export type EnvLookup = Readonly<Record<string, string | undefined>>; | ||
|
|
||
| function normalizeConfigKey(key: string): ConfigKey { | ||
| const normalized = key.replace(/[-_]/g, "").toLowerCase(); | ||
| if (normalized === "instanceurl") return "INSTANCE_URL"; | ||
| if (normalized === "token") return "TOKEN"; | ||
| throw new Error(`Unknown config key "${key}". Expected INSTANCE_URL or TOKEN.`); | ||
| } | ||
|
|
||
| /** Resolves the XDG-compatible agent-render config file path. */ | ||
| export function getConfigPath(env: EnvLookup = process.env): string { | ||
| const configHome = env.XDG_CONFIG_HOME?.trim(); | ||
| const home = env.HOME?.trim() || os.homedir(); | ||
| return path.join(configHome || path.join(home, ".config"), "agent-render", "config.json"); | ||
| } | ||
|
|
||
| /** Reads stored CLI configuration, treating a missing file as empty configuration. */ | ||
| export async function readStoredConfig(env: EnvLookup = process.env): Promise<StoredConfig> { | ||
| const configPath = getConfigPath(env); | ||
| let contents: string; | ||
| try { | ||
| contents = await readFile(configPath, "utf8"); | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; | ||
| throw error; | ||
| } | ||
|
|
||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(contents); | ||
| } catch { | ||
| // Name the file: config is read on every create, including fragment mode that needs none, so a | ||
| // bare SyntaxError here would fail an unrelated command with no way to find the cause. | ||
| throw new Error(`Config file ${configPath} is not valid JSON.`); | ||
| } | ||
| if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { | ||
| throw new Error(`Config file ${configPath} must contain a JSON object.`); | ||
| } | ||
|
|
||
| const record = parsed as Record<string, unknown>; | ||
| return { | ||
| instanceUrl: typeof record.instanceUrl === "string" ? record.instanceUrl : undefined, | ||
| token: typeof record.token === "string" ? record.token : undefined, | ||
| }; | ||
| } | ||
|
|
||
| /** Resolves CLI configuration with flags taking precedence over environment and file values. */ | ||
| export async function resolveConfig( | ||
| flags: StoredConfig = {}, | ||
| env: EnvLookup = process.env, | ||
| ): Promise<ResolvedConfig> { | ||
| const stored = await readStoredConfig(env); | ||
| return { | ||
| instanceUrl: flags.instanceUrl ?? env.AGENT_RENDER_INSTANCE_URL ?? stored.instanceUrl, | ||
| token: flags.token ?? env.AGENT_RENDER_TOKEN ?? stored.token, | ||
| configPath: getConfigPath(env), | ||
| }; | ||
| } | ||
|
|
||
| /** Stores one supported CLI configuration value. */ | ||
| export async function setConfigValue( | ||
| keyInput: string, | ||
| value: string, | ||
| env: EnvLookup = process.env, | ||
| ): Promise<string> { | ||
| const key = normalizeConfigKey(keyInput); | ||
| const configPath = getConfigPath(env); | ||
| const config = await readStoredConfig(env); | ||
| if (key === "INSTANCE_URL") config.instanceUrl = value; | ||
| else config.token = value; | ||
|
|
||
| await mkdir(path.dirname(configPath), { recursive: true }); | ||
| await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION:
Reply with |
||
| // writeFile's mode only applies when it creates the file, so an existing world-readable config | ||
| // would keep its permissions while now holding a bearer token. | ||
| await chmod(configPath, 0o600); | ||
| return configPath; | ||
| } | ||
|
|
||
| /** Reads one supported value directly from the config file. */ | ||
| export async function getConfigValue( | ||
| keyInput: string, | ||
| env: EnvLookup = process.env, | ||
| ): Promise<string | undefined> { | ||
| const key = normalizeConfigKey(keyInput); | ||
| const config = await readStoredConfig(env); | ||
| return key === "INSTANCE_URL" ? config.instanceUrl : config.token; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
What is the current latest Node.js LTS version and what@types/nodemajor version tracks it?💡 Result:
As of July 31, 2026, the current latest Node.js Active LTS version is v24.18.1 (codenamed Krypton) [1][2][3]. The
@types/nodepackage typically tracks the major version of Node.js LTS releases [4]. As of the most recent updates in late July 2026, the@types/nodepackage is at version 26.1.2 [5][6]. While@types/nodeversions often align with Node.js major versions, the DefinitelyTyped repository maintains separate type definitions (e.g., v24, v22) to accommodate the different LTS versions actively in use [7]. Users should install the version of@types/nodethat corresponds to the specific major version of Node.js they are using in their project [7].Citations:
🏁 Script executed:
Repository: baanish/agent-render
Length of output: 2489
🌐 Web query:
@types/node major version Node 20 TypeScript target node22 lib set node20 support💡 Result:
To configure your TypeScript project for Node.js 20 or 22, the following guidelines apply regarding
@types/nodeversions, target settings, and library configurations.@types/nodeVersioning The major version of@types/nodeis intended to track the major version of Node.js itself [1][2]. For a project running on Node 20 or Node 22, you should install the corresponding major version of@types/node(e.g.,@types/node@20or@types/node@22) to ensure your type definitions match the APIs available in your target runtime [3][1]. TypeScript Target and Lib Settings According to the official TypeScript Node Target Mapping, the recommended compiler options for both Node 20 and Node 22 are identical [4][5]: { "compilerOptions": { "lib": ["ES2023"], "module": "nodenext", "target": "ES2023" } } Key Considerations: 1. Target/Lib: Using ES2023 as both the target and the lib provides a baseline that covers the ECMAScript features supported by these modern Node.js versions [4][5]. 2. Module Resolution: Settingmoduletonodenextis currently the standard recommendation to ensure correct module resolution for both CommonJS and ESM in modern Node environments [4][5]. 3. Future Updates: While some community-maintained@tsconfig/basespackages may differ slightly in their specific configurations, the Microsoft TypeScript wiki serves as the authoritative source for these mappings [4][6][5]. Note that TypeScript continues to evolve its Node support; watch for future minor updates to these configurations as new TypeScript versions are released [4][5][7].Citations:
@tsconfig/node20does not match Node Target Mapping wiki tsconfig/bases#308@tsconfig/node22/tsconfig.jsonuses the wrong module and moduleResolution!?! tsconfig/bases#279Align
@types/nodewith the supported Node baseline.engines.nodeallows Node>=20, while@types/nodeis^26.0.0. This makestsc --noEmitvalid against Node 26 APIs that newer Node LTSs add, so code that only runs incli/src/**/*.tsorcli/tests/**/*.tscan type-check on Node 20-23 as supported but fail at runtime. Pin@types/nodecloser to the minimum supported Node version, or raiseengines.nodeto the actual supported minimum.🤖 Prompt for AI Agents