Skip to content

Commit 8d5fd1e

Browse files
Merge pull request #2 from ai-action/feat/cli
2 parents 8dd75cf + 756cf51 commit 8d5fd1e

12 files changed

Lines changed: 436 additions & 57 deletions

File tree

.github/workflows/test.yml

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ permissions:
55
contents: read
66

77
jobs:
8-
test:
8+
unit:
99
runs-on: ubuntu-latest
1010
steps:
1111
- name: Checkout repository
@@ -30,3 +30,58 @@ jobs:
3030
uses: codecov/codecov-action@v6
3131
with:
3232
token: ${{ secrets.CODECOV_TOKEN }}
33+
34+
integration:
35+
runs-on: ubuntu-latest
36+
steps:
37+
- name: Checkout repository
38+
uses: actions/checkout@v6
39+
40+
- name: Use Node.js
41+
uses: actions/setup-node@v6
42+
with:
43+
cache: npm
44+
node-version-file: .nvmrc
45+
46+
- name: Install dependencies
47+
run: npm ci --prefer-offline
48+
49+
- name: Build package
50+
run: npm run build
51+
52+
- name: Setup Ollama
53+
uses: ai-action/setup-ollama@v2
54+
55+
- name: Install CLI
56+
run: |
57+
package_archive=$(npm pack | tail -n 1)
58+
npm install --global "./$package_archive"
59+
60+
- name: Verify CLI version
61+
run: |
62+
actual=$(code-ollama --version)
63+
printf '%s\n' "$actual"
64+
expected="$(node -p "require('./package.json').version")"
65+
grep -F "$expected" <<< "$actual"
66+
67+
- name: Verify CLI help
68+
run: |
69+
output=$(code-ollama --help)
70+
printf '%s\n' "$output"
71+
grep -F "code-ollama" <<< "$output"
72+
grep -F "run <model> <prompt>" <<< "$output"
73+
74+
- name: Cache model
75+
uses: actions/cache@v5
76+
with:
77+
path: ~/.ollama
78+
key: ${{ runner.os }}-ollama
79+
80+
- name: Pull model
81+
run: ollama pull qwen2.5:0.5b
82+
83+
- name: Verify CLI run
84+
run: |
85+
output=$(code-ollama run qwen2.5:0.5b "Repeat exactly this token and nothing else: INTEGRATION_PASS_7F3A")
86+
printf '%s\n' "$output"
87+
grep -F "INTEGRATION_PASS_7F3A" <<< "$output"

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Single-test examples:
4545
- TypeScript is `strict`; avoid implicit `any`
4646
- Use barrel files (`index.ts`) to consolidate related exports
4747
- Use `// v8 ignore` in tests to exclude unreachable entrypoint guards; use `vi.hoisted()` for mock variables accessed by `vi.mock()` hoisted scopes
48+
- Use Conventional Commits: type(scope): description
4849
- Create PR with `.github/PULL_REQUEST_TEMPLATE.md`
4950

5051
## Verification

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ npm install --global code-ollama
2424

2525
## Usage
2626

27-
Run the CLI:
27+
### TUI
28+
29+
Open the TUI:
2830

2931
```sh
3032
code-ollama
@@ -36,6 +38,27 @@ Or use the alias:
3638
collama
3739
```
3840

41+
### CLI
42+
43+
Show the version:
44+
45+
```sh
46+
code-ollama --version
47+
```
48+
49+
Show the help:
50+
51+
```sh
52+
code-ollama --help
53+
```
54+
55+
Run a one-off prompt:
56+
57+
```sh
58+
# code-ollama run <model> <prompt>
59+
code-ollama run gemma4 "review diff"
60+
```
61+
3962
## License
4063

