@truss-harness/cli 0.1.0 → 0.1.3

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 CHANGED
@@ -23,13 +23,17 @@ truss-cli
23
23
  truss-cli help
24
24
  ```
25
25
 
26
+ ## Interactive setup
27
+
28
+ Run truss-cli setup after installation to discover local servers, choose a model, and save a default user-level profile. It is a command rather than an install-time prompt, so npm installs remain safe in CI and scripts.
29
+
26
30
  ## First run
27
31
 
28
32
  Start your local model server, open a terminal in the project you want Truss to work on, and run:
29
33
 
30
34
  ```sh
31
35
  truss-cli models
32
- truss-cli chat "Explain this workspace"
36
+ truss-cli chat --mode edit "Explain this workspace"
33
37
  ```
34
38
 
35
39
  Truss probes the standard Ollama, LM Studio, and llama.cpp endpoints when no model is configured. Create a reusable workspace profile when you want explicit settings:
@@ -50,6 +54,16 @@ truss-cli chat --internet-access "Check the current library documentation"
50
54
 
51
55
  Direct CLI chat is non-interactive and auto-allows registered tools. Run it only in a trusted workspace. Public internet tools are disabled unless `--internet-access`, a profile, or the environment explicitly enables them.
52
56
 
57
+ ## Persistent chat
58
+
59
+ Run `truss-cli chat` without a prompt to keep one local conversation open. The configuration flags supplied when it starts remain active, and history remains available as you send messages.
60
+
61
+ ```sh
62
+ truss-cli chat --mode edit --permission auto-read
63
+ ```
64
+
65
+ Use `:mode chat`, `:mode plan`, or `:mode edit` to change the active mode. You can also prefix a message with `--mode edit`, for example `--mode edit Inspect the repository and explain it.` The mode change persists for the rest of that conversation. Use `:clear` for a fresh conversation, `:help` for controls, and `:exit` to close it.
66
+
53
67
  ## Workspace commands
54
68
 
55
69
  These deterministic commands work without a configured model:
package/dist/bin.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { cwd } from "node:process";
3
+ import { createInterface } from "node:readline/promises";
3
4
  import { detectLocalEndpoints, listLocalModels } from "@truss-harness/provider-openai-compatible";
4
5
  import { brand } from "@truss-harness/branding";
5
6
  import { executeWorkspaceCommand, workspaceCommandHelp } from "@truss-harness/runtime";
6
7
  import { createClientRuntime } from "./runtime.js";
7
- import { configurationPaths, initializeWorkspaceConfiguration, parseConfigurationOverrides, resolveConfiguration } from "./config.js";
8
+ import { configurationPaths, initializeWorkspaceConfiguration, parseConfigurationOverrides, resolveConfiguration, saveUserProfile } from "./config.js";
8
9
  import { ProtocolToolApproval, runService } from "./protocol.js";
9
10
  const help = `${brand.productName} CLI
10
11
 
@@ -16,11 +17,14 @@ Usage:
16
17
  Quick start:
17
18
  1. Start Ollama, LM Studio, llama.cpp, or a compatible local server.
18
19
  2. Run: ${brand.cliCommand} models
19
- 3. Run: ${brand.cliCommand} chat "Explain this workspace"
20
- 4. Optional: ${brand.cliCommand} config init
20
+ 3. Run: ${brand.cliCommand} chat "Explain this workspace"
21
+ 3. Run: ${brand.cliCommand} setup
22
+ 4. Run: ${brand.cliCommand} chat --mode edit
23
+ 5. Optional: ${brand.cliCommand} config init
21
24
 
22
25
  Commands:
23
- chat <prompt> Stream one agent response in the current workspace
26
+ chat [prompt] Stream one response or open persistent chat
27
+ setup Interactively choose and save local-model defaults
24
28
  models List detected local servers and models
25
29
  config path Print user and workspace configuration paths
26
30
  config init Create a workspace configuration template
@@ -74,6 +78,149 @@ Examples:
74
78
 
