cowork-cli 1.2.0 → 2.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 CHANGED
@@ -56,6 +56,7 @@ cwk "Explain the data flow in the engine/ models"
56
56
  - **Zero-Whitespace UI**: High-density terminal output designed for professionals. No fluff, no headers, just data.
57
57
  - **Interactive Feedback**: The AI can request clarifications via the `askUser` tool or trigger an interactive `[ Yes ] No` toggle using `askConfirm`.
58
58
  - **Smart Discovery**: Built-in `searchText`, `findFile`, and `projectTree` tools that respect your `.gitignore`.
59
+ - **Web Research**: Dynamically search the web (`webSearch`) and read documentation (`webFetch`) directly from the CLI.
59
60
  - **Surgical I/O**: Read entire files or specific line ranges (`readFileChunk`) with automatic binary detection.
60
61
  - **Piping Support**: Pipe logs or diffs directly into `cwk` for instant analysis.
61
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-cli",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "work with cowork",
5
5
  "bin": {
6
6
  "cwk": "bin/cli.js"
@@ -35,4 +35,4 @@
35
35
  "ipaddr.js": "^2.4.0",
36
36
  "openai": "^6.38.0"
37
37
  }
38
- }
38
+ }
@@ -181,6 +181,7 @@ export default class BaseModel {
181
181
  readFileChunk: 'peeking',
182
182
  searchText: 'searching',
183
183
  webFetch: 'fetching',
184
+ webSearch: 'searching web',
184
185
  findFile: 'finding',
185
186
  findDir: 'finding',
186
187
  listTools: 'listing'
@@ -189,7 +190,11 @@ export default class BaseModel {
189
190
  const label = toolLabels[name] || name;
190
191
  let displayArg = "";
191
192
 
192
- if (name === 'searchText') displayArg = `'${args.pattern}' in ${args.path}`;
193
+ if (name === 'searchText') {
194
+ const ctx = (args.context != null && args.context !== 2) ? ` [C${args.context}]` : '';
195
+ displayArg = `'${args.pattern}' in ${args.path}${ctx}`;
196
+ }
197
+ else if (name === 'webSearch') displayArg = `'${args.query}'`;
193
198
  else if (name === 'findFile' || name === 'findDir') displayArg = `'${args.pattern}' in ${args.dirPath || '.'}`;
194
199
  else if (name === 'readFileChunk') displayArg = `${args.filePath} [L${args.startLine}-${args.endLine}]`;
195
200
  else displayArg = args.url || args.filePath || args.dirPath || args.path || args.pattern || JSON.stringify(args);
@@ -4,6 +4,7 @@ import projectTree from './projectTree.js';
4
4
  import readFileChunk from './readFileChunk.js';
5
5
  import searchText from './searchText.js';
6
6
  import webFetch from './webFetch.js';
7
+ import webSearch from './webSearch.js';
7
8
  import listTools from './listTools.js';
8
9
  import findFile from './findFile.js';
9
10
  import findDir from './findDir.js';
@@ -73,13 +74,14 @@ export const toolDefinitions = [
73
74
  type: "function",
74
75
  function: {
75
76
  name: "searchText",
76
- description: "Regex search in files/folders. Supports recursion and .gitignore.",
77
+ description: "Regex search in files/folders. Returns matching lines with surrounding context lines. Supports recursion and .gitignore.",
77
78
  parameters: {
78
79
  type: "object",
79
80
  properties: {
80
81
  pattern: { type: "string", description: "Regex or text pattern." },
81
82
  path: { type: "string", description: "File or directory to search." },
82
- recursive: { type: "boolean", description: "Search subdirectories? (default: false)" }
83
+ recursive: { type: "boolean", description: "Search subdirectories? (default: false)" },
84
+ context: { type: "number", description: "Lines of context around each match (default: 2, max: 5)." }
83
85
  },
84
86
  required: ["pattern", "path"]
85
87
  }
@@ -99,6 +101,21 @@ export const toolDefinitions = [
99
101
  }
100
102
  }
101
103
  },
104
+ {
105
+ type: "function",
106
+ function: {
107
+ name: "webSearch",
108
+ description: "Search the web to find URLs, docs, and solutions. Returns a list of titles, URLs, and snippet summaries. Always use this first to find a URL before calling webFetch.",
109
+ parameters: {
110
+ type: "object",
111
+ properties: {
112
+ query: { type: "string", description: "The search term/query." },
113
+ limit: { type: "number", description: "Max results to return (default: 5, max: 20)." }
114
+ },
115
+ required: ["query"]
116
+ }
117
+ }
118
+ },
102
119
  {
103
120
  type: "function",
104
121
  function: {
@@ -182,6 +199,7 @@ const toolImplementations = {
182
199
  readFileChunk,
183
200
  searchText,
184
201
  webFetch,
202
+ webSearch,
185
203
  listTools,
186
204
  findFile,
187
205
  findDir,
@@ -30,9 +30,9 @@ export default async function listTools() {
30
30
  },
31
31
  {
32
32
  name: "searchText",
33
- usage: "searchText({ pattern: 'TODO', path: 'src/', recursive: true })",
34
- description: "Performs a regex search for text across files and directories. Respects .gitignore.",
35
- whenToUse: "To find variable usages, search for specific strings, or locate technical debt across the codebase."
33
+ usage: "searchText({ pattern: 'safePath', path: 'src/', recursive: true, context: 3 })",
34
+ description: "Performs a regex search for text across files and directories. Returns each match with surrounding context lines (default: 2, max: 5). Overlapping context windows are merged. Respects .gitignore.",
35
+ whenToUse: "To find variable usages, trace function calls, or locate patterns across the codebase. Increase context when you need more surrounding code per match."
36
36
  },
37
37
  {
38
38
  name: "webFetch",
@@ -40,6 +40,12 @@ export default async function listTools() {
40
40
  description: "Fetches and extracts clean text from a web URL, removing HTML clutter.",
41
41
  whenToUse: "To gather information from online documentation, API references, or external technical articles."
42
42
  },
43
+ {
44
+ name: "webSearch",
45
+ usage: "webSearch({ query: 'nodejs fetch api example', limit: 5 })",
46
+ description: "Searches the web dynamically and returns a list of titles, direct URLs, and snippet summaries (default: 5, max: 20).",
47
+ whenToUse: "When you need to search the web for external documentation, solutions, or API references before using webFetch to read a specific page."
48
+ },
43
49
  {
44
50
  name: "findFile",
45
51
  usage: "findFile({ pattern: 'config.*\\\\.js$', dirPath: 'src/', limit: 10 })",
@@ -63,6 +69,12 @@ export default async function listTools() {
63
69
  usage: "askUser({ question: 'What is the API endpoint for this service?' })",
64
70
  description: "Asks the user a specific question via the terminal and waits for a text response.",
65
71
  whenToUse: "When you need specific information, clarification, or feedback from the user that cannot be found in the codebase."
72
+ },
73
+ {
74
+ name: "askConfirm",
75
+ usage: "askConfirm({ question: 'Should I proceed with deleting this file?' })",
76
+ description: "Asks the user a yes/no question using an interactive toggle. Returns { confirmed: true } for yes, { confirmed: false } for no, or { confirmed: false, dismissed: true } on cancellation.",
77
+ whenToUse: "When only a boolean decision is needed from the user. Prefer this over askUser for simple yes/no choices."
66
78
  }
67
79
  ];
68
80
 
@@ -6,11 +6,12 @@ import { getIgnorePatterns, isSafeEntry, loadNestedIgnores, safePath } from '../
6
6
  const MAX_MATCHES_PER_FILE = 20;
7
7
  const MAX_TOTAL_MATCHES = 100;
8
8
  const MAX_DEPTH = 10;
9
+ const CONTEXT_LINES = 2;
9
10
 
10
11
  /**
11
12
  * Enhanced searchText tool with recursion, ignore rules, and safety limits.
12
13
  */
13
- export default async function searchText({ pattern, path: searchPath, recursive = false }) {
14
+ export default async function searchText({ pattern, path: searchPath, recursive = false, context = CONTEXT_LINES }) {
14
15
  let totalMatches = 0;
15
16
  let isTruncated = false;
16
17
 
@@ -66,12 +67,13 @@ export default async function searchText({ pattern, path: searchPath, recursive
66
67
  await walk(fullPath, depth + 1, childIgnores);
67
68
  }
68
69
  } else if (item.isFile()) {
69
- const fileMatches = await searchInFile(fullPath, regex);
70
+ const allowed = MAX_TOTAL_MATCHES - totalMatches;
71
+ const fileMatches = await searchInFile(fullPath, regex, context);
70
72
  if (fileMatches.length > 0) {
71
- const allowedInFile = Math.min(fileMatches.length, MAX_TOTAL_MATCHES - totalMatches);
73
+ const allowedInFile = Math.min(fileMatches.length, allowed);
72
74
  results.push({
73
75
  file: path.relative(process.cwd(), fullPath),
74
- matches: fileMatches.slice(0, allowedInFile)
76
+ blocks: fileMatches.slice(0, allowedInFile)
75
77
  });
76
78
  totalMatches += allowedInFile;
77
79
  if (fileMatches.length > allowedInFile) isTruncated = true;
@@ -81,12 +83,12 @@ export default async function searchText({ pattern, path: searchPath, recursive
81
83
  };
82
84
 
83
85
  if (stats.isFile()) {
84
- const fileMatches = await searchInFile(resolvedPath, regex);
86
+ const fileMatches = await searchInFile(resolvedPath, regex, context);
85
87
  if (fileMatches.length > 0) {
86
88
  const allowed = Math.min(fileMatches.length, MAX_TOTAL_MATCHES);
87
89
  results.push({
88
90
  file: path.relative(process.cwd(), resolvedPath),
89
- matches: fileMatches.slice(0, allowed)
91
+ blocks: fileMatches.slice(0, allowed)
90
92
  });
91
93
  totalMatches = allowed;
92
94
  if (fileMatches.length > allowed) isTruncated = true;
@@ -98,7 +100,7 @@ export default async function searchText({ pattern, path: searchPath, recursive
98
100
  if (results.length === 0) return "No matches found.";
99
101
 
100
102
  let output = results.map(res => {
101
- return `[${res.file}]\n${res.matches.join('\n')}`;
103
+ return `[${res.file}]\n${res.blocks.join('\n---\n')}`;
102
104
  }).join('\n');
103
105
 
104
106
  if (isTruncated) {
@@ -113,8 +115,11 @@ export default async function searchText({ pattern, path: searchPath, recursive
113
115
  }
114
116
  }
115
117
 
116
- async function searchInFile(filePath, regex) {
118
+ async function searchInFile(filePath, regex, contextLines = CONTEXT_LINES) {
119
+ const CTX = Math.min(Math.max(0, contextLines), 5);
120
+
117
121
  try {
122
+ // Binary check — read first 1KB
118
123
  const handle = await fs.open(filePath, 'r');
119
124
  const { bytesRead, buffer } = await handle.read(Buffer.alloc(1024), 0, 1024, 0);
120
125
  await handle.close();
@@ -125,16 +130,51 @@ async function searchInFile(filePath, regex) {
125
130
 
126
131
  const content = await fs.readFile(filePath, 'utf8');
127
132
  const lines = content.split('\n');
128
- const matches = [];
129
133
 
134
+ // Collect all matching line indices (0-based)
135
+ const matchIndices = [];
130
136
  for (let i = 0; i < lines.length; i++) {
131
137
  if (regex.test(lines[i])) {
132
- matches.push(`${i + 1}:${lines[i].trim()}`);
133
- if (matches.length >= MAX_MATCHES_PER_FILE) break;
138
+ matchIndices.push(i);
139
+ if (matchIndices.length >= MAX_MATCHES_PER_FILE * 3) break; // Internal safety cap
140
+ }
141
+ }
142
+
143
+ if (matchIndices.length === 0) return [];
144
+
145
+ // Build context windows, merging overlapping ones
146
+ const blocks = [];
147
+ let currentBlock = null;
148
+
149
+ for (const idx of matchIndices) {
150
+ const start = Math.max(0, idx - CTX);
151
+ const end = Math.min(lines.length - 1, idx + CTX);
152
+
153
+ if (currentBlock && start <= currentBlock.end + 1) {
154
+ // Overlapping or adjacent — merge into current block
155
+ currentBlock.end = Math.max(currentBlock.end, end);
156
+ currentBlock.matchIndices.add(idx);
157
+ } else {
158
+ if (currentBlock) blocks.push(formatBlock(lines, currentBlock));
159
+ if (blocks.length >= MAX_MATCHES_PER_FILE) break;
160
+ currentBlock = { start, end, matchIndices: new Set([idx]) };
134
161
  }
135
162
  }
136
- return matches;
163
+ if (currentBlock && blocks.length < MAX_MATCHES_PER_FILE) {
164
+ blocks.push(formatBlock(lines, currentBlock));
165
+ }
166
+
167
+ return blocks;
137
168
  } catch (err) {
138
169
  return [];
139
170
  }
140
171
  }
172
+
173
+ function formatBlock(lines, block) {
174
+ let output = "";
175
+ for (let i = block.start; i <= block.end; i++) {
176
+ const prefix = block.matchIndices.has(i) ? "► " : " ";
177
+ output += `${i + 1}:${prefix}${lines[i]}\n`;
178
+ }
179
+ return output.trim();
180
+ }
@@ -0,0 +1,78 @@
1
+ const TIMEOUT_MS = 10000;
2
+ const MAX_RESULTS_HARD_LIMIT = 20;
3
+
4
+ /**
5
+ * Searches the web using DuckDuckGo HTML (zero dependencies).
6
+ * Extracts title, clean URL, and snippet summary.
7
+ *
8
+ * @param {Object} args
9
+ * @param {string} args.query - The search query.
10
+ * @param {number} [args.limit=5] - Max results to return (max 20).
11
+ * @returns {Promise<string>} JSON string of search results or error message.
12
+ */
13
+ export default async function webSearch({ query, limit = 5 }) {
14
+ if (!query) return "Error: Search query cannot be empty.";
15
+
16
+ const maxLimit = Math.min(Math.max(1, limit), MAX_RESULTS_HARD_LIMIT);
17
+
18
+ try {
19
+ const controller = new AbortController();
20
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
21
+
22
+ const response = await fetch('https://html.duckduckgo.com/html/', {
23
+ method: 'POST',
24
+ signal: controller.signal,
25
+ headers: {
26
+ 'Content-Type': 'application/x-www-form-urlencoded',
27
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
28
+ },
29
+ body: `q=${encodeURIComponent(query)}`
30
+ });
31
+
32
+ clearTimeout(timeoutId);
33
+
34
+ if (!response.ok) {
35
+ throw new Error(`HTTP error! status: ${response.status}`);
36
+ }
37
+
38
+ const html = await response.text();
39
+ const results = [];
40
+
41
+ // Split HTML into result blocks.
42
+ // The slice(1) skips the header block before the first result.
43
+ const blocks = html.split('class="links_main links_deep result__body"').slice(1);
44
+
45
+ for (const block of blocks) {
46
+ if (results.length >= maxLimit) break;
47
+
48
+ const titleMatch = block.match(/<h2 class="result__title">[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>/i);
49
+ const snippetMatch = block.match(/<a class="result__snippet[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
50
+
51
+ if (titleMatch && snippetMatch) {
52
+ // Strip nested HTML tags (like <b> tags for highlighted keywords)
53
+ const title = titleMatch[1].replace(/<[^>]+>/g, '').trim();
54
+ const snippet = snippetMatch[2].replace(/<[^>]+>/g, '').trim();
55
+
56
+ // Clean DuckDuckGo's tracking wrapper from the URL
57
+ let url = snippetMatch[1];
58
+ if (url.startsWith('//duckduckgo.com/l/?uddg=')) {
59
+ url = decodeURIComponent(url.split('uddg=')[1].split('&')[0]);
60
+ }
61
+
62
+ results.push({ title, url, snippet });
63
+ }
64
+ }
65
+
66
+ if (results.length === 0) {
67
+ return "No results found.";
68
+ }
69
+
70
+ return JSON.stringify(results, null, 2);
71
+
72
+ } catch (err) {
73
+ if (err.name === 'AbortError') {
74
+ return `Error: Request timed out after ${TIMEOUT_MS}ms`;
75
+ }
76
+ return `Error searching web: ${err.message}`;
77
+ }
78
+ }