From 0e7217a3b41e59c3d18d30c5b550a0f5407bd4bb Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Wed, 10 Jun 2026 11:41:44 +0000 Subject: [PATCH 1/2] Add OSC 8 hyperlinks to the root-help header via .links() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #20. The root-help header (`name vX.Y.Z`) can now carry clickable OSC 8 hyperlinks: the name links to the repo/homepage and the version to the release tag. Both blockers from the issue are addressed: - New `.links({ name?, version? })` builder method stores header-only link targets, so the linked name never leaks into usage lines, the `--help` hint, binName, the commands table, or completion scripts. Called without arguments, URLs are derived from package.json metadata when `.packageJson()` is active (discovery and pre-loaded data forms): name → normalized `repository` URL (fallback `homepage`), version → forge release tag (GitHub `/releases/tag/v{v}`, GitLab `/-/releases/v{v}`). Escapes are gated on TTY detection and overridable via the new `help.hyperlinks` option. - Width helpers are now ANSI/OSC-aware: shared `padEnd()`/`wrapText()` in `help/ansi.ts` measure with `visibleWidth()` (strips CSI + OSC escapes), replacing the duplicated `.length`-based copies in `help/index.ts` and `root-help.ts`. Also exported: `osc8(url, text)`, `visibleWidth(text)`, and `packageRepositoryUrl(pkg)` (normalizes git+/scp/shorthand repository locators to browsable https URLs); package.json parsing now extracts `homepage` and `repository`. --- docs/reference/main.md | 74 +++++ src/core/cli/cli-links.test.ts | 329 +++++++++++++++++++++++ src/core/cli/help-links.ts | 82 ++++++ src/core/cli/index.ts | 105 +++++++- src/core/cli/root-help.ts | 89 +++--- src/core/cli/runtime-preflight.ts | 5 + src/core/completion/completion.test.ts | 1 + src/core/config/index.ts | 4 +- src/core/config/package-json.test.ts | 109 +++++++- src/core/config/package-json.ts | 121 ++++++++- src/core/help/ansi.test.ts | 105 ++++++++ src/core/help/ansi.ts | 144 ++++++++++ src/core/help/index.ts | 59 +--- src/core/json-schema/json-schema.test.ts | 1 + src/index.ts | 5 +- 15 files changed, 1119 insertions(+), 114 deletions(-) create mode 100644 src/core/cli/cli-links.test.ts create mode 100644 src/core/cli/help-links.ts create mode 100644 src/core/help/ansi.test.ts create mode 100644 src/core/help/ansi.ts diff --git a/docs/reference/main.md b/docs/reference/main.md index 1ead63fc..6342bb13 100644 --- a/docs/reference/main.md +++ b/docs/reference/main.md @@ -140,6 +140,39 @@ const deploy = command('deploy'); cli('mycli').packageJson(pkg).command(deploy).run(); ``` +### `.links(links?)` + +Make the root-help header clickable with [OSC 8 +hyperlinks](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda). The program name and +version on the first line of root `--help` output become links in supporting terminals. Escapes are +only emitted when stdout is a TTY (override with the `help.hyperlinks` run option), and only on the +header line — usage lines, the `--help` hint, the commands table, and completion scripts stay plain. + +URLs not provided are derived from `package.json` metadata when `.packageJson()` is active (both the +discovery and pre-loaded data forms): the name links to the normalized `repository` URL (falling back +to `homepage`), and the version links to the forge release tag (`{repo}/releases/tag/v{version}` on +GitHub, `{repo}/-/releases/v{version}` on GitLab). + +```ts twoslash +import { cli, command } from '@kjanat/dreamcli'; + +const deploy = command('deploy'); + +// Derive both links from package.json repository/homepage: +cli('mycli').packageJson().links().command(deploy).run(); + +// Explicit URLs (no package.json required): +cli('mycli') + .version('1.0.0') + .links({ + name: 'https://github.com/me/mycli', + version: + 'https://github.com/me/mycli/releases/tag/v1.0.0', + }) + .command(deploy) + .run(); +``` + ### `.plugin(definition)` Register a CLI plugin created with `plugin(...)`. Plugins run in registration order and can observe @@ -478,6 +511,47 @@ inferCliName({ bin: { mycli: './dist/cli.js' } }); // 'mycli' inferCliName({ name: '@scope/mycli' }); // 'mycli' ``` +### `packageRepositoryUrl(pkg)` + +Resolve a package's `repository` field to a browsable `https://` URL. Handles the locator formats +npm accepts — the `{ type, url }` object form, `git+`-prefixed and `.git`-suffixed URLs, scp-style +locators (`git@host:u/r.git`), and the `github:`/`gitlab:`/`bitbucket:`/bare `u/r` shorthands. +Returns `undefined` when the field is absent or unrecognised. Used by `.links()` to derive the +header name link. + +```ts twoslash +import { packageRepositoryUrl } from '@kjanat/dreamcli'; + +packageRepositoryUrl({ + repository: 'git+https://github.com/me/mycli.git', +}); +// 'https://github.com/me/mycli' +packageRepositoryUrl({ repository: 'github:me/mycli' }); +// 'https://github.com/me/mycli' +``` + +## Help + +### `osc8(url, text)` + +Wrap `text` in an [OSC 8 +hyperlink](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) pointing at `url` +(string or `URL`). Supporting terminals render clickable text; others ignore the escapes. Useful for +linking arbitrary help strings, e.g. `.version(osc8(releaseUrl, '1.0.0'))`. + +### `visibleWidth(text)` + +Measure the visible column width of `text`, ignoring ANSI CSI (colors) and OSC (hyperlink) escape +sequences. Help formatting uses this internally for padding and wrapping, so colored or linked text +no longer breaks table alignment. + +```ts twoslash +import { osc8, visibleWidth } from '@kjanat/dreamcli'; + +const link = osc8('https://github.com/me/mycli', 'mycli'); +visibleWidth(link); // 5 +``` + ## Errors ### `CLIError` diff --git a/src/core/cli/cli-links.test.ts b/src/core/cli/cli-links.test.ts new file mode 100644 index 00000000..405e8b9f --- /dev/null +++ b/src/core/cli/cli-links.test.ts @@ -0,0 +1,329 @@ +/** + * Integration tests for OSC 8 hyperlinks in the root-help header. + * + * Tests the .links() builder method, TTY gating, derivation from + * package.json metadata (repository/homepage, discovery and pre-loaded + * data), and that escapes never leak outside the header line. + */ +import { describe, expect, it } from 'vitest'; +import { osc8 } from '#internals/core/help/index.ts'; +import { command } from '#internals/core/schema/command.ts'; +import { createTestAdapter, ExitError } from '#internals/runtime/index.ts'; +import { cli } from './index.ts'; +import { formatRootHelp } from './root-help.ts'; + +// === Test helpers + +const ESC = '\u001B'; +const REPO = 'https://github.com/me/mytool'; + +/** Command with a description for the commands table. */ +function deployCommand() { + return command('deploy') + .description('Deploy the app') + .action(({ out }) => { + out.log('deployed'); + }); +} + +/** Helper: run app via .run() with adapter, capture stdout/stderr. */ +async function runWithAdapter( + app: ReturnType, + argv: readonly string[], + options?: { + readonly files?: Readonly>; + readonly isTTY?: boolean; + }, +): Promise<{ stdout: string[]; stderr: string[]; exitCode: number }> { + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + let exitCode = 0; + + const adapter = createTestAdapter({ + argv: ['node', 'test', ...argv], + stdout: (s) => stdoutLines.push(s), + stderr: (s) => stderrLines.push(s), + readFile: async (path: string) => options?.files?.[path] ?? null, + ...(options?.isTTY !== undefined ? { isTTY: options.isTTY } : {}), + }); + + try { + await app.run({ adapter }); + } catch (e: unknown) { + if (e instanceof ExitError) { + exitCode = e.code; + } else { + throw e; + } + } + + return { stdout: stdoutLines, stderr: stderrLines, exitCode }; +} + +// === CLIBuilder.links() — builder method + +describe('CLIBuilder.links() — builder method', () => { + it('returns a new CLIBuilder (immutability)', () => { + const a = cli('mytool'); + const b = a.links(); + expect(a).not.toBe(b); + expect(a.schema.helpLinks).toBeUndefined(); + expect(b.schema.helpLinks).toBeDefined(); + }); + + it('stores explicit URLs', () => { + const app = cli('mytool').links({ name: REPO, version: `${REPO}/releases/tag/v1.0.0` }); + expect(app.schema.helpLinks).toEqual({ + name: REPO, + version: `${REPO}/releases/tag/v1.0.0`, + }); + }); + + it('normalizes URL instances to strings', () => { + const app = cli('mytool').links({ name: new URL(REPO) }); + expect(app.schema.helpLinks?.name).toBe(REPO); + }); + + it('stores undefined fields when called without arguments (derive later)', () => { + const app = cli('mytool').links(); + expect(app.schema.helpLinks).toEqual({ name: undefined, version: undefined }); + }); + + it('helpLinks is undefined when .links() not called', () => { + expect(cli('mytool').schema.helpLinks).toBeUndefined(); + }); +}); + +// === Root help — explicit links via execute() + +describe('root help — explicit links', () => { + it('wraps name and version in OSC 8 hyperlinks when TTY', async () => { + const app = cli('mytool') + .version('1.0.0') + .links({ name: REPO, version: `${REPO}/releases/tag/v1.0.0` }) + .command(deployCommand()); + + const result = await app.execute(['--help'], { isTTY: true }); + + const output = result.stdout.join(''); + expect(output).toContain( + `${osc8(REPO, 'mytool')} ${osc8(`${REPO}/releases/tag/v1.0.0`, 'v1.0.0')}`, + ); + }); + + it('keeps usage line, hint, and commands table plain', async () => { + const app = cli('mytool') + .version('1.0.0') + .links({ name: REPO, version: `${REPO}/releases/tag/v1.0.0` }) + .command(deployCommand()); + + const result = await app.execute(['--help'], { isTTY: true }); + + const output = result.stdout.join(''); + expect(output).toContain('Usage: mytool [options]'); + expect(output).toContain("Run 'mytool --help' for more information."); + // Escapes appear in the header line only + const [header, ...rest] = output.split('\n'); + expect(header).toContain(ESC); + expect(rest.join('\n')).not.toContain(ESC); + }); + + it('emits no escapes when stdout is not a TTY (default)', async () => { + const app = cli('mytool') + .version('1.0.0') + .links({ name: REPO }) + .command(deployCommand()); + + const result = await app.execute(['--help']); + + const output = result.stdout.join(''); + expect(output).not.toContain(ESC); + expect(output).toContain('mytool v1.0.0'); + }); + + it('help.hyperlinks option forces links without TTY', async () => { + const app = cli('mytool').version('1.0.0').links({ name: REPO }).command(deployCommand()); + + const result = await app.execute(['--help'], { help: { hyperlinks: true } }); + + expect(result.stdout.join('')).toContain(osc8(REPO, 'mytool')); + }); + + it('help.hyperlinks: false suppresses links on a TTY', async () => { + const app = cli('mytool').version('1.0.0').links({ name: REPO }).command(deployCommand()); + + const result = await app.execute(['--help'], { isTTY: true, help: { hyperlinks: false } }); + + expect(result.stdout.join('')).not.toContain(ESC); + }); + + it('links only the name when no version URL is configured', async () => { + const app = cli('mytool').version('1.0.0').links({ name: REPO }).command(deployCommand()); + + const result = await app.execute(['--help'], { isTTY: true }); + + expect(result.stdout.join('')).toContain(`${osc8(REPO, 'mytool')} v1.0.0`); + }); + + it('--version output stays plain', async () => { + const app = cli('mytool') + .version('1.0.0') + .links({ name: REPO, version: `${REPO}/releases/tag/v1.0.0` }) + .command(deployCommand()); + + const result = await app.execute(['--version'], { isTTY: true }); + + expect(result.stdout.join('')).toBe('1.0.0\n'); + }); +}); + +// === Root help — links derived from package.json data + +describe('root help — links derived from .packageJson(data)', () => { + it('derives name from repository and version from the GitHub release tag', async () => { + const app = cli('mytool') + .packageJson({ version: '2.4.0', repository: `git+${REPO}.git` }) + .links() + .command(deployCommand()); + + const result = await app.execute(['--help'], { isTTY: true }); + + const output = result.stdout.join(''); + expect(output).toContain( + `${osc8(REPO, 'mytool')} ${osc8(`${REPO}/releases/tag/v2.4.0`, 'v2.4.0')}`, + ); + }); + + it('derives links when .links() is called before .packageJson(data)', async () => { + const app = cli('mytool') + .links() + .packageJson({ version: '2.4.0', repository: REPO }) + .command(deployCommand()); + + const result = await app.execute(['--help'], { isTTY: true }); + + expect(result.stdout.join('')).toContain(osc8(REPO, 'mytool')); + }); + + it('falls back to homepage for the name when no repository exists', async () => { + const app = cli('mytool') + .packageJson({ version: '1.0.0', homepage: 'https://mytool.dev' }) + .links() + .command(deployCommand()); + + const result = await app.execute(['--help'], { isTTY: true }); + + const output = result.stdout.join(''); + expect(output).toContain(osc8('https://mytool.dev', 'mytool')); + // No repository → no derived release link + expect(output).toContain(' v1.0.0'); + }); + + it('uses the GitLab release route for gitlab.com repositories', async () => { + const repo = 'https://gitlab.com/me/mytool'; + const app = cli('mytool') + .packageJson({ version: '1.0.0', repository: `git+${repo}.git` }) + .links() + .command(deployCommand()); + + const result = await app.execute(['--help'], { isTTY: true }); + + expect(result.stdout.join('')).toContain(osc8(`${repo}/-/releases/v1.0.0`, 'v1.0.0')); + }); + + it('explicit URLs win over derived ones', async () => { + const app = cli('mytool') + .packageJson({ version: '1.0.0', repository: REPO, homepage: 'https://mytool.dev' }) + .links({ name: 'https://docs.mytool.dev' }) + .command(deployCommand()); + + const result = await app.execute(['--help'], { isTTY: true }); + + const output = result.stdout.join(''); + expect(output).toContain(osc8('https://docs.mytool.dev', 'mytool')); + expect(output).toContain(osc8(`${REPO}/releases/tag/v1.0.0`, 'v1.0.0')); + }); + + it('explicit .version() is used for the derived release tag', async () => { + const app = cli('mytool') + .version('9.9.9') + .packageJson({ version: '1.0.0', repository: REPO }) + .links() + .command(deployCommand()); + + const result = await app.execute(['--help'], { isTTY: true }); + + expect(result.stdout.join('')).toContain(osc8(`${REPO}/releases/tag/v9.9.9`, 'v9.9.9')); + }); + + it('renders a plain header when package.json has no link metadata', async () => { + const app = cli('mytool') + .packageJson({ version: '1.0.0' }) + .links() + .command(deployCommand()); + + const result = await app.execute(['--help'], { isTTY: true }); + + const output = result.stdout.join(''); + expect(output).not.toContain(ESC); + expect(output).toContain('mytool v1.0.0'); + }); +}); + +// === Root help — links derived via .run() discovery + +describe('root help — links derived from package.json discovery in .run()', () => { + const files = { + '/test/package.json': JSON.stringify({ + version: '3.0.0', + repository: { type: 'git', url: `git+${REPO}.git` }, + }), + }; + + it('derives links from the discovered package.json on a TTY', async () => { + const app = cli('mytool').packageJson().links().command(deployCommand()); + + const { stdout } = await runWithAdapter(app, ['--help'], { files, isTTY: true }); + + const output = stdout.join(''); + expect(output).toContain( + `${osc8(REPO, 'mytool')} ${osc8(`${REPO}/releases/tag/v3.0.0`, 'v3.0.0')}`, + ); + }); + + it('stays plain when stdout is not a TTY', async () => { + const app = cli('mytool').packageJson().links().command(deployCommand()); + + const { stdout } = await runWithAdapter(app, ['--help'], { files }); + + const output = stdout.join(''); + expect(output).not.toContain(ESC); + expect(output).toContain('mytool v3.0.0'); + }); + + it('explicit links work in .run() without .packageJson()', async () => { + const app = cli('mytool').version('1.0.0').links({ name: REPO }).command(deployCommand()); + + const { stdout } = await runWithAdapter(app, ['--help'], { isTTY: true }); + + expect(stdout.join('')).toContain(osc8(REPO, 'mytool')); + }); +}); + +// === formatRootHelp — hyperlinks option + +describe('formatRootHelp — hyperlinks option', () => { + it('emits links only when hyperlinks is enabled', () => { + const app = cli('mytool').version('1.0.0').links({ name: REPO }); + + expect(formatRootHelp(app.schema)).not.toContain(ESC); + expect(formatRootHelp(app.schema, { hyperlinks: true })).toContain(osc8(REPO, 'mytool')); + }); + + it('links the name alone when no version is configured', () => { + const app = cli('mytool').links({ name: REPO }); + + const help = formatRootHelp(app.schema, { hyperlinks: true }); + expect(help.startsWith(`${osc8(REPO, 'mytool')}\n`)).toBe(true); + }); +}); diff --git a/src/core/cli/help-links.ts b/src/core/cli/help-links.ts new file mode 100644 index 00000000..1f1d4e3c --- /dev/null +++ b/src/core/cli/help-links.ts @@ -0,0 +1,82 @@ +/** + * Root-help header hyperlink resolution. + * + * Stores explicit `.links()` URLs and derives missing ones from package.json + * metadata (`repository` / `homepage`) once that data is available — during + * runtime preflight for `.run()`, or directly from pre-loaded + * `.packageJson(data)` for `.execute()`. + * + * @module dreamcli/core/cli/help-links + * @internal + */ + +import type { PackageJsonData } from '#internals/core/config/package-json.ts'; +import { packageRepositoryUrl } from '#internals/core/config/package-json.ts'; + +/** + * OSC 8 hyperlink targets for the root-help header. + * + * Set via `CLIBuilder.links()`; fields left `undefined` are derived from + * package.json metadata when `.packageJson()` is active. + */ +interface HelpLinks { + /** URL the program name links to (e.g. the repository or homepage). */ + readonly name: string | undefined; + /** URL the version links to (e.g. the release tag). */ + readonly version: string | undefined; +} + +/** + * Build the release-tag URL for a version on a known forge. + * + * GitHub and GitLab have stable release-tag routes; other hosts return + * `undefined` rather than guessing. + * + * @internal + */ +function releaseTagUrl(repoUrl: string, version: string): string | undefined { + let hostname: string; + try { + hostname = new URL(repoUrl).hostname; + } catch { + return undefined; + } + if (hostname === 'github.com') return `${repoUrl}/releases/tag/v${version}`; + if (hostname === 'gitlab.com') return `${repoUrl}/-/releases/v${version}`; + return undefined; +} + +/** + * Fill `undefined` link fields from package.json metadata. + * + * Explicit URLs always win. The name falls back to the normalized + * `repository` URL, then `homepage`; the version falls back to the forge + * release-tag URL when both a repository and a version are known. + * Idempotent — already-resolved fields pass through unchanged. + * + * @internal + */ +function deriveHelpLinks( + links: HelpLinks | undefined, + pkg: PackageJsonData | undefined, + version: string | undefined, +): HelpLinks | undefined { + if (links === undefined) return undefined; + if (pkg === undefined || (links.name !== undefined && links.version !== undefined)) { + return links; + } + const repoUrl = packageRepositoryUrl(pkg); + return { + name: links.name ?? repoUrl ?? pkg.homepage, + version: + links.version ?? + (repoUrl !== undefined && version !== undefined + ? releaseTagUrl(repoUrl, version) + : undefined), + }; +} + +// --- Exports + +export type { HelpLinks }; +export { deriveHelpLinks }; diff --git a/src/core/cli/index.ts b/src/core/cli/index.ts index 504a73f7..8f8e23cb 100644 --- a/src/core/cli/index.ts +++ b/src/core/cli/index.ts @@ -35,6 +35,8 @@ import type { RunOptions, RunResult } from '#internals/core/schema/run.ts'; import { runCommand } from '#internals/core/testkit/index.ts'; import type { RuntimeAdapter } from '#internals/runtime/adapter.ts'; import { createAdapter } from '#internals/runtime/auto.ts'; +import type { HelpLinks } from './help-links.ts'; +import { deriveHelpLinks } from './help-links.ts'; import type { OutputPolicy } from './planner.ts'; import { planInvocation } from './planner.ts'; import type { CLIPlugin } from './plugin.ts'; @@ -185,6 +187,14 @@ interface CLISchema { * Set via the {@linkcode CLIBuilder.packageJson | .packageJson()} builder method. */ readonly packageJsonSettings: PackageJsonSettings | undefined; + /** + * OSC 8 hyperlink targets for the root-help header (name/version). + * + * Set via the {@linkcode CLIBuilder.links | .links()} builder method. + * Fields left `undefined` are derived from package.json metadata + * (`repository` / `homepage`) when `.packageJson()` is active. + */ + readonly helpLinks: HelpLinks | undefined; /** Whether built-in `.completions()` command registration is active. */ readonly hasBuiltInCompletions: boolean; /** Registered CLI plugins. */ @@ -364,6 +374,54 @@ class CLIBuilder { return new CLIBuilder({ ...this.schema, description: text }); } + /** + * Make the root-help header clickable with OSC 8 hyperlinks. + * + * Links the program name and version on the first line of root `--help` + * output in terminals that support + * [OSC 8 hyperlinks](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda). + * The escapes are only emitted when stdout is a TTY (overridable via + * `options.help.hyperlinks`), and only in the header — usage lines, the + * `--help` hint, the commands table, and completion scripts stay plain. + * + * URLs not provided here are derived from package.json metadata when + * `.packageJson()` is active (works with both filesystem discovery and + * pre-loaded data): + * - **name** → normalized `repository` URL, falling back to `homepage` + * - **version** → forge release tag (`{repo}/releases/tag/v{version}` on + * GitHub, `{repo}/-/releases/v{version}` on GitLab) + * + * @param links - Explicit URLs; omit to derive everything from package.json. + * @returns The builder (for chaining). + * + * @example + * ```ts + * // Derive both links from package.json repository/homepage: + * cli('mycli') + * .packageJson() + * .links() + * .run(); + * + * // Explicit URLs (no package.json required): + * cli('mycli') + * .version('1.0.0') + * .links({ + * name: 'https://github.com/me/mycli', + * version: 'https://github.com/me/mycli/releases/tag/v1.0.0', + * }) + * .run(); + * ``` + */ + links(links?: { readonly name?: string | URL; readonly version?: string | URL }): CLIBuilder { + return new CLIBuilder({ + ...this.schema, + helpLinks: { + name: toUrlString(links?.name), + version: toUrlString(links?.version), + }, + }); + } + /** * Enable automatic config file discovery. * @@ -766,10 +824,12 @@ class CLIBuilder { ); } - // Resolve help options — default binName to CLI program name + // Resolve help options — default binName to CLI program name, + // hyperlinks to TTY detection (escapes never leak into piped output) const helpOptions: HelpOptions = { ...options?.help, binName: options?.help?.binName ?? this.schema.name, + hyperlinks: options?.help?.hyperlinks ?? out.isTTY, }; // -- Shared options for command execution ---------------------------------- @@ -796,7 +856,7 @@ class CLIBuilder { return buildRunResult({ exitCode: 0, error: undefined }, captured); case 'root-help': { - const helpText = formatRootHelp(this.schema, planned.help); + const helpText = formatRootHelp(resolveHelpLinksSchema(this.schema), planned.help); out.log(helpText); return buildRunResult({ exitCode: 0, error: undefined }, captured); } @@ -974,6 +1034,32 @@ function inferInvocationName(argv: readonly string[]): string | undefined { return argv0 !== undefined ? basename(argv0) : undefined; } +// --- Help link helpers + +/** Coerce a `string | URL` link input to its string form. @internal */ +function toUrlString(value: string | URL | undefined): string | undefined { + if (value === undefined) return undefined; + return value instanceof URL ? value.href : value; +} + +/** + * Resolve derived help links against pre-loaded package.json data. + * + * `.run()` resolves links during runtime preflight (where discovered + * package.json metadata lives); this covers the filesystem-free + * `.execute()` path, where only the `.packageJson(data)` overload can + * contribute. Idempotent — already-resolved fields pass through unchanged. + * + * @internal + */ +function resolveHelpLinksSchema(schema: CLISchema): CLISchema { + if (schema.helpLinks === undefined) return schema; + return { + ...schema, + helpLinks: deriveHelpLinks(schema.helpLinks, schema.packageJsonSettings?.data, schema.version), + }; +} + // --- packageJson.from normalisation /** @@ -1016,14 +1102,21 @@ function normalizeFromSetting(from: string | URL | undefined): string | undefine * * A value is treated as `PackageJsonData` when it's a plain object that * carries at least one recognised field (`name`, `version`, `description`, - * or `bin`). An empty `{}` or a settings-shaped object (`inferName` / - * `from`) falls through to the settings overload. + * `bin`, `homepage`, or `repository`). An empty `{}` or a settings-shaped + * object (`inferName` / `from`) falls through to the settings overload. * * @internal */ function isPackageJsonData(value: unknown): value is PackageJsonData { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - return 'name' in value || 'version' in value || 'description' in value || 'bin' in value; + return ( + 'name' in value || + 'version' in value || + 'description' in value || + 'bin' in value || + 'homepage' in value || + 'repository' in value + ); } // --- Factory function @@ -1093,6 +1186,7 @@ function cli(nameOrOptions: string | CLIOptions): CLIBuilder { defaultCommand: undefined, configSettings: undefined, packageJsonSettings: undefined, + helpLinks: undefined, hasBuiltInCompletions: false, plugins: [], }); @@ -1107,5 +1201,6 @@ export type { PluginCommandContext, ResolvedCommandParams, } from './plugin.ts'; +export type { HelpLinks } from './help-links.ts'; export type { CLIOptions, CLIRunOptions, CLISchema, ConfigSettings, PackageJsonSettings }; export { CLIBuilder, cli, formatRootHelp, plugin }; diff --git a/src/core/cli/root-help.ts b/src/core/cli/root-help.ts index 94b02bd3..773a57c4 100644 --- a/src/core/cli/root-help.ts +++ b/src/core/cli/root-help.ts @@ -8,6 +8,7 @@ * @internal */ +import { osc8, padEnd, wrapText } from '#internals/core/help/ansi.ts'; import type { HelpOptions } from '#internals/core/help/index.ts'; import { formatHelpSections } from '#internals/core/help/index.ts'; import type { CommandSchema } from '#internals/core/schema/command.ts'; @@ -15,7 +16,7 @@ import { resolveRootSurface } from './root-surface.ts'; // Re-use CLISchema inline to avoid circular import through the barrel. // Only the shape matters — we read `.name`, `.version`, `.description`, `.commands`, -// `.defaultCommand`. +// `.defaultCommand`, `.helpLinks`. /** Structural subset of `CLISchema` — avoids circular imports through the barrel. */ interface CLISchemaLike { readonly name: string; @@ -25,6 +26,12 @@ interface CLISchemaLike { readonly schema: CommandSchema; }>; readonly defaultCommand: { readonly schema: CommandSchema } | undefined; + readonly helpLinks?: + | { + readonly name: string | undefined; + readonly version: string | undefined; + } + | undefined; } // --- Root help formatter @@ -40,6 +47,7 @@ interface CLISchemaLike { */ function formatRootHelp(schema: CLISchemaLike, options?: HelpOptions): string { const width = options?.width ?? 80; + const hyperlinks = options?.hyperlinks === true; const rootSurface = resolveRootSurface(schema); if (rootSurface.hasSingleVisibleDefault) { const defaultCommand = rootSurface.visibleDefaultCommand; @@ -49,6 +57,7 @@ function formatRootHelp(schema: CLISchemaLike, options?: HelpOptions): string { rootSurface.visibleCommands, rootSurface.visibleDefaultCommand, width, + hyperlinks, ); const usageIndex = sections.findIndex((section) => section.startsWith('Usage: ')); const commandSections = [ @@ -79,6 +88,7 @@ function formatRootHelp(schema: CLISchemaLike, options?: HelpOptions): string { rootSurface.visibleCommands, rootSurface.visibleDefaultCommand, width, + hyperlinks, ); const placeholder = commandPlaceholder( rootSurface.visibleCommands, @@ -99,6 +109,7 @@ function formatRootHelp(schema: CLISchemaLike, options?: HelpOptions): string { * @param schema - The CLI schema. * @param visibleCommands - Non-hidden top-level commands. * @param width - Terminal width for text wrapping. + * @param hyperlinks - Whether to emit OSC 8 hyperlinks in the header. * @returns Ordered help sections (joined later with blank lines). * @internal */ @@ -107,12 +118,12 @@ function buildRootSections( visibleCommands: readonly CommandSchema[], visibleDefaultCommand: CommandSchema | undefined, width: number, + hyperlinks: boolean, ): string[] { const sections: string[] = []; // ---- Header: name + version --------------------------------------------- - const header = schema.version !== undefined ? `${schema.name} v${schema.version}` : schema.name; - sections.push(header); + sections.push(formatHeader(schema, hyperlinks)); // ---- Description -------------------------------------------------------- if (schema.description !== undefined) { @@ -135,6 +146,28 @@ function buildRootSections( return sections; } +/** + * Render the header line (`name vX.Y.Z`). + * + * When `hyperlinks` is enabled and the schema carries link URLs (via + * `CLIBuilder.links()`, possibly derived from package.json metadata), the + * name and version are wrapped in OSC 8 hyperlinks. The header is the only + * place links are emitted — usage lines, hints, and the commands table stay + * plain. + * + * @param schema - The CLI schema. + * @param hyperlinks - Whether OSC 8 hyperlinks may be emitted. + * @returns The header line. + * @internal + */ +function formatHeader(schema: CLISchemaLike, hyperlinks: boolean): string { + const links = hyperlinks ? schema.helpLinks : undefined; + const name = links?.name !== undefined ? osc8(links.name, schema.name) : schema.name; + if (schema.version === undefined) return name; + const version = `v${schema.version}`; + return `${name} ${links?.version !== undefined ? osc8(links.version, version) : version}`; +} + /** * Return the usage-line placeholder for commands (``, `[command]`, or empty). * @@ -214,56 +247,6 @@ function mergeUsageSections(rootUsage: string, commandUsage: string): string { return `${rootUsage}\n${' '.repeat(usagePrefix.length)}${commandSuffix}`; } -// --- Text helpers (duplicated from help module to avoid coupling) - -/** - * Pad `text` to `length` with trailing spaces. - * - * @param text - The string to pad. - * @param length - Target width. - * @returns Padded string. - */ -function padEnd(text: string, length: number): string { - if (text.length >= length) return text; - return text + ' '.repeat(length - text.length); -} - -/** - * Wrap text to `width`, preserving leading indent on continuation lines. - * - * @param text - The text to wrap. - * @param width - Maximum line width. - * @param indent - Indentation for continuation lines. - * @returns Wrapped text with continuation indentation. - */ -function wrapText(text: string, width: number, indent: number): string { - if (text.length + indent <= width) return text; - - const maxLen = width - indent; - if (maxLen <= 0) return text; - - const words = text.split(' '); - const lines: string[] = []; - let current = ''; - - for (const word of words) { - if (current.length === 0) { - current = word; - } else if (current.length + 1 + word.length <= maxLen) { - current += ` ${word}`; - } else { - lines.push(current); - current = word; - } - } - if (current.length > 0) { - lines.push(current); - } - - const pad = ' '.repeat(indent); - return lines.map((line, i) => (i === 0 ? line : `${pad}${line}`)).join('\n'); -} - // --- Exports export { formatRootHelp }; diff --git a/src/core/cli/runtime-preflight.ts b/src/core/cli/runtime-preflight.ts index a4d54f35..32063bba 100644 --- a/src/core/cli/runtime-preflight.ts +++ b/src/core/cli/runtime-preflight.ts @@ -20,6 +20,8 @@ import type { PromptEngine } from '#internals/core/prompt/index.ts'; import { createTerminalPrompter } from '#internals/core/prompt/index.ts'; import type { CommandSchema, ErasedCommand } from '#internals/core/schema/command.ts'; import type { RuntimeAdapter } from '#internals/runtime/adapter.ts'; +import type { HelpLinks } from './help-links.ts'; +import { deriveHelpLinks } from './help-links.ts'; import { planInvocation } from './planner.ts'; import type { CLIPlugin } from './plugin.ts'; @@ -65,6 +67,8 @@ interface RuntimePreflightSchemaLike { readonly configSettings: RuntimeConfigSettings | undefined; /** Package.json discovery settings; `undefined` disables package.json inference. */ readonly packageJsonSettings: RuntimePackageJsonSettings | undefined; + /** Root-help header link targets; `undefined` fields may be derived from package.json. */ + readonly helpLinks: HelpLinks | undefined; /** Whether `.completions()` registered the built-in completions command. */ readonly hasBuiltInCompletions: boolean; /** Plugins forwarded into the execution pipeline. */ @@ -296,6 +300,7 @@ async function applyPackageJsonDiscovery( ? { description: pkg.description } : {}), ...(inferredName !== undefined ? { name: inferredName } : {}), + helpLinks: deriveHelpLinks(schema.helpLinks, pkg, schema.version ?? pkg.version), }; })() : schema; diff --git a/src/core/completion/completion.test.ts b/src/core/completion/completion.test.ts index 3bd3a1a5..aad40c3b 100644 --- a/src/core/completion/completion.test.ts +++ b/src/core/completion/completion.test.ts @@ -98,6 +98,7 @@ function minimalSchema(overrides: MinimalSchemaOverrides = {}): CLISchema { ...(overrides.packageJsonSettings !== undefined ? { packageJsonSettings: overrides.packageJsonSettings } : { packageJsonSettings: undefined }), + helpLinks: undefined, hasBuiltInCompletions: overrides.hasBuiltInCompletions ?? false, plugins: overrides.plugins ?? [], }; diff --git a/src/core/config/index.ts b/src/core/config/index.ts index d7f16d64..23640c46 100644 --- a/src/core/config/index.ts +++ b/src/core/config/index.ts @@ -394,8 +394,8 @@ function configFormat( // --- Exports -export type { PackageJsonAdapter, PackageJsonData } from './package-json.ts'; -export { discoverPackageJson, inferCliName } from './package-json.ts'; +export type { PackageJsonAdapter, PackageJsonData, PackageRepository } from './package-json.ts'; +export { discoverPackageJson, inferCliName, packageRepositoryUrl } from './package-json.ts'; export type { ConfigAdapter, ConfigDiscoveryOptions, diff --git a/src/core/config/package-json.test.ts b/src/core/config/package-json.test.ts index c8e2d640..695ccc41 100644 --- a/src/core/config/package-json.test.ts +++ b/src/core/config/package-json.test.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest'; import type { PackageJsonAdapter } from './package-json.ts'; -import { discoverPackageJson, inferCliName } from './package-json.ts'; +import { discoverPackageJson, inferCliName, packageRepositoryUrl } from './package-json.ts'; // === Test helpers @@ -238,6 +238,51 @@ describe('discoverPackageJson', () => { const result = await discoverPackageJson(adapter); expect(result?.bin).toBeUndefined(); }); + + it('extracts homepage and string repository', async () => { + const adapter = createAdapter({ + '/projects/myapp/package.json': JSON.stringify({ + homepage: 'https://myapp.dev', + repository: 'github:me/myapp', + }), + }); + + const result = await discoverPackageJson(adapter); + expect(result?.homepage).toBe('https://myapp.dev'); + expect(result?.repository).toBe('github:me/myapp'); + }); + + it('extracts object repository (type/url/directory)', async () => { + const adapter = createAdapter({ + '/projects/myapp/package.json': JSON.stringify({ + repository: { + type: 'git', + url: 'git+https://github.com/me/myapp.git', + directory: 'packages/cli', + }, + }), + }); + + const result = await discoverPackageJson(adapter); + expect(result?.repository).toEqual({ + type: 'git', + url: 'git+https://github.com/me/myapp.git', + directory: 'packages/cli', + }); + }); + + it('ignores non-string homepage and malformed repository', async () => { + const adapter = createAdapter({ + '/projects/myapp/package.json': JSON.stringify({ + homepage: 42, + repository: { type: 7, url: false }, + }), + }); + + const result = await discoverPackageJson(adapter); + expect(result?.homepage).toBeUndefined(); + expect(result?.repository).toBeUndefined(); + }); }); // --- error resilience @@ -392,3 +437,65 @@ describe('inferCliName — resolution order', () => { expect(name).toBe('fallback'); }); }); + +// === packageRepositoryUrl — repository field normalization + +describe('packageRepositoryUrl — repository field normalization', () => { + it('strips git+ prefix and .git suffix from https URLs', () => { + expect(packageRepositoryUrl({ repository: 'git+https://github.com/me/myapp.git' })).toBe( + 'https://github.com/me/myapp', + ); + }); + + it('passes plain https URLs through (trailing slash removed)', () => { + expect(packageRepositoryUrl({ repository: 'https://github.com/me/myapp/' })).toBe( + 'https://github.com/me/myapp', + ); + }); + + it('resolves the object form via its url field', () => { + expect( + packageRepositoryUrl({ + repository: { type: 'git', url: 'git+https://github.com/me/myapp.git' }, + }), + ).toBe('https://github.com/me/myapp'); + }); + + it('converts scp-style locators (git@host:path)', () => { + expect(packageRepositoryUrl({ repository: 'git@github.com:me/myapp.git' })).toBe( + 'https://github.com/me/myapp', + ); + }); + + it('converts git:// and ssh:// URLs to https', () => { + expect(packageRepositoryUrl({ repository: 'git://github.com/me/myapp.git' })).toBe( + 'https://github.com/me/myapp', + ); + expect(packageRepositoryUrl({ repository: 'ssh://git@github.com/me/myapp.git' })).toBe( + 'https://github.com/me/myapp', + ); + }); + + it('expands github/gitlab/bitbucket shorthands', () => { + expect(packageRepositoryUrl({ repository: 'github:me/myapp' })).toBe( + 'https://github.com/me/myapp', + ); + expect(packageRepositoryUrl({ repository: 'gitlab:me/myapp' })).toBe( + 'https://gitlab.com/me/myapp', + ); + expect(packageRepositoryUrl({ repository: 'bitbucket:me/myapp' })).toBe( + 'https://bitbucket.org/me/myapp', + ); + }); + + it('treats bare user/repo as a GitHub shorthand (npm convention)', () => { + expect(packageRepositoryUrl({ repository: 'me/myapp' })).toBe('https://github.com/me/myapp'); + }); + + it('returns undefined for missing, empty, or unrecognised locators', () => { + expect(packageRepositoryUrl({})).toBeUndefined(); + expect(packageRepositoryUrl({ repository: ' ' })).toBeUndefined(); + expect(packageRepositoryUrl({ repository: 'not a repo' })).toBeUndefined(); + expect(packageRepositoryUrl({ repository: { type: 'git' } })).toBeUndefined(); + }); +}); diff --git a/src/core/config/package-json.ts b/src/core/config/package-json.ts index 0c2ee63c..4516210e 100644 --- a/src/core/config/package-json.ts +++ b/src/core/config/package-json.ts @@ -19,6 +19,19 @@ function isPlainObject(value: unknown): value is Record { // --- Types +/** + * Object form of the package.json `repository` field + * (e.g. `{"type":"git","url":"git+https://github.com/u/r.git"}`). + */ +interface PackageRepository { + /** Version control system type (usually `'git'`). */ + readonly type?: string; + /** Repository URL or locator. */ + readonly url?: string; + /** Subdirectory within a monorepo where the package lives. */ + readonly directory?: string; +} + /** * Subset of package.json fields relevant to CLI metadata. * @@ -33,6 +46,10 @@ interface PackageJsonData { readonly description?: string; /** Binary entry point(s) — string for single-bin, object for multi-bin. */ readonly bin?: string | Readonly>; + /** Project homepage URL. */ + readonly homepage?: string; + /** Repository locator — URL/shorthand string or `{type, url, directory}` object. */ + readonly repository?: string | PackageRepository; } /** @@ -162,11 +179,15 @@ function parsePackageJson(content: string): PackageJsonData | null { const description = typeof parsed['description'] === 'string' ? parsed['description'] : undefined; const bin = parseBinField(parsed['bin']); + const homepage = typeof parsed['homepage'] === 'string' ? parsed['homepage'] : undefined; + const repository = parseRepositoryField(parsed['repository']); return { ...(name !== undefined ? { name } : {}), ...(version !== undefined ? { version } : {}), ...(description !== undefined ? { description } : {}), ...(bin !== undefined ? { bin } : {}), + ...(homepage !== undefined ? { homepage } : {}), + ...(repository !== undefined ? { repository } : {}), }; } catch { return null; @@ -193,6 +214,102 @@ function parseBinField(value: unknown): string | Readonly return result; } +/** + * Parse the `repository` field from package.json. + * + * Accepts either a locator string (`"repository": "github:u/r"`) or an + * object (`"repository": { "type": "git", "url": "..." }`). Returns + * `undefined` for anything else. + * + * @internal + */ +function parseRepositoryField(value: unknown): string | PackageRepository | undefined { + if (typeof value === 'string') return value; + if (!isPlainObject(value)) return undefined; + const type = typeof value['type'] === 'string' ? value['type'] : undefined; + const url = typeof value['url'] === 'string' ? value['url'] : undefined; + const directory = typeof value['directory'] === 'string' ? value['directory'] : undefined; + if (type === undefined && url === undefined && directory === undefined) return undefined; + return { + ...(type !== undefined ? { type } : {}), + ...(url !== undefined ? { url } : {}), + ...(directory !== undefined ? { directory } : {}), + }; +} + +// --- packageRepositoryUrl + +/** Browsable base URLs for npm repository shorthand prefixes. @internal */ +const SHORTHAND_HOSTS: Readonly> = { + github: 'https://github.com', + gitlab: 'https://gitlab.com', + bitbucket: 'https://bitbucket.org', +}; + +/** Strip a trailing `.git` suffix from a repository path. @internal */ +function stripGitSuffix(path: string): string { + return path.endsWith('.git') ? path.slice(0, -'.git'.length) : path; +} + +/** + * Resolve a package's `repository` field to a browsable `https://` URL. + * + * Handles the locator formats npm accepts: + * - object form: `{ "type": "git", "url": "git+https://github.com/u/r.git" }` + * - `https`/`git`/`ssh` URLs (`git+` prefix and `.git` suffix stripped) + * - scp-style locators: `git@github.com:u/r.git` + * - shorthands: `github:u/r`, `gitlab:u/r`, `bitbucket:u/r`, and bare `u/r` + * (GitHub, per npm convention) + * + * Returns `undefined` when the field is absent or unrecognised. + * + * @example + * ```ts + * packageRepositoryUrl({ repository: 'git+https://github.com/u/r.git' }); + * // 'https://github.com/u/r' + * ``` + */ +function packageRepositoryUrl(pkg: PackageJsonData): string | undefined { + const raw = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url; + if (raw === undefined) return undefined; + + let locator = raw.trim(); + if (locator.length === 0) return undefined; + + // npm shorthands: `github:u/r`, `gitlab:u/r`, `bitbucket:u/r` + const shorthand = /^(github|gitlab|bitbucket):([^/]+\/[^/]+)$/.exec(locator); + if (shorthand !== null && shorthand[1] !== undefined && shorthand[2] !== undefined) { + const host = SHORTHAND_HOSTS[shorthand[1]]; + if (host !== undefined) { + return `${host}/${stripGitSuffix(shorthand[2])}`; + } + } + + // Bare `u/r` implies GitHub (npm convention) + if (/^[\w.-]+\/[\w.-]+$/.test(locator)) { + return `https://github.com/${stripGitSuffix(locator)}`; + } + + if (locator.startsWith('git+')) { + locator = locator.slice('git+'.length); + } + + // scp-style locator: `git@github.com:u/r.git` + const scp = /^git@([^:/]+):(.+)$/.exec(locator); + if (scp !== null && scp[1] !== undefined && scp[2] !== undefined) { + return `https://${scp[1]}/${stripGitSuffix(scp[2].replace(/\/+$/, ''))}`; + } + + if (!/^(?:https?|git|ssh):\/\//.test(locator)) return undefined; + try { + const url = new URL(locator); + const path = stripGitSuffix(url.pathname.replace(/\/+$/, '')); + return `https://${url.hostname}${path}`; + } catch { + return undefined; + } +} + // --- inferCliName /** @@ -229,5 +346,5 @@ function inferCliName(pkg: PackageJsonData): string | undefined { // --- Exports -export type { PackageJsonAdapter, PackageJsonData }; -export { discoverPackageJson, inferCliName }; +export type { PackageJsonAdapter, PackageJsonData, PackageRepository }; +export { discoverPackageJson, inferCliName, packageRepositoryUrl }; diff --git a/src/core/help/ansi.test.ts b/src/core/help/ansi.test.ts new file mode 100644 index 00000000..27488878 --- /dev/null +++ b/src/core/help/ansi.test.ts @@ -0,0 +1,105 @@ +/** + * Unit tests for ANSI/OSC-aware text helpers. + * + * Tests osc8() hyperlink construction, escape stripping, visible-width + * measurement, and the escape-aware padEnd()/wrapText() used by help + * formatting. + */ +import { describe, expect, it } from 'vitest'; +import { osc8, padEnd, stripAnsi, visibleWidth, wrapText } from './ansi.ts'; + +// === osc8 — hyperlink construction + +describe('osc8 — hyperlink construction', () => { + it('wraps text in an OSC 8 hyperlink terminated by BEL', () => { + expect(osc8('https://example.com', 'name')).toBe( + '\u001B]8;;https://example.com\u0007name\u001B]8;;\u0007', + ); + }); + + it('accepts a URL instance', () => { + expect(osc8(new URL('https://example.com/path'), 'x')).toBe( + '\u001B]8;;https://example.com/path\u0007x\u001B]8;;\u0007', + ); + }); +}); + +// === stripAnsi / visibleWidth — escape-aware measurement + +describe('stripAnsi / visibleWidth — escape-aware measurement', () => { + it('passes plain text through unchanged', () => { + expect(stripAnsi('plain text')).toBe('plain text'); + expect(visibleWidth('plain text')).toBe(10); + }); + + it('strips OSC 8 hyperlinks down to the visible text', () => { + const linked = osc8('https://example.com', 'name'); + expect(stripAnsi(linked)).toBe('name'); + expect(visibleWidth(linked)).toBe(4); + }); + + it('strips ST-terminated OSC sequences', () => { + const linked = '\u001B]8;;https://example.com\u001B\\name\u001B]8;;\u001B\\'; + expect(stripAnsi(linked)).toBe('name'); + }); + + it('strips CSI sequences (SGR colors)', () => { + expect(stripAnsi('\u001B[31mred\u001B[0m')).toBe('red'); + expect(visibleWidth('\u001B[1;4mbold\u001B[0m')).toBe(4); + }); + + it('handles mixed links and colors in one string', () => { + const text = `\u001B[2m${osc8('https://example.com', 'dim link')}\u001B[0m`; + expect(visibleWidth(text)).toBe(8); + }); +}); + +// === padEnd — visible-width padding + +describe('padEnd — visible-width padding', () => { + it('pads plain text to the target width', () => { + expect(padEnd('ab', 5)).toBe('ab '); + }); + + it('returns text unchanged at or beyond the target width', () => { + expect(padEnd('abcde', 5)).toBe('abcde'); + expect(padEnd('abcdef', 5)).toBe('abcdef'); + }); + + it('ignores escape sequences when computing padding', () => { + const linked = osc8('https://example.com', 'ab'); + const padded = padEnd(linked, 5); + expect(padded).toBe(`${linked} `); + expect(visibleWidth(padded)).toBe(5); + }); +}); + +// === wrapText — visible-width wrapping + +describe('wrapText — visible-width wrapping', () => { + it('leaves short text unwrapped', () => { + expect(wrapText('short text', 80, 4)).toBe('short text'); + }); + + it('wraps long text with continuation indentation', () => { + const wrapped = wrapText('aaa bbb ccc ddd', 10, 2); + expect(wrapped).toBe('aaa bbb\n ccc ddd'); + }); + + it('does not wrap when escapes inflate the raw length past the width', () => { + // Visible: 'link text' (9 cols) — raw length far exceeds width 20. + const text = `${osc8('https://example.com/very/long/url/that/inflates/length', 'link')} text`; + expect(text.length).toBeGreaterThan(20); + expect(wrapText(text, 20, 4)).toBe(text); + }); + + it('measures linked words by visible width when wrapping', () => { + const linked = osc8('https://example.com/some/long/url', 'bbb'); + const wrapped = wrapText(`aaa ${linked} ccc ddd`, 10, 2); + expect(wrapped).toBe(`aaa ${linked}\n ccc ddd`); + }); + + it('returns text unchanged when indent consumes the full width', () => { + expect(wrapText('aaa bbb ccc ddd ee', 4, 4)).toBe('aaa bbb ccc ddd ee'); + }); +}); diff --git a/src/core/help/ansi.ts b/src/core/help/ansi.ts new file mode 100644 index 00000000..57d2e08f --- /dev/null +++ b/src/core/help/ansi.ts @@ -0,0 +1,144 @@ +/** + * ANSI/OSC-aware text helpers for help formatting. + * + * Terminal escape sequences (SGR colors, OSC 8 hyperlinks) occupy zero + * columns when rendered, so width math must measure the *visible* text. + * {@linkcode visibleWidth} strips escapes before counting, and the shared + * {@linkcode padEnd}/{@linkcode wrapText} helpers build on it so aligned + * tables and wrapped lines stay intact when escapes are present. + * + * @module dreamcli/core/help/ansi + */ + +// --- Escape stripping + +/** + * Matches ANSI escape sequences: CSI (e.g. SGR color codes like `\x1b[31m`) + * and OSC (e.g. OSC 8 hyperlinks), with both BEL and ST terminators. + * + * @internal + */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal escape sequences requires control characters +const ANSI_PATTERN = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\))/g; + +/** + * Remove ANSI CSI and OSC escape sequences from `text`. + * + * @param text - Text possibly containing terminal escapes. + * @returns The text with all escape sequences removed. + */ +function stripAnsi(text: string): string { + return text.replace(ANSI_PATTERN, ''); +} + +/** + * Measure the visible column width of `text`, ignoring ANSI/OSC escapes. + * + * Escape sequences (SGR colors, OSC 8 hyperlinks) occupy zero columns when + * rendered, so `.length` overcounts whenever they are present. Help + * formatting uses this for padding and wrapping; exported for custom help + * renderers that mix colors or links into aligned output. + * + * @param text - Text possibly containing terminal escapes. + * @returns Number of visible characters. + * + * @example + * ```ts + * visibleWidth('plain'); // 5 + * visibleWidth(osc8('https://x.dev', 'x')); // 1 + * ``` + */ +function visibleWidth(text: string): number { + return stripAnsi(text).length; +} + +// --- OSC 8 hyperlinks + +const OSC = '\u001B]'; +const BEL = '\u0007'; + +/** + * Wrap `text` in an [OSC 8 hyperlink](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) + * pointing at `url`. + * + * Supporting terminals render `text` as a clickable link; non-supporting + * terminals ignore the escape sequence and show plain text. Combine with + * {@linkcode visibleWidth}-aware formatting when embedding links in aligned + * output. + * + * @param url - Link target (string or `URL` instance). + * @param text - Visible link text. + * @returns The OSC 8 escape sequence wrapping `text`. + * + * @example + * ```ts + * cli('mycli').version(osc8('https://github.com/me/mycli/releases/tag/v1.0.0', '1.0.0')); + * ``` + */ +function osc8(url: string | URL, text: string): string { + const href = url instanceof URL ? url.href : url; + return `${OSC}8;;${href}${BEL}${text}${OSC}8;;${BEL}`; +} + +// --- Width-aware padding and wrapping + +/** + * Pad `text` to `length` visible columns with trailing spaces. + * + * @param text - The string to pad (may contain ANSI/OSC escapes). + * @param length - Target visible width in columns. + * @returns The padded string, unchanged if already at or beyond `length`. + */ +function padEnd(text: string, length: number): string { + const visible = visibleWidth(text); + if (visible >= length) return text; + return text + ' '.repeat(length - visible); +} + +/** + * Wrap text to `width`, preserving leading indent on continuation lines. + * + * Line lengths are measured with {@linkcode visibleWidth}, so embedded + * ANSI/OSC escapes do not trigger premature wrapping. + * + * @param text - The text to wrap. + * @param width - Maximum line width in columns. + * @param indent - Number of leading spaces for continuation lines. + * @returns The wrapped string with newlines inserted as needed. + */ +function wrapText(text: string, width: number, indent: number): string { + if (visibleWidth(text) + indent <= width) return text; + + const maxLen = width - indent; + if (maxLen <= 0) return text; + + const words = text.split(' '); + const lines: string[] = []; + let current = ''; + let currentWidth = 0; + + for (const word of words) { + const wordWidth = visibleWidth(word); + if (current.length === 0) { + current = word; + currentWidth = wordWidth; + } else if (currentWidth + 1 + wordWidth <= maxLen) { + current += ` ${word}`; + currentWidth += 1 + wordWidth; + } else { + lines.push(current); + current = word; + currentWidth = wordWidth; + } + } + if (current.length > 0) { + lines.push(current); + } + + const pad = ' '.repeat(indent); + return lines.map((line, i) => (i === 0 ? line : `${pad}${line}`)).join('\n'); +} + +// --- Exports + +export { osc8, padEnd, stripAnsi, visibleWidth, wrapText }; diff --git a/src/core/help/index.ts b/src/core/help/index.ts index 92a0562e..0ef6e162 100644 --- a/src/core/help/index.ts +++ b/src/core/help/index.ts @@ -10,6 +10,7 @@ import { formatDisplayValue } from '#internals/core/output/display-value.ts'; import { getFlagAliasNames } from '#internals/core/schema/flag.ts'; +import { padEnd, wrapText } from './ansi.ts'; import type { ArgSchema, CommandArgEntry, @@ -26,6 +27,13 @@ interface HelpOptions { readonly width?: number; /** Binary/program name shown in the usage line. Defaults to command name. */ readonly binName?: string; + /** + * Emit OSC 8 hyperlinks where link metadata is available (currently the + * root-help header name/version configured via `CLIBuilder.links()`). + * Defaults to `false`; `CLIBuilder.execute()`/`.run()` enable it + * automatically when stdout is a TTY. + */ + readonly hyperlinks?: boolean; /** @internal Whether this usage line is being rendered as merged root/default help. */ readonly isDefaultHelp?: boolean; } @@ -53,56 +61,6 @@ function resolveOptions(options?: HelpOptions): ResolvedHelpOptions { }; } -// --- Internal helpers - -/** - * Pad `text` to `length` with trailing spaces. - * - * @param text - The string to pad. - * @param length - Target length in characters. - * @returns The padded string, unchanged if already at or beyond `length`. - */ -function padEnd(text: string, length: number): string { - if (text.length >= length) return text; - return text + ' '.repeat(length - text.length); -} - -/** - * Wrap text to `width`, preserving leading indent on continuation lines. - * - * @param text - The text to wrap. - * @param width - Maximum line width in columns. - * @param indent - Number of leading spaces for continuation lines. - * @returns The wrapped string with newlines inserted as needed. - */ -function wrapText(text: string, width: number, indent: number): string { - if (text.length + indent <= width) return text; - - const maxLen = width - indent; - if (maxLen <= 0) return text; - - const words = text.split(' '); - const lines: string[] = []; - let current = ''; - - for (const word of words) { - if (current.length === 0) { - current = word; - } else if (current.length + 1 + word.length <= maxLen) { - current += ` ${word}`; - } else { - lines.push(current); - current = word; - } - } - if (current.length > 0) { - lines.push(current); - } - - const pad = ' '.repeat(indent); - return lines.map((line, i) => (i === 0 ? line : `${pad}${line}`)).join('\n'); -} - // --- Deprecation formatting /** @@ -554,4 +512,5 @@ function formatExamplesSection(examples: readonly CommandExample[]): string { // --- Exports export type { HelpOptions }; +export { osc8, visibleWidth } from './ansi.ts'; export { formatHelp, formatHelpSections }; diff --git a/src/core/json-schema/json-schema.test.ts b/src/core/json-schema/json-schema.test.ts index 6b969b1d..8f1bbe29 100644 --- a/src/core/json-schema/json-schema.test.ts +++ b/src/core/json-schema/json-schema.test.ts @@ -76,6 +76,7 @@ function minimalCLI(overrides: MinimalCLIOverrides = {}): CLISchema { defaultCommand: overrides.defaultCommand ?? undefined, configSettings: undefined, packageJsonSettings: undefined, + helpLinks: undefined, hasBuiltInCompletions: false, plugins: [], }; diff --git a/src/index.ts b/src/index.ts index c00dba15..19a43ce1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export type { CLIRunOptions, CLISchema, ConfigSettings, + HelpLinks, PackageJsonSettings, PluginCommandContext, ResolvedCommandParams, @@ -49,6 +50,7 @@ export type { FormatLoader, PackageJsonAdapter, PackageJsonData, + PackageRepository, } from './core/config/index.ts'; export { buildConfigSearchPaths, @@ -56,6 +58,7 @@ export { discoverConfig, discoverPackageJson, inferCliName, + packageRepositoryUrl, } from './core/config/index.ts'; export type { CLIErrorJSON, @@ -75,7 +78,7 @@ export { ValidationError, } from './core/errors/index.ts'; export type { HelpOptions } from './core/help/index.ts'; -export { formatHelp } from './core/help/index.ts'; +export { formatHelp, osc8, visibleWidth } from './core/help/index.ts'; export type { JsonSchemaOptions } from './core/json-schema/index.ts'; export { definitionMetaSchema, From c4538985a712030d931d77755fb4ec386cf568da Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Wed, 10 Jun 2026 20:41:04 +0000 Subject: [PATCH 2/2] Bump version to 2.2.1 and add changelog entry --- CHANGELOG.md | 29 ++++++++++++++++++++++++++++- deno.json | 2 +- package.json | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a06540d..34a20d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [2.2.1] - 2026-06-10 + +### Added + +- **`.links()` — OSC 8 hyperlinks in the root-help header** — the program name and version on the + first line of root `--help` output can now carry + [OSC 8 hyperlinks](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) in + supporting terminals ([#20](https://github.com/kjanat/dreamcli/issues/20)). Pass explicit URLs + (`.links({ name, version })`) or call `.links()` with no arguments to derive them from + `package.json` metadata when `.packageJson()` is active: the name links to the normalized + `repository` URL (falling back to `homepage`), and the version links to the forge release tag + (`{repo}/releases/tag/v{version}` on GitHub, `{repo}/-/releases/v{version}` on GitLab). Escapes + are emitted only when stdout is a TTY (overridable via the new `help.hyperlinks` option) and only + on the header line — usage lines, the `--help` hint, the commands table, `--version` output, and + completion scripts stay plain. +- **ANSI/OSC-aware help width helpers** — help padding and wrapping now measure _visible_ width: + the shared `padEnd()`/`wrapText()` helpers strip ANSI CSI (colors) and OSC (hyperlink) escape + sequences before counting columns, so escape-bearing text no longer mangles `--help` alignment. + New public exports `osc8(url, text)` (wrap text in an OSC 8 hyperlink) and `visibleWidth(text)` + (escape-aware width measurement) support custom help rendering. +- **`packageRepositoryUrl(pkg)`** — new public helper that normalizes a package's `repository` + field to a browsable `https://` URL, handling the locator formats npm accepts (`{ type, url }` + object form, `git+`/`.git` affixes, scp-style `git@host:u/r.git`, and the + `github:`/`gitlab:`/`bitbucket:`/bare `u/r` shorthands). `PackageJsonData` now also parses the + `homepage` and `repository` fields. + ## [2.2.0] - 2026-06-09 ### Added @@ -764,7 +790,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - MIT License. - Markdownlint configuration. -[Unreleased]: https://github.com/kjanat/dreamcli/compare/v2.2.0...HEAD +[Unreleased]: https://github.com/kjanat/dreamcli/compare/v2.2.1...HEAD +[2.2.1]: https://github.com/kjanat/dreamcli/compare/v2.2.0...v2.2.1 [2.2.0]: https://github.com/kjanat/dreamcli/compare/v2.1.0...v2.2.0 [2.1.0]: https://github.com/kjanat/dreamcli/compare/v2.0.1...v2.1.0 [2.0.1]: https://github.com/kjanat/dreamcli/compare/v2.0.0...v2.0.1 diff --git a/deno.json b/deno.json index 39c7abce..6a62c4dc 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/denoland/deno/main/cli/schemas/config-file.v1.json", "name": "@kjanat/dreamcli", - "version": "2.2.0", + "version": "2.2.1", "license": "MIT", "tasks": { "check": { diff --git a/package.json b/package.json index ea82c803..cf624498 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kjanat/dreamcli", - "version": "2.2.0", + "version": "2.2.1", "description": "Schema-first, fully typed TypeScript CLI framework", "keywords": [ "cli",