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
65 changes: 65 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ use. Truthy values are `1`, `true`, `yes`, or `on` (case-insensitive).
`XDG_CONFIG_HOME`.
- `OO_DATA_DIR`: Override the data directory that holds the local cache,
uploads, and download-session state. Defaults to `<config-root>/data`.
- `OO_OPEN_FLOW_COMMAND_DIR`: During local Open Flow integration testing, point
`oo flow` at an expanded Open Flow command artifact directory. The directory
must contain `entry.js`; the standard repository build writes it to
`packages/open-flow/dist/command/open-flow-command`. When this variable is
unset, `oo flow` uses the Open Flow release bundled with the current `oo`
release.
- `OO_LOG_DIR`: Override the debug-log directory. Takes precedence over every
platform default.
- `OO_API_KEY`: Run execution commands with this API key without an interactive
Expand Down Expand Up @@ -80,6 +86,65 @@ use. Truthy values are `1`, `true`, `yes`, or `on` (case-insensitive).
- `OO_NO_SELF_UPDATE`: A truthy value disables `oo update`, `oo install`, and
`oo check-update` and forces self-update PATH modification off.

## Open Flow

### `oo flow [args...]`

Run the Open Flow CLI release pinned by the current `oo` release. The first
invocation downloads and verifies its immutable command archive. Later
invocations reuse the verified local cache without checking for updates or
requiring network access.

- Every argument after `flow` is passed to Open Flow unchanged. The main `oo`
CLI does not parse, reorder, or log these arguments.
- The effective `oo --lang` locale is passed to Open Flow as `en` or
`zh-CN`. Open Flow owns and versions its translated command text.
- `oo flow --help` and `oo flow --version` are therefore Open Flow commands.
Use `oo help flow` to show the host-side command description without loading
Open Flow.
- Root help and generated shell completions list `flow` only when
`OO_ENDPOINT=oomol.dev`. Other endpoints hide it without disabling direct
`oo flow` or `oo help flow` invocations.
- Main `oo` global options such as `--lang` and `--debug` must appear before
`flow`. Options after `flow` belong to Open Flow.
- Open Flow uses the current process's working directory, standard streams,
environment, and signals. Its exit code becomes the `oo` exit code.
- The downloaded archive is accepted only when its length, SHA-256 digest,
internal manifest, complete file set, and Bun version match the release
pinned by `oo`.
- On a cache miss, interactive terminals show in-place byte progress on stderr;
non-interactive streams receive one start and one completion line. Cache hits
remain silent.
- `OO_OPEN_FLOW_COMMAND_DIR` bypasses download and cache resolution for local
repository integration testing.
- Open Flow Cloud commands use the current `oo` credential and effective Team.
The Cloud gateway is derived from the current endpoint as
`https://open-flow.<endpoint>`; for example, `OO_ENDPOINT=oomol.dev` uses
`https://open-flow.oomol.dev`.
- The `oo` credential and Team identity are attached only to `/v1/` requests
for that gateway. Deployment package uploads use the temporary upload URL
returned by Cloud without the `oo` credential.

Local repository example:

```bash
cd /path/to/open-flow
bun run --filter @oomol-lab/open-flow build

cd /path/to/oo-cli
OO_OPEN_FLOW_COMMAND_DIR=/path/to/open-flow/packages/open-flow/dist/command/open-flow-command \
bun run index.ts flow --help
```

Test the online dev Cloud with a locally built Open Flow command (requires a
dev login and an effective Team):

```bash
OO_ENDPOINT=oomol.dev \
OO_OPEN_FLOW_COMMAND_DIR=/path/to/open-flow/packages/open-flow/dist/command/open-flow-command \
bun run index.ts flow project list
```

## JSON Output

Commands that document `--format=json` and `--json` share the following
Expand Down
55 changes: 55 additions & 0 deletions docs/commands.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、
子目录)。优先级高于 `XDG_CONFIG_HOME`。
- `OO_DATA_DIR`:覆盖数据目录,其中包含本地缓存、上传和下载会话状态。默认值为
`<配置根目录>/data`。
- `OO_OPEN_FLOW_COMMAND_DIR`:本地联调 Open Flow 时,让 `oo flow`
使用指定的已展开命令产物目录。该目录必须包含 `entry.js`;Open Flow
仓库的标准构建会将它写到
`packages/open-flow/dist/command/open-flow-command`。未设置时,`oo flow`
使用当前 `oo` 版本固定的 Open Flow release。
- `OO_LOG_DIR`:覆盖 debug 日志目录。优先级高于所有平台默认值。
- `OO_API_KEY`:使用该 API key 执行命令,无需交互式登录。设置后 CLI 会构造一个
内存账号,不读取、不要求、也不写入 `auth.toml`,且优先级高于任何已保存的账号。
Expand Down Expand Up @@ -65,6 +70,56 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、
- `OO_NO_SELF_UPDATE`:设为真值会禁用 `oo update`、`oo install` 和
`oo check-update`,并强制关闭 self-update 的 PATH 改写。