75
79
  ${workspaceCommandHelp()}
76
80
  `;
81
+ function subscribeToRuntimeEvents(events) {
82
+ events.subscribe((event) => {
83
+ if (event.type === "text_delta")
84
+ process.stdout.write(event.text);
85
+ if (event.type === "tool_call_requested")
86
+ process.stderr.write("\n[tool] " + event.tool + "\n");
87
+ if (event.type === "plan_updated") {
88
+ const steps = event.plan.steps.map((step) => " " + (step.status === "completed" ? "[x]" : step.status === "in_progress" ? "[..]" : "[ ]") + " " + step.content).join("\n");
89
+ process.stderr.write("\n[plan] " + event.plan.title + "\n" + steps + "\n");
90
+ }
91
+ });
92
+ }
93
+ function inlineMode(input) {
94
+ const match = input.trim().match(/^--mode\s+(chat|plan|edit)(?:\s+([\s\S]*))?$/);
95
+ return match ? { mode: match[1], prompt: match[2]?.trim() ?? "" } : { prompt: input.trim() };
96
+ }
97
+ async function runInteractiveChat(initialConfiguration) {
98
+ let configuration = initialConfiguration;
99
+ let client = await createClientRuntime(configuration);
100
+ let session = await client.runtime.createSession();
101
+ let messages = session.messages;
102
+ subscribeToRuntimeEvents(client.events);
103
+ const readline = createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY === true });
104
+ process.stdout.write(brand.productName + " interactive chat. :help for controls. Mode: " + configuration.mode + ".\n");
105
+ const replaceRuntime = async (mode) => {
106
+ const current = await client.runtime.getSession(session.id);
107
+ messages = current?.messages ?? messages;
108
+ await client.dispose();
109
+ configuration = { ...configuration, mode };
110
+ client = await createClientRuntime(configuration);
111
+ session = await client.runtime.createSession(messages);
112
+ subscribeToRuntimeEvents(client.events);
113
+ process.stdout.write("\n[mode: " + mode + "]\n");
114
+ };
115
+ try {
116
+ while (true) {
117
+ const line = await readline.question(brand.cliCommand + " (" + configuration.mode + ") > ").catch(() => undefined);
118
+ if (line === undefined)
119
+ break;
120
+ const input = line.trim();
121
+ if (!input)
122
+ continue;
123
+ if (input === ":exit" || input === ":quit")
124
+ break;
125
+ if (input === ":help") {
126
+ process.stdout.write("Controls: :mode chat|plan|edit, :clear, :exit. You can also prefix a message with --mode chat|plan|edit.\n");
127
+ continue;
128
+ }
129
+ if (input === ":clear") {
130
+ session = await client.runtime.createSession();
131
+ messages = session.messages;
132
+ process.stdout.write("[conversation cleared]\n");
133
+ continue;
134
+ }
135
+ const modeCommand = input.match(/^:mode\s+(chat|plan|edit)$/);
136
+ if (modeCommand) {
137
+ await replaceRuntime(modeCommand[1]);
138
+ continue;
139
+ }
140
+ if (input.startsWith(":mode")) {
141
+ process.stdout.write("Use :mode chat, :mode plan, or :mode edit.\n");
142
+ continue;
143
+ }
144
+ const next = inlineMode(input);
145
+ if (next.mode && next.mode !== configuration.mode)
146
+ await replaceRuntime(next.mode);
147
+ if (!next.prompt)
148
+ continue;
149
+ const workspaceCommand = await executeWorkspaceCommand({ workspaceRoot: cwd(), input: next.prompt });
150
+ if (workspaceCommand.handled) {
151
+ process.stdout.write(workspaceCommand.message + "\n");
152
+ continue;
153
+ }
154
+ try {
155
+ await client.runtime.run(session.id, next.prompt);
156
+ process.stdout.write("\n");
157
+ }
158
+ catch (error) {
159
+ process.stderr.write("\n" + (error instanceof Error ? error.message : String(error)) + "\n");
160
+ }
161
+ }
162
+ }
163
+ finally {
164
+ readline.close();
165
+ await client.dispose();
166
+ }
167
+ }
168
+ function selectedIndex(input, count, fallback) {
169
+ const index = Number.parseInt(input, 10);
170
+ return Number.isInteger(index) && index >= 1 && index <= count ? index - 1 : fallback;
171
+ }
172
+ async function runSetup() {
173
+ const readline = createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY === true });
174
+ const ask = async (label, fallback) => {
175
+ const answer = (await readline.question(label + " [" + fallback + "]: ")).trim();
176
+ return answer || fallback;
177
+ };
178
+ try {
179
+ const endpoints = await detectLocalEndpoints();
180
+ process.stdout.write(brand.productName + " setup\n");
181
+ process.stdout.write("Choose a local model server. Values in brackets are defaults.\n\n");
182
+ endpoints.forEach((endpoint, index) => process.stdout.write(String(index + 1) + ". " + endpoint.label + " (" + endpoint.baseUrl + ")\n"));
183
+ process.stdout.write(String(endpoints.length + 1) + ". Custom endpoint\n");
184
+ const endpointChoice = selectedIndex(await ask("Server", "1"), endpoints.length + 1, 0);
185
+ let endpoint;
186
+ if (endpointChoice < endpoints.length) {
187
+ endpoint = endpoints[endpointChoice];
188
+ }
189
+ else {
190
+ const provider = await ask("Provider: ollama or openai-compatible", "openai-compatible");
191
+ if (provider !== "ollama" && provider !== "openai-compatible")
192
+ throw new Error("Provider must be ollama or openai-compatible.");
193
+ const fallbackUrl = provider === "ollama" ? "http://127.0.0.1:11434" : "http://127.0.0.1:1234/v1";
194
+ endpoint = { id: "custom", label: "Custom endpoint", kind: provider, baseUrl: await ask("Endpoint URL", fallbackUrl) };
195
+ }
196
+ const models = await listLocalModels(endpoint).catch(() => []);
197
+ models.forEach((model, index) => process.stdout.write(String(index + 1) + ". " + model.name + "\n"));
198
+ const model = models.length
199
+ ? models[selectedIndex(await ask("Model", "1"), models.length, 0)].name
200
+ : await ask("Model ID", "local-model");
201
+ const profileName = await ask("Profile name", endpoint.id === "custom" ? "local" : endpoint.id);
202
+ const mode = await ask("Default mode: chat, plan, or edit", "edit");
203
+ if (mode !== "chat" && mode !== "plan" && mode !== "edit")
204
+ throw new Error("Mode must be chat, plan, or edit.");
205
+ const permission = await ask("Default permission: ask, auto-read, or auto-all", "auto-read");
206
+ if (permission !== "ask" && permission !== "auto-read" && permission !== "auto-all")
207
+ throw new Error("Permission must be ask, auto-read, or auto-all.");
208
+ const internet = await ask("Enable internet research: yes or no", "no");
209
+ const path = await saveUserProfile(cwd(), profileName, {
210
+ provider: endpoint.kind,
211
+ baseUrl: endpoint.baseUrl,
212
+ model,
213
+ mode,
214
+ permission,
215
+ internetAccess: /^(y|yes|true|1)$/i.test(internet)
216
+ });
217
+ process.stdout.write("\nSaved profile '" + profileName + "' to " + path + "\n");
218
+ process.stdout.write("Start a persistent session with: " + brand.cliCommand + " chat --profile " + profileName + "\n");
219
+ }
220
+ finally {
221
+ readline.close();
222
+ }
223
+ }
77
224
  async function main() {
78
225
  const [command = "help", ...rawArgs] = process.argv.slice(2);
79
226
  if (command === "help" || command === "--help" || command === "-h") {
@@ -86,6 +233,10 @@ async function main() {
86
233
  process.stdout.write(`${JSON.stringify(models, null, 2)}\n`);
87
234
  return;
88
235
  }
236
+ if (command === "setup") {
237
+ await runSetup();
238
+ return;
239
+ }
89
240
  if (command === "config") {
90
241
  const [action = "path"] = rawArgs;
91
242
  const paths = configurationPaths(cwd());
@@ -113,7 +264,7 @@ async function main() {
113
264
  process.exitCode = 1;
114
265
  return;
115
266
  }
116
- if (command === "chat") {
267
+ if (command === "chat" && args.length) {
117
268
  const prompt = args.join(" ").trim();
118
269
  if (!prompt)
119
270
  throw new Error(`Provide a prompt: ${brand.cliCommand} chat <prompt>`);
@@ -140,6 +291,10 @@ async function main() {
140
291
  }
141
292
  return;
142
293
  }
294
+ if (command === "chat" && !args.join(" ").trim()) {
295
+ await runInteractiveChat(configuration);
296
+ return;
297
+ }
143
298
  const client = await createClientRuntime(configuration);
144
299
  const { runtime, events } = client;
145
300
  for (const server of client.mcpServers) {
package/dist/config.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type McpServerConfigurations } from "@truss-harness/mcp";
2
2
  import { type LocalEndpointKind } from "@truss-harness/provider-openai-compatible";
3
3
  import type { AgentMode, ClientConfiguration } from "./runtime.js";
4
4
  import type { PermissionMode } from "./protocol.js";
5
+ export type TuiThemeName = "forest" | "sage" | "dusk";
5
6
  export interface ProfileConfiguration {
6
7
  readonly provider?: LocalEndpointKind;
7
8
  readonly baseUrl?: string;
@@ -13,6 +14,8 @@ export interface ProfileConfiguration {
13
14
  /** Name of an environment variable containing a local endpoint token. */
14
15
  readonly apiKeyEnv?: string;
15
16
  readonly mcpServers?: McpServerConfigurations;
17
+ /** Visual preset used by the terminal UI. */
18
+ readonly tuiTheme?: TuiThemeName;
16
19
  }
17
20
  export interface HarnessConfiguration extends ProfileConfiguration {
18
21
  readonly defaultProfile?: string;
@@ -26,6 +29,7 @@ export interface ConfigurationOverrides extends ProfileConfiguration {
26
29
  export interface ResolvedConfiguration extends ClientConfiguration {
27
30
  readonly profile?: string;
28
31
  readonly permission: PermissionMode;
32
+ readonly tuiTheme?: TuiThemeName;
29
33
  readonly paths: {
30
34
  readonly user: string;
31
35
  readonly workspace: string;
@@ -43,6 +47,8 @@ export declare function resolveConfiguration(options: {
43
47
  readonly paths?: ConfigurationPaths;
44
48
  }): Promise<ResolvedConfiguration>;
45
49
  export declare function initializeWorkspaceConfiguration(workspaceRoot: string, paths?: ConfigurationPaths): Promise<string>;
50
+ /** Saves a selected local-model profile in the user configuration file. */
51
+ export declare function saveUserProfile(workspaceRoot: string, profileName: string, profile: ProfileConfiguration, paths?: ConfigurationPaths): Promise<string>;
46
52
  export declare function parseConfigurationOverrides(arguments_: readonly string[]): {
47
53
  readonly overrides: ConfigurationOverrides;
48
54
  readonly rest: readonly string[];
package/dist/config.js CHANGED
@@ -13,6 +13,9 @@ function validMode(value) {
13
13
  function validPermission(value) {
14
14
  return value === "ask" || value === "auto-read" || value === "auto-all" ? value : undefined;
15
15
  }
16
+ function validTuiTheme(value) {
17
+ return value === "forest" || value === "sage" || value === "dusk" ? value : undefined;
18
+ }
16
19
  function object(value) {
17
20
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
18
21
  }
@@ -29,7 +32,8 @@ function parseProfile(value) {
29
32
  internetAccess: typeof source.internetAccess === "boolean" ? source.internetAccess : undefined,
30
33
  systemPrompt: typeof source.systemPrompt === "string" ? source.systemPrompt : undefined,
31
34
  apiKeyEnv: typeof source.apiKeyEnv === "string" ? source.apiKeyEnv : undefined,
32
- mcpServers: source.mcpServers === undefined ? undefined : parseMcpServerConfigurations(source.mcpServers)
35
+ mcpServers: source.mcpServers === undefined ? undefined : parseMcpServerConfigurations(source.mcpServers),
36
+ tuiTheme: validTuiTheme(source.tuiTheme)
33
37
  };
34
38
  }
35
39
  function parseConfiguration(value) {
@@ -79,7 +83,8 @@ function environmentConfiguration(environment) {
79
83
  : environment.TRUSS_HARNESS_INTERNET_ACCESS === "true" || environment.TRUSS_HARNESS_INTERNET_ACCESS === "1",
80
84
  systemPrompt: environment.TRUSS_HARNESS_SYSTEM_PROMPT,
81
85
  apiKeyEnv: environment.TRUSS_HARNESS_API_KEY ? "TRUSS_HARNESS_API_KEY" : undefined,
82
- mcpServers
86
+ mcpServers,
87
+ tuiTheme: validTuiTheme(environment.TRUSS_HARNESS_TUI_THEME)
83
88
  };
84
89
  }
85
90
  const profileKeys = [
@@ -91,7 +96,8 @@ const profileKeys = [
91
96
  "internetAccess",
92
97
  "systemPrompt",
93
98
  "apiKeyEnv",
94
- "mcpServers"
99
+ "mcpServers",
100
+ "tuiTheme"
95
101
  ];
96
102
  function mergeProfiles(...sources) {
97
103
  const merged = {};
@@ -165,6 +171,7 @@ export async function resolveConfiguration(options) {
165
171
  systemPrompt: merged.systemPrompt,
166
172
  mcpServers,
167
173
  profile,
174
+ tuiTheme: merged.tuiTheme,
168
175
  paths
169
176
  };
170
177
  }
@@ -208,6 +215,20 @@ export async function initializeWorkspaceConfiguration(workspaceRoot, paths = co
208
215
  }, null, 2)}\n`, "utf8");
