Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
59182f7
feat: add sctl daemon
CodFrm Jul 21, 2026
38b48d8
refactor: reorganize sctl architecture
CodFrm Jul 21, 2026
3889987
ci: replace goreleaser and refine repository guides
CodFrm Jul 21, 2026
4ac428e
refactor: flatten trust to a single enrollment (External Access)
CodFrm Jul 23, 2026
4143e64
chore: add local development Makefile
CodFrm Jul 23, 2026
1c9af6c
refactor: group envelope types into session/bridge layers, drop dead …
CodFrm Jul 24, 2026
d9621e0
chore: bump go-sdk to v1.7.0 for MCP 2026-07-28
CodFrm Aug 3, 2026
88d867a
feat: declare scripts.edit.request and scripts.source.grep in protoco…
CodFrm Aug 3, 2026
99f345c
feat: resource-oriented CLI surface (get/delete/enable/disable, -o/--…
CodFrm Aug 3, 2026
60cc197
fix: tighten resource-oriented CLI guards and strip the resource word…
CodFrm Aug 3, 2026
9cc688f
fix: refuse to launch the daemon when the running executable is not sctl
CodFrm Aug 3, 2026
1a9b3a6
feat: add edit and grep commands, and register their MCP tools
CodFrm Aug 3, 2026
c6b99cb
chore: translate user-facing strings to English
CodFrm Aug 3, 2026
6077219
docs: rewrite README in English and sync docs with the kubectl-style CLI
CodFrm Aug 3, 2026
52b71cb
feat: migrate WebSocket transport to JSON-RPC 2.0
CodFrm Aug 4, 2026
b871d32
fix: generate CSP-safe TypeScript RPC validators
CodFrm Aug 4, 2026
5545a67
feat: add global data directory flag
CodFrm Aug 4, 2026
2fafe4b
docs: add MCP installation guide
CodFrm Aug 4, 2026
badeaa3
docs: streamline bilingual README
CodFrm Aug 4, 2026
eabb371
docs: move Chinese README under docs
CodFrm Aug 4, 2026
6f6270d
docs: keep README focused on users
CodFrm Aug 4, 2026
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
150 changes: 150 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
name: release

on:
push:
tags: ["v*"]

permissions:
contents: read

concurrency:
# 发布不允许被后续运行打断。
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
# 复用测试工作流的全部门禁(lint / test / 协议一致性),门禁不过不发布。
test:
uses: ./.github/workflows/test.yaml
permissions:
contents: read

build:
needs: test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: darwin
goarch: amd64
arch: x86_64
format: tar.gz
- goos: darwin
goarch: arm64
arch: arm64
format: tar.gz
- goos: linux
goarch: amd64
arch: x86_64
format: tar.gz
- goos: linux
goarch: arm64
arch: arm64
format: tar.gz
- goos: windows
goarch: amd64
arch: x86_64
format: zip
- goos: windows
goarch: arm64
arch: arm64
format: zip
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: 构建并打包
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
BUILD_DATE=$(git show -s --format=%cI HEAD)
COMMIT_TIMESTAMP=$(git show -s --format=%ct HEAD)
PACKAGE="sctl_${VERSION}_${GOOS}_${{ matrix.arch }}"
STAGE="dist/${PACKAGE}"
BINARY=sctl
if [ "$GOOS" = windows ]; then
BINARY=sctl.exe
fi

mkdir -p "$STAGE/docs"
go build -trimpath -ldflags "-s -w \
-X github.com/scriptscat/sctl/internal/cli.Version=${VERSION} \
-X github.com/scriptscat/sctl/internal/cli.Commit=${GITHUB_SHA} \
-X github.com/scriptscat/sctl/internal/cli.BuildDate=${BUILD_DATE}" \
-o "$STAGE/$BINARY" ./cmd/sctl
cp README.md LICENSE* "$STAGE/"
cp -R docs/. "$STAGE/docs/"
find "$STAGE" -exec touch -d "@${COMMIT_TIMESTAMP}" {} +

if [ "${{ matrix.format }}" = zip ]; then
(cd dist && zip -X -q -r "${PACKAGE}.zip" "$PACKAGE")
else
tar --sort=name --mtime="@${COMMIT_TIMESTAMP}" --owner=0 --group=0 --numeric-owner \
-C dist -czf "dist/${PACKAGE}.tar.gz" "$PACKAGE"
fi
- uses: actions/upload-artifact@v7
with:
name: release-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/*.${{ matrix.format }}
if-no-files-found: error

release:
name: Create Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write # 创建 GitHub Release 并上传产物
id-token: write # 构建来源证明(build provenance)签名
attestations: write
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v8
with:
path: artifacts
pattern: release-*
merge-multiple: true
- name: 生成校验和
run: cd artifacts && sha256sum * > checksums.txt
- name: 判断是否为预发布
id: prerelease
env:
TAG: ${{ github.ref_name }}
run: |
if [[ "$TAG" == *-* ]]; then
echo "flag=--prerelease" >> "$GITHUB_OUTPUT"
fi
- name: 创建 GitHub 草稿 Release
env:
GH_TOKEN: ${{ github.token }}
PRERELEASE_FLAG: ${{ steps.prerelease.outputs.flag }}
run: |
gh release create "$GITHUB_REF_NAME" \
--draft \
--title "$GITHUB_REF_NAME" \
--generate-notes \
$PRERELEASE_FLAG \
artifacts/*
- name: 生成构建来源证明
# 只对 checksums.txt 签名即可:它覆盖了全部产物的 sha256,
# 用户先 `sha256sum -c` 再 `gh attestation verify checksums.txt` 就形成完整信任链。
uses: actions/attest-build-provenance@v4
with:
subject-path: artifacts/checksums.txt
- name: 输出发布摘要
run: |
{
echo "## sctl ${GITHUB_REF_NAME} 发布产物"
echo
echo '```'
cat artifacts/checksums.txt
echo '```'
echo
echo "Release 以**草稿**形式创建,确认 changelog 后手动点击 Publish。"
} >> "$GITHUB_STEP_SUMMARY"
60 changes: 60 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: test

on:
push:
branches: [main, "release/**"]
pull_request:
# 供 release 工作流复用:打 tag 发布前跑同一套门禁,避免两边规则漂移。
workflow_call:
workflow_dispatch:

permissions:
contents: read

concurrency:
# PR 上新提交会取消上一次运行;main / release 分支保留每次运行。
group: test-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
lint:
name: lint
runs-on: ubuntu-latest
env:
# 与本地开发保持同一版本,避免"本地过 CI 挂"。升级时同步改 docs/development.md。
GOLANGCI_LINT_VERSION: v2.12.2
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: golangci/golangci-lint-action@v9
with:
version: ${{ env.GOLANGCI_LINT_VERSION }}

test:
name: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- run: go build ./...
- run: go vet ./...
- run: go test -race ./...

protocol-schema:
name: protocol-schema
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- run: make protocol-check
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/sctl
/bin/
dist/
# 一次性端到端验证脚本与证据(见 docs/verification.md),永远不进版本库
/e2e/scratch/
52 changes: 52 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
version: "2"

# 注意:golangci-lint 只分析当前 GOOS 下参与构建的文件,`_windows.go` 一类
# 带构建标签的代码在 Linux 测试 CI 上不会被检查;跨平台产物由发布流程编译。

linters:
default: standard # errcheck govet ineffassign staticcheck unused
enable:
- bodyclose # HTTP body 未关闭
- errorlint # %w / errors.Is 用法
- gocritic
- misspell
- nilerr # 拿到 err 却返回 nil
- nolintlint # //nolint 必须写明理由
- revive
- unconvert
- usestdlibvars
- whitespace
settings:
errcheck:
check-type-assertions: true
nolintlint:
require-explanation: true
require-specific: true
revive:
rules:
- name: exported
disabled: true # 内部包为主,不强制导出符号注释
- name: unused-parameter
disabled: true
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
# 测试里大量 defer close / 断言,放宽噪音较大的检查。
- path: _test\.go
linters:
- bodyclose
- errcheck

formatters:
enable:
- gofmt
- goimports
settings:
goimports:
local-prefixes:
- github.com/scriptscat/sctl
125 changes: 125 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Repository Guidelines

This file is the entry point for AI coding agents and contributors working on sctl.

> **It is the single source of truth relative to `CLAUDE.md`** — `CLAUDE.md` only contains `@AGENTS.md` and
> re-imports this file; don't split guidance between the two. Everything beyond engineering principles and the
> architecture quick-map is owned by the docs linked below (ownership table in
> [`docs/README.md`](./docs/README.md)) — cross-link them, don't copy their content here.

> **Before writing any code, read [`docs/development.md`](./docs/development.md)** — build and test commands,
> static analysis, environment variables, the version floor, branches, CI, and releases.

> **Before changing package structure, adding a package, or altering dependency direction, read
> [`docs/architecture.md`](./docs/architecture.md)** — process model, directory layout, per-package
> responsibilities, dependency direction.

> **Before changing the envelope, RPC methods, limits, or handshake, read
> [`docs/protocol.md`](./docs/protocol.md)** — [`internal/pkg/protocol/protocol.json`](./internal/pkg/protocol/protocol.json) is the schema authority; generated files
> are updated with `make protocol-generate`, while the doc owns temporal semantics.

> **Before touching authentication, keys, enrollment, or auditing, read
> [`docs/threat-model.md`](./docs/threat-model.md)** — security boundaries, attack surface, accepted
> trade-offs, and the inventory of credentials on disk.

> **Before claiming a change "actually works", read [`docs/verification.md`](./docs/verification.md)** — how to
> write one-shot verification scripts under `e2e/scratch/`, what counts as evidence, and which side effects to
> inspect for surfaces you cannot drive.

> **Before adding, rewriting, or reviewing any documentation, read
> [`docs/doc-maintenance.md`](./docs/doc-maintenance.md)** — ownership rules and truth discipline
> (*if you can't `git grep` it on this branch, don't write it down*).

## Project Overview

sctl is ScriptCat's local control tool: a bridge daemon, an MCP server, and script management commands,
shipped as a single cross-platform binary. Go, built on the [cago](https://github.com/cago-frame/cago)
framework and [cobra](https://github.com/spf13/cobra).

```text
sctl mcp / CLI verbs ──/control/* HTTP──▶ sctl serve (daemon) ──WS──▶ ScriptCat extension (approval authority)
internal/client/ internal/daemon/ internal/pkg/ (shared by both sides)
```

The authority always lives on the extension side: the daemon approves no write on its own — it forwards the
request and blocks until a human decides in the browser. Full process model and package responsibilities are
in [`docs/architecture.md`](./docs/architecture.md).

## Engineering Principles

These are non-negotiable, regardless of what the owning docs say about mechanics. All of them are held by
review today; the one exception is called out in the item itself.

- **Reproduce before you fix.** Reproduce a reported bug yourself and capture the failing evidence, then pin it
down with a failing test, and only then change code. The order is not negotiable: a fix that starts from an
assumption often repairs a problem that never existed while burying the real cause deeper. If it doesn't
reproduce, say so plainly and stop — "it's obviously wrong" is not an exception, and neither is a one-line
change. How to reproduce and what counts as evidence are in
[`docs/verification.md`](./docs/verification.md).

- **Tests first.** Write the failing test before the implementation. Test names state **behavior** ("list
returns exit code 3 when the extension is not connected"), not implementation detail ("calls dispatch"). When
a test fails, fix the code, not the test. Delete meaningless tests outright — tautologies, tests that only
assert a mock, pure pass-throughs — but confirm against the source, one by one, that each really protects
nothing before deleting it.

- **Fix root causes, not symptoms.** No `//nolint` to paper over a diagnostic, no swallowed errors, no empty
branch added just to silence something. A `//nolint` must name the specific rule and give a reason — that
much is mechanically enforced by `nolintlint` in `.golangci.yaml` — but whether the reason is a genuine
exception rather than "make it pass" is still something only review can judge.

- **Validate at boundaries, don't second-guess internally.** Everywhere untrusted data enters — WS envelopes,
`/control/*` requests, payloads from the extension, command-line arguments — must be validated. Between
trusted internal layers, add no `if x == nil` fallbacks, no swallowed errors, no "just in case" runtime
shims. An assumption you cannot hold should panic or return an error rather than be masked by a branch that
never fires — that branch only makes the bug that *does* fire harder to find.

- **Layer by process role; dependencies point one way.** `client/` (request side) and `daemon/` (guard side) do
not depend on each other and communicate across processes only through `/control/*`. `internal/pkg/` is the
shared layer and may only be depended upon from above. `bridge` knows nothing about the HTTP control plane.
The single exception is the guard side referencing `client/control` one-way. Once a shortcut breaks the
direction, the two process roles are welded together at compile time and separating them again means a
rewrite. The current dependency graph and the reason for the exception are in
[`docs/architecture.md`](./docs/architecture.md).

- **Sensitive files hit disk in exactly one place.** Long-term keys, the control token, and the client store
all go through `internal/pkg/fsutil.WriteFileAtomic`. A non-atomic write leaves truncated content behind on a
crash, and a bare `os.WriteFile` also loses the 0600 permission bits. The inventory of credentials on disk is
in [`docs/threat-model.md`](./docs/threat-model.md).

- **stdout belongs to the CLI alone.** stdout carries `sctl mcp`'s JSON-RPC channel; a single byte written
there by the daemon side or a library corrupts MCP frames. Diagnostics always go through
`internal/pkg/logging`, which writes to stderr plus the log files under the data directory — never stdout.

- **Extend through the existing extension points.** A new RPC method lands in `internal/pkg/protocol/protocol.json` first. A new verb
reuses the `dispatch` / `dispatchBlocking` skeleton in `internal/cli/dispatch.go` instead of re-implementing
connection, cancellation, and exit-code mapping. A new `/control/*` handler is registered in
`controlapi.Handler.Register`, on the mux that `internal/daemon/component.go` assembles. Inject dependencies
through constructors and depend on narrow
interfaces rather than concrete types — `controlapi.Bridge` is the template for that pattern. Never branch on
a type string in shared code.

- **Reuse before you rebuild.** `git grep` for an existing helper before writing a new one. Extract a shared
implementation the second time the same logic appears — one concept, one implementation, so a single fix
lands everywhere. But don't pre-abstract for hypothetical needs: three repeated lines beat a premature
generic helper.

- **Stay in scope.** A bug fix touches only the files that bug requires. Correcting a stale comment or an
incorrect doc line right under your cursor is in scope; drive-by refactors and rename sweeps are not.

- **Comments explain "why"; no dead code.** A comment states a constraint or reason the code cannot express —
why 0600, why the symlink must be resolved first here. Comments that restate the steps get deleted. Likewise:
no dead code, no commented-out blocks, no `// removed` markers — git remembers.

- **Documentation doesn't claim what isn't there.** Before asserting that a file, function, or flag exists,
verify it on the current branch with `git grep` / `git ls-files`. The discipline and the per-claim
verification table are in [`docs/doc-maintenance.md`](./docs/doc-maintenance.md).

## Before You Commit

```bash
go build ./... && go vet ./... && go test ./... -race && golangci-lint run ./...
```

Never claim "fixed" or "passing" without real output as evidence — see
[`docs/verification.md`](./docs/verification.md).
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
Loading