## Open Flow

### `oo flow [args...]`

运行当前 `oo` 版本固定的 Open Flow CLI release。首次调用会下载并验证对应的
不可变命令归档;之后直接离线复用已验证的本地缓存,不会在每次启动时检查更新。

- `flow` 后的全部参数都会原样传给 Open Flow;主 `oo` CLI
不解析、不重排,也不把这些参数写入日志。
- 当前生效的 `oo --lang` locale 会以 `en` 或 `zh-CN` 传给 Open Flow;
Open Flow 自己拥有并随版本发布对应的命令翻译文案。
- 因此 `oo flow --help` 和 `oo flow --version` 都属于 Open Flow 命令。
如需在不加载 Open Flow 的情况下查看宿主侧命令说明,请使用 `oo help flow`。
- 只有设置 `OO_ENDPOINT=oomol.dev` 时,根帮助和生成的 shell 补全才会列出
`flow`。其他 endpoint 只会隐藏该命令,不会禁用直接调用 `oo flow` 或
`oo help flow`。
- `--lang`、`--debug` 等 `oo` 全局选项必须放在 `flow` 前面;
`flow` 后的选项归 Open Flow 所有。
- Open Flow 使用当前进程的工作目录、标准输入输出、环境变量和信号;
它的退出码会直接成为 `oo` 的退出码。
- 只有归档长度、SHA-256、内部 manifest、完整文件集合和 Bun 版本都与 `oo`
固定的 release 一致时,下载内容才会被接受。
- 缓存未命中时,交互式终端会在 stderr 原地刷新字节进度;非交互式输出会分别打印
一行开始和完成信息。命中缓存时保持静默。
- `OO_OPEN_FLOW_COMMAND_DIR` 仅用于本地仓库联调;设置后会跳过下载和缓存解析。
- Open Flow Cloud 子命令使用当前 `oo` 登录凭证和生效的 Team。Cloud gateway
由当前 endpoint 派生为 `https://open-flow.<endpoint>`;例如
`OO_ENDPOINT=oomol.dev` 使用 `https://open-flow.oomol.dev`。
- `oo` 登录凭证和 Team identity 只附加到上述 gateway 的 `/v1/` 请求。部署包上传使用
Cloud 返回的临时上传地址,不携带 `oo` 登录凭证。

本地仓库联调示例:

```bash
cd /path/to/open-flow
bun run --filter @oomol-lab/open-flow build

cd /path/to/oo-cli
OO_OPEN_FLOW_COMMAND_DIR=/path/to/open-flow/packages/open-flow/dist/command/open-flow-command \
bun run index.ts flow --help
```

使用本地构建的 Open Flow 命令测试线上 dev Cloud(需要已登录 dev 账号及已选择 Team):

```bash
OO_ENDPOINT=oomol.dev \
OO_OPEN_FLOW_COMMAND_DIR=/path/to/open-flow/packages/open-flow/dist/command/open-flow-command \
bun run index.ts flow project list
```

## JSON 输出

文档中带有 `--format=json` 和 `--json` 的命令遵循以下约定:
Expand Down
10 changes: 10 additions & 0 deletions src/adapters/completion/static-completion-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,14 @@ describe("StaticCompletionRenderer", () => {
);
expect(output).toContain("en zh");
});

