Skip to content

Commit 7003b62

Browse files
feat(cli): add command to run a one-off prompt
1 parent 8dd75cf commit 7003b62

5 files changed

Lines changed: 317 additions & 6 deletions

File tree

.github/workflows/test.yml

Lines changed: 52 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,54 @@ 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: Cache model
56+
uses: actions/cache@v5
57+
with:
58+
path: ~/.ollama
59+
key: ${{ runner.os }}-ollama
60+
61+
- name: Install CLI
62+
run: |
63+
package_archive=$(npm pack | tail -n 1)
64+
npm install --global "./$package_archive"
65+
66+
- name: Verify CLI version
67+
run: |
68+
version_output=$(code-ollama --version)
69+
printf '%s\n' "$version_output"
70+
[ "$version_output" = "$(node -p "require('./package.json').version")" ]
71+
72+
- name: Verify CLI help
73+
run: |
74+
help_output=$(code-ollama --help)
75+
printf '%s\n' "$help_output"
76+
grep -F "code-ollama" <<< "$help_output"
77+
grep -F "run <model> <prompt>" <<< "$help_output"
78+
79+
- name: Verify CLI run
80+
run: |
81+
output=$(code-ollama run tinyllama "Repeat exactly this token and nothing else: INTEGRATION_PASS_7F3A")
82+
printf '%s\n' "$output"
83+
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)

src/cli.test.ts

Lines changed: 177 additions & 2 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+
render,
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(),
722
render: 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 } }));
30+
vi.mock('./utils', () => ({
31+
agents: { createSystemMessage },
32+
ollama: { streamChat },
33+
screen: { clear: clearScreen },
34+
tools: { TOOLS: ['mock-tool'], executeTool },
35+
}));
1136
vi.mock('ink', () => ({ render }));
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,16 +53,22 @@ 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

3874
it('renders TUI with no args', () => {
@@ -57,4 +93,143 @@ describe('cli', () => {
5793
main(['-v']);
5894
expect(parse).toHaveBeenCalledWith(['node', 'code-ollama', '-v']);
5995
});
96+
97+
it('calls parse for run without rendering TUI', () => {
98+
main(['run', 'gemma4', 'review diff']);
99+
expect(render).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)