@phnx-labs/agents-cli 1.20.91 → 1.20.93
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 +276 -0
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +72 -6
- package/dist/commands/activity.js +198 -49
- package/dist/commands/beta.js +1 -0
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/events.js +91 -1
- package/dist/commands/exec.d.ts +14 -0
- package/dist/commands/exec.js +144 -14
- package/dist/commands/projects.d.ts +22 -0
- package/dist/commands/projects.js +539 -0
- package/dist/commands/secrets.d.ts +17 -0
- package/dist/commands/secrets.js +198 -7
- package/dist/commands/send.d.ts +14 -12
- package/dist/commands/send.js +105 -35
- package/dist/commands/sessions-picker.d.ts +15 -0
- package/dist/commands/sessions-picker.js +37 -12
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +9 -1
- package/dist/commands/sessions.d.ts +10 -5
- package/dist/commands/sessions.js +65 -27
- package/dist/commands/sync.js +9 -3
- package/dist/commands/view.js +4 -0
- package/dist/index.js +18 -1
- package/dist/lib/activity.d.ts +77 -12
- package/dist/lib/activity.js +424 -74
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/channels/send.d.ts +83 -0
- package/dist/lib/channels/send.js +112 -0
- package/dist/lib/devices/registry.d.ts +14 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/events-ingest.d.ts +46 -0
- package/dist/lib/events-ingest.js +182 -0
- package/dist/lib/events.d.ts +15 -3
- package/dist/lib/events.js +55 -3
- package/dist/lib/feed-post.js +8 -2
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/linear-project-counts.d.ts +62 -0
- package/dist/lib/linear-project-counts.js +122 -0
- package/dist/lib/linear-projects.d.ts +50 -0
- package/dist/lib/linear-projects.js +114 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +14 -4
- package/dist/lib/menubar/install-menubar.js +20 -6
- package/dist/lib/menubar/notify-desktop.d.ts +17 -2
- package/dist/lib/menubar/notify-desktop.js +8 -2
- package/dist/lib/project-key.d.ts +44 -0
- package/dist/lib/project-key.js +79 -0
- package/dist/lib/project-probe.d.ts +75 -0
- package/dist/lib/project-probe.js +160 -0
- package/dist/lib/project-resources.d.ts +8 -0
- package/dist/lib/project-resources.js +31 -3
- package/dist/lib/project-root.js +16 -0
- package/dist/lib/project-status.d.ts +100 -0
- package/dist/lib/project-status.js +182 -0
- package/dist/lib/projects.d.ts +144 -0
- package/dist/lib/projects.js +313 -0
- package/dist/lib/remote-agents-json.d.ts +9 -0
- package/dist/lib/remote-agents-json.js +11 -5
- package/dist/lib/routine-notify.d.ts +11 -0
- package/dist/lib/routine-notify.js +22 -0
- package/dist/lib/run-notify.js +3 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +53 -10
- package/dist/lib/secrets/list-filter.d.ts +20 -5
- package/dist/lib/secrets/list-filter.js +22 -6
- package/dist/lib/secrets/usage-db.d.ts +106 -0
- package/dist/lib/secrets/usage-db.js +236 -0
- package/dist/lib/session/bash-command.d.ts +53 -0
- package/dist/lib/session/bash-command.js +364 -0
- package/dist/lib/session/digest.d.ts +6 -0
- package/dist/lib/session/digest.js +19 -0
- package/dist/lib/session/relative-time.d.ts +23 -0
- package/dist/lib/session/relative-time.js +60 -8
- package/dist/lib/session/remote-active.d.ts +5 -1
- package/dist/lib/session/remote-active.js +4 -1
- package/dist/lib/session/remote-list.js +5 -2
- package/dist/lib/session/render.d.ts +2 -9
- package/dist/lib/session/render.js +25 -56
- package/dist/lib/sqlite.js +28 -1
- package/dist/lib/ssh-exec.d.ts +6 -0
- package/dist/lib/ssh-exec.js +10 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +14 -0
- package/dist/lib/state.js +19 -0
- package/dist/lib/terminal/backends/index.d.ts +10 -2
- package/dist/lib/terminal/backends/index.js +14 -2
- package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
- package/dist/lib/terminal/backends/terminal-app.js +73 -0
- package/dist/lib/terminal/index.d.ts +2 -1
- package/dist/lib/terminal/index.js +2 -1
- package/dist/lib/terminal/preferred.d.ts +89 -0
- package/dist/lib/terminal/preferred.js +87 -0
- package/dist/lib/terminal/run-surface.d.ts +82 -0
- package/dist/lib/terminal/run-surface.js +146 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/types.d.ts +6 -5
- package/dist/lib/versions.d.ts +6 -0
- package/dist/lib/versions.js +6 -4
- package/package.json +2 -1
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents projects` — named, multi-repo projects and the progress
|
|
3
|
+
* rollup. Definitions live in `~/.agents/projects/<name>.yaml` (see
|
|
4
|
+
* `lib/projects.ts`); this registers the command tree over them. Beta-gated on
|
|
5
|
+
* `isBetaEnabled('projects')`, mirroring `agents factory`.
|
|
6
|
+
*
|
|
7
|
+
* The headline is `status`: instead of the vague per-agent activity line, it
|
|
8
|
+
* rolls every session up by project (matched on cwd) into one card — agents by
|
|
9
|
+
* lifecycle state, plan completion, open PRs, and tickets in flight.
|
|
10
|
+
*/
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import { execFileSync, spawnSync } from 'child_process';
|
|
14
|
+
import { betaEnableHint, isBetaEnabled } from '../lib/beta.js';
|
|
15
|
+
import { setHelpSections } from '../lib/help.js';
|
|
16
|
+
import { getMainRepoRoot } from '../lib/git.js';
|
|
17
|
+
import { parseOwnerRepoFromRemote } from '../lib/registry.js';
|
|
18
|
+
import { toHomeRelative } from '../lib/project-root.js';
|
|
19
|
+
import { machineId } from '../lib/machine-id.js';
|
|
20
|
+
import { getActiveSessions } from '../lib/session/active.js';
|
|
21
|
+
import { gatherRemoteActive } from '../lib/session/remote-active.js';
|
|
22
|
+
import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
|
|
23
|
+
import { factoryProjectsPath } from '../lib/auto-dispatch.js';
|
|
24
|
+
import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, } from '../lib/project-probe.js';
|
|
25
|
+
import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
|
|
26
|
+
import { rollupSessionsByProject, planPct, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
|
|
27
|
+
import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
|
|
28
|
+
import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
|
|
29
|
+
/** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
|
|
30
|
+
export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
|
|
31
|
+
/** Max peers named in the skipped note before the rest collapse to `+N`. */
|
|
32
|
+
const SKIPPED_NAME_LIMIT = 4;
|
|
33
|
+
/**
|
|
34
|
+
* One compact trailing note for peers that didn't answer the `--fleet`
|
|
35
|
+
* fan-out — unreachable, running an agents-cli too old to carry `projects
|
|
36
|
+
* probe`, or too slow to finish inside the 12s SSH budget. Mirrors
|
|
37
|
+
* `formatUnreachableNote` (activity) with the probe-specific reasons; empty
|
|
38
|
+
* string when everything answered.
|
|
39
|
+
*/
|
|
40
|
+
export function formatFleetSkippedNote(skipped) {
|
|
41
|
+
if (skipped.length === 0)
|
|
42
|
+
return '';
|
|
43
|
+
const named = skipped.slice(0, SKIPPED_NAME_LIMIT);
|
|
44
|
+
const rest = skipped.length - named.length;
|
|
45
|
+
const list = rest > 0 ? `${named.join(', ')} +${rest}` : named.join(', ');
|
|
46
|
+
const noun = skipped.length === 1 ? 'device' : 'devices';
|
|
47
|
+
return chalk.gray(` · ${skipped.length} ${noun} didn't answer (unreachable, older agents-cli, or timed out): ${list}\n`);
|
|
48
|
+
}
|
|
49
|
+
/** `path:purpose` → a context anchor. Purpose may contain colons. */
|
|
50
|
+
function parseContextFlag(raw) {
|
|
51
|
+
const i = raw.indexOf(':');
|
|
52
|
+
if (i === -1)
|
|
53
|
+
return { path: raw.trim(), purpose: '' };
|
|
54
|
+
return { path: raw.slice(0, i).trim(), purpose: raw.slice(i + 1).trim() };
|
|
55
|
+
}
|
|
56
|
+
/** Best-effort `owner/repo` from a repo's origin remote. */
|
|
57
|
+
function originSlug(cwd) {
|
|
58
|
+
try {
|
|
59
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8' }).trim();
|
|
60
|
+
return parseOwnerRepoFromRemote(url) ?? undefined;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function statusBar(r) {
|
|
67
|
+
const parts = [];
|
|
68
|
+
const push = (n, label, color) => {
|
|
69
|
+
if (n && n > 0)
|
|
70
|
+
parts.push(color(`${n} ${label}`));
|
|
71
|
+
};
|
|
72
|
+
push(r.byStatus.running, 'running', chalk.green);
|
|
73
|
+
push(r.byStatus.idle, 'idle', chalk.gray);
|
|
74
|
+
push(r.byStatus.input_required, 'need-input', chalk.yellow);
|
|
75
|
+
push(r.byStatus.queued, 'queued', chalk.gray);
|
|
76
|
+
const shown = (r.byStatus.running ?? 0) + (r.byStatus.idle ?? 0) + (r.byStatus.input_required ?? 0) + (r.byStatus.queued ?? 0);
|
|
77
|
+
// Sessions in a non-live state (orphaned/crashed/unknown) count toward the
|
|
78
|
+
// headline — render the remainder so the bar never sums to less than it.
|
|
79
|
+
if (r.agents > shown)
|
|
80
|
+
parts.push(chalk.gray(`+${r.agents - shown} other`));
|
|
81
|
+
return parts.join(' · ') || chalk.gray('no live agents');
|
|
82
|
+
}
|
|
83
|
+
function renderCard(def, r, remote, fleet, linear) {
|
|
84
|
+
const agents = r?.agents ?? 0;
|
|
85
|
+
const pct = r ? planPct(r.plan) : undefined;
|
|
86
|
+
const planStr = pct === undefined ? '' : ` · ${chalk.cyan(`${pct}% plan`)}`;
|
|
87
|
+
console.log(`${chalk.bold(def.name)} ${chalk.dim('·')} ${chalk.bold(`${agents} agents`)}${planStr}`);
|
|
88
|
+
if (def.description)
|
|
89
|
+
console.log(` ${chalk.dim(def.description)}`);
|
|
90
|
+
console.log(` ${chalk.dim('live')} ${r ? statusBar(r) : chalk.gray('no live agents')}`);
|
|
91
|
+
if (r && r.members.length)
|
|
92
|
+
console.log(` ${chalk.dim('agents')} ${formatProjectMembers(r.members)}`);
|
|
93
|
+
const ships = [];
|
|
94
|
+
if (remote?.mergedPrs)
|
|
95
|
+
ships.push(chalk.green(`${remote.mergedPrs} merged (${remote.windowDays}d)`));
|
|
96
|
+
if (r?.openPrs.length)
|
|
97
|
+
ships.push(`${r.openPrs.length} open PR${r.openPrs.length === 1 ? '' : 's'}`);
|
|
98
|
+
if (r?.worktrees)
|
|
99
|
+
ships.push(`${r.worktrees} worktree${r.worktrees === 1 ? '' : 's'}`);
|
|
100
|
+
if (remote?.latestRelease)
|
|
101
|
+
ships.push(remote.latestRelease.tag);
|
|
102
|
+
if (ships.length)
|
|
103
|
+
console.log(` ${chalk.dim('ships')} ${ships.join(' · ')}`);
|
|
104
|
+
if (linear) {
|
|
105
|
+
console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done · ${linear.inProgress} in progress`);
|
|
106
|
+
}
|
|
107
|
+
if (r && r.tickets.length) {
|
|
108
|
+
console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
|
|
109
|
+
}
|
|
110
|
+
if (fleet) {
|
|
111
|
+
const lines = formatFleetWorkspaces(fleet);
|
|
112
|
+
if (lines.length === 0) {
|
|
113
|
+
console.log(` ${chalk.dim('fleet')} ${chalk.gray('no workspace paths (set root or repos[].path)')}`);
|
|
114
|
+
}
|
|
115
|
+
lines.forEach((line, i) => {
|
|
116
|
+
console.log(` ${chalk.dim((i === 0 ? 'fleet' : '').padEnd(5))} ${line}`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (remote?.artifacts) {
|
|
120
|
+
const last = remote.lastArtifact ? ` ${chalk.dim(`· last: ${remote.lastArtifact}`)}` : '';
|
|
121
|
+
console.log(` ${chalk.dim('proof')} ${remote.artifacts} artifact${remote.artifacts === 1 ? '' : 's'} (${remote.windowDays}d)${last}`);
|
|
122
|
+
}
|
|
123
|
+
const repos = [def.repo, ...(def.repos ?? []).map((x) => x.slug)].filter(Boolean);
|
|
124
|
+
if (repos.length)
|
|
125
|
+
console.log(` ${chalk.dim('repos')} ${[...new Set(repos)].join(' · ')}`);
|
|
126
|
+
if (def.contexts?.length) {
|
|
127
|
+
console.log(` ${chalk.dim('context')} ${def.contexts.map((c) => c.path).join(' · ')}`);
|
|
128
|
+
}
|
|
129
|
+
if (def.integrations?.length) {
|
|
130
|
+
console.log(` ${chalk.dim('links')} ${def.integrations.map((i) => i.label ?? i.kind).join(' · ')}`);
|
|
131
|
+
}
|
|
132
|
+
console.log('');
|
|
133
|
+
}
|
|
134
|
+
export function registerProjectsCommands(program) {
|
|
135
|
+
const enabled = isBetaEnabled('projects');
|
|
136
|
+
const projects = program
|
|
137
|
+
.command('projects', { hidden: !enabled })
|
|
138
|
+
.description('Named multi-repo projects with a progress rollup.');
|
|
139
|
+
setHelpSections(projects, {
|
|
140
|
+
examples: `
|
|
141
|
+
agents projects add rush --repo phnx-labs/rush --path apps/web
|
|
142
|
+
agents projects list
|
|
143
|
+
agents projects status # progress card for every project
|
|
144
|
+
agents projects status rush --json # one project, machine-readable
|
|
145
|
+
agents projects status --fleet # + per-device workspace drift over SSH
|
|
146
|
+
agents projects link rush --linear # bind the Linear project (auto-suggest)
|
|
147
|
+
agents run --project rush # land an agent in the project
|
|
148
|
+
`,
|
|
149
|
+
notes: `
|
|
150
|
+
Definitions are hand-editable YAML in ~/.agents/projects/ and sync across
|
|
151
|
+
machines with 'agents push/pull'. Enable with: agents beta enable projects.
|
|
152
|
+
`,
|
|
153
|
+
});
|
|
154
|
+
projects.hook('preAction', (_thisCommand, actionCommand) => {
|
|
155
|
+
if (enabled)
|
|
156
|
+
return;
|
|
157
|
+
// `probe` is the peer half of `status --fleet`: it must answer whenever the
|
|
158
|
+
// binary carries it, even where the beta flag is off — a gated peer would
|
|
159
|
+
// look unreachable to every fleet member on a newer CLI.
|
|
160
|
+
if (actionCommand.name() === 'probe')
|
|
161
|
+
return;
|
|
162
|
+
console.error(chalk.red('agents projects is in beta.'));
|
|
163
|
+
console.error(chalk.gray(betaEnableHint('projects')));
|
|
164
|
+
process.exit(1);
|
|
165
|
+
});
|
|
166
|
+
// ---- list ----
|
|
167
|
+
projects
|
|
168
|
+
.command('list')
|
|
169
|
+
.description('List defined projects with their root, repo, and live agent count.')
|
|
170
|
+
.option('--json', 'Machine-readable output')
|
|
171
|
+
.action(async (opts) => {
|
|
172
|
+
const defs = listProjectDefs();
|
|
173
|
+
if (opts.json) {
|
|
174
|
+
const roll = rollupSessionsByProject(defs, await getActiveSessions());
|
|
175
|
+
console.log(JSON.stringify(defs.map((d) => ({ ...d, agents: roll.get(d.name)?.agents ?? 0 })), null, 2));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!defs.length) {
|
|
179
|
+
console.log(chalk.gray('No projects defined. Add one: agents projects add <name>'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const roll = rollupSessionsByProject(defs, await getActiveSessions());
|
|
183
|
+
for (const d of defs) {
|
|
184
|
+
const agents = roll.get(d.name)?.agents ?? 0;
|
|
185
|
+
const repo = d.repo ?? d.repos?.[0]?.slug ?? '';
|
|
186
|
+
console.log(` ${chalk.bold(d.name.padEnd(16))} ${chalk.dim((d.root ?? d.defaultPath ?? '').padEnd(32))} ${chalk.cyan(repo.padEnd(24))} ${agents} agents`);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
// ---- add ----
|
|
190
|
+
projects
|
|
191
|
+
.command('add <name>')
|
|
192
|
+
.description('Define a project. Infers root and repo from the current git repo when not given.')
|
|
193
|
+
.option('--root <path>', 'Repo / monorepo root (defaults to the current git repo root)')
|
|
194
|
+
.option('--path <subdir>', 'Default cwd for agents (a monorepo subdir)')
|
|
195
|
+
.option('--repo <owner/repo>', 'Primary GitHub slug (defaults to the origin remote)')
|
|
196
|
+
.option('--context <path:purpose...>', 'A described starting point; repeatable')
|
|
197
|
+
.option('--linear <url-or-id>', 'Linear project URL or id')
|
|
198
|
+
.option('--force', 'Overwrite an existing definition')
|
|
199
|
+
.action(async (name, opts) => {
|
|
200
|
+
if (!isSafeProjectName(name)) {
|
|
201
|
+
console.error(chalk.red(`Invalid project name: "${name}" (letters, digits, ., _, - only)`));
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
if (loadProjectDef(name) && !opts.force) {
|
|
205
|
+
console.error(chalk.red(`Project "${name}" already exists. Use --force to overwrite, or 'agents projects edit ${name}'.`));
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
const cwd = process.cwd();
|
|
209
|
+
let root = opts.root;
|
|
210
|
+
if (!root) {
|
|
211
|
+
try {
|
|
212
|
+
root = toHomeRelative(await getMainRepoRoot(cwd));
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
console.error(chalk.red('Not inside a git repo — pass --root <path> explicitly.'));
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const repo = opts.repo ?? originSlug(cwd);
|
|
220
|
+
const def = { name, root };
|
|
221
|
+
if (opts.path)
|
|
222
|
+
def.defaultPath = `${root.replace(/\/$/, '')}/${opts.path.replace(/^\//, '')}`;
|
|
223
|
+
if (repo)
|
|
224
|
+
def.repo = repo;
|
|
225
|
+
if (opts.context?.length)
|
|
226
|
+
def.contexts = opts.context.map(parseContextFlag);
|
|
227
|
+
if (opts.linear) {
|
|
228
|
+
def.linear = /^https?:/.test(opts.linear) ? { url: opts.linear } : { projectId: opts.linear };
|
|
229
|
+
}
|
|
230
|
+
const target = writeProjectDef(def);
|
|
231
|
+
console.log(chalk.green(`Defined project "${name}"`));
|
|
232
|
+
console.log(chalk.gray(` ${target}`));
|
|
233
|
+
console.log(chalk.gray(` root ${def.root}${def.repo ? ` · repo ${def.repo}` : ''}`));
|
|
234
|
+
});
|
|
235
|
+
// ---- show ----
|
|
236
|
+
projects
|
|
237
|
+
.command('show <name>')
|
|
238
|
+
.description('Show a project definition, resolved paths, repos, contexts, and links.')
|
|
239
|
+
.option('--json', 'Machine-readable output')
|
|
240
|
+
.action((name, opts) => {
|
|
241
|
+
const def = loadProjectDef(name);
|
|
242
|
+
if (!def) {
|
|
243
|
+
console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
if (opts.json) {
|
|
247
|
+
console.log(JSON.stringify(def, null, 2));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
console.log(chalk.bold(def.name) + (def.description ? chalk.dim(` — ${def.description}`) : ''));
|
|
251
|
+
if (def.root)
|
|
252
|
+
console.log(` root ${def.root}`);
|
|
253
|
+
if (def.defaultPath)
|
|
254
|
+
console.log(` defaultPath ${def.defaultPath}`);
|
|
255
|
+
const repos = [def.repo, ...(def.repos ?? []).map((r) => (r.subpath ? `${r.slug} (${r.subpath})` : r.slug))].filter(Boolean);
|
|
256
|
+
if (repos.length)
|
|
257
|
+
console.log(` repos ${repos.join(', ')}`);
|
|
258
|
+
for (const c of def.contexts ?? [])
|
|
259
|
+
console.log(` context ${chalk.cyan(c.path)} — ${c.purpose}`);
|
|
260
|
+
for (const i of def.integrations ?? [])
|
|
261
|
+
console.log(` ${i.kind.padEnd(12)} ${i.url}${i.label ? chalk.dim(` (${i.label})`) : ''}`);
|
|
262
|
+
if (def.linear?.url || def.linear?.projectId)
|
|
263
|
+
console.log(` linear ${def.linear.url ?? def.linear.projectId}`);
|
|
264
|
+
for (const d of def.docs ?? [])
|
|
265
|
+
console.log(` doc ${d}`);
|
|
266
|
+
console.log(chalk.gray(` ${projectDefPath(name)}`));
|
|
267
|
+
});
|
|
268
|
+
// ---- edit ----
|
|
269
|
+
projects
|
|
270
|
+
.command('edit <name>')
|
|
271
|
+
.description('Open the project YAML in $EDITOR (it is hand-editable regardless).')
|
|
272
|
+
.action((name) => {
|
|
273
|
+
const target = projectDefPath(name);
|
|
274
|
+
if (!fs.existsSync(target)) {
|
|
275
|
+
console.error(chalk.red(`No project named "${name}". Create it: agents projects add ${name}`));
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
const editor = process.env.VISUAL || process.env.EDITOR || 'vi';
|
|
279
|
+
// $EDITOR commonly carries args ("code --wait") — split like monitors/routines do.
|
|
280
|
+
const parts = editor.split(/\s+/).filter(Boolean);
|
|
281
|
+
const res = spawnSync(parts[0], [...parts.slice(1), target], { stdio: 'inherit' });
|
|
282
|
+
process.exit(res.status ?? 0);
|
|
283
|
+
});
|
|
284
|
+
// ---- status ----
|
|
285
|
+
projects
|
|
286
|
+
.command('status [name]')
|
|
287
|
+
.description('Progress rollup: agents, plan %, merged/open PRs, tickets, and artifacts per project.')
|
|
288
|
+
.option('--json', 'Machine-readable output')
|
|
289
|
+
.option('--window <days>', 'Window for merged PRs and artifacts', '7')
|
|
290
|
+
.option('--no-remote', 'Skip the GitHub lookup (merged-PR count); faster, offline')
|
|
291
|
+
.option('--fleet', 'Also dial every fleet device for workspace presence, branch, and drift (one SSH per peer)')
|
|
292
|
+
.action(async (name, opts) => {
|
|
293
|
+
const all = listProjectDefs();
|
|
294
|
+
// Named lookup goes through the strict single-def loader so a broken
|
|
295
|
+
// <name>.yaml surfaces its validation error instead of "No project named".
|
|
296
|
+
const defs = name ? [loadProjectDef(name)].filter((d) => d !== undefined) : all;
|
|
297
|
+
if (name && !defs.length) {
|
|
298
|
+
console.error(chalk.red(`No project named "${name}".`));
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
if (!defs.length) {
|
|
302
|
+
if (opts.json)
|
|
303
|
+
console.log('[]');
|
|
304
|
+
else
|
|
305
|
+
console.log(chalk.gray('No projects defined. Add one: agents projects add <name>'));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const windowDays = Math.max(1, Number.parseInt(opts.window ?? '7', 10) || 7);
|
|
309
|
+
const nowMs = Date.now();
|
|
310
|
+
// --fleet: probe each shown def's workspace paths (root + repos[].path)
|
|
311
|
+
// locally and on every peer in one parallel SSH round, and widen the
|
|
312
|
+
// live-session rollup to the whole fleet via the existing sessions
|
|
313
|
+
// fan-out. Both are opt-in — they dial the fleet.
|
|
314
|
+
const fleetTargets = opts.fleet ? [...new Set(defs.flatMap(workspaceTargetsForDef))] : [];
|
|
315
|
+
let fleetWs = [];
|
|
316
|
+
let fleetSkipped = [];
|
|
317
|
+
let fleetSessions = [];
|
|
318
|
+
if (opts.fleet) {
|
|
319
|
+
const self = machineId();
|
|
320
|
+
fleetWs.push(...probeProjectWorkspaces(fleetTargets).map((s) => ({ ...s, host: self })));
|
|
321
|
+
const [probeRes, activeRes] = await Promise.all([
|
|
322
|
+
fleetTargets.length > 0
|
|
323
|
+
? gatherRemoteAgentsJson({
|
|
324
|
+
args: ['projects', 'probe', '--json', ...fleetTargets],
|
|
325
|
+
noFanoutEnv: PROJECTS_NO_FANOUT_ENV,
|
|
326
|
+
parse: parseRemoteProbe,
|
|
327
|
+
quiet: true,
|
|
328
|
+
})
|
|
329
|
+
: Promise.resolve({ items: [], deviceCount: 0, skipped: [] }),
|
|
330
|
+
gatherRemoteActive(undefined, { quiet: true }),
|
|
331
|
+
]);
|
|
332
|
+
fleetWs.push(...probeRes.items);
|
|
333
|
+
fleetSkipped = probeRes.skipped;
|
|
334
|
+
fleetSessions = activeRes.sessions;
|
|
335
|
+
}
|
|
336
|
+
const roll = rollupSessionsByProject(all, [...await getActiveSessions(), ...fleetSessions]);
|
|
337
|
+
// Enrich only the shown projects. --no-remote still reads the local artifact
|
|
338
|
+
// log but skips the gh calls + Linear counts (both are network).
|
|
339
|
+
const remote = new Map();
|
|
340
|
+
const linear = new Map();
|
|
341
|
+
await Promise.all(defs.map(async (d) => {
|
|
342
|
+
const skipRemote = opts.remote === false;
|
|
343
|
+
const [sig, counts] = await Promise.all([
|
|
344
|
+
enrichProjectSignals(d, windowDays, nowMs, { skipRemote }),
|
|
345
|
+
!skipRemote && d.linear?.projectId
|
|
346
|
+
? fetchLinearProjectCounts(d.linear.projectId)
|
|
347
|
+
: Promise.resolve(undefined),
|
|
348
|
+
]);
|
|
349
|
+
remote.set(d.name, sig);
|
|
350
|
+
if (counts)
|
|
351
|
+
linear.set(d.name, counts);
|
|
352
|
+
}));
|
|
353
|
+
/** This def's slice of the fleet probe, in its own target order. */
|
|
354
|
+
const fleetFor = (d) => {
|
|
355
|
+
const targets = new Set(workspaceTargetsForDef(d));
|
|
356
|
+
return fleetWs.filter((s) => targets.has(s.path));
|
|
357
|
+
};
|
|
358
|
+
if (opts.json) {
|
|
359
|
+
if (fleetSkipped.length > 0)
|
|
360
|
+
process.stderr.write(formatFleetSkippedNote(fleetSkipped));
|
|
361
|
+
console.log(JSON.stringify(defs.map((d) => {
|
|
362
|
+
const r = roll.get(d.name);
|
|
363
|
+
const rem = remote.get(d.name);
|
|
364
|
+
return {
|
|
365
|
+
name: d.name,
|
|
366
|
+
agents: r?.agents ?? 0,
|
|
367
|
+
byStatus: r?.byStatus ?? {},
|
|
368
|
+
members: r?.members ?? [],
|
|
369
|
+
plan: r?.plan ?? { done: 0, total: 0 },
|
|
370
|
+
planPct: r ? planPct(r.plan) ?? null : null,
|
|
371
|
+
openPrs: r?.openPrs ?? [],
|
|
372
|
+
mergedPrs: rem?.mergedPrs ?? 0,
|
|
373
|
+
latestRelease: rem?.latestRelease ?? null,
|
|
374
|
+
linear: linear.get(d.name) ?? null,
|
|
375
|
+
tickets: r?.tickets ?? [],
|
|
376
|
+
worktrees: r?.worktrees ?? 0,
|
|
377
|
+
artifacts: rem?.artifacts ?? 0,
|
|
378
|
+
lastArtifact: rem?.lastArtifact ?? null,
|
|
379
|
+
windowDays,
|
|
380
|
+
repos: [d.repo, ...(d.repos ?? []).map((r2) => r2.slug)].filter(Boolean),
|
|
381
|
+
...(opts.fleet ? { workspaces: fleetFor(d) } : {}),
|
|
382
|
+
};
|
|
383
|
+
}), null, 2));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
for (const d of defs) {
|
|
387
|
+
renderCard(d, roll.get(d.name), remote.get(d.name), opts.fleet ? fleetFor(d) : undefined, linear.get(d.name));
|
|
388
|
+
}
|
|
389
|
+
if (fleetSkipped.length > 0)
|
|
390
|
+
process.stdout.write(formatFleetSkippedNote(fleetSkipped));
|
|
391
|
+
});
|
|
392
|
+
// ---- probe (hidden; the peer half of `status --fleet`) ----
|
|
393
|
+
projects
|
|
394
|
+
.command('probe [paths...]', { hidden: true })
|
|
395
|
+
.description('Probe workspace repos (presence, branch, drift, dirtiness) and print JSON. Answers for this machine only.')
|
|
396
|
+
.option('--json', 'Machine-readable output (the only output format)')
|
|
397
|
+
.action((paths) => {
|
|
398
|
+
// Never fans out — the recursion guard env is set by the parent fan-out,
|
|
399
|
+
// and this command has no remote code path either way.
|
|
400
|
+
console.log(JSON.stringify(probeProjectWorkspaces(paths ?? []), null, 2));
|
|
401
|
+
});
|
|
402
|
+
// ---- import ----
|
|
403
|
+
projects
|
|
404
|
+
.command('import')
|
|
405
|
+
.description('Absorb the Factory project registry (~/.agents/factory/projects.json) into YAML definitions.')
|
|
406
|
+
.requiredOption('--from-factory', 'Import from the Factory projects.json registry')
|
|
407
|
+
.option('--force', 'Overwrite existing definitions')
|
|
408
|
+
.action((opts) => {
|
|
409
|
+
const src = factoryProjectsPath();
|
|
410
|
+
let rawText;
|
|
411
|
+
try {
|
|
412
|
+
rawText = fs.readFileSync(src, 'utf8');
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
console.error(chalk.red(`No Factory registry at ${src}`));
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
418
|
+
let rows;
|
|
419
|
+
try {
|
|
420
|
+
rows = JSON.parse(rawText);
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
console.error(chalk.red(`Factory registry at ${src} is not valid JSON`));
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
const list = Array.isArray(rows) ? rows : Array.isArray(rows.projects) ? rows.projects : [];
|
|
427
|
+
let created = 0;
|
|
428
|
+
let skipped = 0;
|
|
429
|
+
for (const raw of list) {
|
|
430
|
+
if (!raw || typeof raw !== 'object')
|
|
431
|
+
continue;
|
|
432
|
+
const o = raw;
|
|
433
|
+
const name = typeof o.name === 'string' ? o.name : undefined;
|
|
434
|
+
if (!name || !isSafeProjectName(name)) {
|
|
435
|
+
skipped++;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (loadProjectDef(name) && !opts.force) {
|
|
439
|
+
skipped++;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const def = { name };
|
|
443
|
+
if (typeof o.path === 'string')
|
|
444
|
+
def.root = toHomeRelative(o.path);
|
|
445
|
+
if (typeof o.repoSlug === 'string')
|
|
446
|
+
def.repo = o.repoSlug;
|
|
447
|
+
if (typeof o.linearProjectId === 'string')
|
|
448
|
+
def.linear = { projectId: o.linearProjectId };
|
|
449
|
+
writeProjectDef(def);
|
|
450
|
+
created++;
|
|
451
|
+
}
|
|
452
|
+
console.log(chalk.green(`Imported ${created} project${created === 1 ? '' : 's'}${skipped ? chalk.gray(` (${skipped} skipped)`) : ''}`));
|
|
453
|
+
});
|
|
454
|
+
// ---- link ----
|
|
455
|
+
projects
|
|
456
|
+
.command('link <name>')
|
|
457
|
+
.description('Attach an external tracker to a project definition (writes linear.projectId into the YAML).')
|
|
458
|
+
.option('--linear [query]', 'Bind a Linear project by exact name or id; no value auto-suggests from the def name + repo')
|
|
459
|
+
.action((name, opts) => {
|
|
460
|
+
const def = loadProjectDef(name);
|
|
461
|
+
if (!def) {
|
|
462
|
+
console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
|
|
463
|
+
process.exit(1);
|
|
464
|
+
}
|
|
465
|
+
if (opts.linear === undefined) {
|
|
466
|
+
console.error(chalk.red('Nothing to link. Pass a tracker flag, e.g. --linear [query].'));
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
// Explicit user command — a missing/unauthenticated `linear` CLI is loud,
|
|
470
|
+
// unlike the best-effort card enrichment.
|
|
471
|
+
let list;
|
|
472
|
+
try {
|
|
473
|
+
list = listLinearProjects();
|
|
474
|
+
}
|
|
475
|
+
catch (e) {
|
|
476
|
+
console.error(chalk.red(e instanceof Error ? e.message : String(e)));
|
|
477
|
+
process.exit(1);
|
|
478
|
+
}
|
|
479
|
+
const query = typeof opts.linear === 'string' ? opts.linear.trim() : '';
|
|
480
|
+
let pick;
|
|
481
|
+
if (query) {
|
|
482
|
+
pick = pickLinearProject(query, list);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
// Auto-suggest from the def's own identity — the primary repo slug is
|
|
486
|
+
// the sharpest key, then the def name. Only an exact normalized match
|
|
487
|
+
// writes itself; anything weaker asks the user to disambiguate.
|
|
488
|
+
pick = { kind: 'none' };
|
|
489
|
+
for (const hint of [def.repo, def.name].filter((h) => typeof h === 'string' && h.length > 0)) {
|
|
490
|
+
const d = pickLinearProject(hint, list);
|
|
491
|
+
if (d.kind === 'match') {
|
|
492
|
+
pick = d;
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
if (pick.kind === 'none')
|
|
496
|
+
pick = d;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (pick.kind !== 'match') {
|
|
500
|
+
if (pick.kind === 'candidates') {
|
|
501
|
+
console.error(chalk.yellow(`No confident Linear match${query ? ` for "${query}"` : ` for "${def.name}"`}. Candidates:`));
|
|
502
|
+
for (const c of pick.projects)
|
|
503
|
+
console.error(` ${chalk.cyan(c.id)} ${c.name}`);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
console.error(chalk.yellow(`No Linear project matches${query ? ` "${query}"` : ` "${def.name}"`}. Available:`));
|
|
507
|
+
for (const c of list)
|
|
508
|
+
console.error(` ${chalk.cyan(c.id)} ${c.name}`);
|
|
509
|
+
}
|
|
510
|
+
console.error(chalk.gray(`Re-run with an explicit name or id: agents projects link ${name} --linear "<name-or-id>"`));
|
|
511
|
+
process.exit(1);
|
|
512
|
+
}
|
|
513
|
+
const p = pick.project;
|
|
514
|
+
// Preserve every other field — load, set linear, write back. Keep a
|
|
515
|
+
// previously linked url when the new CLI row carries none.
|
|
516
|
+
if (def.linear?.projectId && def.linear.projectId !== p.id) {
|
|
517
|
+
console.log(chalk.gray(` replacing previous Linear link (${def.linear.projectId})`));
|
|
518
|
+
}
|
|
519
|
+
def.linear = { ...def.linear, projectId: p.id };
|
|
520
|
+
if (p.url)
|
|
521
|
+
def.linear.url = p.url;
|
|
522
|
+
writeProjectDef(def);
|
|
523
|
+
console.log(chalk.green(`${def.name} → Linear project "${p.name}" (${p.id})${p.url ? ` ${p.url}` : ''}`));
|
|
524
|
+
});
|
|
525
|
+
// ---- rm ----
|
|
526
|
+
projects
|
|
527
|
+
.command('rm <name>')
|
|
528
|
+
.alias('remove')
|
|
529
|
+
.description('Delete a project definition. Never touches the repo.')
|
|
530
|
+
.action((name) => {
|
|
531
|
+
if (removeProjectDef(name)) {
|
|
532
|
+
console.log(chalk.green(`Removed project "${name}"`));
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
console.error(chalk.red(`No project named "${name}".`));
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
}
|
|
@@ -10,6 +10,7 @@ import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
|
|
|
10
10
|
import { quoteWin32ExecArg } from '../lib/platform/index.js';
|
|
11
11
|
import { type SecretsBackend, type SecretsBundle, type SecretsPolicy } from '../lib/secrets/bundles.js';
|
|
12
12
|
import { type SecretsListFilterOpts } from '../lib/secrets/list-filter.js';
|
|
13
|
+
import { type BundleUsageSummary } from '../lib/secrets/usage-db.js';
|
|
13
14
|
/** Read all available data from stdin synchronously, trimmed. */
|
|
14
15
|
/**
|
|
15
16
|
* Read the raw `.env` text for `import --from <path|->`. A `-` reads the .env
|
|
@@ -125,6 +126,22 @@ export declare function formatHoldWindow(ms: number): string;
|
|
|
125
126
|
* shared by the POLICY column, the `--held` filter, and the JSON payload, so the
|
|
126
127
|
* three can never disagree about the same bundle. */
|
|
127
128
|
export declare function liveHold(expiresAt: number | undefined, now?: number): number | null;
|
|
129
|
+
/**
|
|
130
|
+
* Compact one-line usage summary for `secrets view` — each recorded event with
|
|
131
|
+
* its count and how long ago it last happened ("accessed 42× (last 2h ago) ·
|
|
132
|
+
* exported 3× (last 1d ago)"). Empty string when nothing has been recorded yet.
|
|
133
|
+
* Pure — unit-tested.
|
|
134
|
+
*/
|
|
135
|
+
export declare function formatUsageLine(summary: BundleUsageSummary | undefined): string;
|
|
136
|
+
/**
|
|
137
|
+
* The held-state line for `secrets view`: whether the secrets-agent currently
|
|
138
|
+
* holds this bundle (so reads are prompt-free) and for how long, or that Touch
|
|
139
|
+
* ID is required on the next read. Only meaningful for keychain bundles on macOS
|
|
140
|
+
* — file/vault backends have no broker to hold, and a `never`-policy bundle is
|
|
141
|
+
* always readable — so both return an empty line and the caller skips it.
|
|
142
|
+
* `heldExpiresAt` is the max expiry across every held scope.
|
|
143
|
+
*/
|
|
144
|
+
export declare function renderViewStatusLine(b: SecretsBundle, heldExpiresAt: number | null): string;
|
|
128
145
|
/**
|
|
129
146
|
* The EXPIRING cell. Counts keys needing attention — already expired plus due
|
|
130
147
|
* within 30 days — and colours by the worst of the two: red once anything has
|