cowork-cli 0.0.1 → 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.
- package/README.md +78 -0
- package/bin/cli.js +43 -0
- package/package.json +33 -5
- package/src/configs/config.json +11 -0
- package/src/configs/sys.txt +33 -0
- package/src/engine/client.js +26 -0
- package/src/engine/models/BaseModel.js +228 -0
- package/src/engine/models/default.js +8 -0
- package/src/engine/models/gemini.js +20 -0
- package/src/engine/run.js +50 -0
- package/src/engine/tools/askConfirm.js +34 -0
- package/src/engine/tools/askUser.js +32 -0
- package/src/engine/tools/findDir.js +73 -0
- package/src/engine/tools/findFile.js +74 -0
- package/src/engine/tools/index.js +204 -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 +56 -0
- package/src/utils/outputFormatter.js +72 -0
- package/src/utils/ui.js +582 -0
- package/index.js +0 -2
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { Buffer } from 'buffer';
|
|
4
|
+
import { getIgnorePatterns, shouldIgnore } from '../../utils/fsUtils.js';
|
|
5
|
+
|
|
6
|
+
const MAX_MATCHES_PER_FILE = 20;
|
|
7
|
+
const MAX_TOTAL_MATCHES = 100;
|
|
8
|
+
const MAX_DEPTH = 10;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Enhanced searchText tool with recursion, ignore rules, and safety limits.
|
|
12
|
+
*/
|
|
13
|
+
export default async function searchText({ pattern, path: searchPath, recursive = false }) {
|
|
14
|
+
let totalMatches = 0;
|
|
15
|
+
let isTruncated = false;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const stats = await fs.stat(searchPath);
|
|
19
|
+
if (!pattern) return "Error: Search pattern cannot be empty.";
|
|
20
|
+
|
|
21
|
+
let regex;
|
|
22
|
+
try {
|
|
23
|
+
regex = new RegExp(pattern, 'i');
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return `Error: Invalid regex pattern '${pattern}': ${e.message}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ignoreList = await getIgnorePatterns();
|
|
29
|
+
const results = [];
|
|
30
|
+
|
|
31
|
+
const walk = async (currentPath, depth = 0) => {
|
|
32
|
+
if (totalMatches >= MAX_TOTAL_MATCHES) {
|
|
33
|
+
isTruncated = true;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (depth > MAX_DEPTH) return;
|
|
38
|
+
|
|
39
|
+
let items;
|
|
40
|
+
try {
|
|
41
|
+
items = await fs.readdir(currentPath, { withFileTypes: true });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return; // Skip unreadable directories
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const item of items) {
|
|
47
|
+
if (totalMatches >= MAX_TOTAL_MATCHES) {
|
|
48
|
+
isTruncated = true;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (shouldIgnore(item.name, ignoreList)) continue;
|
|
53
|
+
|
|
54
|
+
const fullPath = path.join(currentPath, item.name);
|
|
55
|
+
|
|
56
|
+
if (item.isDirectory()) {
|
|
57
|
+
if (recursive || depth === 0) {
|
|
58
|
+
await walk(fullPath, depth + 1);
|
|
59
|
+
}
|
|
60
|
+
} else if (item.isFile()) {
|
|
61
|
+
const fileMatches = await searchInFile(fullPath, regex);
|
|
62
|
+
if (fileMatches.length > 0) {
|
|
63
|
+
const allowedInFile = Math.min(fileMatches.length, MAX_TOTAL_MATCHES - totalMatches);
|
|
64
|
+
results.push({
|
|
65
|
+
file: path.relative(process.cwd(), fullPath),
|
|
66
|
+
matches: fileMatches.slice(0, allowedInFile)
|
|
67
|
+
});
|
|
68
|
+
totalMatches += allowedInFile;
|
|
69
|
+
if (fileMatches.length > allowedInFile) isTruncated = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (stats.isFile()) {
|
|
76
|
+
const fileMatches = await searchInFile(searchPath, regex);
|
|
77
|
+
if (fileMatches.length > 0) {
|
|
78
|
+
const allowed = Math.min(fileMatches.length, MAX_TOTAL_MATCHES);
|
|
79
|
+
results.push({
|
|
80
|
+
file: path.relative(process.cwd(), searchPath),
|
|
81
|
+
matches: fileMatches.slice(0, allowed)
|
|
82
|
+
});
|
|
83
|
+
totalMatches = allowed;
|
|
84
|
+
if (fileMatches.length > allowed) isTruncated = true;
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
await walk(searchPath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (results.length === 0) return "No matches found.";
|
|
91
|
+
|
|
92
|
+
let output = results.map(res => {
|
|
93
|
+
return `[${res.file}]\n${res.matches.join('\n')}`;
|
|
94
|
+
}).join('\n');
|
|
95
|
+
|
|
96
|
+
if (isTruncated) {
|
|
97
|
+
output += `\n[Warning: Truncated at ${MAX_TOTAL_MATCHES} matches]`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return output;
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err.code === 'ENOENT') return `Error: Path not found at '${searchPath}'.`;
|
|
105
|
+
return `Error searching text: ${err.message}`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function searchInFile(filePath, regex) {
|
|
110
|
+
try {
|
|
111
|
+
const handle = await fs.open(filePath, 'r');
|
|
112
|
+
const { bytesRead, buffer } = await handle.read(Buffer.alloc(1024), 0, 1024, 0);
|
|
113
|
+
await handle.close();
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
116
|
+
if (buffer[i] === 0) return []; // Skip binary
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
120
|
+
const lines = content.split('\n');
|
|
121
|
+
const matches = [];
|
|
122
|
+
|
|
123
|
+
for (let i = 0; i < lines.length; i++) {
|
|
124
|
+
if (regex.test(lines[i])) {
|
|
125
|
+
matches.push(`${i + 1}:${lines[i].trim()}`);
|
|
126
|
+
if (matches.length >= MAX_MATCHES_PER_FILE) break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return matches;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -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.MODEL_NAME,
|
|
21
|
+
model_url: envConfig.CWK_MODEL_URL || envConfig.MODEL_URL,
|
|
22
|
+
model_api_key: envConfig.CWK_MODEL_API_KEY || envConfig.MODEL_API_KEY,
|
|
23
|
+
model_type: envConfig.CWK_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,56 @@
|
|
|
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
|
+
config = {
|
|
13
|
+
accents: {
|
|
14
|
+
main: '#7BA5DA',
|
|
15
|
+
tool: '#F2CF6E',
|
|
16
|
+
data: '#C2C6C5',
|
|
17
|
+
success: '#7AC391',
|
|
18
|
+
error: '#E07070',
|
|
19
|
+
dim: '#606060',
|
|
20
|
+
header: '#A37ACC',
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hexToAnsi(hex) {
|
|
26
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
27
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
28
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
29
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const reset = '\x1b[0m';
|
|
33
|
+
|
|
34
|
+
const colors = {
|
|
35
|
+
main: hexToAnsi(config.accents.main),
|
|
36
|
+
tool: hexToAnsi(config.accents.tool),
|
|
37
|
+
data: hexToAnsi(config.accents.data),
|
|
38
|
+
success: hexToAnsi(config.accents.success),
|
|
39
|
+
error: hexToAnsi(config.accents.error),
|
|
40
|
+
dim: hexToAnsi(config.accents.dim),
|
|
41
|
+
header: hexToAnsi(config.accents.header),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const formatMain = (text) => `${colors.main}${text}${reset}`;
|
|
45
|
+
export const formatSecondary = (text) => `${colors.tool}${text}${reset}`;
|
|
46
|
+
export const formatNormal = (text) => `${colors.data}${text}${reset}`;
|
|
47
|
+
export const formatError = (text) => `${colors.error}${text}${reset}`;
|
|
48
|
+
export const formatDim = (text) => `${colors.dim}${text}${reset}`;
|
|
49
|
+
export const formatHeader = (text) => `${colors.header}${text}${reset}`;
|
|
50
|
+
|
|
51
|
+
export const logger = {
|
|
52
|
+
main: (msg) => console.log(formatMain(msg)),
|
|
53
|
+
secondary: (msg) => console.log(formatSecondary(msg)),
|
|
54
|
+
normal: (msg) => console.log(formatNormal(msg)),
|
|
55
|
+
error: (msg) => console.error(formatError(msg)),
|
|
56
|
+
};
|
|
@@ -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
|
+
}
|