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
32 changes: 31 additions & 1 deletion docs/src/content/docs/getting-started/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ export REDMINE_AUTH_METHOD=apikey
# opt out of the startup update check
export REDMINE_NO_UPDATE_CHECK=1

# refuse all requests that modify data (read-only mode)
export REDMINE_READ_ONLY=1

# hard-disable the optional system keyring
export REDMINE_NO_KEYRING=1
```
Expand All @@ -135,15 +138,42 @@ Flags take precedence over config values and environment variables.
| `--profile` | Use a specific auth profile |
| `--config` | Config file path (default `~/.redmine-cli.yaml`) |
| `--no-color` | Disable colored output |
| `--read-only` | Refuse all requests that modify data on the server |
| `-v, --verbose` | Enable debug logging |

## Read-only mode

Point the CLI at a production Redmine without any risk of changing data. When enabled, every request that would modify the server is refused before it is sent; all listing, viewing, and searching commands keep working.

Enable it in any of three ways (precedence: flag > environment variable > config):

```bash
redmine --read-only issues list # single invocation
export REDMINE_READ_ONLY=1 # whole shell session
```

```yaml title="~/.redmine-cli.yaml"
profiles:
prod:
server: https://redmine.example.com
auth_method: apikey
api_key: your-api-key
read_only: true
```

Pass `--read-only=false` to override the environment variable or a `read_only: true` profile for a single invocation.

<Aside type="note" title="MCP server">
The MCP server has its own gate: mutating tools are only registered when `--enable-writes` is passed (see [AI agents](/guides/ai-agents/)). A profile with `read_only: true` additionally blocks writes at the HTTP layer, even when `--enable-writes` is set.
</Aside>

## Inspect current config

```bash
redmine config
```

Prints the active profile, server, auth method, default project, and output format.
Prints the active profile, server, auth method, default project, output format, and read-only state.

## Next

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/ai-agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The full skill source: [`skills/redmine-cli/SKILL.md`](https://github.com/aarond

- **Transport:** stdio by default. The host can spawn `redmine mcp serve` and talk JSON-RPC over its standard streams, or you can pass `--http :8080` to expose the same server over streamable HTTP.
- **Authentication:** the active profile is used by default. Override with `--profile <name>`, `--server` / `--api-key`, or the `REDMINE_*` environment variables -- exactly like every other subcommand.
- **Read-only by default.** Mutating tools (create / update / delete, comment, close, reopen, and similar write operations) are registered only when `--enable-writes` is passed. Without the flag they never appear in `tools/list`.
- **Read-only by default.** Mutating tools (create / update / delete, comment, close, reopen, and similar write operations) are registered only when `--enable-writes` is passed. Without the flag they never appear in `tools/list`. For a second, transport-level guarantee, set [read-only mode](/getting-started/configuration/#read-only-mode) (`read_only: true` in the profile or `REDMINE_READ_ONLY=1`) -- it blocks every write at the HTTP layer, even when `--enable-writes` is passed.
- **Configurable surface.** `--enable-groups` / `--disable-groups` constrain the registered tools to specific categories (`issues`, `wiki`, `time`, ...). For sharper control, `--enable-tools` / `--disable-tools` allow- or deny-list individual tool names. Run `redmine mcp tools` to print the full catalog.

<Aside type="caution">
Expand Down
32 changes: 31 additions & 1 deletion docs/src/content/docs/zh-cn/getting-started/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ export REDMINE_AUTH_METHOD=apikey
# 关闭启动时的更新检查
export REDMINE_NO_UPDATE_CHECK=1

# 拒绝所有修改数据的请求(只读模式)
export REDMINE_READ_ONLY=1

# 彻底禁用可选的系统密钥环
export REDMINE_NO_KEYRING=1
```
Expand All @@ -135,15 +138,42 @@ export REDMINE_NO_KEYRING=1
| `--profile` | 使用指定的身份验证配置文件 |
| `--config` | 配置文件路径(默认为 `~/.redmine-cli.yaml`) |
| `--no-color` | 禁用彩色输出 |
| `--read-only` | 拒绝所有修改服务器数据的请求 |
| `-v, --verbose` | 启用调试日志 |

## 只读模式

将 CLI 指向生产环境的 Redmine 而无需担心数据被修改。启用后,任何会修改服务器数据的请求都会在发送前被拒绝;所有列表、查看和搜索命令继续正常工作。

可通过三种方式启用(优先级:标志 > 环境变量 > 配置文件):

```bash
redmine --read-only issues list # 单次调用
export REDMINE_READ_ONLY=1 # 整个 shell 会话
```

```yaml title="~/.redmine-cli.yaml"
profiles:
prod:
server: https://redmine.example.com
auth_method: apikey
api_key: your-api-key
read_only: true
```

传入 `--read-only=false` 可在单次调用中覆盖环境变量或配置文件中的 `read_only: true`。

<Aside type="note" title="MCP 服务器">
MCP 服务器有自己的开关:只有传入 `--enable-writes` 时才会注册写入类工具(参见 [AI 代理](/zh-cn/guides/ai-agents/))。配置文件中的 `read_only: true` 会额外在 HTTP 层阻止写入,即使已传入 `--enable-writes`。
</Aside>

