@gobius/t3ctl 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -3
- package/package.json +1 -1
- package/t3ctl.mjs +82 -2
package/README.md
CHANGED
|
@@ -18,13 +18,19 @@ T3 Code is MIT-licensed open source. **Read the source before guessing at anythi
|
|
|
18
18
|
t3ctl host add <name> <origin> <token>
|
|
19
19
|
t3ctl project create <title> <workspace-root>
|
|
20
20
|
t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>]
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
t3ctl thread start <thread> <message...> [--interaction-mode plan] [--model ...]
|
|
22
|
+
t3ctl thread interrupt <thread>
|
|
23
|
+
t3ctl thread settle|archive|unarchive|unpin|delete <thread>
|
|
24
|
+
|
|
25
|
+
`<project>` resolves by id, title, or workspace root. `<thread>` resolves by id,
|
|
26
|
+
exact title, then unique case-insensitive substring (ambiguous matches are listed,
|
|
27
|
+
not guessed). The five verb commands above are exactly those whose payload is
|
|
28
|
+
`{commandId, threadId}`; `unsettle` carries extra fields and is not among them. `--model` defaults to
|
|
23
29
|
`claudeAgent/claude-opus-5`; `instanceId` is the segment before the first slash
|
|
24
30
|
(opencode models are themselves slashed, e.g. `opencode/github-copilot/gpt-5.4`).
|
|
25
31
|
|
|
26
32
|
`thread create` creates an *idle* thread with no messages — it does not start the
|
|
27
|
-
agent. The UI never produces this state: it always fires `thread.create` immediately
|
|
33
|
+
agent; use `thread start` for that. The UI never produces this state: it always fires `thread.create` immediately
|
|
28
34
|
followed by `thread.turn.start`, a single command that carries the first message
|
|
29
35
|
inline (`message: {messageId, role, text, attachments}` plus a `titleSeed`).
|
|
30
36
|
`thread.message-sent` and `thread.turn-start-requested` are the resulting *events*,
|
|
@@ -87,6 +93,53 @@ Most-urgent-first: `running` (`session.activeTurnId` or `session.status==="runni
|
|
|
87
93
|
`.../environment-link-challenges`, `.../devices`. Not implemented here yet; see
|
|
88
94
|
`docs/internals/t3-connect.md` and `packages/contracts/src/relay.ts`.
|
|
89
95
|
|
|
96
|
+
## Releasing
|
|
97
|
+
|
|
98
|
+
Publishing runs in CI via **npm trusted publishing (OIDC)**. There is no `NPM_TOKEN`
|
|
99
|
+
anywhere — not in the workflow, not in repo secrets. CI proves its identity with a
|
|
100
|
+
short-lived OIDC token that npm verifies against a configured trusted publisher, so
|
|
101
|
+
there is no long-lived credential to leak, rotate, or exfiltrate. npm also attaches a
|
|
102
|
+
provenance attestation linking the tarball to the exact commit and workflow run.
|
|
103
|
+
|
|
104
|
+
To cut a release:
|
|
105
|
+
|
|
106
|
+
npm version minor # or patch/major; commits and tags
|
|
107
|
+
git push origin main --follow-tags
|
|
108
|
+
|
|
109
|
+
The `v*` tag triggers `.github/workflows/release.yml`, which **stages** the release.
|
|
110
|
+
CI cannot make a version public: the trusted publisher is configured stage-only, so
|
|
111
|
+
a maintainer must promote it with 2FA. Either:
|
|
112
|
+
|
|
113
|
+
- **npmjs.com** → the package → **Staged Packages** tab → **Approve**, or
|
|
114
|
+
- `npm stage list @gobius/t3ctl` then `npm stage approve <stage-id>`
|
|
115
|
+
|
|
116
|
+
2FA is required either way. `npm stage view <id>` and `npm stage download <id>` let
|
|
117
|
+
you inspect the exact tarball before approving. Prereleases (`1.2.3-beta.0`) target
|
|
118
|
+
the `next` dist-tag; everything else `latest`.
|
|
119
|
+
|
|
120
|
+
Trust boundaries, deliberately:
|
|
121
|
+
|
|
122
|
+
- **Staged, not published.** CI stages with provenance; a human promotes with 2FA.
|
|
123
|
+
A compromised workflow cannot ship anything to users. This is npm's own hardened
|
|
124
|
+
recommendation, and the stage subcommands can't use OIDC tokens by design.
|
|
125
|
+
- **Tag-triggered, not push-to-main.** Merging never publishes.
|
|
126
|
+
- **`environment: release`.** The OIDC subject npm checks includes the environment,
|
|
127
|
+
so a workflow running outside it cannot publish even from this repo. Add required
|
|
128
|
+
reviewers to that environment in GitHub settings to gate releases on a human.
|
|
129
|
+
- **`permissions: {}` at the top**, with the job opting into only `contents: read`
|
|
130
|
+
and `id-token: write`.
|
|
131
|
+
- **Actions pinned to full commit SHAs**, so a moved tag cannot swap the code.
|
|
132
|
+
- **`persist-credentials: false`**, so the job's token isn't left in `.git/config`.
|
|
133
|
+
- **`--ignore-scripts`** on publish, and the package has zero dependencies, so no
|
|
134
|
+
third-party code executes in the release job.
|
|
135
|
+
- **Tag/version agreement is enforced** before publishing, not after.
|
|
136
|
+
|
|
137
|
+
One-time setup on npmjs.com (package → Settings → Trusted Publisher):
|
|
138
|
+
organization/user `Goobles`, repository `t3ctl`, workflow `release.yml`,
|
|
139
|
+
environment `release`. Grant it `npm stage publish` only — not `npm publish`.
|
|
140
|
+
Once that works, consider disallowing token-based publishes for the package entirely
|
|
141
|
+
so this pipeline becomes the only path in.
|
|
142
|
+
|
|
90
143
|
## Caveats
|
|
91
144
|
|
|
92
145
|
- Unofficial client. Built against T3 Code Nightly; pin to `snapshot` + `dispatch`.
|
package/package.json
CHANGED
package/t3ctl.mjs
CHANGED
|
@@ -171,6 +171,63 @@ const resolveProject = (snap, ref) =>
|
|
|
171
171
|
snap.projects.find((p) => !p.deletedAt && p.title === ref) ??
|
|
172
172
|
snap.projects.find((p) => !p.deletedAt && p.workspaceRoot === path.resolve(ref.replace(/^~/, os.homedir())));
|
|
173
173
|
|
|
174
|
+
|
|
175
|
+
// Commands whose entire payload is {commandId, threadId}. Verified against
|
|
176
|
+
// packages/contracts/src/orchestration.ts — note `unsettle` is NOT one of
|
|
177
|
+
// these (it carries extra fields), so it is deliberately absent.
|
|
178
|
+
const SIMPLE_THREAD_COMMANDS = ['settle', 'archive', 'unarchive', 'unpin', 'delete'];
|
|
179
|
+
|
|
180
|
+
const resolveThread = (snap, ref) => {
|
|
181
|
+
const live = snap.threads.filter((t) => !t.deletedAt);
|
|
182
|
+
const byId = live.find((t) => t.id === ref);
|
|
183
|
+
if (byId) return byId;
|
|
184
|
+
const exact = live.filter((t) => t.title === ref);
|
|
185
|
+
if (exact.length === 1) return exact[0];
|
|
186
|
+
const fuzzy = live.filter((t) => (t.title ?? '').toLowerCase().includes(ref.toLowerCase()));
|
|
187
|
+
if (fuzzy.length === 1) return fuzzy[0];
|
|
188
|
+
if (fuzzy.length > 1) {
|
|
189
|
+
throw new Error(`"${ref}" matches ${fuzzy.length} threads:\n` +
|
|
190
|
+
fuzzy.slice(0, 8).map((t) => ` ${t.id} ${t.title}`).join('\n'));
|
|
191
|
+
}
|
|
192
|
+
throw new Error(`no thread matching "${ref}"`);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
// thread.turn.start is ONE command carrying the first message inline — this is
|
|
197
|
+
// what the UI fires immediately after thread.create, which is why a thread with
|
|
198
|
+
// no messages is a state the UI never produces. The client-side schema requires
|
|
199
|
+
// runtimeMode/interactionMode explicitly (the server-side one defaults them).
|
|
200
|
+
const cmdThreadStart = async (thread, host, text, flags) => {
|
|
201
|
+
const command = {
|
|
202
|
+
type: 'thread.turn.start',
|
|
203
|
+
commandId: crypto.randomUUID(),
|
|
204
|
+
threadId: thread.id,
|
|
205
|
+
message: { messageId: crypto.randomUUID(), role: 'user', text, attachments: [] },
|
|
206
|
+
runtimeMode: flags['runtime-mode'] ?? thread.runtimeMode ?? 'full-access',
|
|
207
|
+
interactionMode: flags['interaction-mode'] ?? 'default',
|
|
208
|
+
createdAt: new Date().toISOString(),
|
|
209
|
+
};
|
|
210
|
+
if (flags.model) {
|
|
211
|
+
const slash = flags.model.indexOf('/');
|
|
212
|
+
if (slash < 1) throw new Error(`--model must be <instance>/<model>, got "${flags.model}"`);
|
|
213
|
+
command.modelSelection = { instanceId: flags.model.slice(0, slash), model: flags.model.slice(slash + 1) };
|
|
214
|
+
} else if (thread.modelSelection) {
|
|
215
|
+
command.modelSelection = thread.modelSelection;
|
|
216
|
+
}
|
|
217
|
+
const { sequence } = await dispatch(host, command);
|
|
218
|
+
const m = command.modelSelection;
|
|
219
|
+
console.log(`started ${bold(thread.title || thread.id)}\n id ${thread.id}` +
|
|
220
|
+
(m ? `\n model ${m.instanceId}/${m.model}` : '') +
|
|
221
|
+
`\n mode ${command.runtimeMode} / ${command.interactionMode}\n seq ${sequence}`);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const cmdThreadInterrupt = async (thread, host) => {
|
|
225
|
+
const { sequence } = await dispatch(host, {
|
|
226
|
+
type: 'thread.turn.interrupt', commandId: crypto.randomUUID(), threadId: thread.id,
|
|
227
|
+
});
|
|
228
|
+
console.log(`interrupted ${bold(thread.title || thread.id)}\n seq ${sequence}`);
|
|
229
|
+
};
|
|
230
|
+
|
|
174
231
|
const cmdProject = async (args) => {
|
|
175
232
|
const [sub, ...rest] = args;
|
|
176
233
|
const { flags, pos } = parseArgs(rest);
|
|
@@ -191,7 +248,26 @@ const cmdProject = async (args) => {
|
|
|
191
248
|
const cmdThread = async (args) => {
|
|
192
249
|
const [sub, ...rest] = args;
|
|
193
250
|
const { flags, pos } = parseArgs(rest);
|
|
194
|
-
if (sub
|
|
251
|
+
if (sub === 'start' || sub === 'interrupt') {
|
|
252
|
+
const host = pickHost(flags);
|
|
253
|
+
const [ref, ...rest2] = pos;
|
|
254
|
+
if (!ref) return console.error(`usage: t3ctl thread ${sub} <thread>` + (sub === 'start' ? ' <message...>' : ''));
|
|
255
|
+
const thread = resolveThread(await snapshot(host), ref);
|
|
256
|
+
if (sub === 'interrupt') return cmdThreadInterrupt(thread, host);
|
|
257
|
+
const text = rest2.join(' ');
|
|
258
|
+
if (!text) return console.error('usage: t3ctl thread start <thread> <message...>');
|
|
259
|
+
return cmdThreadStart(thread, host, text, flags);
|
|
260
|
+
}
|
|
261
|
+
if (SIMPLE_THREAD_COMMANDS.includes(sub)) {
|
|
262
|
+
const host = pickHost(flags);
|
|
263
|
+
const thread = resolveThread(await snapshot(host), pos.join(' '));
|
|
264
|
+
const { sequence } = await dispatch(host, {
|
|
265
|
+
type: `thread.${sub}`, commandId: crypto.randomUUID(), threadId: thread.id,
|
|
266
|
+
});
|
|
267
|
+
console.log(`${sub}d ${bold(thread.title || thread.id)}\n id ${thread.id}\n seq ${sequence}`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (sub !== 'create') return console.error('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]\n t3ctl thread <settle|archive|unarchive|unpin|delete> <thread>');
|
|
195
271
|
const [projectRef, ...titleParts] = pos;
|
|
196
272
|
const title = titleParts.join(' ');
|
|
197
273
|
if (!projectRef || !title) return console.error('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]');
|
|
@@ -231,7 +307,11 @@ if (!commands[cmd]) {
|
|
|
231
307
|
|
|
232
308
|
t3ctl project create <title> <root> create a project for an existing dir
|
|
233
309
|
t3ctl thread create <project> <title> start a thread (--model inst/model,
|
|
234
|
-
--branch, --host)
|
|
310
|
+
--branch, --host)
|
|
311
|
+
t3ctl thread start <thread> <message...> send a message and run the agent
|
|
312
|
+
t3ctl thread interrupt <thread> stop the running turn
|
|
313
|
+
t3ctl thread settle <thread> settle a thread (also: archive,
|
|
314
|
+
unarchive, unpin, delete)`);
|
|
235
315
|
process.exit(cmd ? 1 : 0);
|
|
236
316
|
}
|
|
237
317
|
await commands[cmd](rest);
|