From f1ea5d241b7d5107449ba3f2a047b3419c4f9adb Mon Sep 17 00:00:00 2001
From: Xiaoxukuaipao <2812156857@qq.com>
Date: Thu, 14 May 2026 21:37:25 -0700
Subject: [PATCH] first commit
---
.agents/plugins/marketplace.json | 20 +
.gitignore | 48 ++
CHANGELOG.md | 13 +
LICENSE | 21 +
README.md | 272 +++++++
docs/design.md | 29 +
docs/images/autodl-remote-boundary.svg | 62 ++
docs/images/autodl-remote-hero.svg | 79 ++
docs/images/autodl-remote-workflow.svg | 68 ++
docs/troubleshooting.md | 75 ++
.../autodl-remote/.codex-plugin/plugin.json | 42 +
plugins/autodl-remote/README.md | 154 ++++
plugins/autodl-remote/bin/autodl-remote | 748 ++++++++++++++++++
plugins/autodl-remote/config/example.conf | 2 +
.../skills/autodl-remote/SKILL.md | 63 ++
scripts/install-cli.sh | 19 +
16 files changed, 1715 insertions(+)
create mode 100644 .agents/plugins/marketplace.json
create mode 100644 .gitignore
create mode 100644 CHANGELOG.md
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 docs/design.md
create mode 100644 docs/images/autodl-remote-boundary.svg
create mode 100644 docs/images/autodl-remote-hero.svg
create mode 100644 docs/images/autodl-remote-workflow.svg
create mode 100644 docs/troubleshooting.md
create mode 100644 plugins/autodl-remote/.codex-plugin/plugin.json
create mode 100644 plugins/autodl-remote/README.md
create mode 100755 plugins/autodl-remote/bin/autodl-remote
create mode 100644 plugins/autodl-remote/config/example.conf
create mode 100644 plugins/autodl-remote/skills/autodl-remote/SKILL.md
create mode 100755 scripts/install-cli.sh
diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
new file mode 100644
index 0000000..767349e
--- /dev/null
+++ b/.agents/plugins/marketplace.json
@@ -0,0 +1,20 @@
+{
+ "name": "autodl-remote-local-marketplace",
+ "interface": {
+ "displayName": "AutoDL Remote Marketplace"
+ },
+ "plugins": [
+ {
+ "name": "autodl-remote",
+ "source": {
+ "source": "local",
+ "path": "./plugins/autodl-remote"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_USE"
+ },
+ "category": "Developer Tools"
+ }
+ ]
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..efa3210
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,48 @@
+.DS_Store
+.codex/
+.autodl-remote/
+.autodl-remote.conf
+*.log
+*.tmp
+
+# Python and Node scratch
+__pycache__/
+*.pyc
+node_modules/
+.venv/
+venv/
+env/
+
+# ML artifacts that should not be published
+data/
+dataset/
+datasets/
+checkpoints/
+checkpoint/
+ckpt/
+weights/
+pretrained/
+models/
+outputs/
+output/
+results/
+result/
+runs/
+wandb/
+mlruns/
+*.pt
+*.pth
+*.ckpt
+*.safetensors
+*.bin
+*.onnx
+*.gguf
+*.h5
+
+# Local secrets
+.env
+.env.*
+*.pem
+*.key
+id_rsa
+id_ed25519
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0ce50f3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,13 @@
+# Changelog
+
+## 0.5.0
+
+Initial public package.
+
+- Added Bash-only `autodl-remote` CLI.
+- Added Codex plugin metadata and skill instructions.
+- Added repo-local marketplace manifest.
+- Added SSH account management.
+- Added explicit `put`, `get`, `sync-up`, and `sync-down` file operations.
+- Added remote `exec`, `tree`, `ls`, `cat`, `tail`, and `shell` commands.
+- Removed earlier source-of-truth modes, manifest tracking, and automatic sync behavior.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0f8dfab
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Zhuanz
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3816d1a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,272 @@
+
+ 中文 | English planned
+
+
+
+

+
AutoDL Remote
+
让 Codex 留在本地,让 GPU 在远端跑
+
一个面向 Codex App 的极简 SSH 插件:账号管理、远端命令、显式上传和下载。
+
+
+
+
+
+
+
+
+
+---
+
+## 🎯 项目介绍
+
+ AutoDL Remote 的目标很直接:**Codex 在本地写代码,远端 GPU 机器负责运行代码;需要什么文件,就显式上传或下载什么文件。**
+
+ 做 LLM、深度学习、论文复现实验时,模型权重、数据集、checkpoint 往往都在 AutoDL 这类远端 GPU 环境里。本地电脑不适合下载大模型,直接在远端安装和登录 Codex 又容易遇到代理、浏览器登录、环境配置等问题。
+
+ 这个插件选择更简单的路线:远端不需要安装 daemon,不需要运行服务,只要能 SSH 登录即可。Codex 仍然在本地工作,本地保留代码修改痕迹和 Git diff;远端负责执行训练、推理和查看日志。
+
+> **一句话理解:** AutoDL Remote 不是同步框架,而是给 Codex 用的一组 SSH 原语。
+
+## ✨ 核心特性
+
+| 能力 | 说明 |
+| --- | --- |
+| 🔐 SSH 账号管理 | 支持保存多个远端账号,项目里只引用账号名,不写密码 |
+| 📁 项目绑定 | 当前本地目录绑定到一个具体远端目录,例如 `/root/autodl-tmp/my-project` |
+| 🖥️ 远端命令 | 用 `exec` 在远端运行训练、推理、环境检查等命令 |
+| ⬆️ 显式上传 | 用 `put` 或 `sync-up` 把本地改过的文件送到远端 |
+| ⬇️ 显式下载 | 用 `get` 或 `sync-down` 只拉回需要查看或提交的结果 |
+| 📜 日志查看 | 用 `tail` 查看远端训练日志,不必下载大文件 |
+| 🧩 Codex 友好 | 插件不替你判断同步策略,把决策权留给用户和 Codex |
+
+## 🧠 工作原理
+
+
+

+
+
+ 典型流程是:先查看远端项目结构,再把需要编辑的脚本拉到本地;Codex 在本地修改后,显式上传对应文件;训练或推理在远端执行;最后只查看日志或拉回必要结果。
+
+
+

+
+
+ 这个边界很重要:**本地负责代码和修改痕迹,远端负责大模型、数据集、checkpoint 和 GPU 任务。** 插件不会默认下载模型权重,也不会默认把整个远端目录同步回来。
+
+## 📚 目录导航
+
+| 文档 | 内容 |
+| --- | --- |
+| [插件目录](./plugins/autodl-remote) | Codex App 插件源码 |
+| [插件说明](./plugins/autodl-remote/README.md) | CLI 命令和插件内部说明 |
+| [设计说明](./docs/design.md) | 为什么做成极简 SSH 工具层 |
+| [故障排查](./docs/troubleshooting.md) | 安装、SSH、路径、权限等问题 |
+| [更新记录](./CHANGELOG.md) | 版本变化 |
+
+## 🚀 快速开始
+
+### 1. 在 Codex App 里安装
+
+1. Clone 这个仓库。
+2. 打开 Codex App。
+3. 进入 `插件` 页面。
+4. 打开插件来源下拉框,选择 `+ 添加更多`。
+5. 选择这个仓库的根目录。
+
+应该选择:
+
+```text
+/path/to/autodl-remote
+```
+
+不要选择:
+
+```text
+/path/to/autodl-remote/plugins/autodl-remote
+```
+
+Codex App 会在仓库根目录下读取:
+
+```text
+.agents/plugins/marketplace.json
+```
+
+添加 marketplace 后,在插件列表里选择 `AutoDL Remote` 并安装/启用。
+
+### 2. 安装 CLI
+
+```bash
+./scripts/install-cli.sh
+```
+
+默认会创建软链接:
+
+```text
+/opt/homebrew/bin/autodl-remote
+```
+
+也可以指定安装目录:
+
+```bash
+./scripts/install-cli.sh ~/.local/bin
+```
+
+### 3. 添加远端账号
+
+密码登录:
+
+```bash
+autodl-remote account add autodl-gpu \
+ --target root@connect.example.com \
+ --port 2222 \
+ --auth prompt \
+ --default-remote /root/autodl-tmp
+```
+
+SSH key 登录:
+
+```bash
+autodl-remote account add gpu-key \
+ --target root@host \
+ --port 22 \
+ --key ~/.ssh/id_rsa \
+ --auth ssh-key
+```
+
+查看和选择账号:
+
+```bash
+autodl-remote account list
+autodl-remote account use autodl-gpu
+autodl-remote account test autodl-gpu
+```
+
+### 4. 绑定当前项目
+
+```bash
+cd /path/to/local/project
+autodl-remote bind --account autodl-gpu --remote /root/autodl-tmp/my-project
+autodl-remote doctor
+```
+
+项目配置会写入当前目录:
+
+```bash
+ACCOUNT="autodl-gpu"
+REMOTE_ROOT="/root/autodl-tmp/my-project"
+```
+
+密码不会写入项目配置。
+
+## 🛠️ 常用命令
+
+| 命令 | 用途 |
+| --- | --- |
+| `autodl-remote doctor` | 检查当前项目绑定、SSH 连通性、远端目录 |
+| `autodl-remote tree . --depth 2` | 查看远端目录结构 |
+| `autodl-remote ls .` | 查看远端目录 |
+| `autodl-remote cat -- train.py` | 查看远端文件内容 |
+| `autodl-remote get train.py train.py` | 把远端文件拉到本地 |
+| `autodl-remote put train.py train.py` | 把本地文件上传到远端 |
+| `autodl-remote sync-up ./src src` | 上传本地目录 |
+| `autodl-remote sync-down outputs outputs` | 下载远端目录 |
+| `autodl-remote exec -- python train.py` | 在远端执行命令 |
+| `autodl-remote exec --detach --name train -- python train.py` | 远端后台运行长任务 |
+| `autodl-remote tail -- .autodl-remote/logs/train.log` | 查看远端日志 |
+
+## 🧪 典型场景
+
+### 远端已有项目
+
+```bash
+autodl-remote tree . --depth 2
+autodl-remote cat -- train.py
+autodl-remote get train.py train.py
+
+# Codex 在本地修改 train.py
+autodl-remote put train.py train.py
+autodl-remote exec -- python train.py
+```
+
+### 本地已有项目
+
+```bash
+autodl-remote bind --account autodl-gpu --remote /root/autodl-tmp/my-project
+autodl-remote sync-up ./src src
+autodl-remote put train.py train.py
+autodl-remote exec -- python train.py
+```
+
+### 远端训练任务
+
+```bash
+autodl-remote exec --detach --name train -- python train.py
+autodl-remote tail -- .autodl-remote/logs/train.log
+autodl-remote cat -- outputs/metrics.json
+autodl-remote get outputs/metrics.json outputs/metrics.json
+```
+
+## ✅ 它不做什么
+
+AutoDL Remote 保持克制,不做这些事:
+
+- 不判断本地或远端谁是 source of truth;
+- 不维护 manifest;
+- 不自动拉取完整远端项目;
+- 不在每次运行前自动推送整个本地项目;
+- 不规定你的项目结构;
+- 不默认下载模型权重、数据集、checkpoint 或大型输出。
+
+这些决策交给用户和 Codex,因为不同项目的代码组织方式差异很大。
+
+## 🔐 安全与隐私
+
+- 账号配置保存在 `~/.autodl-remote/accounts/`。
+- 项目只保存账号名和远端目录。
+- 密码不会写进项目文件。
+- 可选使用 macOS Keychain 保存密码:
+
+```bash
+autodl-remote account password-save autodl-gpu
+autodl-remote account password-delete autodl-gpu
+```
+
+网络流量只会发往你配置的 SSH 主机。远端命令和文件传输都由本地 CLI 显式触发。
+
+## 📦 仓库结构
+
+```text
+.
+├── .agents/plugins/marketplace.json
+├── plugins/autodl-remote/
+│ ├── .codex-plugin/plugin.json
+│ ├── bin/autodl-remote
+│ ├── config/example.conf
+│ ├── skills/autodl-remote/SKILL.md
+│ └── README.md
+├── docs/
+│ ├── design.md
+│ ├── troubleshooting.md
+│ └── images/
+├── scripts/install-cli.sh
+├── CHANGELOG.md
+├── .gitignore
+└── LICENSE
+```
+
+## 🤝 贡献
+
+欢迎提交 Issue 或 Pull Request。这个项目会优先保持轻量,不会加入复杂的自动同步策略。如果你想贡献新能力,建议先说明它是否仍然符合三个核心原语:
+
+- SSH 账号管理;
+- 远端命令执行;
+- 显式上传和下载。
+
+## 📜 License
+
+MIT
+
+
+
如果这个项目对你有帮助,欢迎给一个 Star。
+
diff --git a/docs/design.md b/docs/design.md
new file mode 100644
index 0000000..df116ff
--- /dev/null
+++ b/docs/design.md
@@ -0,0 +1,29 @@
+# Design
+
+AutoDL Remote is intentionally small.
+
+It provides:
+
+- SSH account management,
+- project-to-remote-directory binding,
+- remote command execution,
+- explicit file upload and download.
+
+It avoids:
+
+- source-of-truth decisions,
+- automatic bidirectional sync,
+- manifest tracking,
+- project skeleton inference,
+- remote daemons,
+- remote Codex installation.
+
+The intended workflow is:
+
+1. Inspect remote state.
+2. Pull only files needed for editing, or edit existing local files.
+3. Upload selected local changes.
+4. Run commands on the remote host.
+5. Inspect logs and small results remotely.
+
+This keeps Codex local while letting remote GPU hosts run heavy jobs.
diff --git a/docs/images/autodl-remote-boundary.svg b/docs/images/autodl-remote-boundary.svg
new file mode 100644
index 0000000..9018589
--- /dev/null
+++ b/docs/images/autodl-remote-boundary.svg
@@ -0,0 +1,62 @@
+
diff --git a/docs/images/autodl-remote-hero.svg b/docs/images/autodl-remote-hero.svg
new file mode 100644
index 0000000..31aad84
--- /dev/null
+++ b/docs/images/autodl-remote-hero.svg
@@ -0,0 +1,79 @@
+
diff --git a/docs/images/autodl-remote-workflow.svg b/docs/images/autodl-remote-workflow.svg
new file mode 100644
index 0000000..73f8335
--- /dev/null
+++ b/docs/images/autodl-remote-workflow.svg
@@ -0,0 +1,68 @@
+
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 0000000..52d12e1
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,75 @@
+# Troubleshooting
+
+## The Codex App Does Not Show The Plugin
+
+Make sure you add the repository root as a local marketplace:
+
+```text
+/path/to/autodl-remote
+```
+
+The root must contain:
+
+```text
+.agents/plugins/marketplace.json
+```
+
+Do not add:
+
+```text
+/path/to/autodl-remote/plugins/autodl-remote
+```
+
+After adding the marketplace, fully quit and restart Codex App.
+
+## The CLI Says `.autodl-remote.conf` Is Missing
+
+Bind the current local project to a remote project directory:
+
+```bash
+autodl-remote bind --account autodl-gpu --remote /root/autodl-tmp/my-project
+```
+
+The config should look like:
+
+```bash
+ACCOUNT="autodl-gpu"
+REMOTE_ROOT="/root/autodl-tmp/my-project"
+```
+
+## Old Config Files
+
+Older experimental versions used fields such as `PROJECT_MODE`, `SSH_TARGET`, and `AUTO_PUSH_BEFORE_RUN`.
+
+The current version does not read that format. Rename the old config and bind again:
+
+```bash
+mv .autodl-remote.conf .autodl-remote.conf.old
+autodl-remote bind --account autodl-gpu --remote /root/autodl-tmp/my-project
+```
+
+## Password Login Hangs Or Fails
+
+For password-based SSH, use prompt auth:
+
+```bash
+autodl-remote account add autodl-gpu --target root@host --port 2222 --auth prompt
+```
+
+If you need non-interactive password login on macOS, store the password in Keychain:
+
+```bash
+autodl-remote account password-save autodl-gpu
+```
+
+## Avoid Pulling Large Files
+
+Do not run broad `get` or `sync-down` commands on model, checkpoint, dataset, or output directories unless you really want those files locally.
+
+Prefer remote inspection:
+
+```bash
+autodl-remote tree . --depth 2
+autodl-remote tail -- logs/train.log
+autodl-remote cat -- results/metrics.json
+```
diff --git a/plugins/autodl-remote/.codex-plugin/plugin.json b/plugins/autodl-remote/.codex-plugin/plugin.json
new file mode 100644
index 0000000..905763d
--- /dev/null
+++ b/plugins/autodl-remote/.codex-plugin/plugin.json
@@ -0,0 +1,42 @@
+{
+ "name": "autodl-remote",
+ "version": "0.5.0",
+ "description": "Bash-only SSH account, command, upload, and download tool layer for AutoDL.",
+ "author": {
+ "name": "Zhuanz",
+ "email": "maintainer@example.com",
+ "url": "https://github.com/Zhuanz/autodl-remote"
+ },
+ "homepage": "https://github.com/Zhuanz/autodl-remote",
+ "repository": "https://github.com/Zhuanz/autodl-remote",
+ "license": "MIT",
+ "keywords": [
+ "autodl",
+ "ssh",
+ "remote-execution",
+ "rsync",
+ "codex"
+ ],
+ "skills": "./skills/",
+ "interface": {
+ "displayName": "AutoDL Remote",
+ "shortDescription": "Control AutoDL over SSH from local Codex.",
+ "longDescription": "Provides SSH account management, remote command execution, and explicit file upload/download primitives. Codex decides what to inspect, push, pull, and run.",
+ "developerName": "Zhuanz",
+ "category": "Developer Tools",
+ "capabilities": [
+ "Read",
+ "Write",
+ "Interactive"
+ ],
+ "websiteURL": "https://github.com/Zhuanz/autodl-remote",
+ "privacyPolicyURL": "https://github.com/Zhuanz/autodl-remote#privacy",
+ "termsOfServiceURL": "https://github.com/Zhuanz/autodl-remote#license",
+ "defaultPrompt": [
+ "Use AutoDL Remote to inspect my remote project.",
+ "Upload this edited file to AutoDL and run training.",
+ "Tail the latest AutoDL training log."
+ ],
+ "brandColor": "#2563EB"
+ }
+}
diff --git a/plugins/autodl-remote/README.md b/plugins/autodl-remote/README.md
new file mode 100644
index 0000000..87e9323
--- /dev/null
+++ b/plugins/autodl-remote/README.md
@@ -0,0 +1,154 @@
+# AutoDL Remote
+
+AutoDL Remote is a thin SSH tool layer for Codex.
+
+It does not decide whether local code or remote code is the source of truth. Codex and the user decide what to inspect, upload, download, and run.
+
+The remote server does not need Codex, Python, a proxy, an OpenAI login, or a daemon.
+
+## Model
+
+The plugin has three primitives:
+
+- SSH account management
+- Remote command execution
+- File upload/download
+
+Global account config lives in:
+
+```bash
+~/.autodl-remote/accounts/
+```
+
+Project binding lives in the project root:
+
+```bash
+.autodl-remote.conf
+```
+
+The project file only stores:
+
+```bash
+ACCOUNT="autodl-gpu"
+REMOTE_ROOT="/root/autodl-tmp/my-project"
+```
+
+Passwords are never written to project files.
+
+## Quick Start
+
+Create or choose an account:
+
+```bash
+autodl-remote account add autodl-gpu \
+ --target root@connect.example.com \
+ --port 2222 \
+ --auth prompt \
+ --default-remote /root/autodl-tmp
+
+autodl-remote account use autodl-gpu
+autodl-remote account list
+```
+
+Bind the current local directory to a concrete remote directory:
+
+```bash
+autodl-remote bind --remote /root/autodl-tmp/my-project
+```
+
+Inspect and run:
+
+```bash
+autodl-remote doctor
+autodl-remote tree . --depth 2
+autodl-remote exec -- pwd
+autodl-remote exec -- python train.py
+autodl-remote exec --detach --name train -- python train.py
+autodl-remote tail -- .autodl-remote/logs/train.log
+```
+
+Move files only when needed:
+
+```bash
+autodl-remote put ./train.py train.py
+autodl-remote get outputs/result.csv ./outputs/result.csv
+autodl-remote sync-up ./src src
+autodl-remote sync-down logs/train.log ./logs/train.log
+```
+
+## Accounts
+
+List accounts:
+
+```bash
+autodl-remote account list
+```
+
+Show an account without exposing passwords:
+
+```bash
+autodl-remote account show autodl-gpu
+```
+
+Set the default account:
+
+```bash
+autodl-remote account use autodl-gpu
+```
+
+Password login is the default for AutoDL:
+
+```bash
+autodl-remote account add autodl-gpu --target root@host --port 2222 --auth prompt
+```
+
+SSH key login:
+
+```bash
+autodl-remote account add gpu-key --target root@host --port 22 --key ~/.ssh/id_rsa --auth ssh-key
+```
+
+Optional macOS Keychain password storage:
+
+```bash
+autodl-remote account password-save autodl-gpu
+```
+
+When an account uses `AUTH="keychain"`, the CLI retrieves the password from macOS Keychain and drives password prompts with `expect`.
+
+## Commands
+
+```bash
+autodl-remote account add [--target root@host] [--port 22] [--key path] [--auth prompt|keychain|ssh-key] [--default-remote path]
+autodl-remote account list
+autodl-remote account show
+autodl-remote account use
+autodl-remote account test [name]
+autodl-remote account password-save
+autodl-remote account password-delete
+
+autodl-remote bind [--account name] --remote /remote/project/root
+autodl-remote doctor
+
+autodl-remote exec [--detach] [--cwd path] [--name name] --
+autodl-remote put [remote-path]
+autodl-remote get [local-path]
+autodl-remote sync-up [remote-path]
+autodl-remote sync-down [local-path]
+autodl-remote tree [remote-path] [--depth 3] [--limit 500]
+autodl-remote ls [remote-path]
+autodl-remote cat [--lines 200] --
+autodl-remote tail [--lines 120] [--follow] --
+autodl-remote shell
+autodl-remote history
+```
+
+## Design Rules
+
+- No `local-main` or `remote-main`.
+- No manifest.
+- No automatic code ownership decision.
+- No automatic pull before edit.
+- No automatic push before run.
+- Use `put/get/sync-up/sync-down` explicitly.
+- Keep large models, datasets, checkpoints, and training outputs on AutoDL unless the user explicitly pulls them.
diff --git a/plugins/autodl-remote/bin/autodl-remote b/plugins/autodl-remote/bin/autodl-remote
new file mode 100755
index 0000000..2de0708
--- /dev/null
+++ b/plugins/autodl-remote/bin/autodl-remote
@@ -0,0 +1,748 @@
+#!/usr/bin/env bash
+set -u
+
+PROGRAM="autodl-remote"
+APP_DIR="$HOME/.autodl-remote"
+ACCOUNTS_DIR="$APP_DIR/accounts"
+DEFAULT_ACCOUNT_FILE="$APP_DIR/default-account"
+CONFIG_FILE=".autodl-remote.conf"
+HISTORY_DIR=".autodl-remote"
+KEYCHAIN_SERVICE="autodl-remote"
+
+die() {
+ printf '%s: %s\n' "$PROGRAM" "$*" >&2
+ exit 2
+}
+
+info() {
+ printf '%s\n' "$*"
+}
+
+usage() {
+ cat <<'EOF'
+usage: autodl-remote [options]
+
+account:
+ account add [--target root@host | --host host --user root] [--port 22] [--key path] [--auth prompt|keychain|ssh-key] [--default-remote path]
+ account list
+ account show
+ account use
+ account test [name]
+ account password-save
+ account password-delete
+
+project:
+ bind [--account name] --remote /remote/project/root
+ doctor
+
+remote:
+ exec [--detach] [--cwd path] [--name name] [--allow-dangerous] --
+ put [remote-path]
+ get [local-path]
+ sync-up [remote-path]
+ sync-down [local-path]
+ tree [remote-path] [--depth 3] [--limit 500]
+ ls [remote-path]
+ cat [--lines 200] --
+ tail [--lines 120] [--follow] --
+ shell
+ history [--lines 20]
+
+EOF
+}
+
+shell_quote() {
+ printf '%q' "$1"
+}
+
+strip_quotes() {
+ local value="$1"
+ value="${value#"${value%%[![:space:]]*}"}"
+ value="${value%"${value##*[![:space:]]}"}"
+ value="${value%%#*}"
+ value="${value%"${value##*[![:space:]]}"}"
+ case "$value" in
+ \"*\") value="${value#\"}"; value="${value%\"}" ;;
+ \'*\') value="${value#\'}"; value="${value%\'}" ;;
+ esac
+ printf '%s' "$value"
+}
+
+json_escape() {
+ sed 's/\\/\\\\/g; s/"/\\"/g' < --remote '."
+}
+
+validate_remote_root() {
+ local root="${1%/}"
+ [ "${root#/}" != "$root" ] || die "remote path must be absolute: $root"
+ [ "$root" != "/" ] || die "remote path is too broad: /"
+ case "$root" in
+ *"/../"*|*/..) die "remote path cannot contain '..': $root" ;;
+ esac
+}
+
+write_history() {
+ local action="$1" detail="$2" file escaped
+ mkdir -p "$PROJECT_ROOT/$HISTORY_DIR"
+ file="$PROJECT_ROOT/$HISTORY_DIR/history.jsonl"
+ escaped="$(json_escape "$detail")"
+ printf '{"ts":"%s","action":"%s","detail":"%s"}\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$action" "$escaped" >> "$file"
+}
+
+load_account() {
+ local name="$1" path
+ valid_name "$name" || die "Invalid account name: $name"
+ path="$(account_path "$name")"
+ [ -f "$path" ] || die "Account not found: $name"
+
+ ACCOUNT_NAME="$name"
+ ACCOUNT_FILE="$path"
+ ACCOUNT_HOST="$(config_get_from "$path" HOST)"
+ ACCOUNT_USER="$(config_get_from "$path" USER)"
+ ACCOUNT_TARGET="$(config_get_from "$path" TARGET)"
+ ACCOUNT_PORT="$(config_get_from "$path" PORT)"
+ ACCOUNT_KEY="$(config_get_from "$path" KEY)"
+ ACCOUNT_AUTH="$(config_get_from "$path" AUTH)"
+ ACCOUNT_DEFAULT_REMOTE="$(config_get_from "$path" DEFAULT_REMOTE)"
+
+ if [ -z "$ACCOUNT_TARGET" ]; then
+ [ -n "$ACCOUNT_HOST" ] || die "Account $name is missing HOST/TARGET."
+ [ -n "$ACCOUNT_USER" ] || die "Account $name is missing USER/TARGET."
+ ACCOUNT_TARGET="$ACCOUNT_USER@$ACCOUNT_HOST"
+ fi
+ ACCOUNT_PORT="${ACCOUNT_PORT:-22}"
+ ACCOUNT_KEY="${ACCOUNT_KEY:-}"
+ ACCOUNT_AUTH="${ACCOUNT_AUTH:-prompt}"
+ ACCOUNT_DEFAULT_REMOTE="${ACCOUNT_DEFAULT_REMOTE:-}"
+
+ case "$ACCOUNT_AUTH" in
+ prompt|keychain|ssh-key) ;;
+ *) die "Account $name has invalid AUTH: $ACCOUNT_AUTH" ;;
+ esac
+}
+
+default_account() {
+ if [ -f "$DEFAULT_ACCOUNT_FILE" ]; then
+ sed -n '1p' "$DEFAULT_ACCOUNT_FILE"
+ return 0
+ fi
+ return 1
+}
+
+load_project() {
+ CONFIG_PATH="$(find_project_config)"
+ PROJECT_ROOT="$(cd "$(dirname "$CONFIG_PATH")" && pwd)"
+ PROJECT_ACCOUNT="$(config_get_from "$CONFIG_PATH" ACCOUNT)"
+ REMOTE_ROOT="$(config_get_from "$CONFIG_PATH" REMOTE_ROOT)"
+
+ [ -n "$PROJECT_ACCOUNT" ] || die "$CONFIG_FILE is missing ACCOUNT."
+ load_account "$PROJECT_ACCOUNT"
+
+ [ -n "$REMOTE_ROOT" ] || REMOTE_ROOT="$ACCOUNT_DEFAULT_REMOTE"
+ [ -n "$REMOTE_ROOT" ] || die "$CONFIG_FILE is missing REMOTE_ROOT."
+ REMOTE_ROOT="${REMOTE_ROOT%/}"
+ validate_remote_root "$REMOTE_ROOT"
+}
+
+ssh_common_args() {
+ SSH_ARGS=(-p "$ACCOUNT_PORT" -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ControlMaster=auto -o ControlPersist=600 -o "ControlPath=$APP_DIR/ssh-%C")
+ mkdir -p "$APP_DIR"
+ [ -n "$ACCOUNT_KEY" ] && SSH_ARGS+=(-i "$ACCOUNT_KEY")
+ if [ "$ACCOUNT_AUTH" != "keychain" ] && [ "$ACCOUNT_AUTH" != "prompt" ]; then
+ SSH_ARGS+=(-o BatchMode=yes)
+ fi
+}
+
+rsync_ssh_command() {
+ local cmd
+ cmd="ssh -p $(shell_quote "$ACCOUNT_PORT") -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ControlMaster=auto -o ControlPersist=600 -o ControlPath=$(shell_quote "$APP_DIR/ssh-%C")"
+ [ -n "$ACCOUNT_KEY" ] && cmd="$cmd -i $(shell_quote "$ACCOUNT_KEY")"
+ if [ "$ACCOUNT_AUTH" != "keychain" ] && [ "$ACCOUNT_AUTH" != "prompt" ]; then
+ cmd="$cmd -o BatchMode=yes"
+ fi
+ printf '%s\n' "$cmd"
+}
+
+keychain_password() {
+ command -v security >/dev/null 2>&1 || die "macOS security command is not available."
+ security find-generic-password -a "$ACCOUNT_NAME" -s "$KEYCHAIN_SERVICE" -w 2>/dev/null
+}
+
+expect_with_password() {
+ command -v expect >/dev/null 2>&1 || die "Account uses keychain auth, but expect is not available."
+ local password="$1"
+ shift
+ AUTODL_REMOTE_PASSWORD="$password" expect -c '
+ set timeout -1
+ set password $env(AUTODL_REMOTE_PASSWORD)
+ spawn {*}$argv
+ expect {
+ -re "(?i)are you sure you want to continue connecting" {
+ send "yes\r"
+ exp_continue
+ }
+ -re "(?i)password:" {
+ send "$password\r"
+ exp_continue
+ }
+ eof
+ }
+ catch wait result
+ exit [lindex $result 3]
+ ' "$@"
+}
+
+run_transport() {
+ if [ "$ACCOUNT_AUTH" = "keychain" ]; then
+ local password
+ password="$(keychain_password)" || die "No Keychain password saved for account: $ACCOUNT_NAME"
+ expect_with_password "$password" "$@"
+ else
+ "$@"
+ fi
+}
+
+ssh_run() {
+ local command="$1"
+ ssh_common_args
+ run_transport ssh "${SSH_ARGS[@]}" "$ACCOUNT_TARGET" "$command"
+}
+
+ssh_capture() {
+ local command="$1"
+ ssh_common_args
+ run_transport ssh "${SSH_ARGS[@]}" "$ACCOUNT_TARGET" "$command"
+}
+
+remote_join() {
+ local path="${1:-.}"
+ if [ -z "$path" ] || [ "$path" = "." ]; then
+ printf '%s\n' "$REMOTE_ROOT"
+ return 0
+ fi
+ case "$path" in
+ /*)
+ validate_remote_root "$path"
+ printf '%s\n' "${path%/}"
+ ;;
+ ../*|*/../*|..)
+ die "path cannot escape remote root: $path"
+ ;;
+ *)
+ printf '%s/%s\n' "$REMOTE_ROOT" "${path#./}"
+ ;;
+ esac
+}
+
+remote_rel_for_local() {
+ local path="$1"
+ case "$path" in
+ /*)
+ if [ "${path#"$PROJECT_ROOT"/}" != "$path" ]; then
+ printf '%s\n' "${path#"$PROJECT_ROOT"/}"
+ else
+ basename "$path"
+ fi
+ ;;
+ *)
+ printf '%s\n' "${path#./}"
+ ;;
+ esac
+}
+
+local_join() {
+ local path="$1"
+ case "$path" in
+ /*) printf '%s\n' "$path" ;;
+ *) printf '%s/%s\n' "$PROJECT_ROOT" "${path#./}" ;;
+ esac
+}
+
+remote_has_rsync() {
+ ssh_capture "command -v rsync >/dev/null 2>&1" >/dev/null 2>&1
+}
+
+ensure_remote_dir() {
+ local dir="$1"
+ ssh_capture "mkdir -p $(shell_quote "$dir")" >/dev/null
+}
+
+dangerous_command_check() {
+ local cmd="$1"
+ case "$cmd" in
+ *"rm -rf /"*|*"mkfs"*|*"shutdown"*|*"reboot"*|*"poweroff"*|*"dd if="*)
+ return 1
+ ;;
+ esac
+ return 0
+}
+
+cmd_account_add() {
+ local name="" target="" host="" user="" port="22" key="" auth="prompt" default_remote="" force=false
+ [ $# -gt 0 ] || die "account add requires a name"
+ name="$1"
+ shift
+ valid_name "$name" || die "Invalid account name: $name"
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --target) target="$2"; shift 2 ;;
+ --host) host="$2"; shift 2 ;;
+ --user) user="$2"; shift 2 ;;
+ --port) port="$2"; shift 2 ;;
+ --key) key="$2"; shift 2 ;;
+ --auth) auth="$2"; shift 2 ;;
+ --default-remote) default_remote="$2"; shift 2 ;;
+ --force) force=true; shift ;;
+ *) die "unknown account add option: $1" ;;
+ esac
+ done
+
+ if [ -z "$target" ]; then
+ if [ -z "$host" ] && [ -t 0 ]; then
+ printf 'host: '
+ read -r host
+ fi
+ if [ -z "$user" ] && [ -t 0 ]; then
+ printf 'user [root]: '
+ read -r user
+ user="${user:-root}"
+ fi
+ [ -n "$host" ] || die "account add requires --target or --host."
+ [ -n "$user" ] || user="root"
+ target="$user@$host"
+ fi
+
+ case "$auth" in
+ prompt|keychain|ssh-key) ;;
+ *) die "--auth must be prompt, keychain, or ssh-key" ;;
+ esac
+ [ -z "$default_remote" ] || validate_remote_root "$default_remote"
+ [ "$auth" != "ssh-key" ] || [ -n "$key" ] || die "--auth ssh-key requires --key"
+
+ local path
+ path="$(account_path "$name")"
+ [ ! -f "$path" ] || [ "$force" = "true" ] || die "Account already exists: $name. Use --force to overwrite."
+ cat > "$path" </dev/null 2>&1; then
+ for file in "$ACCOUNTS_DIR"/*.conf; do
+ seen=true
+ name="$(basename "$file" .conf)"
+ if [ "$(default_account 2>/dev/null || true)" = "$name" ]; then
+ printf '* %s\n' "$name"
+ else
+ printf ' %s\n' "$name"
+ fi
+ done
+ fi
+ [ "$seen" = "true" ] || info "No accounts saved."
+}
+
+cmd_account_show() {
+ [ $# -gt 0 ] || die "account show requires a name"
+ load_account "$1"
+ info "name=$ACCOUNT_NAME"
+ info "target=$ACCOUNT_TARGET"
+ info "port=$ACCOUNT_PORT"
+ info "key=$ACCOUNT_KEY"
+ info "auth=$ACCOUNT_AUTH"
+ info "default_remote=$ACCOUNT_DEFAULT_REMOTE"
+}
+
+cmd_account_use() {
+ [ $# -gt 0 ] || die "account use requires a name"
+ load_account "$1"
+ ensure_app_dirs
+ printf '%s\n' "$ACCOUNT_NAME" > "$DEFAULT_ACCOUNT_FILE"
+ info "Default account: $ACCOUNT_NAME"
+}
+
+cmd_account_test() {
+ local name="${1:-}"
+ if [ -z "$name" ]; then
+ name="$(default_account)" || die "No default account. Run 'autodl-remote account use '."
+ fi
+ load_account "$name"
+ info "Testing $ACCOUNT_NAME -> $ACCOUNT_TARGET:$ACCOUNT_PORT"
+ ssh_capture "printf 'host=' && hostname && printf 'pwd=' && pwd && printf 'rsync=' && { command -v rsync || true; }"
+}
+
+cmd_account_password_save() {
+ [ $# -gt 0 ] || die "account password-save requires a name"
+ load_account "$1"
+ command -v security >/dev/null 2>&1 || die "macOS security command is not available."
+ local password=""
+ if [ -n "${AUTODL_REMOTE_PASSWORD:-}" ]; then
+ password="$AUTODL_REMOTE_PASSWORD"
+ else
+ [ -t 0 ] || die "Set AUTODL_REMOTE_PASSWORD or run from an interactive terminal."
+ printf 'password for %s: ' "$ACCOUNT_NAME" >&2
+ stty -echo
+ read -r password
+ stty echo
+ printf '\n' >&2
+ fi
+ [ -n "$password" ] || die "Empty password not saved."
+ security add-generic-password -U -a "$ACCOUNT_NAME" -s "$KEYCHAIN_SERVICE" -w "$password" >/dev/null
+ local path
+ path="$(account_path "$ACCOUNT_NAME")"
+ if [ ! -f "$path" ]; then
+ local add_args=("$ACCOUNT_NAME" --target "$ACCOUNT_TARGET" --port "$ACCOUNT_PORT" --auth keychain --default-remote "$ACCOUNT_DEFAULT_REMOTE" --force)
+ [ -n "$ACCOUNT_KEY" ] && add_args+=(--key "$ACCOUNT_KEY")
+ cmd_account_add "${add_args[@]}" >/dev/null
+ else
+ awk -F= 'BEGIN{done=0} $1=="AUTH"{print "AUTH=\"keychain\""; done=1; next} {print} END{if(!done) print "AUTH=\"keychain\""}' "$path" > "$path.tmp"
+ mv "$path.tmp" "$path"
+ chmod 600 "$path"
+ fi
+ info "Saved password in macOS Keychain for account: $ACCOUNT_NAME"
+}
+
+cmd_account_password_delete() {
+ [ $# -gt 0 ] || die "account password-delete requires a name"
+ load_account "$1"
+ command -v security >/dev/null 2>&1 || die "macOS security command is not available."
+ security delete-generic-password -a "$ACCOUNT_NAME" -s "$KEYCHAIN_SERVICE" >/dev/null 2>&1 || true
+ info "Deleted Keychain password for account: $ACCOUNT_NAME"
+}
+
+cmd_account() {
+ [ $# -gt 0 ] || die "account requires a subcommand"
+ local sub="$1"
+ shift
+ case "$sub" in
+ add) cmd_account_add "$@" ;;
+ list|ls) cmd_account_list "$@" ;;
+ show) cmd_account_show "$@" ;;
+ use) cmd_account_use "$@" ;;
+ test) cmd_account_test "$@" ;;
+ password-save) cmd_account_password_save "$@" ;;
+ password-delete) cmd_account_password_delete "$@" ;;
+ *) die "unknown account command: $sub" ;;
+ esac
+}
+
+cmd_bind() {
+ local account="" remote="" force=false
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --account) account="$2"; shift 2 ;;
+ --remote) remote="$2"; shift 2 ;;
+ --force) force=true; shift ;;
+ *) die "unknown bind option: $1" ;;
+ esac
+ done
+ if [ -z "$account" ]; then
+ account="$(default_account)" || die "bind requires --account or a default account."
+ fi
+ load_account "$account"
+ [ -n "$remote" ] || remote="$ACCOUNT_DEFAULT_REMOTE"
+ [ -n "$remote" ] || die "bind requires --remote."
+ remote="${remote%/}"
+ validate_remote_root "$remote"
+ [ ! -f "$CONFIG_FILE" ] || [ "$force" = "true" ] || die "$CONFIG_FILE already exists. Use --force to overwrite."
+ cat > "$CONFIG_FILE" </dev/null 2>&1 && nvidia-smi --query-gpu=name,memory.total --format=csv,noheader || true"
+}
+
+cmd_exec() {
+ load_project
+ local detach=false cwd="$REMOTE_ROOT" name="" allow_dangerous=false
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --detach) detach=true; shift ;;
+ --cwd) cwd="$(remote_join "$2")"; shift 2 ;;
+ --name) name="$2"; shift 2 ;;
+ --allow-dangerous) allow_dangerous=true; shift ;;
+ --) shift; break ;;
+ *) break ;;
+ esac
+ done
+ [ $# -gt 0 ] || die "exec requires a command"
+ local cmd="$*"
+ if [ "$allow_dangerous" != "true" ]; then
+ dangerous_command_check "$cmd" || die "Dangerous remote command blocked. Re-run with --allow-dangerous after explicit approval."
+ fi
+ [ -n "$name" ] || name="exec-$(date +%Y%m%d-%H%M%S)"
+ name="$(printf '%s' "$name" | sed 's/[^A-Za-z0-9_.-]/-/g')"
+ local log="$REMOTE_ROOT/.autodl-remote/logs/$name.log"
+ info "log=$log"
+ if [ "$detach" = "true" ]; then
+ ssh_capture "mkdir -p $(shell_quote "$(dirname "$log")") && cd $(shell_quote "$cwd") && nohup bash -lc $(shell_quote "$cmd") > $(shell_quote "$log") 2>&1 < /dev/null & printf 'pid=%s\nlog=%s\n' \"\$!\" $(shell_quote "$log")"
+ write_history exec-detach "$cmd log=$log"
+ else
+ local inner rc
+ inner="set -o pipefail; ( $cmd ) 2>&1 | tee $(shell_quote "$log"); exit \${PIPESTATUS[0]}"
+ ssh_run "mkdir -p $(shell_quote "$(dirname "$log")") && cd $(shell_quote "$cwd") && bash -lc $(shell_quote "$inner")"
+ rc=$?
+ write_history exec "$cmd rc=$rc log=$log"
+ return "$rc"
+ fi
+}
+
+cmd_put() {
+ load_project
+ local dry_run=false delete=false
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --dry-run) dry_run=true; shift ;;
+ --delete) delete=true; shift ;;
+ --) shift; break ;;
+ *) break ;;
+ esac
+ done
+ [ $# -ge 1 ] || die "put requires [remote-path]"
+ local local_arg="$1" remote_arg="${2:-}" local_path remote_path
+ local_path="$(local_join "$local_arg")"
+ [ -e "$local_path" ] || die "Local path does not exist: $local_arg"
+ if [ -z "$remote_arg" ]; then
+ remote_arg="$(remote_rel_for_local "$local_path")"
+ fi
+ remote_path="$(remote_join "$remote_arg")"
+ ensure_remote_dir "$(dirname "$remote_path")"
+ if command -v rsync >/dev/null 2>&1 && remote_has_rsync; then
+ local args=(-az --human-readable -e "$(rsync_ssh_command)")
+ [ "$dry_run" = "true" ] && args+=(--dry-run)
+ [ "$delete" = "true" ] && args+=(--delete)
+ if [ -d "$local_path" ]; then
+ run_transport rsync "${args[@]}" "$local_path/" "$ACCOUNT_TARGET:$remote_path/"
+ else
+ run_transport rsync "${args[@]}" "$local_path" "$ACCOUNT_TARGET:$remote_path"
+ fi
+ else
+ [ "$dry_run" != "true" ] || die "dry-run requires rsync."
+ ssh_common_args
+ local scp_args=(-P "$ACCOUNT_PORT")
+ [ -n "$ACCOUNT_KEY" ] && scp_args+=(-i "$ACCOUNT_KEY")
+ if [ -d "$local_path" ]; then
+ run_transport scp -r "${scp_args[@]}" "$local_path" "$ACCOUNT_TARGET:$remote_path"
+ else
+ run_transport scp "${scp_args[@]}" "$local_path" "$ACCOUNT_TARGET:$remote_path"
+ fi
+ fi
+ write_history put "$local_arg -> $remote_path"
+}
+
+cmd_get() {
+ load_project
+ local dry_run=false
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --dry-run) dry_run=true; shift ;;
+ --) shift; break ;;
+ *) break ;;
+ esac
+ done
+ [ $# -ge 1 ] || die "get requires [local-path]"
+ local remote_arg="$1" local_arg="${2:-}" remote_path local_path kind
+ remote_path="$(remote_join "$remote_arg")"
+ if [ -z "$local_arg" ]; then
+ case "$remote_arg" in
+ /*) local_arg="$(basename "$remote_arg")" ;;
+ *) local_arg="$remote_arg" ;;
+ esac
+ fi
+ local_path="$(local_join "$local_arg")"
+ mkdir -p "$(dirname "$local_path")"
+ kind="$(ssh_capture "if [ -d $(shell_quote "$remote_path") ]; then echo dir; elif [ -f $(shell_quote "$remote_path") ]; then echo file; else echo missing; fi")"
+ [ "$kind" != "missing" ] || die "Remote path does not exist: $remote_arg"
+ if command -v rsync >/dev/null 2>&1 && remote_has_rsync; then
+ local args=(-az --human-readable -e "$(rsync_ssh_command)")
+ [ "$dry_run" = "true" ] && args+=(--dry-run)
+ if [ "$kind" = "dir" ]; then
+ mkdir -p "$local_path"
+ run_transport rsync "${args[@]}" "$ACCOUNT_TARGET:$remote_path/" "$local_path/"
+ else
+ run_transport rsync "${args[@]}" "$ACCOUNT_TARGET:$remote_path" "$local_path"
+ fi
+ else
+ [ "$dry_run" != "true" ] || die "dry-run requires rsync."
+ local scp_args=(-P "$ACCOUNT_PORT")
+ [ -n "$ACCOUNT_KEY" ] && scp_args+=(-i "$ACCOUNT_KEY")
+ if [ "$kind" = "dir" ]; then
+ run_transport scp -r "${scp_args[@]}" "$ACCOUNT_TARGET:$remote_path" "$local_path"
+ else
+ run_transport scp "${scp_args[@]}" "$ACCOUNT_TARGET:$remote_path" "$local_path"
+ fi
+ fi
+ write_history get "$remote_path -> $local_arg"
+}
+
+cmd_tree() {
+ load_project
+ local path="." depth=3 limit=500
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --depth) depth="$2"; shift 2 ;;
+ --limit) limit="$2"; shift 2 ;;
+ --) shift; break ;;
+ -*) die "unknown tree option: $1" ;;
+ *) path="$1"; shift ;;
+ esac
+ done
+ local remote_path
+ remote_path="$(remote_join "$path")"
+ ssh_capture "cd $(shell_quote "$remote_path") && { find . -maxdepth $depth | sed 's#^./##' | sort | head -n $limit; true; }"
+}
+
+cmd_ls() {
+ load_project
+ [ "${1:-}" = "--" ] && shift
+ local path="${1:-.}"
+ ssh_run "ls -lah $(shell_quote "$(remote_join "$path")")"
+}
+
+cmd_cat() {
+ load_project
+ local lines=200
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --lines) lines="$2"; shift 2 ;;
+ --) shift; break ;;
+ *) break ;;
+ esac
+ done
+ [ $# -gt 0 ] || die "cat requires a path"
+ ssh_run "sed -n '1,${lines}p' $(shell_quote "$(remote_join "$1")")"
+}
+
+cmd_tail() {
+ load_project
+ local lines=120 follow=false
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --lines) lines="$2"; shift 2 ;;
+ --follow|-f) follow=true; shift ;;
+ --) shift; break ;;
+ *) break ;;
+ esac
+ done
+ [ $# -gt 0 ] || die "tail requires a path"
+ local flag=""
+ [ "$follow" = "true" ] && flag="-f"
+ ssh_run "tail -n $lines $flag $(shell_quote "$(remote_join "$1")")"
+}
+
+cmd_shell() {
+ load_project
+ ssh_common_args
+ run_transport ssh -t "${SSH_ARGS[@]}" "$ACCOUNT_TARGET" "cd $(shell_quote "$REMOTE_ROOT") && exec bash -l"
+}
+
+cmd_history() {
+ load_project
+ local lines=20 file
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --lines) lines="$2"; shift 2 ;;
+ *) die "unknown history option: $1" ;;
+ esac
+ done
+ file="$PROJECT_ROOT/$HISTORY_DIR/history.jsonl"
+ [ -f "$file" ] || { info "No history yet."; return 0; }
+ tail -n "$lines" "$file"
+}
+
+main() {
+ [ $# -gt 0 ] || { usage; exit 0; }
+ local cmd="$1"
+ shift
+ case "$cmd" in
+ -h|--help|help) usage ;;
+ account) cmd_account "$@" ;;
+ bind) cmd_bind "$@" ;;
+ doctor) cmd_doctor "$@" ;;
+ exec) cmd_exec "$@" ;;
+ put|sync-up) cmd_put "$@" ;;
+ get|sync-down) cmd_get "$@" ;;
+ tree) cmd_tree "$@" ;;
+ ls) cmd_ls "$@" ;;
+ cat) cmd_cat "$@" ;;
+ tail) cmd_tail "$@" ;;
+ shell) cmd_shell "$@" ;;
+ history) cmd_history "$@" ;;
+ *) die "unknown command: $cmd" ;;
+ esac
+}
+
+main "$@"
diff --git a/plugins/autodl-remote/config/example.conf b/plugins/autodl-remote/config/example.conf
new file mode 100644
index 0000000..df60725
--- /dev/null
+++ b/plugins/autodl-remote/config/example.conf
@@ -0,0 +1,2 @@
+ACCOUNT="autodl-gpu"
+REMOTE_ROOT="/root/autodl-tmp/YOUR_PROJECT"
diff --git a/plugins/autodl-remote/skills/autodl-remote/SKILL.md b/plugins/autodl-remote/skills/autodl-remote/SKILL.md
new file mode 100644
index 0000000..49534eb
--- /dev/null
+++ b/plugins/autodl-remote/skills/autodl-remote/SKILL.md
@@ -0,0 +1,63 @@
+---
+name: autodl-remote
+description: Use when Codex should control an AutoDL or SSH server from the local machine, run remote commands, and explicitly upload or download selected files.
+---
+
+# AutoDL Remote
+
+Use this skill when the user wants Codex to work locally while controlling AutoDL through SSH.
+
+## Operating Model
+
+- The plugin is a thin SSH tool layer.
+- Do not decide that local or remote is the source of truth globally.
+- Do not use `local-main`, `remote-main`, manifests, sparse-cache modes, or automatic code ownership rules.
+- Codex decides the smallest useful action each time: inspect, upload, download, run, or tail logs.
+- Do not download LLM weights, checkpoints, datasets, or large outputs unless the user explicitly asks.
+- Prefer reading remote files/logs in place with `cat`, `tail`, `ls`, and `tree`.
+
+## Setup
+
+Use an existing account or create one:
+
+```bash
+autodl-remote account list
+autodl-remote account add autodl-gpu --target root@host --port 2222 --auth prompt
+autodl-remote account use autodl-gpu
+```
+
+Bind the current local directory to the concrete remote directory the user wants:
+
+```bash
+autodl-remote bind --account autodl-gpu --remote /root/autodl-tmp/my-project
+```
+
+The project config should only contain `ACCOUNT` and `REMOTE_ROOT`.
+
+## Core Commands
+
+```bash
+autodl-remote doctor
+autodl-remote tree . --depth 2
+autodl-remote ls .
+autodl-remote cat -- README.md
+autodl-remote tail -- logs/train.log
+
+autodl-remote put ./train.py train.py
+autodl-remote get outputs/result.csv ./outputs/result.csv
+autodl-remote sync-up ./src src
+autodl-remote sync-down logs/train.log ./logs/train.log
+
+autodl-remote exec -- pwd
+autodl-remote exec -- python train.py
+autodl-remote exec --detach --name train -- python train.py
+```
+
+## Behavior Rules
+
+- Before editing remote-existing code, inspect with `tree`, `ls`, `cat`, or `get` only the needed files.
+- Before running remote code that depends on local edits, explicitly upload the edited files or directories with `put` or `sync-up`.
+- After remote execution, inspect logs remotely first; pull only small result files when useful.
+- If both local and remote have similar files, do not assume one should overwrite the other. Compare or inspect first, then choose `put` or `get`.
+- Use `exec --detach` for long training jobs and then `tail` the log path.
+- Treat broad destructive commands as dangerous; require explicit user approval before using `--allow-dangerous`.
diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh
new file mode 100755
index 0000000..cb123ec
--- /dev/null
+++ b/scripts/install-cli.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SOURCE="$ROOT_DIR/plugins/autodl-remote/bin/autodl-remote"
+DEST_DIR="${1:-/opt/homebrew/bin}"
+DEST="$DEST_DIR/autodl-remote"
+
+if [ ! -f "$SOURCE" ]; then
+ echo "Missing CLI source: $SOURCE" >&2
+ exit 1
+fi
+
+mkdir -p "$DEST_DIR"
+ln -sf "$SOURCE" "$DEST"
+chmod +x "$SOURCE"
+
+echo "Installed autodl-remote -> $DEST"
+echo "Run: autodl-remote --help"