Skip to content
Closed
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 CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ set(RCLI_SOURCES
src/commands/cmd_info.cpp
src/commands/cmd_auth.cpp
src/commands/cmd_account.cpp
src/commands/cmd_opencode.cpp
src/commands/cmd_backends.cpp
src/commands/cmd_list.cpp
src/commands/cmd_lora.cpp
Expand All @@ -50,6 +51,7 @@ set(RCLI_SOURCES
src/commands/cmd_bench.cpp
src/account/console.cpp
src/account/credentials.cpp
src/harness/opencode.cpp
src/commands/engine_options.cpp
src/commands/model_setup.cpp
src/config/cli_paths.cpp
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ console can be selected explicitly with
`RCLI_CONSOLE_URL=http://localhost:<port>` or `--console-url`; non-loopback HTTP
origins are rejected.

With OpenCode installed, start an explicitly hosted coding session with a
console model id:

```bash
rcli opencode --cloud --model <console-model-id>
```

Pass OpenCode's own arguments after `--`, for example
`rcli opencode --cloud --model <console-model-id> -- run`. RCLI supplies an
OpenAI-compatible `<console-origin>/v1` provider through
`OPENCODE_CONFIG_CONTENT` only while OpenCode runs. It never edits project or
user tool configuration and restores an existing environment value afterward.

The cloud session is separate from `rcli auth`, which configures the on-device
SDK/control-plane connection. Cloud login does not require
`RUNANYWHERE_API_KEY`. On macOS and Linux the session file is stored under a
Expand Down
1 change: 1 addition & 0 deletions src/app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ void configure_app(CLI::App& app, GlobalOptions& options) {
commands::register_version(app, options);
commands::register_auth(app, options);
commands::register_account(app, options);
commands::register_opencode(app, options);
commands::register_telemetry(app, options);
}

Expand Down
38 changes: 38 additions & 0 deletions src/commands/cmd_opencode.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#include <memory>
#include <string>
#include <vector>

#include "commands/commands.h"
#include "harness/opencode.h"

namespace rcli::commands {
namespace {

void Fail(int status) {
if (status != 0) {
throw CLI::RuntimeError(status);
}
}

} // namespace

void register_opencode(CLI::App& app, GlobalOptions& options) {
static_cast<void>(options);
auto cloud = std::make_shared<bool>(false);
auto model = std::make_shared<std::string>();
auto arguments = std::make_shared<std::vector<std::string>>();

CLI::App* command = app.add_subcommand(
"opencode", "start OpenCode with an ephemeral RunAnywhere cloud provider");
command->add_flag("--cloud", *cloud, "use the signed-in hosted inference endpoint")->required();
command->add_option("-m,--model", *model, "hosted model id")->required();
command
->add_option("arguments", *arguments,
"arguments after `--` are passed directly to OpenCode")
->allow_extra_args();
command->positionals_at_end(true);
command->callback(
[cloud, model, arguments] { Fail(harness::LaunchOpenCodeCloud(*model, *arguments)); });
}

} // namespace rcli::commands
2 changes: 2 additions & 0 deletions src/commands/commands.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ void register_bench(CLI::App& app, GlobalOptions& options);
void register_auth(CLI::App& app, GlobalOptions& options);
// Browser-approved cloud session. Separate from SDK/device `auth` credentials.
void register_account(CLI::App& app, GlobalOptions& options);
// Explicit hosted coding session. Does not bootstrap the local SDK or write tool config.
void register_opencode(CLI::App& app, GlobalOptions& options);
void register_telemetry(CLI::App& app, GlobalOptions& options);

/**
Expand Down
225 changes: 225 additions & 0 deletions src/harness/opencode.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
#include "harness/opencode.h"

#include <cerrno>
#include <chrono>
#include <cstdlib>
#include <nlohmann/json.hpp>
#include <string>
#include <utility>
#include <vector>

#if defined(_WIN32)
#include <process.h>
#else
#include <unistd.h>

#include <sys/wait.h>
#endif

#include "account/credentials.h"
#include "io/output.h"

namespace rcli::harness {
namespace {

constexpr const char* kOpenCodeConfigVariable = "OPENCODE_CONFIG_CONTENT";

long long EpochSeconds() {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
}

bool ModelIsSafe(const std::string& model) {
if (model.empty() || model.size() > 512) {
return false;
}
for (const unsigned char byte : model) {
if (byte < 0x20 || byte == 0x7f) {
return false;
}
}
return true;
}

bool SetEnvironment(const char* name, const std::string& value) {
#if defined(_WIN32)
return _putenv_s(name, value.c_str()) == 0;
#else
return setenv(name, value.c_str(), 1) == 0;
#endif
}

bool UnsetEnvironment(const char* name) {
#if defined(_WIN32)
return _putenv_s(name, "") == 0;
#else
return unsetenv(name) == 0;
#endif
}

class ScopedOpenCodeConfig {
public:
ScopedOpenCodeConfig() {
const char* previous = std::getenv(kOpenCodeConfigVariable);
if (previous != nullptr) {
had_previous_ = true;
previous_ = previous;
}
}

ScopedOpenCodeConfig(const ScopedOpenCodeConfig&) = delete;
ScopedOpenCodeConfig& operator=(const ScopedOpenCodeConfig&) = delete;

~ScopedOpenCodeConfig() {
if (!active_) {
return;
}
if (had_previous_) {
static_cast<void>(SetEnvironment(kOpenCodeConfigVariable, previous_));
} else {
static_cast<void>(UnsetEnvironment(kOpenCodeConfigVariable));
}
}

bool Activate(const std::string& value) {
active_ = SetEnvironment(kOpenCodeConfigVariable, value);
return active_;
}

private:
std::string previous_;
bool had_previous_ = false;
bool active_ = false;
};

int Spawn(const std::string& executable, const std::vector<std::string>& arguments) {
std::vector<std::string> owned;
owned.reserve(arguments.size() + 1);
owned.push_back(executable);
owned.insert(owned.end(), arguments.begin(), arguments.end());

std::vector<char*> argv;
argv.reserve(owned.size() + 1);
for (std::string& value : owned) {
argv.push_back(value.data());
}
argv.push_back(nullptr);

#if defined(_WIN32)
const intptr_t status = _spawnvp(_P_WAIT, executable.c_str(), argv.data());
if (status < 0) {
out::error_line("OpenCode was not found on PATH");
return 127;
}
return static_cast<int>(status);
#else
const pid_t child = fork();
if (child < 0) {
out::error_line("could not start OpenCode");
return 1;
}
if (child == 0) {
execvp(executable.c_str(), argv.data());
_exit(127);
}

int status = 0;
while (waitpid(child, &status, 0) < 0) {
if (errno == EINTR) {
continue;
}
out::error_line("lost track of OpenCode");
return 1;
}
if (!WIFEXITED(status)) {
return 1;
}
const int exit_code = WEXITSTATUS(status);
if (exit_code == 127) {
out::error_line("OpenCode was not found on PATH");
}
return exit_code;
#endif
}

bool RefreshCredentials(const account::ConsoleClient& console, account::Credentials* credentials,
std::string* error) {
if (credentials->refresh_token.empty()) {
if (error != nullptr) {
*error = "the cloud session cannot be refreshed; run `rcli login`";
}
return false;
}

account::Grant grant;
if (!console.Refresh(credentials->console_url, credentials->refresh_token, &grant, error)) {
return false;
}
credentials->access_token = grant.access_token;
if (!grant.refresh_token.empty()) {
credentials->refresh_token = grant.refresh_token;
}
if (!grant.email.empty()) {
credentials->email = grant.email;
}
credentials->expires_at = EpochSeconds() + (grant.expires_in > 0 ? grant.expires_in : 3600);
return account::Save(*credentials, error);
}

} // namespace

std::string BuildOpenCodeCloudConfig(const std::string& model, const std::string& base_url,
const std::string& access_token) {
using Json = nlohmann::json;
const Json provider = {
{"npm", "@ai-sdk/openai-compatible"},
{"name", "RunAnywhere"},
{"options", {{"baseURL", base_url}, {"apiKey", access_token}}},
{"models", {{model, {{"name", model}}}}},
};
return Json{{"provider", {{"runanywhere", provider}}}, {"model", "runanywhere/" + model}}
.dump();
}

int LaunchOpenCodeCloud(const std::string& model, const std::vector<std::string>& arguments,
const account::ConsoleClient& console, const SpawnFunction& spawn) {
if (!ModelIsSafe(model)) {
out::error_line("a non-empty cloud model name without control characters is required");
return 2;
}

account::Credentials credentials;
std::string error;
if (!account::Load(&credentials, &error)) {
out::error_line(error);
return 1;
}
if (!credentials.signed_in()) {
out::error_line("not signed in - run `rcli login`");
return 1;
}
if (credentials.access_token_expired(EpochSeconds()) &&
!RefreshCredentials(console, &credentials, &error)) {
out::error_line(error);
return 1;
}

const std::string base_url = credentials.console_url + "/v1";
const std::string config = BuildOpenCodeCloudConfig(model, base_url, credentials.access_token);
ScopedOpenCodeConfig environment;
if (!environment.Activate(config)) {
out::error_line("could not set the temporary OpenCode configuration");
return 1;
}

out::status_line("launching OpenCode with the RunAnywhere cloud session");
return spawn("opencode", arguments);
}

int LaunchOpenCodeCloud(const std::string& model, const std::vector<std::string>& arguments) {
const account::ConsoleClient console;
return LaunchOpenCodeCloud(model, arguments, console, Spawn);
}

} // namespace rcli::harness
33 changes: 33 additions & 0 deletions src/harness/opencode.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#ifndef RCLI_HARNESS_OPENCODE_H
#define RCLI_HARNESS_OPENCODE_H

#include <functional>
#include <string>
#include <vector>

#include "account/console.h"

namespace rcli::harness {

using SpawnFunction =
std::function<int(const std::string& executable, const std::vector<std::string>& arguments)>;

/// OpenCode's complete, ephemeral provider configuration for a hosted model.
/// Exposed so the contract can be tested without launching a child process.
std::string BuildOpenCodeCloudConfig(const std::string& model, const std::string& base_url,
const std::string& access_token);

/// Launch OpenCode against the signed-in RunAnywhere cloud session.
///
/// Only OPENCODE_CONFIG_CONTENT is changed, only for the duration of the child.
/// No OpenCode or project configuration file is read or written. The default
/// overload starts `opencode` directly (never through a shell).
int LaunchOpenCodeCloud(const std::string& model, const std::vector<std::string>& arguments);

/// Test seam for the console refresh transport and child process.
int LaunchOpenCodeCloud(const std::string& model, const std::vector<std::string>& arguments,
const account::ConsoleClient& console, const SpawnFunction& spawn);

} // namespace rcli::harness

#endif // RCLI_HARNESS_OPENCODE_H
6 changes: 6 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ target_link_libraries(test_rcli_account PRIVATE rcli_core)
rcli_stage_windows_runtime_dlls(test_rcli_account)
add_test(NAME rcli_account_tests COMMAND test_rcli_account --run-all)

add_executable(test_rcli_opencode test_rcli_opencode.cpp)
target_include_directories(test_rcli_opencode PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(test_rcli_opencode PRIVATE rcli_core)
rcli_stage_windows_runtime_dlls(test_rcli_opencode)
add_test(NAME rcli_opencode_tests COMMAND test_rcli_opencode --run-all)

find_package(Python3 REQUIRED COMPONENTS Interpreter)
add_test(
NAME rcli_account_cli_e2e
Expand Down
Loading