Skip to content

Commit 82f1e09

Browse files
committed
feat: add pass-through args (--) forwarded verbatim to the tool
Anything after a bare '--' separator on the command line is forwarded to the tool inside the container. Works for both 'construct' and 'construct qs'. Pass-through args are not persisted to last-used settings and are ignored in debug mode. Generated by construct
1 parent b9ecec7 commit 82f1e09

8 files changed

Lines changed: 281 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## [Unreleased]
44

5+
### Added
6+
- **Pass-through args (`--`)** — both `construct [flags] [path] -- <tool-args>` and `construct qs [path] -- <tool-args>` now forward everything after the bare `--` separator verbatim to the tool inside the container (e.g. `construct qs -- continue-session <session-id>`). Pass-through args are not persisted to last-used settings. Debug mode (`--debug`) ignores them.
7+
58
### Fixed
69
- **Container startup "Permission denied" errors** — the entrypoint script's heredoc that writes `~/.config/opencode/AGENTS.md` used an unquoted delimiter, causing the shell to treat backtick-wrapped paths (`` `/workspace` ``, `` `/home/agent` ``) as command substitutions. The delimiter is now quoted (`<< 'AGENTSEOF'`), preventing the errors `/workspace: Permission denied` and `/home/agent: Permission denied` on startup.
710

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ go build -o construct ./cmd/construct
5151
## Usage
5252

5353
```
54-
construct [--stack <stack>] [--docker <mode>] [--rebuild] [--reset] [--debug] [--mcp] [--port <port>] [path]
54+
construct [--stack <stack>] [--docker <mode>] [--rebuild] [--reset] [--debug] [--mcp] [--port <port>] [path] [-- <tool-args>]
5555
construct config <set|unset|list> [--local] [KEY [VALUE]]
56-
construct qs [path]
56+
construct qs [path] [-- <tool-args>]
5757
```
5858

5959
`path` defaults to the current working directory.
@@ -88,6 +88,22 @@ construct qs [path]
8888

8989
`qs` restores the last `--stack`, `--docker`, `--mcp`, and all `--port` values used for that repo. Settings are stored in `~/.construct/last-used.json`.
9090

91+
You can pass extra arguments to the tool after `--`. They are forwarded verbatim and are not saved to last-used:
92+
93+
```bash
94+
# Resume a previous opencode session
95+
construct qs -- -s ses_deadbeefcafe1234abcd5678
96+
97+
# Same with an explicit repo path
98+
construct qs ~/projects/myapp -- -s ses_deadbeefcafe1234abcd5678
99+
```
100+
101+
The `--` separator works with plain `construct` too:
102+
103+
```bash
104+
construct --stack go -- -s ses_deadbeefcafe1234abcd5678
105+
```
106+
91107
## Supported tools
92108

93109
| Tool | Package | Yolo mode |

cmd/construct/main.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,19 @@ func (p *portFlag) Set(v string) error {
2828
return nil
2929
}
3030

