@losclaws/cli 0.1.2 → 0.1.4

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.
@@ -0,0 +1,264 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ import { loadConfig } from '../src/config.js';
9
+ import {
10
+ buildTaskPrompt,
11
+ resolveAcpRuntime,
12
+ resolveServerAcpDescriptor,
13
+ runWorkshopAcpTask,
14
+ } from '../src/acp-runtime.js';
15
+
16
+ test('resolveServerAcpDescriptor reads the server-composed task.acp', () => {
17
+ const descriptor = resolveServerAcpDescriptor({
18
+ acp: { key: 'opencode', command: 'opencode', args: ['acp'] },
19
+ });
20
+ assert.deepEqual(descriptor, { key: 'opencode', command: 'opencode', args: ['acp'] });
21
+ });
22
+
23
+ test('resolveAcpRuntime builds a runtime from task.acp', () => {
24
+ const resolved = resolveAcpRuntime({
25
+ task: { acp: { key: 'opencode', command: 'opencode', args: ['acp'], model: 'gpt-5.4' } },
26
+ options: {},
27
+ });
28
+ assert.equal(resolved.name, 'opencode');
29
+ assert.equal(resolved.runtime.command, 'opencode');
30
+ assert.deepEqual(resolved.runtime.args, ['acp']);
31
+ assert.equal(resolved.runtime.model, 'gpt-5.4');
32
+ assert.equal(resolved.source, 'server');
33
+ });
34
+
35
+ test('resolveAcpRuntime throws when the server returns no coder runtime', () => {
36
+ assert.throws(() => resolveAcpRuntime({ task: {}, options: {} }), /no coder runtime/);
37
+ });
38
+
39
+ test('buildTaskPrompt includes artifact and skill disclosure', () => {
40
+ const result = buildTaskPrompt({
41
+ role: 'worker',
42
+ task: {
43
+ id: 'tsk_123',
44
+ title: 'Write docs',
45
+ description: 'Update the project guide.',
46
+ inputArtifacts: [{ id: 'art_1' }],
47
+ },
48
+ localState: {
49
+ rootPath: '/repo',
50
+ artifacts: [
51
+ { id: 'art_1', relativePath: 'artifacts/project/guide.md' },
52
+ ],
53
+ skills: [
54
+ { role: 'worker', path: 'skills/worker/docs-skill' },
55
+ ],
56
+ },
57
+ });
58
+
59
+ assert.match(result.prompt, /Task title: Write docs/);
60
+ assert.match(result.prompt, /artifacts\/project\/guide.md/);
61
+ assert.match(result.prompt, /skills\/worker\/docs-skill/);
62
+ });
63
+
64
+ test('buildTaskPrompt appends operator instructions only when provided', () => {
65
+ const withMessage = buildTaskPrompt({
66
+ role: 'worker',
67
+ task: {
68
+ id: 'tsk_123',
69
+ title: 'Write docs',
70
+ description: 'Update the project guide.',
71
+ },
72
+ localState: {
73
+ rootPath: '/repo',
74
+ artifacts: [],
75
+ skills: [],
76
+ },
77
+ extraMessage: 'Please keep the wording concise.',
78
+ });
79
+ const withoutMessage = buildTaskPrompt({
80
+ role: 'worker',
81
+ task: {
82
+ id: 'tsk_123',
83
+ title: 'Write docs',
84
+ description: 'Update the project guide.',
85
+ },
86
+ localState: {
87
+ rootPath: '/repo',
88
+ artifacts: [],
89
+ skills: [],
90
+ },
91
+ });
92
+
93
+ assert.match(withMessage.prompt, /Additional operator instructions:\nPlease keep the wording concise\./);
94
+ assert.doesNotMatch(withoutMessage.prompt, /Additional operator instructions:/);
95
+ });
96
+
97
+ test('runWorkshopAcpTask executes a real ACP prompt against a local runtime mapping', async () => {
98
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'losclaws-acp-run-'));
99
+ const metaRoot = path.join(tempRoot, '.losclaws');
100
+ await fs.mkdir(path.join(tempRoot, 'artifacts', 'project'), { recursive: true });
101
+ await fs.mkdir(path.join(tempRoot, 'skills', 'worker', 'docs-skill'), { recursive: true });
102
+ await fs.mkdir(metaRoot, { recursive: true });
103
+ await fs.writeFile(path.join(tempRoot, 'artifacts', 'project', 'guide.md'), 'hello', 'utf8');
104
+ await fs.writeFile(
105
+ path.join(metaRoot, 'state.json'),
106
+ `${JSON.stringify(
107
+ {
108
+ version: 1,
109
+ rootType: 'project',
110
+ rootPath: tempRoot,
111
+ role: 'worker',
112
+ workspace: { id: 'wrk_1', name: 'Workspace', slug: 'workspace', version: 1 },
113
+ project: { id: 'prj_1', name: 'Project', slug: 'project', version: 1 },
114
+ artifacts: [
115
+ {
116
+ id: 'art_1',
117
+ scope: 'project',
118
+ relativePath: 'artifacts/project/guide.md',
119
+ contentKind: 'text',
120
+ mimeType: 'text/plain',
121
+ lastSyncedHash: 'hash',
122
+ remoteRevisionNo: 1,
123
+ remoteVersion: 1,
124
+ },
125
+ ],
126
+ skills: [
127
+ {
128
+ name: 'docs-skill',
129
+ role: 'worker',
130
+ path: 'skills/worker/docs-skill',
131
+ source: 'bundled',
132
+ revision: 'bundled',
133
+ },
134
+ ],
135
+ },
136
+ null,
137
+ 2,
138
+ )}\n`,
139
+ 'utf8',
140
+ );
141
+
142
+ const configPath = path.join(tempRoot, 'config.json');
143
+ const state = await loadConfig(configPath);
144
+ const fixturePath = fileURLToPath(new URL('../testdata/mock-acp-agent.js', import.meta.url));
145
+
146
+ const worklogs = [];
147
+ const requestWorkshop = async ({ method, path: requestPath, body }) => {
148
+ if (requestPath === '/api/v1/tasks/tsk_123' && (!method || method === 'GET')) {
149
+ return {
150
+ task: {
151
+ id: 'tsk_123',
152
+ title: 'Write docs',
153
+ description: 'Update the project guide.',
154
+ assigneeRole: 'worker',
155
+ version: 4,
156
+ acp: {
157
+ key: 'mock-agent',
158
+ command: process.execPath,
159
+ args: [fixturePath],
160
+ authMethodId: 'local-auth',
161
+ },
162
+ inputArtifacts: [{ id: 'art_1' }],
163
+ },
164
+ projectId: 'prj_1',
165
+ };
166
+ }
167
+ if (requestPath === '/api/v1/tasks/tsk_123/worklogs' && method === 'POST') {
168
+ worklogs.push(body);
169
+ return { id: `wl_${worklogs.length}`, version: 4 };
170
+ }
171
+ throw new Error(`Unexpected request: ${method} ${requestPath}`);
172
+ };
173
+
174
+ const result = await runWorkshopAcpTask({
175
+ requestWorkshop,
176
+ state,
177
+ options: {
178
+ id: 'tsk_123',
179
+ root: tempRoot,
180
+ approvalPolicy: 'auto-allow',
181
+ closeSession: true,
182
+ },
183
+ });
184
+
185
+ assert.equal(result.agent.name, 'mock-agent');
186
+ assert.equal(result.acp.stopReason, 'end_turn');
187
+ assert.match(result.acp.sessionId, /^sess_/);
188
+ const logText = await fs.readFile(result.logPath, 'utf8');
189
+ assert.match(logText, /session_update/);
190
+ assert.ok(worklogs.length >= 1, 'expected at least one worklog to be recorded');
191
+ assert.ok(worklogs.every((entry) => typeof entry.updateType === 'string'));
192
+ });
193
+
194
+ test('runWorkshopAcpTask routes a remote permission request through feedback and back', async () => {
195
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'losclaws-acp-remote-'));
196
+ const metaRoot = path.join(tempRoot, '.losclaws');
197
+ await fs.mkdir(metaRoot, { recursive: true });
198
+ await fs.writeFile(
199
+ path.join(metaRoot, 'state.json'),
200
+ `${JSON.stringify({ version: 1, rootType: 'project', rootPath: tempRoot, role: 'worker', artifacts: [], skills: [] }, null, 2)}\n`,
201
+ 'utf8',
202
+ );
203
+
204
+ const configPath = path.join(tempRoot, 'config.json');
205
+ const state = await loadConfig(configPath);
206
+ const fixturePath = fileURLToPath(new URL('../testdata/mock-acp-agent.js', import.meta.url));
207
+
208
+ let feedbackRequested = null;
209
+ let feedbackResolved = false;
210
+ const requestWorkshop = async ({ method, path: requestPath, body }) => {
211
+ if (requestPath === '/api/v1/tasks/tsk_remote' && (!method || method === 'GET')) {
212
+ // Once a feedback request exists, report it as submitted so the poll resolves.
213
+ const feedbackSession = feedbackRequested
214
+ ? {
215
+ id: 'fb_1',
216
+ status: feedbackResolved ? 'submitted' : 'open',
217
+ kind: 'single_select',
218
+ version: 1,
219
+ entries: feedbackResolved ? [{ id: 'e1', response: { optionId: 'allow' }, body: 'Allow' }] : [],
220
+ }
221
+ : null;
222
+ if (feedbackRequested) {
223
+ feedbackResolved = true;
224
+ }
225
+ return {
226
+ task: {
227
+ id: 'tsk_remote',
228
+ title: 'Remote task',
229
+ version: 7,
230
+ acp: { key: 'mock-agent', command: process.execPath, args: [fixturePath], authMethodId: 'local-auth' },
231
+ },
232
+ feedbackSession,
233
+ };
234
+ }
235
+ if (requestPath === '/api/v1/tasks/tsk_remote/feedback-requests' && method === 'POST') {
236
+ feedbackRequested = body;
237
+ return { version: 8 };
238
+ }
239
+ if (requestPath === '/api/v1/tasks/tsk_remote/worklogs' && method === 'POST') {
240
+ return { id: 'wl', version: 8 };
241
+ }
242
+ throw new Error(`Unexpected request: ${method} ${requestPath}`);
243
+ };
244
+
245
+ const result = await runWorkshopAcpTask({
246
+ requestWorkshop,
247
+ state,
248
+ options: {
249
+ id: 'tsk_remote',
250
+ root: tempRoot,
251
+ approvalPolicy: 'remote',
252
+ feedbackTimeout: 20000,
253
+ closeSession: true,
254
+ // The mock agent issues a permission request when this env var is set on
255
+ // the spawned coder; --env is merged into the coder's process environment.
256
+ env: { MOCK_ACP_REQUEST_PERMISSION: '1' },
257
+ },
258
+ });
259
+
260
+ assert.equal(result.acp.stopReason, 'end_turn');
261
+ assert.ok(feedbackRequested, 'expected a feedback request to be posted');
262
+ assert.equal(feedbackRequested.kind, 'single_select');
263
+ assert.ok(Array.isArray(feedbackRequested.options) && feedbackRequested.options.length === 2);
264
+ });
@@ -6,7 +6,12 @@ import path from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
 