test("shows flow completion only for the online dev endpoint", () => {
const renderer = new StaticCompletionRenderer(createTranslator("en"));
const hiddenOutput = renderer.render("fish", createCliCatalog());
const devOutput = renderer.render("fish", createCliCatalog("oomol.dev"));
const flowCompletion = `complete -c ${APP_NAME} -n '__fish_use_subcommand' -a 'flow'`;

expect(hiddenOutput).not.toContain(flowCompletion);
expect(devOutput).toContain(flowCompletion);
});
});
56 changes: 47 additions & 9 deletions src/application/bootstrap/run-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ import {
import { createTranslator } from "../../i18n/translator.ts";
import { migrateLegacyDefaultTeam } from "../auth/default-team.ts";
import { createCliCatalog } from "../commands/catalog.ts";
import {
resolveOpenFlowInvocation,
runOpenFlowCommand,
} from "../commands/flow.ts";
import { synchronizeManagedSkillsForAvailableHosts } from "../commands/skills/auto-sync.ts";
import { APP_NAME } from "../config/app-config.ts";
import {
Expand Down Expand Up @@ -155,8 +159,12 @@ export async function executeCli(invocation: CliInvocation): Promise<number> {
const startTimeMs = Date.now();
const sessionId = Bun.randomUUIDv7();
const telemetryRecorder = createTelemetryInvocationRecorder();
const debugPathEnabled = hasCliDebugFlag(invocation.argv);
const rawCliLanguage = detectCliLanguageFlag(invocation.argv);
const openFlowInvocation = resolveOpenFlowInvocation(invocation.argv);
const ooArgv = openFlowInvocation === undefined
? invocation.argv
: invocation.argv.slice(0, openFlowInvocation.commandIndex);
const debugPathEnabled = hasCliDebugFlag(ooArgv);
const rawCliLanguage = detectCliLanguageFlag(ooArgv);
const parsedCliLanguage = parseExplicitLocale(rawCliLanguage);
const storePaths = resolveStorePaths({
appName: APP_NAME,
Expand Down Expand Up @@ -267,7 +275,7 @@ export async function executeCli(invocation: CliInvocation): Promise<number> {
systemLocale: invocation.systemLocale,
}),
);
const catalog = createCliCatalog();
const catalog = createCliCatalog(invocation.env.OO_ENDPOINT?.trim());
const completionRenderer = new StaticCompletionRenderer(translator);
const packageName = invocation.packageName ?? packageManifest.name;

Expand Down Expand Up @@ -326,12 +334,31 @@ export async function executeCli(invocation: CliInvocation): Promise<number> {
await synchronizeManagedSkillsForAvailableHosts(context);
}

exitCode = await adapter.run({
argv: invocation.argv,
catalog,
context,
observer: telemetryRecorder.observer,
});
if (openFlowInvocation !== undefined) {
telemetryRecorder.observer.onCommandResolved?.({
argCount: 0,
commandPath: ["flow"],
excludeFromTelemetry: false,
flagsCount: 0,
outputFormat: "text",
});
exitCode = await runOpenFlowCommand(openFlowInvocation.args, context);

if (exitCode === 0) {
telemetryRecorder.observer.onCommandCompleted?.({ exitCode });
}
else {
telemetryRecorder.observer.onCommandFailed?.({ exitCode });
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else {
exitCode = await adapter.run({
argv: invocation.argv,
catalog,
context,
observer: telemetryRecorder.observer,
});
}
}
catch (error) {
if (error instanceof CliUserError) {
Expand Down Expand Up @@ -676,6 +703,17 @@ function getSystemLocale(): string | undefined {
}

function redactSensitiveCliArguments(argv: readonly string[]): string[] {
const openFlowInvocation = resolveOpenFlowInvocation(argv);

if (openFlowInvocation !== undefined) {
return [
...argv.slice(0, openFlowInvocation.commandIndex + 1),
...(openFlowInvocation.args.length === 0
? []
: [redactedCliArgumentValue]),
];
}

const positionalRule = sensitiveCliPositionalRules.find(rule =>
rule.commandPath.every((word, index) => argv[index] === word),
);
Expand Down
9 changes: 8 additions & 1 deletion src/application/commands/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { completionCommand } from "./completion.ts";
import { configCommand } from "./config/index.ts";
import { connectorCommand } from "./connector/index.ts";
import { fileCommand } from "./file/index.ts";
import { flowCommand } from "./flow.ts";
import { infoCommand } from "./info.ts";
import { installCommand } from "./install.ts";
import { llmCommand } from "./llm/index.ts";
Expand Down Expand Up @@ -38,7 +39,9 @@ const globalOptions = [
},
] as const;

export function createCliCatalog(): CliCatalog {
const onlineDevEndpoint = "oomol.dev";

export function createCliCatalog(endpoint?: string): CliCatalog {
return {
name: APP_NAME,
descriptionKey: "app.description",
Expand All @@ -48,6 +51,10 @@ export function createCliCatalog(): CliCatalog {
checkUpdateCommand,
connectorCommand,
fileCommand,
{
...flowCommand,
hidden: endpoint !== onlineDevEndpoint,
},
infoCommand,
installCommand,
llmCommand,
Expand Down
34 changes: 6 additions & 28 deletions src/application/commands/connector/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
isNetworkRestrictedSandboxError,
requestOo,
} from "../shared/oo-request.ts";
import { teamIdentityHeaders } from "../team/identity.ts";

export const connectorActionDefinitionSchema = z.object({
description: z.string().optional().default(""),
Expand Down Expand Up @@ -204,7 +205,7 @@ export async function searchConnectorActions(
// The action list itself is identity-independent, but each result's
// `authenticated` flag reflects the effective identity's connected
// apps, so the identity headers are forwarded like `apps`/`run`.
headers: connectorIdentityHeaders(options.identity),
headers: teamIdentityHeaders(options.identity),
host: { baseUrl: options.target.baseUrl },
label: "Connector action search",
logFields: {
Expand Down Expand Up @@ -240,7 +241,7 @@ export async function listConnectorApps(
authorization: options.target.authorization,
context,
errors: { scope: "connectorApps" },
headers: connectorIdentityHeaders(options.identity),
headers: teamIdentityHeaders(options.identity),
host: { baseUrl: options.target.baseUrl },
label: "Connector apps list",
path: "/v1/apps",
Expand Down Expand Up @@ -268,7 +269,7 @@ export async function listConnectorAppsByService(
authorization: options.target.authorization,
context,
errors: { scope: "connectorApps" },
headers: connectorIdentityHeaders(options.identity),
headers: teamIdentityHeaders(options.identity),
host: { baseUrl: options.target.baseUrl },
label: "Connector apps list",
logFields: {
Expand Down Expand Up @@ -366,7 +367,7 @@ export async function runConnectorAction(
errors: { scope: "connectorRun" },
headers: {
...connectorConnectionSelectorHeaders(options.connectionSelector),
...connectorIdentityHeaders(options.identity),
...teamIdentityHeaders(options.identity),
},
host: { baseUrl: options.target.baseUrl },
jsonBody: { input: options.inputData },
Expand Down Expand Up @@ -407,7 +408,7 @@ export async function runConnectorProxy(
authorization: options.target.authorization,
context,
errors: { scope: "connectorProxy" },
headers: connectorIdentityHeaders(options.identity),
headers: teamIdentityHeaders(options.identity),
host: { baseUrl: options.target.baseUrl },
jsonBody: options.proxyRequest,
label: "Connector proxy",
Expand Down Expand Up @@ -437,29 +438,6 @@ function connectorActionPath(serviceName: string, actionName: string): string {
return `/v1/actions/${encodeURIComponent(serviceName)}.${encodeURIComponent(actionName)}`;
}

// Builds the identity request headers (`x-oo-team-name` / `x-oo-team-id`)
// from whichever dimensions the identity carries. Returns an empty object for
// the personal identity so callers can spread it unconditionally.
function connectorIdentityHeaders(
identity: TeamIdentity | undefined,
): Record<string, string> {
const headers: Record<string, string> = {};

if (identity === undefined) {
return headers;
}

if (identity.name !== null) {
headers["x-oo-team-name"] = identity.name;
}

if (identity.id !== null) {
headers["x-oo-team-id"] = identity.id;
}

return headers;
}

// Self-hosted servers are typically local processes, so a connection failure
// usually means the server is not running — the sandbox hint the OOMOL paths
// use would send users down the wrong path.
Expand Down
2 changes: 1 addition & 1 deletion src/application/commands/file/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getConfiguredFileDownloadOutDir,
} from "../../schemas/settings.ts";
import { bucketTelemetryBytes } from "../../telemetry/buckets.ts";
import { createDownloadProgressReporter } from "../shared/download-progress.ts";
import {
finalizeDownloadedFile,
openTemporaryDownloadFile,
Expand All @@ -22,7 +23,6 @@ import {
parseFileDownloadUrl,
} from "./download/input.ts";
import { resolveDownloadPlan } from "./download/plan.ts";
import { createDownloadProgressReporter } from "./download/progress.ts";
import {
createDownloadSessionKey,
deleteDownloadSessionBestEffort,
Expand Down
2 changes: 1 addition & 1 deletion src/application/commands/file/download/file-system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createTemporaryDirectory,
createTextBuffer,
} from "../../../../../__tests__/helpers.ts";
import { createDownloadProgressReporter } from "../../shared/download-progress.ts";
import {
createDownloadSessionRecordFixture,
createDownloadSessionStoreSpy,
Expand All @@ -20,7 +21,6 @@ import {
resolveTemporaryDownloadFileName,
writeDownloadToTemporaryFile,
} from "./file-system.ts";
import { createDownloadProgressReporter } from "./progress.ts";

describe("resolveTemporaryDownloadFileName", () => {
test("skips reserved and existing temporary file names", async () => {
Expand Down
Loading
Loading