@claude-flow/cli 3.54.0 → 3.55.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.
- package/.claude/helpers/helpers.manifest.json +3 -3
- package/.claude/helpers/hook-handler.cjs +65 -3
- package/bin/cli.js +1 -1
- package/bin/mcp-server.js +1 -1
- package/catalog-manifest.json +2 -2
- package/dist/src/blocking-stdio.d.ts +11 -0
- package/dist/src/blocking-stdio.js +23 -0
- package/dist/src/commands/autopilot.js +2 -2
- package/dist/src/commands/daemon.js +21 -1
- package/dist/src/commands/hive-mind.js +7 -0
- package/dist/src/commands/mcp.js +35 -1
- package/dist/src/commands/policy.js +10 -2
- package/dist/src/commands/session.js +11 -37
- package/dist/src/index.js +3 -0
- package/dist/src/init/executor.js +1 -0
- package/dist/src/init/helpers-generator.js +65 -3
- package/dist/src/mcp-server.d.ts +33 -0
- package/dist/src/mcp-server.js +96 -4
- package/dist/src/mcp-tools/agentdb-tools.js +13 -1
- package/dist/src/mcp-tools/hive-mind-tools.d.ts +2 -4
- package/dist/src/mcp-tools/hive-mind-tools.js +214 -40
- package/dist/src/mcp-tools/hooks-tools.js +1 -1
- package/dist/src/mcp-tools/session-tools.js +4 -1
- package/dist/src/memory/graph-edge-writer.js +3 -2
- package/dist/src/memory/lucene-bm25.js +11 -19
- package/dist/src/memory/memory-bridge.d.ts +1 -0
- package/dist/src/memory/memory-bridge.js +14 -1
- package/dist/src/memory/memory-initializer.js +5 -4
- package/dist/src/memory/shared-sqlite.d.ts +7 -0
- package/dist/src/memory/shared-sqlite.js +45 -0
- package/dist/src/mods/probe.d.ts +7 -0
- package/dist/src/mods/probe.js +29 -1
- package/dist/src/parser.d.ts +3 -0
- package/dist/src/parser.js +64 -13
- package/dist/src/production/monitoring.d.ts +2 -0
- package/dist/src/production/monitoring.js +8 -3
- package/dist/src/production/rate-limiter.js +8 -5
- package/dist/src/production/retry.js +3 -1
- package/dist/src/services/bounded-worker-pool.js +3 -0
- package/dist/src/services/config-file-manager.d.ts +3 -0
- package/dist/src/services/config-file-manager.js +24 -7
- package/dist/src/services/git-workspace-identity.js +20 -3
- package/dist/src/services/policy-ledger-anchor.d.ts +42 -0
- package/dist/src/services/policy-ledger-anchor.js +163 -0
- package/dist/src/services/policy-runtime.d.ts +11 -5
- package/dist/src/services/policy-runtime.js +70 -15
- package/dist/src/services/worker-daemon.d.ts +1 -0
- package/dist/src/services/worker-daemon.js +12 -0
- package/dist/src/services/worker-queue.d.ts +1 -0
- package/dist/src/services/worker-queue.js +20 -3
- package/node_modules/@claude-flow/mcp/dist/connection-pool.d.ts.map +1 -1
- package/node_modules/@claude-flow/mcp/dist/connection-pool.js +5 -2
- package/node_modules/@claude-flow/mcp/dist/connection-pool.js.map +1 -1
- package/node_modules/@claude-flow/mcp/dist/transport/http.d.ts +7 -1
- package/node_modules/@claude-flow/mcp/dist/transport/http.d.ts.map +1 -1
- package/node_modules/@claude-flow/mcp/dist/transport/http.js +60 -47
- package/node_modules/@claude-flow/mcp/dist/transport/http.js.map +1 -1
- package/node_modules/@claude-flow/mcp/package.json +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/application/federation-coordinator.js +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/application/federation-coordinator.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.d.ts +3 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.d.ts.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.js +4 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.d.ts.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.js +1 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/package.json +1 -1
- package/node_modules/@claude-flow/security/dist/policy/engine.d.ts +11 -1
- package/node_modules/@claude-flow/security/dist/policy/engine.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/dist/policy/engine.js +19 -3
- package/node_modules/@claude-flow/security/dist/policy/engine.js.map +1 -1
- package/node_modules/@claude-flow/security/dist/policy/types.d.ts +4 -2
- package/node_modules/@claude-flow/security/dist/policy/types.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/package.json +1 -1
- package/package.json +7 -7
- package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +1 -1
- package/plugins/ruflo-metaharness/scripts/_invoke.mjs +80 -7
- package/plugins/ruflo-metaharness/scripts/smoke.sh +4 -4
- package/plugins/ruflo-metaharness/scripts/test-cli-resolution-3558.mjs +141 -0
- package/plugins/ruflo-metaharness/scripts/test-no-tool-time-npx.mjs +5 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest": {
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.55.0",
|
|
4
4
|
"files": {
|
|
5
5
|
"auto-memory-hook.mjs": "c8d218526a3c34034c2a69de23f507f0eb0f70d59c16426a8edc849e6ac22529",
|
|
6
|
-
"hook-handler.cjs": "
|
|
6
|
+
"hook-handler.cjs": "34d2aacfb59a73ff94b1467bb57ab9d9c4af89c0e470aadf3bd0d74a503a658d",
|
|
7
7
|
"intelligence.cjs": "ac5033a77bcea9796856629d5d319bde9ab6b7edf0dcd0d4af187ce30af90836",
|
|
8
8
|
"statusline.cjs": "4a48353b4f1566fa4379b00fd0321b6676a22b6cc91fbbd8380ac5183d619468",
|
|
9
9
|
"router.cjs": "16648b3c0d73b6681ac2d8d13def6a68286c57f5a11004de5b19a0fa088c833d"
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
|
-
"signature": "
|
|
12
|
+
"signature": "FT6oLMAU2RXu8iAdlOFTNGcjYZ52pkhrGFgn3/oKJdtVab0etCzO24Nr2ut1Hr9kn3tRAqb6D8MZwKfcoSqlCQ==",
|
|
13
13
|
"algorithm": "ed25519"
|
|
14
14
|
}
|
|
@@ -486,6 +486,7 @@ const handlers = {
|
|
|
486
486
|
// Join literal quote fragments and normalize root paths without shell evaluation.
|
|
487
487
|
function hasRootDelete(command, depth = 0) {
|
|
488
488
|
let word = '', quote = '', started = false, redirect = false
|
|
489
|
+
let ansiNul = false // a NUL inside $'...' ends that string, as in bash
|
|
489
490
|
let inRm = false, optionsEnded = false, recursive = false, force = false, root = false
|
|
490
491
|
const isRoot = (operand) => {
|
|
491
492
|
if (!operand.startsWith('/')) return false
|
|
@@ -497,22 +498,55 @@ const handlers = {
|
|
|
497
498
|
}
|
|
498
499
|
return parts.length === 0 || /[*?\[]/.test(parts[0])
|
|
499
500
|
}
|
|
501
|
+
// One backslash escape inside $'...', from the character after the backslash.
|
|
502
|
+
// Returns the text it stands for and the index of its last character. An unknown escape keeps its backslash.
|
|
503
|
+
// The command arrives lowercased, so an uppercase-U escape reads as a lowercase one: eight digits starting 0000 are one code point.
|
|
504
|
+
const ansiEscape = (s, at) => {
|
|
505
|
+
const c = s[at]
|
|
506
|
+
const simple = { a: '\x07', b: '\b', e: '\x1b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', '\\': '\\', "'": "'", '"': '"', '?': '?' }
|
|
507
|
+
if (simple[c] !== undefined) return [simple[c], at]
|
|
508
|
+
const digits = (from, max, pattern) => pattern.exec(s.slice(from, from + max))?.[0]
|
|
509
|
+
const octal = digits(at, 3, /^[0-7]+/)
|
|
510
|
+
if (octal) return [String.fromCharCode(parseInt(octal, 8) & 255), at + octal.length - 1]
|
|
511
|
+
const hex = c === 'x' ? digits(at + 1, 2, /^[0-9a-f]+/) : undefined
|
|
512
|
+
if (hex) return [String.fromCharCode(parseInt(hex, 16)), at + hex.length]
|
|
513
|
+
const wide = c === 'u' ? (digits(at + 1, 8, /^0000[0-9a-f]{4}$/) ?? digits(at + 1, 4, /^[0-9a-f]+/)) : undefined
|
|
514
|
+
if (wide) return [String.fromCodePoint(parseInt(wide, 16)), at + wide.length]
|
|
515
|
+
if (c === 'c' && at + 1 < s.length) return [String.fromCharCode(s.charCodeAt(at + 1) & 31), at + 1]
|
|
516
|
+
return ['\\' + c, at]
|
|
517
|
+
}
|
|
500
518
|
const finishWord = () => {
|
|
501
519
|
if (!started) return false
|
|
502
520
|
// Literal shell strings (e.g. sh -c 'rm -rf /') also carried the old guard.
|
|
503
521
|
// Bound rescanning to four levels; beyond that retain its conservative check.
|
|
504
|
-
if (
|
|
522
|
+
if (/[\s;&|()\x60]/.test(word)) {
|
|
505
523
|
if (depth < 4 ? hasRootDelete(word, depth + 1) : word.includes('rm -rf /')) return true
|
|
506
524
|
}
|
|
507
525
|
if (!inRm) inRm = word === 'rm' || word.endsWith('/rm')
|
|
508
526
|
else if (!optionsEnded && word === '--') optionsEnded = true
|
|
509
527
|
else if (!optionsEnded && word.startsWith('-')) {
|
|
510
|
-
|
|
511
|
-
|
|
528
|
+
// GNU getopt accepts any unambiguous long-option prefix: --r, --recur, --forc.
|
|
529
|
+
recursive = recursive || (word.length > 2 && '--recursive'.startsWith(word)) || /^-[a-z]*r[a-z]*$/.test(word)
|
|
530
|
+
force = force || (word.length > 2 && '--force'.startsWith(word)) || /^-[a-z]*f[a-z]*$/.test(word)
|
|
512
531
|
} else root = root || isRoot(word)
|
|
513
532
|
word = ''; started = false
|
|
514
533
|
return inRm && recursive && force && root
|
|
515
534
|
}
|
|
535
|
+
// A command substitution's body, from just after its opening backtick. Bash removes a
|
|
536
|
+
// backslash only before $, a backtick or a backslash (and " inside double quotes) and
|
|
537
|
+
// drops backslash-newline; any other backslash stays for the nested scan.
|
|
538
|
+
const substitution = (from, inDouble) => {
|
|
539
|
+
let body = '', j = from
|
|
540
|
+
for (; j < command.length && command[j] !== '\x60'; j++) {
|
|
541
|
+
const next = command[j + 1]
|
|
542
|
+
if (command[j] === '\\' && j + 1 < command.length) {
|
|
543
|
+
if (next === '\n') { j++; continue }
|
|
544
|
+
if (next === '$' || next === '\x60' || next === '\\' || (inDouble && next === '"')) { body += next; j++; continue }
|
|
545
|
+
}
|
|
546
|
+
body += command[j]
|
|
547
|
+
}
|
|
548
|
+
return [j, depth < 4 ? hasRootDelete(body, depth + 1) : body.includes('rm -rf /')]
|
|
549
|
+
}
|
|
516
550
|
const finishCommand = () => {
|
|
517
551
|
const denied = inRm && recursive && force && root
|
|
518
552
|
inRm = optionsEnded = recursive = force = root = false
|
|
@@ -523,7 +557,25 @@ const handlers = {
|
|
|
523
557
|
const redirectionAmpersand = char === '&' && (redirect || command[i + 1] === '>')
|
|
524
558
|
redirect = false
|
|
525
559
|
if (quote) {
|
|
560
|
+
if (quote === "$'") {
|
|
561
|
+
if (char === "'") { quote = ''; ansiNul = false }
|
|
562
|
+
else if (char === '\\' && i + 1 < command.length) {
|
|
563
|
+
const [text, last] = ansiEscape(command, i + 1)
|
|
564
|
+
i = last
|
|
565
|
+
if (!ansiNul) { const nul = text.indexOf('\0'); if (nul < 0) word += text; else { word += text.slice(0, nul); ansiNul = true } }
|
|
566
|
+
} else if (!ansiNul) word += char
|
|
567
|
+
continue
|
|
568
|
+
}
|
|
526
569
|
if (char === quote) quote = ''
|
|
570
|
+
else if (quote === '"' && char === '\x60' && command.indexOf('\x60', i + 1) > i) {
|
|
571
|
+
const [end, denied] = substitution(i + 1, true)
|
|
572
|
+
if (denied) return true
|
|
573
|
+
// The substitution is gone from the word, as bash's empty output is (a quoted empty substitution glued to -rf leaves -rf).
|
|
574
|
+
// A # right after it is still inside the word: a placeholder keeps a rescan (sh -c "...")
|
|
575
|
+
// from reading it as the start of a comment.
|
|
576
|
+
if (command[end + 1] === '#') word += '\u0001'
|
|
577
|
+
i = end
|
|
578
|
+
}
|
|
527
579
|
else if (quote === '"' && char === '\\' && i + 1 < command.length &&
|
|
528
580
|
(command[i + 1] === '"' || command[i + 1] === '\\' || command[i + 1] === '$' ||
|
|
529
581
|
command.charCodeAt(i + 1) === 96 || command[i + 1] === '\n')) {
|
|
@@ -535,6 +587,16 @@ const handlers = {
|
|
|
535
587
|
if (char === '\\' && i + 1 < command.length) {
|
|
536
588
|
const next = command[++i]
|
|
537
589
|
if (next !== '\n') { word += next; started = true }
|
|
590
|
+
} else if (char === '\x60' && command.indexOf('\x60', i + 1) > i) {
|
|
591
|
+
// Command substitution: scan the body as its own command; the substitution
|
|
592
|
+
// stays inside the enclosing word, so the rm being parsed keeps its state,
|
|
593
|
+
// and the word has started (a # right after it is not a comment).
|
|
594
|
+
const [end, denied] = substitution(i + 1, false)
|
|
595
|
+
if (denied) return true
|
|
596
|
+
i = end; started = true
|
|
597
|
+
} else if (char === '$' && (command[i + 1] === "'" || command[i + 1] === '"')) {
|
|
598
|
+
// ANSI-C ($'...': escapes decoded) and locale ($"...") quoting: the $ is not part of the word.
|
|
599
|
+
quote = command[i + 1] === "'" ? "$'" : '"'; started = true; i++
|
|
538
600
|
} else if (char === '"' || char === "'") {
|
|
539
601
|
quote = char; started = true
|
|
540
602
|
} else if (char === '#' && !started) {
|
package/bin/cli.js
CHANGED
|
@@ -301,7 +301,7 @@ if (isMCPMode) {
|
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
try {
|
|
304
|
-
const result = await callMCPTool(toolName, toolParams, { sessionId });
|
|
304
|
+
const result = await callMCPTool(toolName, toolParams, { sessionId, transport: 'stdio' });
|
|
305
305
|
// Failed task outcomes are data; an explicit handler error is a tool failure.
|
|
306
306
|
const isError = result !== null && typeof result === 'object'
|
|
307
307
|
&& typeof result.error === 'string' && result.error.trim().length > 0;
|
package/bin/mcp-server.js
CHANGED
|
@@ -190,7 +190,7 @@ async function handleMessage(message) {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
try {
|
|
193
|
-
const result = await callMCPTool(toolName, toolParams, { sessionId });
|
|
193
|
+
const result = await callMCPTool(toolName, toolParams, { sessionId, transport: 'stdio' });
|
|
194
194
|
return {
|
|
195
195
|
jsonrpc: '2.0',
|
|
196
196
|
id: message.id,
|
package/catalog-manifest.json
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3851: stdout/stderr writes to a pipe are asynchronous on POSIX, so the
|
|
3
|
+
* `process.exit()` calls that follow a command (bin wrappers, CLI.run error
|
|
4
|
+
* paths, commands that exit themselves) discard whatever the consumer has not
|
|
5
|
+
* read yet, i.e. everything past the pipe buffer (64 KiB). Switching the pipe
|
|
6
|
+
* handles to blocking mode makes every write complete before it returns, so
|
|
7
|
+
* every exit path is safe. Same technique as the MCP stdio server.
|
|
8
|
+
* Files and TTYs are already synchronous and are left alone.
|
|
9
|
+
*/
|
|
10
|
+
export declare function ensureBlockingStdio(): void;
|
|
11
|
+
//# sourceMappingURL=blocking-stdio.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3851: stdout/stderr writes to a pipe are asynchronous on POSIX, so the
|
|
3
|
+
* `process.exit()` calls that follow a command (bin wrappers, CLI.run error
|
|
4
|
+
* paths, commands that exit themselves) discard whatever the consumer has not
|
|
5
|
+
* read yet, i.e. everything past the pipe buffer (64 KiB). Switching the pipe
|
|
6
|
+
* handles to blocking mode makes every write complete before it returns, so
|
|
7
|
+
* every exit path is safe. Same technique as the MCP stdio server.
|
|
8
|
+
* Files and TTYs are already synchronous and are left alone.
|
|
9
|
+
*/
|
|
10
|
+
export function ensureBlockingStdio() {
|
|
11
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
12
|
+
const s = stream;
|
|
13
|
+
if (s.isTTY)
|
|
14
|
+
continue;
|
|
15
|
+
try {
|
|
16
|
+
s._handle?.setBlocking?.(true);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// best effort: never fail the CLI because stdio could not be switched
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=blocking-stdio.js.map
|
|
@@ -181,8 +181,8 @@ const configCommand = {
|
|
|
181
181
|
name: 'config',
|
|
182
182
|
description: 'Configure max iterations, timeout, and task sources',
|
|
183
183
|
options: [
|
|
184
|
-
{ name: 'max-iterations', type: '
|
|
185
|
-
{ name: 'timeout', type: '
|
|
184
|
+
{ name: 'max-iterations', type: 'number', description: 'Max re-engagement iterations (1-1000)' },
|
|
185
|
+
{ name: 'timeout', type: 'number', description: 'Timeout in minutes (1-1440)' },
|
|
186
186
|
{ name: 'task-sources', type: 'string', description: `Comma-separated task sources: ${[...VALID_TASK_SOURCES].join(', ')}` },
|
|
187
187
|
],
|
|
188
188
|
action: async (ctx) => {
|
|
@@ -10,6 +10,11 @@ import { fileURLToPath } from 'url';
|
|
|
10
10
|
import { dirname, join, resolve } from 'path';
|
|
11
11
|
import { homedir } from 'os';
|
|
12
12
|
import * as fs from 'fs';
|
|
13
|
+
// #1968 / #3547: shared validator for `--workers <list>`, used both when
|
|
14
|
+
// reading the flag into DaemonConfig (below) and when forwarding it to the
|
|
15
|
+
// forked background child's argv (startBackgroundDaemon). One pattern, one
|
|
16
|
+
// place, so the two paths can't drift out of sync on what they accept.
|
|
17
|
+
const WORKERS_RE = /^[a-z][a-z0-9_-]*(,[a-z][a-z0-9_-]*)*$/;
|
|
13
18
|
// Start daemon subcommand
|
|
14
19
|
const startCommand = {
|
|
15
20
|
name: 'start',
|
|
@@ -119,6 +124,22 @@ const startCommand = {
|
|
|
119
124
|
output.printWarning(`Ignoring invalid --ttl value: ${sanitize(rawTtl)}`);
|
|
120
125
|
}
|
|
121
126
|
}
|
|
127
|
+
// #3547 (#1968 follow-up): `--workers` was forwarded to the forked
|
|
128
|
+
// background child's argv (below, in startBackgroundDaemon) but never
|
|
129
|
+
// read into DaemonConfig here, so WorkerDaemon always fell back to
|
|
130
|
+
// DEFAULT_WORKERS (7 enabled) regardless of what was requested — in
|
|
131
|
+
// both foreground mode (this same action, run directly) and background
|
|
132
|
+
// mode (this same action, re-run in the forked child with --foreground
|
|
133
|
+
// and the forwarded --workers flag).
|
|
134
|
+
const rawWorkers = ctx.flags.workers;
|
|
135
|
+
if (typeof rawWorkers === 'string' && rawWorkers.length > 0) {
|
|
136
|
+
if (WORKERS_RE.test(rawWorkers)) {
|
|
137
|
+
config.enabledWorkers = rawWorkers.split(',');
|
|
138
|
+
}
|
|
139
|
+
else if (!quiet) {
|
|
140
|
+
output.printWarning(`Ignoring invalid --workers value: ${sanitize(rawWorkers)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
122
143
|
// Check if background daemon already running (skip if we ARE the daemon process)
|
|
123
144
|
//
|
|
124
145
|
// #2407 — without an atomic lockfile, N concurrent `daemon start` calls
|
|
@@ -518,7 +539,6 @@ async function startBackgroundDaemon(projectRoot, quiet, forwarded = {}) {
|
|
|
518
539
|
// through — argv goes straight to a forked process so reject anything
|
|
519
540
|
// that doesn't look like a comma-separated worker-name list or one of
|
|
520
541
|
// the allowed sandbox modes.
|
|
521
|
-
const WORKERS_RE = /^[a-z][a-z0-9_-]*(,[a-z][a-z0-9_-]*)*$/;
|
|
522
542
|
if (typeof workers === 'string' && workers.length > 0 && WORKERS_RE.test(workers)) {
|
|
523
543
|
forkArgs.push('--workers', workers);
|
|
524
544
|
}
|
|
@@ -1016,6 +1016,13 @@ const consensusCommand = {
|
|
|
1016
1016
|
const action = ctx.flags.action || 'list';
|
|
1017
1017
|
try {
|
|
1018
1018
|
const result = await callMCPTool('hive-mind_consensus', { action, proposalId: ctx.flags.proposalId, type: ctx.flags.type, value: ctx.flags.value, vote: ctx.flags.vote === 'yes', voterId: ctx.flags.voterId, hiveToken: getHiveTokenForCli() });
|
|
1019
|
+
if (result.error) {
|
|
1020
|
+
if (ctx.flags.format === 'json')
|
|
1021
|
+
output.printJson(result);
|
|
1022
|
+
else
|
|
1023
|
+
output.printError(`Consensus error: ${String(result.error)}`);
|
|
1024
|
+
return { success: false, exitCode: 1, data: result };
|
|
1025
|
+
}
|
|
1019
1026
|
if (ctx.flags.format === 'json') {
|
|
1020
1027
|
output.printJson(result);
|
|
1021
1028
|
return { success: true, data: result };
|
package/dist/src/commands/mcp.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { output } from '../output.js';
|
|
9
9
|
import { confirm } from '../prompt.js';
|
|
10
10
|
import { installParentDeathWatchdog } from '../runtime/parent-death-watchdog.js';
|
|
11
|
-
import { getServerManager, getMCPServerStatus, filterAdvertisedMcpTools, parseMcpToolSelection, } from '../mcp-server.js';
|
|
11
|
+
import { getServerManager, getMCPServerStatus, resolveMcpHttpAuthToken, isLoopbackHost, filterAdvertisedMcpTools, parseMcpToolSelection, } from '../mcp-server.js';
|
|
12
12
|
import { listMCPTools, callMCPTool, hasTool } from '../mcp-client.js';
|
|
13
13
|
// MCP tools categories
|
|
14
14
|
const TOOL_CATEGORIES = [
|
|
@@ -71,6 +71,16 @@ const startCommand = {
|
|
|
71
71
|
description: 'HTTP request timeout in milliseconds (default 30000; env RUFLO_MCP_REQUEST_TIMEOUT_MS)',
|
|
72
72
|
type: 'number'
|
|
73
73
|
},
|
|
74
|
+
{
|
|
75
|
+
name: 'auth-token-file',
|
|
76
|
+
description: 'Read the HTTP bearer token from this file (http transport). Preferred over --auth-token; env RUFLO_MCP_HTTP_TOKEN also works',
|
|
77
|
+
type: 'string'
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'auth-token',
|
|
81
|
+
description: 'HTTP bearer token (16+ printable ASCII chars). Visible in argv/ps - prefer RUFLO_MCP_HTTP_TOKEN or --auth-token-file',
|
|
82
|
+
type: 'string'
|
|
83
|
+
},
|
|
74
84
|
{
|
|
75
85
|
name: 'daemon',
|
|
76
86
|
short: 'd',
|
|
@@ -90,6 +100,8 @@ const startCommand = {
|
|
|
90
100
|
{ command: 'claude-flow mcp start', description: 'Start with defaults (stdio)' },
|
|
91
101
|
{ command: 'claude-flow mcp start -p 8080 -t http', description: 'Start HTTP server' },
|
|
92
102
|
{ command: 'claude-flow mcp start -t http --request-timeout-ms 120000', description: 'Allow slower cold-start HTTP tools' },
|
|
103
|
+
{ command: 'RUFLO_MCP_HTTP_TOKEN=<16+ chars> claude-flow mcp start -t http', description: 'HTTP server requiring "Authorization: Bearer <token>" on every request (except GET /health)' },
|
|
104
|
+
{ command: 'RUFLO_MCP_HTTP_TOKEN=<16+ chars> claude-flow mcp start -t http --host 0.0.0.0', description: 'Non-loopback bind: allowed only with a token (or RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1)' },
|
|
93
105
|
{ command: 'claude-flow mcp start -d', description: 'Start as daemon' },
|
|
94
106
|
{ command: 'claude-flow mcp start -f', description: 'Force restart (kill existing)' }
|
|
95
107
|
],
|
|
@@ -109,6 +121,17 @@ const startCommand = {
|
|
|
109
121
|
output.printError('Request timeout must be an integer from 1 to 3600000 milliseconds');
|
|
110
122
|
return { success: false, exitCode: 1 };
|
|
111
123
|
}
|
|
124
|
+
let authToken;
|
|
125
|
+
try {
|
|
126
|
+
authToken = resolveMcpHttpAuthToken({
|
|
127
|
+
token: (ctx.flags.authToken ?? ctx.flags['auth-token']),
|
|
128
|
+
tokenFile: (ctx.flags.authTokenFile ?? ctx.flags['auth-token-file']),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
output.printError(error.message);
|
|
133
|
+
return { success: false, exitCode: 1 };
|
|
134
|
+
}
|
|
112
135
|
const daemon = ctx.flags.daemon ?? false;
|
|
113
136
|
const force = ctx.flags.force ?? false;
|
|
114
137
|
output.writeln();
|
|
@@ -166,6 +189,7 @@ const startCommand = {
|
|
|
166
189
|
port,
|
|
167
190
|
tools: !tools || tools === 'all' ? 'all' : tools.split(','),
|
|
168
191
|
...(requestTimeoutMs === undefined ? {} : { requestTimeoutMs }),
|
|
192
|
+
...(authToken ? { authToken } : {}),
|
|
169
193
|
daemonize: daemon,
|
|
170
194
|
};
|
|
171
195
|
try {
|
|
@@ -218,6 +242,15 @@ const startCommand = {
|
|
|
218
242
|
if (transport === 'http') {
|
|
219
243
|
output.writeln(output.dim(` Health: http://${host}:${port}/health`));
|
|
220
244
|
output.writeln(output.dim(` RPC: http://${host}:${port}/rpc`));
|
|
245
|
+
if (authToken) {
|
|
246
|
+
output.writeln(output.dim(' Auth: bearer token required (Authorization: Bearer <token>); /health is public'));
|
|
247
|
+
}
|
|
248
|
+
else if (isLoopbackHost(host)) {
|
|
249
|
+
output.printWarning('No auth token set: any local process (and DNS-rebinding web pages) can call tools. Set RUFLO_MCP_HTTP_TOKEN to require a bearer token.');
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
output.printWarning('Running UNAUTHENTICATED on a non-loopback host (RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP is set).');
|
|
253
|
+
}
|
|
221
254
|
}
|
|
222
255
|
else if (transport === 'websocket') {
|
|
223
256
|
output.writeln(output.dim(` WebSocket: ws://${host}:${port}/ws`));
|
|
@@ -596,6 +629,7 @@ const execCommand = {
|
|
|
596
629
|
const startTime = performance.now();
|
|
597
630
|
const result = await callMCPTool(tool, params, {
|
|
598
631
|
sessionId: `cli-${Date.now().toString(36)}`,
|
|
632
|
+
transport: 'cli',
|
|
599
633
|
requestId: `exec-${Date.now()}`,
|
|
600
634
|
});
|
|
601
635
|
const duration = performance.now() - startTime;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { anchorPosture } from '../services/policy-ledger-anchor.js';
|
|
1
2
|
import { output } from '../output.js';
|
|
2
3
|
import { autoMigratePolicyStateIfNeeded, evaluatePolicyRequest, loadPolicyState, revokePolicyApproval, setPolicyBudget, setPolicyMode, upsertPolicyRule, verifyPolicyLedger, } from '../services/policy-runtime.js';
|
|
3
4
|
function argJson(value, label) {
|
|
@@ -19,12 +20,16 @@ function requireInteractiveAdministrator() {
|
|
|
19
20
|
throw new Error('policy administration requires an interactive local terminal');
|
|
20
21
|
}
|
|
21
22
|
}
|
|
23
|
+
// #3602: say what verify can and cannot prove.
|
|
24
|
+
const LEDGER_TRUST_SCOPE = 'detects edits confined to state.json or to the project directory; '
|
|
25
|
+
+ 'does not defend against an attacker who can rewrite both the project directory and ~/.config/ruflo (ADR-475)';
|
|
22
26
|
export const policyCommand = {
|
|
23
27
|
name: 'policy',
|
|
24
28
|
description: 'Agentic policy engine — evaluate actions, manage rules/approvals, and verify the decision ledger (ADR-324)',
|
|
25
29
|
options: [
|
|
26
30
|
{ name: 'mode', type: 'string', description: 'Policy mode: legacy | observe | enforce' },
|
|
27
31
|
{ name: 'project-root', type: 'string', description: 'Project root containing .claude-flow/policy' },
|
|
32
|
+
{ name: 'establish-anchor', type: 'boolean', description: 'verify: explicitly anchor a ledger that has receipts but no anchor (interactive, logged with user and time)' },
|
|
28
33
|
],
|
|
29
34
|
async action(context) {
|
|
30
35
|
const args = context.args ?? [];
|
|
@@ -87,8 +92,11 @@ export const policyCommand = {
|
|
|
87
92
|
return print({ receipts: state.receipts });
|
|
88
93
|
}
|
|
89
94
|
if (operation === 'verify') {
|
|
90
|
-
const
|
|
91
|
-
|
|
95
|
+
const establishAnchor = flags['establish-anchor'] === true || flags.establishAnchor === true;
|
|
96
|
+
if (establishAnchor)
|
|
97
|
+
requireInteractiveAdministrator();
|
|
98
|
+
const ledger = await verifyPolicyLedger(root, { establishAnchor });
|
|
99
|
+
const result = print({ ...ledger, anchors: anchorPosture(root), scope: LEDGER_TRUST_SCOPE });
|
|
92
100
|
return ledger.valid ? result : { ...result, success: false, exitCode: 1 };
|
|
93
101
|
}
|
|
94
102
|
throw new Error(`unknown policy operation: ${operation}`);
|
|
@@ -7,6 +7,8 @@ import { confirm, input, select } from '../prompt.js';
|
|
|
7
7
|
import { callMCPTool, MCPClientError } from '../mcp-client.js';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as path from 'path';
|
|
10
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
11
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
10
12
|
// Format date for display
|
|
11
13
|
function formatDate(dateStr) {
|
|
12
14
|
if (!dateStr)
|
|
@@ -561,7 +563,7 @@ const exportCommand = {
|
|
|
561
563
|
// Format output
|
|
562
564
|
let content;
|
|
563
565
|
if (exportFormat === 'yaml') {
|
|
564
|
-
content =
|
|
566
|
+
content = stringifyYaml(result.data);
|
|
565
567
|
}
|
|
566
568
|
else {
|
|
567
569
|
content = JSON.stringify(result.data, null, 2);
|
|
@@ -570,7 +572,8 @@ const exportCommand = {
|
|
|
570
572
|
const absolutePath = path.isAbsolute(outputPath)
|
|
571
573
|
? outputPath
|
|
572
574
|
: path.join(ctx.cwd, outputPath);
|
|
573
|
-
|
|
575
|
+
const bytes = compress ? gzipSync(content) : Buffer.from(content, 'utf-8');
|
|
576
|
+
fs.writeFileSync(absolutePath, bytes);
|
|
574
577
|
spinner.succeed('Session exported');
|
|
575
578
|
output.writeln();
|
|
576
579
|
// session_export returns the whole record under `data`; its stats live there.
|
|
@@ -587,14 +590,14 @@ const exportCommand = {
|
|
|
587
590
|
{ property: 'Agents', value: exportStats.agentCount ?? exportStats.agents ?? 0 },
|
|
588
591
|
{ property: 'Tasks', value: exportStats.taskCount ?? exportStats.tasks ?? 0 },
|
|
589
592
|
{ property: 'Memory Entries', value: exportStats.memoryEntries ?? 0 },
|
|
590
|
-
{ property: 'File Size', value: formatSize(
|
|
593
|
+
{ property: 'File Size', value: formatSize(bytes.length) }
|
|
591
594
|
]
|
|
592
595
|
});
|
|
593
596
|
output.writeln();
|
|
594
597
|
output.printSuccess(`Session exported to ${outputPath}`);
|
|
595
598
|
return {
|
|
596
599
|
success: true,
|
|
597
|
-
data: { sessionId, outputPath, format: exportFormat, size:
|
|
600
|
+
data: { sessionId, outputPath, format: exportFormat, size: bytes.length }
|
|
598
601
|
};
|
|
599
602
|
}
|
|
600
603
|
catch (error) {
|
|
@@ -647,9 +650,11 @@ const importCommand = {
|
|
|
647
650
|
try {
|
|
648
651
|
// The YAML written by `session export --format yaml` is display-only;
|
|
649
652
|
// there is no YAML parser here, so say so instead of failing obscurely.
|
|
650
|
-
if (
|
|
653
|
+
if (/\.ya?ml(?:\.gz)?$/i.test(absolutePath)) {
|
|
651
654
|
try {
|
|
652
|
-
|
|
655
|
+
const bytes = fs.readFileSync(absolutePath);
|
|
656
|
+
const content = bytes[0] === 0x1f && bytes[1] === 0x8b ? gunzipSync(bytes) : bytes;
|
|
657
|
+
JSON.parse(content.toString('utf-8'));
|
|
653
658
|
}
|
|
654
659
|
catch {
|
|
655
660
|
spinner.fail('Failed to import session');
|
|
@@ -783,37 +788,6 @@ function formatDuration(ms) {
|
|
|
783
788
|
}
|
|
784
789
|
return `${seconds}s`;
|
|
785
790
|
}
|
|
786
|
-
function toSimpleYaml(obj, indent = 0) {
|
|
787
|
-
// Simple YAML serializer (for basic types)
|
|
788
|
-
if (obj === null)
|
|
789
|
-
return 'null';
|
|
790
|
-
if (typeof obj === 'boolean')
|
|
791
|
-
return String(obj);
|
|
792
|
-
if (typeof obj === 'number')
|
|
793
|
-
return String(obj);
|
|
794
|
-
if (typeof obj === 'string')
|
|
795
|
-
return obj.includes(':') ? `"${obj}"` : obj;
|
|
796
|
-
const spaces = ' '.repeat(indent);
|
|
797
|
-
let result = '';
|
|
798
|
-
if (Array.isArray(obj)) {
|
|
799
|
-
for (const item of obj) {
|
|
800
|
-
result += `${spaces}- ${toSimpleYaml(item, indent + 1).trim()}\n`;
|
|
801
|
-
}
|
|
802
|
-
return result;
|
|
803
|
-
}
|
|
804
|
-
if (typeof obj === 'object') {
|
|
805
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
806
|
-
if (typeof value === 'object' && value !== null) {
|
|
807
|
-
result += `${spaces}${key}:\n${toSimpleYaml(value, indent + 1)}`;
|
|
808
|
-
}
|
|
809
|
-
else {
|
|
810
|
-
result += `${spaces}${key}: ${toSimpleYaml(value, indent)}\n`;
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
return result;
|
|
814
|
-
}
|
|
815
|
-
return String(obj);
|
|
816
|
-
}
|
|
817
791
|
// Main session command
|
|
818
792
|
export const sessionCommand = {
|
|
819
793
|
name: 'session',
|
package/dist/src/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { output } from './output.js';
|
|
|
19
19
|
import { commands, getCommandsByCategory, getCommand, getCommandAsync, getCommandNames, getLazyCommandNames, hasCommand } from './commands/index.js';
|
|
20
20
|
import { suggestCommand } from './suggest.js';
|
|
21
21
|
import { runStartupUpdateCheck } from './update/index.js';
|
|
22
|
+
import { ensureBlockingStdio } from './blocking-stdio.js';
|
|
22
23
|
// Read version from package.json at runtime
|
|
23
24
|
function getPackageVersion() {
|
|
24
25
|
try {
|
|
@@ -66,6 +67,8 @@ export class CLI {
|
|
|
66
67
|
* Run the CLI with given arguments
|
|
67
68
|
*/
|
|
68
69
|
async run(args = process.argv.slice(2)) {
|
|
70
|
+
// #3851: keep piped output intact across the process.exit() calls below
|
|
71
|
+
ensureBlockingStdio();
|
|
69
72
|
try {
|
|
70
73
|
// #1791.2 — If the user invoked a lazy command (e.g. `hive-mind task`),
|
|
71
74
|
// pre-load it BEFORE parsing so the parser can build scoped flag
|
|
@@ -436,6 +436,7 @@ module.exports = commands;
|
|
|
436
436
|
// Literal-word root guard mirrored in the mod and both shipped classic helpers.
|
|
437
437
|
const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, depth = 0) {
|
|
438
438
|
let word = '', quote = '', started = false, redirect = false
|
|
439
|
+
let ansiNul = false // a NUL inside $'...' ends that string, as in bash
|
|
439
440
|
let inRm = false, optionsEnded = false, recursive = false, force = false, root = false
|
|
440
441
|
const isRoot = (operand) => {
|
|
441
442
|
if (!operand.startsWith('/')) return false
|
|
@@ -447,22 +448,55 @@ const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, dep
|
|
|
447
448
|
}
|
|
448
449
|
return parts.length === 0 || /[*?\[]/.test(parts[0])
|
|
449
450
|
}
|
|
451
|
+
// One backslash escape inside $'...', from the character after the backslash.
|
|
452
|
+
// Returns the text it stands for and the index of its last character. An unknown escape keeps its backslash.
|
|
453
|
+
// The command arrives lowercased, so an uppercase-U escape reads as a lowercase one: eight digits starting 0000 are one code point.
|
|
454
|
+
const ansiEscape = (s, at) => {
|
|
455
|
+
const c = s[at]
|
|
456
|
+
const simple = { a: '\x07', b: '\b', e: '\x1b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', '\\': '\\', "'": "'", '"': '"', '?': '?' }
|
|
457
|
+
if (simple[c] !== undefined) return [simple[c], at]
|
|
458
|
+
const digits = (from, max, pattern) => pattern.exec(s.slice(from, from + max))?.[0]
|
|
459
|
+
const octal = digits(at, 3, /^[0-7]+/)
|
|
460
|
+
if (octal) return [String.fromCharCode(parseInt(octal, 8) & 255), at + octal.length - 1]
|
|
461
|
+
const hex = c === 'x' ? digits(at + 1, 2, /^[0-9a-f]+/) : undefined
|
|
462
|
+
if (hex) return [String.fromCharCode(parseInt(hex, 16)), at + hex.length]
|
|
463
|
+
const wide = c === 'u' ? (digits(at + 1, 8, /^0000[0-9a-f]{4}$/) ?? digits(at + 1, 4, /^[0-9a-f]+/)) : undefined
|
|
464
|
+
if (wide) return [String.fromCodePoint(parseInt(wide, 16)), at + wide.length]
|
|
465
|
+
if (c === 'c' && at + 1 < s.length) return [String.fromCharCode(s.charCodeAt(at + 1) & 31), at + 1]
|
|
466
|
+
return ['\\' + c, at]
|
|
467
|
+
}
|
|
450
468
|
const finishWord = () => {
|
|
451
469
|
if (!started) return false
|
|
452
470
|
// Literal shell strings (e.g. sh -c 'rm -rf /') also carried the old guard.
|
|
453
471
|
// Bound rescanning to four levels; beyond that retain its conservative check.
|
|
454
|
-
if (
|
|
472
|
+
if (/[\s;&|()\x60]/.test(word)) {
|
|
455
473
|
if (depth < 4 ? hasRootDelete(word, depth + 1) : word.includes('rm -rf /')) return true
|
|
456
474
|
}
|
|
457
475
|
if (!inRm) inRm = word === 'rm' || word.endsWith('/rm')
|
|
458
476
|
else if (!optionsEnded && word === '--') optionsEnded = true
|
|
459
477
|
else if (!optionsEnded && word.startsWith('-')) {
|
|
460
|
-
|
|
461
|
-
|
|
478
|
+
// GNU getopt accepts any unambiguous long-option prefix: --r, --recur, --forc.
|
|
479
|
+
recursive = recursive || (word.length > 2 && '--recursive'.startsWith(word)) || /^-[a-z]*r[a-z]*$/.test(word)
|
|
480
|
+
force = force || (word.length > 2 && '--force'.startsWith(word)) || /^-[a-z]*f[a-z]*$/.test(word)
|
|
462
481
|
} else root = root || isRoot(word)
|
|
463
482
|
word = ''; started = false
|
|
464
483
|
return inRm && recursive && force && root
|
|
465
484
|
}
|
|
485
|
+
// A command substitution's body, from just after its opening backtick. Bash removes a
|
|
486
|
+
// backslash only before $, a backtick or a backslash (and " inside double quotes) and
|
|
487
|
+
// drops backslash-newline; any other backslash stays for the nested scan.
|
|
488
|
+
const substitution = (from, inDouble) => {
|
|
489
|
+
let body = '', j = from
|
|
490
|
+
for (; j < command.length && command[j] !== '\x60'; j++) {
|
|
491
|
+
const next = command[j + 1]
|
|
492
|
+
if (command[j] === '\\' && j + 1 < command.length) {
|
|
493
|
+
if (next === '\n') { j++; continue }
|
|
494
|
+
if (next === '$' || next === '\x60' || next === '\\' || (inDouble && next === '"')) { body += next; j++; continue }
|
|
495
|
+
}
|
|
496
|
+
body += command[j]
|
|
497
|
+
}
|
|
498
|
+
return [j, depth < 4 ? hasRootDelete(body, depth + 1) : body.includes('rm -rf /')]
|
|
499
|
+
}
|
|
466
500
|
const finishCommand = () => {
|
|
467
501
|
const denied = inRm && recursive && force && root
|
|
468
502
|
inRm = optionsEnded = recursive = force = root = false
|
|
@@ -473,7 +507,25 @@ const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, dep
|
|
|
473
507
|
const redirectionAmpersand = char === '&' && (redirect || command[i + 1] === '>')
|
|
474
508
|
redirect = false
|
|
475
509
|
if (quote) {
|
|
510
|
+
if (quote === "$'") {
|
|
511
|
+
if (char === "'") { quote = ''; ansiNul = false }
|
|
512
|
+
else if (char === '\\' && i + 1 < command.length) {
|
|
513
|
+
const [text, last] = ansiEscape(command, i + 1)
|
|
514
|
+
i = last
|
|
515
|
+
if (!ansiNul) { const nul = text.indexOf('\0'); if (nul < 0) word += text; else { word += text.slice(0, nul); ansiNul = true } }
|
|
516
|
+
} else if (!ansiNul) word += char
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
476
519
|
if (char === quote) quote = ''
|
|
520
|
+
else if (quote === '"' && char === '\x60' && command.indexOf('\x60', i + 1) > i) {
|
|
521
|
+
const [end, denied] = substitution(i + 1, true)
|
|
522
|
+
if (denied) return true
|
|
523
|
+
// The substitution is gone from the word, as bash's empty output is (a quoted empty substitution glued to -rf leaves -rf).
|
|
524
|
+
// A # right after it is still inside the word: a placeholder keeps a rescan (sh -c "...")
|
|
525
|
+
// from reading it as the start of a comment.
|
|
526
|
+
if (command[end + 1] === '#') word += '\u0001'
|
|
527
|
+
i = end
|
|
528
|
+
}
|
|
477
529
|
else if (quote === '"' && char === '\\' && i + 1 < command.length &&
|
|
478
530
|
(command[i + 1] === '"' || command[i + 1] === '\\' || command[i + 1] === '$' ||
|
|
479
531
|
command.charCodeAt(i + 1) === 96 || command[i + 1] === '\n')) {
|
|
@@ -485,6 +537,16 @@ const ROOT_DELETE_CHECK_SOURCE = String.raw `function hasRootDelete(command, dep
|
|
|
485
537
|
if (char === '\\' && i + 1 < command.length) {
|
|
486
538
|
const next = command[++i]
|
|
487
539
|
if (next !== '\n') { word += next; started = true }
|
|
540
|
+
} else if (char === '\x60' && command.indexOf('\x60', i + 1) > i) {
|
|
541
|
+
// Command substitution: scan the body as its own command; the substitution
|
|
542
|
+
// stays inside the enclosing word, so the rm being parsed keeps its state,
|
|
543
|
+
// and the word has started (a # right after it is not a comment).
|
|
544
|
+
const [end, denied] = substitution(i + 1, false)
|
|
545
|
+
if (denied) return true
|
|
546
|
+
i = end; started = true
|
|
547
|
+
} else if (char === '$' && (command[i + 1] === "'" || command[i + 1] === '"')) {
|
|
548
|
+
// ANSI-C ($'...': escapes decoded) and locale ($"...") quoting: the $ is not part of the word.
|
|
549
|
+
quote = command[i + 1] === "'" ? "$'" : '"'; started = true; i++
|
|
488
550
|
} else if (char === '"' || char === "'") {
|
|
489
551
|
quote = char; started = true
|
|
490
552
|
} else if (char === '#' && !started) {
|