@phnx-labs/agents-cli 1.22.118 → 1.22.120
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/CHANGELOG.md +75 -0
- package/dist/commands/accounts.js +7 -4
- package/dist/commands/projects.js +92 -7
- package/dist/lib/accounts/add.d.ts +3 -1
- package/dist/lib/accounts/add.js +24 -13
- package/dist/lib/agent-spec/agents.js +1 -0
- package/dist/lib/claude-account-token.d.ts +7 -1
- package/dist/lib/claude-account-token.js +26 -4
- package/dist/lib/exec.js +10 -2
- package/dist/lib/github/pr-mergeable.d.ts +4 -2
- package/dist/lib/github/pr-mergeable.js +5 -3
- package/dist/lib/github/project-prs.d.ts +58 -3
- package/dist/lib/github/project-prs.js +205 -7
- package/dist/lib/harness/adapters/cursor.js +14 -4
- package/dist/lib/harness-auth-capabilities.d.ts +4 -3
- package/dist/lib/harness-auth-capabilities.js +1 -1
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/installations/shims.d.ts +1 -1
- package/dist/lib/installations/shims.js +23 -5
- package/dist/lib/installations/store.d.ts +9 -0
- package/dist/lib/installations/store.js +24 -0
- package/dist/lib/installations/versions.js +3 -2
- package/dist/lib/projects.d.ts +16 -0
- package/dist/lib/projects.js +31 -0
- package/dist/lib/session/active.d.ts +17 -6
- package/dist/lib/session/active.js +108 -49
- package/dist/lib/session/pid-registry.d.ts +6 -3
- package/dist/lib/session/pid-registry.js +18 -8
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +7 -3
- package/dist/lib/types.d.ts +6 -0
- package/package.json +1 -1
|
@@ -23,13 +23,22 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { type GhExec } from './pr-mergeable.js';
|
|
25
25
|
import { type RollupItem } from './rest.js';
|
|
26
|
-
import type
|
|
26
|
+
import { type ProjectDef } from '../projects.js';
|
|
27
27
|
/** The author of a PR, as the menu renders it (login + avatar). */
|
|
28
28
|
export interface ProjectPrAuthor {
|
|
29
29
|
login: string;
|
|
30
30
|
avatarUrl: string;
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Where a PR sits for a project whose repository is SHARED with another project
|
|
34
|
+
* (a monorepo): `project` touches the paths this project claims, `repo-wide`
|
|
35
|
+
* touches no sharing project's paths (root config, CI, docs). A PR that touches
|
|
36
|
+
* only another project's paths is not listed at all. Null when the repository is
|
|
37
|
+
* not shared, or this project claims all of it. Claims are {@link repoPathClaims},
|
|
38
|
+
* the same ownership that attributes a session's cwd.
|
|
39
|
+
*/
|
|
40
|
+
export type ProjectPrScope = 'project' | 'repo-wide';
|
|
41
|
+
/** One open PR row. `checks`/`reviewDecision`/merge state are null unless the PR was enriched. */
|
|
33
42
|
export interface ProjectPr {
|
|
34
43
|
number: number;
|
|
35
44
|
title: string;
|
|
@@ -37,12 +46,18 @@ export interface ProjectPr {
|
|
|
37
46
|
isDraft: boolean;
|
|
38
47
|
/** Upper-cased GitHub state — always `OPEN` here (only open PRs are listed). */
|
|
39
48
|
state: string;
|
|
49
|
+
createdAt: string;
|
|
40
50
|
updatedAt: string;
|
|
41
51
|
author: ProjectPrAuthor;
|
|
42
52
|
headRefName: string;
|
|
43
53
|
baseRefName: string;
|
|
44
54
|
headSha: string;
|
|
45
55
|
body: string;
|
|
56
|
+
scope: ProjectPrScope | null;
|
|
57
|
+
/** Populated only for the `--number` PR: GitHub's mergeability (null while it computes). */
|
|
58
|
+
mergeable: boolean | null;
|
|
59
|
+
/** Populated only for the `--number` PR: `clean` | `unstable` | `blocked` | `behind` | `dirty` | `draft` | `unknown` … */
|
|
60
|
+
mergeableState: string | null;
|
|
46
61
|
/** Populated only for the `--number` PR: the head-SHA status-check rollup. */
|
|
47
62
|
checks: RollupItem[] | null;
|
|
48
63
|
/** Populated only for the `--number` PR: GitHub's computed review decision. */
|
|
@@ -51,6 +66,8 @@ export interface ProjectPr {
|
|
|
51
66
|
/** One repository's open PRs, or the error that stopped its fetch. */
|
|
52
67
|
export interface ProjectRepoPrs {
|
|
53
68
|
slug: string;
|
|
69
|
+
/** Other project definitions attached to this same repository. */
|
|
70
|
+
sharedWith: string[];
|
|
54
71
|
pullRequests: ProjectPr[];
|
|
55
72
|
/** Non-null when the fetch failed — the list is then NOT authoritative. */
|
|
56
73
|
error: string | null;
|
|
@@ -61,6 +78,8 @@ export interface ProjectPrsEnvelope {
|
|
|
61
78
|
name: string;
|
|
62
79
|
linearProjectId: string | null;
|
|
63
80
|
};
|
|
81
|
+
/** The authenticated GitHub login — what a "mine" filter compares `author.login` to. */
|
|
82
|
+
viewer: string | null;
|
|
64
83
|
repositories: ProjectRepoPrs[];
|
|
65
84
|
/** True when at least one repository fetch failed (its list is incomplete). */
|
|
66
85
|
partial: boolean;
|
|
@@ -110,9 +129,45 @@ export declare function enrichPr(repo: string, pr: ProjectPr, gh?: GhExec): Prom
|
|
|
110
129
|
* is not one of the project's attached repos (raw or canonical) is refused.
|
|
111
130
|
*/
|
|
112
131
|
export declare function resolveTargetSlugs(def: ProjectDef, repo: string | undefined, gh: GhExec): Promise<string[]>;
|
|
132
|
+
/**
|
|
133
|
+
* Classify one PR of a shared repository by the files it changes. `own` are
|
|
134
|
+
* this project's prefixes, `others` every other sharing project's. Returns
|
|
135
|
+
* null when the PR belongs only to another project.
|
|
136
|
+
*/
|
|
137
|
+
export declare function scopeForFiles(files: readonly string[], own: readonly string[], others: readonly string[]): ProjectPrScope | null;
|
|
138
|
+
/** The authenticated login, from a REST read gh caches for a day. */
|
|
139
|
+
export declare function fetchViewer(gh?: GhExec): Promise<string | null>;
|
|
113
140
|
/**
|
|
114
141
|
* Build the full envelope. `number` (which the command requires alongside
|
|
115
142
|
* `repo`) fetches and enriches exactly that one PR; otherwise every target
|
|
116
143
|
* repo's open PRs are listed with checks/reviewDecision left null.
|
|
144
|
+
*
|
|
145
|
+
* `peers` are every project definition. A repository attached to more than one
|
|
146
|
+
* project is scoped: when this project claims part of it ({@link repoPathClaims}),
|
|
147
|
+
* its list keeps only PRs that touch those paths (`scope: project`) or touch no
|
|
148
|
+
* sharing project's paths (`scope: repo-wide`).
|
|
149
|
+
*/
|
|
150
|
+
export declare function buildProjectPrs(def: ProjectDef, opts?: ProjectPrsOptions, gh?: GhExec, peers?: readonly ProjectDef[]): Promise<ProjectPrsEnvelope>;
|
|
151
|
+
/** How a PR is merged; the default is the first of these the repository allows. */
|
|
152
|
+
export declare const MERGE_METHODS: readonly ["rebase", "squash", "merge"];
|
|
153
|
+
export type MergeMethod = (typeof MERGE_METHODS)[number];
|
|
154
|
+
/** The result of one `projects prs merge`. */
|
|
155
|
+
export interface ProjectPrMergeResult {
|
|
156
|
+
repo: string;
|
|
157
|
+
number: number;
|
|
158
|
+
method: MergeMethod;
|
|
159
|
+
merged: boolean;
|
|
160
|
+
/** The merge commit SHA GitHub reports. */
|
|
161
|
+
sha: string | null;
|
|
162
|
+
message: string;
|
|
163
|
+
}
|
|
164
|
+
/** The first of {@link MERGE_METHODS} the repository allows. */
|
|
165
|
+
export declare function defaultMergeMethod(repo: string, gh?: GhExec): Promise<MergeMethod>;
|
|
166
|
+
/**
|
|
167
|
+
* Merge one PR over REST (`PUT repos/{repo}/pulls/{n}/merge`), pinned to `sha`:
|
|
168
|
+
* GitHub refuses with 409 when the head moved since the caller looked, so a push
|
|
169
|
+
* that landed after the menu rendered is never merged unseen. Branch protection,
|
|
170
|
+
* required checks and reviews are GitHub's to enforce; their refusal comes back
|
|
171
|
+
* as `merged: false` with GitHub's own message.
|
|
117
172
|
*/
|
|
118
|
-
export declare function
|
|
173
|
+
export declare function mergeProjectPr(repo: string, number: number, sha: string, method: MergeMethod | undefined, gh?: GhExec): Promise<ProjectPrMergeResult>;
|
|
@@ -21,14 +21,20 @@
|
|
|
21
21
|
* - A per-repo fetch failure is reported as `repositories[].error` and flips
|
|
22
22
|
* `partial` — it is NEVER relabeled as "zero open PRs".
|
|
23
23
|
*/
|
|
24
|
+
import * as fs from 'fs';
|
|
25
|
+
import * as path from 'path';
|
|
24
26
|
import { ghExec, canonicalizeRepo, projectRepoSlugs } from './pr-mergeable.js';
|
|
25
27
|
import { rollupForSha } from './rest.js';
|
|
28
|
+
import { repoPathClaims } from '../projects.js';
|
|
29
|
+
import { getCacheDir } from '../state.js';
|
|
30
|
+
import { atomicWriteFileSync } from '../fs-atomic.js';
|
|
26
31
|
/** The jq projection that flattens a REST PR object into {@link ProjectPr}'s scalars. */
|
|
27
32
|
const PR_JQ = '{number, title, url: .html_url, isDraft: (.draft // false), ' +
|
|
28
|
-
'state: (.state // "" | ascii_upcase), updatedAt: (.updated_at // ""), ' +
|
|
33
|
+
'state: (.state // "" | ascii_upcase), createdAt: (.created_at // ""), updatedAt: (.updated_at // ""), ' +
|
|
29
34
|
'login: (.user.login // ""), avatarUrl: (.user.avatar_url // ""), ' +
|
|
30
35
|
'headRefName: (.head.ref // ""), baseRefName: (.base.ref // ""), ' +
|
|
31
|
-
'headSha: (.head.sha // ""), body: (.body // "")
|
|
36
|
+
'headSha: (.head.sha // ""), body: (.body // ""), ' +
|
|
37
|
+
'mergeable: .mergeable, mergeableState: .mergeable_state}';
|
|
32
38
|
/** Parse newline-delimited JSON (gh `--jq` streams one object per line/page). */
|
|
33
39
|
function parseNdjson(out) {
|
|
34
40
|
const rows = [];
|
|
@@ -48,12 +54,16 @@ export function rowToProjectPr(row) {
|
|
|
48
54
|
url: String(row.url ?? ''),
|
|
49
55
|
isDraft: Boolean(row.isDraft),
|
|
50
56
|
state: String(row.state ?? ''),
|
|
57
|
+
createdAt: String(row.createdAt ?? ''),
|
|
51
58
|
updatedAt: String(row.updatedAt ?? ''),
|
|
52
59
|
author: { login: String(row.login ?? ''), avatarUrl: String(row.avatarUrl ?? '') },
|
|
53
60
|
headRefName: String(row.headRefName ?? ''),
|
|
54
61
|
baseRefName: String(row.baseRefName ?? ''),
|
|
55
62
|
headSha: String(row.headSha ?? ''),
|
|
56
63
|
body: String(row.body ?? ''),
|
|
64
|
+
scope: null,
|
|
65
|
+
mergeable: typeof row.mergeable === 'boolean' ? row.mergeable : null,
|
|
66
|
+
mergeableState: typeof row.mergeableState === 'string' ? row.mergeableState : null,
|
|
57
67
|
checks: null,
|
|
58
68
|
reviewDecision: null,
|
|
59
69
|
};
|
|
@@ -121,7 +131,7 @@ export async function enrichPr(repo, pr, gh = ghExec) {
|
|
|
121
131
|
*/
|
|
122
132
|
export async function resolveTargetSlugs(def, repo, gh) {
|
|
123
133
|
const raw = projectRepoSlugs([def]);
|
|
124
|
-
// Canonicalize CONCURRENTLY — each
|
|
134
|
+
// Canonicalize CONCURRENTLY — each repo read can take up to gh's 30s
|
|
125
135
|
// timeout, so a sequential walk of N repos would stack N×30s and blow past a
|
|
126
136
|
// native caller's bounded deadline. Repo count per project is small.
|
|
127
137
|
const canonPairs = await Promise.all(raw.map(async (slug) => [slug, await canonicalizeRepo(slug, gh)]));
|
|
@@ -139,19 +149,145 @@ export async function resolveTargetSlugs(def, repo, gh) {
|
|
|
139
149
|
}
|
|
140
150
|
return [requested];
|
|
141
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Classify one PR of a shared repository by the files it changes. `own` are
|
|
154
|
+
* this project's prefixes, `others` every other sharing project's. Returns
|
|
155
|
+
* null when the PR belongs only to another project.
|
|
156
|
+
*/
|
|
157
|
+
export function scopeForFiles(files, own, others) {
|
|
158
|
+
const under = (prefixes) => files.some((f) => prefixes.some((p) => f.startsWith(p)));
|
|
159
|
+
if (under(own))
|
|
160
|
+
return 'project';
|
|
161
|
+
if (under(others))
|
|
162
|
+
return null;
|
|
163
|
+
return 'repo-wide';
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The files a PR changes, keyed by its head SHA. The list for a given head is
|
|
167
|
+
* immutable, so a cached entry never goes stale; a push mints a new key. Entries
|
|
168
|
+
* for heads that are no longer open in that repo are pruned on write.
|
|
169
|
+
*/
|
|
170
|
+
class PrFilesCache {
|
|
171
|
+
file = path.join(getCacheDir(), 'project-pr-files.json');
|
|
172
|
+
entries = {};
|
|
173
|
+
dirty = false;
|
|
174
|
+
constructor() {
|
|
175
|
+
// A missing or unreadable cache only costs re-reads; it never fails a list.
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
|
|
178
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
179
|
+
this.entries = parsed;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
this.entries = {};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async files(slug, pr, gh) {
|
|
186
|
+
const key = `${slug}@${pr.headSha}`;
|
|
187
|
+
const hit = this.entries[key];
|
|
188
|
+
if (hit)
|
|
189
|
+
return hit;
|
|
190
|
+
// A rename counts on both sides: moving a file out of a project's path touches that project.
|
|
191
|
+
const out = await gh(['api', `repos/${slug}/pulls/${pr.number}/files?per_page=100`, '--paginate', '--jq', '.[] | .filename, (.previous_filename // empty)']);
|
|
192
|
+
const files = out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
193
|
+
this.entries[key] = files;
|
|
194
|
+
this.dirty = true;
|
|
195
|
+
return files;
|
|
196
|
+
}
|
|
197
|
+
prune(slug, openHeads) {
|
|
198
|
+
for (const key of Object.keys(this.entries)) {
|
|
199
|
+
const at = key.lastIndexOf('@');
|
|
200
|
+
if (key.slice(0, at) === slug && !openHeads.has(key.slice(at + 1))) {
|
|
201
|
+
delete this.entries[key];
|
|
202
|
+
this.dirty = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
save() {
|
|
207
|
+
if (!this.dirty)
|
|
208
|
+
return;
|
|
209
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
210
|
+
atomicWriteFileSync(this.file, JSON.stringify(this.entries));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Run `fn` over `items` with at most `limit` in flight, preserving order. */
|
|
214
|
+
async function mapBounded(items, limit, fn) {
|
|
215
|
+
const results = new Array(items.length);
|
|
216
|
+
let next = 0;
|
|
217
|
+
const worker = async () => {
|
|
218
|
+
while (next < items.length) {
|
|
219
|
+
const i = next++;
|
|
220
|
+
results[i] = await fn(items[i]);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
224
|
+
return results;
|
|
225
|
+
}
|
|
226
|
+
/** The authenticated login, from a REST read gh caches for a day. */
|
|
227
|
+
export async function fetchViewer(gh = ghExec) {
|
|
228
|
+
try {
|
|
229
|
+
return (await gh(['api', 'user', '--cache', '24h', '--jq', '.login'])).trim() || null;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The repository paths `def` claims, keyed by canonical slug. Canonicalization
|
|
237
|
+
* is cached, so this costs no network after the first run of the day.
|
|
238
|
+
*/
|
|
239
|
+
async function canonicalClaims(def, gh) {
|
|
240
|
+
const out = new Map();
|
|
241
|
+
for (const { slug, prefix } of repoPathClaims(def)) {
|
|
242
|
+
const canonical = await canonicalizeRepo(slug, gh);
|
|
243
|
+
out.set(canonical, [...(out.get(canonical) ?? []), prefix]);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
/** For each canonical slug, the other projects attached to it and the paths they claim. */
|
|
248
|
+
async function repoPeers(def, peers, gh) {
|
|
249
|
+
const bySlug = new Map();
|
|
250
|
+
await Promise.all(peers.filter((p) => p.name !== def.name).map(async (peer) => {
|
|
251
|
+
const [slugs, claims] = await Promise.all([
|
|
252
|
+
Promise.all(projectRepoSlugs([peer]).map((s) => canonicalizeRepo(s, gh))),
|
|
253
|
+
canonicalClaims(peer, gh),
|
|
254
|
+
]);
|
|
255
|
+
for (const slug of new Set(slugs)) {
|
|
256
|
+
const entry = bySlug.get(slug) ?? { names: [], prefixes: [] };
|
|
257
|
+
entry.names.push(peer.name);
|
|
258
|
+
entry.prefixes.push(...(claims.get(slug) ?? []));
|
|
259
|
+
bySlug.set(slug, entry);
|
|
260
|
+
}
|
|
261
|
+
}));
|
|
262
|
+
for (const entry of bySlug.values())
|
|
263
|
+
entry.names.sort();
|
|
264
|
+
return bySlug;
|
|
265
|
+
}
|
|
142
266
|
/**
|
|
143
267
|
* Build the full envelope. `number` (which the command requires alongside
|
|
144
268
|
* `repo`) fetches and enriches exactly that one PR; otherwise every target
|
|
145
269
|
* repo's open PRs are listed with checks/reviewDecision left null.
|
|
270
|
+
*
|
|
271
|
+
* `peers` are every project definition. A repository attached to more than one
|
|
272
|
+
* project is scoped: when this project claims part of it ({@link repoPathClaims}),
|
|
273
|
+
* its list keeps only PRs that touch those paths (`scope: project`) or touch no
|
|
274
|
+
* sharing project's paths (`scope: repo-wide`).
|
|
146
275
|
*/
|
|
147
|
-
export async function buildProjectPrs(def, opts = {}, gh = ghExec) {
|
|
276
|
+
export async function buildProjectPrs(def, opts = {}, gh = ghExec, peers = []) {
|
|
148
277
|
const project = { name: def.name, linearProjectId: def.linear?.projectId ?? null };
|
|
149
278
|
const slugs = await resolveTargetSlugs(def, opts.repo, gh);
|
|
279
|
+
if (slugs.length === 0)
|
|
280
|
+
return { project, viewer: null, repositories: [], partial: false };
|
|
281
|
+
const viewerRead = fetchViewer(gh);
|
|
282
|
+
const [shared, ownClaims] = await Promise.all([repoPeers(def, peers, gh), canonicalClaims(def, gh)]);
|
|
283
|
+
const filesCache = new PrFilesCache();
|
|
150
284
|
// Fetch repos in PARALLEL so a native caller's overall deadline scales with the
|
|
151
285
|
// slowest repo, not the sum — a serial multi-repo walk of 30s gh calls can blow
|
|
152
286
|
// past a bounded caller timeout and lose the whole result. Repo count is small
|
|
153
287
|
// (a project's attached repos), so this is a bounded fan-out, not unbounded.
|
|
154
288
|
const repositories = await Promise.all(slugs.map(async (slug) => {
|
|
289
|
+
const peer = shared.get(slug);
|
|
290
|
+
const sharedWith = peer?.names ?? [];
|
|
155
291
|
try {
|
|
156
292
|
let pullRequests;
|
|
157
293
|
if (opts.number !== undefined) {
|
|
@@ -160,14 +296,76 @@ export async function buildProjectPrs(def, opts = {}, gh = ghExec) {
|
|
|
160
296
|
}
|
|
161
297
|
else {
|
|
162
298
|
pullRequests = await listOpenPrs(slug, gh);
|
|
299
|
+
const own = ownClaims.get(slug);
|
|
300
|
+
if (peer && own) {
|
|
301
|
+
filesCache.prune(slug, new Set(pullRequests.map((pr) => pr.headSha)));
|
|
302
|
+
const scopes = await mapBounded(pullRequests, 8, async (pr) => scopeForFiles(await filesCache.files(slug, pr, gh), own, peer.prefixes));
|
|
303
|
+
pullRequests = pullRequests
|
|
304
|
+
.map((pr, i) => ({ ...pr, scope: scopes[i] }))
|
|
305
|
+
.filter((pr) => pr.scope !== null);
|
|
306
|
+
}
|
|
163
307
|
}
|
|
164
|
-
return { slug, pullRequests, error: null };
|
|
308
|
+
return { slug, sharedWith, pullRequests, error: null };
|
|
165
309
|
}
|
|
166
310
|
catch (err) {
|
|
167
311
|
// A fetch failure is reported, never relabeled as zero open PRs.
|
|
168
|
-
return { slug, pullRequests: [], error: err instanceof Error ? err.message : String(err) };
|
|
312
|
+
return { slug, sharedWith, pullRequests: [], error: err instanceof Error ? err.message : String(err) };
|
|
169
313
|
}
|
|
170
314
|
}));
|
|
315
|
+
filesCache.save();
|
|
171
316
|
const partial = repositories.some((r) => r.error !== null);
|
|
172
|
-
return { project, repositories, partial };
|
|
317
|
+
return { project, viewer: await viewerRead, repositories, partial };
|
|
318
|
+
}
|
|
319
|
+
/** How a PR is merged; the default is the first of these the repository allows. */
|
|
320
|
+
export const MERGE_METHODS = ['rebase', 'squash', 'merge'];
|
|
321
|
+
/** The first of {@link MERGE_METHODS} the repository allows. */
|
|
322
|
+
export async function defaultMergeMethod(repo, gh = ghExec) {
|
|
323
|
+
const allowed = JSON.parse((await gh([
|
|
324
|
+
'api', `repos/${repo}`, '--cache', '1h',
|
|
325
|
+
'--jq', '{rebase: .allow_rebase_merge, squash: .allow_squash_merge, merge: .allow_merge_commit}',
|
|
326
|
+
])).trim());
|
|
327
|
+
const method = MERGE_METHODS.find((m) => allowed[m]);
|
|
328
|
+
if (!method)
|
|
329
|
+
throw new Error(`${repo} allows no merge method this token can see.`);
|
|
330
|
+
return method;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* gh prints GitHub's refusal on stderr (`gh: Required status check … (HTTP 405)`),
|
|
334
|
+
* sometimes followed by a hint line (a 401 adds `try authenticating with: gh auth
|
|
335
|
+
* login`). Keep the line carrying the HTTP status, else the first line.
|
|
336
|
+
*/
|
|
337
|
+
function ghFailure(err) {
|
|
338
|
+
const stderr = err?.stderr;
|
|
339
|
+
const text = typeof stderr === 'string' && stderr.trim() ? stderr : err instanceof Error ? err.message : String(err);
|
|
340
|
+
const lines = text.trim().split('\n').map((l) => l.trim()).filter(Boolean);
|
|
341
|
+
const line = lines.find((l) => /\(HTTP \d{3}\)/.test(l)) ?? lines[0] ?? text;
|
|
342
|
+
return line.replace(/^gh:\s*/, '');
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Merge one PR over REST (`PUT repos/{repo}/pulls/{n}/merge`), pinned to `sha`:
|
|
346
|
+
* GitHub refuses with 409 when the head moved since the caller looked, so a push
|
|
347
|
+
* that landed after the menu rendered is never merged unseen. Branch protection,
|
|
348
|
+
* required checks and reviews are GitHub's to enforce; their refusal comes back
|
|
349
|
+
* as `merged: false` with GitHub's own message.
|
|
350
|
+
*/
|
|
351
|
+
export async function mergeProjectPr(repo, number, sha, method, gh = ghExec) {
|
|
352
|
+
let chosen;
|
|
353
|
+
try {
|
|
354
|
+
chosen = method ?? await defaultMergeMethod(repo, gh);
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
return { repo, number, method: method ?? MERGE_METHODS[0], merged: false, sha: null, message: ghFailure(err) };
|
|
358
|
+
}
|
|
359
|
+
let out;
|
|
360
|
+
try {
|
|
361
|
+
out = await gh([
|
|
362
|
+
'api', '-X', 'PUT', `repos/${repo}/pulls/${number}/merge`,
|
|
363
|
+
'-f', `sha=${sha}`, '-f', `merge_method=${chosen}`, '--jq', '.sha',
|
|
364
|
+
]);
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
return { repo, number, method: chosen, merged: false, sha: null, message: ghFailure(err) };
|
|
368
|
+
}
|
|
369
|
+
// GitHub answers this endpoint 200 only once the PR is merged; anything else made gh exit non-zero.
|
|
370
|
+
return { repo, number, method: chosen, merged: true, sha: out.trim() || null, message: 'Merged' };
|
|
173
371
|
}
|
|
@@ -15,13 +15,23 @@ export const cursorAdapter = {
|
|
|
15
15
|
stripForeignConfigDir(result);
|
|
16
16
|
},
|
|
17
17
|
execPreModeArgs(ctx) {
|
|
18
|
-
// A configured headless run is the workspace trust decision
|
|
19
|
-
//
|
|
20
|
-
|
|
18
|
+
// A configured headless run is the workspace trust decision, in plan mode
|
|
19
|
+
// too: an untrusted cwd otherwise stops on Cursor's trust prompt with no one
|
|
20
|
+
// to answer it. Keep this narrower than --yolo/-f, which skip already passes
|
|
21
|
+
// and which also bypasses permission checks.
|
|
22
|
+
return ctx.resolvedMode !== 'skip' && !ctx.interactive ? ['--trust'] : undefined;
|
|
23
|
+
},
|
|
24
|
+
// Headless read-only runs use ask mode, not --plan. In plan mode Cursor
|
|
25
|
+
// delivers its answer through the createPlan tool, which `-p` text output
|
|
26
|
+
// never prints, so the run exits 0 with an empty stdout. Ask mode is equally
|
|
27
|
+
// read-only (it refuses file writes) and prints its answer. Interactive plan
|
|
28
|
+
// keeps --plan, where the TUI renders the plan.
|
|
29
|
+
execModeArgs(ctx) {
|
|
30
|
+
return ctx.resolvedMode === 'plan' && !ctx.interactive ? ['--mode', 'ask'] : undefined;
|
|
21
31
|
},
|
|
22
32
|
routineModeArgs(cmd, ctx) {
|
|
23
33
|
if (ctx.mode === 'plan') {
|
|
24
|
-
cmd.push('--
|
|
34
|
+
cmd.push('--mode', 'ask', '--trust');
|
|
25
35
|
}
|
|
26
36
|
else if (ctx.mode === 'skip') {
|
|
27
37
|
cmd.push('-f');
|
|
@@ -16,9 +16,10 @@ import type { AgentId } from './types.js';
|
|
|
16
16
|
type HarnessIdentityKind = 'strong' | 'email' | 'opaque';
|
|
17
17
|
/**
|
|
18
18
|
* Durable worker credential, or `none` when the harness must log in per box.
|
|
19
|
-
* Codex
|
|
20
|
-
*
|
|
21
|
-
* store because it is a
|
|
19
|
+
* Codex and Grok are both: an API key (portable, bills the API) OR a
|
|
20
|
+
* per-device device-auth login (the subscription seat — ChatGPT plan,
|
|
21
|
+
* SuperGrok, X Premium+; never stored in the reserved store because it is a
|
|
22
|
+
* rotating session, so each box signs in for itself).
|
|
22
23
|
*/
|
|
23
24
|
type HarnessWorkerKind = 'setup-token' | 'none' | `api-key:${string}` | `per-device${'' | `:${string}`}`;
|
|
24
25
|
type HarnessWorker = HarnessWorkerKind | HarnessWorkerKind[];
|
|
@@ -6,7 +6,7 @@ export const HARNESS_AUTH = {
|
|
|
6
6
|
// the OS defaults to. The device-code screen prints the URL + code instead,
|
|
7
7
|
// so the human finishes it in the browser profile they choose — which is
|
|
8
8
|
// what makes it usable over an SSH shell on a worker.
|
|
9
|
-
grok: { login: ['login', '--device-auth'], status: null, identity: 'strong', worker: 'api-key:XAI_API_KEY', slotEnv: 'GROK_HOME' },
|
|
9
|
+
grok: { login: ['login', '--device-auth'], status: null, identity: 'strong', worker: ['api-key:XAI_API_KEY', 'per-device:device-auth'], slotEnv: 'GROK_HOME' },
|
|
10
10
|
// auth.json has no email claim; identity is the sorted provider-id join
|
|
11
11
|
// (`resolveOpenCodeAccountId`). NATIVE_ACCOUNT_CAPABILITIES.opencode.inspection
|
|
12
12
|
// is already 'opaque'.
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* it would claim a distribution responsibility agents-cli no longer has.
|
|
42
42
|
*/
|
|
43
43
|
export const HELPER_RELEASES = {
|
|
44
|
-
menubar: { tagPrefix: 'menubar', floor: '1.6.
|
|
44
|
+
menubar: { tagPrefix: 'menubar', floor: '1.6.2' },
|
|
45
45
|
};
|
|
46
46
|
/** The release tag for one helper at one version, e.g. `menubar/v1.0.0`. */
|
|
47
47
|
export function helperTag(helper, version) {
|
|
@@ -64,7 +64,7 @@ export interface ConflictInfo {
|
|
|
64
64
|
* top-level entry add/remove — deep edits to plugin contents won't
|
|
65
65
|
* trigger auto-resync, run `agents sync` for that.
|
|
66
66
|
*/
|
|
67
|
-
export declare const SHIM_SCHEMA_VERSION =
|
|
67
|
+
export declare const SHIM_SCHEMA_VERSION = 33;
|
|
68
68
|
/**
|
|
69
69
|
* Generate the full bash shim script for the given agent. The returned string
|
|
70
70
|
* is written to ~/.agents/shims/{cliCommand} and made executable.
|
|
@@ -271,7 +271,7 @@ async function promptConflictStrategy(conflictInfos) {
|
|
|
271
271
|
// v32 — every config-dir pin (claude, grok, opencode, kimi, muse, copilot) yields to
|
|
272
272
|
// an account-slot launch (AGENTS_EXEC_HOME from `agents run`), so a slot run
|
|
273
273
|
// reads and writes its own slot home instead of the shared version home.
|
|
274
|
-
export const SHIM_SCHEMA_VERSION =
|
|
274
|
+
export const SHIM_SCHEMA_VERSION = 33;
|
|
275
275
|
/** Internal marker string used to embed the schema version in shim scripts. */
|
|
276
276
|
const SHIM_VERSION_MARKER = 'agents-shim-version:';
|
|
277
277
|
function shellQuote(value) {
|
|
@@ -306,7 +306,14 @@ function shellQuote(value) {
|
|
|
306
306
|
* versions.ts (which reads bare filenames via `fs.readdirSync` and so never
|
|
307
307
|
* had the full-path-match bug).
|
|
308
308
|
*/
|
|
309
|
-
const GROK_RESOLVE_BINARY_FN = `
|
|
309
|
+
const GROK_RESOLVE_BINARY_FN = `_resolve_grok_current() {
|
|
310
|
+
local target
|
|
311
|
+
target=$(readlink -f "$1/bin/grok" 2>/dev/null) || return 0
|
|
312
|
+
[ -f "$target" ] && [ -x "$target" ] || return 0
|
|
313
|
+
[ "$(wc -c < "$target" 2>/dev/null || echo 0)" -ge 1000000 ] || return 0
|
|
314
|
+
printf '%s\\n' "$target"
|
|
315
|
+
}
|
|
316
|
+
_resolve_grok_binary() {
|
|
310
317
|
local dir="$1" version_hint="$2"
|
|
311
318
|
local candidate base size mtime
|
|
312
319
|
for candidate in "$dir"/grok-*; do
|
|
@@ -569,12 +576,20 @@ ${GROK_RESOLVE_BINARY_FN}
|
|
|
569
576
|
# Check the versioned home first — this is where the binary lands when the
|
|
570
577
|
# installer runs with GROK_HOME set (i.e. via the shim or a correct
|
|
571
578
|
# \`agents add grok\`), or when grok self-updates from within the shim.
|
|
579
|
+
# \`bin/grok\` is grok's own current-release pointer, kept by its updater in
|
|
580
|
+
# both layouts (\`bin/grok -> ../downloads/grok-<v>-<arch>\` and the newer
|
|
581
|
+
# \`bin/grok -> grok-<v>\`); \`grok update\` never touches downloads/ in the
|
|
582
|
+
# newer one, so scanning downloads/ alone keeps exec'ing a dead release.
|
|
583
|
+
BINARY=$(_resolve_grok_current "$VERSION_DIR/home/.grok")
|
|
572
584
|
GROK_DOWNLOADS="$VERSION_DIR/home/.grok/downloads"
|
|
573
|
-
if [ -d "$GROK_DOWNLOADS" ]; then
|
|
585
|
+
if [ -z "$BINARY" ] && [ -d "$GROK_DOWNLOADS" ]; then
|
|
574
586
|
BINARY=$(_resolve_grok_binary "$GROK_DOWNLOADS" "$VERSION")
|
|
575
587
|
fi
|
|
576
588
|
# Fall back to the global grok home (binary installed without GROK_HOME set,
|
|
577
589
|
# e.g. an earlier \`agents add grok@latest\` before this resolution fix).
|
|
590
|
+
if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
|
|
591
|
+
BINARY=$(_resolve_grok_current "$HOME/.grok")
|
|
592
|
+
fi
|
|
578
593
|
if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
|
|
579
594
|
GROK_DOWNLOADS="$HOME/.grok/downloads"
|
|
580
595
|
if [ -d "$GROK_DOWNLOADS" ]; then
|
|
@@ -1300,12 +1315,15 @@ export AGENT_CLI_CREDENTIAL_STORE="file"
|
|
|
1300
1315
|
? `# Grok ships its native binary in the versioned home's .grok/downloads (or,
|
|
1301
1316
|
# for pre-fix installs, the global ~/.grok/downloads), not node_modules.
|
|
1302
1317
|
${GROK_RESOLVE_BINARY_FN}
|
|
1318
|
+
BINARY=$(_resolve_grok_current "${versionDir}/home/.grok")
|
|
1303
1319
|
GROK_DOWNLOADS="${versionDir}/home/.grok/downloads"
|
|
1304
|
-
BINARY
|
|
1305
|
-
if [ -d "$GROK_DOWNLOADS" ]; then
|
|
1320
|
+
if [ -z "$BINARY" ] && [ -d "$GROK_DOWNLOADS" ]; then
|
|
1306
1321
|
BINARY=$(_resolve_grok_binary "$GROK_DOWNLOADS" "${version}")
|
|
1307
1322
|
fi
|
|
1308
1323
|
# Fall back to the global grok home (binary installed without GROK_HOME set).
|
|
1324
|
+
if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
|
|
1325
|
+
BINARY=$(_resolve_grok_current "$AGENTS_REAL_HOME/.grok")
|
|
1326
|
+
fi
|
|
1309
1327
|
if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
|
|
1310
1328
|
GROK_GLOBAL_DOWNLOADS="$AGENTS_REAL_HOME/.grok/downloads"
|
|
1311
1329
|
if [ -d "$GROK_GLOBAL_DOWNLOADS" ]; then
|
|
@@ -118,6 +118,15 @@ export declare function getVersionDir(agent: AgentId, version: string): string;
|
|
|
118
118
|
* Returns null (fail loud, never guess) when nothing survives the size filter.
|
|
119
119
|
*/
|
|
120
120
|
export declare function resolveGrokFallbackBinary(downloadsDir: string): string | null;
|
|
121
|
+
/**
|
|
122
|
+
* Grok's own current-release pointer, `<grokHome>/bin/grok`, resolved to the
|
|
123
|
+
* real binary, or null when absent or not a real binary. Grok's updater keeps
|
|
124
|
+
* it current in both of its layouts (`bin/grok -> ../downloads/grok-<v>-<arch>`
|
|
125
|
+
* and the newer `bin/grok -> grok-<v>`, which never touches downloads/), so it
|
|
126
|
+
* is the only location that tracks `grok update`. Mirrors the shim's
|
|
127
|
+
* `_resolve_grok_current`.
|
|
128
|
+
*/
|
|
129
|
+
export declare function resolveGrokCurrentBinary(grokHome: string): string | null;
|
|
121
130
|
/**
|
|
122
131
|
* Get the binary path for a specific agent version.
|
|
123
132
|
*/
|
|
@@ -339,12 +339,36 @@ export function resolveGrokFallbackBinary(downloadsDir) {
|
|
|
339
339
|
}
|
|
340
340
|
return best ? path.join(downloadsDir, best.name) : null;
|
|
341
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* Grok's own current-release pointer, `<grokHome>/bin/grok`, resolved to the
|
|
344
|
+
* real binary, or null when absent or not a real binary. Grok's updater keeps
|
|
345
|
+
* it current in both of its layouts (`bin/grok -> ../downloads/grok-<v>-<arch>`
|
|
346
|
+
* and the newer `bin/grok -> grok-<v>`, which never touches downloads/), so it
|
|
347
|
+
* is the only location that tracks `grok update`. Mirrors the shim's
|
|
348
|
+
* `_resolve_grok_current`.
|
|
349
|
+
*/
|
|
350
|
+
export function resolveGrokCurrentBinary(grokHome) {
|
|
351
|
+
try {
|
|
352
|
+
const target = fs.realpathSync(path.join(grokHome, 'bin', 'grok'));
|
|
353
|
+
const stat = fs.statSync(target);
|
|
354
|
+
if (!stat.isFile() || stat.size < MIN_GROK_BINARY_BYTES)
|
|
355
|
+
return null;
|
|
356
|
+
fs.accessSync(target, fs.constants.X_OK);
|
|
357
|
+
return target;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
342
363
|
/**
|
|
343
364
|
* Get the binary path for a specific agent version.
|
|
344
365
|
*/
|
|
345
366
|
export function getBinaryPath(agent, version) {
|
|
346
367
|
const agentConfig = AGENTS[agent];
|
|
347
368
|
if (agent === 'grok') {
|
|
369
|
+
const current = resolveGrokCurrentBinary(path.join(getVersionHomePath(agent, version), '.grok'));
|
|
370
|
+
if (current)
|
|
371
|
+
return current;
|
|
348
372
|
const grokDownloads = path.join(getVersionHomePath(agent, version), '.grok', 'downloads');
|
|
349
373
|
// The directory token is the stable installation/account label. A
|
|
350
374
|
// self-updating slot may carry a newer vendor release, whose binary keeps
|
|
@@ -2550,7 +2550,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
|
|
|
2550
2550
|
// Orphan-sweep stale skill directories from previous syncs under a
|
|
2551
2551
|
// different cwd. Only runs in "full sync" mode (no explicit selection) —
|
|
2552
2552
|
// see the matching guard on the commands sweep above for why. Skip
|
|
2553
|
-
// dot-dirs to keep plugin-managed subtrees (.plugins/, .promptcuts) intact
|
|
2553
|
+
// dot-dirs to keep plugin-managed subtrees (.plugins/, .promptcuts) intact,
|
|
2554
|
+
// and the harness's own directories (ownedSkillDirs), which it writes itself.
|
|
2554
2555
|
const skillsTargetSweep = path.join(agentDir, 'skills');
|
|
2555
2556
|
if (!userPassedSelection && fs.existsSync(skillsTargetSweep) && !fs.lstatSync(skillsTargetSweep).isSymbolicLink()) {
|
|
2556
2557
|
// Trust real skills AND command-skills: when commandsInstallAsSkills, the
|
|
@@ -2558,7 +2559,7 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
|
|
|
2558
2559
|
// skills/. Those names are not in skillsToSync, so without this they'd be
|
|
2559
2560
|
// swept as orphans — silently deleting every converted command (e.g.
|
|
2560
2561
|
// /recap on Kimi or newer Codex releases).
|
|
2561
|
-
const trustedSkills = new Set(skillsToSync);
|
|
2562
|
+
const trustedSkills = new Set([...skillsToSync, ...(agentConfig.ownedSkillDirs ?? [])]);
|
|
2562
2563
|
if (commandsInstallAsSkills)
|
|
2563
2564
|
for (const cmd of commandsToSync)
|
|
2564
2565
|
trustedSkills.add(cmd);
|
package/dist/lib/projects.d.ts
CHANGED
|
@@ -193,6 +193,22 @@ export declare function projectDirsAbs(def: ProjectDef, opts: {
|
|
|
193
193
|
forRemote: boolean;
|
|
194
194
|
primary?: string;
|
|
195
195
|
}): string[];
|
|
196
|
+
/**
|
|
197
|
+
* The repository paths a project claims, as `{ slug, prefix }` with `prefix`
|
|
198
|
+
* repo-relative and `dir/` shaped — the same ownership {@link projectRootsAbs}
|
|
199
|
+
* applies to a session's cwd, expressed against the repository instead of a
|
|
200
|
+
* checkout: a `defaultPath` narrowed under `root` claims that subtree of the
|
|
201
|
+
* primary `repo`, and a `repos[]` entry with a `subpath` claims that subpath. A
|
|
202
|
+
* project with no narrowed claim on a repository claims all of it and yields no
|
|
203
|
+
* row for it. Two cases of {@link projectRootsAbs} have no repository form and
|
|
204
|
+
* yield nothing here: a `defaultPath` outside `root` (no repo-relative path),
|
|
205
|
+
* and the weak root of a narrowed project (it only takes what nobody claims,
|
|
206
|
+
* which for a PR is the `repo-wide` scope).
|
|
207
|
+
*/
|
|
208
|
+
export declare function repoPathClaims(def: ProjectDef): Array<{
|
|
209
|
+
slug: string;
|
|
210
|
+
prefix: string;
|
|
211
|
+
}>;
|
|
196
212
|
/**
|
|
197
213
|
* Which defined project a session belongs to, derived from its working
|
|
198
214
|
* directory. A session whose `cwd` sits inside a project's repo root (or a
|