@koalaauto/agentchannel 0.0.0-stage → 3.0.0-rc.1
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/LICENSE +202 -0
- package/README.md +154 -2
- package/dist/src/adapters/claude.d.ts +65 -0
- package/dist/src/adapters/claude.js +243 -0
- package/dist/src/adapters/codex.d.ts +74 -0
- package/dist/src/adapters/codex.js +237 -0
- package/dist/src/adapters/fake.d.ts +65 -0
- package/dist/src/adapters/fake.js +105 -0
- package/dist/src/adapters/index.d.ts +11 -0
- package/dist/src/adapters/index.js +26 -0
- package/dist/src/adapters/paths.d.ts +35 -0
- package/dist/src/adapters/paths.js +113 -0
- package/dist/src/adapters/procgroup.d.ts +14 -0
- package/dist/src/adapters/procgroup.js +49 -0
- package/dist/src/adapters/types.d.ts +57 -0
- package/dist/src/adapters/types.js +1 -0
- package/dist/src/args.d.ts +14 -0
- package/dist/src/args.js +257 -0
- package/dist/src/attachments.d.ts +63 -0
- package/dist/src/attachments.js +227 -0
- package/dist/src/attention.d.ts +85 -0
- package/dist/src/attention.js +262 -0
- package/dist/src/avatars.d.ts +40 -0
- package/dist/src/avatars.js +90 -0
- package/dist/src/cli.d.ts +45 -0
- package/dist/src/cli.js +1822 -0
- package/dist/src/client.d.ts +520 -0
- package/dist/src/client.js +449 -0
- package/dist/src/config.d.ts +62 -0
- package/dist/src/config.js +121 -0
- package/dist/src/desknotify.d.ts +154 -0
- package/dist/src/desknotify.js +426 -0
- package/dist/src/doctor.d.ts +75 -0
- package/dist/src/doctor.js +375 -0
- package/dist/src/env.d.ts +31 -0
- package/dist/src/env.js +52 -0
- package/dist/src/import.d.ts +245 -0
- package/dist/src/import.js +1008 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +13 -0
- package/dist/src/invite.d.ts +103 -0
- package/dist/src/invite.js +229 -0
- package/dist/src/invite_person.d.ts +16 -0
- package/dist/src/invite_person.js +75 -0
- package/dist/src/keystore.d.ts +55 -0
- package/dist/src/keystore.js +108 -0
- package/dist/src/mcp.d.ts +62 -0
- package/dist/src/mcp.js +503 -0
- package/dist/src/names.d.ts +30 -0
- package/dist/src/names.js +108 -0
- package/dist/src/netproxy.d.ts +20 -0
- package/dist/src/netproxy.js +36 -0
- package/dist/src/onboard.d.ts +105 -0
- package/dist/src/onboard.js +270 -0
- package/dist/src/perm.d.ts +12 -0
- package/dist/src/perm.js +20 -0
- package/dist/src/prompts.d.ts +166 -0
- package/dist/src/prompts.js +409 -0
- package/dist/src/runner/runner.d.ts +212 -0
- package/dist/src/runner/runner.js +1148 -0
- package/dist/src/runner/state.d.ts +68 -0
- package/dist/src/runner/state.js +80 -0
- package/dist/src/runner/workspace.d.ts +70 -0
- package/dist/src/runner/workspace.js +265 -0
- package/dist/src/service.d.ts +29 -0
- package/dist/src/service.js +101 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +3 -0
- package/dist/src/wiki.d.ts +74 -0
- package/dist/src/wiki.js +222 -0
- package/package.json +48 -4
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './client.js';
|
|
2
|
+
export * from './config.js';
|
|
3
|
+
export * from './env.js';
|
|
4
|
+
export * from './perm.js';
|
|
5
|
+
export * from './prompts.js';
|
|
6
|
+
export * from './adapters/index.js';
|
|
7
|
+
export { Runner, gcWorktrees, secretPaths, openRequests, unseenReplies, budgetBlocked, approves, type RunnerOptions, type Job } from './runner/runner.js';
|
|
8
|
+
export { GitWorkspace, isSensitivePath, parseRepoFlags, type Workspace } from './runner/workspace.js';
|
|
9
|
+
export * from './invite.js';
|
|
10
|
+
export * from './service.js';
|
|
11
|
+
export * from './keystore.js';
|
|
12
|
+
export { RunnerState } from './runner/state.js';
|
|
13
|
+
export { main, VERSION } from './cli.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './client.js';
|
|
2
|
+
export * from './config.js';
|
|
3
|
+
export * from './env.js';
|
|
4
|
+
export * from './perm.js';
|
|
5
|
+
export * from './prompts.js';
|
|
6
|
+
export * from './adapters/index.js';
|
|
7
|
+
export { Runner, gcWorktrees, secretPaths, openRequests, unseenReplies, budgetBlocked, approves } from './runner/runner.js';
|
|
8
|
+
export { GitWorkspace, isSensitivePath, parseRepoFlags } from './runner/workspace.js';
|
|
9
|
+
export * from './invite.js';
|
|
10
|
+
export * from './service.js';
|
|
11
|
+
export * from './keystore.js';
|
|
12
|
+
export { RunnerState } from './runner/state.js';
|
|
13
|
+
export { main, VERSION } from './cli.js';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything a teammate needs to connect, in one copy-paste line: `ach join <blob>`,
|
|
3
|
+
* blob = `ach2.` + base64url(JSON {hub, join, cf_id?, cf_secret?}). `join` is a
|
|
4
|
+
* one-time token the client exchanges for the agent key (POST
|
|
5
|
+
* /api/ach/invite/exchange); once used or expired the line is worthless. The
|
|
6
|
+
* optional Cloudflare Access service token lets the CLI through the hub's front
|
|
7
|
+
* door. Send the line privately.
|
|
8
|
+
*/
|
|
9
|
+
export interface Invite {
|
|
10
|
+
hub: string;
|
|
11
|
+
/** The one-time join token. */
|
|
12
|
+
join: string;
|
|
13
|
+
cf_id?: string;
|
|
14
|
+
cf_secret?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare const JOIN_TOKEN_RE: RegExp;
|
|
17
|
+
export declare function encodeInvite(i: Invite): string;
|
|
18
|
+
export declare function decodeInvite(blob: string): Invite;
|
|
19
|
+
/**
|
|
20
|
+
* Hosts where plain http:// is acceptable: this machine (localhost,
|
|
21
|
+
* 127.0.0.1, ::1, *.localhost) and the reserved *.test names (local dev/test
|
|
22
|
+
* setups). Anything else must be https://.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isLocalHost(hostname: string): boolean;
|
|
25
|
+
/** True when a hub URL is https://, or http:// to a local host (isLocalHost). */
|
|
26
|
+
export declare function hubTransportOk(hub: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The npm spec in the join line: `@koalaauto/agentchannel@3` (the major), or the
|
|
29
|
+
* exact version for a prerelease (2.0.0-rc.3 is not matched by the range "2").
|
|
30
|
+
*/
|
|
31
|
+
export declare function npmSpec(version?: string): string;
|
|
32
|
+
/** The one line a teammate pastes: npx fetches the CLI and `join` sets everything up. */
|
|
33
|
+
export declare function joinLine(blob: string, version?: string): string;
|
|
34
|
+
/** `npx -y @koalaauto/agentchannel@<spec>`: how messages and MCP entries start the CLI without relying on PATH. */
|
|
35
|
+
export declare function npxAch(version?: string): string[];
|
|
36
|
+
/** The join line minus the blob (for parsing tests and docs). */
|
|
37
|
+
export declare const JOIN_LINE_RE: RegExp;
|
|
38
|
+
/** What to tell the person who gets the line. */
|
|
39
|
+
export declare const JOIN_HINT: string;
|
|
40
|
+
/** The MCP server name printed for `claude mcp add` / `codex mcp add`. */
|
|
41
|
+
export declare const MCP_SERVER_NAME = "ach";
|
|
42
|
+
/** Every executable `name` on PATH, in PATH order (like `which -a`). */
|
|
43
|
+
export declare function whichAll(name: string, env?: NodeJS.ProcessEnv): string[];
|
|
44
|
+
/**
|
|
45
|
+
* True for executables that are not the user's own install: anything in an
|
|
46
|
+
* npx cache (`.../_npx/...`) or in a node_modules/.bin (e.g. npx puts this
|
|
47
|
+
* package's dependencies' bins, such as the `codex` of @openai/codex-sdk, on
|
|
48
|
+
* PATH while `npx agentchannel` runs).
|
|
49
|
+
*/
|
|
50
|
+
export declare function isBundledBin(p: string): boolean;
|
|
51
|
+
/** The first user-installed `name` on PATH (bundled/npx-cache bins skipped). */
|
|
52
|
+
export declare function userBin(name: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
53
|
+
/** True when an executable `name` is on PATH. */
|
|
54
|
+
export declare function onPath(name: string, env?: NodeJS.ProcessEnv): boolean;
|
|
55
|
+
/** This CLI's entry file (dist/src/cli.js). */
|
|
56
|
+
export declare function thisCliPath(): string;
|
|
57
|
+
/**
|
|
58
|
+
* Which AgentChannel an `ach` executable is, without running it: follow the
|
|
59
|
+
* symlink (npm global bins are symlinks) up to the nearest package.json.
|
|
60
|
+
* `ours` = it is this very CLI.
|
|
61
|
+
*/
|
|
62
|
+
export declare function identifyAch(path: string, self?: string): {
|
|
63
|
+
path: string;
|
|
64
|
+
real?: string;
|
|
65
|
+
name?: string;
|
|
66
|
+
version?: string;
|
|
67
|
+
ours: boolean;
|
|
68
|
+
};
|
|
69
|
+
/** True for an `ach` of this package (this CLI, or another install of it). */
|
|
70
|
+
export declare function isOurs(i: {
|
|
71
|
+
ours: boolean;
|
|
72
|
+
name?: string;
|
|
73
|
+
}): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* The exact `claude mcp add` / `codex mcp add` commands for this machine. They
|
|
76
|
+
* are printed, never run. `achCmd` is how to start the CLI (an absolute
|
|
77
|
+
* `ach`, or `npx -y @koalaauto/agentchannel@<spec>`).
|
|
78
|
+
*/
|
|
79
|
+
export declare function mcpCommands(o: {
|
|
80
|
+
achCmd: string[];
|
|
81
|
+
home?: string;
|
|
82
|
+
}): {
|
|
83
|
+
claude: string;
|
|
84
|
+
codex: string;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* How the MCP entry starts this CLI: `ach` when the first `ach` on PATH is this
|
|
88
|
+
* package, else this exact CLI by absolute path (another program called `ach`
|
|
89
|
+
* earlier on PATH, or none, must never be what the MCP entry runs).
|
|
90
|
+
*/
|
|
91
|
+
export declare function achCommand(env?: NodeJS.ProcessEnv, self?: string): string[];
|
|
92
|
+
/**
|
|
93
|
+
* When the first `ach` on PATH is some other program, `ach ...` typed by a
|
|
94
|
+
* person (or their agent) runs that one. The note to print, or undefined when
|
|
95
|
+
* this package (or nothing) comes first.
|
|
96
|
+
*/
|
|
97
|
+
export declare function shadowHint(env?: NodeJS.ProcessEnv, self?: string, zh?: boolean): string | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* `ach admin invite <agent>`: a paragraph the admin sends and the engineer
|
|
100
|
+
* pastes into their own Claude Code / Codex, which then does the setup and
|
|
101
|
+
* asks before installing the background helper. `line` is joinLine(...).
|
|
102
|
+
*/
|
|
103
|
+
export declare function agentInvitePrompt(line: string, lang?: 'en' | 'zh'): string;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { accessSync, constants, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { PKG_NAME, VERSION } from './version.js';
|
|
5
|
+
const PREFIX = 'ach2.';
|
|
6
|
+
export const JOIN_TOKEN_RE = /^ach_jt_[a-z0-9]{15}_[A-Za-z0-9]{40}$/;
|
|
7
|
+
export function encodeInvite(i) {
|
|
8
|
+
const o = { hub: i.hub.replace(/\/+$/, ''), join: i.join };
|
|
9
|
+
if (i.cf_id && i.cf_secret) {
|
|
10
|
+
o.cf_id = i.cf_id;
|
|
11
|
+
o.cf_secret = i.cf_secret;
|
|
12
|
+
}
|
|
13
|
+
return PREFIX + Buffer.from(JSON.stringify(o), 'utf8').toString('base64url');
|
|
14
|
+
}
|
|
15
|
+
export function decodeInvite(blob) {
|
|
16
|
+
const s = blob.trim();
|
|
17
|
+
if (!s.startsWith(PREFIX))
|
|
18
|
+
throw new Error('not an AgentChannel invite (expected ach2.<...>; copy the line from the panel)');
|
|
19
|
+
let o;
|
|
20
|
+
try {
|
|
21
|
+
o = JSON.parse(Buffer.from(s.slice(PREFIX.length), 'base64url').toString('utf8'));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error('invite is corrupted (copy the whole line)');
|
|
25
|
+
}
|
|
26
|
+
if (!o || typeof o.hub !== 'string' || !/^https?:\/\/[^\s]+$/.test(o.hub))
|
|
27
|
+
throw new Error('invite has no valid hub URL');
|
|
28
|
+
let hubUrl;
|
|
29
|
+
try {
|
|
30
|
+
hubUrl = new URL(o.hub);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error('invite has no valid hub URL');
|
|
34
|
+
}
|
|
35
|
+
// the join token (exchanged for a key) and the front-door pass travel to this address
|
|
36
|
+
if (hubUrl.protocol !== 'https:' && !isLocalHost(hubUrl.hostname)) {
|
|
37
|
+
throw new Error(`invite points at ${hubUrl.host} over plain http://: refusing to send a key there (ask your admin for the https:// line)`);
|
|
38
|
+
}
|
|
39
|
+
if (typeof o.join !== 'string' || !JOIN_TOKEN_RE.test(o.join))
|
|
40
|
+
throw new Error('invite has no valid join token');
|
|
41
|
+
const out = { hub: o.hub.replace(/\/+$/, ''), join: o.join };
|
|
42
|
+
if (typeof o.cf_id === 'string' && typeof o.cf_secret === 'string' && o.cf_id && o.cf_secret) {
|
|
43
|
+
out.cf_id = o.cf_id;
|
|
44
|
+
out.cf_secret = o.cf_secret;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Hosts where plain http:// is acceptable: this machine (localhost,
|
|
50
|
+
* 127.0.0.1, ::1, *.localhost) and the reserved *.test names (local dev/test
|
|
51
|
+
* setups). Anything else must be https://.
|
|
52
|
+
*/
|
|
53
|
+
export function isLocalHost(hostname) {
|
|
54
|
+
const h = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
55
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.localhost') || h.endsWith('.test');
|
|
56
|
+
}
|
|
57
|
+
/** True when a hub URL is https://, or http:// to a local host (isLocalHost). */
|
|
58
|
+
export function hubTransportOk(hub) {
|
|
59
|
+
try {
|
|
60
|
+
const u = new URL(hub);
|
|
61
|
+
return u.protocol === 'https:' || (u.protocol === 'http:' && isLocalHost(u.hostname));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The npm spec in the join line: `@koalaauto/agentchannel@3` (the major), or the
|
|
69
|
+
* exact version for a prerelease (2.0.0-rc.3 is not matched by the range "2").
|
|
70
|
+
*/
|
|
71
|
+
export function npmSpec(version = VERSION) {
|
|
72
|
+
return /-/.test(version) ? `${PKG_NAME}@${version}` : `${PKG_NAME}@${version.split('.')[0]}`;
|
|
73
|
+
}
|
|
74
|
+
/** The one line a teammate pastes: npx fetches the CLI and `join` sets everything up. */
|
|
75
|
+
export function joinLine(blob, version = VERSION) {
|
|
76
|
+
return `npx -y ${npmSpec(version)} join ${blob}`;
|
|
77
|
+
}
|
|
78
|
+
/** `npx -y @koalaauto/agentchannel@<spec>`: how messages and MCP entries start the CLI without relying on PATH. */
|
|
79
|
+
export function npxAch(version = VERSION) {
|
|
80
|
+
return ['npx', '-y', npmSpec(version)];
|
|
81
|
+
}
|
|
82
|
+
/** The join line minus the blob (for parsing tests and docs). */
|
|
83
|
+
export const JOIN_LINE_RE = /^npx -y @koalaauto\/agentchannel@\S+ join (ach2\.\S+)$/;
|
|
84
|
+
/** What to tell the person who gets the line. */
|
|
85
|
+
export const JOIN_HINT = 'They paste the line in a terminal (Node 20+); npx fetches the CLI. To type `ach` yourself: `npm i -g ' + PKG_NAME + '@next`. ' +
|
|
86
|
+
'`ach doctor` checks the setup.';
|
|
87
|
+
/** The MCP server name printed for `claude mcp add` / `codex mcp add`. */
|
|
88
|
+
export const MCP_SERVER_NAME = 'ach';
|
|
89
|
+
/** Every executable `name` on PATH, in PATH order (like `which -a`). */
|
|
90
|
+
export function whichAll(name, env = process.env) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const d of (env.PATH ?? '').split(delimiter)) {
|
|
93
|
+
if (!d)
|
|
94
|
+
continue;
|
|
95
|
+
const f = join(d, name);
|
|
96
|
+
try {
|
|
97
|
+
accessSync(f, constants.X_OK);
|
|
98
|
+
if (!out.includes(f))
|
|
99
|
+
out.push(f);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* next */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* True for executables that are not the user's own install: anything in an
|
|
109
|
+
* npx cache (`.../_npx/...`) or in a node_modules/.bin (e.g. npx puts this
|
|
110
|
+
* package's dependencies' bins, such as the `codex` of @openai/codex-sdk, on
|
|
111
|
+
* PATH while `npx agentchannel` runs).
|
|
112
|
+
*/
|
|
113
|
+
export function isBundledBin(p) {
|
|
114
|
+
return /[\\/]_npx[\\/]/.test(p) || /[\\/]node_modules[\\/]\.bin[\\/]/.test(p);
|
|
115
|
+
}
|
|
116
|
+
/** The first user-installed `name` on PATH (bundled/npx-cache bins skipped). */
|
|
117
|
+
export function userBin(name, env = process.env) {
|
|
118
|
+
return whichAll(name, env).find((p) => !isBundledBin(p) && !isBundledBin(real(p) ?? p));
|
|
119
|
+
}
|
|
120
|
+
/** True when an executable `name` is on PATH. */
|
|
121
|
+
export function onPath(name, env = process.env) {
|
|
122
|
+
return whichAll(name, env).length > 0;
|
|
123
|
+
}
|
|
124
|
+
/** This CLI's entry file (dist/src/cli.js). */
|
|
125
|
+
export function thisCliPath() {
|
|
126
|
+
return fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
127
|
+
}
|
|
128
|
+
function real(p) {
|
|
129
|
+
try {
|
|
130
|
+
return realpathSync(p);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Which AgentChannel an `ach` executable is, without running it: follow the
|
|
138
|
+
* symlink (npm global bins are symlinks) up to the nearest package.json.
|
|
139
|
+
* `ours` = it is this very CLI.
|
|
140
|
+
*/
|
|
141
|
+
export function identifyAch(path, self = thisCliPath()) {
|
|
142
|
+
const r = real(path);
|
|
143
|
+
const ours = !!r && r === real(self);
|
|
144
|
+
let d = r ? dirname(r) : undefined;
|
|
145
|
+
for (let i = 0; d && i < 6; i++, d = dirname(d)) {
|
|
146
|
+
try {
|
|
147
|
+
const pj = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8'));
|
|
148
|
+
if (pj && typeof pj.name === 'string')
|
|
149
|
+
return { path, real: r, name: pj.name, version: String(pj.version ?? ''), ours };
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* go up */
|
|
153
|
+
}
|
|
154
|
+
if (dirname(d) === d)
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
return { path, real: r, ours };
|
|
158
|
+
}
|
|
159
|
+
/** True for an `ach` of this package (this CLI, or another install of it). */
|
|
160
|
+
export function isOurs(i) {
|
|
161
|
+
return i.ours || i.name === PKG_NAME;
|
|
162
|
+
}
|
|
163
|
+
function shq(s) {
|
|
164
|
+
return /^[A-Za-z0-9_./:=@-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The exact `claude mcp add` / `codex mcp add` commands for this machine. They
|
|
168
|
+
* are printed, never run. `achCmd` is how to start the CLI (an absolute
|
|
169
|
+
* `ach`, or `npx -y @koalaauto/agentchannel@<spec>`).
|
|
170
|
+
*/
|
|
171
|
+
export function mcpCommands(o) {
|
|
172
|
+
const cmd = o.achCmd.map(shq).join(' ');
|
|
173
|
+
const envClaude = o.home ? ` -e AGENTCHANNEL_HOME=${shq(o.home)}` : '';
|
|
174
|
+
const envCodex = o.home ? ` --env AGENTCHANNEL_HOME=${shq(o.home)}` : '';
|
|
175
|
+
return {
|
|
176
|
+
claude: `claude mcp add --scope user${envClaude} ${MCP_SERVER_NAME} -- ${cmd} mcp`,
|
|
177
|
+
codex: `codex mcp add${envCodex} ${MCP_SERVER_NAME} -- ${cmd} mcp`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* How the MCP entry starts this CLI: `ach` when the first `ach` on PATH is this
|
|
182
|
+
* package, else this exact CLI by absolute path (another program called `ach`
|
|
183
|
+
* earlier on PATH, or none, must never be what the MCP entry runs).
|
|
184
|
+
*/
|
|
185
|
+
export function achCommand(env = process.env, self = thisCliPath()) {
|
|
186
|
+
// run through npx: the cache may be cleared, so entries use the pinned npx spec
|
|
187
|
+
if (/[\\/]_npx[\\/]/.test(self))
|
|
188
|
+
return npxAch();
|
|
189
|
+
const first = whichAll('ach', env)[0];
|
|
190
|
+
if (first && isOurs(identifyAch(first, self)))
|
|
191
|
+
return ['ach'];
|
|
192
|
+
return [process.execPath, self];
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* When the first `ach` on PATH is some other program, `ach ...` typed by a
|
|
196
|
+
* person (or their agent) runs that one. The note to print, or undefined when
|
|
197
|
+
* this package (or nothing) comes first.
|
|
198
|
+
*/
|
|
199
|
+
export function shadowHint(env = process.env, self = thisCliPath(), zh = false) {
|
|
200
|
+
const first = whichAll('ach', env)[0];
|
|
201
|
+
if (!first)
|
|
202
|
+
return undefined;
|
|
203
|
+
const i = identifyAch(first, self);
|
|
204
|
+
if (isOurs(i))
|
|
205
|
+
return undefined;
|
|
206
|
+
const what = i.name ? `${i.name}${i.version ? `@${i.version}` : ''}` : first;
|
|
207
|
+
const npx = `${npxAch().join(' ')} …`;
|
|
208
|
+
return zh
|
|
209
|
+
? `注意:PATH 上第一个 \`ach\` 是 ${what}(${first}),不是 AgentChannel 的,所以 \`ach ...\` 会运行它。修复:卸载它,或改用 ${npx}。`
|
|
210
|
+
: `Note: the first \`ach\` on your PATH is ${what} (${first}), not AgentChannel's, so \`ach ...\` runs that. Fix: remove it, or use ${npx}.`;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* `ach admin invite <agent>`: a paragraph the admin sends and the engineer
|
|
214
|
+
* pastes into their own Claude Code / Codex, which then does the setup and
|
|
215
|
+
* asks before installing the background helper. `line` is joinLine(...).
|
|
216
|
+
*/
|
|
217
|
+
export function agentInvitePrompt(line, lang = 'en') {
|
|
218
|
+
// the line is the consent: run by an agent it stores the key and connects the agent's own tool, nothing else
|
|
219
|
+
const spec = /^npx -y (\S+) join /.exec(line)?.[1] ?? npmSpec();
|
|
220
|
+
const npx = `npx -y ${spec}`;
|
|
221
|
+
if (lang === 'zh') {
|
|
222
|
+
return (`请帮我接入团队的 AgentChannel:在终端运行 \`${line}\`。` +
|
|
223
|
+
`它会保存这个智能体的密钥,并把 AgentChannel 接入你自己,不会安装别的东西;这行只能用一次。` +
|
|
224
|
+
`完成后告诉我收件箱里有什么。如果出错,运行 \`${npx} doctor\`,按提示修好再继续。`);
|
|
225
|
+
}
|
|
226
|
+
return (`Please connect me to my team's AgentChannel: run \`${line}\` in a terminal. ` +
|
|
227
|
+
`It stores this agent's key and adds AgentChannel to you; it installs nothing else (the line works once). ` +
|
|
228
|
+
`Then tell me what is in my inbox. If anything fails, run \`${npx} doctor\`, fix what it says, and continue.`);
|
|
229
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Client, FetchLike } from './client.js';
|
|
2
|
+
import { type Exec } from './import.js';
|
|
3
|
+
/**
|
|
4
|
+
* `ach admin invite <person>`: one copyable one-time login link (24 h; a new
|
|
5
|
+
* one replaces the older one). Send options appear only for delivery that is
|
|
6
|
+
* set up on this machine (Feishu through lark-cli). A reminder about the hub's
|
|
7
|
+
* sign-in gate appears only when the hub has one and its allowed list (as the
|
|
8
|
+
* hub knows it) does not include the person's email.
|
|
9
|
+
*/
|
|
10
|
+
export declare function invitePerson(ref: string, values: Record<string, unknown>, io: {
|
|
11
|
+
env: NodeJS.ProcessEnv;
|
|
12
|
+
out: (s: string) => void;
|
|
13
|
+
err: (s: string) => void;
|
|
14
|
+
fetch?: FetchLike;
|
|
15
|
+
exec?: Exec;
|
|
16
|
+
}, a: Client): Promise<number>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { UsageError } from './args.js';
|
|
2
|
+
import { makeExec, inviteMessage } from './import.js';
|
|
3
|
+
import { syncFeishuAvatars } from './avatars.js';
|
|
4
|
+
/**
|
|
5
|
+
* `ach admin invite <person>`: one copyable one-time login link (24 h; a new
|
|
6
|
+
* one replaces the older one). Send options appear only for delivery that is
|
|
7
|
+
* set up on this machine (Feishu through lark-cli). A reminder about the hub's
|
|
8
|
+
* sign-in gate appears only when the hub has one and its allowed list (as the
|
|
9
|
+
* hub knows it) does not include the person's email.
|
|
10
|
+
*/
|
|
11
|
+
export async function invitePerson(ref, values, io, a) {
|
|
12
|
+
const want = ref.trim().toLowerCase().replace(/^@/, '');
|
|
13
|
+
const users = await a.adminListUsers();
|
|
14
|
+
const u = users.find((x) => x.id === ref) ?? users.find((x) => String(x.email ?? '').toLowerCase() === want) ?? users.find((x) => String(x.name ?? '').toLowerCase() === want);
|
|
15
|
+
if (!u)
|
|
16
|
+
throw new UsageError(`admin invite: no person or agent named ${ref} (add a person with \`ach admin create-user --name <n> --email <e>\`)`);
|
|
17
|
+
if (u.deactivated)
|
|
18
|
+
throw new UsageError(`admin invite: ${u.name || u.email} is deactivated (reactivate with \`ach admin user ${u.email || u.id} --reactivate\`)`);
|
|
19
|
+
const link = await a.adminUserLoginLink(u.id);
|
|
20
|
+
const url = link.url && /^https?:/.test(link.url) ? link.url : `${a.hub}${link.path}`;
|
|
21
|
+
const exec = io.exec ?? makeExec(io.env);
|
|
22
|
+
const lark = (await exec('lark-cli', ['--version'])).code === 0;
|
|
23
|
+
const settings = await a.request('GET', '/api/ach/settings').catch(() => null);
|
|
24
|
+
const workspace = settings?.workspace_name || undefined;
|
|
25
|
+
const result = { person: u.name, email: u.email ?? '', url, expires_at: link.expires_at };
|
|
26
|
+
if (values.feishu) {
|
|
27
|
+
if (!lark)
|
|
28
|
+
io.err('Feishu is not set up on this machine (lark-cli not found): send the link yourself.');
|
|
29
|
+
else {
|
|
30
|
+
const r = await sendFeishu(exec, u, inviteMessage(u.name || u.email, url, workspace));
|
|
31
|
+
if (r.ok) {
|
|
32
|
+
result.sent = 'feishu';
|
|
33
|
+
io.err(`sent a Feishu message to ${u.name || u.email}`);
|
|
34
|
+
await syncFeishuAvatars(a, [{ id: u.id, name: u.name, email: u.email }], { exec, fetch: io.fetch, log: io.err });
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
io.err(`Feishu message to ${u.name || u.email} failed (${r.why}): send the link yourself.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (values.json)
|
|
41
|
+
io.out(JSON.stringify(result, null, 2));
|
|
42
|
+
else
|
|
43
|
+
io.out(url);
|
|
44
|
+
io.err(`One-time login link for ${u.name || u.email}: works once, expires in 24 h.`);
|
|
45
|
+
if (!values.feishu && lark && u.email)
|
|
46
|
+
io.err(`Send it on Feishu: ach admin invite ${u.email} --feishu`);
|
|
47
|
+
if (u.email) {
|
|
48
|
+
const gate = await a.request('GET', `/api/ach/admin/access-check?email=${encodeURIComponent(u.email)}`).catch(() => null);
|
|
49
|
+
if (gate?.cf && gate.covered === false) {
|
|
50
|
+
io.err(`Note: ${u.email} is not on the hub's list of people allowed through its sign-in page yet. Add the email there, or the link will not open for them.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
async function sendFeishu(exec, u, text) {
|
|
56
|
+
let openId = typeof u.feishu_open_id === 'string' && /^ou_/.test(u.feishu_open_id) ? u.feishu_open_id : '';
|
|
57
|
+
if (!openId && u.email) {
|
|
58
|
+
const r = await exec('lark-cli', ['contact', '+search-user', '--query', u.email, '--as', 'user', '--exclude-external-users', '--format', 'json']);
|
|
59
|
+
if (r.code !== 0)
|
|
60
|
+
return { ok: false, why: (r.stderr.trim() || r.stdout.trim()).split('\n')[0] || `exit ${r.code}` };
|
|
61
|
+
try {
|
|
62
|
+
const j = JSON.parse(r.stdout);
|
|
63
|
+
const list = j?.data?.users ?? j?.users ?? [];
|
|
64
|
+
const hit = list.find((x) => [x.email, x.enterprise_email].some((e) => String(e ?? '').toLowerCase() === String(u.email).toLowerCase()));
|
|
65
|
+
openId = hit?.open_id ?? '';
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* unexpected output */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!openId)
|
|
72
|
+
return { ok: false, why: 'not found in Feishu by email' };
|
|
73
|
+
const r = await exec('lark-cli', ['im', '+messages-send', '--user-id', openId, '--text', text, '--idempotency-key', `ach-inv-${u.id}-${Date.now().toString(36)}`.slice(0, 50)]);
|
|
74
|
+
return r.code === 0 ? { ok: true, why: '' } : { ok: false, why: r.stderr.trim().split('\n')[0] || `exit ${r.code}` };
|
|
75
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the agent key lives (S2). Default: the OS keychain via @napi-rs/keyring
|
|
3
|
+
* (macOS Keychain, Windows Credential Manager, Linux Secret Service). When the
|
|
4
|
+
* keychain is unavailable (e.g. Linux without a secret service) or
|
|
5
|
+
* ACH_KEYSTORE=file is set, the key goes to <home>/agent-key with mode 0600.
|
|
6
|
+
* The key is never written to config.json any more.
|
|
7
|
+
*/
|
|
8
|
+
export interface KeyStore {
|
|
9
|
+
readonly kind: 'keychain' | 'file';
|
|
10
|
+
get(): string | undefined;
|
|
11
|
+
set(key: string): void;
|
|
12
|
+
delete(): void;
|
|
13
|
+
}
|
|
14
|
+
export declare const KEYCHAIN_SERVICE = "agentchannel";
|
|
15
|
+
export declare function keyFilePath(home: string): string;
|
|
16
|
+
export declare class FileKeyStore implements KeyStore {
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly kind: "file";
|
|
19
|
+
constructor(path: string);
|
|
20
|
+
get(): string | undefined;
|
|
21
|
+
set(key: string): void;
|
|
22
|
+
delete(): void;
|
|
23
|
+
}
|
|
24
|
+
interface KeyringEntry {
|
|
25
|
+
getPassword(): string | null;
|
|
26
|
+
setPassword(p: string): void;
|
|
27
|
+
deletePassword(): boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare class KeychainKeyStore implements KeyStore {
|
|
30
|
+
private entry;
|
|
31
|
+
readonly kind: "keychain";
|
|
32
|
+
constructor(entry: KeyringEntry);
|
|
33
|
+
get(): string | undefined;
|
|
34
|
+
set(key: string): void;
|
|
35
|
+
delete(): void;
|
|
36
|
+
}
|
|
37
|
+
/** The keychain entry for this AGENTCHANNEL_HOME, or undefined if no usable keychain. */
|
|
38
|
+
export declare function openKeychain(home: string, account?: string): KeychainKeyStore | undefined;
|
|
39
|
+
/** Keychain unless ACH_KEYSTORE=file or the keychain is unavailable. */
|
|
40
|
+
export declare function openKeyStore(home: string, env?: NodeJS.ProcessEnv): KeyStore;
|
|
41
|
+
/**
|
|
42
|
+
* B1: the human session of `ach admin` (a PocketBase user token). Kept out of
|
|
43
|
+
* config.json: the OS keychain (account "admin-session:<home>"), else
|
|
44
|
+
* <home>/admin-session (0600), a file the runner denies to spawned agents.
|
|
45
|
+
*/
|
|
46
|
+
export declare function adminSessionPath(home: string): string;
|
|
47
|
+
export declare function openAdminSessionStore(home: string, env?: NodeJS.ProcessEnv): KeyStore;
|
|
48
|
+
/**
|
|
49
|
+
* The secret half of the hub's front-door service token (from the `ach join`
|
|
50
|
+
* line): OS keychain (account "cf-secret:<home>"), else <home>/cf-secret
|
|
51
|
+
* (0600). The id stays in config.json; the secret never does.
|
|
52
|
+
*/
|
|
53
|
+
export declare function cfSecretPath(home: string): string;
|
|
54
|
+
export declare function openCfSecretStore(home: string, env?: NodeJS.ProcessEnv): KeyStore;
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
export const KEYCHAIN_SERVICE = 'agentchannel';
|
|
5
|
+
export function keyFilePath(home) {
|
|
6
|
+
return join(home, 'agent-key');
|
|
7
|
+
}
|
|
8
|
+
export class FileKeyStore {
|
|
9
|
+
path;
|
|
10
|
+
kind = 'file';
|
|
11
|
+
constructor(path) {
|
|
12
|
+
this.path = path;
|
|
13
|
+
}
|
|
14
|
+
get() {
|
|
15
|
+
try {
|
|
16
|
+
const v = readFileSync(this.path, 'utf8').trim();
|
|
17
|
+
return v || undefined;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
set(key) {
|
|
24
|
+
const dir = dirname(this.path);
|
|
25
|
+
if (!existsSync(dir))
|
|
26
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
27
|
+
writeFileSync(this.path, key + '\n', { mode: 0o600 });
|
|
28
|
+
chmodSync(this.path, 0o600);
|
|
29
|
+
}
|
|
30
|
+
delete() {
|
|
31
|
+
rmSync(this.path, { force: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class KeychainKeyStore {
|
|
35
|
+
entry;
|
|
36
|
+
kind = 'keychain';
|
|
37
|
+
constructor(entry) {
|
|
38
|
+
this.entry = entry;
|
|
39
|
+
}
|
|
40
|
+
get() {
|
|
41
|
+
return this.entry.getPassword() ?? undefined;
|
|
42
|
+
}
|
|
43
|
+
set(key) {
|
|
44
|
+
this.entry.setPassword(key);
|
|
45
|
+
}
|
|
46
|
+
delete() {
|
|
47
|
+
try {
|
|
48
|
+
this.entry.deletePassword();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* already gone */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** The keychain entry for this AGENTCHANNEL_HOME, or undefined if no usable keychain. */
|
|
56
|
+
export function openKeychain(home, account = `agent-key:${home}`) {
|
|
57
|
+
try {
|
|
58
|
+
const req = createRequire(import.meta.url);
|
|
59
|
+
const { Entry } = req('@napi-rs/keyring');
|
|
60
|
+
const entry = new Entry(KEYCHAIN_SERVICE, account);
|
|
61
|
+
entry.getPassword(); // probe: throws when no credential store is reachable
|
|
62
|
+
return new KeychainKeyStore(entry);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Keychain unless ACH_KEYSTORE=file or the keychain is unavailable. */
|
|
69
|
+
export function openKeyStore(home, env = process.env) {
|
|
70
|
+
if (env.ACH_KEYSTORE !== 'file') {
|
|
71
|
+
const k = openKeychain(home);
|
|
72
|
+
if (k)
|
|
73
|
+
return k;
|
|
74
|
+
}
|
|
75
|
+
return new FileKeyStore(keyFilePath(home));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* B1: the human session of `ach admin` (a PocketBase user token). Kept out of
|
|
79
|
+
* config.json: the OS keychain (account "admin-session:<home>"), else
|
|
80
|
+
* <home>/admin-session (0600), a file the runner denies to spawned agents.
|
|
81
|
+
*/
|
|
82
|
+
export function adminSessionPath(home) {
|
|
83
|
+
return join(home, 'admin-session');
|
|
84
|
+
}
|
|
85
|
+
export function openAdminSessionStore(home, env = process.env) {
|
|
86
|
+
if (env.ACH_KEYSTORE !== 'file') {
|
|
87
|
+
const k = openKeychain(home, `admin-session:${home}`);
|
|
88
|
+
if (k)
|
|
89
|
+
return k;
|
|
90
|
+
}
|
|
91
|
+
return new FileKeyStore(adminSessionPath(home));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The secret half of the hub's front-door service token (from the `ach join`
|
|
95
|
+
* line): OS keychain (account "cf-secret:<home>"), else <home>/cf-secret
|
|
96
|
+
* (0600). The id stays in config.json; the secret never does.
|
|
97
|
+
*/
|
|
98
|
+
export function cfSecretPath(home) {
|
|
99
|
+
return join(home, 'cf-secret');
|
|
100
|
+
}
|
|
101
|
+
export function openCfSecretStore(home, env = process.env) {
|
|
102
|
+
if (env.ACH_KEYSTORE !== 'file') {
|
|
103
|
+
const k = openKeychain(home, `cf-secret:${home}`);
|
|
104
|
+
if (k)
|
|
105
|
+
return k;
|
|
106
|
+
}
|
|
107
|
+
return new FileKeyStore(cfSecretPath(home));
|
|
108
|
+
}
|