Skip to content

Commit c2f9e62

Browse files
committed
feat(new tools): Shell Commands Formatter and Linearizer
1 parent 4956df7 commit c2f9e62

6 files changed

Lines changed: 282 additions & 0 deletions

File tree

src/tools/shell-formatter/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Terminal2 } from '@vicons/tabler';
2+
import { defineTool } from '../tool';
3+
4+
export const tool = defineTool({
5+
name: 'Shell Commands Formatter',
6+
path: '/shell-formatter',
7+
description: 'Format shell commands as multiline shell commands split on arguments',
8+
keywords: ['shell', 'multiline', 'formatter'],
9+
component: () => import('./shell-formatter.vue'),
10+
icon: Terminal2,
11+
createdAt: new Date('2026-02-14'),
12+
category: 'Default',
13+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script setup lang="ts">
2+
import { formatShellCommand } from '@/utils/shell-formatter';
3+
4+
const defaultValue = 'docker run -p 80:80 -v /var/run/docker.sock:/tmp/docker.sock:ro --restart always --log-opt max-size=1g nginx';
5+
6+
function transformer(value: string) {
7+
try {
8+
return formatShellCommand(value);
9+
}
10+
catch (e: any) {
11+
return `# ERROR: ${e.toString()}`;
12+
}
13+
}
14+
</script>
15+
16+
<template>
17+
<format-transformer
18+
input-label="Shell commands to format"
19+
:input-default="defaultValue"
20+
input-placeholder="Put your shell commands to format here..."
21+
output-label="Formatted shell commands"
22+
output-language="bash"
23+
:transformer="transformer"
24+
/>
25+
</template>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Terminal2 } from '@vicons/tabler';
2+
import { defineTool } from '../tool';
3+
4+
export const tool = defineTool({
5+
name: 'Shell Commands Linearizer',
6+
path: '/shell-linearizer',
7+
description: 'Linearize multiline shell commands',
8+
keywords: ['shell', 'multiline', 'linearizer'],
9+
component: () => import('./shell-linearizer.vue'),
10+
icon: Terminal2,
11+
createdAt: new Date('2026-02-14'),
12+
category: 'Default',
13+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script setup lang="ts">
2+
import { collapseBackslashLines } from '@/utils/shell-formatter';
3+
4+
const defaultValue = 'docker run -p 80:80 -v /var/run/docker.sock:/tmp/docker.sock:ro --restart always --log-opt max-size=1g nginx';
5+
6+
function transformer(value: string) {
7+
try {
8+
return collapseBackslashLines(value);
9+
}
10+
catch (e: any) {
11+
return `# ERROR: ${e.toString()}`;
12+
}
13+
}
14+
</script>
15+
16+
<template>
17+
<format-transformer
18+
input-label="Shell commands to linearize"
19+
:input-default="defaultValue"
20+
input-placeholder="Put your shell commands to linearize here..."
21+
output-label="Linearized shell commands"
22+
output-language="bash"
23+
:transformer="transformer"
24+
/>
25+
</template>

src/utils/shell-formatter.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { collapseBackslashLines, formatShellCommand } from './shell-formatter';
3+
4+
describe('collapseBackslashLines', () => {
5+
it('collapses lines ending with backslash', () => {
6+
const input = `
7+
echo hello \\
8+
world
9+
`;
10+
expect(collapseBackslashLines(input)).toBe(`
11+
echo hello world
12+
`.trim());
13+
});
14+
15+
it('keeps comments untouched', () => {
16+
const input = `
17+
# comment
18+
echo test
19+
`;
20+
expect(collapseBackslashLines(input)).toBe(`
21+
# comment
22+
echo test
23+
`.trim());
24+
});
25+
26+
it('keeps unrelated lines as-is', () => {
27+
const input = `
28+
line1
29+
line2
30+
`;
31+
expect(collapseBackslashLines(input)).toBe(`
32+
line1
33+
line2
34+
`.trim());
35+
});
36+
37+
it('collapses multiple continuation lines', () => {
38+
const input = `
39+
cmd a \\
40+
b \\
41+
c
42+
`;
43+
expect(collapseBackslashLines(input)).toBe('cmd a b c');
44+
});
45+
46+
it('handles mixed comments and commands', () => {
47+
const input = `
48+
# before
49+
cmd a \\
50+
b
51+
# after
52+
`;
53+
expect(collapseBackslashLines(input)).toBe(`
54+
# before
55+
cmd a b
56+
# after
57+
`.trim());
58+
});
59+
});
60+
61+
describe('formatShellCommand', () => {
62+
it('returns empty string for empty input', () => {
63+
expect(formatShellCommand('')).toBe('');
64+
});
65+
66+
it('groups arguments with values when groupArgs=true', () => {
67+
const cmd = 'docker run -d --name mycontainer -p 8080:80 myimage';
68+
const result = formatShellCommand(cmd);
69+
70+
expect(result).toBe(
71+
[
72+
'docker run \\',
73+
' -d \\',
74+
' --name mycontainer \\',
75+
' -p 8080:80 \\',
76+
' myimage',
77+
].join('\n'),
78+
);
79+
});
80+
81+
it('respects custom indentation', () => {
82+
const cmd = 'docker run -d --name mycontainer -p 8080:80 myimage';
83+
const result = formatShellCommand(cmd, 4);
84+
85+
expect(result).toBe(
86+
[
87+
'docker run \\',
88+
' -d \\',
89+
' --name mycontainer \\',
90+
' -p 8080:80 \\',
91+
' myimage',
92+
].join('\n'),
93+
);
94+
});
95+
96+
it('handles quoted arguments correctly', () => {
97+
const cmd = 'docker run --label "some label with spaces" myimage';
98+
const result = formatShellCommand(cmd);
99+
100+
expect(result).toBe(
101+
[
102+
'docker run \\',
103+
' --label "some label with spaces" \\',
104+
' myimage',
105+
].join('\n'),
106+
);
107+
});
108+
109+
it('handles base command with no args', () => {
110+
const cmd = 'echo hello';
111+
const result = formatShellCommand(cmd);
112+
113+
expect(result).toBe('echo hello');
114+
});
115+
});

src/utils/shell-formatter.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Formats a shell command by splitting dash arguments onto new lines,
3+
* with optional grouping and line wrapping.
4+
*/
5+
export function formatShellCommand(
6+
commands: string,
7+
indentSize: number = 2,
8+
): string {
9+
const indent = ' '.repeat(indentSize);
10+
11+
return collapseBackslashLines(commands).split(/\r?\n/).map((command) => {
12+
if (!command?.trim() || command.trimStart().startsWith('#')) {
13+
return command;
14+
}
15+
16+
// Split by whitespace while respecting quoted strings
17+
const args = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
18+
if (args.length === 0) {
19+
return command;
20+
}
21+
22+
const formatted: string[] = [];
23+
24+
let hadArgument = false;
25+
for (let i = 0; i < args.length; i++) {
26+
const arg = args[i];
27+
28+
if (arg.startsWith('-')) {
29+
if (!hadArgument && formatted.length) {
30+
formatted[formatted.length - 1] += ' \\';
31+
}
32+
hadArgument = true;
33+
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
34+
formatted.push(`${indent}${arg} ${args[i + 1]} \\`);
35+
i++;
36+
}
37+
else {
38+
formatted.push(`${indent}${arg} \\`);
39+
}
40+
}
41+
else if (hadArgument) {
42+
formatted.push(`${indent}${arg}`);
43+
}
44+
else {
45+
if (formatted.length) {
46+
formatted[0] = `${formatted[0]} ${arg}`;
47+
}
48+
else {
49+
formatted.push(arg);
50+
}
51+
}
52+
}
53+
54+
return formatted.join('\n');
55+
}).join('\n');
56+
}
57+
58+
export function collapseBackslashLines(input: string): string {
59+
const lines = input.trim().split(/\r?\n/);
60+
61+
const out: string[] = [];
62+
let buffer: string[] = [];
63+
64+
const flush = () => {
65+
if (buffer.length > 0) {
66+
out.push(buffer.join(' ').trim());
67+
buffer = [];
68+
}
69+
};
70+
71+
for (const raw of lines) {
72+
const line = raw.trimEnd();
73+
74+
if (line.endsWith('\\')) {
75+
// Remove trailing backslash and continue accumulating
76+
buffer.push(line.slice(0, -1).trim());
77+
}
78+
else {
79+
if (buffer.length > 0) {
80+
buffer.push(line.trim());
81+
flush();
82+
}
83+
else {
84+
out.push(line);
85+
}
86+
}
87+
}
88+
89+
flush();
90+
return out.join('\n');
91+
}

0 commit comments

Comments
 (0)