@bahulam/code 0.1.12 → 0.1.14

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,162 @@
1
+ /**
2
+ * Pi runtime shim — the synthetic `pi` module a composed pi package
3
+ * imports at load time.
4
+ *
5
+ * Pi's extension API is imperative: extensions do `import { pi } from
6
+ * 'pi'` and call `pi.registerTool(name, schema, handler)` /
7
+ * `pi.registerCommand(cmd, handler)` / `pi.ctx.ui.setWidget(...)`. We
8
+ * intercept the module resolution via a Node ESM loader hook
9
+ * (`loader-hook.mjs`) that returns a virtual module which imports THIS
10
+ * shim and instantiates it against a shared capture object.
11
+ *
12
+ * v1 scope:
13
+ * - registerTool: captured, exposed to our loop as a pluginToolMap entry
14
+ * - registerCommand: captured but not surfaced (no REPL command bridge)
15
+ * - pi.events.on/emit: no-op (cross-extension event bus, deferred)
16
+ * - pi.ctx.ui.setWidget/custom: no-op with debug warning (TUI widgets
17
+ * don't translate to our workspace canvas; author dedicated panels)
18
+ * - pi.ctx.log: forwards to stderr with plugin prefix
19
+ */
20
+
21
+ export function createPiShim({ pluginName = 'pi', captured }) {
22
+ if (!captured || typeof captured !== 'object') {
23
+ throw new Error('createPiShim: captured object is required');
24
+ }
25
+ captured.tools ||= [];
26
+ captured.commands ||= [];
27
+
28
+ const pi = {
29
+ // Pi's canonical shape is registerTool({name, description, parameters,
30
+ // execute}) — a single descriptor with an `execute` function. Older
31
+ // examples use registerTool(name, schema, handler) with positional args.
32
+ // Accept both.
33
+ registerTool(arg1, arg2, arg3) {
34
+ if (arg1 && typeof arg1 === 'object' && !Array.isArray(arg1)) {
35
+ const desc = arg1;
36
+ const name = desc.name;
37
+ if (!name || typeof name !== 'string') return;
38
+ // Descriptor form: pi's canonical `{name, description, parameters,
39
+ // execute}`. Handler is called as execute(id, params).
40
+ captured.tools.push({
41
+ name,
42
+ description: desc.description || '',
43
+ schema: desc.parameters || desc.input_schema || desc.schema || { type: 'object', properties: {} },
44
+ handler: typeof desc.execute === 'function' ? desc.execute
45
+ : typeof desc.handler === 'function' ? desc.handler
46
+ : typeof desc.call === 'function' ? desc.call
47
+ : null,
48
+ _form: 'descriptor',
49
+ });
50
+ return;
51
+ }
52
+ // Positional legacy form: (name, schema, handler). Handler is
53
+ // called as handler(args).
54
+ if (!arg1 || typeof arg1 !== 'string') return;
55
+ let name = arg1, schema = arg2, handler = arg3;
56
+ if (typeof handler !== 'function' && typeof schema === 'function') {
57
+ handler = schema;
58
+ schema = { type: 'object', properties: {} };
59
+ }
60
+ captured.tools.push({
61
+ name,
62
+ description: '',
63
+ schema: schema || { type: 'object', properties: {} },
64
+ handler,
65
+ _form: 'positional',
66
+ });
67
+ },
68
+
69
+ // registerCommand(cmd, descriptor) in real pi; descriptor has
70
+ // {description, execute}. Older form: registerCommand(cmd, handler).
71
+ registerCommand(cmd, arg2) {
72
+ if (!cmd || typeof cmd !== 'string') return;
73
+ if (arg2 && typeof arg2 === 'object' && !Array.isArray(arg2)) {
74
+ const desc = arg2;
75
+ captured.commands.push({
76
+ cmd,
77
+ description: desc.description || '',
78
+ handler: typeof desc.execute === 'function' ? desc.execute
79
+ : typeof desc.handler === 'function' ? desc.handler
80
+ : null,
81
+ });
82
+ } else if (typeof arg2 === 'function') {
83
+ captured.commands.push({ cmd, handler: arg2 });
84
+ }
85
+ },
86
+
87
+ events: {
88
+ on() { /* no-op in v1 */ },
89
+ emit() { /* no-op in v1 */ },
90
+ off() { /* no-op in v1 */ },
91
+ },
92
+
93
+ // Pi packages call pi.on(...) directly for lifecycle events (session
94
+ // start/end etc.) — no-op them so activation reaches registerTool.
95
+ // Same treatment for pi.off, pi.emit, pi.once.
96
+ on() { /* no-op */ },
97
+ off() { /* no-op */ },
98
+ emit() { /* no-op */ },
99
+ once() { /* no-op */ },
100
+
101
+ // Additional pi surfaces called at activation-time by real packages
102
+ // (pi-web-access, etc.). Stub them so activation completes and tools
103
+ // register; runtime callers that rely on these still throw at call
104
+ // time, which is the correct signal that a feature isn't supported.
105
+ registerShortcut() { /* no-op */ },
106
+ appendEntry() { /* no-op */ },
107
+ sendMessage() { /* no-op */ },
108
+ exec() {
109
+ throw new Error(`[pi:${pluginName}] pi.exec is not supported in Bahulam compat`);
110
+ },
111
+ // pi.fetch is pi's authenticated fetch. Delegate to global fetch —
112
+ // that's what the extension expects: an HTTP client. Auth headers
113
+ // are typically added by the extension itself using env credentials.
114
+ fetch(...args) { return globalThis.fetch(...args); },
115
+
116
+ ctx: {
117
+ ui: {
118
+ setWidget(widget) {
119
+ if (process.env.DEBUG) {
120
+ const title = widget?.title || widget?.name || 'untitled';
121
+ process.stderr.write(`[pi:${pluginName}] widget ignored: ${title}\n`);
122
+ }
123
+ },
124
+ custom() { /* no-op */ },
125
+ clear() { /* no-op */ },
126
+ },
127
+ log(...args) {
128
+ process.stderr.write(`[pi:${pluginName}] ${args.map(String).join(' ')}\n`);
129
+ },
130
+ },
131
+ };
132
+
133
+ // Pi's ExtensionAPI is a moving target — packages call methods we haven't
134
+ // stubbed yet (registerMessageRenderer, registerRoute, registerHandler,
135
+ // …). Any unstubbed method call throws, aborting activation before
136
+ // registerTool ever runs, and the probe reports 0 tools.
137
+ //
138
+ // Fall back to a no-op returner for every unknown property so activation
139
+ // reaches its full extent. A tool's runtime call may still fail if it
140
+ // needed that surface — that's an accurate signal at execution time,
141
+ // not a silent black hole at load time.
142
+ return new Proxy(pi, {
143
+ get(target, prop, receiver) {
144
+ if (prop in target) return Reflect.get(target, prop, receiver);
145
+ if (typeof prop === 'symbol') return undefined;
146
+ if (process.env.DEBUG) {
147
+ process.stderr.write(`[pi:${pluginName}] shim: pi.${String(prop)} stubbed (no-op)\n`);
148
+ }
149
+ // Return a callable that also has method access (e.g. pi.foo.bar).
150
+ // Property access on the stub returns another stub, so chains never
151
+ // throw. Result is undefined so anything that reads a return value
152
+ // treats it as "not present" (typeof result === 'undefined').
153
+ const stub = function stub() { return undefined; };
154
+ return new Proxy(stub, {
155
+ get(t, p) {
156
+ if (typeof p === 'symbol') return t[p];
157
+ return stub;
158
+ },
159
+ });
160
+ },
161
+ });
162
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Pi composition helpers.
3
+ *
4
+ * This is the contract layer for PRD-102 §13.6.1b. It deliberately does
5
+ * not install or execute pi packages yet; it gives manifest/preflight/
6
+ * registry code one normalized shape to test against.
7
+ */
8
+
9
+ export const PI_TOOLS_CACHE = '.bahulam-tools.json';
10
+
11
+ const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
12
+ const NAMESPACE_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
13
+ const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i;
14
+
15
+ export function parsePiSource(source) {
16
+ const raw = String(source || '').trim();
17
+ if (!raw.startsWith('pi:')) return null;
18
+ const spec = raw.slice(3).trim();
19
+ if (!spec) return null;
20
+
21
+ let packageName = spec;
22
+ let versionRange = '';
23
+ if (spec.startsWith('@')) {
24
+ const slash = spec.indexOf('/');
25
+ const versionAt = slash >= 0 ? spec.indexOf('@', slash + 1) : -1;
26
+ if (versionAt > 0) {
27
+ packageName = spec.slice(0, versionAt);
28
+ versionRange = spec.slice(versionAt + 1);
29
+ }
30
+ } else {
31
+ const versionAt = spec.lastIndexOf('@');
32
+ if (versionAt > 0) {
33
+ packageName = spec.slice(0, versionAt);
34
+ versionRange = spec.slice(versionAt + 1);
35
+ }
36
+ }
37
+
38
+ if (!NPM_NAME_RE.test(packageName)) return null;
39
+ return {
40
+ kind: 'pi',
41
+ source: raw,
42
+ spec,
43
+ package_name: packageName,
44
+ packageName,
45
+ version_range: versionRange || null,
46
+ versionRange: versionRange || null,
47
+ };
48
+ }
49
+
50
+ export function normalizeCompose(composeDef, index = 0) {
51
+ const source = String(composeDef?.source || '').trim();
52
+ const parsed = parsePiSource(source);
53
+ const expose = Array.isArray(composeDef?.expose)
54
+ ? composeDef.expose.map(item => String(item || '').trim()).filter(Boolean)
55
+ : [];
56
+ const namespace = String(composeDef?.as || '').trim();
57
+
58
+ return {
59
+ source,
60
+ as: namespace || '',
61
+ expose,
62
+ verified: composeDef?.verified === true,
63
+ package_name: parsed?.package_name || '',
64
+ packageName: parsed?.packageName || '',
65
+ version_range: parsed?.version_range || null,
66
+ versionRange: parsed?.versionRange || null,
67
+ _index: index,
68
+ _kind: 'pi',
69
+ };
70
+ }
71
+
72
+ export function normalizeComposes(value) {
73
+ if (!Array.isArray(value)) return [];
74
+ return value
75
+ .map((item, index) => normalizeCompose(item, index))
76
+ .filter(item => item.source || item.expose.length || item.as);
77
+ }
78
+
79
+ // Anthropic's tool-name regex (`^[a-zA-Z0-9_-]{1,64}$`) forbids dots, and
80
+ // the backend's client-tool sanitizer enforces the same shape. `__` is the
81
+ // convention Claude Code and MCP both use for namespaced tool names, so
82
+ // stay compatible: `namespace__tool`.
83
+ export const COMPOSED_TOOL_SEPARATOR = '__';
84
+
85
+ export function composedToolName(compose, exposedName) {
86
+ const name = String(exposedName || '').trim();
87
+ return compose?.as ? `${compose.as}${COMPOSED_TOOL_SEPARATOR}${name}` : name;
88
+ }
89
+
90
+ export function validateCompose(compose) {
91
+ const errors = [];
92
+ const warnings = [];
93
+ const label = `Compose #${Number.isInteger(compose?._index) ? compose._index : '?'}`;
94
+
95
+ if (!parsePiSource(compose?.source)) {
96
+ errors.push(`${label}: source must be a pi npm spec, for example pi:@scope/package@^1.0.0`);
97
+ }
98
+ if (compose?.as && !NAMESPACE_RE.test(compose.as)) {
99
+ errors.push(`${label}: as "${compose.as}" must match ${NAMESPACE_RE}`);
100
+ }
101
+ if (!Array.isArray(compose?.expose) || compose.expose.length === 0) {
102
+ errors.push(`${label}: expose must list at least one pi tool`);
103
+ } else {
104
+ const seen = new Set();
105
+ for (const exposed of compose.expose) {
106
+ if (!TOOL_NAME_RE.test(exposed)) {
107
+ errors.push(`${label}: expose "${exposed}" must match ${TOOL_NAME_RE}`);
108
+ }
109
+ if (seen.has(exposed)) {
110
+ errors.push(`${label}: duplicate exposed tool "${exposed}"`);
111
+ }
112
+ seen.add(exposed);
113
+ }
114
+ }
115
+ if (compose?.verified !== true) {
116
+ warnings.push(`${label}: ${compose?.source || 'pi package'} is unverified; hosted Studios require verified pi packages`);
117
+ }
118
+
119
+ return { errors, warnings };
120
+ }
121
+
122
+ export function expandComposedTools(pluginName, pluginDir, composes = []) {
123
+ const tools = [];
124
+ for (const compose of composes || []) {
125
+ for (const exposedName of compose.expose || []) {
126
+ tools.push({
127
+ name: composedToolName(compose, exposedName),
128
+ description: `Composed pi tool ${exposedName} from ${compose.source}`,
129
+ input_schema: { type: 'object', properties: {} },
130
+ tool: '',
131
+ plugin_name: pluginName,
132
+ _plugin_name: pluginName,
133
+ _plugin_dir: pluginDir,
134
+ _composed: {
135
+ kind: 'pi',
136
+ source: compose.source,
137
+ package_name: compose.package_name,
138
+ version_range: compose.version_range,
139
+ namespace: compose.as || null,
140
+ original_name: exposedName,
141
+ verified: compose.verified === true,
142
+ },
143
+ });
144
+ }
145
+ }
146
+ return tools;
147
+ }
@@ -22,6 +22,7 @@ import * as os from 'node:os';
22
22
  import * as path from 'node:path';
23
23
  import { pathToFileURL } from 'node:url';
24
24
  import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
25
+ import { composedToolName, validateCompose } from './pi-compose.mjs';
25
26
 
26
27
  const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
27
28
  const AGENT_SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
@@ -38,6 +39,8 @@ export const RESERVED_TOOL_NAMES = new Set([
38
39
  // write
39
40
  'write_file', 'write_project', 'edit_file', 'delete_file', 'shell',
40
41
  'analyze_image', 'generate_image',
42
+ // background jobs (PRD-102 §6.2.3) — long-running renders, builds, etc.
43
+ 'job_output', 'job_kill', 'job_status', 'job_list',
41
44
  // agent/skill/workflow admin
42
45
  'ask_user', 'agent_create', 'agent_sync', 'agents_list',
43
46
  'skill_install', 'skill_update', 'skill_remove', 'skill_view', 'skills_list',
@@ -86,6 +89,7 @@ export async function preflightPlugin(pluginDir, opts = {}) {
86
89
  const views = manifest.spec?.workspace?.views || [];
87
90
  const mcpServers = manifest.spec?.mcpServers || {};
88
91
  const mcpServerNames = new Set(Object.keys(mcpServers));
92
+ const composes = manifest.spec?.composes || [];
89
93
 
90
94
  // MCP server sanity — every server should have EITHER command (stdio)
91
95
  // OR url (remote). Anything else is meaningless config.
@@ -101,6 +105,26 @@ export async function preflightPlugin(pluginDir, opts = {}) {
101
105
  }
102
106
  }
103
107
 
108
+ // Pi composition sanity. Composed tools become part of the agent-visible
109
+ // tool namespace, but they are not local files and are not imported here.
110
+ const composedToolNames = new Set();
111
+ for (const compose of composes) {
112
+ const validated = validateCompose(compose);
113
+ errors.push(...validated.errors);
114
+ warnings.push(...validated.warnings);
115
+ if (compose.as && mcpServerNames.has(compose.as)) {
116
+ errors.push(`Compose #${compose._index}: namespace "${compose.as}" collides with an MCP server name`);
117
+ }
118
+ for (const exposedName of compose.expose || []) {
119
+ const fullName = composedToolName(compose, exposedName);
120
+ if (composedToolNames.has(fullName)) errors.push(`Composed tool "${fullName}": duplicate name`);
121
+ if (RESERVED_TOOL_NAMES.has(fullName)) {
122
+ errors.push(`Composed tool "${fullName}": shadows a built-in tool`);
123
+ }
124
+ composedToolNames.add(fullName);
125
+ }
126
+ }
127
+
104
128
  // 2 + 3 + 4. Tool checks
105
129
  const toolNames = new Set();
106
130
  for (const [i, tool] of tools.entries()) {
@@ -114,6 +138,9 @@ export async function preflightPlugin(pluginDir, opts = {}) {
114
138
  if (RESERVED_TOOL_NAMES.has(tool.name)) {
115
139
  errors.push(`Tool "${t}": shadows a built-in tool — pick a different name (built-ins always win)`);
116
140
  }
141
+ if (composedToolNames.has(tool.name)) {
142
+ errors.push(`Tool "${t}": collides with a composed pi tool`);
143
+ }
117
144
  if (!tool.description || tool.description.length < 8) {
118
145
  warnings.push(`Tool "${t}": description is missing or very short (<8 chars) — the model uses this to decide when to call it`);
119
146
  }
@@ -166,12 +193,12 @@ export async function preflightPlugin(pluginDir, opts = {}) {
166
193
  // plugin's mcpServers. The <tool> half is discovered live.
167
194
  if (toolRef.includes('.')) {
168
195
  const serverName = toolRef.split('.', 1)[0];
169
- if (!mcpServerNames.has(serverName)) {
196
+ if (!mcpServerNames.has(serverName) && !composedToolNames.has(toolRef)) {
170
197
  errors.push(`Agent "${slug}": tool "${toolRef}" references MCP server "${serverName}" which is not declared in mcpServers`);
171
198
  }
172
199
  continue;
173
200
  }
174
- if (!toolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
201
+ if (!toolNames.has(toolRef) && !composedToolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
175
202
  errors.push(`Agent "${slug}": tool "${toolRef}" is not defined by this plugin and is not a built-in`);
176
203
  }
177
204
  }
@@ -9,6 +9,7 @@ import fs from 'fs';
9
9
  import path from 'path';
10
10
  import os from 'os';
11
11
  import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
12
+ import { expandComposedTools } from './pi-compose.mjs';
12
13
 
13
14
  const DEFAULT_PLUGIN_DIRS = () => [
14
15
  path.join(process.cwd(), '.bahulam', 'plugins'),
@@ -159,6 +160,11 @@ export class PluginRegistry {
159
160
  _plugin_dir: plugin._dir,
160
161
  });
161
162
  }
163
+ tools.push(...expandComposedTools(
164
+ plugin.metadata?.name || '',
165
+ plugin._dir,
166
+ plugin.spec?.composes || [],
167
+ ));
162
168
  }
163
169
  return tools;
164
170
  }
@@ -24,9 +24,10 @@ const subcommand = process.argv[2];
24
24
  const subcommandArgs = process.argv.slice(3);
25
25
 
26
26
  const PLUGIN_MANAGEMENT_COMMANDS = new Set([
27
- 'install', 'validate', 'check', 'lint',
27
+ 'validate', 'check', 'lint',
28
28
  'list', 'ls', 'remove', 'rm', 'uninstall',
29
29
  'enable', 'disable', 'info', 'update', 'upgrade',
30
+ 'doctor',
30
31
  ]);
31
32
 
32
33
  function parsePluginArgs(argv) {
@@ -63,14 +64,13 @@ function parsePluginArgs(argv) {
63
64
  }
64
65
  if (positional.length && PLUGIN_MANAGEMENT_COMMANDS.has(positional[0].toLowerCase())) {
65
66
  parsed.action = positional.shift().toLowerCase();
66
- if (parsed.action === 'install') parsed.source = positional.shift() || null;
67
- else if (['validate', 'check', 'lint'].includes(parsed.action)) {
67
+ if (['validate', 'check', 'lint'].includes(parsed.action)) {
68
68
  // Accepts either a directory path or an installed plugin name.
69
69
  const arg = positional.shift() || null;
70
70
  if (arg && (arg.includes('/') || fs.existsSync?.(arg))) parsed.source = arg;
71
71
  else parsed.pluginName = arg;
72
72
  }
73
- else if (['info', 'remove', 'rm', 'uninstall', 'enable', 'disable', 'update', 'upgrade'].includes(parsed.action)) {
73
+ else if (['info', 'remove', 'rm', 'uninstall', 'enable', 'disable', 'update', 'upgrade', 'doctor'].includes(parsed.action)) {
74
74
  parsed.pluginName = positional.shift() || null;
75
75
  }
76
76
  } else {
@@ -279,7 +279,28 @@ async function main() {
279
279
  return;
280
280
  }
281
281
 
282
+ if (subcommand === 'pull') {
283
+ const { handlePullCommand } = await import('../commands/install.mjs');
284
+ await handlePullCommand(subcommandArgs, { cwd: process.cwd() });
285
+ return;
286
+ }
287
+
288
+ if (subcommand === 'install') {
289
+ const { handleInstallCommand } = await import('../commands/install.mjs');
290
+ await handleInstallCommand(subcommandArgs, { cwd: process.cwd() });
291
+ return;
292
+ }
293
+
282
294
  if (subcommand === 'plugin' || subcommand === 'plugins') {
295
+ // `install`/`pull` moved to top-level. Detect the old form and redirect.
296
+ if (subcommandArgs[0] === 'install' || subcommandArgs[0] === 'pull') {
297
+ const verb = subcommandArgs[0];
298
+ const rest = subcommandArgs.slice(1);
299
+ process.stderr.write(`\x1b[33m!\x1b[0m \`bahulam plugin ${verb}\` moved to top-level. Use:\n`);
300
+ process.stderr.write(` \x1b[36mbahulam install ${rest.join(' ')}\x1b[0m (pack — scaffolds around pi:, installs git/tarball/local)\n`);
301
+ process.stderr.write(` \x1b[36mbahulam pull ${rest.join(' ')}\x1b[0m (ingredient only — pi: sources)\n`);
302
+ process.exit(2);
303
+ }
283
304
  const args = parsePluginArgs(subcommandArgs);
284
305
  if (args.action && args.action !== 'open') {
285
306
  const { handlePluginManagementCommand } = await import('../commands/plugin-manage.mjs');
@@ -332,8 +353,14 @@ async function main() {
332
353
  bahulam workspace list List recent local workspace sessions
333
354
  bahulam local open [path] Alias for workspace open
334
355
 
335
- \x1b[1mPlugins:\x1b[0m
336
- bahulam plugin <name> [path] Open a workspace with a named plugin
356
+ \x1b[1mPacks & ingredients:\x1b[0m
357
+ bahulam pull pi:<name> Pull a pi ingredient (composable, not runnable on its own)
358
+ bahulam install pi:<name> Pull ingredient + scaffold a full Bahulam pack around it
359
+ bahulam install <git-url> Install a hand-authored pack from git
360
+ bahulam install <local-path> Install a hand-authored pack from disk
361
+ bahulam plugin list List installed packs and pi ingredients
362
+ bahulam plugin remove <name> Remove an installed pack
363
+ bahulam plugin <name> [path] Open a workspace with an installed pack
337
364
 
338
365
  \x1b[1mAnalytics:\x1b[0m
339
366
  bahulam sessions List recent local sessions
@@ -0,0 +1,23 @@
1
+ export function isRawMultilinePasteChunk(text) {
2
+ const value = String(text || '');
3
+ if (!value) return false;
4
+ if (!/[\r\n]/.test(value)) return false;
5
+
6
+ const withoutLineBreaks = value.replace(/[\r\n]/g, '');
7
+ if (!withoutLineBreaks.length) return false;
8
+
9
+ // A single printable char followed by Enter can be delivered in one chunk
10
+ // by some terminals; keep that as normal line submission.
11
+ return withoutLineBreaks.length > 1 || value.split(/\r?\n|\r/).length > 2;
12
+ }
13
+
14
+ export function normalizePastedText(text) {
15
+ return String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
16
+ }
17
+
18
+ export function pastedTextLabel(text) {
19
+ const value = normalizePastedText(text);
20
+ const lines = value ? value.split('\n').length : 0;
21
+ if (lines > 1) return `[text copied · ${lines} lines]`;
22
+ return '[text copied]';
23
+ }