@ocis/myagent-cli 0.2.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 +357 -0
- package/dist/agent/context.d.ts +33 -0
- package/dist/agent/context.js +169 -0
- package/dist/agent/modes.d.ts +21 -0
- package/dist/agent/modes.js +84 -0
- package/dist/agent/prompt-builder.d.ts +9 -0
- package/dist/agent/prompt-builder.js +31 -0
- package/dist/agent/sessions.d.ts +38 -0
- package/dist/agent/sessions.js +130 -0
- package/dist/agent/todo.d.ts +18 -0
- package/dist/agent/todo.js +61 -0
- package/dist/agent/turn.d.ts +309 -0
- package/dist/agent/turn.js +1253 -0
- package/dist/approval/policy.d.ts +81 -0
- package/dist/approval/policy.js +157 -0
- package/dist/config.d.ts +49 -0
- package/dist/config.js +156 -0
- package/dist/git/status.d.ts +89 -0
- package/dist/git/status.js +226 -0
- package/dist/headless.d.ts +72 -0
- package/dist/headless.js +330 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +511 -0
- package/dist/protocol/client.d.ts +123 -0
- package/dist/protocol/client.js +250 -0
- package/dist/protocol/sse-frames.d.ts +6 -0
- package/dist/protocol/sse-frames.js +75 -0
- package/dist/protocol/types.d.ts +200 -0
- package/dist/protocol/types.js +8 -0
- package/dist/runtime.d.ts +38 -0
- package/dist/runtime.js +166 -0
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +21 -0
- package/dist/skills/discovery.d.ts +24 -0
- package/dist/skills/discovery.js +109 -0
- package/dist/tools/binary.d.ts +2 -0
- package/dist/tools/binary.js +22 -0
- package/dist/tools/diff.d.ts +1 -0
- package/dist/tools/diff.js +49 -0
- package/dist/tools/find.d.ts +2 -0
- package/dist/tools/find.js +61 -0
- package/dist/tools/fs.d.ts +2 -0
- package/dist/tools/fs.js +276 -0
- package/dist/tools/glob.d.ts +6 -0
- package/dist/tools/glob.js +131 -0
- package/dist/tools/grep.d.ts +3 -0
- package/dist/tools/grep.js +228 -0
- package/dist/tools/paths.d.ts +27 -0
- package/dist/tools/paths.js +124 -0
- package/dist/tools/registry.d.ts +13 -0
- package/dist/tools/registry.js +38 -0
- package/dist/tools/shell.d.ts +2 -0
- package/dist/tools/shell.js +136 -0
- package/dist/tools/skills.d.ts +2 -0
- package/dist/tools/skills.js +36 -0
- package/dist/tools/todo.d.ts +2 -0
- package/dist/tools/todo.js +43 -0
- package/dist/tools/transfer.d.ts +2 -0
- package/dist/tools/transfer.js +145 -0
- package/dist/tools/truncate.d.ts +12 -0
- package/dist/tools/truncate.js +46 -0
- package/dist/tools/types.d.ts +85 -0
- package/dist/tools/types.js +63 -0
- package/dist/ui/app.d.ts +39 -0
- package/dist/ui/app.js +1061 -0
- package/dist/ui/colors.d.ts +100 -0
- package/dist/ui/colors.js +169 -0
- package/dist/ui/components.d.ts +267 -0
- package/dist/ui/components.js +811 -0
- package/dist/ui/diff.d.ts +37 -0
- package/dist/ui/diff.js +143 -0
- package/dist/ui/format.d.ts +28 -0
- package/dist/ui/format.js +76 -0
- package/dist/ui/help.d.ts +6 -0
- package/dist/ui/help.js +45 -0
- package/dist/ui/highlight.d.ts +20 -0
- package/dist/ui/highlight.js +210 -0
- package/dist/ui/logo.d.ts +24 -0
- package/dist/ui/logo.js +106 -0
- package/dist/ui/model-list.d.ts +10 -0
- package/dist/ui/model-list.js +33 -0
- package/dist/ui/quit-confirm.d.ts +8 -0
- package/dist/ui/quit-confirm.js +40 -0
- package/dist/ui/select-popup.d.ts +30 -0
- package/dist/ui/select-popup.js +54 -0
- package/dist/ui/theme.d.ts +4 -0
- package/dist/ui/theme.js +41 -0
- package/dist/ui/tool-view.d.ts +20 -0
- package/dist/ui/tool-view.js +326 -0
- package/package.json +44 -0
- package/skills/git-commit/SKILL.md +27 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ApprovalRequest } from "../tools/types.js";
|
|
2
|
+
import type { AgentMode } from "../agent/modes.js";
|
|
3
|
+
export type ApprovalMode = "ask" | "auto";
|
|
4
|
+
export type ApprovalOutcome = "approved" | "denied" | "timeout" | "cancelled";
|
|
5
|
+
export interface ApprovalPrompt {
|
|
6
|
+
(request: ApprovalRequest, signal: AbortSignal): Promise<ApprovalOutcome>;
|
|
7
|
+
}
|
|
8
|
+
/** Fixed approval deadline — never reset by keypresses. */
|
|
9
|
+
export declare const APPROVAL_TIMEOUT_MS: number;
|
|
10
|
+
export declare class ApprovalPolicy {
|
|
11
|
+
private mode;
|
|
12
|
+
private readonly prompt;
|
|
13
|
+
private readonly getAgentMode;
|
|
14
|
+
/**
|
|
15
|
+
* Text handed to the model when a mutating tool is denied. Headless mode
|
|
16
|
+
* overrides it: with no one to ask, "the user declined" would be false.
|
|
17
|
+
*/
|
|
18
|
+
readonly deniedMessage: string;
|
|
19
|
+
/**
|
|
20
|
+
* Text handed to the model when the approval deadline passes. The agent
|
|
21
|
+
* must stop waiting on the user and say so instead of hanging.
|
|
22
|
+
*/
|
|
23
|
+
readonly timeoutMessage: string;
|
|
24
|
+
/** Injectable for tests; the deadline is fixed everywhere else. */
|
|
25
|
+
private readonly timeoutMs;
|
|
26
|
+
/**
|
|
27
|
+
* Every approval not yet settled — the one being shown plus the ones queued
|
|
28
|
+
* behind it. Concurrency is real (a round's read-only calls run in parallel
|
|
29
|
+
* and outside-workspace reads prompt; a second tool round can start while an
|
|
30
|
+
* earlier one waits), so a single slot would lose an earlier prompt's cancel
|
|
31
|
+
* and leave it hanging until the deadline.
|
|
32
|
+
*/
|
|
33
|
+
private pending;
|
|
34
|
+
/**
|
|
35
|
+
* Serializes the prompts themselves: the UI shows ONE approval card, so a
|
|
36
|
+
* second concurrent prompt would overwrite the first's resolver and strand
|
|
37
|
+
* it. Each waiter releases the next in its `finally`.
|
|
38
|
+
*/
|
|
39
|
+
private queue;
|
|
40
|
+
/** Approvals in flight (prompting or queued) — 0 means the card is free. */
|
|
41
|
+
private inFlight;
|
|
42
|
+
constructor(mode: ApprovalMode, prompt: ApprovalPrompt, getAgentMode: () => AgentMode,
|
|
43
|
+
/**
|
|
44
|
+
* Text handed to the model when a mutating tool is denied. Headless mode
|
|
45
|
+
* overrides it: with no one to ask, "the user declined" would be false.
|
|
46
|
+
*/
|
|
47
|
+
deniedMessage?: string,
|
|
48
|
+
/**
|
|
49
|
+
* Text handed to the model when the approval deadline passes. The agent
|
|
50
|
+
* must stop waiting on the user and say so instead of hanging.
|
|
51
|
+
*/
|
|
52
|
+
timeoutMessage?: string,
|
|
53
|
+
/** Injectable for tests; the deadline is fixed everywhere else. */
|
|
54
|
+
timeoutMs?: number);
|
|
55
|
+
get current(): ApprovalMode;
|
|
56
|
+
setMode(mode: ApprovalMode): void;
|
|
57
|
+
toggle(): ApprovalMode;
|
|
58
|
+
/** Decide whether a tool may run. Outside-workspace calls always prompt. */
|
|
59
|
+
approve(request: ApprovalRequest): Promise<ApprovalOutcome>;
|
|
60
|
+
/**
|
|
61
|
+
* Cancel pending approvals — the one on screen and any queued behind it.
|
|
62
|
+
* With `toolCallIds`, only the ones about those calls (a release of another
|
|
63
|
+
* call in the same round must not cancel this one). Called by the runner
|
|
64
|
+
* when the server releases a call or the run is stopped — a released call
|
|
65
|
+
* must never run.
|
|
66
|
+
*/
|
|
67
|
+
cancelPending(toolCallIds?: string[]): void;
|
|
68
|
+
/**
|
|
69
|
+
* Race the prompt against the fixed deadline. The deadline is enforced HERE
|
|
70
|
+
* (single source of truth); the signal lets the UI dismiss its card when
|
|
71
|
+
* the deadline passes or the approval is cancelled. The deadline promise
|
|
72
|
+
* settles before the abort fires, so a prompt that also resolves on abort
|
|
73
|
+
* can never beat the policy's outcome.
|
|
74
|
+
*
|
|
75
|
+
* The deadline runs from THIS call, not from the moment the card appears:
|
|
76
|
+
* a queued approval that waits out the deadline behind another one settles
|
|
77
|
+
* as `timeout` without ever prompting — the agent must not be left waiting
|
|
78
|
+
* on a card the user will never see.
|
|
79
|
+
*/
|
|
80
|
+
private promptWithDeadline;
|
|
81
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Approval policy.
|
|
3
|
+
//
|
|
4
|
+
// `auto` (the default) runs workspace work unattended, but still prompts when a
|
|
5
|
+
// call reaches outside the workspace. `ask` prompts before every mutating tool
|
|
6
|
+
// (and for outside reads too). The mode is per session, toggled with /edit or
|
|
7
|
+
// Shift+Tab; read-only workspace tools never prompt. Plan mode is enforced at
|
|
8
|
+
// execution time in AgentRunner (mutating tools refuse); this layer's plan
|
|
9
|
+
// check is the second line of defense in case a call ever reaches approval.
|
|
10
|
+
//
|
|
11
|
+
// The prompt resolves to a 4-state outcome rather than a boolean: `timeout`
|
|
12
|
+
// (the fixed 10-minute deadline passed — the agent gets an error result so it
|
|
13
|
+
// stops waiting) and `cancelled` (the server released the call, or the run was
|
|
14
|
+
// stopped — the tool never runs and nothing is submitted) must be
|
|
15
|
+
// distinguishable from a plain deny. Both the deadline and the cancellation are
|
|
16
|
+
// enforced HERE: every prompt implementation gets them for free, and its
|
|
17
|
+
// AbortSignal is the dismissal hook for whatever UI is showing.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
import { isReadOnlyTool } from "../agent/modes.js";
|
|
20
|
+
/** Fixed approval deadline — never reset by keypresses. */
|
|
21
|
+
export const APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
22
|
+
export class ApprovalPolicy {
|
|
23
|
+
mode;
|
|
24
|
+
prompt;
|
|
25
|
+
getAgentMode;
|
|
26
|
+
deniedMessage;
|
|
27
|
+
timeoutMessage;
|
|
28
|
+
timeoutMs;
|
|
29
|
+
/**
|
|
30
|
+
* Every approval not yet settled — the one being shown plus the ones queued
|
|
31
|
+
* behind it. Concurrency is real (a round's read-only calls run in parallel
|
|
32
|
+
* and outside-workspace reads prompt; a second tool round can start while an
|
|
33
|
+
* earlier one waits), so a single slot would lose an earlier prompt's cancel
|
|
34
|
+
* and leave it hanging until the deadline.
|
|
35
|
+
*/
|
|
36
|
+
pending = new Set();
|
|
37
|
+
/**
|
|
38
|
+
* Serializes the prompts themselves: the UI shows ONE approval card, so a
|
|
39
|
+
* second concurrent prompt would overwrite the first's resolver and strand
|
|
40
|
+
* it. Each waiter releases the next in its `finally`.
|
|
41
|
+
*/
|
|
42
|
+
queue = Promise.resolve();
|
|
43
|
+
/** Approvals in flight (prompting or queued) — 0 means the card is free. */
|
|
44
|
+
inFlight = 0;
|
|
45
|
+
constructor(mode, prompt, getAgentMode,
|
|
46
|
+
/**
|
|
47
|
+
* Text handed to the model when a mutating tool is denied. Headless mode
|
|
48
|
+
* overrides it: with no one to ask, "the user declined" would be false.
|
|
49
|
+
*/
|
|
50
|
+
deniedMessage = "The user declined to run this action.",
|
|
51
|
+
/**
|
|
52
|
+
* Text handed to the model when the approval deadline passes. The agent
|
|
53
|
+
* must stop waiting on the user and say so instead of hanging.
|
|
54
|
+
*/
|
|
55
|
+
timeoutMessage = "Error: request approval timeout — the user did not respond within 10 minutes. Stop and wait for the user.",
|
|
56
|
+
/** Injectable for tests; the deadline is fixed everywhere else. */
|
|
57
|
+
timeoutMs = APPROVAL_TIMEOUT_MS) {
|
|
58
|
+
this.mode = mode;
|
|
59
|
+
this.prompt = prompt;
|
|
60
|
+
this.getAgentMode = getAgentMode;
|
|
61
|
+
this.deniedMessage = deniedMessage;
|
|
62
|
+
this.timeoutMessage = timeoutMessage;
|
|
63
|
+
this.timeoutMs = timeoutMs;
|
|
64
|
+
}
|
|
65
|
+
get current() {
|
|
66
|
+
return this.mode;
|
|
67
|
+
}
|
|
68
|
+
setMode(mode) {
|
|
69
|
+
this.mode = mode;
|
|
70
|
+
}
|
|
71
|
+
toggle() {
|
|
72
|
+
this.mode = this.mode === "auto" ? "ask" : "auto";
|
|
73
|
+
return this.mode;
|
|
74
|
+
}
|
|
75
|
+
/** Decide whether a tool may run. Outside-workspace calls always prompt. */
|
|
76
|
+
async approve(request) {
|
|
77
|
+
// Plan mode keys off the per-CALL mutation verdict when the caller has it
|
|
78
|
+
// (a tool whose arguments decide it — transfer get vs put/list); the
|
|
79
|
+
// name-based read-only list is the fallback for callers that don't.
|
|
80
|
+
const sideEffecting = request.mutating ?? !isReadOnlyTool(request.tool);
|
|
81
|
+
if (sideEffecting && this.getAgentMode() === "plan") {
|
|
82
|
+
return "denied";
|
|
83
|
+
}
|
|
84
|
+
if (request.outsideWorkspace)
|
|
85
|
+
return this.promptWithDeadline(request);
|
|
86
|
+
if (!sideEffecting || this.mode === "auto")
|
|
87
|
+
return "approved";
|
|
88
|
+
return this.promptWithDeadline(request);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Cancel pending approvals — the one on screen and any queued behind it.
|
|
92
|
+
* With `toolCallIds`, only the ones about those calls (a release of another
|
|
93
|
+
* call in the same round must not cancel this one). Called by the runner
|
|
94
|
+
* when the server releases a call or the run is stopped — a released call
|
|
95
|
+
* must never run.
|
|
96
|
+
*/
|
|
97
|
+
cancelPending(toolCallIds) {
|
|
98
|
+
for (const pending of [...this.pending]) {
|
|
99
|
+
// With a filter, only entries about those calls cancel. An entry with
|
|
100
|
+
// no callId cannot be matched, so a release of another call must leave
|
|
101
|
+
// it alone (the unfiltered form cancels everything).
|
|
102
|
+
if (toolCallIds && (pending.callId === undefined || !toolCallIds.includes(pending.callId)))
|
|
103
|
+
continue;
|
|
104
|
+
pending.cancel();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Race the prompt against the fixed deadline. The deadline is enforced HERE
|
|
109
|
+
* (single source of truth); the signal lets the UI dismiss its card when
|
|
110
|
+
* the deadline passes or the approval is cancelled. The deadline promise
|
|
111
|
+
* settles before the abort fires, so a prompt that also resolves on abort
|
|
112
|
+
* can never beat the policy's outcome.
|
|
113
|
+
*
|
|
114
|
+
* The deadline runs from THIS call, not from the moment the card appears:
|
|
115
|
+
* a queued approval that waits out the deadline behind another one settles
|
|
116
|
+
* as `timeout` without ever prompting — the agent must not be left waiting
|
|
117
|
+
* on a card the user will never see.
|
|
118
|
+
*/
|
|
119
|
+
async promptWithDeadline(request) {
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const deadline = Promise.withResolvers();
|
|
122
|
+
let settled = false;
|
|
123
|
+
const finish = (outcome) => {
|
|
124
|
+
if (settled)
|
|
125
|
+
return;
|
|
126
|
+
settled = true;
|
|
127
|
+
deadline.resolve(outcome);
|
|
128
|
+
controller.abort();
|
|
129
|
+
};
|
|
130
|
+
const timer = setTimeout(() => finish("timeout"), this.timeoutMs);
|
|
131
|
+
timer.unref?.();
|
|
132
|
+
const entry = { callId: request.callId, cancel: () => finish("cancelled") };
|
|
133
|
+
this.pending.add(entry);
|
|
134
|
+
// Take a slot in the prompt queue before awaiting the previous one, so
|
|
135
|
+
// arrival order is preserved. A free card (nothing in flight) is shown
|
|
136
|
+
// WITHOUT awaiting: the common single-approval case must not cost a tick.
|
|
137
|
+
const previous = this.queue;
|
|
138
|
+
const released = Promise.withResolvers();
|
|
139
|
+
const wait = this.inFlight > 0;
|
|
140
|
+
this.inFlight++;
|
|
141
|
+
this.queue = released.promise;
|
|
142
|
+
try {
|
|
143
|
+
// A cancel/timeout while queued settles immediately — no card, no wait.
|
|
144
|
+
if (wait)
|
|
145
|
+
await Promise.race([previous.catch(() => { }), deadline.promise]);
|
|
146
|
+
if (settled)
|
|
147
|
+
return await deadline.promise;
|
|
148
|
+
return await Promise.race([this.prompt(request, controller.signal), deadline.promise]);
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
this.pending.delete(entry);
|
|
152
|
+
this.inFlight--;
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
released.resolve();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type ApprovalMode = "ask" | "auto";
|
|
2
|
+
export interface StoredConfig {
|
|
3
|
+
/** MyAgent backend base URL, e.g. https://agent.example.com */
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
/** Integration ID from the MyAgent Integrations page. */
|
|
6
|
+
integrationId: string;
|
|
7
|
+
/** Integration API key (iak_...). */
|
|
8
|
+
apiKey: string;
|
|
9
|
+
/** Default approval mode for new sessions ("auto" = run tools unattended). */
|
|
10
|
+
approvalDefault: ApprovalMode;
|
|
11
|
+
/** @deprecated superseded by approvalDefault; kept for config compatibility. */
|
|
12
|
+
autoApprove?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare const EMPTY_CONFIG: StoredConfig;
|
|
15
|
+
export declare function configDir(): string;
|
|
16
|
+
export declare function configPath(): string;
|
|
17
|
+
/** Local session index (cwd → known threads). Not secret; 0644. */
|
|
18
|
+
export declare function sessionsPath(): string;
|
|
19
|
+
/** Global context file, loaded before project AGENTS.md/CLAUDE.md files. */
|
|
20
|
+
export declare function globalContextPath(): string;
|
|
21
|
+
/**
|
|
22
|
+
* One-time migration: if the config dir from a previous name (myagent-cli)
|
|
23
|
+
* exists and the current one does not, rename it so existing credentials and
|
|
24
|
+
* skills carry over. Idempotent and best-effort.
|
|
25
|
+
*/
|
|
26
|
+
export declare function migrateLegacyConfigDir(): Promise<void>;
|
|
27
|
+
export declare function loadConfig(): Promise<StoredConfig>;
|
|
28
|
+
export declare function saveConfig(config: StoredConfig): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve effective credentials from file config, environment, and flags.
|
|
31
|
+
* Later sources win. Returns the merged config plus whether it's usable.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveConfig(stored: StoredConfig, flags: Partial<StoredConfig>): StoredConfig;
|
|
34
|
+
export declare function isConfigured(config: StoredConfig): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Split a combined API base URL (`<host>/integrations/<id>`) into its parts.
|
|
37
|
+
* The Integrations page shows this single URL, so pasting it anywhere a base
|
|
38
|
+
* URL is accepted (wizard, --base-url, MYAGENT_BASE_URL, config file) must
|
|
39
|
+
* work. A trailing path is tolerated (old clipboards carry full endpoint URLs
|
|
40
|
+
* like `.../integrations/<id>/v1/chat/completions` — same rule as the
|
|
41
|
+
* extension's parseEndpointUrl); a URL without `/integrations/<id>` is
|
|
42
|
+
* returned unchanged.
|
|
43
|
+
*/
|
|
44
|
+
export declare function splitIntegrationUrl(url: string): {
|
|
45
|
+
baseUrl: string;
|
|
46
|
+
integrationId?: string;
|
|
47
|
+
};
|
|
48
|
+
/** The combined API base URL shown in the UI, for display/copy. */
|
|
49
|
+
export declare function combinedIntegrationUrl(config: StoredConfig): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// -------------------------------------------------------------------
|
|
2
|
+
// Persistent CLI configuration (credentials + preferences).
|
|
3
|
+
//
|
|
4
|
+
// Stored at $XDG_CONFIG_HOME/myagent/config.json (defaults to
|
|
5
|
+
// ~/.config/myagent/config.json), with 0600 perms since it holds an
|
|
6
|
+
// API key. Runtime values resolve in order: CLI flags > env vars > file.
|
|
7
|
+
// -------------------------------------------------------------------
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { mkdir, chmod, rename, stat } from "node:fs/promises";
|
|
11
|
+
import { fileExists, readJsonFile, writeFile } from "./runtime.js";
|
|
12
|
+
export const EMPTY_CONFIG = {
|
|
13
|
+
baseUrl: "",
|
|
14
|
+
integrationId: "",
|
|
15
|
+
apiKey: "",
|
|
16
|
+
approvalDefault: "auto",
|
|
17
|
+
};
|
|
18
|
+
function configBase() {
|
|
19
|
+
return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
20
|
+
}
|
|
21
|
+
export function configDir() {
|
|
22
|
+
return join(configBase(), "myagent");
|
|
23
|
+
}
|
|
24
|
+
export function configPath() {
|
|
25
|
+
return join(configDir(), "config.json");
|
|
26
|
+
}
|
|
27
|
+
/** Local session index (cwd → known threads). Not secret; 0644. */
|
|
28
|
+
export function sessionsPath() {
|
|
29
|
+
return join(configDir(), "sessions.json");
|
|
30
|
+
}
|
|
31
|
+
/** Global context file, loaded before project AGENTS.md/CLAUDE.md files. */
|
|
32
|
+
export function globalContextPath() {
|
|
33
|
+
return join(configDir(), "AGENTS.md");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One-time migration: if the config dir from a previous name (myagent-cli)
|
|
37
|
+
* exists and the current one does not, rename it so existing credentials and
|
|
38
|
+
* skills carry over. Idempotent and best-effort.
|
|
39
|
+
*/
|
|
40
|
+
export async function migrateLegacyConfigDir() {
|
|
41
|
+
const current = configDir();
|
|
42
|
+
const legacy = join(configBase(), "myagent-cli");
|
|
43
|
+
if (current === legacy)
|
|
44
|
+
return;
|
|
45
|
+
try {
|
|
46
|
+
const hasCurrent = await stat(current).then(() => true).catch(() => false);
|
|
47
|
+
const hasLegacy = await stat(legacy).then(() => true).catch(() => false);
|
|
48
|
+
if (!hasCurrent && hasLegacy)
|
|
49
|
+
await rename(legacy, current);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* best-effort — a failed migration just means the user reconfigures */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function loadConfig() {
|
|
56
|
+
try {
|
|
57
|
+
const path = configPath();
|
|
58
|
+
if (!(await fileExists(path)))
|
|
59
|
+
return { ...EMPTY_CONFIG };
|
|
60
|
+
const data = await readJsonFile(path);
|
|
61
|
+
const merged = { ...EMPTY_CONFIG, ...data };
|
|
62
|
+
// Legacy field: autoApprove:false used to mean "ask" — honour it once.
|
|
63
|
+
if (data.approvalDefault === undefined && data.autoApprove === false) {
|
|
64
|
+
merged.approvalDefault = "ask";
|
|
65
|
+
}
|
|
66
|
+
// Fail CLOSED: only an explicit "auto" means auto — corrupted files,
|
|
67
|
+
// typos, or future unknown values must fall back to asking, never to
|
|
68
|
+
// unattended tool execution.
|
|
69
|
+
merged.approvalDefault = merged.approvalDefault === "auto" ? "auto" : "ask";
|
|
70
|
+
// A stored combined URL (`<host>/integrations/<id>`) is self-contained —
|
|
71
|
+
// its id is authoritative for this source.
|
|
72
|
+
applyBaseUrl(merged, merged.baseUrl);
|
|
73
|
+
return merged;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Corrupt or unreadable config — start from empty rather than crashing.
|
|
77
|
+
return { ...EMPTY_CONFIG };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function saveConfig(config) {
|
|
81
|
+
await mkdir(configDir(), { recursive: true });
|
|
82
|
+
// Write to a temp file then rename atomically so a mid-write crash or signal
|
|
83
|
+
// never leaves a truncated config (and the API key) behind.
|
|
84
|
+
const tmp = configPath() + ".tmp";
|
|
85
|
+
await writeFile(tmp, JSON.stringify(config, null, 2) + "\n");
|
|
86
|
+
// The file holds a secret — keep it owner-only. Best-effort on Windows.
|
|
87
|
+
try {
|
|
88
|
+
await chmod(tmp, 0o600);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* chmod is a no-op / unsupported on some platforms */
|
|
92
|
+
}
|
|
93
|
+
await rename(tmp, configPath());
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Resolve effective credentials from file config, environment, and flags.
|
|
97
|
+
* Later sources win. Returns the merged config plus whether it's usable.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveConfig(stored, flags) {
|
|
100
|
+
const env = {};
|
|
101
|
+
if (process.env.MYAGENT_BASE_URL)
|
|
102
|
+
applyBaseUrl(env, process.env.MYAGENT_BASE_URL);
|
|
103
|
+
if (process.env.MYAGENT_INTEGRATION_ID)
|
|
104
|
+
env.integrationId = process.env.MYAGENT_INTEGRATION_ID;
|
|
105
|
+
if (process.env.MYAGENT_API_KEY)
|
|
106
|
+
env.apiKey = process.env.MYAGENT_API_KEY;
|
|
107
|
+
if (process.env.MYAGENT_APPROVAL === "ask")
|
|
108
|
+
env.approvalDefault = "ask";
|
|
109
|
+
const merged = { ...stored, ...env };
|
|
110
|
+
// Only override with flags that were actually provided (non-empty).
|
|
111
|
+
if (flags.baseUrl)
|
|
112
|
+
applyBaseUrl(merged, flags.baseUrl);
|
|
113
|
+
if (flags.integrationId)
|
|
114
|
+
merged.integrationId = flags.integrationId;
|
|
115
|
+
if (flags.apiKey)
|
|
116
|
+
merged.apiKey = flags.apiKey;
|
|
117
|
+
if (flags.approvalDefault)
|
|
118
|
+
merged.approvalDefault = flags.approvalDefault;
|
|
119
|
+
return merged;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Set a base URL on a config patch, deriving the integration id when the URL
|
|
123
|
+
* is the combined form (`<host>/integrations/<id>`) — a combined URL is
|
|
124
|
+
* self-contained, so its id is authoritative for that source.
|
|
125
|
+
*/
|
|
126
|
+
function applyBaseUrl(target, baseUrl) {
|
|
127
|
+
const split = splitIntegrationUrl(baseUrl);
|
|
128
|
+
target.baseUrl = split.baseUrl;
|
|
129
|
+
if (split.integrationId)
|
|
130
|
+
target.integrationId = split.integrationId;
|
|
131
|
+
}
|
|
132
|
+
export function isConfigured(config) {
|
|
133
|
+
return Boolean(config.baseUrl && config.integrationId && config.apiKey);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Split a combined API base URL (`<host>/integrations/<id>`) into its parts.
|
|
137
|
+
* The Integrations page shows this single URL, so pasting it anywhere a base
|
|
138
|
+
* URL is accepted (wizard, --base-url, MYAGENT_BASE_URL, config file) must
|
|
139
|
+
* work. A trailing path is tolerated (old clipboards carry full endpoint URLs
|
|
140
|
+
* like `.../integrations/<id>/v1/chat/completions` — same rule as the
|
|
141
|
+
* extension's parseEndpointUrl); a URL without `/integrations/<id>` is
|
|
142
|
+
* returned unchanged.
|
|
143
|
+
*/
|
|
144
|
+
export function splitIntegrationUrl(url) {
|
|
145
|
+
const trimmed = url.trim();
|
|
146
|
+
const match = /^(.*?)\/integrations\/([^/]+)(?:\/.*)?$/.exec(trimmed);
|
|
147
|
+
if (!match)
|
|
148
|
+
return { baseUrl: trimmed };
|
|
149
|
+
return { baseUrl: match[1], integrationId: match[2] };
|
|
150
|
+
}
|
|
151
|
+
/** The combined API base URL shown in the UI, for display/copy. */
|
|
152
|
+
export function combinedIntegrationUrl(config) {
|
|
153
|
+
if (!config.baseUrl || !config.integrationId)
|
|
154
|
+
return config.baseUrl || "";
|
|
155
|
+
return `${config.baseUrl.replace(/\/+$/, "")}/integrations/${config.integrationId}`;
|
|
156
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export interface GitFileChange {
|
|
2
|
+
path: string;
|
|
3
|
+
/** Porcelain status code (M, A, D, R, ??, …). */
|
|
4
|
+
status: string;
|
|
5
|
+
insertions: number;
|
|
6
|
+
deletions: number;
|
|
7
|
+
}
|
|
8
|
+
export interface GitStatus {
|
|
9
|
+
isRepo: boolean;
|
|
10
|
+
branch: string | null;
|
|
11
|
+
files: GitFileChange[];
|
|
12
|
+
totalInsertions: number;
|
|
13
|
+
totalDeletions: number;
|
|
14
|
+
}
|
|
15
|
+
export declare const EMPTY_GIT_STATUS: GitStatus;
|
|
16
|
+
/**
|
|
17
|
+
* Strip terminal-control sequences from a git-derived string (file paths,
|
|
18
|
+
* branch names). A malicious repository can carry filenames embedding ANSI/OSC
|
|
19
|
+
* escape sequences; rendering those raw would inject cursor moves, terminal
|
|
20
|
+
* title changes, or clipboard writes into the user's terminal. Applied at
|
|
21
|
+
* the parse boundary so every consumer (sidebar, footer, diff popup) is
|
|
22
|
+
* covered without each one having to remember.
|
|
23
|
+
*/
|
|
24
|
+
import { sanitizeForTerminal } from "../sanitize.js";
|
|
25
|
+
export { sanitizeForTerminal };
|
|
26
|
+
/**
|
|
27
|
+
* Parse `git status --porcelain=v1 -z --branch` output. The -z form is
|
|
28
|
+
* NUL-separated and never quotes paths, so filenames with spaces, quotes,
|
|
29
|
+
* tabs, or non-ASCII bytes arrive verbatim — no unquoting step to get wrong.
|
|
30
|
+
* Renames are `XY PATH\0ORIG_PATH\0` (PATH is already the destination).
|
|
31
|
+
*/
|
|
32
|
+
export declare function parsePorcelain(output: string): {
|
|
33
|
+
branch: string | null;
|
|
34
|
+
files: Array<{
|
|
35
|
+
path: string;
|
|
36
|
+
status: string;
|
|
37
|
+
}>;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Parse `git diff --numstat -z` output into a path → line-count map.
|
|
41
|
+
*
|
|
42
|
+
* The -z form never quotes or abbreviates: a regular record is
|
|
43
|
+
* `ins\tdel\tpath\0`; a rename is `ins\tdel\t\0ORIG\0DEST\0` (the stats field
|
|
44
|
+
* ends at the tab, then the two paths follow as their own NUL fields, orig
|
|
45
|
+
* first). Keying on DEST matches porcelain -z's destination path, so the
|
|
46
|
+
* panel's +/- counts line up with the file list for every filename — CJK,
|
|
47
|
+
* spaces, quotes — instead of silently zeroing on a quoted/abbreviated key.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseNumstat(output: string): Map<string, {
|
|
50
|
+
insertions: number;
|
|
51
|
+
deletions: number;
|
|
52
|
+
}>;
|
|
53
|
+
/** Combine porcelain + staged/unstaged numstat into the panel model. */
|
|
54
|
+
export declare function mergeGitStatus(porcelain: {
|
|
55
|
+
branch: string | null;
|
|
56
|
+
files: Array<{
|
|
57
|
+
path: string;
|
|
58
|
+
status: string;
|
|
59
|
+
}>;
|
|
60
|
+
}, unstaged: Map<string, {
|
|
61
|
+
insertions: number;
|
|
62
|
+
deletions: number;
|
|
63
|
+
}>, staged: Map<string, {
|
|
64
|
+
insertions: number;
|
|
65
|
+
deletions: number;
|
|
66
|
+
}>): GitStatus;
|
|
67
|
+
/** Collect the current git status; never throws. */
|
|
68
|
+
export declare function collectGitStatus(workspace: string, signal?: AbortSignal): Promise<GitStatus>;
|
|
69
|
+
export interface FileDiff {
|
|
70
|
+
/** Unified diff text (possibly truncated). */
|
|
71
|
+
text: string;
|
|
72
|
+
truncated: boolean;
|
|
73
|
+
}
|
|
74
|
+
export declare const MAX_DIFF_LINES = 5000;
|
|
75
|
+
export declare const MAX_DIFF_CHARS: number;
|
|
76
|
+
/** Cut the diff to the char/line budget, reporting whether anything was lost. */
|
|
77
|
+
export declare function truncateDiff(text: string): FileDiff;
|
|
78
|
+
/**
|
|
79
|
+
* Unified diff for one working-tree file. Tracked files diff against HEAD
|
|
80
|
+
* (staged + unstaged); when there is no HEAD yet (fresh `git init`) the
|
|
81
|
+
* index/worktree pair is diffed instead, so staged edits still show.
|
|
82
|
+
* Untracked files diff against /dev/null, where git exits 1 even on success.
|
|
83
|
+
*
|
|
84
|
+
* Porcelain paths are relative to the repository root, so the diff runs from
|
|
85
|
+
* the root too — otherwise a workspace that is a subdirectory of the repo
|
|
86
|
+
* resolves the pathspec against the wrong prefix and produces nothing.
|
|
87
|
+
* Returns an empty text when git cannot produce a diff.
|
|
88
|
+
*/
|
|
89
|
+
export declare function collectFileDiff(workspace: string, file: GitFileChange, signal?: AbortSignal): Promise<FileDiff>;
|