launchr-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ name: Publish npm Package
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ release:
8
+ types:
9
+ - published
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ publish:
16
+ runs-on: ubuntu-latest
17
+ concurrency:
18
+ group: npm-publish-${{ github.event.release.tag_name || github.ref_name }}
19
+ cancel-in-progress: false
20
+
21
+ steps:
22
+ - name: Checkout repository
23
+ uses: actions/checkout@v4
24
+
25
+ - name: Setup Node.js
26
+ uses: actions/setup-node@v4
27
+ with:
28
+ node-version: 20
29
+ registry-url: https://registry.npmjs.org
30
+ cache: npm
31
+
32
+ - name: Install dependencies
33
+ run: npm ci
34
+
35
+ - name: Run tests
36
+ run: npm test
37
+
38
+ - name: Resolve tag name
39
+ id: tag
40
+ run: |
41
+ if [ "${{ github.event_name }}" = "release" ]; then
42
+ TAG="${{ github.event.release.tag_name }}"
43
+ else
44
+ TAG="${{ github.ref_name }}"
45
+ fi
46
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
47
+
48
+ - name: Verify tag matches package version
49
+ run: |
50
+ PKG_VERSION="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")"
51
+ EXPECTED_TAG="v$PKG_VERSION"
52
+
53
+ if [ "${{ steps.tag.outputs.tag }}" != "$EXPECTED_TAG" ]; then
54
+ echo "Tag ${{ steps.tag.outputs.tag }} does not match package version $EXPECTED_TAG."
55
+ exit 1
56
+ fi
57
+
58
+ - name: Check if this version already exists on npm
59
+ id: npm_check
60
+ run: |
61
+ PKG_NAME="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).name")"
62
+ PKG_VERSION="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")"
63
+
64
+ if npm view "${PKG_NAME}@${PKG_VERSION}" version >/dev/null 2>&1; then
65
+ echo "already_published=true" >> "$GITHUB_OUTPUT"
66
+ else
67
+ echo "already_published=false" >> "$GITHUB_OUTPUT"
68
+ fi
69
+
70
+ - name: Publish to npm
71
+ if: steps.npm_check.outputs.already_published != 'true'
72
+ run: npm publish --access public
73
+ env:
74
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
75
+
76
+ - name: Skip publish (already published)
77
+ if: steps.npm_check.outputs.already_published == 'true'
78
+ run: echo "This package version is already published. Skipping."
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # launchr CLI
2
+
3
+ <p align="center">
4
+ <img src="assets/launchr-logo.png" alt="launchr logo" width="420">
5
+ </p>
6
+
7
+ `launchr` is a configuration-driven CLI to build URLs from typed parameters and open them in your default browser.
8
+
9
+ Built with:
10
+ - Node.js (ESM)
11
+ - `zx`
12
+ - `fs/promises`
13
+
14
+ ## Requirements
15
+ - Node.js 20+
16
+
17
+ ## Install
18
+ ```bash
19
+ npm install
20
+ npm link
21
+ ```
22
+
23
+ After linking, `launchr` is available in your shell.
24
+
25
+ ## Publish to npm (GitHub Actions)
26
+ This repository includes an automated workflow at:
27
+
28
+ `.github/workflows/publish-npm.yml`
29
+
30
+ It publishes to npm when you either:
31
+ - push a tag matching `v*`
32
+ - publish a GitHub Release
33
+
34
+ Rules:
35
+ - tag must match package version in `package.json` (example: `v1.2.3`)
36
+ - tests must pass before publish
37
+ - if that version already exists on npm, publish is skipped
38
+
39
+ Required GitHub secret:
40
+ - `NPM_TOKEN`: npm automation token with publish access
41
+
42
+ Typical release flow:
43
+ ```bash
44
+ npm version patch
45
+ git push origin main --follow-tags
46
+ ```
47
+
48
+ ## Configuration Location
49
+ `launchr` uses:
50
+
51
+ `~/.launchr-configurations/launchr-commands.json`
52
+
53
+ If the file is missing, the CLI prompts:
54
+
55
+ `No configuration found. Do you want to create one? (yes/no)`
56
+
57
+ If declined, the CLI exits with:
58
+
59
+ `Configuration file is required to use this CLI.`
60
+
61
+ ## Commands
62
+ ```bash
63
+ launchr
64
+ launchr help
65
+ launchr list
66
+ launchr init
67
+ launchr <custom-command> help
68
+ launchr <custom-command> [flags]
69
+ ```
70
+
71
+ ## Interactive Setup
72
+ Run:
73
+ ```bash
74
+ launchr init
75
+ ```
76
+
77
+ You will be asked for:
78
+ - command name
79
+ - description
80
+ - URL template (with named placeholders like `{query}`)
81
+ - parameters (loop until `done`)
82
+ - key
83
+ - type (`string`, `integer`, `boolean`, `single-choice-list`)
84
+ - flag (`q` means `-q`)
85
+ - required (`true/false`)
86
+ - default value
87
+ - allowed values (for `single-choice-list`)
88
+
89
+ Type `finish` when asked for command name to stop immediately.
90
+
91
+ ## JSON Example
92
+ ```json
93
+ {
94
+ "grafana": {
95
+ "description": "some useful information",
96
+ "url": "https://grafana.com/{environments}/{query}/{timeframe}",
97
+ "parameters": {
98
+ "environments": {
99
+ "type": "single-choice-list",
100
+ "flag": "e",
101
+ "defaultValue": "staging",
102
+ "required": true,
103
+ "values": [
104
+ "staging",
105
+ "production"
106
+ ]
107
+ },
108
+ "query": {
109
+ "type": "string",
110
+ "flag": "q",
111
+ "defaultValue": "error",
112
+ "required": true
113
+ },
114
+ "timeframe": {
115
+ "type": "single-choice-list",
116
+
117
+ "flag": "t",
118
+ "defaultValue": "5m",
119
+ "required": true,
120
+ "values": [
121
+ "5m",
122
+ "10m",
123
+ "1h",
124
+ "6h"
125
+ ]
126
+ }
127
+ }
128
+ }
129
+ }
130
+ ```
131
+
132
+ ## Usage Examples
133
+ ```bash
134
+ launchr list
135
+ launchr grafana help
136
+ launchr grafana -e production -q error -t 5m
137
+ ```
138
+
139
+ ## Validation and Errors
140
+ - schema validation on config load
141
+ - malformed JSON detection
142
+ - required parameter checks
143
+ - type checks (`string`, `integer`, `boolean`, `single-choice-list`)
144
+ - allowed-value checks for `single-choice-list`
145
+ - URL placeholder count checks
146
+
147
+ ## Tests
148
+ ```bash
149
+ npm test
150
+ ```
@@ -0,0 +1,11 @@
1
+ # launchr v1.0.0
2
+
3
+ First stable release of `launchr`, a config-driven CLI to build URLs from typed flags and open them in your default browser.
4
+
5
+ ## Highlights
6
+
7
+ - Interactive setup with `launchr init` to create custom commands and URL templates.
8
+ - Typed parameter support: `string`, `integer`, `boolean`, `single-choice-list`.
9
+ - Runtime checks for required params, unknown flags, invalid values, and URL placeholders.
10
+ - URL interpolation with encoding plus cross-platform browser launch (`open` / `cmd start` / `xdg-open`).
11
+ - Automated npm publish workflow with test gate and version/tag verification.
Binary file
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "launchr-cli",
3
+ "version": "1.0.0",
4
+ "description": "Config-driven URL launcher CLI built with Node.js + zx",
5
+ "type": "module",
6
+ "bin": {
7
+ "launchr": "./src/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "start": "node ./src/cli.mjs",
11
+ "test": "node --test"
12
+ },
13
+ "engines": {
14
+ "node": ">=20.0.0"
15
+ },
16
+ "dependencies": {
17
+ "zx": "^8.8.4"
18
+ }
19
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+
3
+ import os from "node:os";
4
+ import { realpathSync } from "node:fs";
5
+ import process from "node:process";
6
+ import { pathToFileURL } from "node:url";
7
+ import { buildGeneralHelp, buildCommandHelp, buildUsageText } from "./commands/help.mjs";
8
+ import { runInitFlow } from "./commands/init.mjs";
9
+ import { buildCommandList } from "./commands/list.mjs";
10
+ import { runCustomCommand } from "./commands/run-custom.mjs";
11
+ import { getConfigDirPath, getConfigFilePath } from "./config/paths.mjs";
12
+ import {
13
+ ensureConfigExistsOrPromptCreate,
14
+ loadConfiguration,
15
+ saveConfiguration,
16
+ } from "./config/store.mjs";
17
+ import { parseArgv } from "./parsing/argv.mjs";
18
+ import { promptYesNo, createPrompter } from "./utils/prompt.mjs";
19
+ import { UsageError } from "./utils/errors.mjs";
20
+ import { openInBrowser } from "./utils/browser.mjs";
21
+
22
+ function writeLine(stream, message = "") {
23
+ stream.write(`${message}\n`);
24
+ }
25
+
26
+ export async function runCli(argv = process.argv.slice(2), options = {}) {
27
+ const stdout = options.stdout ?? process.stdout;
28
+ const stderr = options.stderr ?? process.stderr;
29
+ const input = options.input ?? process.stdin;
30
+ const homeDir = options.homeDir ?? os.homedir();
31
+ const promptYesNoFn = options.promptYesNoFn ?? promptYesNo;
32
+ const createPrompterFn = options.createPrompterFn ?? createPrompter;
33
+ const openUrlFn = options.openUrlFn ?? openInBrowser;
34
+
35
+ const configDirPath = getConfigDirPath(homeDir);
36
+ const configFilePath = getConfigFilePath(homeDir);
37
+
38
+ try {
39
+ await ensureConfigExistsOrPromptCreate({
40
+ configDirPath,
41
+ configFilePath,
42
+ promptYesNo: (question) => promptYesNoFn(question, { input, output: stdout }),
43
+ });
44
+
45
+ let config = await loadConfiguration(configFilePath);
46
+ const parsed = parseArgv(argv);
47
+
48
+ if (!parsed.command) {
49
+ writeLine(stdout, buildUsageText(config));
50
+ return 0;
51
+ }
52
+
53
+ if (parsed.command === "help") {
54
+ writeLine(stdout, buildGeneralHelp(config));
55
+ return 0;
56
+ }
57
+
58
+ if (parsed.command === "list") {
59
+ writeLine(stdout, buildCommandList(config));
60
+ return 0;
61
+ }
62
+
63
+ if (parsed.command === "init") {
64
+ const prompter = createPrompterFn({ input, output: stdout });
65
+ try {
66
+ config = await runInitFlow({
67
+ config,
68
+ prompter,
69
+ saveConfig: (nextConfig) => saveConfiguration(configFilePath, nextConfig),
70
+ });
71
+ if (Object.keys(config).length > 0) {
72
+ writeLine(stdout, buildCommandList(config));
73
+ }
74
+ } finally {
75
+ prompter.close();
76
+ }
77
+ return 0;
78
+ }
79
+
80
+ const commandConfig = config[parsed.command];
81
+ if (!commandConfig) {
82
+ throw new UsageError(`Unknown command "${parsed.command}". Run "launchr list" to see available commands.`);
83
+ }
84
+
85
+ if (parsed.rest[0] === "help") {
86
+ writeLine(stdout, buildCommandHelp(parsed.command, commandConfig));
87
+ return 0;
88
+ }
89
+
90
+ const result = await runCustomCommand({
91
+ commandName: parsed.command,
92
+ commandConfig,
93
+ argv: parsed.rest,
94
+ openUrl: openUrlFn,
95
+ });
96
+ writeLine(stdout, `Opening URL: ${result.finalUrl}`);
97
+ return 0;
98
+ } catch (error) {
99
+ writeLine(stderr, error.message);
100
+ return 1;
101
+ }
102
+ }
103
+
104
+ function isDirectInvocation() {
105
+ if (!process.argv[1]) {
106
+ return false;
107
+ }
108
+
109
+ const argvPath = process.argv[1];
110
+ const argvHref = pathToFileURL(argvPath).href;
111
+ if (import.meta.url === argvHref) {
112
+ return true;
113
+ }
114
+
115
+ try {
116
+ const resolvedArgvPath = realpathSync(argvPath);
117
+ const resolvedArgvHref = pathToFileURL(resolvedArgvPath).href;
118
+ return import.meta.url === resolvedArgvHref;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ if (isDirectInvocation()) {
125
+ const code = await runCli();
126
+ process.exitCode = code;
127
+ }
@@ -0,0 +1,101 @@
1
+ const BUILTIN_COMMANDS = [
2
+ { name: "help", description: "Show manual" },
3
+ { name: "list", description: "List available commands" },
4
+ { name: "init", description: "Interactive setup" },
5
+ ];
6
+
7
+ function formatRequired(required) {
8
+ return required ? "required" : "optional";
9
+ }
10
+
11
+ function formatParameterType(parameterDefinition) {
12
+ if (parameterDefinition.type === "single-choice-list") {
13
+ return `Allowed values: ${parameterDefinition.values.join(", ")}`;
14
+ }
15
+ return `Type: ${parameterDefinition.type}`;
16
+ }
17
+
18
+ function normalizeDescription(rawDescription) {
19
+ if (typeof rawDescription !== "string") {
20
+ return "No description";
21
+ }
22
+
23
+ const trimmed = rawDescription.trim();
24
+ return trimmed.length > 0 ? trimmed : "No description";
25
+ }
26
+
27
+ function formatCommandRows(commandRows) {
28
+ if (commandRows.length === 0) {
29
+ return [" (none)"];
30
+ }
31
+
32
+ const nameWidth = commandRows.reduce((maxWidth, row) => {
33
+ return Math.max(maxWidth, row.name.length);
34
+ }, 0);
35
+
36
+ return commandRows.map((row) => {
37
+ return ` ${row.name.padEnd(nameWidth)} ${row.description}`;
38
+ });
39
+ }
40
+
41
+ export function buildUsageText(config = {}) {
42
+ const customCommandRows = Object.entries(config).map(([commandName, commandConfig]) => {
43
+ return {
44
+ name: commandName,
45
+ description: normalizeDescription(commandConfig?.description),
46
+ };
47
+ });
48
+
49
+ return [
50
+ "Usage: launchr <command> [options]",
51
+ "",
52
+ "Built-in Commands:",
53
+ ...formatCommandRows(BUILTIN_COMMANDS),
54
+ "",
55
+ "Custom Commands:",
56
+ ...formatCommandRows(customCommandRows),
57
+ ].join("\n");
58
+ }
59
+
60
+ export function buildGeneralHelp(config = {}) {
61
+ const usageText = buildUsageText(config);
62
+ return [
63
+ usageText,
64
+ "",
65
+ "How to define commands:",
66
+ " Run `launchr init` and follow prompts to create command metadata, URL templates, and parameters.",
67
+ "",
68
+ "How parameters work:",
69
+ " Each parameter has a type, a short flag, required/default rules, and optional allowed values.",
70
+ " URL templates use named placeholders like `{query}` mapped by parameter key.",
71
+ "",
72
+ "Examples:",
73
+ " launchr list",
74
+ " launchr grafana help",
75
+ " launchr grafana -e production -q error -t 5m",
76
+ ].join("\n");
77
+ }
78
+
79
+ export function buildCommandHelp(commandName, commandConfig) {
80
+ const parameterEntries = Object.entries(commandConfig.parameters);
81
+
82
+ const usageTokens = parameterEntries.map(([paramName, definition]) => {
83
+ const token = `-${definition.flag.replace(/^-+/, "")} <${paramName}>`;
84
+ return definition.required ? token : `[${token}]`;
85
+ });
86
+
87
+ const optionLines = [];
88
+ for (const [parameterName, definition] of parameterEntries) {
89
+ const normalizedFlag = definition.flag.replace(/^-+/, "");
90
+ optionLines.push(` -${normalizedFlag} ${parameterName} (${formatRequired(definition.required)})`);
91
+ optionLines.push(` ${formatParameterType(definition)}`);
92
+ }
93
+
94
+ return [
95
+ "Usage:",
96
+ ` launchr ${commandName}${usageTokens.length > 0 ? ` ${usageTokens.join(" ")}` : ""}`,
97
+ "",
98
+ "Options:",
99
+ ...(optionLines.length > 0 ? optionLines : [" (No parameters defined)"]),
100
+ ].join("\n");
101
+ }
@@ -0,0 +1,198 @@
1
+ import { PARAMETER_TYPES } from "../constants.mjs";
2
+ import { ValidationError } from "../utils/errors.mjs";
3
+
4
+ function normalizeFlag(rawFlag) {
5
+ const flag = rawFlag.replace(/^-+/, "").trim();
6
+ if (!/^[a-zA-Z]$/.test(flag)) {
7
+ throw new ValidationError('Flag must be a single alphabetic character (example: "q" for "-q").');
8
+ }
9
+ return flag;
10
+ }
11
+
12
+ function parseBooleanStrict(rawValue, fieldName) {
13
+ const normalized = rawValue.trim().toLowerCase();
14
+ if (["true", "yes", "y", "1"].includes(normalized)) {
15
+ return true;
16
+ }
17
+ if (["false", "no", "n", "0"].includes(normalized)) {
18
+ return false;
19
+ }
20
+ throw new ValidationError(`${fieldName} must be true/false.`);
21
+ }
22
+
23
+ function parseIntegerStrict(rawValue, fieldName) {
24
+ if (!/^-?\d+$/.test(rawValue.trim())) {
25
+ throw new ValidationError(`${fieldName} must be an integer.`);
26
+ }
27
+ return Number.parseInt(rawValue.trim(), 10);
28
+ }
29
+
30
+ async function askNonEmpty(prompter, question) {
31
+ while (true) {
32
+ const answer = await prompter.ask(question);
33
+ if (answer.length > 0) {
34
+ return answer;
35
+ }
36
+ }
37
+ }
38
+
39
+ async function askParameterType(prompter) {
40
+ while (true) {
41
+ const answer = (await askNonEmpty(prompter, "What type? (string/integer/boolean/single-choice-list): ")).toLowerCase();
42
+ if (PARAMETER_TYPES.includes(answer)) {
43
+ return answer;
44
+ }
45
+ prompter.write(`Invalid type. Allowed values: ${PARAMETER_TYPES.join(", ")}\n`);
46
+ }
47
+ }
48
+
49
+ async function askRequired(prompter) {
50
+ while (true) {
51
+ const answer = (await askNonEmpty(prompter, "Is it required? (true/false): ")).toLowerCase();
52
+ if (answer === "true" || answer === "false") {
53
+ return answer === "true";
54
+ }
55
+ prompter.write("Please answer true or false.\n");
56
+ }
57
+ }
58
+
59
+ function parseDefaultValue(rawDefault, type, values) {
60
+ const trimmed = rawDefault.trim();
61
+ if (trimmed === "") {
62
+ return null;
63
+ }
64
+
65
+ switch (type) {
66
+ case "string":
67
+ return trimmed;
68
+ case "integer":
69
+ return parseIntegerStrict(trimmed, "Default value");
70
+ case "boolean":
71
+ return parseBooleanStrict(trimmed, "Default value");
72
+ case "single-choice-list":
73
+ if (!values.includes(trimmed)) {
74
+ throw new ValidationError(`Default value must be one of: ${values.join(", ")}`);
75
+ }
76
+ return trimmed;
77
+ default:
78
+ return trimmed;
79
+ }
80
+ }
81
+
82
+ async function collectParameterDefinition(prompter, parameterKeys, flagsInUse) {
83
+ const parameterKey = await askNonEmpty(prompter, "What parameter key do you want? (type done to finish): ");
84
+ if (parameterKey.toLowerCase() === "done") {
85
+ return null;
86
+ }
87
+ if (parameterKeys.has(parameterKey)) {
88
+ throw new ValidationError(`Parameter "${parameterKey}" already exists for this command.`);
89
+ }
90
+
91
+ const type = await askParameterType(prompter);
92
+ const rawFlag = await askNonEmpty(prompter, "What flag should be used? (example: q): ");
93
+ const flag = normalizeFlag(rawFlag);
94
+ if (flagsInUse.has(flag)) {
95
+ throw new ValidationError(`Flag "-${flag}" is already in use for this command.`);
96
+ }
97
+
98
+ const required = await askRequired(prompter);
99
+ const rawDefault = await prompter.ask("Default value? (leave empty for none): ");
100
+
101
+ let values = undefined;
102
+ if (type === "single-choice-list") {
103
+ const rawValues = await askNonEmpty(prompter, "Enter allowed values (comma separated): ");
104
+ values = rawValues
105
+ .split(",")
106
+ .map((value) => value.trim())
107
+ .filter(Boolean);
108
+
109
+ if (values.length === 0) {
110
+ throw new ValidationError("Single-choice-list requires at least one allowed value.");
111
+ }
112
+ }
113
+
114
+ const defaultValue = parseDefaultValue(rawDefault, type, values ?? []);
115
+ return {
116
+ parameterKey,
117
+ parameterDefinition: {
118
+ type,
119
+ flag,
120
+ required,
121
+ defaultValue,
122
+ ...(values ? { values } : {}),
123
+ },
124
+ };
125
+ }
126
+
127
+ async function askAddAnotherCommand(prompter) {
128
+ while (true) {
129
+ const answer = (await askNonEmpty(prompter, "Do you want to add another command? (yes/no): ")).toLowerCase();
130
+ if (answer === "yes" || answer === "y") {
131
+ return true;
132
+ }
133
+ if (answer === "no" || answer === "n") {
134
+ return false;
135
+ }
136
+ prompter.write("Please answer yes or no.\n");
137
+ }
138
+ }
139
+
140
+ export async function runInitFlow({ config, prompter, saveConfig }) {
141
+ const updatedConfig = { ...config };
142
+
143
+ while (true) {
144
+ const commandName = await askNonEmpty(
145
+ prompter,
146
+ "What command do you want to create? (example: grafana, google, or finish): ",
147
+ );
148
+
149
+ if (commandName.toLowerCase() === "finish") {
150
+ break;
151
+ }
152
+
153
+ if (updatedConfig[commandName]) {
154
+ prompter.write(`Command "${commandName}" already exists.\n`);
155
+ continue;
156
+ }
157
+
158
+ const description = await askNonEmpty(prompter, "What description do you want to add? ");
159
+ const url = await askNonEmpty(
160
+ prompter,
161
+ "What URL template do you want to use? (example: https://grafana.com/{environments}/{query}/{timeframe}) ",
162
+ );
163
+
164
+ const parameterKeys = new Set();
165
+ const flagsInUse = new Set();
166
+ const parameters = {};
167
+
168
+ while (true) {
169
+ try {
170
+ const parameter = await collectParameterDefinition(prompter, parameterKeys, flagsInUse);
171
+ if (parameter === null) {
172
+ break;
173
+ }
174
+
175
+ parameterKeys.add(parameter.parameterKey);
176
+ flagsInUse.add(parameter.parameterDefinition.flag);
177
+ parameters[parameter.parameterKey] = parameter.parameterDefinition;
178
+ } catch (error) {
179
+ prompter.write(`${error.message}\n`);
180
+ }
181
+ }
182
+
183
+ updatedConfig[commandName] = {
184
+ description,
185
+ url,
186
+ parameters,
187
+ };
188
+
189
+ const addAnother = await askAddAnotherCommand(prompter);
190
+ if (!addAnother) {
191
+ break;
192
+ }
193
+ }
194
+
195
+ await saveConfig(updatedConfig);
196
+ prompter.write("Configuration updated.\n");
197
+ return updatedConfig;
198
+ }
@@ -0,0 +1,10 @@
1
+ export function buildCommandList(config = {}) {
2
+ const entries = Object.entries(config);
3
+ if (entries.length === 0) {
4
+ return "No commands configured yet. Run `launchr init` to add commands.";
5
+ }
6
+
7
+ return entries
8
+ .map(([name, definition]) => `${name} - ${definition.description}`)
9
+ .join("\n");
10
+ }