31+
// splitPassthrough splits args on the first bare "--" token.
32+
// The left slice contains everything before "--" (construct flags and path).
33+
// The right slice contains everything after "--" (tool pass-through args).
34+
// If "--" is absent, right is nil.
35+
func splitPassthrough(args []string) (left, right []string) {
36+
for i, arg := range args {
37+
if arg == "--" {
38+
return args[:i], args[i+1:]
39+
}
40+
}
41+
return args, nil
42+
}
43+
3144
func main() {
3245
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-version") {
3346
v := buildinfo.Version
@@ -50,6 +63,8 @@ func main() {
5063

5164
// runAgent is the original construct behaviour: build images and launch the agent.
5265
func runAgent(args []string) {
66+
constructArgs, passthroughArgs := splitPassthrough(args)
67+
5368
allStacks := stacks.All()
5469

5570
fs := flag.NewFlagSet("construct", flag.ExitOnError)
@@ -63,12 +78,15 @@ func runAgent(args []string) {
6378
fs.Var(&ports, "port", "Publish a container port to the host (repeatable): --port 3000 --port 8080:8080")
6479

6580
fs.Usage = func() {
66-
fmt.Fprintf(os.Stderr, "Usage: construct [--stack <stack>] [--docker <mode>] [--rebuild] [--reset] [--debug] [--mcp] [--port <port>] [path]\n\n")
81+
fmt.Fprintf(os.Stderr, "Usage: construct [--stack <stack>] [--docker <mode>] [--rebuild] [--reset] [--debug] [--mcp] [--port <port>] [path] [-- <tool-args>]\n\n")
6782
fmt.Fprintf(os.Stderr, "Subcommands:\n")
6883
fmt.Fprintf(os.Stderr, " config Manage credential environment variables\n")
6984
fmt.Fprintf(os.Stderr, " qs Re-run the last stack used in the current repo\n\n")
7085
fmt.Fprintf(os.Stderr, "Other flags:\n")
7186
fmt.Fprintf(os.Stderr, " --version Print the construct version and exit\n\n")
87+
fmt.Fprintf(os.Stderr, "Pass-through args:\n")
88+
fmt.Fprintf(os.Stderr, " Anything after -- is forwarded verbatim to the tool inside the container.\n")
89+
fmt.Fprintf(os.Stderr, " Example: construct qs -- continue-session <session-id>\n\n")
7290
fmt.Fprintf(os.Stderr, "Available stacks:\n")
7391
for _, s := range allStacks {
7492
fmt.Fprintf(os.Stderr, " %s\n", s)
@@ -87,7 +105,7 @@ func runAgent(args []string) {
87105
fs.PrintDefaults()
88106
}
89107

90-
if err := fs.Parse(args); err != nil {
108+
if err := fs.Parse(constructArgs); err != nil {
91109
os.Exit(1)
92110
}
93111

@@ -117,6 +135,7 @@ func runAgent(args []string) {
117135
}
118136

119137
// Persist so `construct qs` can replay this invocation.
138+
// Pass-through args (after --) are not persisted.
120139
if err := config.SaveLastUsed(absRepoPath, *stackName, *mcp, []string(ports), *dockerMode); err != nil {
121140
log.Printf("warning: could not save last-used settings: %v", err)
122141
}
@@ -131,6 +150,7 @@ func runAgent(args []string) {
131150
MCP: *mcp,
132151
Ports: []string(ports),
133152
DockerMode: *dockerMode,
153+
ExtraArgs: passthroughArgs,
134154
}); err != nil {
135155
log.Fatal(err)
136156
}
@@ -231,12 +251,15 @@ func targetEnvFile(local bool) (string, error) {
231251

232252
// runQuickstart re-runs the last stack recorded for the target repo.
233253
func runQuickstart(args []string) {
254+
qsArgs, passthroughArgs := splitPassthrough(args)
255+
234256
fs := flag.NewFlagSet("construct qs", flag.ExitOnError)
235257
fs.Usage = func() {
236-
fmt.Fprintf(os.Stderr, "Usage: construct qs [path]\n\n")
258+
fmt.Fprintf(os.Stderr, "Usage: construct qs [path] [-- <tool-args>]\n\n")
237259
fmt.Fprintf(os.Stderr, "Re-runs the last stack used for the given repo (defaults to cwd).\n")
260+
fmt.Fprintf(os.Stderr, "Anything after -- is forwarded verbatim to the tool inside the container.\n")
238261
}
239-
if err := fs.Parse(args); err != nil {
262+
if err := fs.Parse(qsArgs); err != nil {
240263
os.Exit(1)
241264
}
242265

@@ -284,5 +307,12 @@ func runQuickstart(args []string) {
284307
agentArgs = append(agentArgs, "--port", p)
285308
}
286309
agentArgs = append(agentArgs, absRepoPath)
310+
// Pass-through args are appended after -- so runAgent's splitPassthrough
311+
// correctly routes them to runner.Config.ExtraArgs (and they are not saved
312+
// to last-used, preserving the original session-resuming semantics).
313+
if len(passthroughArgs) > 0 {
314+
agentArgs = append(agentArgs, "--")
315+
agentArgs = append(agentArgs, passthroughArgs...)
316+
}
287317
runAgent(agentArgs)
288318
}

cmd/construct/passthrough_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Integration tests for the construct CLI -- pass-through args feature.
2+
//
3+
// These tests reuse the binary compiled by TestMain in config_test.go.
4+
package main_test
5+
6+
import (
7+
"encoding/json"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"testing"
12+
)
13+
14+
// TestPassthrough_DoubleDashSeparatesToolArgs verifies that args after -- do
15+
// not cause a flag-parse error and are accepted by the binary.
16+
func TestPassthrough_DoubleDashSeparatesToolArgs(t *testing.T) {
17+
home := t.TempDir()
18+
// The binary will fail (no Docker), but must not exit with a flag-parse error.
19+
out, _ := run(t, home, "", "--", "continue-session", "dead-beef-1234")
20+
if strings.Contains(out, "flag provided but not defined") {
21+
t.Errorf("-- caused a flag-parse error: %s", out)
22+
}
23+
}
24+
25+
// TestPassthrough_FlagsBeforeDoubleDash verifies that construct flags before --
26+
// are still parsed correctly when pass-through args follow.
27+
func TestPassthrough_FlagsBeforeDoubleDash(t *testing.T) {
28+
home := t.TempDir()
29+
out, _ := run(t, home, "", "--stack", "base", "--", "continue-session", "abc")
30+
if strings.Contains(out, "flag provided but not defined") {
31+
t.Errorf("flags before -- caused a parse error: %s", out)
32+
}
33+
if strings.Contains(out, "unknown stack") {
34+
t.Errorf("valid stack was rejected: %s", out)
35+
}
36+
}
37+
38+
// TestPassthrough_UsageDocumentsDoubleDash verifies the usage text mentions --.
39+
func TestPassthrough_UsageDocumentsDoubleDash(t *testing.T) {
40+
home := t.TempDir()
41+
out, _ := run(t, home, "", "--help")
42+
if !strings.Contains(out, "--") {
43+
t.Errorf("usage output does not mention --:\n%s", out)
44+
}
45+
}
46+
47+
// TestPassthrough_QsDoubleDash verifies that qs accepts -- without a flag error.
48+
func TestPassthrough_QsDoubleDash(t *testing.T) {
49+
home := t.TempDir()
50+
repo := t.TempDir()
51+
52+
// Write a last-used entry so qs doesn't fail on "no previous run".
53+
lastUsedDir := filepath.Join(home, ".construct")
54+
if err := os.MkdirAll(lastUsedDir, 0o700); err != nil {
55+
t.Fatal(err)
56+
}
57+
entry := map[string]interface{}{
58+
repo: map[string]interface{}{"stack": "base", "docker": "none"},
59+
}
60+
data, err := json.Marshal(entry)
61+
if err != nil {
62+
t.Fatal(err)
63+
}
64+
if err := os.WriteFile(filepath.Join(lastUsedDir, "last-used.json"), data, 0o600); err != nil {
65+
t.Fatal(err)
66+
}
67+
68+
out, _ := run(t, home, repo, "qs", repo, "--", "continue-session", "dead-beef-1234")
69+
if strings.Contains(out, "flag provided but not defined") {
70+
t.Errorf("qs -- caused a flag-parse error: %s", out)
71+
}
72+
}
73+
74+
// TestPassthrough_QsUsageDocumentsDoubleDash verifies the qs usage text mentions --.
75+
func TestPassthrough_QsUsageDocumentsDoubleDash(t *testing.T) {
76+
home := t.TempDir()
77+
// qs with no last-used entry exits non-zero with usage; that's fine.
78+
out, _ := run(t, home, "", "qs", "--help")
79+
if !strings.Contains(out, "--") {
80+
t.Errorf("qs usage output does not mention --:\n%s", out)
81+
}
82+
}

docs/spec/passthrough-args.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Spec: Pass-through args (`--`)
2+
3+
## Problem
4+
5+
Some tools accept flags of their own (e.g. `opencode -s <session-id>`). Currently there is no way to hand those arguments to the tool when launching via `construct` or `construct qs`.
6+
7+
## Solution
8+
9+
Support a `--` separator in both `construct` and `construct qs`. Anything after `--` is appended verbatim to the tool's `RunCmd` inside the container.
10+
11+
## Behaviour
12+
13+
```
14+
construct [flags] [path] -- [tool-args...]
15+
construct qs [path] -- [tool-args...]
16+
```
17+
18+
- Everything after the first bare `--` token is collected as `tool-args` and appended to the container's command after `Tool.RunCmd`.
19+
- The `[path]` positional and all `construct` flags are parsed from the portion **before** `--`.
20+
- If `--` is absent the behaviour is identical to today (no change in the default case).
21+
- `--debug` mode (`/bin/bash`) still ignores pass-through args — there is nothing useful to forward to a shell.
22+
23+
### Examples
24+
25+
```
26+
# Resume an existing opencode session
27+
construct qs -- -s ses_deadbeefcafe1234abcd5678
28+
29+
# Same, specifying the repo explicitly
30+
construct qs ~/projects/myapp -- -s ses_deadbeefcafe1234abcd5678
31+
32+
# Plain construct invocation with pass-through args
33+
construct --stack go -- -s ses_deadbeefcafe1234abcd5678
34+
```
35+
36+
## Persistence
37+
38+
Pass-through args are **not** persisted to `~/.construct/last-used.json`. They are one-off overrides. A subsequent bare `construct qs` will replay the saved flags without the pass-through args.
39+
40+
## Implementation
41+
42+
| File | Change |
43+
|------|--------|
44+
| `internal/runner/runner.go` | Add `ExtraArgs []string` to `Config`; append to `RunCmd` in `buildRunArgs` (only when not in debug mode) |
45+
| `cmd/construct/main.go` | `splitPassthrough` helper splits `args` on `--`; `runAgent` and `runQuickstart` use it to populate `runner.Config.ExtraArgs`; usage strings updated |
46+
47+
## Non-goals
48+
49+
- No persistence of pass-through args in last-used.
50+
- No validation of the tool args — they are forwarded verbatim.
51+
- No pass-through support in `--debug` mode (shell is the CMD; there is nothing to forward to).

docs/spec/quickstart-qs.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Introduce a `qs` subcommand that replays the last `--stack`, `--docker`, `--mcp`
1111
## Behaviour
1212

1313
```
14-
construct qs [path]
14+
construct qs [path] [-- <tool-args>]
1515
```
1616

1717
- `path` defaults to the current working directory.
@@ -20,6 +20,7 @@ construct qs [path]
2020
- Errors with a clear message if no previous run has been recorded for the given path.
2121
- Replays `--docker`, `--mcp`, and all `--port` values that were used in the last recorded invocation.
2222
- For entries recorded before `--docker` was introduced (no `"docker"` key), defaults to `--docker none`.
23+
- Anything after a bare `--` separator is forwarded verbatim to the tool inside the container and is **not** saved to last-used (see `docs/spec/passthrough-args.md`).
2324

2425
## Persistence
2526

@@ -44,11 +45,12 @@ Settings are saved automatically at the end of argument validation in every norm
4445
| File | Change |
4546
|------|--------|
4647
| `internal/config/lastused.go` | New — `SaveLastUsed`, `LoadLastUsed`, JSON read/write helpers; `DockerMode` field added |
47-
| `cmd/construct/main.go` | `main()` routes `qs``runQuickstart`; `runAgent` calls `SaveLastUsed`; `runQuickstart` prints and replays all flags; help lists `qs` under Subcommands |
48+
| `cmd/construct/main.go` | `main()` routes `qs``runQuickstart`; `runAgent` calls `SaveLastUsed`; `runQuickstart` prints and replays all flags; `runQuickstart` uses `splitPassthrough` to forward `--` args without saving them; help lists `qs` under Subcommands |
4849
| `README.md` | New **Quickstart (qs)** section |
4950

5051
## Non-goals
5152

5253
- No flag to disable auto-saving.
5354
- No `qs --stack` overrides (just use the full command instead).
5455
- No TTY prompt to confirm before launching; the reused settings are printed to stderr.
56+
- Pass-through args after `--` are not persisted; a subsequent bare `construct qs` replays only the saved flags.

internal/runner/runner.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ type Config struct {
4545
// Valid values: "none" (no Docker; default), "dood" (Docker-outside-of-Docker
4646
// via host socket bind-mount), "dind" (Docker-in-Docker sidecar).
4747
DockerMode string
48+
// ExtraArgs are additional arguments passed verbatim to the tool after
49+
// Tool.RunCmd. They are collected from everything the user supplies after
50+
// a bare "--" separator on the command line. Ignored in Debug mode.
51+
ExtraArgs []string
4852
}
4953

5054
// Run builds images, starts any requested Docker sidecar, and runs the agent
@@ -286,6 +290,7 @@ func buildRunArgs(cfg *Config, dindInst *dind.Instance, image, sessionID, homeVo
286290
args = append(args, "/bin/bash")
287291
} else {
288292
args = append(args, cfg.Tool.RunCmd...)
293+
args = append(args, cfg.ExtraArgs...)
289294
}
290295
return args
291296
}

0 commit comments

Comments
 (0)