savant-code 0.0.9

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.
Files changed (5) hide show
  1. package/README.md +107 -0
  2. package/http.js +439 -0
  3. package/index.js +34 -0
  4. package/launcher.js +1216 -0
  5. package/package.json +56 -0
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # The most powerful coding agent
2
+
3
+ SavantCode is a CLI tool that writes code for you.
4
+
5
+ 1. Run `savant-code` from your project directory
6
+ 2. Tell it what to do
7
+ 3. It will read and write to files and run commands to produce the code you want
8
+
9
+ Note: SavantCode will run commands in your terminal as it deems necessary to fulfill your request.
10
+
11
+ ## Installation
12
+
13
+ To install SavantCode, run:
14
+
15
+ ```bash
16
+ npm install -g savant-code
17
+ ```
18
+
19
+ (Use `sudo` if you get a permission error.)
20
+
21
+ ## Usage
22
+
23
+ After installation, you can start SavantCode by running:
24
+
25
+ ```bash
26
+ savant-code [project-directory]
27
+ ```
28
+
29
+ If no project directory is specified, SavantCode will use the current directory.
30
+
31
+ Once running, simply chat with SavantCode to say what coding task you want done.
32
+
33
+ ## Features
34
+
35
+ - Understands your whole codebase
36
+ - Creates and edits multiple files based on your request
37
+ - Can run your tests or type checker or linter; can install packages
38
+ - It's powerful: ask SavantCode to keep working until it reaches a condition and it will.
39
+
40
+ Our users regularly use SavantCode to implement new features, write unit tests, refactor code,write scripts, or give advice.
41
+
42
+ ## Knowledge Files
43
+
44
+ To unlock the full benefits of modern LLMs, we recommend storing knowledge alongside your code. Add a `knowledge.md` file anywhere in your project to provide helpful context, guidance, and tips for the LLM as it performs tasks for you.
45
+
46
+ SavantCode can fluently read and write files, so it will add knowledge as it goes. You don't need to write knowledge manually!
47
+
48
+ Some have said every change should be paired with a unit test. In 2024, every change should come with a knowledge update!
49
+
50
+ ## Tips
51
+
52
+ 1. Type '/help' or just '/' to see available commands.
53
+ 2. Create a `knowledge.md` file and collect specific points of advice. The assistant will use this knowledge to improve its responses.
54
+ 3. Type `undo` or `redo` to revert or reapply file changes from the conversation.
55
+ 4. Press `Esc` or `Ctrl+C` while SavantCode is generating a response to stop it.
56
+
57
+ ## Troubleshooting
58
+
59
+ ### Permission Errors
60
+
61
+ If you are getting permission errors during installation, try using sudo:
62
+
63
+ ```
64
+ sudo npm install -g savant-code
65
+ ```
66
+
67
+ If you still have errors, it's a good idea to [reinstall Node](https://nodejs.org/en/download).
68
+
69
+ ### Corporate Proxy / Firewall
70
+
71
+ If you see `Failed to download savant-code: Request timeout` or `Failed to determine latest version`, you may be behind a corporate proxy or firewall.
72
+
73
+ SavantCode respects standard proxy environment variables. Set `HTTPS_PROXY` to route traffic through your proxy:
74
+
75
+ **Linux / macOS (bash/zsh):**
76
+ ```bash
77
+ export HTTPS_PROXY=http://your-proxy-server:port
78
+ savant-code
79
+ ```
80
+
81
+ **Windows (PowerShell):**
82
+ ```powershell
83
+ $env:HTTPS_PROXY = "http://your-proxy-server:port"
84
+ savant-code
85
+ ```
86
+
87
+ **Windows (CMD):**
88
+ ```cmd
89
+ set HTTPS_PROXY=http://your-proxy-server:port
90
+ savant-code
91
+ ```
92
+
93
+ To make it permanent, add the `export` or `set` line to your shell profile (e.g. `~/.bashrc`, `~/.zshrc`, or Windows System Environment Variables).
94
+
95
+ **Supported environment variables:**
96
+
97
+ | Variable | Purpose |
98
+ |---|---|
99
+ | `HTTPS_PROXY` / `https_proxy` | Proxy for HTTPS requests (recommended) |
100
+ | `HTTP_PROXY` / `http_proxy` | Fallback proxy for HTTP requests |
101
+ | `NO_PROXY` / `no_proxy` | Comma-separated list of hostnames to bypass the proxy (port suffixes are ignored) |
102
+
103
+ Both `http://` and `https://` proxy URLs are supported. Proxy authentication is supported via URL credentials (e.g. `http://user:password@proxy:port`).
104
+
105
+ ## Feedback
106
+
107
+ We value your input! Please email your feedback to `founders@savant-code.com`. Thank you for using SavantCode!
package/http.js ADDED
@@ -0,0 +1,439 @@
1
+ const http = require('http')
2
+ const https = require('https')
3
+ const fs = require('fs')
4
+ const { pipeline } = require('stream/promises')
5
+ const tls = require('tls')
6
+
7
+ function createReleaseHttpClient({
8
+ env = process.env,
9
+ userAgent,
10
+ requestTimeout,
11
+ httpModule = http,
12
+ httpsModule = https,
13
+ fsModule = fs,
14
+ pipelineFn = pipeline,
15
+ tlsModule = tls,
16
+ }) {
17
+ function getProxyUrl(protocol = 'https:') {
18
+ if (protocol === 'http:') {
19
+ return (
20
+ env.HTTP_PROXY ||
21
+ env.http_proxy ||
22
+ env.HTTPS_PROXY ||
23
+ env.https_proxy ||
24
+ null
25
+ )
26
+ }
27
+ return (
28
+ env.HTTPS_PROXY ||
29
+ env.https_proxy ||
30
+ env.HTTP_PROXY ||
31
+ env.http_proxy ||
32
+ null
33
+ )
34
+ }
35
+
36
+ function shouldBypassProxy(hostname) {
37
+ const noProxy = env.NO_PROXY || env.no_proxy || ''
38
+ if (!noProxy) return false
39
+
40
+ const domains = noProxy
41
+ .split(',')
42
+ .map((domain) => domain.trim().toLowerCase().replace(/:\d+$/, ''))
43
+ const host = hostname.toLowerCase()
44
+
45
+ return domains.some((domain) => {
46
+ if (domain === '*') return true
47
+ if (domain.startsWith('.')) {
48
+ return host.endsWith(domain) || host === domain.slice(1)
49
+ }
50
+ return host === domain || host.endsWith(`.${domain}`)
51
+ })
52
+ }
53
+
54
+ function connectThroughProxy(proxyUrl, targetHost, targetPort) {
55
+ return new Promise((resolve, reject) => {
56
+ const proxy = new URL(proxyUrl)
57
+ const isHttpsProxy = proxy.protocol === 'https:'
58
+ const connectOptions = {
59
+ hostname: proxy.hostname,
60
+ port: proxy.port || (isHttpsProxy ? 443 : 80),
61
+ method: 'CONNECT',
62
+ path: `${targetHost}:${targetPort}`,
63
+ headers: {
64
+ Host: `${targetHost}:${targetPort}`,
65
+ },
66
+ }
67
+
68
+ if (proxy.username || proxy.password) {
69
+ const auth = Buffer.from(
70
+ `${decodeURIComponent(proxy.username || '')}:${decodeURIComponent(
71
+ proxy.password || '',
72
+ )}`,
73
+ ).toString('base64')
74
+ connectOptions.headers['Proxy-Authorization'] = `Basic ${auth}`
75
+ }
76
+
77
+ const transport = isHttpsProxy ? httpsModule : httpModule
78
+ const req = transport.request(connectOptions)
79
+
80
+ req.on('connect', (res, socket) => {
81
+ if (res.statusCode === 200) {
82
+ resolve(socket)
83
+ return
84
+ }
85
+
86
+ socket.destroy()
87
+ reject(new Error(`Proxy CONNECT failed with status ${res.statusCode}`))
88
+ })
89
+
90
+ req.on('error', (error) => {
91
+ reject(new Error(`Proxy connection failed: ${error.message}`))
92
+ })
93
+
94
+ req.setTimeout(requestTimeout, () => {
95
+ req.destroy()
96
+ reject(new Error('Proxy connection timeout.'))
97
+ })
98
+
99
+ req.end()
100
+ })
101
+ }
102
+
103
+ async function buildRequest(url, options = {}) {
104
+ const parsedUrl = new URL(url)
105
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
106
+ throw new Error(`Unsupported URL protocol: ${parsedUrl.protocol}`)
107
+ }
108
+ const isHttps = parsedUrl.protocol === 'https:'
109
+ const reqOptions = {
110
+ hostname: parsedUrl.hostname,
111
+ port: parsedUrl.port || (isHttps ? 443 : 80),
112
+ path: parsedUrl.pathname + parsedUrl.search,
113
+ headers: {
114
+ 'User-Agent': userAgent,
115
+ ...options.headers,
116
+ },
117
+ }
118
+
119
+ const proxyUrl = getProxyUrl(parsedUrl.protocol)
120
+ if (!proxyUrl || shouldBypassProxy(parsedUrl.hostname)) {
121
+ return { transport: isHttps ? httpsModule : httpModule, reqOptions }
122
+ }
123
+
124
+ const proxy = new URL(proxyUrl)
125
+ if (!['http:', 'https:'].includes(proxy.protocol)) {
126
+ throw new Error(`Unsupported proxy protocol: ${proxy.protocol}`)
127
+ }
128
+
129
+ if (!isHttps) {
130
+ reqOptions.hostname = proxy.hostname
131
+ reqOptions.port = proxy.port || (proxy.protocol === 'https:' ? 443 : 80)
132
+ reqOptions.path = parsedUrl.href
133
+ reqOptions.headers.Host = parsedUrl.host
134
+ if (proxy.username || proxy.password) {
135
+ const auth = Buffer.from(
136
+ `${decodeURIComponent(proxy.username || '')}:${decodeURIComponent(proxy.password || '')}`,
137
+ ).toString('base64')
138
+ reqOptions.headers['Proxy-Authorization'] = `Basic ${auth}`
139
+ }
140
+ return {
141
+ transport: proxy.protocol === 'https:' ? httpsModule : httpModule,
142
+ reqOptions,
143
+ }
144
+ }
145
+
146
+ const tunnelSocket = await connectThroughProxy(
147
+ proxyUrl,
148
+ parsedUrl.hostname,
149
+ parsedUrl.port || 443,
150
+ )
151
+
152
+ class TunnelAgent extends httpsModule.Agent {
153
+ createConnection(_options, callback) {
154
+ const secureSocket = tlsModule.connect({
155
+ socket: tunnelSocket,
156
+ servername: parsedUrl.hostname,
157
+ })
158
+
159
+ if (typeof callback === 'function') {
160
+ if (typeof secureSocket.once === 'function') {
161
+ let settled = false
162
+ const finish = (error) => {
163
+ if (settled) return
164
+ settled = true
165
+ callback(error || null, error ? undefined : secureSocket)
166
+ }
167
+
168
+ secureSocket.once('secureConnect', () => finish(null))
169
+ secureSocket.once('error', (error) => finish(error))
170
+ } else {
171
+ callback(null, secureSocket)
172
+ }
173
+ }
174
+
175
+ return secureSocket
176
+ }
177
+ }
178
+
179
+ reqOptions.agent = new TunnelAgent({ keepAlive: false })
180
+ return { transport: httpsModule, reqOptions }
181
+ }
182
+
183
+ async function httpGet(url, options = {}) {
184
+ const redirectCount = options.redirectCount || 0
185
+ const { transport, reqOptions } = await buildRequest(url, options)
186
+
187
+ return new Promise((resolve, reject) => {
188
+ const req = transport.get(reqOptions, (res) => {
189
+ if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
190
+ res.resume()
191
+
192
+ if (!res.headers.location) {
193
+ reject(
194
+ new Error(`Redirect ${res.statusCode} missing Location header.`),
195
+ )
196
+ return
197
+ }
198
+ if (redirectCount >= (options.maxRedirects ?? 10)) {
199
+ reject(new Error('Too many redirects.'))
200
+ return
201
+ }
202
+
203
+ httpGet(new URL(res.headers.location, url).href, {
204
+ ...options,
205
+ redirectCount: redirectCount + 1,
206
+ })
207
+ .then(resolve)
208
+ .catch(reject)
209
+ return
210
+ }
211
+
212
+ res.requestUrl = url
213
+ resolve(res)
214
+ })
215
+
216
+ req.on('error', (error) => {
217
+ error.requestUrl = url
218
+ reject(error)
219
+ })
220
+ req.setTimeout(options.timeout || requestTimeout, () => {
221
+ req.destroy()
222
+ const error = new Error('Request timeout.')
223
+ error.code = 'ETIMEDOUT'
224
+ error.requestUrl = url
225
+ reject(error)
226
+ })
227
+ })
228
+ }
229
+
230
+ function getFileSize(filePath) {
231
+ try {
232
+ return fsModule.statSync(filePath).size
233
+ } catch (error) {
234
+ if (error.code === 'ENOENT') return 0
235
+ throw error
236
+ }
237
+ }
238
+
239
+ function removeFileIfPresent(filePath) {
240
+ try {
241
+ fsModule.unlinkSync(filePath)
242
+ } catch (error) {
243
+ if (error.code !== 'ENOENT') throw error
244
+ }
245
+ }
246
+
247
+ function throwResponseError(res, message, requestUrl, retryable) {
248
+ res.resume()
249
+ const error = new Error(message)
250
+ error.statusCode = res.statusCode
251
+ error.retryable = retryable
252
+ error.requestUrl = requestUrl
253
+ throw error
254
+ }
255
+
256
+ function parseContentRange(value) {
257
+ if (!value) return null
258
+
259
+ const completeMatch = value.match(/^bytes (\d+)-(\d+)\/(\d+)$/i)
260
+ if (completeMatch) {
261
+ const start = Number(completeMatch[1])
262
+ const end = Number(completeMatch[2])
263
+ const total = Number(completeMatch[3])
264
+ if (start > end || end >= total) return null
265
+ return {
266
+ start,
267
+ end,
268
+ total,
269
+ }
270
+ }
271
+
272
+ const unsatisfiedMatch = value.match(/^bytes \*\/(\d+)$/i)
273
+ if (unsatisfiedMatch) {
274
+ return { start: null, end: null, total: Number(unsatisfiedMatch[1]) }
275
+ }
276
+
277
+ return null
278
+ }
279
+
280
+ function isRetryableStatus(statusCode) {
281
+ return (
282
+ statusCode === 408 ||
283
+ statusCode === 425 ||
284
+ statusCode === 429 ||
285
+ statusCode >= 500
286
+ )
287
+ }
288
+
289
+ async function downloadFile(url, destinationPath, options = {}) {
290
+ let resumedFrom = getFileSize(destinationPath)
291
+ let totalBytes = null
292
+
293
+ const headers = { ...options.headers }
294
+ if (resumedFrom > 0) {
295
+ headers.Range = `bytes=${resumedFrom}-`
296
+ }
297
+
298
+ const res = await httpGet(url, { ...options, headers })
299
+ const responseUrl = res.requestUrl || url
300
+ const contentRange = parseContentRange(res.headers['content-range'])
301
+
302
+ if (res.statusCode === 416 && contentRange?.total === resumedFrom) {
303
+ res.resume()
304
+ return {
305
+ downloadedBytes: resumedFrom,
306
+ totalBytes: resumedFrom,
307
+ resumedFrom,
308
+ responseUrl,
309
+ }
310
+ }
311
+
312
+ if (res.statusCode === 416) {
313
+ removeFileIfPresent(destinationPath)
314
+ throwResponseError(
315
+ res,
316
+ 'Saved partial download is no longer valid',
317
+ responseUrl,
318
+ true,
319
+ )
320
+ }
321
+
322
+ let writeFlags
323
+ let expectedResponseBytes = null
324
+ if (res.statusCode === 206) {
325
+ if (!contentRange || contentRange.start !== resumedFrom) {
326
+ removeFileIfPresent(destinationPath)
327
+ throwResponseError(
328
+ res,
329
+ `Download resume mismatch: expected byte ${resumedFrom}`,
330
+ responseUrl,
331
+ true,
332
+ )
333
+ }
334
+ totalBytes = contentRange.total
335
+ expectedResponseBytes = contentRange.end - contentRange.start + 1
336
+ writeFlags = 'a'
337
+ } else if (res.statusCode === 200) {
338
+ // The server may ignore Range. Restart safely instead of appending a
339
+ // complete response to the existing partial archive.
340
+ resumedFrom = 0
341
+ totalBytes = Number(res.headers['content-length']) || null
342
+ writeFlags = 'w'
343
+ } else {
344
+ throwResponseError(
345
+ res,
346
+ `Download failed: HTTP ${res.statusCode}`,
347
+ responseUrl,
348
+ isRetryableStatus(res.statusCode),
349
+ )
350
+ }
351
+
352
+ let downloadedBytes = resumedFrom
353
+ res.on('data', (chunk) => {
354
+ downloadedBytes += chunk.length
355
+ options.onProgress?.({ downloadedBytes, totalBytes, resumedFrom })
356
+ })
357
+
358
+ try {
359
+ await pipelineFn(
360
+ res,
361
+ fsModule.createWriteStream(destinationPath, { flags: writeFlags }),
362
+ )
363
+ } catch (error) {
364
+ error.requestUrl ||= responseUrl
365
+ error.downloadedBytes = getFileSize(destinationPath)
366
+ error.totalBytes = totalBytes
367
+ throw error
368
+ }
369
+
370
+ downloadedBytes = getFileSize(destinationPath)
371
+ const responseBytes = downloadedBytes - resumedFrom
372
+ if (
373
+ expectedResponseBytes !== null &&
374
+ responseBytes !== expectedResponseBytes
375
+ ) {
376
+ if (responseBytes > expectedResponseBytes) {
377
+ removeFileIfPresent(destinationPath)
378
+ }
379
+ const error = new Error(
380
+ `Download incomplete: response contained ${responseBytes} of ${expectedResponseBytes} bytes`,
381
+ )
382
+ error.code = 'EINCOMPLETE'
383
+ error.retryable = true
384
+ error.requestUrl = responseUrl
385
+ error.downloadedBytes = getFileSize(destinationPath)
386
+ error.totalBytes = totalBytes
387
+ throw error
388
+ }
389
+ if (totalBytes !== null && downloadedBytes !== totalBytes) {
390
+ const error = new Error(
391
+ `Download incomplete: received ${downloadedBytes} of ${totalBytes} bytes`,
392
+ )
393
+ error.code = 'EINCOMPLETE'
394
+ error.retryable = true
395
+ error.requestUrl = responseUrl
396
+ error.downloadedBytes = downloadedBytes
397
+ error.totalBytes = totalBytes
398
+ throw error
399
+ }
400
+
401
+ return { downloadedBytes, totalBytes, resumedFrom, responseUrl }
402
+ }
403
+
404
+ async function withRetries(
405
+ operation,
406
+ {
407
+ maxAttempts = 1,
408
+ baseDelayMs = 1000,
409
+ shouldRetry = () => true,
410
+ onRetry = () => {},
411
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
412
+ },
413
+ ) {
414
+ for (let attempt = 1; ; attempt++) {
415
+ try {
416
+ return await operation(attempt)
417
+ } catch (error) {
418
+ if (attempt >= maxAttempts || !shouldRetry(error)) {
419
+ throw error
420
+ }
421
+
422
+ const delayMs = baseDelayMs * 2 ** (attempt - 1)
423
+ await onRetry({ error, attempt, nextAttempt: attempt + 1, delayMs })
424
+ await sleep(delayMs)
425
+ }
426
+ }
427
+ }
428
+
429
+ return {
430
+ getProxyUrl,
431
+ downloadFile,
432
+ httpGet,
433
+ withRetries,
434
+ }
435
+ }
436
+
437
+ module.exports = {
438
+ createReleaseHttpClient,
439
+ }
package/index.js ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const packagedLauncherPath = path.join(__dirname, 'launcher.js')
7
+ const sourceLauncherPath = path.join(
8
+ __dirname,
9
+ '..',
10
+ 'release-core',
11
+ 'launcher.js',
12
+ )
13
+ // Published packages must not let an unrelated sibling path shadow their
14
+ // bundled launcher. Source checkouts only fall back when that copy is absent.
15
+ const { createLauncher } = require(
16
+ fs.existsSync(packagedLauncherPath)
17
+ ? packagedLauncherPath
18
+ : sourceLauncherPath,
19
+ )
20
+
21
+ const launcher = createLauncher({
22
+ packageName: 'savant-code',
23
+ displayName: 'SavantCode',
24
+ tempDownloadDirName: '.download-temp',
25
+ })
26
+
27
+ module.exports = launcher
28
+
29
+ if (require.main === module) {
30
+ launcher.main().catch((error) => {
31
+ console.error('❌ Unexpected error:', error.message)
32
+ process.exit(1)
33
+ })
34
+ }