209
216
  return paths.workspace;
210
217
  }
218
+ /** Saves a selected local-model profile in the user configuration file. */
219
+ export async function saveUserProfile(workspaceRoot, profileName, profile, paths = configurationPaths(workspaceRoot)) {
220
+ const existing = await readConfiguration(paths.user);
221
+ await mkdir(dirname(paths.user), { recursive: true });
222
+ await writeFile(paths.user, JSON.stringify({
223
+ ...existing,
224
+ defaultProfile: profileName,
225
+ profiles: {
226
+ ...existing.profiles,
227
+ [profileName]: profile
228
+ }
229
+ }, null, 2) + "\n", "utf8");
230
+ return paths.user;
231
+ }
211
232
  export function parseConfigurationOverrides(arguments_) {
212
233
  const overrides = {};
213
234
  const rest = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truss-harness/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Command-line client and runtime service for Truss.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,7 +14,7 @@
14
14
  }
15
15
  },
16
16
  "bin": {
17
- "truss-cli": "./dist/bin.js"
17
+ "truss-cli": "dist/bin.js"
18
18
  },
19
19
  "scripts": {
20
20
  "build": "tsc -b",
@@ -3,9 +3,8 @@ if (process.env.npm_config_global === "true" || process.env.npm_config_global ==
3
3
  Truss CLI installed.
4
4
 
5
5
  Next steps:
6
- truss-cli models
7
- truss-cli config init
8
- truss-cli chat "Explain this workspace"
6
+ truss-cli setup
7
+ truss-cli chat --mode edit
9
8
 
10
9
  Run truss-cli help for modes, permissions, configuration, and examples.
11
10
  `);