9
- import { clearedAuthProfile, configForDisplay, resolveRuntime } from '../src/config.js';
9
+ import {
10
+ clearedAuthProfile,
11
+ configForDisplay,
12
+ loadConfig,
13
+ resolveRuntime,
14
+ } from '../src/config.js';
10
15
 
11
16
  test('resolveRuntime includes clawworkshop base URL from profile', () => {
12
17
  const runtime = resolveRuntime(
@@ -0,0 +1,50 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+
5
+ import { getHelpText, workshopHelpLines } from '../src/cli.js';
6
+
7
+ const legacyWorkshopLines = [
8
+ ' losclaws workshop agents list',
9
+ ' losclaws workshop agents get --name NAME',
10
+ ' losclaws workshop agents set --name NAME --command CMD [--title TEXT] [--args JSON|--args-file PATH] [--env JSON|--env-file PATH] [--cwd PATH] [--auth-method-id ID] [--mcp-servers JSON|--mcp-servers-file PATH] [--default-prompt TEXT]',
11
+ ' losclaws workshop agents delete --name NAME',
12
+ ' losclaws workshop acp run --id ID [--root PATH] [--agent NAME] [--role worker|reviewer] [--approval-policy interactive|read-only|auto-allow|auto-reject] [--acp-session-id ID] [--workshop-session-id ID] [--workshop-session-version N] [--message TEXT|--message-file PATH] [--close-session] [--dry-run]',
13
+ ' losclaws workshop sessions update --id ID --expected-version N [--events JSON|--events-file PATH] [--status STATUS] [--log-message TEXT] [--feedback-response JSON]',
14
+ ];
15
+
16
+ test('CLI help reflects current workshop commands and excludes removed ones', () => {
17
+ const helpText = getHelpText();
18
+
19
+ for (const line of workshopHelpLines) {
20
+ assert.match(helpText, new RegExp(`^${escapeRegExp(line)}$`, 'm'));
21
+ }
22
+
23
+ for (const line of legacyWorkshopLines) {
24
+ assert.doesNotMatch(helpText, new RegExp(`^${escapeRegExp(line)}$`, 'm'));
25
+ }
26
+ });
27
+
28
+ test('README and command reference stay aligned with workshop help', async () => {
29
+ const readme = await readRelative('../README.md');
30
+ const commandsReference = await readRelative('../skill/losclaws-cli/references/commands.md');
31
+
32
+ for (const line of workshopHelpLines) {
33
+ const bulletLine = `- \`${line.trim()}\``;
34
+ assert.match(readme, new RegExp(`^${escapeRegExp(bulletLine)}$`, 'm'));
35
+ assert.match(commandsReference, new RegExp(`^${escapeRegExp(bulletLine)}$`, 'm'));
36
+ }
37
+
38
+ for (const line of legacyWorkshopLines) {
39
+ assert.doesNotMatch(readme, new RegExp(escapeRegExp(line.trim())));
40
+ assert.doesNotMatch(commandsReference, new RegExp(escapeRegExp(line.trim())));
41
+ }
42
+ });
43
+
44
+ async function readRelative(relativePath) {
45
+ return fs.readFile(new URL(relativePath, import.meta.url), 'utf8');
46
+ }
47
+
48
+ function escapeRegExp(value) {
49
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
50
+ }
@@ -0,0 +1,83 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+
8
+ import { buildArtifactIndex, summarizeIndex } from '../src/workshop-local.js';
9
+
10
+ test('summarizeIndex returns status counters', () => {
11
+ const summary = summarizeIndex({
12
+ entries: [
13
+ { status: 'clean' },
14
+ { status: 'clean' },
15
+ { status: 'modified' },
16
+ { status: 'missing' },
17
+ { status: 'untracked' },
18
+ ],
19
+ });
20
+
21
+ assert.deepEqual(summary, {
22
+ clean: 2,
23
+ modified: 1,
24
+ missing: 1,
25
+ untracked: 1,
26
+ total: 5,
27
+ });
28
+ });
29
+
30
+ test('buildArtifactIndex detects modified/missing/untracked artifacts', async () => {
31
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'losclaws-workshop-local-test-'));
32
+ const artifactsDir = path.join(tempRoot, 'artifacts', 'project');
33
+ await fs.mkdir(artifactsDir, { recursive: true });
34
+
35
+ const cleanPath = path.join(artifactsDir, 'clean.txt');
36
+ const modifiedPath = path.join(artifactsDir, 'modified.txt');
37
+ const untrackedPath = path.join(artifactsDir, 'untracked.txt');
38
+ await fs.writeFile(cleanPath, 'clean', 'utf8');
39
+ await fs.writeFile(modifiedPath, 'new-value', 'utf8');
40
+ await fs.writeFile(untrackedPath, 'untracked', 'utf8');
41
+
42
+ const state = {
43
+ rootPath: tempRoot,
44
+ artifacts: [
45
+ {
46
+ id: 'art_clean',
47
+ relativePath: path.join('artifacts', 'project', 'clean.txt'),
48
+ lastSyncedHash: hashText('clean'),
49
+ remoteRevisionNo: 1,
50
+ remoteVersion: 10,
51
+ contentKind: 'text',
52
+ },
53
+ {
54
+ id: 'art_modified',
55
+ relativePath: path.join('artifacts', 'project', 'modified.txt'),
56
+ lastSyncedHash: hashText('old-value'),
57
+ remoteRevisionNo: 2,
58
+ remoteVersion: 20,
59
+ contentKind: 'text',
60
+ },
61
+ {
62
+ id: 'art_missing',
63
+ relativePath: path.join('artifacts', 'project', 'missing.txt'),
64
+ lastSyncedHash: hashText('missing'),
65
+ remoteRevisionNo: 3,
66
+ remoteVersion: 30,
67
+ contentKind: 'text',
68
+ },
69
+ ],
70
+ };
71
+
72
+ const index = await buildArtifactIndex(state);
73
+ const byPath = new Map(index.entries.map((entry) => [entry.relativePath.replaceAll('\\', '/'), entry]));
74
+
75
+ assert.equal(byPath.get('artifacts/project/clean.txt').status, 'clean');
76
+ assert.equal(byPath.get('artifacts/project/modified.txt').status, 'modified');
77
+ assert.equal(byPath.get('artifacts/project/missing.txt').status, 'missing');
78
+ assert.equal(byPath.get('artifacts/project/untracked.txt').status, 'untracked');
79
+ });
80
+
81
+ function hashText(value) {
82
+ return createHash('sha256').update(Buffer.from(value, 'utf8')).digest('hex');
83
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Readable, Writable } from 'node:stream';
4
+ import * as acp from '@agentclientprotocol/sdk';
5
+
6
+ class MockAgent {
7
+ constructor(connection) {
8
+ this.connection = connection;
9
+ this.sessions = new Map();
10
+ }
11
+
12
+ async initialize() {
13
+ return {
14
+ protocolVersion: acp.PROTOCOL_VERSION,
15
+ agentCapabilities: {
16
+ loadSession: true,
17
+ sessionCapabilities: {
18
+ close: {},
19
+ resume: {},
20
+ },
21
+ },
22
+ authMethods: [
23
+ {
24
+ id: 'local-auth',
25
+ name: 'Local auth',
26
+ description: 'Mock authentication method',
27
+ },
28
+ ],
29
+ agentInfo: {
30
+ name: 'mock-acp-agent',
31
+ title: 'Mock ACP Agent',
32
+ version: '1.0.0',
33
+ },
34
+ };
35
+ }
36
+
37
+ async authenticate() {
38
+ return {};
39
+ }
40
+
41
+ async newSession() {
42
+ const sessionId = `sess_${Math.random().toString(16).slice(2)}`;
43
+ this.sessions.set(sessionId, true);
44
+ return { sessionId };
45
+ }
46
+
47
+ async loadSession(params) {
48
+ this.sessions.set(params.sessionId, true);
49
+ return {};
50
+ }
51
+
52
+ async resumeSession(params) {
53
+ this.sessions.set(params.sessionId, true);
54
+ return {};
55
+ }
56
+
57
+ async closeSession() {
58
+ return {};
59
+ }
60
+
61
+ async prompt(params) {
62
+ const sessionId = params.sessionId;
63
+
64
+ // Reasoning / plan / tool-call surface so the bridge maps several update types.
65
+ await this.connection.sessionUpdate({
66
+ sessionId,
67
+ update: {
68
+ sessionUpdate: 'agent_thought_chunk',
69
+ content: { type: 'text', text: 'Planning the work.' },
70
+ },
71
+ });
72
+ await this.connection.sessionUpdate({
73
+ sessionId,
74
+ update: {
75
+ sessionUpdate: 'plan',
76
+ entries: [
77
+ { content: 'Read the task', status: 'completed', priority: 'high' },
78
+ { content: 'Make the change', status: 'in_progress', priority: 'high' },
79
+ ],
80
+ },
81
+ });
82
+ await this.connection.sessionUpdate({
83
+ sessionId,
84
+ update: {
85
+ sessionUpdate: 'tool_call',
86
+ toolCallId: 'call_1',
87
+ title: 'Edit guide.md',
88
+ kind: 'edit',
89
+ status: 'in_progress',
90
+ },
91
+ });
92
+
93
+ // Optionally exercise the human-in-the-loop permission round-trip.
94
+ if (process.env.MOCK_ACP_REQUEST_PERMISSION === '1') {
95
+ const decision = await this.connection.requestPermission({
96
+ sessionId,
97
+ toolCall: { toolCallId: 'call_1', title: 'Apply the edit to guide.md', kind: 'edit' },
98
+ options: [
99
+ { optionId: 'allow', name: 'Allow', kind: 'allow_once' },
100
+ { optionId: 'deny', name: 'Deny', kind: 'reject_once' },
101
+ ],
102
+ });
103
+ await this.connection.sessionUpdate({
104
+ sessionId,
105
+ update: {
106
+ sessionUpdate: 'agent_message_chunk',
107
+ content: { type: 'text', text: `permission outcome: ${JSON.stringify(decision.outcome)}` },
108
+ },
109
+ });
110
+ }
111
+
112
+ await this.connection.sessionUpdate({
113
+ sessionId,
114
+ update: {
115
+ sessionUpdate: 'agent_message_chunk',
116
+ content: {
117
+ type: 'text',
118
+ text: `mock-agent received: ${params.prompt[0].text.slice(0, 40)}`,
119
+ },
120
+ },
121
+ });
122
+
123
+ return {
124
+ stopReason: 'end_turn',
125
+ };
126
+ }
127
+
128
+ async cancel() {}
129
+ }
130
+
131
+ const stream = acp.ndJsonStream(
132
+ Writable.toWeb(process.stdout),
133
+ Readable.toWeb(process.stdin),
134
+ );
135
+ new acp.AgentSideConnection((connection) => new MockAgent(connection), stream);