@koalaauto/agentchannel 0.0.0-stage → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +154 -2
  3. package/dist/src/adapters/claude.d.ts +65 -0
  4. package/dist/src/adapters/claude.js +243 -0
  5. package/dist/src/adapters/codex.d.ts +74 -0
  6. package/dist/src/adapters/codex.js +237 -0
  7. package/dist/src/adapters/fake.d.ts +65 -0
  8. package/dist/src/adapters/fake.js +105 -0
  9. package/dist/src/adapters/index.d.ts +11 -0
  10. package/dist/src/adapters/index.js +26 -0
  11. package/dist/src/adapters/paths.d.ts +35 -0
  12. package/dist/src/adapters/paths.js +113 -0
  13. package/dist/src/adapters/procgroup.d.ts +14 -0
  14. package/dist/src/adapters/procgroup.js +49 -0
  15. package/dist/src/adapters/types.d.ts +57 -0
  16. package/dist/src/adapters/types.js +1 -0
  17. package/dist/src/args.d.ts +14 -0
  18. package/dist/src/args.js +257 -0
  19. package/dist/src/attachments.d.ts +63 -0
  20. package/dist/src/attachments.js +227 -0
  21. package/dist/src/attention.d.ts +85 -0
  22. package/dist/src/attention.js +262 -0
  23. package/dist/src/avatars.d.ts +40 -0
  24. package/dist/src/avatars.js +90 -0
  25. package/dist/src/cli.d.ts +45 -0
  26. package/dist/src/cli.js +1822 -0
  27. package/dist/src/client.d.ts +520 -0
  28. package/dist/src/client.js +449 -0
  29. package/dist/src/config.d.ts +62 -0
  30. package/dist/src/config.js +121 -0
  31. package/dist/src/desknotify.d.ts +154 -0
  32. package/dist/src/desknotify.js +426 -0
  33. package/dist/src/doctor.d.ts +75 -0
  34. package/dist/src/doctor.js +375 -0
  35. package/dist/src/env.d.ts +31 -0
  36. package/dist/src/env.js +52 -0
  37. package/dist/src/import.d.ts +245 -0
  38. package/dist/src/import.js +1008 -0
  39. package/dist/src/index.d.ts +13 -0
  40. package/dist/src/index.js +13 -0
  41. package/dist/src/invite.d.ts +103 -0
  42. package/dist/src/invite.js +229 -0
  43. package/dist/src/invite_person.d.ts +16 -0
  44. package/dist/src/invite_person.js +75 -0
  45. package/dist/src/keystore.d.ts +55 -0
  46. package/dist/src/keystore.js +108 -0
  47. package/dist/src/mcp.d.ts +62 -0
  48. package/dist/src/mcp.js +503 -0
  49. package/dist/src/names.d.ts +30 -0
  50. package/dist/src/names.js +108 -0
  51. package/dist/src/netproxy.d.ts +20 -0
  52. package/dist/src/netproxy.js +36 -0
  53. package/dist/src/onboard.d.ts +105 -0
  54. package/dist/src/onboard.js +270 -0
  55. package/dist/src/perm.d.ts +12 -0
  56. package/dist/src/perm.js +20 -0
  57. package/dist/src/prompts.d.ts +166 -0
  58. package/dist/src/prompts.js +409 -0
  59. package/dist/src/runner/runner.d.ts +212 -0
  60. package/dist/src/runner/runner.js +1148 -0
  61. package/dist/src/runner/state.d.ts +68 -0
  62. package/dist/src/runner/state.js +80 -0
  63. package/dist/src/runner/workspace.d.ts +70 -0
  64. package/dist/src/runner/workspace.js +265 -0
  65. package/dist/src/service.d.ts +29 -0
  66. package/dist/src/service.js +101 -0
  67. package/dist/src/version.d.ts +3 -0
  68. package/dist/src/version.js +3 -0
  69. package/dist/src/wiki.d.ts +74 -0
  70. package/dist/src/wiki.js +222 -0
  71. package/package.json +48 -4