4164
[MIT](https://github.com/ai-action/code-ollama/blob/master/LICENSE)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
},
1111
"scripts": {
1212
"build": "vite build",
13-
"start": "tsx --tsconfig tsconfig.test.json src/cli.tsx",
13+
"start": "tsx --tsconfig tsconfig.test.json src/cli.ts",
1414
"clean": "rm -rf coverage dist docs",
1515
"lint": "eslint .",
1616
"lint:fix": "npm run lint -- --fix",

src/cli.test.ts

Lines changed: 188 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,49 @@
11
import type { MockInstance } from 'vitest';
22

3-
const { clearScreen, outputHelp, parse, render } = vi.hoisted(() => ({
3+
type RunAction = (model: string, prompt: string) => Promise<void>;
4+
5+
const {
6+
clearScreen,
7+
createSystemMessage,
8+
executeTool,
9+
outputHelp,
10+
parse,
11+
renderApp,
12+
streamChat,
13+
} = vi.hoisted(() => ({
414
clearScreen: vi.fn(),
15+
createSystemMessage: vi.fn(() => ({
16+
role: 'system',
17+
content: 'system prompt',
18+
})),
19+
executeTool: vi.fn(),
520
outputHelp: vi.fn(),
621
parse: vi.fn(),
7-
render: vi.fn(),
22+
renderApp: vi.fn(),
23+
streamChat: vi.fn(),
24+
}));
25+
26+
const commandState = vi.hoisted(() => ({
27+
runAction: null as RunAction | null,
828
}));
929

10-
vi.mock('./utils', () => ({ screen: { clear: clearScreen } }));
11-
vi.mock('ink', () => ({ render }));
30+
vi.mock('./utils', () => ({
31+
agents: { createSystemMessage },
32+
ollama: { streamChat },
33+
screen: { clear: clearScreen },
34+
tools: { TOOLS: ['mock-tool'], executeTool },
35+
}));
36+
vi.mock('./tui', () => ({ renderApp }));
1237

1338
vi.mock('cac', () => ({
1439
default: () => ({
1540
version: vi.fn(),
1641
help: vi.fn(),
42+
command: vi.fn(() => ({
43+
action: vi.fn((callback: RunAction) => {
44+
commandState.runAction = callback;
45+
}),
46+
})),
1747
outputHelp,
1848
parse,
1949
}),
@@ -23,38 +53,183 @@ import { main } from './cli';
2353

2454
describe('cli', () => {
2555
let stdoutSpy: MockInstance<typeof process.stdout.write>;
56+
let stderrSpy: MockInstance<typeof process.stderr.write>;
2657

2758
beforeEach(() => {
2859
stdoutSpy = vi
2960
.spyOn(process.stdout, 'write')
3061
.mockImplementation(() => true);
62+
stderrSpy = vi
63+
.spyOn(process.stderr, 'write')
64+
.mockImplementation(() => true);
3165
});
3266

3367
afterEach(() => {
3468
vi.clearAllMocks();
3569
stdoutSpy.mockRestore();
70+
stderrSpy.mockRestore();
71+
process.exitCode = undefined;
3672
});
3773

38-
it('renders TUI with no args', () => {
39-
main([]);
74+
it('renders TUI with no args', async () => {
75+
await main([]);
4076
expect(clearScreen).toHaveBeenCalledOnce();
41-
expect(render).toHaveBeenCalledOnce();
77+
expect(renderApp).toHaveBeenCalledOnce();
4278
expect(parse).not.toHaveBeenCalled();
4379
});
4480

45-
it('calls parse with --help', () => {
46-
main(['--help']);
81+
it('calls parse with --help', async () => {
82+
await main(['--help']);
4783
expect(parse).toHaveBeenCalledWith(['node', 'code-ollama', '--help']);
4884
expect(outputHelp).not.toHaveBeenCalled();
4985
});
5086

51-
it('calls parse with --version', () => {
52-
main(['--version']);
87+
it('calls parse with --version', async () => {
88+
await main(['--version']);
5389
expect(parse).toHaveBeenCalledWith(['node', 'code-ollama', '--version']);
5490
});
5591

56-
it('calls parse with -v', () => {
57-
main(['-v']);
92+
it('calls parse with -v', async () => {
93+
await main(['-v']);
5894
expect(parse).toHaveBeenCalledWith(['node', 'code-ollama', '-v']);
5995
});
96+
97+
it('calls parse for run without rendering TUI', async () => {
98+
await main(['run', 'gemma4', 'review diff']);
99+
expect(renderApp).not.toHaveBeenCalled();
100+
expect(parse).toHaveBeenCalledWith([
101+
'node',
102+
'code-ollama',
103+
'run',
104+
'gemma4',
105+
'review diff',
106+
]);
107+
});
108+
109+
it('streams one-off run output with the provided model', async () => {
110+
streamChat.mockImplementationOnce(async function* () {
111+
await Promise.resolve();
112+
yield { type: 'content', content: 'Review complete.' };
113+
});
114+
115+
await commandState.runAction?.('gemma4', 'review diff');
116+
117+
expect(createSystemMessage).toHaveBeenCalledOnce();
118+
expect(streamChat).toHaveBeenCalledWith(
119+
[
120+
{ role: 'system', content: 'system prompt' },
121+
{ role: 'user', content: 'review diff' },
122+
],
123+
'gemma4',
124+
['mock-tool'],
125+
);
126+
expect(stdoutSpy).toHaveBeenNthCalledWith(1, 'Review complete.');
127+
expect(stdoutSpy).toHaveBeenNthCalledWith(2, '\n');
128+
});
129+
130+
it('executes tool calls and continues the run conversation', async () => {
131+
streamChat
132+
.mockImplementationOnce(async function* () {
133+
await Promise.resolve();
134+
yield {
135+
type: 'tool_calls',
136+
tool_calls: [
137+
{
138+
function: {
139+
name: 'run_shell',
140+
arguments: { command: 'git diff --stat' },
141+
},
142+
},
143+
],
144+
};
145+
})
146+
.mockImplementationOnce(async function* () {
147+
await Promise.resolve();
148+
yield { type: 'content', content: 'Diff reviewed.' };
149+
});
150+
executeTool.mockResolvedValueOnce({
151+
content: ' src/cli.tsx | 10 +++++++++-',
152+
});
153+
154+
await commandState.runAction?.('gemma4', 'review diff');
155+
156+
expect(executeTool).toHaveBeenCalledWith('run_shell', {
157+
command: 'git diff --stat',
158+
});
159+
expect(streamChat).toHaveBeenNthCalledWith(
160+
2,
161+
[
162+
{ role: 'system', content: 'system prompt' },
163+
{ role: 'user', content: 'review diff' },
164+
{ role: 'assistant', content: '' },
165+
{
166+
role: 'system',
167+
content: 'Tool run_shell result:\n src/cli.tsx | 10 +++++++++-',
168+
},
169+
],
170+
'gemma4',
171+
['mock-tool'],
172+
);
173+
expect(stdoutSpy).toHaveBeenNthCalledWith(1, 'Diff reviewed.');
174+
expect(stdoutSpy).toHaveBeenNthCalledWith(2, '\n');
175+
});
176+
177+
it('includes tool execution errors in the follow-up run conversation', async () => {
178+
streamChat
179+
.mockImplementationOnce(async function* () {
180+
await Promise.resolve();
181+
yield {
182+
type: 'tool_calls',
183+
tool_calls: [
184+
{
185+
function: {
186+
name: 'run_shell',
187+
arguments: { command: 'git diff --stat' },
188+
},
189+
},
190+
],
191+
};
192+
})
193+
.mockImplementationOnce(async function* () {
194+
await Promise.resolve();
195+
yield { type: 'content', content: 'Tool error handled.' };
196+
});
197+
executeTool.mockResolvedValueOnce({
198+
content: 'partial output',
199+
error: 'shell failed',
200+
});
201+
202+
await commandState.runAction?.('gemma4', 'review diff');
203+
204+
expect(streamChat).toHaveBeenNthCalledWith(
205+
2,
206+
[
207+
{ role: 'system', content: 'system prompt' },
208+
{ role: 'user', content: 'review diff' },
209+
{ role: 'assistant', content: '' },
210+
{
211+
role: 'system',
212+
content:
213+
'Tool run_shell result:\npartial output\nError: shell failed',
214+
},
215+
],
216+
'gemma4',
217+
['mock-tool'],
218+
);
219+
expect(stdoutSpy).toHaveBeenNthCalledWith(1, 'Tool error handled.');
220+
expect(stdoutSpy).toHaveBeenNthCalledWith(2, '\n');
221+
});
222+
223+
it('reports run errors and sets exit code', async () => {
224+
streamChat.mockImplementationOnce(async function* () {
225+
await Promise.resolve();
226+
throw new Error('Ollama unavailable');
227+
yield { type: 'content', content: '' };
228+
});
229+
230+
await commandState.runAction?.('gemma4', 'review diff');
231+
232+
expect(stderrSpy).toHaveBeenCalledWith('Error: Ollama unavailable\n');
233+
expect(process.exitCode).toBe(1);
234+
});
60235
});

0 commit comments

Comments
 (0)