Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
name: Test (node ${{ matrix.node }} / ${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
# npm test = build + run every compiled test; the runner script keeps
# node --test working on Node 18 (glob expansion is a Node 21+ feature)
- run: npm test
- name: Offline demo smoke test
run: node dist/src/cli.js --demo
- name: CLI version check
run: node dist/src/cli.js --version

package:
name: Package hygiene
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
# prints the tarball contents so a wrong "files" whitelist fails loudly
- run: npm pack --dry-run
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 nullcache

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
35 changes: 34 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@
"description": "TypeScript port of CoreCoder — minimal AI coding agent with an AsyncGenerator event-stream core. Zero runtime dependencies.",
"license": "MIT",
"type": "module",
"keywords": [
"ai",
"coding-agent",
"claude-code",
"cli",
"llm",
"agent",
"typescript",
"openai-compatible",
"terminal"
],
"repository": {
"type": "git",
"url": "git+https://github.com/nullcache/corecoder-ts.git"
},
"bugs": {
"url": "https://github.com/nullcache/corecoder-ts/issues"
},
"homepage": "https://github.com/nullcache/corecoder-ts#readme",
"sideEffects": false,
"engines": {
"node": ">=18.17.0"
},
Expand All @@ -12,11 +32,24 @@
},
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"import": "./dist/src/index.js",
"default": "./dist/src/index.js"
}
},
"files": [
"dist/src",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc",
"start": "node dist/src/cli.js",
"demo": "node dist/src/cli.js --demo",
"test": "npm run build && node --test dist/tests/*.test.js",
"test": "npm run build && node scripts/run-tests.mjs",
"prepublishOnly": "npm run build",
"clean": "rm -rf dist"
},
"devDependencies": {
Expand Down
29 changes: 29 additions & 0 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env node
/**
* Cross-version test runner.
*
* `node --test <glob>` only expands glob patterns itself since Node 21, but
* this package claims engines >= 18.17 — on Node 18/20, and on Windows cmd
* where the shell never expands the pattern either, `dist/tests/*.test.js`
* is passed through literally and the run fails. Resolve the compiled test
* files here and hand node the explicit list instead, so `npm test` behaves
* the same on every supported Node version and platform.
*/
import { spawnSync } from 'node:child_process'
import { readdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const dir = fileURLToPath(new URL('../dist/tests/', import.meta.url))
const files = readdirSync(dir)
.filter(f => f.endsWith('.test.js'))
.sort()
.map(f => path.join(dir, f))

if (files.length === 0) {
console.error('run-tests: no compiled test files found in dist/tests/')
process.exit(1)
}

const res = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' })
process.exit(res.status ?? 1)
35 changes: 25 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { appendFileSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import path from 'node:path'
import readline from 'node:readline/promises'
Expand All @@ -21,7 +22,12 @@ import { StreamRenderer } from './render.js'
import { listSessions, loadSession, saveSession } from './session.js'
import { changedFiles } from './tools/edit.js'

const VERSION = '0.1.0'
// Single source of truth: the version in package.json, so a release bump
// can never drift out of sync with --version / the REPL banner. createRequire
// resolves relative to this file (dist/src/cli.js -> ../../package.json),
// which is the same layout in a published npm tarball.
const require = createRequire(import.meta.url)
export const VERSION: string = require('../../package.json').version
const HIST_PATH = path.join(os.homedir(), '.corecoder_ts_history')

// ---------------------------------------------------------------- colors
Expand Down Expand Up @@ -155,13 +161,15 @@ export async function main(): Promise<void> {
* owns Ctrl+C wiring: in the REPL readline's raw mode swallows ^C and emits a
* 'SIGINT' *event*, while one-shot mode gets the real process signal — two
* different hooks, one abort path.
* Returns the final text, or null if interrupted/errored.
* Returns the final text, plus whether it was streamed and — when null —
* whether the turn was cancelled (^C) or failed, so the caller can pick the
* right process exit code: 130 for interrupt, 1 for error.
*/
async function runTurn(
agent: Agent,
input: string,
ac: AbortController,
): Promise<{ text: string | null; streamed: boolean }> {
): Promise<{ text: string | null; streamed: boolean; aborted: boolean }> {
// Streamed text renders as markdown line-by-line (see render.ts). The
// renderer holds at most one partial line, flushed before tool banners.
const renderer = new StreamRenderer(s => process.stdout.write(s), useColor)
Expand All @@ -183,15 +191,16 @@ async function runTurn(
step = await gen.next()
}
renderer.flush()
return { text: step.value, streamed }
return { text: step.value, streamed, aborted: false }
} catch (e) {
renderer.flush()
if (e instanceof Error && e.name === 'AbortError') {
const aborted = e instanceof Error && e.name === 'AbortError'
if (aborted) {
console.log(yellow('\nInterrupted.'))
} else {
console.log(red(`\nError: ${e instanceof Error ? e.message : e}`))
}
return { text: null, streamed }
return { text: null, streamed, aborted }
}
}

Expand All @@ -200,17 +209,23 @@ function renderMarkdown(text: string): void {
new StreamRenderer(s => process.stdout.write(s), useColor).renderAll(text)
}

/** Exit code for a finished turn: 0 success, 130 interrupt (^C), 1 error. */
export function turnExitCode(result: { text: string | null; aborted: boolean }): number {
if (result.text !== null) return 0
return result.aborted ? 130 : 1
}

/** Non-interactive: run one prompt and exit. */
async function runOnce(agent: Agent, prompt: string): Promise<number> {
// No readline here, so ^C arrives as a real process signal.
const ac = new AbortController()
const onSigint = () => ac.abort()
process.once('SIGINT', onSigint)
try {
const { text, streamed } = await runTurn(agent, prompt, ac)
if (text === null) return 130
if (!streamed && text) renderMarkdown(text)
return 0
const { text, streamed, aborted } = await runTurn(agent, prompt, ac)
const code = turnExitCode({ text, aborted })
if (code === 0 && !streamed && text) renderMarkdown(text)
return code
} finally {
process.removeListener('SIGINT', onSigint)
}
Expand Down
22 changes: 22 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** CLI contract tests: exit codes and version sync. */

import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'

// importing cli.js is safe: isDirectRun guards against running main()
import { turnExitCode, VERSION } from '../src/cli.js'

test('turnExitCode: 0 success, 130 interrupt, 1 error', () => {
assert.equal(turnExitCode({ text: 'done', aborted: false }), 0)
assert.equal(turnExitCode({ text: 'done', aborted: true }), 0)
assert.equal(turnExitCode({ text: null, aborted: true }), 130)
assert.equal(turnExitCode({ text: null, aborted: false }), 1)
})

test('CLI version is read from package.json (single source of truth)', () => {
// from dist/tests/ the package.json is two levels up
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
assert.equal(VERSION, pkg.version)
assert.match(VERSION, /^\d+\.\d+\.\d+$/)
})