cowork-cli 0.0.1 → 0.2.8
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/README.md +78 -0
- package/bin/cli.js +43 -0
- package/package.json +34 -6
- package/src/configs/config.json +8 -0
- package/src/engine/client.js +26 -0
- package/src/engine/models/BaseModel.js +232 -0
- package/src/engine/models/default.js +8 -0
- package/src/engine/models/gemini.js +20 -0
- package/src/engine/run.js +51 -0
- package/src/engine/tools/askUser.js +62 -0
- package/src/engine/tools/findDir.js +73 -0
- package/src/engine/tools/findFile.js +74 -0
- package/src/engine/tools/index.js +188 -0
- package/src/engine/tools/listTools.js +79 -0
- package/src/engine/tools/projectTree.js +89 -0
- package/src/engine/tools/readDir.js +32 -0
- package/src/engine/tools/readFile.js +41 -0
- package/src/engine/tools/readFileChunk.js +48 -0
- package/src/engine/tools/searchText.js +133 -0
- package/src/engine/tools/webFetch.js +154 -0
- package/src/main.js +50 -0
- package/src/utils/configManager.js +71 -0
- package/src/utils/fsUtils.js +37 -0
- package/src/utils/helpMsg.js +23 -0
- package/src/utils/logger.js +45 -0
- package/src/utils/outputFormatter.js +72 -0
- package/src/utils/ui.js +68 -0
- package/index.js +0 -2
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import ipaddr from 'ipaddr.js';
|
|
4
|
+
|
|
5
|
+
const MAX_CHARS = 15000;
|
|
6
|
+
const TIMEOUT_MS = 10000;
|
|
7
|
+
const MAX_REDIRECTS = 5;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Checks if a hostname resolves to a private or reserved IP address.
|
|
11
|
+
*/
|
|
12
|
+
async function validateUrlSafety(url) {
|
|
13
|
+
try {
|
|
14
|
+
const parsedUrl = new URL(url);
|
|
15
|
+
|
|
16
|
+
// Enforce protocol
|
|
17
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
18
|
+
throw new Error(`Unsupported protocol: ${parsedUrl.protocol}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const hostname = parsedUrl.hostname;
|
|
22
|
+
|
|
23
|
+
// Resolve hostname to IP addresses
|
|
24
|
+
// This also handles cases where hostname is already an IP address
|
|
25
|
+
const addresses = await lookup(hostname, { all: true });
|
|
26
|
+
|
|
27
|
+
if (addresses.length === 0) {
|
|
28
|
+
throw new Error(`Could not resolve hostname: ${hostname}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const addr of addresses) {
|
|
32
|
+
if (!ipaddr.isValid(addr.address)) continue;
|
|
33
|
+
|
|
34
|
+
const parsedAddr = ipaddr.parse(addr.address);
|
|
35
|
+
let addrToTest = parsedAddr;
|
|
36
|
+
|
|
37
|
+
// IPv4-mapped IPv6 handling
|
|
38
|
+
if (parsedAddr instanceof ipaddr.IPv6 && parsedAddr.isIPv4MappedAddress()) {
|
|
39
|
+
addrToTest = parsedAddr.toIPv4Address();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check range
|
|
43
|
+
const range = addrToTest.range();
|
|
44
|
+
|
|
45
|
+
// We only allow 'unicast' for public internet access.
|
|
46
|
+
// This blocks: 'private', 'loopback', 'linkLocal', 'multicast', 'reserved', etc.
|
|
47
|
+
if (range !== 'unicast') {
|
|
48
|
+
return { safe: false, reason: `Address ${addr.address} (${range}) is not allowed.` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Explicitly block IANA benchmark range (198.18.0.0/15)
|
|
52
|
+
if (addrToTest instanceof ipaddr.IPv4) {
|
|
53
|
+
const [benchmarkRange, mask] = ipaddr.parseCIDR('198.18.0.0/15');
|
|
54
|
+
if (addrToTest.match(benchmarkRange, mask)) {
|
|
55
|
+
return { safe: false, reason: `Address ${addr.address} is in a reserved benchmark range.` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { safe: true };
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw new Error(`Safety validation failed: ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* webFetch tool implementation.
|
|
67
|
+
* Hardened with manual redirect following and SSRF protection at every hop.
|
|
68
|
+
*/
|
|
69
|
+
export default async function webFetch({ url }) {
|
|
70
|
+
let currentUrl = url;
|
|
71
|
+
let redirectCount = 0;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
while (redirectCount <= MAX_REDIRECTS) {
|
|
75
|
+
// 1. Safety Check for the current hop
|
|
76
|
+
const validation = await validateUrlSafety(currentUrl);
|
|
77
|
+
if (!validation.safe) {
|
|
78
|
+
throw new Error(`SSRF Protection: ${validation.reason}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2. Fetch with Timeout and manual redirect handling
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
84
|
+
|
|
85
|
+
const response = await fetch(currentUrl, {
|
|
86
|
+
method: 'GET',
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
redirect: 'manual', // We handle redirects manually for safety
|
|
89
|
+
headers: {
|
|
90
|
+
'User-Agent': 'cowork-cli/0.1 (Analyst Tool; SSRF-Protected)',
|
|
91
|
+
'Accept': 'text/html,application/xhtml+xml,application/json,text/plain'
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
clearTimeout(timeoutId);
|
|
96
|
+
|
|
97
|
+
// 3. Handle Redirects
|
|
98
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
99
|
+
const location = response.headers.get('location');
|
|
100
|
+
if (!location) {
|
|
101
|
+
throw new Error(`Redirect status ${response.status} received without Location header.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolve relative redirects
|
|
105
|
+
currentUrl = new URL(location, currentUrl).href;
|
|
106
|
+
redirectCount++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 4. Process Response
|
|
115
|
+
let text = await response.text();
|
|
116
|
+
const contentType = response.headers.get('content-type') || '';
|
|
117
|
+
|
|
118
|
+
// 5. HTML Stripping (Aggressive for context awareness)
|
|
119
|
+
if (contentType.includes('text/html')) {
|
|
120
|
+
text = text
|
|
121
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
122
|
+
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
|
|
123
|
+
.replace(/<nav\b[^<]*(?:(?!<\/nav>)<[^<]*)*<\/nav>/gi, '')
|
|
124
|
+
.replace(/<header\b[^<]*(?:(?!<\/header>)<[^<]*)*<\/header>/gi, '')
|
|
125
|
+
.replace(/<footer\b[^<]*(?:(?!<\/footer>)<[^<]*)*<\/footer>/gi, '')
|
|
126
|
+
.replace(/<aside\b[^<]*(?:(?!<\/aside>)<[^<]*)*<\/aside>/gi, '')
|
|
127
|
+
.replace(/<[^>]+>/g, ' ')
|
|
128
|
+
.replace(/\s+/g, ' ')
|
|
129
|
+
.trim();
|
|
130
|
+
} else if (contentType.includes('application/json')) {
|
|
131
|
+
try {
|
|
132
|
+
text = JSON.stringify(JSON.parse(text), null, 2);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
// Fallback to raw text if JSON parsing fails
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 6. Context Awareness: Truncation
|
|
139
|
+
if (text.length > MAX_CHARS) {
|
|
140
|
+
text = text.slice(0, MAX_CHARS) + "\n\n[Warning: Output truncated to fit context limits]";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return text;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
|
|
147
|
+
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (err.name === 'AbortError') {
|
|
150
|
+
return `Error: Request timed out after ${TIMEOUT_MS}ms`;
|
|
151
|
+
}
|
|
152
|
+
return `Error: ${err.message}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import show_help from "./utils/helpMsg.js";
|
|
2
|
+
import clientLoader from "./engine/client.js";
|
|
3
|
+
import runQuery from "./engine/run.js";
|
|
4
|
+
import { loadConfig, verifyConnectivity } from "./utils/configManager.js";
|
|
5
|
+
import { logger } from "./utils/logger.js";
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const PKG_PATH = path.join(__dirname, '../package.json');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Main entry point for the cwk CLI.
|
|
15
|
+
* @param {string[]} args Command line arguments.
|
|
16
|
+
*/
|
|
17
|
+
export default async function main(args) {
|
|
18
|
+
// Handle empty arguments or help flags
|
|
19
|
+
if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
|
|
20
|
+
show_help();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Handle version flags
|
|
25
|
+
if (args[0] === '-v' || args[0] === '--version') {
|
|
26
|
+
try {
|
|
27
|
+
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf8'));
|
|
28
|
+
console.log(`cwk version ${pkg.version}`);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
logger.error("Error reading version from package.json");
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Handle query execution
|
|
36
|
+
const query = args[0];
|
|
37
|
+
const config = loadConfig();
|
|
38
|
+
|
|
39
|
+
// clientLoader handles config validation and throws if invalid
|
|
40
|
+
const client = clientLoader();
|
|
41
|
+
|
|
42
|
+
// Silent connectivity check: logs only on failure
|
|
43
|
+
const isConnected = await verifyConnectivity(client);
|
|
44
|
+
if (!isConnected) {
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await runQuery(client, config, query);
|
|
50
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import dotenv from 'dotenv';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
|
|
7
|
+
const CONFIG_PATH = path.join(os.homedir(), '.env');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Loads the user configuration from ~/.env file.
|
|
11
|
+
* @returns {Object|null} The configuration object or null if it doesn't exist or is invalid.
|
|
12
|
+
*/
|
|
13
|
+
export const loadConfig = () => {
|
|
14
|
+
try {
|
|
15
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
16
|
+
const content = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
17
|
+
const envConfig = dotenv.parse(content);
|
|
18
|
+
|
|
19
|
+
const config = {
|
|
20
|
+
model_name: envConfig.CWK_MODEL_NAME || envConfig.BTW_MODEL_NAME || envConfig.MODEL_NAME,
|
|
21
|
+
model_url: envConfig.CWK_MODEL_URL || envConfig.BTW_MODEL_URL || envConfig.MODEL_URL,
|
|
22
|
+
model_api_key: envConfig.CWK_MODEL_API_KEY || envConfig.BTW_MODEL_API_KEY || envConfig.MODEL_API_KEY,
|
|
23
|
+
model_type: envConfig.CWK_MODEL_TYPE || envConfig.BTW_MODEL_TYPE || envConfig.MODEL_TYPE
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Remove undefined/empty values
|
|
27
|
+
const filteredConfig = Object.fromEntries(
|
|
28
|
+
Object.entries(config).filter(([_, v]) => v !== undefined && v !== '')
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
if (Object.keys(filteredConfig).length > 0) {
|
|
32
|
+
return filteredConfig;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
logger.error(`Error loading configuration from ~/.env: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validates the configuration object.
|
|
43
|
+
* @param {Object} config The configuration object to validate.
|
|
44
|
+
* @returns {boolean} True if valid, false otherwise.
|
|
45
|
+
*/
|
|
46
|
+
export const validateConfig = (config) => {
|
|
47
|
+
if (!config) return false;
|
|
48
|
+
const requiredKeys = ['model_name', 'model_url', 'model_api_key', 'model_type'];
|
|
49
|
+
const hasAllKeys = requiredKeys.every(key => config[key] && config[key].trim() !== '');
|
|
50
|
+
|
|
51
|
+
if (!hasAllKeys) return false;
|
|
52
|
+
|
|
53
|
+
const validTypes = ['openai', 'gemini'];
|
|
54
|
+
return validTypes.includes(config.model_type.toLowerCase());
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Verifies the connectivity and credentials by listing models.
|
|
59
|
+
* @param {Object} client The initialized OpenAI client.
|
|
60
|
+
* @returns {Promise<boolean>} True if connectivity is verified, false otherwise.
|
|
61
|
+
*/
|
|
62
|
+
export const verifyConnectivity = async (client) => {
|
|
63
|
+
try {
|
|
64
|
+
// This call verifies both the API Key and the Base URL
|
|
65
|
+
await client.models.list();
|
|
66
|
+
return true;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
logger.error(`Connection verification failed: ${err.message}`);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_IGNORES = ['.git', 'node_modules', 'dist', 'build', '.npm', '.DS_Store'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Loads .gitignore patterns from the current working directory.
|
|
8
|
+
* @returns {Promise<string[]>} Array of ignore patterns.
|
|
9
|
+
*/
|
|
10
|
+
export async function getIgnorePatterns() {
|
|
11
|
+
const ignoreList = [...DEFAULT_IGNORES];
|
|
12
|
+
try {
|
|
13
|
+
const gitignorePath = path.join(process.cwd(), '.gitignore');
|
|
14
|
+
const content = await fs.readFile(gitignorePath, 'utf8');
|
|
15
|
+
const lines = content.split('\n')
|
|
16
|
+
.map(l => l.trim())
|
|
17
|
+
.filter(l => l && !l.startsWith('#'));
|
|
18
|
+
ignoreList.push(...lines);
|
|
19
|
+
} catch (e) {
|
|
20
|
+
// Ignore if not found or unreadable
|
|
21
|
+
}
|
|
22
|
+
return ignoreList;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Checks if a file or directory should be ignored.
|
|
27
|
+
* @param {string} name Item name.
|
|
28
|
+
* @param {string[]} ignoreList List of patterns.
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
export function shouldIgnore(name, ignoreList) {
|
|
32
|
+
for (const ignore of ignoreList) {
|
|
33
|
+
const pattern = ignore.endsWith('/') ? ignore.slice(0, -1) : ignore;
|
|
34
|
+
if (name === pattern) return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { formatMain, formatSecondary, formatNormal } from './logger.js';
|
|
2
|
+
|
|
3
|
+
const helpMsg = `${formatMain('cwk - Ask AI from your terminal')}
|
|
4
|
+
|
|
5
|
+
${formatSecondary('Usage:')}
|
|
6
|
+
${formatNormal('cwk "<query>" Ask a question to your configured AI model.')}
|
|
7
|
+
${formatNormal('cwk -v, --version Show version information.')}
|
|
8
|
+
${formatNormal('cwk -h, --help Show this help message.')}
|
|
9
|
+
|
|
10
|
+
${formatSecondary('Examples:')}
|
|
11
|
+
${formatNormal('cwk "How do I list files in Node.js?"')}
|
|
12
|
+
${formatNormal('cwk -h')}
|
|
13
|
+
|
|
14
|
+
${formatSecondary('Configuration:')}
|
|
15
|
+
${formatNormal('Provide API settings in your ~/.env file using these keys:')}
|
|
16
|
+
${formatNormal(' CWK_MODEL_NAME=gpt-4')}
|
|
17
|
+
${formatNormal(' CWK_MODEL_URL=https://api.openai.com/v1')}
|
|
18
|
+
${formatNormal(' CWK_MODEL_API_KEY=your_key_here')}
|
|
19
|
+
${formatNormal(' CWK_MODEL_TYPE=openai')}`;
|
|
20
|
+
|
|
21
|
+
export default function show_help() {
|
|
22
|
+
console.log(helpMsg);
|
|
23
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const configPath = path.join(__dirname, '../configs/config.json');
|
|
7
|
+
|
|
8
|
+
let config;
|
|
9
|
+
try {
|
|
10
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
11
|
+
} catch (e) {
|
|
12
|
+
// Fallback config if file is missing or invalid
|
|
13
|
+
config = {
|
|
14
|
+
accents: {
|
|
15
|
+
orangex: "#D97757",
|
|
16
|
+
greyx: "#808080",
|
|
17
|
+
resetx: "#FFFFFF"
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function hexToAnsi(hex) {
|
|
23
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
24
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
25
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
26
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const colors = {
|
|
30
|
+
main: hexToAnsi(config.accents.orangex),
|
|
31
|
+
secondary: hexToAnsi(config.accents.greyx),
|
|
32
|
+
normal: hexToAnsi(config.accents.resetx),
|
|
33
|
+
reset: '\x1b[0m'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const formatMain = (text) => `${colors.main}${text}${colors.reset}`;
|
|
37
|
+
export const formatSecondary = (text) => `${colors.secondary}${text}${colors.reset}`;
|
|
38
|
+
export const formatNormal = (text) => `${colors.normal}${text}${colors.reset}`;
|
|
39
|
+
|
|
40
|
+
export const logger = {
|
|
41
|
+
main: (msg) => console.log(formatMain(msg)),
|
|
42
|
+
secondary: (msg) => console.log(formatSecondary(msg)),
|
|
43
|
+
normal: (msg) => console.log(formatNormal(msg)),
|
|
44
|
+
error: (msg) => console.error(formatMain(msg))
|
|
45
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps text to the current terminal width without breaking words unless necessary.
|
|
3
|
+
* Dynamically detects terminal width and preserves whitespace/indentation.
|
|
4
|
+
* @param {string} text The text to format.
|
|
5
|
+
* @param {number} [overrideWidth] Optional manual width override.
|
|
6
|
+
* @returns {string} The formatted text.
|
|
7
|
+
*/
|
|
8
|
+
export function outputFormatted(text, overrideWidth) {
|
|
9
|
+
if (!text) return '';
|
|
10
|
+
|
|
11
|
+
// Dynamically determine width (fallback to 80 if not detectable)
|
|
12
|
+
const width = overrideWidth || process.stdout.columns || 80;
|
|
13
|
+
|
|
14
|
+
const lines = text.split('\n');
|
|
15
|
+
const wrappedLines = lines.map(line => {
|
|
16
|
+
if (line.length <= width) return line;
|
|
17
|
+
|
|
18
|
+
// Split by whitespace but keep the whitespace as tokens
|
|
19
|
+
const tokens = line.split(/(\s+)/);
|
|
20
|
+
const result = [];
|
|
21
|
+
let currentLine = '';
|
|
22
|
+
|
|
23
|
+
for (const token of tokens) {
|
|
24
|
+
if (!token) continue;
|
|
25
|
+
|
|
26
|
+
// If adding this token exceeds width
|
|
27
|
+
if ((currentLine + token).length > width) {
|
|
28
|
+
|
|
29
|
+
// If it's a long word (not whitespace) that exceeds the entire width
|
|
30
|
+
if (token.length > width && !/^\s+$/.test(token)) {
|
|
31
|
+
// If we have content in currentLine, push it first
|
|
32
|
+
if (currentLine) {
|
|
33
|
+
result.push(currentLine.trimEnd());
|
|
34
|
+
currentLine = '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Force-break the long word
|
|
38
|
+
let remainingWord = token;
|
|
39
|
+
while (remainingWord.length > width) {
|
|
40
|
+
result.push(remainingWord.substring(0, width));
|
|
41
|
+
remainingWord = remainingWord.substring(width);
|
|
42
|
+
}
|
|
43
|
+
currentLine = remainingWord;
|
|
44
|
+
}
|
|
45
|
+
// If it's whitespace that causes overflow, just wrap to next line
|
|
46
|
+
else if (/^\s+$/.test(token)) {
|
|
47
|
+
if (currentLine) {
|
|
48
|
+
result.push(currentLine.trimEnd());
|
|
49
|
+
currentLine = '';
|
|
50
|
+
}
|
|
51
|
+
// Do not start a new line with just spaces if it was leading spaces for a wrapped line
|
|
52
|
+
// (Unless they are the very first spaces on a line, which we handled)
|
|
53
|
+
}
|
|
54
|
+
// If it's a normal word that exceeds width, push currentLine and start new with this word
|
|
55
|
+
else {
|
|
56
|
+
if (currentLine) {
|
|
57
|
+
result.push(currentLine.trimEnd());
|
|
58
|
+
}
|
|
59
|
+
currentLine = token;
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
// Just append the token (word or whitespace)
|
|
63
|
+
currentLine += token;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (currentLine) result.push(currentLine.trimEnd());
|
|
68
|
+
return result.join('\n');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return wrappedLines.join('\n');
|
|
72
|
+
}
|
package/src/utils/ui.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { formatSecondary } from './logger.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A minimalist terminal spinner that uses text-based dot animations.
|
|
5
|
+
*/
|
|
6
|
+
export class Spinner {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.frames = ['', '.', '..', '...'];
|
|
9
|
+
this.interval = null;
|
|
10
|
+
this.currentFrame = 0;
|
|
11
|
+
this.text = '';
|
|
12
|
+
this.isSpinning = false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Starts the spinner with a base message.
|
|
17
|
+
*/
|
|
18
|
+
start(text) {
|
|
19
|
+
if (this.isSpinning) this.stop(true);
|
|
20
|
+
process.stdout.write('\x1b[?25l'); // Hide cursor
|
|
21
|
+
this.text = text;
|
|
22
|
+
this.isSpinning = true;
|
|
23
|
+
this.render();
|
|
24
|
+
this.interval = setInterval(() => {
|
|
25
|
+
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
|
|
26
|
+
this.render();
|
|
27
|
+
}, 400); // Slower interval for dot cycle
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Updates the base text message.
|
|
32
|
+
*/
|
|
33
|
+
update(text) {
|
|
34
|
+
this.text = text;
|
|
35
|
+
if (this.isSpinning) this.render();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Stops the spinner and restores the cursor.
|
|
40
|
+
* @param {boolean} clear If true, clears the entire line.
|
|
41
|
+
*/
|
|
42
|
+
stop(clear = true) {
|
|
43
|
+
if (!this.isSpinning) {
|
|
44
|
+
process.stdout.write('\x1b[?25h'); // Ensure cursor is shown
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
clearInterval(this.interval);
|
|
48
|
+
this.isSpinning = false;
|
|
49
|
+
if (clear) {
|
|
50
|
+
process.stdout.write('\r\x1b[K'); // Clear entire line
|
|
51
|
+
} else {
|
|
52
|
+
process.stdout.write('\n');
|
|
53
|
+
}
|
|
54
|
+
process.stdout.write('\x1b[?25h'); // Show cursor
|
|
55
|
+
this.currentFrame = 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @private
|
|
60
|
+
*/
|
|
61
|
+
render() {
|
|
62
|
+
const dots = this.frames[this.currentFrame];
|
|
63
|
+
// Renders the text followed by the cycling dots
|
|
64
|
+
process.stdout.write(`\r\x1b[K${formatSecondary(`${this.text}${dots}`)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const spinner = new Spinner();
|
package/index.js
DELETED