Skip to content

Commit 365f8ed

Browse files
snomiaoclaude
andcommitted
feat(agent): add coreutils (echo, cat, ls, pwd, wc, head, tail, grep, true, false)
Each command is a Command function using the CmdContext / AsyncIterable protocol, so they compose with the runtime's pipe engine. registerCoreutils() installs all of them on a CommandRegistry. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 102d931 commit 365f8ed

2 files changed

Lines changed: 262 additions & 0 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { CommandRegistryImpl } from '../runtime'
4+
import type { CmdContext } from '../types'
5+
import { collect, emptyIter, stringIter } from '../types'
6+
import { MemoryVFS } from '../vfs/memory'
7+
import { coreutils, registerCoreutils } from './coreutils'
8+
9+
function baseCtx(
10+
argv: string[],
11+
stdin: AsyncIterable<string> = emptyIter(),
12+
vfs = new MemoryVFS()
13+
): CmdContext {
14+
return {
15+
argv,
16+
stdin,
17+
env: new Map(),
18+
cwd: '/',
19+
vfs,
20+
signal: new AbortController().signal
21+
}
22+
}
23+
24+
describe('coreutils', () => {
25+
it('echo joins args with space', async () => {
26+
const r = await coreutils.echo(baseCtx(['echo', 'hello', 'world']))
27+
expect(await collect(r.stdout)).toBe('hello world\n')
28+
})
29+
30+
it('echo -n omits newline', async () => {
31+
const r = await coreutils.echo(baseCtx(['echo', '-n', 'hi']))
32+
expect(await collect(r.stdout)).toBe('hi')
33+
})
34+
35+
it('cat reads file', async () => {
36+
const fs = new MemoryVFS()
37+
await fs.write('/f', 'contents')
38+
const r = await coreutils.cat(baseCtx(['cat', '/f'], emptyIter(), fs))
39+
expect(await collect(r.stdout)).toBe('contents')
40+
})
41+
42+
it('cat passes through stdin with no args', async () => {
43+
const r = await coreutils.cat(baseCtx(['cat'], stringIter('passed\n')))
44+
expect(await collect(r.stdout)).toBe('passed\n')
45+
})
46+
47+
it('ls lists sorted entries', async () => {
48+
const fs = new MemoryVFS()
49+
await fs.write('/b', '')
50+
await fs.write('/a', '')
51+
await fs.write('/sub/x', '')
52+
const r = await coreutils.ls(baseCtx(['ls', '/'], emptyIter(), fs))
53+
expect(await collect(r.stdout)).toBe('a\nb\nsub/\n')
54+
})
55+
56+
it('pwd emits cwd', async () => {
57+
const r = await coreutils.pwd(baseCtx(['pwd']))
58+
expect(await collect(r.stdout)).toBe('/\n')
59+
})
60+
61+
it('wc counts lines, words, bytes', async () => {
62+
const r = await coreutils.wc(baseCtx(['wc'], stringIter('a\nb\nc\n')))
63+
expect(await collect(r.stdout)).toBe('3 3 6\n')
64+
})
65+
66+
it('head -n 2 keeps first 2', async () => {
67+
const r = await coreutils.head(
68+
baseCtx(['head', '-n', '2'], stringIter('1\n2\n3\n4\n'))
69+
)
70+
expect(await collect(r.stdout)).toBe('1\n2\n')
71+
})
72+
73+
it('tail -n 2 keeps last 2', async () => {
74+
const r = await coreutils.tail(
75+
baseCtx(['tail', '-n', '2'], stringIter('1\n2\n3\n4\n'))
76+
)
77+
expect(await collect(r.stdout)).toBe('3\n4\n')
78+
})
79+
80+
it('grep filters', async () => {
81+
const r = await coreutils.grep(
82+
baseCtx(['grep', 'foo'], stringIter('foo\nbar\nfood\n'))
83+
)
84+
expect(await collect(r.stdout)).toBe('foo\nfood\n')
85+
})
86+
87+
it('true exits 0, false exits 1', async () => {
88+
expect((await coreutils.true(baseCtx(['true']))).exitCode).toBe(0)
89+
expect((await coreutils.false(baseCtx(['false']))).exitCode).toBe(1)
90+
})
91+
92+
it('registerCoreutils registers all commands', () => {
93+
const reg = new CommandRegistryImpl()
94+
registerCoreutils(reg)
95+
expect(reg.list()).toEqual(
96+
[
97+
'cat',
98+
'echo',
99+
'false',
100+
'grep',
101+
'head',
102+
'ls',
103+
'pwd',
104+
'tail',
105+
'true',
106+
'wc'
107+
].sort()
108+
)
109+
})
110+
})
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import type { CmdContext, CmdResult, Command, CommandRegistry } from '../types'
2+
import { collect, emptyIter, lines, stringIter } from '../types'
3+
4+
function ok(stdout: AsyncIterable<string>, exitCode = 0): CmdResult {
5+
return { stdout, exitCode }
6+
}
7+
8+
function err(message: string, exitCode = 2): CmdResult {
9+
return { stdout: emptyIter(), exitCode, stderr: message }
10+
}
11+
12+
const echo: Command = async (ctx) => {
13+
const args = ctx.argv.slice(1)
14+
let newline = true
15+
if (args[0] === '-n') {
16+
newline = false
17+
args.shift()
18+
}
19+
const text = args.join(' ') + (newline ? '\n' : '')
20+
return ok(stringIter(text))
21+
}
22+
23+
const cat: Command = async (ctx) => {
24+
const paths = ctx.argv.slice(1)
25+
if (paths.length === 0) return ok(ctx.stdin)
26+
async function* gen(): AsyncIterable<string> {
27+
for (const p of paths) {
28+
yield await ctx.vfs.read(p)
29+
}
30+
}
31+
return ok(gen())
32+
}
33+
34+
const ls: Command = async (ctx) => {
35+
const path = ctx.argv[1] ?? ctx.cwd
36+
const entries = await ctx.vfs.list(path)
37+
const out =
38+
entries.map((e) => (e.type === 'dir' ? e.name + '/' : e.name)).join('\n') +
39+
(entries.length > 0 ? '\n' : '')
40+
return ok(stringIter(out))
41+
}
42+
43+
const pwd: Command = async (ctx) => ok(stringIter(ctx.cwd + '\n'))
44+
45+
const wc: Command = async (ctx) => {
46+
const data = await collect(ctx.stdin)
47+
const bytes = data.length
48+
const lineCount =
49+
data === '' ? 0 : data.split('\n').length - (data.endsWith('\n') ? 1 : 0)
50+
const words = data.split(/\s+/).filter((w) => w.length > 0).length
51+
return ok(stringIter(`${lineCount} ${words} ${bytes}\n`))
52+
}
53+
54+
function parseNFlag(
55+
argv: string[],
56+
defaultN: number
57+
): { n: number; rest: string[] } {
58+
const rest = argv.slice(1)
59+
let n = defaultN
60+
if (rest[0] === '-n') {
61+
const parsed = Number(rest[1])
62+
if (!Number.isFinite(parsed)) throw new Error('invalid -n value')
63+
n = parsed
64+
rest.splice(0, 2)
65+
} else if (rest[0]?.startsWith('-n')) {
66+
const parsed = Number(rest[0].slice(2))
67+
if (!Number.isFinite(parsed)) throw new Error('invalid -n value')
68+
n = parsed
69+
rest.shift()
70+
}
71+
return { n, rest }
72+
}
73+
74+
const head: Command = async (ctx) => {
75+
let n: number
76+
try {
77+
;({ n } = parseNFlag(ctx.argv, 10))
78+
} catch (e) {
79+
return err('usage: head [-n N]')
80+
}
81+
async function* gen(): AsyncIterable<string> {
82+
let i = 0
83+
for await (const line of lines(ctx.stdin)) {
84+
if (i >= n) break
85+
yield line + '\n'
86+
i++
87+
}
88+
}
89+
return ok(gen())
90+
}
91+
92+
const tail: Command = async (ctx) => {
93+
let n: number
94+
try {
95+
;({ n } = parseNFlag(ctx.argv, 10))
96+
} catch (e) {
97+
return err('usage: tail [-n N]')
98+
}
99+
const buf: string[] = []
100+
for await (const line of lines(ctx.stdin)) {
101+
buf.push(line)
102+
if (buf.length > n) buf.shift()
103+
}
104+
const out = buf.length > 0 ? buf.join('\n') + '\n' : ''
105+
return ok(stringIter(out))
106+
}
107+
108+
const grep: Command = async (ctx) => {
109+
const pattern = ctx.argv[1]
110+
if (!pattern) return err('usage: grep <pattern>')
111+
const re = new RegExp(pattern)
112+
async function* gen(): AsyncIterable<string> {
113+
let matched = false
114+
for await (const line of lines(ctx.stdin)) {
115+
if (re.test(line)) {
116+
yield line + '\n'
117+
matched = true
118+
}
119+
}
120+
void matched
121+
}
122+
return ok(gen())
123+
}
124+
125+
const trueCmd: Command = async () => ok(emptyIter(), 0)
126+
const falseCmd: Command = async () => ok(emptyIter(), 1)
127+
128+
export function registerCoreutils(registry: CommandRegistry): void {
129+
registry.register('echo', echo)
130+
registry.register('cat', cat)
131+
registry.register('ls', ls)
132+
registry.register('pwd', pwd)
133+
registry.register('wc', wc)
134+
registry.register('head', head)
135+
registry.register('tail', tail)
136+
registry.register('grep', grep)
137+
registry.register('true', trueCmd)
138+
registry.register('false', falseCmd)
139+
}
140+
141+
export const coreutils = {
142+
echo,
143+
cat,
144+
ls,
145+
pwd,
146+
wc,
147+
head,
148+
tail,
149+
grep,
150+
true: trueCmd,
151+
false: falseCmd
152+
} satisfies Record<string, (ctx: CmdContext) => Promise<CmdResult>>

0 commit comments

Comments
 (0)