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.
@@ -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
+ }
@@ -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
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- console.log("Coming soon!");