@@ -0,0 +1,108 @@
1
+ // Name helpers for `ach post` (channel by name/title, @name check) and
2
+ // `ach ask-human --to` (closest-name suggestions). Pure; the hub stays the
3
+ // authority (it parses mentions and validates `to_user` itself).
4
+ import { HubError } from './client.js';
5
+ /** Same shape as the hub's MENTION_RE (ach_lib.js); code spans/blocks ignored. */
6
+ const MENTION_RE = /(^|[^A-Za-z0-9_.\/-])@([a-z0-9][a-z0-9-]{1,40})(?![A-Za-z0-9-])/g;
7
+ export function mentionNames(text) {
8
+ const plain = String(text).replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ');
9
+ const out = [];
10
+ for (const m of plain.matchAll(MENTION_RE))
11
+ if (!out.includes(m[2]))
12
+ out.push(m[2]);
13
+ return out;
14
+ }
15
+ export function editDistance(a, b) {
16
+ const d = Array.from({ length: b.length + 1 }, (_, j) => j);
17
+ for (let i = 1; i <= a.length; i++) {
18
+ let prev = d[0];
19
+ d[0] = i;
20
+ for (let j = 1; j <= b.length; j++) {
21
+ const cur = d[j];
22
+ d[j] = Math.min(d[j] + 1, d[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
23
+ prev = cur;
24
+ }
25
+ }
26
+ return d[b.length];
27
+ }
28
+ /** Closest candidate (case-insensitive), or undefined when nothing is near. */
29
+ export function closest(name, candidates) {
30
+ const n = name.toLowerCase();
31
+ let best;
32
+ let bd = Infinity;
33
+ for (const c of candidates) {
34
+ const l = c.toLowerCase();
35
+ const d = l.startsWith(n) || n.startsWith(l) ? Math.min(1, editDistance(n, l)) : editDistance(n, l);
36
+ if (d < bd)
37
+ [best, bd] = [c, d];
38
+ }
39
+ return best !== undefined && bd <= Math.max(2, Math.floor(n.length / 3)) ? best : undefined;
40
+ }
41
+ /** "Ops Data" / "ops-data" / "#ops-data" -> the channel's name, by name or title. */
42
+ export function matchChannel(ref, channels) {
43
+ const r = String(ref).trim().replace(/^#/, '');
44
+ const low = r.toLowerCase();
45
+ const norm = (s) => s.toLowerCase().replace(/[\s_]+/g, '-');
46
+ return (channels.find((c) => c.name === r) ??
47
+ channels.find((c) => c.name.toLowerCase() === low) ??
48
+ channels.find((c) => String(c.title ?? '').trim().toLowerCase() === low) ??
49
+ channels.find((c) => norm(c.name) === norm(r)));
50
+ }
51
+ /** Handles a person can be written as after @: "Evan Li" -> "evan-li", email local part. */
52
+ export function personHandles(p) {
53
+ const out = new Set();
54
+ const n = String(p.name ?? '').trim().toLowerCase();
55
+ if (n) {
56
+ out.add(n.replace(/\s+/g, '-'));
57
+ out.add(n.replace(/\s+/g, ''));
58
+ out.add(n.split(/\s+/)[0]);
59
+ }
60
+ const local = String(p.email ?? '').split('@')[0].toLowerCase();
61
+ if (local)
62
+ out.add(local);
63
+ return [...out].filter(Boolean);
64
+ }
65
+ /** @names in `body` that match no agent or person, each with the closest known name. */
66
+ export function unknownMentions(body, agents, people) {
67
+ const agentNames = agents.map((a) => a.name).filter(Boolean);
68
+ const handles = new Map();
69
+ for (const p of people)
70
+ for (const h of personHandles(p))
71
+ handles.set(h, h);
72
+ const known = new Set([...agentNames.map((a) => a.toLowerCase()), ...handles.keys()]);
73
+ return mentionNames(body)
74
+ .filter((n) => !known.has(n.toLowerCase()))
75
+ .map((n) => ({ name: n, suggest: closest(n, [...agentNames, ...handles.keys()]) }));
76
+ }
77
+ /**
78
+ * `--to`: omitted = your owner (the hub may add the other side for money), "self" = only your
79
+ * owner, "both" = both owners, anything else names a person (`to_user`).
80
+ */
81
+ export function decisionTo(to) {
82
+ const t = String(to ?? '').trim();
83
+ if (!t)
84
+ return {};
85
+ if (t === 'self' || t === 'both')
86
+ return { to: t };
87
+ return { to_user: t.replace(/^@/, '') };
88
+ }
89
+ /** Turns the hub's to_user refusals into one line an agent can act on. */
90
+ export async function explainToUser(e, person, c, taskId) {
91
+ if (!(e instanceof HubError))
92
+ return e;
93
+ const body = e.body ?? {};
94
+ if (e.status === 403 && e.code === 'not_member') {
95
+ const ch = await c.getTask(taskId).then((r) => r?.task?.channel_name).catch(() => '');
96
+ return new HubError(e.status, `${person} is not a member of ${ch ? `#${ch}` : "this task's channel"}; ask someone in the channel (or omit --to to ask your owner)`, e.code, e.body);
97
+ }
98
+ if (e.status === 400 && e.code === 'ambiguous') {
99
+ const cands = (Array.isArray(body.candidates) ? body.candidates : []).map((x) => `${x.name}${x.email ? ` <${x.email}>` : ''}`);
100
+ return new HubError(e.status, `more than one person is called ${person}${cands.length ? ` (${cands.join(', ')})` : ''}; use --to <email>`, e.code, e.body);
101
+ }
102
+ if (e.status === 404 && !/task/i.test(String(body.error ?? body.message ?? ''))) {
103
+ const owners = await c.agents().then((a) => [...new Set(a.map((x) => x.owner_name ?? '').filter(Boolean))]).catch(() => []);
104
+ const near = closest(person, owners);
105
+ return new HubError(e.status, `no active person "${person}"${near ? ` (did you mean ${near}?)` : ''}; use their name or email as the hub knows it`, e.code, e.body);
106
+ }
107
+ return e;
108
+ }
@@ -0,0 +1,20 @@
1
+ type Env = Record<string, string | undefined>;
2
+ /**
3
+ * Which proxy, if any, hub requests use. Node's fetch ignores HTTP(S)_PROXY, so
4
+ * without this `ach` cannot leave a sandbox that only allows its proxy (live
5
+ * test: inside a Claude worker, plain fetch got ENOTFOUND for public hosts and
6
+ * EPERM for 127.0.0.1, while the proxy reached both).
7
+ * - Inside a spawned agent (ACH_RUN_TOKEN): every request goes through the
8
+ * proxy, NO_PROXY included, because the sandbox blocks direct connections.
9
+ * - Elsewhere: the usual HTTP(S)_PROXY / NO_PROXY rules.
10
+ * Pure, unit-tested; returns null when no proxy is configured.
11
+ */
12
+ export declare function proxyPlan(env: Env, hub?: string): {
13
+ kind: 'all';
14
+ uri: string;
15
+ } | {
16
+ kind: 'env';
17
+ } | null;
18
+ /** Install the proxy for this process's fetch (global dispatcher). */
19
+ export declare function installProxy(env?: Env, hub?: string | undefined): void;
20
+ export {};
@@ -0,0 +1,36 @@
1
+ import { EnvHttpProxyAgent, ProxyAgent, setGlobalDispatcher } from 'undici';
2
+ function proxyFrom(env, https) {
3
+ const pick = (...names) => names.map((n) => env[n]).find((v) => !!v && v.trim()) ?? '';
4
+ return https ? pick('HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy') : pick('HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy');
5
+ }
6
+ /**
7
+ * Which proxy, if any, hub requests use. Node's fetch ignores HTTP(S)_PROXY, so
8
+ * without this `ach` cannot leave a sandbox that only allows its proxy (live
9
+ * test: inside a Claude worker, plain fetch got ENOTFOUND for public hosts and
10
+ * EPERM for 127.0.0.1, while the proxy reached both).
11
+ * - Inside a spawned agent (ACH_RUN_TOKEN): every request goes through the
12
+ * proxy, NO_PROXY included, because the sandbox blocks direct connections.
13
+ * - Elsewhere: the usual HTTP(S)_PROXY / NO_PROXY rules.
14
+ * Pure, unit-tested; returns null when no proxy is configured.
15
+ */
16
+ export function proxyPlan(env, hub) {
17
+ const https = !hub || hub.startsWith('https:');
18
+ const uri = proxyFrom(env, https);
19
+ if (!uri)
20
+ return null;
21
+ return env.ACH_RUN_TOKEN ? { kind: 'all', uri } : { kind: 'env' };
22
+ }
23
+ /** Install the proxy for this process's fetch (global dispatcher). */
24
+ export function installProxy(env = process.env, hub = env.ACH_HUB) {
25
+ const plan = proxyPlan(env, hub);
26
+ if (!plan)
27
+ return;
28
+ let d;
29
+ try {
30
+ d = plan.kind === 'all' ? new ProxyAgent(plan.uri) : new EnvHttpProxyAgent();
31
+ }
32
+ catch {
33
+ return; // a malformed proxy URL: keep direct connections
34
+ }
35
+ setGlobalDispatcher(d);
36
+ }
@@ -0,0 +1,105 @@
1
+ import type { ExecLike } from './doctor.js';
2
+ /**
3
+ * One-line onboarding: what `ach join` sets up after the key is stored.
4
+ *
5
+ * 1. connect: add the `ach` MCP server to the agent's own tool (Claude Code
6
+ * for a claude agent, Codex for a codex one; both found ones for "other")
7
+ * 2. background helper, only with --service: `ach run --install-service`
8
+ * (+ --notify on a machine with a desktop). Run through npx, `ach` is
9
+ * installed first, so the helper never runs from the npx cache.
10
+ *
11
+ * Pasting the line is the consent: with no terminal (an agent ran it) the steps
12
+ * run and say what they ran. In a terminal each step lists its commands and
13
+ * files and asks first; --yes skips that. --dry-run prints the plan and changes
14
+ * nothing (the invite stays unused).
15
+ */
16
+ export interface SetupIo {
17
+ env: NodeJS.ProcessEnv;
18
+ out: (s: string) => void;
19
+ err: (s: string) => void;
20
+ exec: ExecLike;
21
+ /** Asks one question; undefined = not interactive. */
22
+ ask?: (q: string) => Promise<string>;
23
+ cwd: string;
24
+ }
25
+ export interface SetupOptions {
26
+ yes: boolean;
27
+ /** Install the background helper (step 2). */
28
+ service?: boolean;
29
+ /** Print the plan only. */
30
+ dryRun?: boolean;
31
+ runtime?: string;
32
+ zh?: boolean;
33
+ /** This CLI's entry (tests). */
34
+ self?: string;
35
+ /** --dry-run: `ach join` has not used the invite yet; print that step too. */
36
+ join?: {
37
+ hub: string;
38
+ };
39
+ }
40
+ export type Argv = string[];
41
+ export interface Step {
42
+ id: 'connect' | 'service';
43
+ question: string;
44
+ /** Commands, printed as shell lines and run one by one. */
45
+ commands: Argv[];
46
+ }
47
+ /** The closing line: which tool to open and what to say to it. */
48
+ export declare function doneLine(tools: ('claude' | 'codex')[], zh: boolean): string;
49
+ /** A desktop to show notifications on: macOS, Windows, or Linux with a display. */
50
+ export declare function hasDesktop(env: NodeJS.ProcessEnv, platform?: string): boolean;
51
+ /** The tools an agent of this runtime is added to: its own; both for "other" or unknown. */
52
+ export declare function toolsFor(runtime: string | undefined): ('claude' | 'codex')[];
53
+ /** True when this CLI runs from the npx cache (it disappears; install it first). */
54
+ export declare function runFromNpx(self?: string): boolean;
55
+ export declare function shq(s: string): string;
56
+ export declare function shellLine(a: Argv): string;
57
+ /** `claude mcp add` / `codex mcp add` argv for this CLI. */
58
+ export declare function mcpAddArgv(kind: 'claude' | 'codex', bin: string, achCmd: Argv, home?: string): Argv;
59
+ export declare function mcpRemoveArgv(kind: 'claude' | 'codex', bin: string, name: string): Argv;
60
+ /**
61
+ * The file `claude mcp add --scope user` / `codex mcp add` writes:
62
+ * $CLAUDE_CONFIG_DIR/.claude.json (else ~/.claude.json), $CODEX_HOME/config.toml
63
+ * (else ~/.codex/config.toml).
64
+ */
65
+ export declare function mcpConfigFile(kind: 'claude' | 'codex', env: NodeJS.ProcessEnv): string;
66
+ export interface Detected {
67
+ /** User-installed claude / codex (never the bins npx puts on PATH). */
68
+ claude?: string;
69
+ codex?: string;
70
+ npm?: string;
71
+ npx: boolean;
72
+ /** An `ach` entry already registered (it is replaced). */
73
+ claudeHasAch: boolean;
74
+ codexHasAch: boolean;
75
+ /** How the MCP entry and service start ach: the pinned npx spec when run through npx, else an absolute `ach`. */
76
+ achCmd: Argv;
77
+ }
78
+ /** The absolute command that starts this CLI: the first `ach` on PATH when it is this package, else node + this file. */
79
+ export declare function absoluteAch(env: NodeJS.ProcessEnv, self: string): Argv;
80
+ /**
81
+ * What is on this machine. `probe` = also ask claude/codex (read-only
82
+ * `mcp get`); without it only PATH is looked at.
83
+ */
84
+ export declare function detect(env: NodeJS.ProcessEnv, exec: ExecLike, self: string, probe: boolean): Promise<Detected>;
85
+ /** The steps that apply on this machine, in order. */
86
+ export declare function planSteps(d: Detected, o: {
87
+ home?: string;
88
+ achCmd: Argv;
89
+ installSpec: string;
90
+ cwd: string;
91
+ zh?: boolean;
92
+ env?: NodeJS.ProcessEnv;
93
+ runtime?: string;
94
+ service?: boolean;
95
+ desktop?: boolean;
96
+ }): Step[];
97
+ export interface SetupResult {
98
+ ran: string[];
99
+ skipped: string[];
100
+ failed: string[];
101
+ /** The tools the agent was added to (for the closing line). */
102
+ tools: ('claude' | 'codex')[];
103
+ }
104
+ /** Run (or, with --dry-run, only print) the setup after `ach join`. */
105
+ export declare function runSetup(io: SetupIo, o: SetupOptions): Promise<SetupResult>;
@@ -0,0 +1,270 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { identifyAch, isOurs, MCP_SERVER_NAME, npmSpec, npxAch, thisCliPath, userBin, whichAll } from './invite.js';
5
+ import { VERSION } from './version.js';
6
+ /** The closing line: which tool to open and what to say to it. */
7
+ export function doneLine(tools, zh) {
8
+ const names = [...new Set(tools)].map((k) => (k === 'claude' ? 'Claude Code' : 'Codex'));
9
+ const where = names.length ? names.join(zh ? ' 或 ' : ' or ') : zh ? '你的 Claude Code 或 Codex' : 'your Claude Code or Codex';
10
+ return zh ? `完成。打开 ${where},对它说:看看我的 AgentChannel 收件箱` : `Done. Open ${where} and say: check my AgentChannel inbox`;
11
+ }
12
+ /** A desktop to show notifications on: macOS, Windows, or Linux with a display. */
13
+ export function hasDesktop(env, platform = process.platform) {
14
+ if (platform === 'darwin' || platform === 'win32')
15
+ return true;
16
+ return !!(env.DISPLAY || env.WAYLAND_DISPLAY);
17
+ }
18
+ /** The tools an agent of this runtime is added to: its own; both for "other" or unknown. */
19
+ export function toolsFor(runtime) {
20
+ return runtime === 'claude' ? ['claude'] : runtime === 'codex' ? ['codex'] : ['claude', 'codex'];
21
+ }
22
+ /** True when this CLI runs from the npx cache (it disappears; install it first). */
23
+ export function runFromNpx(self = thisCliPath()) {
24
+ return /[\\/]_npx[\\/]/.test(self);
25
+ }
26
+ export function shq(s) {
27
+ return /^[A-Za-z0-9_./:=@+-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
28
+ }
29
+ export function shellLine(a) {
30
+ return a.map(shq).join(' ');
31
+ }
32
+ /** `claude mcp add` / `codex mcp add` argv for this CLI. */
33
+ export function mcpAddArgv(kind, bin, achCmd, home) {
34
+ if (kind === 'claude')
35
+ return [bin, 'mcp', 'add', '--scope', 'user', ...(home ? ['-e', `AGENTCHANNEL_HOME=${home}`] : []), MCP_SERVER_NAME, '--', ...achCmd, 'mcp'];
36
+ return [bin, 'mcp', 'add', ...(home ? ['--env', `AGENTCHANNEL_HOME=${home}`] : []), MCP_SERVER_NAME, '--', ...achCmd, 'mcp'];
37
+ }
38
+ export function mcpRemoveArgv(kind, bin, name) {
39
+ return kind === 'claude' ? [bin, 'mcp', 'remove', '--scope', 'user', name] : [bin, 'mcp', 'remove', name];
40
+ }
41
+ /**
42
+ * The file `claude mcp add --scope user` / `codex mcp add` writes:
43
+ * $CLAUDE_CONFIG_DIR/.claude.json (else ~/.claude.json), $CODEX_HOME/config.toml
44
+ * (else ~/.codex/config.toml).
45
+ */
46
+ export function mcpConfigFile(kind, env) {
47
+ const home = env.HOME || homedir();
48
+ if (kind === 'claude')
49
+ return env.CLAUDE_CONFIG_DIR ? join(env.CLAUDE_CONFIG_DIR, '.claude.json') : join(home, '.claude.json');
50
+ return join(env.CODEX_HOME || join(home, '.codex'), 'config.toml');
51
+ }
52
+ /** The absolute command that starts this CLI: the first `ach` on PATH when it is this package, else node + this file. */
53
+ export function absoluteAch(env, self) {
54
+ const first = whichAll('ach', env)[0];
55
+ if (first && isOurs(identifyAch(first, self)))
56
+ return [first];
57
+ return [process.execPath, self];
58
+ }
59
+ /**
60
+ * What is on this machine. `probe` = also ask claude/codex (read-only
61
+ * `mcp get`); without it only PATH is looked at.
62
+ */
63
+ export async function detect(env, exec, self, probe) {
64
+ const claude = userBin('claude', env);
65
+ const codex = userBin('codex', env);
66
+ const npx = runFromNpx(self);
67
+ const d = {
68
+ claude, codex, npm: whichAll('npm', env)[0], npx,
69
+ claudeHasAch: false, codexHasAch: false,
70
+ // run through npx: the MCP entry uses the same pinned spec as the join line
71
+ achCmd: npx ? npxAch() : absoluteAch(env, self),
72
+ };
73
+ if (!probe)
74
+ return d;
75
+ const has = async (bin) => !!bin && (await exec(bin, ['mcp', 'get', MCP_SERVER_NAME], env)).code === 0;
76
+ d.claudeHasAch = await has(claude);
77
+ d.codexHasAch = await has(codex);
78
+ return d;
79
+ }
80
+ /** "Claude Code and Codex" / "Claude Code" / "Codex" / "" */
81
+ function targets(d, zh) {
82
+ const t = [d.claude ? 'Claude Code' : '', d.codex ? 'Codex' : ''].filter(Boolean);
83
+ return t.join(zh ? ' 和 ' : ' and ');
84
+ }
85
+ const changes = (kind, env, zh) => `${zh ? '修改' : 'changes'} ${mcpConfigFile(kind, env)}`;
86
+ /** The steps that apply on this machine, in order. */
87
+ export function planSteps(d, o) {
88
+ const zh = !!o.zh;
89
+ const env = o.env ?? {};
90
+ const steps = [];
91
+ const want = toolsFor(o.runtime);
92
+ // 1. connect: the MCP entry in the agent's own tool
93
+ const connect = { id: 'connect', question: '', commands: [] };
94
+ const lines = [];
95
+ for (const kind of want) {
96
+ const bin = d[kind];
97
+ if (!bin)
98
+ continue;
99
+ if (kind === 'claude' ? d.claudeHasAch : d.codexHasAch)
100
+ connect.commands.push(mcpRemoveArgv(kind, bin, MCP_SERVER_NAME));
101
+ const add = mcpAddArgv(kind, bin, o.achCmd, o.home);
102
+ connect.commands.push(add);
103
+ lines.push(` ${shellLine([kind, ...add.slice(1)])} # ${changes(kind, env, zh)}`);
104
+ }
105
+ if (lines.length) {
106
+ const where = targets({ claude: want.includes('claude') ? d.claude : undefined, codex: want.includes('codex') ? d.codex : undefined }, zh);
107
+ connect.question = [zh ? `把 AgentChannel 接入 ${where}?会运行:` : `Add AgentChannel to ${where}? This runs:`, ...lines].join('\n') + '\n';
108
+ steps.push(connect);
109
+ }
110
+ // 2. background helper: only when asked for (--service)
111
+ if (o.service) {
112
+ const notify = o.desktop !== false;
113
+ const helper = ['run', '--install-service', ...(notify ? ['--notify'] : []), '--interactive-first', '--dir', o.cwd];
114
+ const install = d.npx && d.npm ? [[d.npm, 'install', '-g', o.installSpec]] : [];
115
+ steps.push({
116
+ id: 'service',
117
+ question: [
118
+ zh
119
+ ? `安装后台小助手(在 ${o.cwd} 里自动领取任务${notify ? ',并弹出系统通知' : ''})?会运行:`
120
+ : `Install the background helper (picks up tasks in ${o.cwd}${notify ? ' and shows desktop notifications' : ''})? This runs:`,
121
+ ...install.map(() => ` npm install -g ${o.installSpec} # ${zh ? '`ach` 命令(后台小助手不能从 npx 缓存运行)' : 'the `ach` command (the helper cannot run from the npx cache)'}`),
122
+ ` ${shellLine(['ach', ...helper])}`,
123
+ ].join('\n') + '\n',
124
+ commands: [...install, [...o.achCmd, ...helper]],
125
+ });
126
+ }
127
+ return steps;
128
+ }
129
+ function yesNo(answer) {
130
+ const a = answer.trim().toLowerCase();
131
+ return !a || /^(y|yes|是|好|ok)$/.test(a);
132
+ }
133
+ /** The global `ach` after `npm install -g` (npm prefix -g)/bin/ach. */
134
+ async function globalAch(npm, exec, env) {
135
+ const r = await exec(npm, ['prefix', '-g'], env);
136
+ const prefix = r.stdout.trim().split('\n').pop()?.trim();
137
+ if (r.code !== 0 || !prefix)
138
+ return undefined;
139
+ const f = process.platform === 'win32' ? join(prefix, 'ach.cmd') : join(prefix, 'bin', 'ach');
140
+ return existsSync(f) ? f : undefined;
141
+ }
142
+ /** Run (or, with --dry-run, only print) the setup after `ach join`. */
143
+ export async function runSetup(io, o) {
144
+ const env = io.env;
145
+ const self = o.self ?? thisCliPath();
146
+ const d = await detect(env, io.exec, self, !o.dryRun);
147
+ const installSpec = npmSpec(VERSION);
148
+ const home = env.AGENTCHANNEL_HOME || undefined;
149
+ const result = { ran: [], skipped: [], failed: [], tools: [] };
150
+ const zh = !!o.zh;
151
+ const desktop = hasDesktop(env);
152
+ const plan = planSteps(d, { home, achCmd: d.achCmd, installSpec, cwd: io.cwd, zh, env, runtime: o.runtime, service: o.service, desktop });
153
+ if (o.dryRun) {
154
+ printOnly(io, d, plan, { zh, join: o.join });
155
+ return result;
156
+ }
157
+ const ask = async (s) => {
158
+ // no terminal (an agent ran the line) or --yes: the line itself was the consent; say what runs
159
+ if (o.yes || !io.ask) {
160
+ io.out(s.question.replace(/[??] ?(会运行:|This runs:)/, (_m, w) => (zh ? `。${w}` : `. ${w}`)).trimEnd());
161
+ return true;
162
+ }
163
+ return yesNo(await io.ask(`${s.question}[Y/n] `));
164
+ };
165
+ const runCmds = async (cmds) => {
166
+ // each program's commands are independent: a failed `claude mcp add` still lets `codex mcp add` run
167
+ const failedBins = new Set();
168
+ for (const c of cmds) {
169
+ if (failedBins.has(c[0]))
170
+ continue;
171
+ const r = await io.exec(c[0], c.slice(1), env);
172
+ if (r.code !== 0) {
173
+ // removing an entry that is not there is fine
174
+ if (c.includes('remove'))
175
+ continue;
176
+ io.err(` failed (exit ${r.code}): ${shellLine(c)}${r.stderr.trim() ? `\n ${r.stderr.trim().split('\n')[0]}` : ''}`);
177
+ failedBins.add(c[0]);
178
+ continue;
179
+ }
180
+ const kind = c[0] === d.claude ? 'claude' : c[0] === d.codex ? 'codex' : undefined;
181
+ if (kind && c[1] === 'mcp') {
182
+ io.out(` ${c[2] === 'add' ? (zh ? '已添加 MCP "ach" 到' : 'added MCP "ach" to') : zh ? `已从中删除 MCP "${c.at(-1)}":` : `removed MCP "${c.at(-1)}" from`} ${mcpConfigFile(kind, env)}`);
183
+ if (c[2] === 'add')
184
+ result.tools.push(kind);
185
+ }
186
+ }
187
+ return failedBins.size === 0;
188
+ };
189
+ for (const s of plan) {
190
+ if (!(await ask(s))) {
191
+ result.skipped.push(s.id);
192
+ continue;
193
+ }
194
+ if (s.id === 'service') {
195
+ // run through npx: install `ach` first; the helper then runs the installed one, never the npx cache
196
+ let helper = s.commands.at(-1);
197
+ if (s.commands.length > 1) {
198
+ if (!(await runCmds(s.commands.slice(0, -1)))) {
199
+ result.failed.push('service');
200
+ continue;
201
+ }
202
+ const g = await globalAch(d.npm, io.exec, env);
203
+ if (g)
204
+ helper = [g, ...helper.slice(d.achCmd.length)];
205
+ }
206
+ const ok = await installAndStartService(io, helper);
207
+ (ok ? result.ran : result.failed).push('service');
208
+ continue;
209
+ }
210
+ const ok = await runCmds(s.commands);
211
+ (ok ? result.ran : result.failed).push(s.id);
212
+ }
213
+ const want = toolsFor(o.runtime);
214
+ if (!want.some((k) => d[k])) {
215
+ const kind = want[0];
216
+ const name = kind === 'claude' ? 'Claude Code' : 'Codex';
217
+ io.out(zh ? `没有找到 ${name}。装好后再运行一次这行 join,或者手动执行:` : `${name} was not found. Install it and run the join line again, or add it yourself:`);
218
+ io.out(` ${shellLine([kind, ...mcpAddArgv(kind, kind, d.achCmd, home).slice(1)])} # ${changes(kind, env, zh)}`);
219
+ }
220
+ if (!o.service) {
221
+ const run = shellLine([...(d.npx ? d.achCmd : ['ach']), 'run', '--install-service', ...(desktop ? ['--notify'] : []), '--dir', io.cwd]);
222
+ io.out(zh ? `可选:让它在后台自动领任务:${run}` : `Optional, to have it pick up tasks in the background: ${run}`);
223
+ }
224
+ return result;
225
+ }
226
+ /** `ach run --install-service --json`, then start the unit (unless ACH_SERVICE_DIR points elsewhere: tests). */
227
+ async function installAndStartService(io, argv) {
228
+ const env = io.env;
229
+ const r = await io.exec(argv[0], [...argv.slice(1), '--json'], env);
230
+ if (r.code !== 0) {
231
+ io.err(` failed (exit ${r.code}): ${shellLine(argv)}${r.stderr.trim() ? `\n ${r.stderr.trim().split('\n')[0]}` : ''}`);
232
+ return false;
233
+ }
234
+ let f;
235
+ try {
236
+ f = JSON.parse(r.stdout);
237
+ }
238
+ catch {
239
+ io.err(' could not read the service file location');
240
+ return false;
241
+ }
242
+ io.out(` wrote ${f.path}`);
243
+ const starts = f.start.map((x) => x.replace(/\s+#.*$/, '')).filter((x) => !/loginctl/.test(x));
244
+ if (env.ACH_SERVICE_DIR) {
245
+ io.out(` start it with: ${starts.join(' && ')}`);
246
+ return true;
247
+ }
248
+ for (const c of starts) {
249
+ const s = await io.exec('/bin/sh', ['-c', c], env);
250
+ if (s.code !== 0) {
251
+ io.err(` failed (exit ${s.code}): ${c}`);
252
+ return false;
253
+ }
254
+ }
255
+ io.out(' started');
256
+ return true;
257
+ }
258
+ /** --dry-run: print what would be run (nothing is run, not even read-only probes). */
259
+ function printOnly(io, d, plan, o) {
260
+ const zh = o.zh;
261
+ const out = [zh ? '演练:什么都没有改。会做的事:' : 'Dry run: nothing was changed. What it would do:'];
262
+ if (o.join) {
263
+ out[0] = zh ? '演练:什么都没有改,邀请还没有用掉,也没有保存密钥。会做的事:' : 'Dry run: nothing was changed; the invite is still unused and no key was stored. What it would do:';
264
+ out.push(zh ? ` 用这个一次性邀请向 ${o.join.hub} 换取智能体密钥,并保存在本机` : ` exchange the one-time invite with ${o.join.hub} for the agent's key and store it on this machine`);
265
+ }
266
+ for (const s of plan)
267
+ out.push(s.question.trimEnd());
268
+ out.push('', `${zh ? '随时检查配置:' : 'Check the setup any time with: '}${shellLine([...(d.npx ? d.achCmd : ['ach']), 'doctor'])}`);
269
+ io.out(out.join('\n'));
270
+ }
@@ -0,0 +1,12 @@
1
+ export type Perm = 'read' | 'workdir' | 'full';
2
+ export declare const PERMS: readonly Perm[];
3
+ export declare function isPerm(v: unknown): v is Perm;
4
+ export declare function permRank(p: Perm): number;
5
+ /** Lower of two permission levels. */
6
+ export declare function minPerm(a: Perm, b: Perm): Perm;
7
+ /**
8
+ * Effective perm for a run = min(hub perm_level, runner --perm).
9
+ * An unknown or missing hub level is treated as 'read' (fail closed).
10
+ * `full` therefore requires both the hub and the runner flag to say full.
11
+ */
12
+ export declare function effectivePerm(hubLevel: unknown, flag: Perm): Perm;
@@ -0,0 +1,20 @@
1
+ export const PERMS = ['read', 'workdir', 'full'];
2
+ export function isPerm(v) {
3
+ return typeof v === 'string' && PERMS.includes(v);
4
+ }
5
+ export function permRank(p) {
6
+ return PERMS.indexOf(p);
7
+ }
8
+ /** Lower of two permission levels. */
9
+ export function minPerm(a, b) {
10
+ return permRank(a) <= permRank(b) ? a : b;
11
+ }
12
+ /**
13
+ * Effective perm for a run = min(hub perm_level, runner --perm).
14
+ * An unknown or missing hub level is treated as 'read' (fail closed).
15
+ * `full` therefore requires both the hub and the runner flag to say full.
16
+ */
17
+ export function effectivePerm(hubLevel, flag) {
18
+ const hub = isPerm(hubLevel) ? hubLevel : 'read';
19
+ return minPerm(hub, flag);
20
+ }