@lasso-ai/cli 1.0.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/ARCHITECTURE.md +342 -0
- package/CHANGELOG.md +102 -0
- package/CODE_OF_CONDUCT.md +129 -0
- package/CONTRIBUTING.md +122 -0
- package/LICENSE +7 -0
- package/README.md +548 -0
- package/SECURITY.md +65 -0
- package/SUPPORT.md +31 -0
- package/dist/cli/agent.d.ts +61 -0
- package/dist/cli/agent.js +418 -0
- package/dist/cli/auth.d.ts +38 -0
- package/dist/cli/auth.js +162 -0
- package/dist/cli/bridge.d.ts +160 -0
- package/dist/cli/bridge.js +450 -0
- package/dist/cli/host/certificates.d.ts +12 -0
- package/dist/cli/host/certificates.js +52 -0
- package/dist/cli/host/client.d.ts +49 -0
- package/dist/cli/host/client.js +141 -0
- package/dist/cli/host/daemon.d.ts +50 -0
- package/dist/cli/host/daemon.js +368 -0
- package/dist/cli/host/dns.d.ts +80 -0
- package/dist/cli/host/dns.js +255 -0
- package/dist/cli/host/install.d.ts +32 -0
- package/dist/cli/host/install.js +177 -0
- package/dist/cli/host/next-host-entry.d.ts +1 -0
- package/dist/cli/host/next-host-entry.js +82 -0
- package/dist/cli/host/paths.d.ts +15 -0
- package/dist/cli/host/paths.js +55 -0
- package/dist/cli/host/registry.d.ts +31 -0
- package/dist/cli/host/registry.js +103 -0
- package/dist/cli/host/runtime.d.ts +23 -0
- package/dist/cli/host/runtime.js +134 -0
- package/dist/cli/host/vite-host-entry.d.ts +1 -0
- package/dist/cli/host/vite-host-entry.js +59 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +322 -0
- package/dist/cli/project.d.ts +56 -0
- package/dist/cli/project.js +298 -0
- package/dist/cli/server/next.d.ts +6 -0
- package/dist/cli/server/next.js +76 -0
- package/dist/cli/server/vite.d.ts +1 -0
- package/dist/cli/server/vite.js +51 -0
- package/dist/cli/utils/framework.d.ts +2 -0
- package/dist/cli/utils/framework.js +26 -0
- package/dist/overlay.js +20365 -0
- package/docs/PUBLISHING.md +63 -0
- package/package.json +106 -0
package/SECURITY.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
Lasso runs locally on your machine and, by design, keeps your code on your machine: no
|
|
4
|
+
telemetry, and your source files are only sent to whichever coding agent you configured.
|
|
5
|
+
That trust model is the core of this project, and we treat anything that breaks it as a
|
|
6
|
+
security bug.
|
|
7
|
+
|
|
8
|
+
## Supported versions
|
|
9
|
+
|
|
10
|
+
| Version | Supported |
|
|
11
|
+
| ------- | ------------------ |
|
|
12
|
+
| 0.1.x | :white_check_mark: |
|
|
13
|
+
|
|
14
|
+
Older versions are not supported. If you are unable to upgrade, please still report the
|
|
15
|
+
issue so we can decide whether a backport is warranted.
|
|
16
|
+
|
|
17
|
+
## Reporting a vulnerability
|
|
18
|
+
|
|
19
|
+
**Please do not open a public issue for security vulnerabilities.** Report them privately
|
|
20
|
+
to the maintainers so we can fix the issue before it is disclosed.
|
|
21
|
+
|
|
22
|
+
- [GitHub private vulnerability reporting](https://github.com/codewithdripzy/lasso/security/advisories/new)
|
|
23
|
+
(preferred).
|
|
24
|
+
- Email the maintainer via the contact on [buymeacoffee.com/thecodeguyy](https://www.buymeacoffee.com/thecodeguyy)
|
|
25
|
+
if you cannot use GitHub.
|
|
26
|
+
|
|
27
|
+
Please include:
|
|
28
|
+
|
|
29
|
+
1. A description of the vulnerability and its impact (be specific about what an attacker
|
|
30
|
+
could and could not do).
|
|
31
|
+
2. Steps to reproduce, or a minimal PoC. Prefer a dropped text file / script over a
|
|
32
|
+
screenshot.
|
|
33
|
+
3. Affected versions and platform (OS, Node version, framework + version).
|
|
34
|
+
4. Any suggested fix, if you have one.
|
|
35
|
+
|
|
36
|
+
The report does **not** need to include your source code — and usually should not. If a
|
|
37
|
+
bug requires an app to trigger it, provide the smallest synthetic repro you can.
|
|
38
|
+
|
|
39
|
+
## What we care about most
|
|
40
|
+
|
|
41
|
+
Things that break the local-first trust model:
|
|
42
|
+
|
|
43
|
+
- Any path where a user's **source code or screenshots leak** beyond the configured agent.
|
|
44
|
+
- Telemetry being **enabled without explicit consent**.
|
|
45
|
+
- The overlay mutating the **live DOM** (a behavioral regression, but can become a security
|
|
46
|
+
issue via injected content from untrusted pages).
|
|
47
|
+
- Arbitrary code execution through a malicious config / project — e.g. `lasso.config.json`,
|
|
48
|
+
a compromised agent adapter, or path traversal when resolving `data-source` attributes.
|
|
49
|
+
- Diff application that writes outside the intended project directory.
|
|
50
|
+
|
|
51
|
+
## Handling process
|
|
52
|
+
|
|
53
|
+
1. You report privately; we acknowledge within **72 hours**.
|
|
54
|
+
2. We reproduce, assess severity, and work on a fix on a private branch.
|
|
55
|
+
3. We release a patched version, then coordinate disclosure with you (typical window:
|
|
56
|
+
7–14 days after the release).
|
|
57
|
+
|
|
58
|
+
## Good-faith note
|
|
59
|
+
|
|
60
|
+
For the code above that does not deal with other people's data, we are happy for you to do
|
|
61
|
+
research against your own projects and report what you find. Please do not attack
|
|
62
|
+
third-party apps running Lasso, and please do not test by attempting to exfiltrate others'
|
|
63
|
+
source files.
|
|
64
|
+
|
|
65
|
+
Thanks for helping keep a local-first tool local-first.
|
package/SUPPORT.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Support
|
|
2
|
+
|
|
3
|
+
Lasso is an open-source project maintained by volunteers. We're happy to help — here's the
|
|
4
|
+
best place for each kind of question.
|
|
5
|
+
|
|
6
|
+
## Bugs and feature requests
|
|
7
|
+
|
|
8
|
+
- **Report a bug**: open a [GitHub issue](https://github.com/codewithdripzy/lasso/issues) with
|
|
9
|
+
the framework you're using, your Node version, and a minimal repro.
|
|
10
|
+
- **Request a feature**: open an issue and label it `enhancement`. Existing roadmap items
|
|
11
|
+
and open design questions live at the bottom of [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
12
|
+
- **Security vulnerability**: do **not** open an issue — use the private contact in
|
|
13
|
+
[SECURITY.md](SECURITY.md).
|
|
14
|
+
|
|
15
|
+
## Real-time help
|
|
16
|
+
|
|
17
|
+
- [GitHub Discussions](https://github.com/codewithdripzy/lasso/discussions) for general
|
|
18
|
+
questions, "how do I…" help, and show-and-tell.
|
|
19
|
+
- Product docs and guides: [lasso.byorello.space](https://lasso.byorello.space).
|
|
20
|
+
|
|
21
|
+
## Getting help faster
|
|
22
|
+
|
|
23
|
+
- Read [README.md](README.md) and [ARCHITECTURE.md](ARCHITECTURE.md) first; most
|
|
24
|
+
"why does it do that?" questions are answered there.
|
|
25
|
+
- Search existing issues before opening a new one.
|
|
26
|
+
- Include versions: `node --version`, the framework version, and Lasso (`npx lasso --version`).
|
|
27
|
+
|
|
28
|
+
## No private support
|
|
29
|
+
|
|
30
|
+
Please keep questions public so others can benefit from the answers. We don't provide
|
|
31
|
+
private 1:1 support for the open-source version.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type SourceChange = {
|
|
2
|
+
filePath: string;
|
|
3
|
+
oldString: string;
|
|
4
|
+
newString: string;
|
|
5
|
+
};
|
|
6
|
+
type AgentInput = {
|
|
7
|
+
instruction: string;
|
|
8
|
+
model: string;
|
|
9
|
+
messages?: Array<{
|
|
10
|
+
role: string;
|
|
11
|
+
content: string;
|
|
12
|
+
createdAt?: string;
|
|
13
|
+
}>;
|
|
14
|
+
changesHistory?: Array<{
|
|
15
|
+
summary: string;
|
|
16
|
+
changes: SourceChange[];
|
|
17
|
+
createdAt?: string;
|
|
18
|
+
}>;
|
|
19
|
+
context?: {
|
|
20
|
+
selectionId?: string;
|
|
21
|
+
position?: Record<string, number>;
|
|
22
|
+
viewport?: Record<string, unknown>;
|
|
23
|
+
styles?: Record<string, string>;
|
|
24
|
+
attributes?: Record<string, string>;
|
|
25
|
+
runtimeErrors?: string[];
|
|
26
|
+
screenshots?: {
|
|
27
|
+
full?: string;
|
|
28
|
+
element?: string;
|
|
29
|
+
};
|
|
30
|
+
drag?: Record<string, any>;
|
|
31
|
+
};
|
|
32
|
+
element: {
|
|
33
|
+
tag: string;
|
|
34
|
+
group: string;
|
|
35
|
+
label: string;
|
|
36
|
+
html?: string;
|
|
37
|
+
sourceHint?: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
export type AgentConfig = {
|
|
41
|
+
provider: "anthropic" | "openai" | "google" | "ollama" | "claude-code" | "codex" | "opencode";
|
|
42
|
+
apiKey?: string;
|
|
43
|
+
model?: string;
|
|
44
|
+
baseUrl?: string;
|
|
45
|
+
};
|
|
46
|
+
export type LocalAgent = "claude-code" | "codex" | "opencode";
|
|
47
|
+
export type AgentProgress = (message: string) => void;
|
|
48
|
+
export type AgentAnswer = {
|
|
49
|
+
question: string;
|
|
50
|
+
context?: AgentInput["context"];
|
|
51
|
+
element: AgentInput["element"];
|
|
52
|
+
messages?: AgentInput["messages"];
|
|
53
|
+
};
|
|
54
|
+
export declare function detectLocalAgents(): Promise<Set<LocalAgent>>;
|
|
55
|
+
export declare function proposeChanges(cwd: string, input: AgentInput, config: AgentConfig, signal?: AbortSignal, onProgress?: AgentProgress): Promise<{
|
|
56
|
+
summary: string;
|
|
57
|
+
changes: SourceChange[];
|
|
58
|
+
}>;
|
|
59
|
+
export declare function answerQuestion(cwd: string, input: AgentAnswer, config: AgentConfig, signal?: AbortSignal, onProgress?: AgentProgress): Promise<string>;
|
|
60
|
+
export declare function generateCommitMessage(cwd: string, status: string[], config: AgentConfig, signal?: AbortSignal, onProgress?: AgentProgress): Promise<string>;
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.detectLocalAgents = detectLocalAgents;
|
|
7
|
+
exports.proposeChanges = proposeChanges;
|
|
8
|
+
exports.answerQuestion = answerQuestion;
|
|
9
|
+
exports.generateCommitMessage = generateCommitMessage;
|
|
10
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const node_child_process_1 = require("node:child_process");
|
|
13
|
+
const node_util_1 = require("node:util");
|
|
14
|
+
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
15
|
+
async function detectLocalAgents() {
|
|
16
|
+
const found = new Set();
|
|
17
|
+
for (const [name, command] of [["claude-code", "claude"], ["codex", "codex"], ["opencode", "opencode"]]) {
|
|
18
|
+
try {
|
|
19
|
+
await execFileAsync(process.platform === "win32" ? "where.exe" : "which", [command]);
|
|
20
|
+
found.add(name);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Local coding CLIs are optional.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return found;
|
|
27
|
+
}
|
|
28
|
+
const ignored = new Set(["node_modules", ".git", ".next", "dist", "build", ".turbo"]);
|
|
29
|
+
const sourceExtensions = /\.(tsx?|jsx?|vue|svelte|css|scss|html)$/i;
|
|
30
|
+
async function sourceFiles(directory) {
|
|
31
|
+
const entries = await promises_1.default.readdir(directory, { withFileTypes: true });
|
|
32
|
+
const files = [];
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (ignored.has(entry.name) || entry.name.startsWith("."))
|
|
35
|
+
continue;
|
|
36
|
+
const fullPath = node_path_1.default.join(directory, entry.name);
|
|
37
|
+
if (entry.isDirectory())
|
|
38
|
+
files.push(...(await sourceFiles(fullPath)));
|
|
39
|
+
else if (sourceExtensions.test(entry.name))
|
|
40
|
+
files.push(fullPath);
|
|
41
|
+
if (files.length >= 80)
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
return files;
|
|
45
|
+
}
|
|
46
|
+
async function contextFor(cwd, element) {
|
|
47
|
+
const files = await sourceFiles(cwd);
|
|
48
|
+
const needle = element.sourceHint || element.label.replace(/^[^.#]+[.#]?/, "");
|
|
49
|
+
const sourceFile = element.sourceHint ? node_path_1.default.basename(element.sourceHint.split(":")[0]) : "";
|
|
50
|
+
const snippets = [];
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
if (snippets.length >= 8)
|
|
53
|
+
break;
|
|
54
|
+
try {
|
|
55
|
+
const content = await promises_1.default.readFile(file, "utf8");
|
|
56
|
+
if (!needle || (sourceFile && file.endsWith(sourceFile)) || content.includes(needle) || content.includes(element.label)) {
|
|
57
|
+
snippets.push(`FILE: ${node_path_1.default.relative(cwd, file)}\n${content.slice(0, 12000)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// A file can disappear while a dev server is rebuilding; skip it.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return snippets.join("\n\n---\n\n");
|
|
65
|
+
}
|
|
66
|
+
function jsonFrom(text) {
|
|
67
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] || text;
|
|
68
|
+
const parsed = JSON.parse(fenced.trim());
|
|
69
|
+
if (!Array.isArray(parsed.changes))
|
|
70
|
+
throw new Error("The agent returned no reviewable changes.");
|
|
71
|
+
for (const change of parsed.changes) {
|
|
72
|
+
if (!change.filePath || typeof change.oldString !== "string" || typeof change.newString !== "string") {
|
|
73
|
+
throw new Error("The agent returned an invalid file change.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { summary: parsed.summary || "The proposed source changes are ready for review.", changes: parsed.changes };
|
|
77
|
+
}
|
|
78
|
+
function extractLocalAgentText(raw, provider) {
|
|
79
|
+
if (provider === "claude-code") {
|
|
80
|
+
try {
|
|
81
|
+
const payload = JSON.parse(raw);
|
|
82
|
+
if (payload.result)
|
|
83
|
+
return payload.result;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Stream-json output is handled below.
|
|
87
|
+
}
|
|
88
|
+
const results = [];
|
|
89
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
90
|
+
try {
|
|
91
|
+
const event = JSON.parse(line);
|
|
92
|
+
if (typeof event.result === "string")
|
|
93
|
+
results.push(event.result);
|
|
94
|
+
const message = event.message;
|
|
95
|
+
for (const part of message?.content || [])
|
|
96
|
+
if (part.type === "text" && part.text)
|
|
97
|
+
results.push(part.text);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Ignore progress lines that are not JSON.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return results.at(-1) || raw;
|
|
104
|
+
}
|
|
105
|
+
const texts = [];
|
|
106
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
107
|
+
try {
|
|
108
|
+
const event = JSON.parse(line);
|
|
109
|
+
const item = event.item;
|
|
110
|
+
const part = event.part;
|
|
111
|
+
const candidates = provider === "opencode"
|
|
112
|
+
? [part?.type === "text" ? part.text : undefined, event.text, event.output_text]
|
|
113
|
+
: [event.text, event.output_text, item?.text, item?.output_text, item?.message];
|
|
114
|
+
for (const candidate of candidates) {
|
|
115
|
+
if (typeof candidate === "string" && candidate.trim())
|
|
116
|
+
texts.push(candidate);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Ignore non-JSON progress lines.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return texts.at(-1) || raw;
|
|
124
|
+
}
|
|
125
|
+
/** Truncate a detail string to a readable length. */
|
|
126
|
+
function snippet(value, max = 80) {
|
|
127
|
+
const s = String(value ?? "").trim().replace(/\s+/g, " ");
|
|
128
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
129
|
+
}
|
|
130
|
+
function progressFromLine(raw, provider) {
|
|
131
|
+
try {
|
|
132
|
+
const event = JSON.parse(raw);
|
|
133
|
+
const item = event.item;
|
|
134
|
+
if (provider === "claude-code") {
|
|
135
|
+
if (event.type === "system")
|
|
136
|
+
return "Claude Code connected";
|
|
137
|
+
// tool_use blocks inside assistant messages
|
|
138
|
+
const toolBlock = event.message?.content?.find?.((p) => p.type === "tool_use");
|
|
139
|
+
if (toolBlock) {
|
|
140
|
+
const toolName = toolBlock.name || "tool";
|
|
141
|
+
const inp = toolBlock.input;
|
|
142
|
+
const detail = inp?.file_path ?? inp?.path ?? inp?.command ?? inp?.query ?? inp?.url ?? "";
|
|
143
|
+
return detail
|
|
144
|
+
? `Claude Code · ${toolName} ${snippet(detail)}`
|
|
145
|
+
: `Claude Code · ${toolName}`;
|
|
146
|
+
}
|
|
147
|
+
// thinking blocks inside assistant messages
|
|
148
|
+
const thinkBlock = event.message?.content?.find?.((p) => p.type === "thinking");
|
|
149
|
+
if (thinkBlock?.thinking) {
|
|
150
|
+
return `Claude Code · ${snippet(thinkBlock.thinking, 100)}`;
|
|
151
|
+
}
|
|
152
|
+
// top-level tool event fields (stream-json verbose format)
|
|
153
|
+
const tool = event.tool_name || event.name || event.tool?.name;
|
|
154
|
+
if (tool) {
|
|
155
|
+
const inp = event.tool_input;
|
|
156
|
+
const detail = inp?.file_path ?? inp?.path ?? inp?.command ?? inp?.query ?? inp?.url ?? "";
|
|
157
|
+
return detail
|
|
158
|
+
? `Claude Code · ${tool} ${snippet(detail)}`
|
|
159
|
+
: `Claude Code · ${tool}`;
|
|
160
|
+
}
|
|
161
|
+
if (event.type === "result" || event.result)
|
|
162
|
+
return "Claude Code · preparing the proposal";
|
|
163
|
+
if (event.type === "assistant")
|
|
164
|
+
return "Claude Code · reasoning about the change";
|
|
165
|
+
}
|
|
166
|
+
else if (provider === "codex") {
|
|
167
|
+
const type = item?.type || event.type || "";
|
|
168
|
+
if (type === "command_execution" || type === "command_execution_output") {
|
|
169
|
+
const cmd = item?.command || event.command || "";
|
|
170
|
+
return cmd ? `Codex · Bash ${snippet(cmd)}` : "Codex · running a command";
|
|
171
|
+
}
|
|
172
|
+
if (type === "file_read" || type === "read_file") {
|
|
173
|
+
const fp = item?.path || event.path || "";
|
|
174
|
+
return fp ? `Codex · Read ${snippet(fp)}` : "Codex · reading a file";
|
|
175
|
+
}
|
|
176
|
+
if (type === "agent_message" || type === "message")
|
|
177
|
+
return "Codex · drafting the proposal";
|
|
178
|
+
if (type === "reasoning") {
|
|
179
|
+
const text = item?.content || event.content || "";
|
|
180
|
+
return text ? `Codex · ${snippet(text, 100)}` : "Codex · reasoning";
|
|
181
|
+
}
|
|
182
|
+
if (type === "turn.started" || type === "turn_start")
|
|
183
|
+
return "Codex · starting a turn";
|
|
184
|
+
if (type === "turn.completed" || type === "turn_complete")
|
|
185
|
+
return "Codex · preparing the proposal";
|
|
186
|
+
}
|
|
187
|
+
else if (provider === "opencode") {
|
|
188
|
+
const part = event.part;
|
|
189
|
+
if (event.type === "step-start")
|
|
190
|
+
return "OpenCode · starting a step";
|
|
191
|
+
if (part?.type === "tool") {
|
|
192
|
+
const toolName = part.tool || "tool";
|
|
193
|
+
const inp = part.input;
|
|
194
|
+
const detail = inp?.file_path ?? inp?.path ?? inp?.command ?? inp?.query ?? inp?.url ?? "";
|
|
195
|
+
return detail
|
|
196
|
+
? `OpenCode · ${toolName} ${snippet(detail)}`
|
|
197
|
+
: `OpenCode · ${toolName}`;
|
|
198
|
+
}
|
|
199
|
+
if (part?.type === "text") {
|
|
200
|
+
const text = part.text || "";
|
|
201
|
+
return text ? `OpenCode · ${snippet(text, 100)}` : "OpenCode · drafting the response";
|
|
202
|
+
}
|
|
203
|
+
if (event.type === "step-finish")
|
|
204
|
+
return "OpenCode · finalizing the response";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Progress output is best-effort; the final parser reports malformed output.
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
function localAgentError(command, stderr, exitCode) {
|
|
213
|
+
const output = stderr.trim();
|
|
214
|
+
if (command === "codex" && output.includes("missing field `base_instructions`")) {
|
|
215
|
+
return "Codex could not read its models cache because it uses an older cache format. Update Codex, then retry. Lasso already bypasses the repository trust check.";
|
|
216
|
+
}
|
|
217
|
+
if (command === "codex" && output.includes("mcp.canva.com") && output.includes("invalid_token")) {
|
|
218
|
+
return "Codex started, but the Canva MCP connection has an expired OAuth token. Re-authenticate or remove the Canva MCP server from Codex, then retry.";
|
|
219
|
+
}
|
|
220
|
+
return `${command} exited with code ${exitCode}${output ? `: ${output.slice(0, 500)}` : ""}`;
|
|
221
|
+
}
|
|
222
|
+
function localModel(provider, model) {
|
|
223
|
+
if (!model)
|
|
224
|
+
return undefined;
|
|
225
|
+
const prefix = `${provider}:`;
|
|
226
|
+
return model.startsWith(prefix) ? model.slice(prefix.length) : model;
|
|
227
|
+
}
|
|
228
|
+
function localCommand(provider, model, prompt) {
|
|
229
|
+
const selectedModel = localModel(provider, model);
|
|
230
|
+
if (provider === "claude-code") {
|
|
231
|
+
return { command: "claude", args: ["-p", prompt || "", "--output-format", "stream-json", "--verbose", "--permission-mode", "plan", "--max-turns", "3", ...(selectedModel ? ["--model", selectedModel] : [])] };
|
|
232
|
+
}
|
|
233
|
+
if (provider === "opencode") {
|
|
234
|
+
return { command: "opencode", args: ["run", "--format", "json", ...(selectedModel ? ["--model", selectedModel] : []), prompt || ""] };
|
|
235
|
+
}
|
|
236
|
+
return { command: "codex", args: ["exec", "--json", "--sandbox", "read-only", "--skip-git-repo-check", ...(selectedModel ? ["--model", selectedModel] : []), prompt || ""] };
|
|
237
|
+
}
|
|
238
|
+
async function proposeWithLocalAgent(cwd, instruction, context, config, signal, onProgress) {
|
|
239
|
+
const outputContract = `Return ONLY valid JSON in this exact shape: {"summary":"short explanation","changes":[{"filePath":"relative/path","oldString":"exact existing text","newString":"replacement text"}]}. Every oldString must occur exactly once. Do not edit files, run write commands, commit, or produce markdown fences.`;
|
|
240
|
+
const prompt = `${instruction}\n\n${outputContract}\n\nLasso has already assembled this source context:\n${context || "No matching source context was found."}`;
|
|
241
|
+
const local = localCommand(config.provider, config.model, prompt);
|
|
242
|
+
const command = local.command;
|
|
243
|
+
const args = local.args;
|
|
244
|
+
const child = (0, node_child_process_1.spawn)(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
245
|
+
let stdout = "";
|
|
246
|
+
let stderr = "";
|
|
247
|
+
let pending = "";
|
|
248
|
+
const consume = (chunk) => {
|
|
249
|
+
pending += String(chunk);
|
|
250
|
+
const lines = pending.split(/\r?\n/);
|
|
251
|
+
pending = lines.pop() || "";
|
|
252
|
+
for (const line of lines) {
|
|
253
|
+
stdout += `${line}\n`;
|
|
254
|
+
const progress = progressFromLine(line, config.provider);
|
|
255
|
+
if (progress)
|
|
256
|
+
onProgress?.(progress);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
child.stdout.on("data", consume);
|
|
260
|
+
child.stderr.on("data", (chunk) => {
|
|
261
|
+
stderr += String(chunk);
|
|
262
|
+
});
|
|
263
|
+
if (signal) {
|
|
264
|
+
if (signal.aborted)
|
|
265
|
+
child.kill("SIGTERM");
|
|
266
|
+
signal.addEventListener("abort", () => child.kill("SIGTERM"), { once: true });
|
|
267
|
+
}
|
|
268
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
269
|
+
child.once("error", reject);
|
|
270
|
+
child.once("close", (code) => resolve(code ?? 1));
|
|
271
|
+
});
|
|
272
|
+
if (pending.trim()) {
|
|
273
|
+
stdout += pending;
|
|
274
|
+
const progress = progressFromLine(pending, config.provider);
|
|
275
|
+
if (progress)
|
|
276
|
+
onProgress?.(progress);
|
|
277
|
+
}
|
|
278
|
+
if (exitCode !== 0)
|
|
279
|
+
throw new Error(localAgentError(command, stderr, exitCode));
|
|
280
|
+
return jsonFrom(extractLocalAgentText(stdout, config.provider));
|
|
281
|
+
}
|
|
282
|
+
async function proposeChanges(cwd, input, config, signal, onProgress) {
|
|
283
|
+
const context = await contextFor(cwd, input.element);
|
|
284
|
+
const visualContext = input.context ? { ...input.context, screenshots: undefined } : undefined;
|
|
285
|
+
const history = input.messages?.map((message) => `${message.role}: ${message.content}`).join("\n") || input.instruction;
|
|
286
|
+
const priorChanges = input.changesHistory?.length ? JSON.stringify(input.changesHistory, null, 2) : "None";
|
|
287
|
+
const dragHint = input.context?.drag
|
|
288
|
+
? `\n\nDRAG REPOSITIONING TASK:\nThe user dragged this element by dx: ${input.context.drag.delta?.dx ?? 0}px, dy: ${input.context.drag.delta?.dy ?? 0}px to target coordinates (left: ${input.context.drag.targetRect?.left ?? 0}px, top: ${input.context.drag.targetRect?.top ?? 0}px). Modify the source code (CSS classes, Tailwind classes, flex/grid alignment, margin offsets, or positioning properties) so the element is accurately rendered at this target position.`
|
|
289
|
+
: "";
|
|
290
|
+
const instruction = `Selection context:\n${JSON.stringify(input.element, null, 2)}\n${JSON.stringify(visualContext, null, 2)}\n\nConversation history:\n${history}\n\nPrevious change history for this selection:\n${priorChanges}\n\nRelevant source context:\n${context || "No matching source context was found. Ask for a more specific selection rather than inventing a file."}${dragHint}\n\nReturn ONLY JSON: {"summary":"short explanation","changes":[{"filePath":"relative/path","oldString":"exact text","newString":"replacement text"}]}`;
|
|
291
|
+
if (config.provider === "claude-code" || config.provider === "codex" || config.provider === "opencode") {
|
|
292
|
+
return proposeWithLocalAgent(cwd, instruction, context, config, signal, onProgress);
|
|
293
|
+
}
|
|
294
|
+
const system = "You are Lasso, a careful source-code editing agent. Return only valid JSON. Each oldString must occur exactly once in its file. Never rewrite whole files. Keep changes focused on the request.";
|
|
295
|
+
const model = config.model || (config.provider === "google" ? "gemini-2.5-flash" : config.provider === "openai" ? "gpt-4.1-mini" : config.provider === "ollama" ? "llama3.2" : "claude-sonnet-4-20250514");
|
|
296
|
+
const image = input.context?.screenshots?.element || input.context?.screenshots?.full;
|
|
297
|
+
const imageData = image?.replace(/^data:image\/[^;]+;base64,/, "");
|
|
298
|
+
const imageMime = image?.match(/^data:(image\/[^;]+);base64,/)?.[1] || "image/jpeg";
|
|
299
|
+
let response;
|
|
300
|
+
if (config.provider === "openai" || config.provider === "ollama") {
|
|
301
|
+
const content = image ? [{ type: "text", text: instruction }, { type: "image_url", image_url: { url: image } }] : instruction;
|
|
302
|
+
response = await fetch(`${config.baseUrl || "https://api.openai.com/v1"}/chat/completions`, {
|
|
303
|
+
method: "POST",
|
|
304
|
+
signal,
|
|
305
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${config.apiKey || ""}` },
|
|
306
|
+
body: JSON.stringify({ model, temperature: 0.1, messages: [{ role: "system", content: system }, { role: "user", content }] }),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
else if (config.provider === "google") {
|
|
310
|
+
const parts = [{ text: instruction }];
|
|
311
|
+
if (imageData)
|
|
312
|
+
parts.push({ inlineData: { mimeType: imageMime, data: imageData } });
|
|
313
|
+
response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(config.apiKey || "")}`, {
|
|
314
|
+
method: "POST",
|
|
315
|
+
signal,
|
|
316
|
+
headers: { "content-type": "application/json" },
|
|
317
|
+
body: JSON.stringify({ systemInstruction: { parts: [{ text: system }] }, contents: [{ role: "user", parts }] }),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
const content = [{ type: "text", text: instruction }];
|
|
322
|
+
if (imageData)
|
|
323
|
+
content.push({ type: "image", source: { type: "base64", media_type: imageMime, data: imageData } });
|
|
324
|
+
response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
325
|
+
method: "POST",
|
|
326
|
+
signal,
|
|
327
|
+
headers: { "content-type": "application/json", "x-api-key": config.apiKey || "", "anthropic-version": "2023-06-01" },
|
|
328
|
+
body: JSON.stringify({ model, max_tokens: 4096, system, messages: [{ role: "user", content }] }),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (!response.ok)
|
|
332
|
+
throw new Error(`Agent request failed (${response.status}).`);
|
|
333
|
+
const payload = await response.json();
|
|
334
|
+
const text = config.provider === "openai"
|
|
335
|
+
? payload.choices?.[0]?.message?.content
|
|
336
|
+
: config.provider === "google"
|
|
337
|
+
? payload.candidates?.[0]?.content?.parts?.map((part) => part.text || "").join("")
|
|
338
|
+
: payload.content?.find((item) => item.type === "text")?.text;
|
|
339
|
+
if (!text)
|
|
340
|
+
throw new Error("The agent returned an empty response.");
|
|
341
|
+
return jsonFrom(text);
|
|
342
|
+
}
|
|
343
|
+
async function answerQuestion(cwd, input, config, signal, onProgress) {
|
|
344
|
+
const context = await contextFor(cwd, input.element);
|
|
345
|
+
const history = input.messages?.map((message) => `${message.role}: ${message.content}`).join("\n") || "None";
|
|
346
|
+
const prompt = `Answer the user's question conversationally and directly. Do not propose file changes and do not return JSON. If the question is about the selected UI, use the selection and source context below.\n\nUser question:\n${input.question}\n\nSelected element:\n${JSON.stringify(input.element, null, 2)}\n\nVisual context:\n${JSON.stringify({ ...input.context, screenshots: undefined }, null, 2)}\n\nConversation:\n${history}\n\nRelevant source context:\n${context || "No matching source context was found."}`;
|
|
347
|
+
if (config.provider === "claude-code" || config.provider === "codex" || config.provider === "opencode") {
|
|
348
|
+
const local = localCommand(config.provider, config.model, prompt);
|
|
349
|
+
const command = local.command;
|
|
350
|
+
const args = local.args;
|
|
351
|
+
const child = (0, node_child_process_1.spawn)(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
352
|
+
let output = "";
|
|
353
|
+
let pending = "";
|
|
354
|
+
let stderr = "";
|
|
355
|
+
const consume = (chunk) => {
|
|
356
|
+
pending += String(chunk);
|
|
357
|
+
const lines = pending.split(/\r?\n/);
|
|
358
|
+
pending = lines.pop() || "";
|
|
359
|
+
for (const line of lines) {
|
|
360
|
+
output += `${line}\n`;
|
|
361
|
+
const progress = progressFromLine(line, config.provider);
|
|
362
|
+
if (progress)
|
|
363
|
+
onProgress?.(progress);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
child.stdout.on("data", consume);
|
|
367
|
+
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
368
|
+
if (signal)
|
|
369
|
+
signal.addEventListener("abort", () => child.kill("SIGTERM"), { once: true });
|
|
370
|
+
const code = await new Promise((resolve, reject) => {
|
|
371
|
+
child.once("error", reject);
|
|
372
|
+
child.once("close", (status) => resolve(status ?? 1));
|
|
373
|
+
});
|
|
374
|
+
if (pending.trim())
|
|
375
|
+
output += pending;
|
|
376
|
+
if (code !== 0)
|
|
377
|
+
throw new Error(localAgentError(command, stderr, code));
|
|
378
|
+
return extractLocalAgentText(output, config.provider).trim();
|
|
379
|
+
}
|
|
380
|
+
const model = config.model || (config.provider === "google" ? "gemini-2.5-flash" : config.provider === "openai" ? "gpt-4.1-mini" : config.provider === "ollama" ? "llama3.2" : "claude-sonnet-4-20250514");
|
|
381
|
+
let response;
|
|
382
|
+
if (config.provider === "openai" || config.provider === "ollama") {
|
|
383
|
+
response = await fetch(`${config.baseUrl || "https://api.openai.com/v1"}/chat/completions`, {
|
|
384
|
+
method: "POST", signal, headers: { "content-type": "application/json", authorization: `Bearer ${config.apiKey || ""}` },
|
|
385
|
+
body: JSON.stringify({ model, temperature: 0.2, messages: [{ role: "system", content: "Answer conversationally. Do not edit files or return JSON." }, { role: "user", content: prompt }] }),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
else if (config.provider === "google") {
|
|
389
|
+
response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(config.apiKey || "")}`, {
|
|
390
|
+
method: "POST", signal, headers: { "content-type": "application/json" },
|
|
391
|
+
body: JSON.stringify({ systemInstruction: { parts: [{ text: "Answer conversationally. Do not edit files or return JSON." }] }, contents: [{ role: "user", parts: [{ text: prompt }] }] }),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
396
|
+
method: "POST", signal, headers: { "content-type": "application/json", "x-api-key": config.apiKey || "", "anthropic-version": "2023-06-01" },
|
|
397
|
+
body: JSON.stringify({ model, max_tokens: 2048, system: "Answer conversationally. Do not edit files or return JSON.", messages: [{ role: "user", content: prompt }] }),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (!response.ok)
|
|
401
|
+
throw new Error(`Agent request failed (${response.status}).`);
|
|
402
|
+
const payload = await response.json();
|
|
403
|
+
const answer = config.provider === "openai" || config.provider === "ollama"
|
|
404
|
+
? payload.choices?.[0]?.message?.content
|
|
405
|
+
: config.provider === "google"
|
|
406
|
+
? payload.candidates?.[0]?.content?.parts?.map((part) => part.text || "").join("")
|
|
407
|
+
: payload.content?.find((item) => item.type === "text")?.text;
|
|
408
|
+
if (!answer?.trim())
|
|
409
|
+
throw new Error("The agent returned an empty answer.");
|
|
410
|
+
return answer.trim();
|
|
411
|
+
}
|
|
412
|
+
async function generateCommitMessage(cwd, status, config, signal, onProgress) {
|
|
413
|
+
const answer = await answerQuestion(cwd, {
|
|
414
|
+
question: `Generate exactly one concise Conventional Commit message for these changes. Return only the commit subject line, no quotes, markdown, explanation, or body. Keep it under 100 characters.\n\nChanged files:\n${status.join("\n") || "No changed files listed."}`,
|
|
415
|
+
element: { tag: "git", group: "workspace", label: "Git working tree" },
|
|
416
|
+
}, config, signal, onProgress);
|
|
417
|
+
return answer.split(/\r?\n/).map((line) => line.replace(/^[-*]\s*/, "").replace(/^['"`]|['"`]$/g, "").trim()).find(Boolean)?.slice(0, 120) || "Update project files";
|
|
418
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface CliCredentials {
|
|
2
|
+
apiKey: string;
|
|
3
|
+
keyId: string;
|
|
4
|
+
workspaceId: string;
|
|
5
|
+
workspaceName: string;
|
|
6
|
+
userName: string;
|
|
7
|
+
userEmail: string;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Base URL of the Lasso API (the main server, not the realtime server). The
|
|
12
|
+
* `lasso auth` flow lives here because the browser confirm needs the web session.
|
|
13
|
+
* Defaults to the local development port used by `server/` and `web/`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function serverUrlFrom(fileEnv: Record<string, string>): string;
|
|
16
|
+
export declare function fetchWithTimeout(url: string, init?: RequestInit, timeoutMs?: number): Promise<Response>;
|
|
17
|
+
export declare function loadCredentials(): CliCredentials | null;
|
|
18
|
+
export declare function saveCredentials(credentials: CliCredentials): void;
|
|
19
|
+
export declare function clearCredentials(): void;
|
|
20
|
+
/**
|
|
21
|
+
* `lasso auth login` — device-style OAuth. The CLI asks the server for a
|
|
22
|
+
* one-time code + verification URL, opens the browser, and polls the status
|
|
23
|
+
* endpoint until the logged-in dashboard user authorizes. On success the
|
|
24
|
+
* freshly minted API key is stored in ~/.lasso/credentials.json (0600).
|
|
25
|
+
*/
|
|
26
|
+
export declare function authLogin(cwd: string, fileEnv: Record<string, string>): Promise<{
|
|
27
|
+
ok: boolean;
|
|
28
|
+
error?: string;
|
|
29
|
+
}>;
|
|
30
|
+
export declare function authStatus(): CliCredentials | null;
|
|
31
|
+
export declare function credentialSummary(credentials: CliCredentials): {
|
|
32
|
+
userEmail: string;
|
|
33
|
+
userName: string;
|
|
34
|
+
workspaceName: string;
|
|
35
|
+
workspaceId: string;
|
|
36
|
+
masked: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function authLogout(): void;
|