Skip to content
Merged
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 @@ -75,6 +75,8 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command
| [`codex-auth import <path> [--alias <alias>]`](./docs/commands/import.md) | Import a single auth file or batch import a folder |
| [`codex-auth import --cpa [<path>]`](./docs/commands/import.md) | Import CLIProxyAPI token JSON |
| [`codex-auth import --purge [<path>]`](./docs/commands/import.md) | Rebuild `registry.json` from auth files |
| [`codex-auth export [<dir>]`](./docs/commands/export.md) | Export stored account auth files |
| [`codex-auth export --cpa [<dir>]`](./docs/commands/export.md) | Export CLIProxyAPI token JSON |
| [`codex-auth clean`](./docs/commands/clean.md) | Delete managed backup and stale account files |

### Configuration
Expand Down
1 change: 1 addition & 0 deletions docs/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This directory documents command behavior by command. Use `codex-auth <command>
| `list` | [docs/commands/list.md](./list.md) |
| `login` | [docs/commands/login.md](./login.md) |
| `import` | [docs/commands/import.md](./import.md) |
| `export` | [docs/commands/export.md](./export.md) |
| `switch` | [docs/commands/switch.md](./switch.md) |
| `remove` | [docs/commands/remove.md](./remove.md) |
| `clean` | [docs/commands/clean.md](./clean.md) |
Expand Down
27 changes: 27 additions & 0 deletions docs/commands/export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# `codex-auth export`

## Usage

```shell
codex-auth export [<dir>]
codex-auth export --cpa [<dir>]
```

## Standard Export

- Exports stored account auth snapshots.
- A directory path writes direct child `*.auth.json` files to that directory.
- Without a directory path, files are written to `CODEX_HOME/accounts/backup`.
- The exported directory can be imported with `codex-auth import <dir>`.

## CLIProxyAPI Export

`--cpa` exports flat [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) token JSON.

- A directory path writes direct child `.json` files to that directory.
- Without a directory path, files are written to `CODEX_HOME/accounts/backup`.
- The exported directory can be imported with `codex-auth import --cpa <dir>`.

## Output

- `stdout` receives the number of exported accounts and destination directory.
1 change: 1 addition & 0 deletions docs/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Managed files:
- `<codex_home>/auth.json`
- `<codex_home>/accounts/registry.json`
- `<codex_home>/accounts/<account file key>.auth.json`
- `<codex_home>/accounts/backup/`
- `<codex_home>/accounts/auth.json.bak.YYYYMMDD-hhmmss[.N]`
- `<codex_home>/accounts/registry.json.bak.YYYYMMDD-hhmmss[.N]`
- `<codex_home>/sessions/...`
Expand Down
38 changes: 38 additions & 0 deletions src/auth/auth.zig
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ const StandardAuthJson = struct {
last_refresh: []const u8,
};

const CpaAuthJson = struct {
id_token: []const u8,
access_token: []const u8,
refresh_token: []const u8,
account_id: []const u8,
last_refresh: []const u8,
};

fn normalizeEmailAlloc(allocator: std.mem.Allocator, email: []const u8) ![]u8 {
var buf = try allocator.alloc(u8, email.len);
for (email, 0..) |ch, i| {
Expand Down Expand Up @@ -256,6 +264,36 @@ pub fn convertCpaAuthJson(allocator: std.mem.Allocator, data: []const u8) ![]u8
return try out.toOwnedSlice();
}

pub fn convertStandardAuthJsonToCpa(allocator: std.mem.Allocator, data: []const u8) ![]u8 {
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
defer parsed.deinit();

const obj = switch (parsed.value) {
.object => |obj| obj,
else => return error.InvalidAuthFormat,
};
const tokens_val = obj.get("tokens") orelse return error.MissingTokens;
const tokens = switch (tokens_val) {
.object => |tokens| tokens,
else => return error.MissingTokens,
};
const refresh_token = jsonStringField(tokens, "refresh_token") orelse return error.MissingRefreshToken;
if (refresh_token.len == 0) return error.MissingRefreshToken;

var out: std.Io.Writer.Allocating = .init(allocator);
errdefer out.deinit();

try std.json.Stringify.value(CpaAuthJson{
.id_token = jsonStringFieldOrDefault(tokens, "id_token"),
.access_token = jsonStringFieldOrDefault(tokens, "access_token"),
.refresh_token = refresh_token,
.account_id = jsonStringFieldOrDefault(tokens, "account_id"),
.last_refresh = jsonStringFieldOrDefault(obj, "last_refresh"),
}, .{ .whitespace = .indent_2 }, &out.writer);
try out.writer.writeAll("\n");
return try out.toOwnedSlice();
}

pub fn decodeJwtPayload(allocator: std.mem.Allocator, jwt: []const u8) ![]u8 {
var it = std.mem.splitScalar(u8, jwt, '.');
_ = it.next();
Expand Down
40 changes: 40 additions & 0 deletions src/cli/commands/export.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const std = @import("std");
const types = @import("../types.zig");
const common = @import("common.zig");

pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult {
if (args.len == 1 and common.isHelpFlag(std.mem.sliceTo(args[0], 0))) {
return .{ .command = .{ .help = .export_auth } };
}

var dest_path: ?[]u8 = null;
var format: types.ExportFormat = .standard;
var i: usize = 0;
while (i < args.len) : (i += 1) {
const arg = std.mem.sliceTo(args[i], 0);
if (std.mem.eql(u8, arg, "--cpa")) {
if (format == .cpa) {
if (dest_path) |path| allocator.free(path);
return common.usageErrorResult(allocator, .export_auth, "duplicate `--cpa` for `export`.", .{});
}
format = .cpa;
} else if (common.isHelpFlag(arg)) {
if (dest_path) |path| allocator.free(path);
return common.usageErrorResult(allocator, .export_auth, "`--help` must be used by itself for `export`.", .{});
} else if (std.mem.startsWith(u8, arg, "-")) {
if (dest_path) |path| allocator.free(path);
return common.usageErrorResult(allocator, .export_auth, "unknown flag `{s}` for `export`.", .{arg});
} else {
if (dest_path != null) {
if (dest_path) |path| allocator.free(path);
return common.usageErrorResult(allocator, .export_auth, "unexpected extra path `{s}` for `export`.", .{arg});
}
dest_path = try allocator.dupe(u8, arg);
}
}

return .{ .command = .{ .export_auth = .{
.dest_path = dest_path,
.format = format,
} } };
}
6 changes: 6 additions & 0 deletions src/cli/commands/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const common = @import("common.zig");
const clean = @import("clean.zig");
const config = @import("config.zig");
const daemon = @import("daemon.zig");
const export_auth = @import("export.zig");
const import_auth = @import("import.zig");
const list = @import("list.zig");
const login = @import("login.zig");
Expand Down Expand Up @@ -41,6 +42,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !type
if (std.mem.eql(u8, cmd, "list")) return list.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "login")) return login.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "import")) return import_auth.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "export")) return export_auth.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "switch")) return switch_account.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "remove")) return remove.parse(allocator, args[2..]);
if (std.mem.eql(u8, cmd, "clean")) return clean.parse(allocator, args[2..]);
Expand All @@ -62,6 +64,9 @@ pub fn freeParseResult(allocator: std.mem.Allocator, result: *types.ParseResult)
fn freeCommand(allocator: std.mem.Allocator, cmd: *types.Command) void {
switch (cmd.*) {
.import_auth => |opts| common.freeImportOptions(allocator, opts.auth_path, opts.alias),
.export_auth => |opts| {
if (opts.dest_path) |path| allocator.free(path);
},
.switch_account => |opts| {
if (opts.query) |query| allocator.free(query);
},
Expand Down Expand Up @@ -92,6 +97,7 @@ fn helpTopicForName(name: []const u8) ?types.HelpTopic {
if (std.mem.eql(u8, name, "status")) return .status;
if (std.mem.eql(u8, name, "login")) return .login;
if (std.mem.eql(u8, name, "import")) return .import_auth;
if (std.mem.eql(u8, name, "export")) return .export_auth;
if (std.mem.eql(u8, name, "switch")) return .switch_account;
if (std.mem.eql(u8, name, "remove")) return .remove_account;
if (std.mem.eql(u8, name, "clean")) return .clean;
Expand Down
21 changes: 19 additions & 2 deletions src/cli/help.zig
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pub fn writeHelp(
try writeCommandDetail(out, use_color, "import <path> [--alias <alias>]");
try writeCommandDetail(out, use_color, "import --cpa [<path>] [--alias <alias>]");
try writeCommandDetail(out, use_color, "import --purge [<path>]");
try writeCommandSummary(out, use_color, "export [<dir>] [--cpa]", "Export stored account auth files");
try writeCommandSummary(out, use_color, "switch", "Switch the active account");
try writeCommandDetail(out, use_color, "switch [--live] [--api|--skip-api]");
try writeCommandDetail(out, use_color, "switch <alias|email|display-number|query>");
Expand Down Expand Up @@ -156,6 +157,7 @@ fn commandNameForTopic(topic: HelpTopic) []const u8 {
.status => "status",
.login => "login",
.import_auth => "import",
.export_auth => "export",
.switch_account => "switch",
.remove_account => "remove",
.clean => "clean",
Expand All @@ -171,6 +173,7 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 {
.status => "Show auto-switch, service, and usage API status.",
.login => "Run `codex login` or `codex login --device-auth`, then add the current account.",
.import_auth => "Import auth files or rebuild the registry.",
.export_auth => "Export stored account auth files.",
.switch_account => "Switch the active account by alias, email, display number, or partial query.",
.remove_account => "Remove one or more accounts by alias, email, display number, or partial query.",
.clean => "Delete backup and stale files under accounts/.",
Expand All @@ -181,14 +184,14 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 {

fn commandHelpHasExamples(topic: HelpTopic) bool {
return switch (topic) {
.import_auth, .switch_account, .remove_account, .config, .daemon => true,
.import_auth, .export_auth, .switch_account, .remove_account, .config, .daemon => true,
else => false,
};
}

fn commandHelpHasOptions(topic: HelpTopic) bool {
return switch (topic) {
.list, .login, .import_auth, .switch_account, .remove_account, .config, .daemon => true,
.list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .config, .daemon => true,
else => false,
};
}
Expand Down Expand Up @@ -227,6 +230,10 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void {
try out.writeAll(" codex-auth import --cpa [<path>] [--alias <alias>]\n");
try out.writeAll(" codex-auth import --purge [<path>]\n");
},
.export_auth => {
try out.writeAll(" codex-auth export [<dir>]\n");
try out.writeAll(" codex-auth export --cpa [<dir>]\n");
},
.switch_account => {
try out.writeAll(" codex-auth switch [--live] [--api|--skip-api]\n");
try out.writeAll(" codex-auth switch <alias|email|display-number|query>\n");
Expand Down Expand Up @@ -260,6 +267,7 @@ pub fn helpCommandForTopic(topic: HelpTopic) []const u8 {
.status => "codex-auth status --help",
.login => "codex-auth login --help",
.import_auth => "codex-auth import --help",
.export_auth => "codex-auth export --help",
.switch_account => "codex-auth switch --help",
.remove_account => "codex-auth remove --help",
.clean => "codex-auth clean --help",
Expand Down Expand Up @@ -290,6 +298,10 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void {
try out.writeAll(" --alias <alias> Set an alias for a single imported account.\n");
try out.writeAll(" --purge [<path>] Rebuild `registry.json` from auth files. Uses the accounts directory when omitted.\n");
},
.export_auth => {
try out.writeAll(" <dir> Directory to write exported account files. Uses `CODEX_HOME/accounts/backup` when omitted.\n");
try out.writeAll(" --cpa Export CPA flat token JSON. Without this, exports Codex auth snapshots.\n");
},
.switch_account => {
try out.writeAll(" --live Open the live switch UI.\n");
try out.writeAll(" --api Load usage and account data from APIs.\n");
Expand Down Expand Up @@ -353,6 +365,11 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void {
try out.writeAll(" codex-auth import --cpa /path/to/token.json --alias work\n");
try out.writeAll(" codex-auth import --purge\n");
},
.export_auth => {
try out.writeAll(" codex-auth export\n");
try out.writeAll(" codex-auth export /path/to/backup\n");
try out.writeAll(" codex-auth export --cpa /path/to/cpa-backup\n");
},
.switch_account => {
try out.writeAll(" codex-auth switch\n");
try out.writeAll(" codex-auth switch --live\n");
Expand Down
7 changes: 7 additions & 0 deletions src/cli/types.zig
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ pub const ImportOptions = struct {
purge: bool,
source: ImportSource,
};
pub const ExportFormat = enum { standard, cpa };
pub const ExportOptions = struct {
dest_path: ?[]u8,
format: ExportFormat,
};
pub const SwitchOptions = struct {
query: ?[]u8,
live: bool = false,
Expand Down Expand Up @@ -56,6 +61,7 @@ pub const HelpTopic = enum {
status,
login,
import_auth,
export_auth,
switch_account,
remove_account,
clean,
Expand All @@ -67,6 +73,7 @@ pub const Command = union(enum) {
list: ListOptions,
login: LoginOptions,
import_auth: ImportOptions,
export_auth: ExportOptions,
switch_account: SwitchOptions,
remove_account: RemoveOptions,
clean: CleanOptions,
Expand Down
90 changes: 90 additions & 0 deletions src/registry/export.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
const std = @import("std");
const app_runtime = @import("../core/runtime.zig");
const auth = @import("../auth/auth.zig");
const common = @import("common.zig");

const Registry = common.Registry;
const accountAuthPath = common.accountAuthPath;
const accountSnapshotFileName = common.accountSnapshotFileName;
const copyManagedFile = common.copyManagedFile;
const ensurePrivateDir = common.ensurePrivateDir;
const readFileAlloc = common.readFileAlloc;
const writeFile = common.writeFile;

pub const ExportFormat = enum { standard, cpa };

pub const ExportSummary = struct {
dest_path: []u8,
exported: usize,

pub fn deinit(self: *ExportSummary, allocator: std.mem.Allocator) void {
allocator.free(self.dest_path);
}
};

pub fn defaultExportDirectory(allocator: std.mem.Allocator, codex_home: []const u8) ![]u8 {
return try std.fs.path.join(allocator, &[_][]const u8{ codex_home, "accounts", "backup" });
}

pub fn exportAccounts(
allocator: std.mem.Allocator,
codex_home: []const u8,
reg: *const Registry,
maybe_dest_path: ?[]const u8,
format: ExportFormat,
) !ExportSummary {
const dest_path = if (maybe_dest_path) |path|
try allocator.dupe(u8, path)
else
try defaultExportDirectory(allocator, codex_home);
errdefer allocator.free(dest_path);

try ensurePrivateDir(dest_path);

var exported: usize = 0;
for (reg.accounts.items) |rec| {
const src = try accountAuthPath(allocator, codex_home, rec.account_key);
defer allocator.free(src);

const base_name = try accountSnapshotFileName(allocator, rec.account_key);
defer allocator.free(base_name);

const dest_name = switch (format) {
.standard => try allocator.dupe(u8, base_name),
.cpa => try cpaExportFileName(allocator, base_name),
};
defer allocator.free(dest_name);

const dest = try std.fs.path.join(allocator, &[_][]const u8{ dest_path, dest_name });
defer allocator.free(dest);

switch (format) {
.standard => try copyManagedFile(src, dest),
.cpa => try exportCpaFile(allocator, src, dest),
}
exported += 1;
}

return .{
.dest_path = dest_path,
.exported = exported,
};
}

fn cpaExportFileName(allocator: std.mem.Allocator, auth_name: []const u8) ![]u8 {
if (std.mem.endsWith(u8, auth_name, ".auth.json")) {
return try std.mem.concat(allocator, u8, &[_][]const u8{ auth_name[0 .. auth_name.len - ".auth.json".len], ".json" });
}
return try std.mem.concat(allocator, u8, &[_][]const u8{ auth_name, ".json" });
}

fn exportCpaFile(allocator: std.mem.Allocator, src: []const u8, dest: []const u8) !void {
var file = try std.Io.Dir.cwd().openFile(app_runtime.io(), src, .{});
defer file.close(app_runtime.io());
const data = try readFileAlloc(file, allocator, 10 * 1024 * 1024);
defer allocator.free(data);

const converted = try auth.convertStandardAuthJsonToCpa(allocator, data);
defer allocator.free(converted);
try writeFile(dest, converted);
}
Loading