Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ lark-channel-bridge --help
```bash
lark-channel-bridge profile create claude --agent claude
lark-channel-bridge profile create codex --agent codex
lark-channel-bridge profile clone claude codex --agent codex
lark-channel-bridge profile list
lark-channel-bridge profile use <name>
lark-channel-bridge profile remove <name>
Expand All @@ -144,6 +145,7 @@ If a profile was created with the wrong agent kind, stop or unregister any match
| `/ws remove <name>` | Delete a named workspace |
| `/resume` | Resume compatible history for the same agent, working directory, and permission mode |
| `/status` | Show profile, agent, working directory, session, lark-cli identity, and run state |
| `/backend [claude\|codex]` | Show or switch the active agent backend (owner/admin only; requires a supervisor that restarts on exit code 75) |
| `/config` | Adjust presentation preferences, access settings, and lark-cli identity policy |
| `/invite user @name` | Allow a user to use the bot in DMs |
| `/invite admin @name` | Add an access-control admin |
Expand Down
2 changes: 2 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ lark-channel-bridge --help
```bash
lark-channel-bridge profile create claude --agent claude
lark-channel-bridge profile create codex --agent codex
lark-channel-bridge profile clone claude codex --agent codex
lark-channel-bridge profile list
lark-channel-bridge profile use <name>
lark-channel-bridge profile remove <name>
Expand All @@ -144,6 +145,7 @@ lark-channel-bridge profile export <name> --include-secrets --yes
| `/ws remove <name>` | 删除命名工作空间 |
| `/resume` | 恢复同 agent、工作目录、权限模式兼容的历史会话 |
| `/status` | 查看 profile、agent、工作目录、会话、lark-cli 身份和运行状态 |
| `/backend [claude\|codex]` | 查看或切换当前 Agent 后端(仅 owner/admin;需要 supervisor 对退出码 75 执行重启) |
| `/config` | 调整展示偏好、访问控制和 lark-cli 身份策略 |
| `/invite user @某人` | 允许用户私聊使用 bot |
| `/invite admin @某人` | 添加访问控制管理员 |
Expand Down
1 change: 1 addition & 0 deletions src/card/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export function helpCard(agentName = 'Agent'): object {
'- `/account` — 查看当前应用;`/account change` 换 appId/secret 并重连',
'- `/config` — 调整偏好、访问控制和 lark-cli 身份策略',
'- `/status` — 当前状态',
'- `/backend [claude|codex]` — 查看或切换当前 Agent 后端(管理员)',
'- `/stop` — 结束当前正在跑的任务(也可点卡片底部 ⏹ 终止 按钮)',
'- `/stop comment:<scopeHash>` — 管理员停止云文档评论任务',
'- `/timeout [N|off|default]` — 当前 session 的探活分钟数,`/config` 改全局默认',
Expand Down
52 changes: 51 additions & 1 deletion src/cli/commands/profile.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { copyFile, mkdir, rm } from 'node:fs/promises';
import { resolveAppPaths } from '../../config/app-paths';
import { paths } from '../../config/paths';
import {
Expand Down Expand Up @@ -35,6 +35,11 @@ export interface ProfileCreateOptions extends ProfileCommandOptions {
tenant?: string;
}

export interface ProfileCloneOptions extends ProfileCommandOptions {
agent?: string;
codexBin?: string;
}

export interface ProfileRemoveOptions extends ProfileCommandOptions {
purge?: boolean;
yes?: boolean;
Expand Down Expand Up @@ -136,6 +141,51 @@ export async function runProfileCreate(
console.log(`已创建 profile: ${name}`);
}

export async function runProfileClone(
sourceName: string,
targetName: string,
opts: ProfileCloneOptions = {},
): Promise<void> {
const rootDir = opts.rootDir ?? paths.rootDir;
const configFile = resolveAppPaths({ rootDir }).configFile;
await withConfigFileLock(configFile, async () => {
const root = await loadRootConfig(configFile);
if (!root) throw new Error('config not initialized');
const source = root.profiles[sourceName];
if (!source) throw new Error(`profile not found: ${sourceName}`);
if (root.profiles[targetName]) throw new Error(`profile already exists: ${targetName}`);

const agentKind = agentKindFromString(opts.agent) ?? source.agentKind;
const target = structuredClone(source);
target.agentKind = agentKind;
target.larkCli = { identityPreset: source.larkCli.identityPreset };
if (agentKind === 'codex') {
target.codex = {
binaryPath: opts.codexBin ?? process.env.LARK_CHANNEL_CODEX_BIN ?? 'codex',
inheritCodexHome: true,
ignoreUserConfig: true,
ignoreRules: true,
};
} else {
target.codex = undefined;
}

const sourcePaths = resolveAppPaths({ rootDir, profile: sourceName });
const targetPaths = resolveAppPaths({ rootDir, profile: targetName });
await mkdir(targetPaths.profileDir, { recursive: true });
for (const [sourceFile, targetFile] of [
[sourcePaths.secretsFile, targetPaths.secretsFile],
[sourcePaths.keystoreSaltFile, targetPaths.keystoreSaltFile],
] as const) {
if (existsSync(sourceFile)) await copyFile(sourceFile, targetFile);
}

root.profiles[targetName] = target;
await saveRootConfig(root, configFile);
});
console.log(`已克隆 profile: ${sourceName} -> ${targetName}`);
}

export async function runProfileUse(
name: string,
opts: ProfileCommandOptions = {},
Expand Down
8 changes: 4 additions & 4 deletions src/cli/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ export async function runStart(opts: StartOptions): Promise<void> {
let restarting = false;

let stopping = false;
const stop = async (sig: string): Promise<void> => {
const stop = async (sig: string, exitCode = 0): Promise<void> => {
if (stopping) return;
stopping = true;
console.log(`\n收到 ${sig},正在关闭...`);
Expand All @@ -197,7 +197,7 @@ export async function runStart(opts: StartOptions): Promise<void> {
unregisterSync(entry.id, appPaths.userRegistryFile);
await releaseRuntimeLocks(runtimeLocks);
await flushTelemetry();
process.exit(0);
process.exit(exitCode);
};

let controls: Controls;
Expand All @@ -223,8 +223,8 @@ export async function runStart(opts: StartOptions): Promise<void> {
configPath,
cfg: currentCfg,
processId: entry.id,
async exit() {
await stop('exit-command');
async exit(exitCode = 0) {
await stop('exit-command', exitCode);
},
async restart() {
if (restarting) return;
Expand Down
13 changes: 13 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from './commands/secrets';
import {
runProfileCreate,
runProfileClone,
runProfileExport,
runProfileList,
runProfileRemove,
Expand Down Expand Up @@ -97,6 +98,18 @@ profile
await runProfileCreate(name, opts);
});

profile
.command('clone <source> <name>')
.description('Clone an existing app profile as another agent backend without copying sessions')
.option('--agent <kind>', 'target agent kind (claude or codex)')
.option('--codex-bin <path>', 'Codex binary path when cloning as codex')
.action(async (source: string, name: string, opts: {
agent?: string;
codexBin?: string;
}) => {
await runProfileClone(source, name, opts);
});

profile
.command('use <name>')
.description('Set the active profile')
Expand Down
130 changes: 129 additions & 1 deletion src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
runtimeProfileConfig,
saveRootConfig,
withConfigFileLock,
writeActiveProfile,
} from '../config/profile-store';
import {
canRunAdminCommand,
Expand Down Expand Up @@ -79,6 +80,7 @@ import type { WorkspaceStore } from '../workspace/store';
import { createBoundChat, defaultChatName } from '../bot/group';
import { fetchKnownChats, type KnownChat } from '../bot/lark-info';
import { applyLarkCliIdentityPolicy, hasStructuredLarkCliUserAuth } from '../lark-cli/identity-policy';
import { checkAgentAvailability, formatAgentPreflightError } from '../agent/preflight';

export interface Controls {
profile: string;
Expand All @@ -93,7 +95,7 @@ export interface Controls {
restart(opts?: { wait?: boolean }): Promise<void>;
/** Stop this whole process gracefully (disconnect + exit). Used by /exit
* when the user targets the receiving process itself. */
exit(): Promise<void>;
exit(exitCode?: number): Promise<void>;
/** Path to the config file the bridge was started with. */
configPath: string;
/** The current app config (snapshot at startChannel time). */
Expand Down Expand Up @@ -172,6 +174,7 @@ const handlers: Record<string, Handler> = {
'/ps': handlePs,
'/exit': handleExit,
'/doctor': handleDoctor,
'/backend': handleBackend,
'/reconnect': handleReconnect,
'/doc': handleDoc,
'/invite': handleInvite,
Expand All @@ -190,6 +193,7 @@ const ADMIN_COMMANDS = new Set([
'/exit',
'/reconnect',
'/doctor',
'/backend',
'/cd',
'/ws',
'/invite',
Expand Down Expand Up @@ -1043,6 +1047,130 @@ async function handleReconnect(args: string, ctx: CommandContext): Promise<void>
}
}

export const PLANNED_PROFILE_SWITCH_EXIT_CODE = 75;

async function handleBackend(args: string, ctx: CommandContext): Promise<void> {
const root = await loadRootConfig(ctx.controls.configPath);
if (!root) {
await reply(ctx, '❌ bridge profile 配置不存在。');
return;
}

const currentProfile = root.profiles[root.activeProfile];
if (!currentProfile) {
await reply(ctx, `❌ 当前 profile \`${root.activeProfile}\` 不存在。`);
return;
}

const targetKind = args.trim().toLowerCase();
if (!targetKind) {
const rows = Object.entries(root.profiles)
.filter(([, profile]) => profile.accounts.app.id === currentProfile.accounts.app.id)
.map(([name, profile]) => {
const marker = name === root.activeProfile ? ' ← 当前' : '';
return `- \`${profile.agentKind}\`:profile \`${name}\`${marker}`;
});
await reply(
ctx,
[
`当前后端:**${currentProfile.agentKind === 'codex' ? 'Codex' : 'Claude Code'}**`,
'',
rows.length > 0 ? rows.join('\n') : '没有可切换的同 App profile。',
'',
'用法:`/backend claude` 或 `/backend codex`',
].join('\n'),
);
return;
}

if (targetKind !== 'claude' && targetKind !== 'codex') {
await reply(ctx, '❌ 仅支持 `/backend claude` 或 `/backend codex`。');
return;
}
if (currentProfile.agentKind === targetKind) {
await reply(ctx, `当前已经是 **${targetKind === 'codex' ? 'Codex' : 'Claude Code'}**。`);
return;
}

const activeScopes = ctx.activeRuns.scopes();
if (activeScopes.length > 0) {
await reply(ctx, `❌ 当前还有 ${activeScopes.length} 个任务在运行,结束后再切换后端。`);
return;
}

const candidates = Object.entries(root.profiles).filter(
([, profile]) =>
profile.agentKind === targetKind &&
profile.accounts.app.id === currentProfile.accounts.app.id,
);
const preferred = candidates.find(([name]) => name === targetKind) ?? candidates[0];
if (!preferred) {
await reply(
ctx,
`❌ 没有找到使用同一飞书 App 的 ${targetKind === 'codex' ? 'Codex' : 'Claude Code'} profile。`,
);
return;
}
if (candidates.length > 1 && preferred[0] !== targetKind) {
await reply(ctx, `❌ 找到多个 ${targetKind} profile,请先将目标 profile 命名为 \`${targetKind}\`。`);
return;
}

const [targetProfileName, targetProfile] = preferred;
const binary =
targetKind === 'codex'
? targetProfile.codex?.binaryPath
: process.env.LARK_CHANNEL_CLAUDE_BIN ?? 'claude';
if (!binary) {
await reply(ctx, `❌ ${targetKind} profile 缺少可执行文件配置。`);
return;
}
const availability = await checkAgentAvailability({
agentId: targetKind,
agentName: targetKind === 'codex' ? 'Codex CLI' : 'Claude Code',
command: binary,
binaryPath: binary,
});
if (!availability.ok) {
await reply(ctx, `❌ 目标后端不可用:\n${formatAgentPreflightError(availability.error)}`);
return;
}

const resumeNewRuns = ctx.activeRuns.pauseNewRuns('backend-switch-in-progress');
try {
await withConfigFileLock(ctx.controls.configPath, async () => {
const latest = await loadRootConfig(ctx.controls.configPath);
if (!latest?.profiles[targetProfileName]) {
throw new Error(`target profile disappeared: ${targetProfileName}`);
}
const latestCurrent = latest.profiles[latest.activeProfile];
const latestTarget = latest.profiles[targetProfileName];
if (!latestCurrent || latestCurrent.accounts.app.id !== latestTarget.accounts.app.id) {
throw new Error('target profile no longer uses the same Lark app');
}
latest.activeProfile = targetProfileName;
await saveRootConfig(latest, ctx.controls.configPath);
await writeActiveProfile(dirname(ctx.controls.configPath), targetProfileName);
});
log.info('command', 'backend-switch', {
from: root.activeProfile,
to: targetProfileName,
agent: targetKind,
});
await reply(
ctx,
`⏳ 正在切换至 **${targetKind === 'codex' ? 'Codex' : 'Claude Code'}**,服务将短暂重启。`,
);
void (async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
await ctx.controls.exit(PLANNED_PROFILE_SWITCH_EXIT_CODE).catch(() => {});
})();
} catch (err) {
resumeNewRuns();
throw err;
}
}

const DOCTOR_ECHO_PROMPT =
'Bridge doctor agent echo check. Do not inspect files, do not use history, and reply exactly: OK';
const DOCTOR_RATE_LIMIT_MS = 30_000;
Expand Down
29 changes: 27 additions & 2 deletions tests/integration/cli/profile-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promi
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { runProfileCreate } from '../../../src/cli/commands/profile';
import { runProfileClone, runProfileCreate } from '../../../src/cli/commands/profile';
import { resolveAppPaths } from '../../../src/config/app-paths';
import {
createDefaultProfileConfig,
type AgentKind,
type RootConfig,
} from '../../../src/config/profile-schema';
import { loadRootConfig } from '../../../src/config/profile-store';
import { getSecret } from '../../../src/config/keystore';
import { getSecret, setSecret } from '../../../src/config/keystore';
import { secretKeyForApp } from '../../../src/config/schema';
import { writeVersionExecutable } from '../../helpers/fake-executable';

Expand Down Expand Up @@ -171,6 +171,31 @@ describe('profile create command', () => {
const managed = await realpath(resolveAppPaths({ rootDir: root, profile: 'claude-managed' }).defaultWorkspaceDir);
expect(saved.profiles['claude-managed']?.workspaces.default).toBe(managed);
});

it('clones an app profile into a Codex standby without copying sessions', async () => {
const root = await makeRoot();
await writeProfiles(root, 'claude', ['claude']);
const sourcePaths = resolveAppPaths({ rootDir: root, profile: 'claude' });
await setSecret(secretKeyForApp('cli_claude'), 'shared-secret', sourcePaths);
await writeFile(sourcePaths.sessionsFile, '{"chat":"claude-session"}\n', 'utf8');
const codex = await writeVersionExecutable(root, 'codex-clone', 'codex 2.0.0');

await runProfileClone('claude', 'codex', {
rootDir: root,
agent: 'codex',
codexBin: codex,
});

const saved = await loadRootConfig(join(root, 'config.json'));
const targetPaths = resolveAppPaths({ rootDir: root, profile: 'codex' });
expect(saved?.activeProfile).toBe('claude');
expect(saved?.profiles.codex?.agentKind).toBe('codex');
expect(saved?.profiles.codex?.accounts.app.id).toBe('cli_claude');
expect(saved?.profiles.codex?.codex?.binaryPath).toBe(codex);
expect(saved?.profiles.codex?.codex?.inheritCodexHome).toBe(true);
await expect(getSecret(secretKeyForApp('cli_claude'), targetPaths)).resolves.toBe('shared-secret');
await expect(readFile(targetPaths.sessionsFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
});
});

async function makeRoot(): Promise<string> {
Expand Down
Loading