## 查看当前配置

```bash
redmine config
```

输出活跃配置文件、服务器、身份验证方式、默认项目及输出格式
输出活跃配置文件、服务器、身份验证方式、默认项目、输出格式及只读状态

## 下一步

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/zh-cn/guides/ai-agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ redmine-cli 提供两种互补的集成方式,二者均与厂商无关:

- **传输方式:** 默认使用 stdio。宿主可生成 `redmine mcp serve` 进程并通过其标准流使用 JSON-RPC 通信;也可传入 `--http :8080` 通过 streamable HTTP 暴露同一个服务。
- **身份验证:** 默认使用当前活跃的配置文件。可通过 `--profile <name>`、`--server` / `--api-key` 或 `REDMINE_*` 环境变量覆盖,与其他子命令完全一致。
- **默认只读。** 修改类工具(创建 / 更新 / 删除、评论、关闭、重新打开及类似写操作)仅在传入 `--enable-writes` 时才注册。未传入该标志时,这些工具不会出现在 `tools/list` 中。
- **默认只读。** 修改类工具(创建 / 更新 / 删除、评论、关闭、重新打开及类似写操作)仅在传入 `--enable-writes` 时才注册。未传入该标志时,这些工具不会出现在 `tools/list` 中。如需传输层的双重保障,可启用[只读模式](/zh-cn/getting-started/configuration/#只读模式)(在配置文件中设置 `read_only: true` 或设置 `REDMINE_READ_ONLY=1`)——它会在 HTTP 层阻止所有写入,即使已传入 `--enable-writes`。
- **可配置暴露范围。** `--enable-groups` / `--disable-groups` 可将注册的工具限制到特定类别(`issues`、`wiki`、`time` 等)。若需更细粒度的控制,可使用 `--enable-tools` / `--disable-tools` 对单个工具做 allow-list 或 deny-list。运行 `redmine mcp tools` 可打印完整工具目录。

<Aside type="caution">
Expand Down
6 changes: 5 additions & 1 deletion internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,12 @@ func NewClient(cfg *config.Config, log *debug.Logger) (*Client, error) {
host: host,
}

var rt http.RoundTripper = transport
if cfg.ReadOnly {
rt = &readOnlyTransport{base: transport}
}
c := &Client{
httpClient: &http.Client{Transport: transport},
httpClient: &http.Client{Transport: rt},
baseURL: baseURL,
debugLog: log,
}
Expand Down
34 changes: 34 additions & 0 deletions internal/api/read_only.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package api

import (
"fmt"
"net/http"
)

// ErrReadOnly is returned when a mutating request is attempted while the
// client is in read-only mode. The request is never sent.
type ErrReadOnly struct {
Method string
Path string
}

func (e *ErrReadOnly) Error() string {
return fmt.Sprintf(
"read-only mode enabled: refusing %s %s (set via --read-only, REDMINE_READ_ONLY, or read_only in the profile; pass --read-only=false to allow writes)",
e.Method, e.Path,
)
}

// readOnlyTransport rejects any non-read HTTP method before delegating to base.
type readOnlyTransport struct {
base http.RoundTripper
}

func (t *readOnlyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
switch req.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return t.base.RoundTrip(req)
default:
return nil, &ErrReadOnly{Method: req.Method, Path: req.URL.Path}
}
}
55 changes: 55 additions & 0 deletions internal/api/read_only_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package api

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"

"github.com/aarondpn/redmine-cli/v2/internal/config"
"github.com/aarondpn/redmine-cli/v2/internal/debug"
)

func TestReadOnlyBlocksWritesAllowsReads(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}))
defer srv.Close()

cfg := &config.Config{Server: srv.URL, APIKey: "k", AuthMethod: "apikey", ReadOnly: true}
c, err := NewClient(cfg, debug.New(nil))
if err != nil {
t.Fatal(err)
}

// Writes are refused before reaching the server.
ctx := context.Background()
writes := map[string]error{
"Post": c.Post(ctx, "/issues.json", map[string]any{}, nil),
"Put": c.Put(ctx, "/issues/1.json", map[string]any{}),
"Delete": c.Delete(ctx, "/issues/1.json"),
}
_, rawErr := c.DoRaw(ctx, http.MethodPatch, "/issues/1.json", nil, nil)
writes["DoRaw PATCH"] = rawErr
for name, err := range writes {
var roErr *ErrReadOnly
if !errors.As(err, &roErr) {
t.Errorf("%s error = %v, want *ErrReadOnly", name, err)
}
}
if hits != 0 {
t.Fatalf("server received %d requests, want 0", hits)
}

// Read still works.
if err := c.Get(context.Background(), "/issues.json", nil, &struct{}{}); err != nil {
t.Fatalf("Get failed: %v", err)
}
if hits != 1 {
t.Fatalf("server received %d requests, want 1", hits)
}
}
8 changes: 8 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"os"
"strconv"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -55,6 +56,7 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) {
verbose bool
cfgFile string
outputFormat string
readOnly bool
)

