byterover-cli 1.5.0 → 1.6.0
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.
- package/README.md +132 -11
- package/dist/core/domain/errors/headless-prompt-error.d.ts +11 -0
- package/dist/core/domain/errors/headless-prompt-error.js +18 -0
- package/dist/core/interfaces/i-cogit-pull-service.d.ts +0 -1
- package/dist/core/interfaces/i-memory-retrieval-service.d.ts +0 -1
- package/dist/core/interfaces/i-memory-storage-service.d.ts +0 -2
- package/dist/core/interfaces/i-space-service.d.ts +1 -2
- package/dist/core/interfaces/i-team-service.d.ts +1 -2
- package/dist/core/interfaces/i-user-service.d.ts +1 -2
- package/dist/core/interfaces/usecase/i-curate-use-case.d.ts +2 -0
- package/dist/core/interfaces/usecase/i-init-use-case.d.ts +9 -3
- package/dist/core/interfaces/usecase/i-login-use-case.d.ts +4 -1
- package/dist/core/interfaces/usecase/i-pull-use-case.d.ts +5 -3
- package/dist/core/interfaces/usecase/i-push-use-case.d.ts +6 -4
- package/dist/core/interfaces/usecase/i-query-use-case.d.ts +2 -0
- package/dist/core/interfaces/usecase/i-status-use-case.d.ts +1 -0
- package/dist/infra/cipher/agent/service-initializer.d.ts +1 -1
- package/dist/infra/cipher/agent/service-initializer.js +0 -1
- package/dist/infra/cipher/http/internal-llm-http-service.d.ts +0 -1
- package/dist/infra/cipher/http/internal-llm-http-service.js +1 -2
- package/dist/infra/cogit/http-cogit-pull-service.js +1 -1
- package/dist/infra/cogit/http-cogit-push-service.js +0 -1
- package/dist/infra/http/authenticated-http-client.d.ts +1 -3
- package/dist/infra/http/authenticated-http-client.js +1 -5
- package/dist/infra/memory/http-memory-retrieval-service.js +1 -1
- package/dist/infra/memory/http-memory-storage-service.js +2 -2
- package/dist/infra/process/inline-agent-executor.d.ts +32 -0
- package/dist/infra/process/inline-agent-executor.js +259 -0
- package/dist/infra/space/http-space-service.d.ts +1 -1
- package/dist/infra/space/http-space-service.js +2 -2
- package/dist/infra/storage/token-store.d.ts +4 -3
- package/dist/infra/storage/token-store.js +6 -5
- package/dist/infra/team/http-team-service.d.ts +1 -1
- package/dist/infra/team/http-team-service.js +2 -2
- package/dist/infra/terminal/headless-terminal.d.ts +91 -0
- package/dist/infra/terminal/headless-terminal.js +211 -0
- package/dist/infra/usecase/curate-use-case.d.ts +40 -1
- package/dist/infra/usecase/curate-use-case.js +176 -15
- package/dist/infra/usecase/init-use-case.d.ts +27 -5
- package/dist/infra/usecase/init-use-case.js +200 -34
- package/dist/infra/usecase/login-use-case.d.ts +10 -8
- package/dist/infra/usecase/login-use-case.js +35 -2
- package/dist/infra/usecase/pull-use-case.d.ts +19 -5
- package/dist/infra/usecase/pull-use-case.js +71 -13
- package/dist/infra/usecase/push-use-case.d.ts +18 -5
- package/dist/infra/usecase/push-use-case.js +81 -14
- package/dist/infra/usecase/query-use-case.d.ts +21 -0
- package/dist/infra/usecase/query-use-case.js +114 -29
- package/dist/infra/usecase/space-list-use-case.js +1 -1
- package/dist/infra/usecase/space-switch-use-case.js +2 -2
- package/dist/infra/usecase/status-use-case.d.ts +36 -0
- package/dist/infra/usecase/status-use-case.js +185 -48
- package/dist/infra/user/http-user-service.d.ts +1 -1
- package/dist/infra/user/http-user-service.js +2 -2
- package/dist/oclif/commands/curate.d.ts +6 -1
- package/dist/oclif/commands/curate.js +24 -3
- package/dist/oclif/commands/init.d.ts +18 -0
- package/dist/oclif/commands/init.js +129 -0
- package/dist/oclif/commands/login.d.ts +9 -0
- package/dist/oclif/commands/login.js +45 -0
- package/dist/oclif/commands/pull.d.ts +16 -0
- package/dist/oclif/commands/pull.js +78 -0
- package/dist/oclif/commands/push.d.ts +17 -0
- package/dist/oclif/commands/push.js +87 -0
- package/dist/oclif/commands/query.d.ts +6 -1
- package/dist/oclif/commands/query.js +29 -4
- package/dist/oclif/commands/status.d.ts +5 -1
- package/dist/oclif/commands/status.js +17 -5
- package/dist/tui/hooks/use-auth-polling.js +1 -1
- package/dist/utils/environment-detector.d.ts +15 -0
- package/dist/utils/environment-detector.js +62 -1
- package/oclif.manifest.json +287 -5
- package/package.json +1 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InlineAgent - Ephemeral in-process CipherAgent for headless commands.
|
|
3
|
+
*
|
|
4
|
+
* Used by `brv curate --headless` and `brv query --headless` to execute tasks
|
|
5
|
+
* without requiring a running REPL instance or Transport/Socket.IO infrastructure.
|
|
6
|
+
*
|
|
7
|
+
* Exposes a `transportClient` property (ITransportClient) so use cases can use it
|
|
8
|
+
* as a drop-in replacement for SocketIOTransportClient.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle:
|
|
11
|
+
* 1. InlineAgent.create() — loads auth, config, starts CipherAgent
|
|
12
|
+
* 2. Use case gets inlineAgent.transportClient and calls on()/request() as normal
|
|
13
|
+
* 3. transportClient.disconnect() — stops CipherAgent and cleans up
|
|
14
|
+
*/
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { getCurrentConfig } from '../../config/environment.js';
|
|
17
|
+
import { DEFAULT_LLM_MODEL, PROJECT } from '../../constants.js';
|
|
18
|
+
import { NotAuthenticatedError, serializeTaskError } from '../../core/domain/errors/task-error.js';
|
|
19
|
+
import { CipherAgent } from '../cipher/agent/index.js';
|
|
20
|
+
import { ProjectConfigStore } from '../config/file-config-store.js';
|
|
21
|
+
import { CurateExecutor } from '../core/executors/curate-executor.js';
|
|
22
|
+
import { QueryExecutor } from '../core/executors/query-executor.js';
|
|
23
|
+
import { createTokenStore } from '../storage/token-store.js';
|
|
24
|
+
/**
|
|
25
|
+
* Ephemeral in-process CipherAgent for headless CLI commands.
|
|
26
|
+
*
|
|
27
|
+
* Creates and owns a CipherAgent, and exposes an ITransportClient that
|
|
28
|
+
* use cases interact with exactly like a SocketIOTransportClient.
|
|
29
|
+
*/
|
|
30
|
+
export class InlineAgent {
|
|
31
|
+
transportClient;
|
|
32
|
+
constructor(agent) {
|
|
33
|
+
this.transportClient = new InlineTransportClient(agent);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Async factory — loads auth/config, creates and starts CipherAgent.
|
|
37
|
+
*
|
|
38
|
+
* @throws NotAuthenticatedError if no auth token or token is expired
|
|
39
|
+
* @throws Error if no project config (.brv/config.json) exists
|
|
40
|
+
*/
|
|
41
|
+
static async create() {
|
|
42
|
+
const tokenStore = createTokenStore();
|
|
43
|
+
const configStore = new ProjectConfigStore();
|
|
44
|
+
const authToken = await tokenStore.load();
|
|
45
|
+
if (!authToken || authToken.isExpired()) {
|
|
46
|
+
throw new NotAuthenticatedError();
|
|
47
|
+
}
|
|
48
|
+
const brvConfig = await configStore.read();
|
|
49
|
+
if (!brvConfig) {
|
|
50
|
+
throw new Error('Project not initialized. Run `brv` then `/init` first.');
|
|
51
|
+
}
|
|
52
|
+
const envConfig = getCurrentConfig();
|
|
53
|
+
const agentConfig = {
|
|
54
|
+
accessToken: authToken.accessToken,
|
|
55
|
+
apiBaseUrl: envConfig.llmApiBaseUrl,
|
|
56
|
+
fileSystem: { workingDirectory: process.cwd() },
|
|
57
|
+
llm: {
|
|
58
|
+
maxIterations: 10,
|
|
59
|
+
maxTokens: 4096,
|
|
60
|
+
temperature: 0.7,
|
|
61
|
+
topK: 10,
|
|
62
|
+
topP: 0.95,
|
|
63
|
+
verbose: false,
|
|
64
|
+
},
|
|
65
|
+
model: DEFAULT_LLM_MODEL,
|
|
66
|
+
projectId: PROJECT,
|
|
67
|
+
sessionKey: authToken.sessionKey,
|
|
68
|
+
};
|
|
69
|
+
const agent = new CipherAgent(agentConfig, brvConfig);
|
|
70
|
+
await agent.start();
|
|
71
|
+
const sessionId = `inline-session-${randomUUID()}`;
|
|
72
|
+
await agent.createSession(sessionId);
|
|
73
|
+
return new InlineAgent(agent);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* ITransportClient backed by an in-process CipherAgent.
|
|
78
|
+
*
|
|
79
|
+
* Translates transport events (task:create, task:completed, llmservice:*) into
|
|
80
|
+
* direct CipherAgent execution via CurateExecutor/QueryExecutor.
|
|
81
|
+
*/
|
|
82
|
+
class InlineTransportClient {
|
|
83
|
+
activeTask;
|
|
84
|
+
agent;
|
|
85
|
+
clientId = `inline-${randomUUID()}`;
|
|
86
|
+
curateExecutor;
|
|
87
|
+
handlers = new Map();
|
|
88
|
+
queryExecutor;
|
|
89
|
+
constructor(agent) {
|
|
90
|
+
this.agent = agent;
|
|
91
|
+
this.curateExecutor = new CurateExecutor();
|
|
92
|
+
this.queryExecutor = new QueryExecutor();
|
|
93
|
+
}
|
|
94
|
+
// ===========================================================================
|
|
95
|
+
// ITransportClient implementation
|
|
96
|
+
// ===========================================================================
|
|
97
|
+
async connect() {
|
|
98
|
+
// No-op — initialization done in InlineAgent.create()
|
|
99
|
+
}
|
|
100
|
+
async disconnect() {
|
|
101
|
+
this.handlers.clear();
|
|
102
|
+
// Stop the agent first — this causes any in-flight execute() to fail,
|
|
103
|
+
// which settles the activeTask promise.
|
|
104
|
+
try {
|
|
105
|
+
await this.agent.stop();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Best-effort cleanup
|
|
109
|
+
}
|
|
110
|
+
// Wait for the task to settle (will resolve/reject quickly after agent.stop())
|
|
111
|
+
if (this.activeTask) {
|
|
112
|
+
await this.activeTask;
|
|
113
|
+
this.activeTask = undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
getClientId() {
|
|
117
|
+
return this.clientId;
|
|
118
|
+
}
|
|
119
|
+
getState() {
|
|
120
|
+
return 'connected';
|
|
121
|
+
}
|
|
122
|
+
async isConnected() {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
async joinRoom() {
|
|
126
|
+
// No-op
|
|
127
|
+
}
|
|
128
|
+
async leaveRoom() {
|
|
129
|
+
// No-op
|
|
130
|
+
}
|
|
131
|
+
on(event, handler) {
|
|
132
|
+
if (!this.handlers.has(event)) {
|
|
133
|
+
this.handlers.set(event, new Set());
|
|
134
|
+
}
|
|
135
|
+
const handlerSet = this.handlers.get(event);
|
|
136
|
+
handlerSet.add(handler);
|
|
137
|
+
return () => {
|
|
138
|
+
handlerSet.delete(handler);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
once(event, handler) {
|
|
142
|
+
const unsubscribe = this.on(event, (data) => {
|
|
143
|
+
unsubscribe();
|
|
144
|
+
handler(data);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
onStateChange(_handler) {
|
|
148
|
+
// No-op — state never changes
|
|
149
|
+
return () => { };
|
|
150
|
+
}
|
|
151
|
+
async request(event, data, _options) {
|
|
152
|
+
if (event === 'task:create') {
|
|
153
|
+
// Returns immediately with {taskId}; execution runs asynchronously
|
|
154
|
+
return this.handleTaskCreate(data);
|
|
155
|
+
}
|
|
156
|
+
// Other events are no-ops for inline execution
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
// ===========================================================================
|
|
160
|
+
// Internal task execution
|
|
161
|
+
// ===========================================================================
|
|
162
|
+
/**
|
|
163
|
+
* Emit an event to all registered handlers.
|
|
164
|
+
*/
|
|
165
|
+
emit(event, data) {
|
|
166
|
+
const handlerSet = this.handlers.get(event);
|
|
167
|
+
if (handlerSet) {
|
|
168
|
+
for (const handler of handlerSet) {
|
|
169
|
+
handler(data);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Execute the task in-process, emitting transport-shaped events as it progresses.
|
|
175
|
+
*/
|
|
176
|
+
async executeTask(data) {
|
|
177
|
+
const taskId = data.taskId;
|
|
178
|
+
const type = data.type;
|
|
179
|
+
const content = data.content;
|
|
180
|
+
const files = data.files;
|
|
181
|
+
const clientCwd = data.clientCwd;
|
|
182
|
+
// Emit task:ack
|
|
183
|
+
this.emit('task:ack', { taskId });
|
|
184
|
+
// Emit task:started
|
|
185
|
+
this.emit('task:started', { taskId });
|
|
186
|
+
// Subscribe to agentEventBus and forward events to registered handlers
|
|
187
|
+
const cleanupForwarders = this.setupEventForwarding(taskId);
|
|
188
|
+
try {
|
|
189
|
+
const result = await (type === 'curate'
|
|
190
|
+
? this.curateExecutor.executeWithAgent(this.agent, {
|
|
191
|
+
clientCwd,
|
|
192
|
+
content,
|
|
193
|
+
files,
|
|
194
|
+
taskId,
|
|
195
|
+
})
|
|
196
|
+
: this.queryExecutor.executeWithAgent(this.agent, {
|
|
197
|
+
query: content,
|
|
198
|
+
taskId,
|
|
199
|
+
}));
|
|
200
|
+
// Emit task:completed
|
|
201
|
+
this.emit('task:completed', { result, taskId });
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
// Emit task:error
|
|
205
|
+
const errorData = serializeTaskError(error);
|
|
206
|
+
this.emit('task:error', { error: errorData, taskId });
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
cleanupForwarders();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Handle task:create request — fire execution asynchronously and return immediately.
|
|
214
|
+
*
|
|
215
|
+
* This matches SocketIOTransportClient behavior: request('task:create') resolves
|
|
216
|
+
* with {taskId} right away, while execution runs in the background emitting events.
|
|
217
|
+
* The use case registers on() handlers after request() returns, before events arrive.
|
|
218
|
+
*/
|
|
219
|
+
handleTaskCreate(data) {
|
|
220
|
+
const taskId = data.taskId;
|
|
221
|
+
// Fire execution asynchronously — do not await.
|
|
222
|
+
// Errors are handled internally (emitted as task:error), so the promise never rejects.
|
|
223
|
+
this.activeTask = this.executeTask(data);
|
|
224
|
+
return { taskId };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Forward agentEventBus events to registered transport-style handlers.
|
|
228
|
+
* Returns a cleanup function to remove all forwarders.
|
|
229
|
+
*/
|
|
230
|
+
setupEventForwarding(taskId) {
|
|
231
|
+
const eventBus = this.agent.agentEventBus;
|
|
232
|
+
if (!eventBus) {
|
|
233
|
+
return () => { };
|
|
234
|
+
}
|
|
235
|
+
const forwarders = [];
|
|
236
|
+
const forward = (busEvent, transportEvent, transform) => {
|
|
237
|
+
const handler = (payload) => {
|
|
238
|
+
const data = payload;
|
|
239
|
+
if (data?.taskId === taskId) {
|
|
240
|
+
this.emit(transportEvent, transform ? transform(data) : data);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
eventBus.on(busEvent, handler);
|
|
244
|
+
forwarders.push({ event: busEvent, handler });
|
|
245
|
+
};
|
|
246
|
+
forward('llmservice:toolCall', 'llmservice:toolCall');
|
|
247
|
+
forward('llmservice:toolResult', 'llmservice:toolResult');
|
|
248
|
+
forward('llmservice:response', 'llmservice:response');
|
|
249
|
+
forward('llmservice:error', 'llmservice:error');
|
|
250
|
+
forward('llmservice:thinking', 'llmservice:thinking');
|
|
251
|
+
forward('llmservice:chunk', 'llmservice:chunk');
|
|
252
|
+
forward('llmservice:unsupportedInput', 'llmservice:unsupportedInput');
|
|
253
|
+
return () => {
|
|
254
|
+
for (const { event, handler } of forwarders) {
|
|
255
|
+
eventBus.off(event, handler);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -7,7 +7,7 @@ export type SpaceServiceConfig = {
|
|
|
7
7
|
export declare class HttpSpaceService implements ISpaceService {
|
|
8
8
|
private readonly config;
|
|
9
9
|
constructor(config: SpaceServiceConfig);
|
|
10
|
-
getSpaces(
|
|
10
|
+
getSpaces(sessionKey: string, teamId: string, option?: {
|
|
11
11
|
fetchAll?: boolean;
|
|
12
12
|
limit?: number;
|
|
13
13
|
offset?: number;
|
|
@@ -9,9 +9,9 @@ export class HttpSpaceService {
|
|
|
9
9
|
timeout: 10_000, // Default 10 seconds timeout
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
|
-
async getSpaces(
|
|
12
|
+
async getSpaces(sessionKey, teamId, option) {
|
|
13
13
|
try {
|
|
14
|
-
const httpClient = new AuthenticatedHttpClient(
|
|
14
|
+
const httpClient = new AuthenticatedHttpClient(sessionKey);
|
|
15
15
|
// Scenario 1: Fetch all automatically via auto-pagination
|
|
16
16
|
if (option?.fetchAll === true) {
|
|
17
17
|
return await this.fetchAllSpaces(httpClient, teamId);
|
|
@@ -3,8 +3,9 @@ import type { ITokenStore } from '../../core/interfaces/i-token-store.js';
|
|
|
3
3
|
* Creates the appropriate token store for the current platform.
|
|
4
4
|
*
|
|
5
5
|
* - WSL: FileTokenStore (encrypted file-based, keychain not available)
|
|
6
|
-
* -
|
|
6
|
+
* - Headless Linux: FileTokenStore (no D-Bus/keyring daemon)
|
|
7
|
+
* - macOS/Windows/Linux with GUI: KeychainTokenStore (system keychain via keytar)
|
|
7
8
|
*
|
|
8
|
-
* @param
|
|
9
|
+
* @param shouldUseFileFn - Optional function for environment detection (for testing)
|
|
9
10
|
*/
|
|
10
|
-
export declare function createTokenStore(
|
|
11
|
+
export declare function createTokenStore(shouldUseFileFn?: () => boolean): ITokenStore;
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { shouldUseFileTokenStore } from '../../utils/environment-detector.js';
|
|
2
2
|
import { FileTokenStore } from './file-token-store.js';
|
|
3
3
|
import { KeychainTokenStore } from './keychain-token-store.js';
|
|
4
4
|
/**
|
|
5
5
|
* Creates the appropriate token store for the current platform.
|
|
6
6
|
*
|
|
7
7
|
* - WSL: FileTokenStore (encrypted file-based, keychain not available)
|
|
8
|
-
* -
|
|
8
|
+
* - Headless Linux: FileTokenStore (no D-Bus/keyring daemon)
|
|
9
|
+
* - macOS/Windows/Linux with GUI: KeychainTokenStore (system keychain via keytar)
|
|
9
10
|
*
|
|
10
|
-
* @param
|
|
11
|
+
* @param shouldUseFileFn - Optional function for environment detection (for testing)
|
|
11
12
|
*/
|
|
12
|
-
export function createTokenStore(
|
|
13
|
-
return
|
|
13
|
+
export function createTokenStore(shouldUseFileFn = shouldUseFileTokenStore) {
|
|
14
|
+
return shouldUseFileFn() ? new FileTokenStore() : new KeychainTokenStore();
|
|
14
15
|
}
|
|
@@ -7,7 +7,7 @@ export type TeamServiceConfig = {
|
|
|
7
7
|
export declare class HttpTeamService implements ITeamService {
|
|
8
8
|
private readonly config;
|
|
9
9
|
constructor(config: TeamServiceConfig);
|
|
10
|
-
getTeams(
|
|
10
|
+
getTeams(sessionKey: string, option?: {
|
|
11
11
|
fetchAll?: boolean;
|
|
12
12
|
isActive?: boolean;
|
|
13
13
|
limit?: number;
|
|
@@ -9,9 +9,9 @@ export class HttpTeamService {
|
|
|
9
9
|
timeout: 10_000, // Default 10 seconds timeout
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
|
-
async getTeams(
|
|
12
|
+
async getTeams(sessionKey, option) {
|
|
13
13
|
try {
|
|
14
|
-
const httpClient = new AuthenticatedHttpClient(
|
|
14
|
+
const httpClient = new AuthenticatedHttpClient(sessionKey);
|
|
15
15
|
// Scenario 1: Fetch all automatically via auto-pagination
|
|
16
16
|
if (option?.fetchAll === true) {
|
|
17
17
|
return await this.fetchAllTeams(httpClient, option?.isActive);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ConfirmOptions, FileSelectorItem, FileSelectorOptions, InputOptions, ITerminal, SearchOptions, SelectOptions } from '../../core/interfaces/i-terminal.js';
|
|
2
|
+
/**
|
|
3
|
+
* Output format for headless terminal.
|
|
4
|
+
* - 'text': Human-readable text output
|
|
5
|
+
* - 'json': NDJSON (newline-delimited JSON) for machine parsing
|
|
6
|
+
*/
|
|
7
|
+
export type HeadlessOutputFormat = 'json' | 'text';
|
|
8
|
+
/**
|
|
9
|
+
* JSON message types for structured output.
|
|
10
|
+
*/
|
|
11
|
+
export type HeadlessMessageType = 'action_start' | 'action_stop' | 'error' | 'log' | 'result' | 'warning';
|
|
12
|
+
/**
|
|
13
|
+
* Structured JSON output message.
|
|
14
|
+
*/
|
|
15
|
+
export interface HeadlessJsonMessage {
|
|
16
|
+
actionId?: string;
|
|
17
|
+
id: string;
|
|
18
|
+
message: string;
|
|
19
|
+
timestamp: string;
|
|
20
|
+
type: HeadlessMessageType;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Options for creating a HeadlessTerminal.
|
|
24
|
+
*/
|
|
25
|
+
export interface HeadlessTerminalOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Stream for errors (defaults to process.stderr).
|
|
28
|
+
*/
|
|
29
|
+
errorStream?: NodeJS.WritableStream;
|
|
30
|
+
/**
|
|
31
|
+
* If true, throw HeadlessPromptError when a prompt cannot be answered.
|
|
32
|
+
* If false, use sensible defaults (first choice, false for confirm, etc.)
|
|
33
|
+
* @default true
|
|
34
|
+
*/
|
|
35
|
+
failOnPrompt?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Output format: 'text' for human readable, 'json' for machine parsing.
|
|
38
|
+
* @default 'text'
|
|
39
|
+
*/
|
|
40
|
+
outputFormat?: HeadlessOutputFormat;
|
|
41
|
+
/**
|
|
42
|
+
* Stream for output (defaults to process.stdout).
|
|
43
|
+
*/
|
|
44
|
+
outputStream?: NodeJS.WritableStream;
|
|
45
|
+
/**
|
|
46
|
+
* Default values for prompts, keyed by prompt message or prompt type.
|
|
47
|
+
* Used to answer prompts automatically in headless mode.
|
|
48
|
+
*/
|
|
49
|
+
promptDefaults?: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Terminal implementation for headless/non-interactive mode.
|
|
53
|
+
* Outputs to stdout/stderr and handles prompts via defaults or fails gracefully.
|
|
54
|
+
*/
|
|
55
|
+
export declare class HeadlessTerminal implements ITerminal {
|
|
56
|
+
private currentActionId;
|
|
57
|
+
private readonly errorOutput;
|
|
58
|
+
private readonly failOnPrompt;
|
|
59
|
+
private readonly output;
|
|
60
|
+
private readonly outputFormat;
|
|
61
|
+
private readonly promptDefaults;
|
|
62
|
+
constructor(options?: HeadlessTerminalOptions);
|
|
63
|
+
actionStart(message: string): void;
|
|
64
|
+
actionStop(message?: string): void;
|
|
65
|
+
confirm(options: ConfirmOptions): Promise<boolean>;
|
|
66
|
+
error(message: string): void;
|
|
67
|
+
fileSelector(options: FileSelectorOptions): Promise<FileSelectorItem | null>;
|
|
68
|
+
input(options: InputOptions): Promise<string>;
|
|
69
|
+
log(message?: string): void;
|
|
70
|
+
search<T>(options: SearchOptions<T>): Promise<T>;
|
|
71
|
+
select<T>(options: SelectOptions<T>): Promise<T>;
|
|
72
|
+
warn(message: string): void;
|
|
73
|
+
/**
|
|
74
|
+
* Write final response with success/error status.
|
|
75
|
+
*/
|
|
76
|
+
writeFinalResponse(response: {
|
|
77
|
+
command: string;
|
|
78
|
+
data?: unknown;
|
|
79
|
+
error?: {
|
|
80
|
+
code: string;
|
|
81
|
+
message: string;
|
|
82
|
+
};
|
|
83
|
+
success: boolean;
|
|
84
|
+
}): void;
|
|
85
|
+
/**
|
|
86
|
+
* Write final result in JSON format (convenience method for commands).
|
|
87
|
+
*/
|
|
88
|
+
writeResult(data: Record<string, unknown>): void;
|
|
89
|
+
private getDefault;
|
|
90
|
+
private writeJson;
|
|
91
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { HeadlessPromptError } from '../../core/domain/errors/headless-prompt-error.js';
|
|
3
|
+
/**
|
|
4
|
+
* Terminal implementation for headless/non-interactive mode.
|
|
5
|
+
* Outputs to stdout/stderr and handles prompts via defaults or fails gracefully.
|
|
6
|
+
*/
|
|
7
|
+
export class HeadlessTerminal {
|
|
8
|
+
currentActionId = null;
|
|
9
|
+
errorOutput;
|
|
10
|
+
failOnPrompt;
|
|
11
|
+
output;
|
|
12
|
+
outputFormat;
|
|
13
|
+
promptDefaults;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.outputFormat = options.outputFormat ?? 'text';
|
|
16
|
+
this.promptDefaults = options.promptDefaults ?? {};
|
|
17
|
+
this.failOnPrompt = options.failOnPrompt ?? true;
|
|
18
|
+
this.output = options.outputStream ?? process.stdout;
|
|
19
|
+
this.errorOutput = options.errorStream ?? process.stderr;
|
|
20
|
+
}
|
|
21
|
+
// ==================== Output Methods ====================
|
|
22
|
+
actionStart(message) {
|
|
23
|
+
this.currentActionId = randomUUID();
|
|
24
|
+
if (this.outputFormat === 'json') {
|
|
25
|
+
this.writeJson({
|
|
26
|
+
actionId: this.currentActionId,
|
|
27
|
+
id: randomUUID(),
|
|
28
|
+
message,
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
type: 'action_start',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// In text mode, suppress action start for cleaner output
|
|
34
|
+
}
|
|
35
|
+
actionStop(message) {
|
|
36
|
+
if (this.outputFormat === 'json' && this.currentActionId) {
|
|
37
|
+
this.writeJson({
|
|
38
|
+
actionId: this.currentActionId,
|
|
39
|
+
id: randomUUID(),
|
|
40
|
+
message: message ?? '',
|
|
41
|
+
timestamp: new Date().toISOString(),
|
|
42
|
+
type: 'action_stop',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
this.currentActionId = null;
|
|
46
|
+
}
|
|
47
|
+
async confirm(options) {
|
|
48
|
+
// Check for explicit default in promptDefaults
|
|
49
|
+
const defaultValue = this.getDefault('confirm', options.message);
|
|
50
|
+
if (defaultValue !== undefined) {
|
|
51
|
+
return Boolean(defaultValue);
|
|
52
|
+
}
|
|
53
|
+
// Use options.default if provided
|
|
54
|
+
if (options.default !== undefined) {
|
|
55
|
+
return options.default;
|
|
56
|
+
}
|
|
57
|
+
// Fail or return false
|
|
58
|
+
if (this.failOnPrompt) {
|
|
59
|
+
throw new HeadlessPromptError('confirm', options.message);
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
error(message) {
|
|
64
|
+
if (this.outputFormat === 'json') {
|
|
65
|
+
this.writeJson({
|
|
66
|
+
id: randomUUID(),
|
|
67
|
+
message,
|
|
68
|
+
timestamp: new Date().toISOString(),
|
|
69
|
+
type: 'error',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
this.errorOutput.write(`Error: ${message}\n`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async fileSelector(options) {
|
|
77
|
+
// Check for explicit default in promptDefaults
|
|
78
|
+
const defaultValue = this.getDefault('file_selector', options.message);
|
|
79
|
+
if (defaultValue !== undefined && typeof defaultValue === 'string') {
|
|
80
|
+
return {
|
|
81
|
+
isDirectory: options.type === 'directory',
|
|
82
|
+
name: defaultValue.split('/').pop() ?? defaultValue,
|
|
83
|
+
path: defaultValue,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// Allow cancel if specified
|
|
87
|
+
if (options.allowCancel) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
// Fail
|
|
91
|
+
if (this.failOnPrompt) {
|
|
92
|
+
throw new HeadlessPromptError('file_selector', options.message);
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
// ==================== Input Methods ====================
|
|
97
|
+
async input(options) {
|
|
98
|
+
// Check for explicit default in promptDefaults
|
|
99
|
+
const defaultValue = this.getDefault('input', options.message);
|
|
100
|
+
if (defaultValue !== undefined) {
|
|
101
|
+
const value = String(defaultValue);
|
|
102
|
+
// Validate if validator is provided
|
|
103
|
+
if (options.validate) {
|
|
104
|
+
const validationResult = options.validate(value);
|
|
105
|
+
if (validationResult !== true) {
|
|
106
|
+
const errorMsg = typeof validationResult === 'string' ? validationResult : 'Validation failed';
|
|
107
|
+
throw new HeadlessPromptError('input', `${options.message} (validation error: ${errorMsg})`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
// Fail
|
|
113
|
+
if (this.failOnPrompt) {
|
|
114
|
+
throw new HeadlessPromptError('input', options.message);
|
|
115
|
+
}
|
|
116
|
+
return '';
|
|
117
|
+
}
|
|
118
|
+
log(message) {
|
|
119
|
+
if (this.outputFormat === 'json') {
|
|
120
|
+
this.writeJson({
|
|
121
|
+
id: randomUUID(),
|
|
122
|
+
message: message ?? '',
|
|
123
|
+
timestamp: new Date().toISOString(),
|
|
124
|
+
type: 'log',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
this.output.write((message ?? '') + '\n');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async search(options) {
|
|
132
|
+
// Search prompts require user interaction - always fail in headless mode
|
|
133
|
+
// unless a default is explicitly provided
|
|
134
|
+
const defaultValue = this.getDefault('search', options.message);
|
|
135
|
+
if (defaultValue !== undefined) {
|
|
136
|
+
return defaultValue;
|
|
137
|
+
}
|
|
138
|
+
throw new HeadlessPromptError('search', options.message);
|
|
139
|
+
}
|
|
140
|
+
async select(options) {
|
|
141
|
+
// Check for explicit default in promptDefaults (by value or name)
|
|
142
|
+
const defaultValue = this.getDefault('select', options.message);
|
|
143
|
+
if (defaultValue !== undefined) {
|
|
144
|
+
const choice = options.choices.find((c) => c.value === defaultValue || c.name === defaultValue);
|
|
145
|
+
if (choice) {
|
|
146
|
+
return choice.value;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Fail or return first choice
|
|
150
|
+
if (this.failOnPrompt) {
|
|
151
|
+
throw new HeadlessPromptError('select', options.message, options.choices.map((c) => c.name));
|
|
152
|
+
}
|
|
153
|
+
// Return first choice as fallback
|
|
154
|
+
if (options.choices.length > 0) {
|
|
155
|
+
return options.choices[0].value;
|
|
156
|
+
}
|
|
157
|
+
throw new HeadlessPromptError('select', options.message, []);
|
|
158
|
+
}
|
|
159
|
+
warn(message) {
|
|
160
|
+
if (this.outputFormat === 'json') {
|
|
161
|
+
this.writeJson({
|
|
162
|
+
id: randomUUID(),
|
|
163
|
+
message,
|
|
164
|
+
timestamp: new Date().toISOString(),
|
|
165
|
+
type: 'warning',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
this.errorOutput.write(`Warning: ${message}\n`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// ==================== Helper Methods ====================
|
|
173
|
+
/**
|
|
174
|
+
* Write final response with success/error status.
|
|
175
|
+
*/
|
|
176
|
+
writeFinalResponse(response) {
|
|
177
|
+
if (this.outputFormat === 'json') {
|
|
178
|
+
this.output.write(JSON.stringify({
|
|
179
|
+
...response,
|
|
180
|
+
timestamp: new Date().toISOString(),
|
|
181
|
+
}) + '\n');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Write final result in JSON format (convenience method for commands).
|
|
186
|
+
*/
|
|
187
|
+
writeResult(data) {
|
|
188
|
+
if (this.outputFormat === 'json') {
|
|
189
|
+
this.writeJson({
|
|
190
|
+
id: randomUUID(),
|
|
191
|
+
message: JSON.stringify(data),
|
|
192
|
+
timestamp: new Date().toISOString(),
|
|
193
|
+
type: 'result',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
getDefault(promptType, promptMessage) {
|
|
198
|
+
// First check by exact message
|
|
199
|
+
if (this.promptDefaults[promptMessage] !== undefined) {
|
|
200
|
+
return this.promptDefaults[promptMessage];
|
|
201
|
+
}
|
|
202
|
+
// Then check by prompt type
|
|
203
|
+
if (this.promptDefaults[promptType] !== undefined) {
|
|
204
|
+
return this.promptDefaults[promptType];
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
writeJson(data) {
|
|
209
|
+
this.output.write(JSON.stringify(data) + '\n');
|
|
210
|
+
}
|
|
211
|
+
}
|