cmd := &cobra.Command{
Expand All @@ -72,6 +74,9 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) {
}
f.Verbose = verbose
f.OutputFormat = outputFormat
if cmd.Flags().Changed("read-only") {
f.ReadOnly = &readOnly
}
return nil
},
SilenceUsage: true,
Expand All @@ -86,6 +91,7 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) {
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable debug logging")
cmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Config file path (default ~/.redmine-cli.yaml)")
cmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "", "Output format: table, json, csv")
cmd.PersistentFlags().BoolVar(&readOnly, "read-only", false, "Refuse all requests that modify data on the server")
_ = cmd.RegisterFlagCompletionFunc("output", cmdutil.CompleteOutputFormat)

// Version
Expand Down Expand Up @@ -148,6 +154,7 @@ func newCmdConfig(f *cmdutil.Factory) *cobra.Command {
"auth_method": cfg.AuthMethod,
"default_project": cfg.DefaultProject,
"output_format": cfg.OutputFormat,
"read_only": strconv.FormatBool(cfg.ReadOnly),
})
return nil
}
Expand All @@ -157,6 +164,7 @@ func newCmdConfig(f *cmdutil.Factory) *cobra.Command {
{Key: "Auth Method", Value: cfg.AuthMethod},
{Key: "Default Project", Value: cfg.DefaultProject},
{Key: "Output Format", Value: cfg.OutputFormat},
{Key: "Read Only", Value: strconv.FormatBool(cfg.ReadOnly)},
})
return nil
},
Expand Down
7 changes: 7 additions & 0 deletions internal/cmdutil/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ type Factory struct {
APIKeyOverride string
NoColorOverride bool

// ReadOnly, when non-nil, is the --read-only flag value and overrides
// both REDMINE_READ_ONLY and the per-profile config (highest precedence).
ReadOnly *bool

// OutputFormat is set by the root persistent --output/-o flag and used
// as the default format when commands call Printer("").
OutputFormat string
Expand Down Expand Up @@ -92,6 +96,9 @@ func (f *Factory) Config() (*config.Config, error) {
if f.NoColorOverride {
cfg.NoColor = true
}
if f.ReadOnly != nil {
cfg.ReadOnly = *f.ReadOnly
}

f.config = cfg
return cfg, nil
Expand Down
38 changes: 38 additions & 0 deletions internal/cmdutil/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,41 @@ func testFactoryWithConfig(t *testing.T, body string) *Factory {
}

var _ = config.Config{}

func TestFactoryReadOnlyFlagBeatsConfig(t *testing.T) {
// File says read_only: true...
cfgPath := writeConfigFile(t, "active_profile: default\nprofiles:\n default:\n server: https://x\n api_key: k\n read_only: true\n")

// ...but the flag explicitly sets it false (highest precedence).
ff := false
f := NewFactory()
f.ConfigPath = cfgPath
f.ProfileOverride = "default"
f.ReadOnly = &ff

cfg, err := f.Config()
if err != nil {
t.Fatal(err)
}
if cfg.ReadOnly {
t.Fatal("flag --read-only=false must override config read_only: true")
}
}

func TestFactoryReadOnlyEnvWhenNoFlag(t *testing.T) {
cfgPath := writeConfigFile(t, "active_profile: default\nprofiles:\n default:\n server: https://x\n api_key: k\n")
t.Setenv("REDMINE_READ_ONLY", "true")

f := NewFactory()
f.ConfigPath = cfgPath
f.ProfileOverride = "default"
// f.ReadOnly stays nil (flag not set)

cfg, err := f.Config()
if err != nil {
t.Fatal(err)
}
if !cfg.ReadOnly {
t.Fatal("REDMINE_READ_ONLY=true must apply when --read-only is not set")
}
}
9 changes: 7 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -433,14 +433,15 @@ func SaveProfile(name string, cfg *Config, path string) error {
return saveProfilesRemovingOrphan(pc, path, name, orphan)
}

// reconcileProfileEntry inherits the existing KeyringID on re-login and
// returns the previous config as an orphan when a profile leaves the keyring.
// reconcileProfileEntry preserves settings that auth login does not manage
// and returns the previous config as an orphan when a profile leaves the keyring.
func reconcileProfileEntry(pc *ProfileConfig, name string, cfg *Config) (Config, *Config) {
entry := *cfg
old, had := pc.Profiles[name]
if !had {
return entry, nil
}
entry.ReadOnly = old.ReadOnly
if entry.CredentialStore == CredentialStoreKeyring && entry.KeyringID == "" && old.KeyringID != "" {
entry.KeyringID = old.KeyringID
}
Expand Down Expand Up @@ -586,6 +587,10 @@ func applyEnvOverrides(cfg *Config, log *debug.Logger) {
cfg.NoColor = true
}

if val := os.Getenv("REDMINE_READ_ONLY"); val != "" {
cfg.ReadOnly = parseBoolEnv(val)
}

mcpListEnv := map[string]*[]string{
"REDMINE_MCP_ENABLE_GROUPS": &cfg.MCP.EnableGroups,
"REDMINE_MCP_DISABLE_GROUPS": &cfg.MCP.DisableGroups,
Expand Down
Loading