@petercjl/topazlabscli 0.1.1 → 0.2.1
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 +8 -2
- package/package.json +2 -2
- package/skill/topazlabscli/SKILL.md +15 -12
- package/skill/topazlabscli/adapters/sealseek.json +12 -4
- package/skill/topazlabscli/capabilities.json +2 -2
- package/src/cli.mjs +52 -5
- package/src/config.mjs +15 -2
- package/src/paths.mjs +20 -4
- package/src/process.mjs +34 -5
- package/src/runtime.mjs +55 -0
- package/src/skill.mjs +23 -4
- package/src/ssh.mjs +14 -5
- package/src/update.mjs +107 -0
package/README.md
CHANGED
|
@@ -15,6 +15,10 @@ npm install --global @petercjl/topazlabscli
|
|
|
15
15
|
topazlabscli skill install --agent all
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
The CLI checks npm for a newer stable release before operational commands, at most once every six hours. When an update is available it upgrades itself, refreshes installed Agent Skills, and then resumes the original command. It discovers npm through the running Node installation, preserves SealSeek's managed global prefix/cache, and discovers Windows OpenSSH through the standard system location, so it also works in Agent runtimes with a restricted `PATH`. A temporary npm outage does not block video processing. `topazlabscli update` forces an immediate manual update.
|
|
19
|
+
|
|
20
|
+
On Windows, SealSeek Skills are installed into `%USERPROFILE%\.sealseek\workspace\skills` when that workspace is present. The CLI automatically uses a managed copy because SealSeek rejects junctions that resolve outside the workspace Skill root; subsequent CLI updates refresh the copy from the npm package. The copy includes a local runtime manifest so the Agent can invoke the canonical package even when its PATH is restricted. `SEALSEEK_SKILLS_HOME` remains available as an explicit override.
|
|
21
|
+
|
|
18
22
|
## Configure a target
|
|
19
23
|
|
|
20
24
|
Use one or more SSH endpoints in priority order. A LAN-only user configures only the LAN entry.
|
|
@@ -56,9 +60,11 @@ The worker uses a global Windows mutex and one queue consumer, so jobs from mult
|
|
|
56
60
|
## Process a video
|
|
57
61
|
|
|
58
62
|
```powershell
|
|
59
|
-
topazlabscli process .\input.mp4 --
|
|
63
|
+
topazlabscli process .\input.mp4 --json
|
|
60
64
|
```
|
|
61
65
|
|
|
66
|
+
Without `--output`, the CLI writes `input-topaz-1080p.mp4` beside the source video. An explicit `--output` remains available for automation.
|
|
67
|
+
|
|
62
68
|
Asynchronous form:
|
|
63
69
|
|
|
64
70
|
```powershell
|
|
@@ -68,7 +74,7 @@ topazlabscli job wait JOB_ID --json
|
|
|
68
74
|
topazlabscli job download JOB_ID --output .\output-1080p.mp4 --json
|
|
69
75
|
```
|
|
70
76
|
|
|
71
|
-
Version 0.
|
|
77
|
+
Version 0.2 includes one preset: `seedance-human-1080p`, using Proteus v4 (`prob-4`), source FPS, aspect-preserving 1080p output, and NVIDIA H.264 encoding.
|
|
72
78
|
|
|
73
79
|
## Configuration
|
|
74
80
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@petercjl/topazlabscli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Cross-Agent CLI and portable Skill for queued remote Topaz Video AI processing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"test": "node --test",
|
|
22
|
-
"check": "node --check bin/topazlabscli.mjs && node --check src/cli.mjs && npm test"
|
|
22
|
+
"check": "node --check bin/topazlabscli.mjs && node --check src/cli.mjs && node --check src/runtime.mjs && node --check src/update.mjs && npm test"
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"access": "public",
|
|
@@ -9,41 +9,44 @@ Use the CLI as the single execution surface. Do not reproduce SSH, SFTP, queue,
|
|
|
9
9
|
|
|
10
10
|
## Input → Strategy → Output
|
|
11
11
|
|
|
12
|
-
- Input: a local video path or a job identifier, the user's requested action, and an already configured target.
|
|
12
|
+
- Input: a local video path or a job identifier, the user's requested action, and an already configured target. A processing request needs only the input path.
|
|
13
13
|
- Strategy: inspect capabilities and health, select the configured reachable endpoint, submit one deterministic preset to the serialized Windows queue, observe terminal state, and download only when requested.
|
|
14
14
|
- Output: structured CLI evidence, a job identifier and state, and for completed processing an explicitly requested local output file.
|
|
15
15
|
|
|
16
16
|
## Main Line
|
|
17
17
|
|
|
18
|
-
1.
|
|
19
|
-
2. Run `topazlabscli
|
|
20
|
-
3.
|
|
21
|
-
4.
|
|
22
|
-
5.
|
|
23
|
-
6.
|
|
18
|
+
1. Resolve the CLI invocation once. Prefer `topazlabscli`. In a Windows SealSeek managed-copy installation, if the npm command shim cannot find `node`, read `.topazlabscli-runtime.json` beside this file and execute its `bin` with its `node`; the CLI creates and refreshes that local manifest. SealSeek-managed npm installs must retain its managed global prefix rather than writing into a Node version directory. Reuse the invocation for the rest of the task; do not disable automatic updates.
|
|
19
|
+
2. Run `topazlabscli capabilities --json` when the live contract is not already known.
|
|
20
|
+
3. Run `topazlabscli doctor --json` before the first job in a conversation or after a connection/model failure, and wait for its terminal result before continuing.
|
|
21
|
+
4. For a processing request, confirm the input exists. When the user does not name an output, let the CLI create `INPUT-BASENAME-topaz-1080p.EXT` beside the source.
|
|
22
|
+
5. Prefer `topazlabscli process INPUT --json` for submit, wait, and download as one operation. Add `--output OUTPUT` only when the user explicitly requests another destination. Use separate `job` commands when the user wants asynchronous control.
|
|
23
|
+
6. Inspect the final JSON. Completion requires `state=completed`, a successful download result, and a real local output file.
|
|
24
|
+
7. Report the selected target/endpoint, job ID, output path, preset/model, dimensions, and any warning or failure.
|
|
24
25
|
|
|
25
26
|
## Preset Boundary
|
|
26
27
|
|
|
27
|
-
`seedance-human-1080p` is the only built-in preset in version 0.
|
|
28
|
+
`seedance-human-1080p` is the only built-in preset in version 0.2. It uses Proteus v4 (`prob-4`), preserves source FPS and aspect ratio, targets a 1080-pixel short edge, and relies on the remote queue's single concurrency slot. Do not silently substitute another model, frame interpolation, stabilization, motion deblur, or 4K output.
|
|
28
29
|
|
|
29
30
|
## Branches
|
|
30
31
|
|
|
31
32
|
- Connection failure: report `CONNECTION_FAILED` and the endpoint attempts. The CLI does not start, reconfigure, or grant access to a VPN.
|
|
32
33
|
- Worker/model not ready: run `worker status` or `model status`; stop with the returned dependency error. Installing/licensing Topaz and downloading models remain GUI administration tasks.
|
|
33
34
|
- Long-running work: use `job submit`, return the job ID, then `job wait` when the user asks to remain attached. Do not resubmit merely because a wait timed out.
|
|
34
|
-
- Cancellation: queued work may cancel immediately. A running task records a cancellation request but is not forcibly killed in version 0.
|
|
35
|
+
- Cancellation: queued work may cancel immediately. A running task records a cancellation request but is not forcibly killed in version 0.2.
|
|
35
36
|
- Missing capability: return `CAPABILITY_UNAVAILABLE` or the CLI's structured error. Do not invent a platform-specific workaround.
|
|
36
37
|
|
|
37
38
|
## Configuration and Safety
|
|
38
39
|
|
|
39
40
|
Configuration, hostnames, addresses, usernames, SSH identities, VPN details, media, Topaz binaries, models, and credentials are external to this Skill and npm package. Installation does not grant access to a workstation. Treat the configured server and Topaz license as user-managed resources.
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
Before operational commands, the CLI performs a cached npm update check. A newer stable package is installed automatically, installed Agent Skills are refreshed, and the original command resumes under the new version. The CLI resolves npm through the running Agent's Node installation when PATH is restricted. Registry, npm, and Skill-refresh failures produce a warning and continue with the installed version.
|
|
43
|
+
|
|
44
|
+
Do not overwrite a local output unless the user has authorized that exact existing target. The remote worker retains job inputs, outputs, status, and logs for operator review; cleanup is an administrative action outside version 0.2.
|
|
42
45
|
|
|
43
46
|
## Skill Management
|
|
44
47
|
|
|
45
|
-
The npm package is the canonical source. Discover it with `topazlabscli skill source --json`; use `skill status`, `skill install`, and `skill update` for Codex and SealSeek targets. Do not edit installed links or copies as independent sources.
|
|
48
|
+
The npm package is the canonical source. Discover it with `topazlabscli skill source --json`; use `skill status`, `skill install`, and `skill update` for Codex and SealSeek targets. Windows SealSeek automatically receives a managed copy because its Skill loader rejects junctions that escape the workspace root; updates refresh that copy from the package. Do not edit installed links or copies as independent sources.
|
|
46
49
|
|
|
47
50
|
## QA and Evolution
|
|
48
51
|
|
|
49
|
-
Use `doctor --json` plus the final job status as runtime evidence.
|
|
52
|
+
Use `doctor --json` plus the final job status as runtime evidence. Do not leave health checks or job commands running without collecting their terminal result. New models, presets, cleanup rules, or worker behavior require an authorized package update with CLI, worker, Skill, capability, and test changes together.
|
|
@@ -5,12 +5,20 @@
|
|
|
5
5
|
"mappings": [
|
|
6
6
|
{
|
|
7
7
|
"capability_id": "command.execute",
|
|
8
|
-
"implementation": {
|
|
8
|
+
"implementation": {
|
|
9
|
+
"kind": "cli",
|
|
10
|
+
"command": "topazlabscli",
|
|
11
|
+
"fallback": "Read .topazlabscli-runtime.json from the managed Skill copy and run its bin with its node"
|
|
12
|
+
},
|
|
9
13
|
"features": ["local-process", "stdout-json", "exit-code"],
|
|
10
|
-
"status": "
|
|
11
|
-
"
|
|
14
|
+
"status": "tested",
|
|
15
|
+
"tested_date": "2026-09-21",
|
|
16
|
+
"evidence": [
|
|
17
|
+
"A clean Windows SealSeek conversation loaded the managed Skill copy, resolved the global package through its runtime manifest, ran version 0.2.1, capabilities, Skill status, and doctor to terminal results, and reported the bounded remote connection failure without disabling automatic updates."
|
|
18
|
+
],
|
|
19
|
+
"configuration": ["Install the npm package globally", "Install the bundled Skill through the CLI into the SealSeek workspace"],
|
|
12
20
|
"permissions": ["local process execution", "network access to configured SSH target"],
|
|
13
|
-
"normalization": "Invoke with --json and consume {ok,data,error}."
|
|
21
|
+
"normalization": "Invoke with --json and consume {ok,data,error}. On Windows with a sanitized PATH, use the CLI-managed runtime manifest in the installed Skill copy."
|
|
14
22
|
},
|
|
15
23
|
{
|
|
16
24
|
"capability_id": "filesystem.local",
|
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
"capabilities": [
|
|
7
7
|
{
|
|
8
8
|
"id": "command.execute",
|
|
9
|
-
"purpose": "Run the stable topazlabscli executable and parse JSON results.",
|
|
9
|
+
"purpose": "Run the stable, self-updating topazlabscli executable and parse JSON results.",
|
|
10
10
|
"required": true,
|
|
11
11
|
"required_features": ["local-process", "stdout-json", "exit-code"],
|
|
12
12
|
"input_fields": ["argv"],
|
|
13
13
|
"output_fields": ["ok", "data", "error"],
|
|
14
14
|
"side_effects": {
|
|
15
|
-
"external_mutation": "May upload videos and enqueue remote processing only when the user requests processing.",
|
|
15
|
+
"external_mutation": "May update the installed npm package and Agent Skill before a command; may upload videos and enqueue remote processing only when the user requests processing.",
|
|
16
16
|
"may_cost_money": false,
|
|
17
17
|
"authorization": "Requires the user's configured SSH access and an explicit processing request."
|
|
18
18
|
},
|
package/src/cli.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { CliError, requireValue } from "./errors.mjs";
|
|
|
8
8
|
import { run } from "./process.mjs";
|
|
9
9
|
import { psLiteral, runPowerShell, selectEndpoint, sftpGet, sftpPut, startPowerShellDetached } from "./ssh.mjs";
|
|
10
10
|
import { skillInstall, skillSource, skillStatus } from "./skill.mjs";
|
|
11
|
+
import { maybeAutoUpdate } from "./update.mjs";
|
|
11
12
|
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
const pkg = require("../package.json");
|
|
@@ -17,9 +18,10 @@ const CAPABILITIES = {
|
|
|
17
18
|
schema_version: 1,
|
|
18
19
|
package: pkg.name,
|
|
19
20
|
version: pkg.version,
|
|
20
|
-
commands: ["version", "capabilities", "doctor", "target", "connection", "worker", "model", "job", "process", "skill", "update"],
|
|
21
|
+
commands: ["version", "capabilities", "doctor", "settings", "target", "connection", "worker", "model", "job", "process", "skill", "update"],
|
|
22
|
+
automatic_updates: { enabled_by_default: true, registry_check_hours: 6, refreshes_installed_skills: true },
|
|
21
23
|
presets: [{ id: PRESET, model: "prob-4", output: "aspect-preserving 1080p", fps: "source", concurrency: 1 }],
|
|
22
|
-
agents: { codex: "tested",
|
|
24
|
+
agents: { codex: "tested", sealseek_windows: "tested" },
|
|
23
25
|
worker_os: ["windows"],
|
|
24
26
|
transport: ["ssh", "sftp"]
|
|
25
27
|
};
|
|
@@ -58,6 +60,7 @@ function help() {
|
|
|
58
60
|
return `topazlabscli ${pkg.version}\n\n` +
|
|
59
61
|
`Commands:\n` +
|
|
60
62
|
` version | capabilities | doctor\n` +
|
|
63
|
+
` settings show | set auto-update <on|off> | set update-check-hours <hours>\n` +
|
|
61
64
|
` target add <name> --endpoint <label=host>... --user <user> [--identity <path>] [--workspace <windows-path>] [--default]\n` +
|
|
62
65
|
` target list\n` +
|
|
63
66
|
` connection check [--target <name>]\n` +
|
|
@@ -66,7 +69,7 @@ function help() {
|
|
|
66
69
|
` job submit <video> [--target <name>] [--preset ${PRESET}]\n` +
|
|
67
70
|
` job list [--target <name>]\n` +
|
|
68
71
|
` job status|wait|download|cancel <job-id> [--target <name>] [--output <path>]\n` +
|
|
69
|
-
` process <video> --output <path> [--target <name>]\n` +
|
|
72
|
+
` process <video> [--output <path>] [--target <name>]\n` +
|
|
70
73
|
` skill source|status|install|update [--agent codex|sealseek|all] [--copy]\n` +
|
|
71
74
|
` update\n\nUse --json for machine-readable output.`;
|
|
72
75
|
}
|
|
@@ -143,6 +146,12 @@ async function downloadJob(id, requestedTarget, outputPath) {
|
|
|
143
146
|
return { id, target: target.name, endpoint: endpoint.name, output: destination };
|
|
144
147
|
}
|
|
145
148
|
|
|
149
|
+
export function defaultOutputPath(inputPath) {
|
|
150
|
+
const resolved = path.resolve(inputPath);
|
|
151
|
+
const extension = path.extname(resolved) || ".mp4";
|
|
152
|
+
return path.join(path.dirname(resolved), `${path.basename(resolved, path.extname(resolved))}-topaz-1080p${extension}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
146
155
|
async function doctor(requestedTarget) {
|
|
147
156
|
const checks = [];
|
|
148
157
|
for (const command of ["ssh", "sftp", "node", "npm"]) {
|
|
@@ -150,7 +159,14 @@ async function doctor(requestedTarget) {
|
|
|
150
159
|
const result = await run(command, args);
|
|
151
160
|
const detail = (result.stdout || result.stderr).trim().split("\n")[0];
|
|
152
161
|
const ok = result.code === 0 || (command === "sftp" && /usage:\s*sftp/i.test(result.stderr));
|
|
153
|
-
checks.push({
|
|
162
|
+
checks.push({
|
|
163
|
+
id: `local.${command}`,
|
|
164
|
+
ok,
|
|
165
|
+
detail,
|
|
166
|
+
resolved_command: result.resolvedCommand,
|
|
167
|
+
resolution: result.resolution,
|
|
168
|
+
timed_out: Boolean(result.timedOut)
|
|
169
|
+
});
|
|
154
170
|
}
|
|
155
171
|
checks.push({ id: "config", ok: fs.existsSync(configPath()), detail: configPath() });
|
|
156
172
|
if (requestedTarget || fs.existsSync(configPath())) {
|
|
@@ -177,6 +193,37 @@ export async function main(rawArgs) {
|
|
|
177
193
|
const command = args.shift();
|
|
178
194
|
if (command === "version" || command === "--version" || command === "-V") return output(pkg.version, json);
|
|
179
195
|
if (command === "capabilities") return output(CAPABILITIES, json);
|
|
196
|
+
|
|
197
|
+
if (!["update", "settings"].includes(command)) {
|
|
198
|
+
const update = await maybeAutoUpdate(rawArgs, pkg);
|
|
199
|
+
if (update.warning) process.stderr.write(`[AUTO_UPDATE_WARNING] ${update.warning}\n`);
|
|
200
|
+
if (update.reexecuted) {
|
|
201
|
+
process.exitCode = update.exitCode;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (command === "settings") {
|
|
207
|
+
const action = args.shift() || "show";
|
|
208
|
+
const config = loadConfig();
|
|
209
|
+
if (action === "show") return output(config.settings, json);
|
|
210
|
+
if (action === "set") {
|
|
211
|
+
const name = requireValue(args.shift(), "SETTING_REQUIRED", "settings set requires a setting name.");
|
|
212
|
+
const value = requireValue(args.shift(), "VALUE_REQUIRED", `settings set ${name} requires a value.`);
|
|
213
|
+
if (name === "auto-update") {
|
|
214
|
+
if (!["on", "off"].includes(value)) throw new CliError("SETTING_INVALID", "auto-update must be on or off.");
|
|
215
|
+
config.settings.auto_update = value === "on";
|
|
216
|
+
} else if (name === "update-check-hours") {
|
|
217
|
+
const hours = Number(value);
|
|
218
|
+
if (!Number.isFinite(hours) || hours < 0) throw new CliError("SETTING_INVALID", "update-check-hours must be zero or a positive number.");
|
|
219
|
+
config.settings.update_check_hours = hours;
|
|
220
|
+
} else throw new CliError("SETTING_UNKNOWN", `Unknown setting: ${name}`);
|
|
221
|
+
const saved = saveConfig(config);
|
|
222
|
+
return output({ path: saved, settings: config.settings }, json);
|
|
223
|
+
}
|
|
224
|
+
throw new CliError("COMMAND_UNKNOWN", `Unknown settings action: ${action}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
180
227
|
if (command === "doctor") return output(await doctor(option(args, "--target")), json);
|
|
181
228
|
|
|
182
229
|
if (command === "target") {
|
|
@@ -253,7 +300,7 @@ export async function main(rawArgs) {
|
|
|
253
300
|
|
|
254
301
|
if (command === "process") {
|
|
255
302
|
const input = requireValue(args.shift(), "INPUT_REQUIRED", "process requires a video.");
|
|
256
|
-
const destination =
|
|
303
|
+
const destination = option(args, "--output") || defaultOutputPath(input);
|
|
257
304
|
const requested = option(args, "--target");
|
|
258
305
|
const submitted = await submitJob(input, requested, option(args, "--preset") || PRESET);
|
|
259
306
|
const finished = await waitJob(submitted.id, requested, Number(option(args, "--interval") || 10), Number(option(args, "--timeout") || 86400));
|
package/src/config.mjs
CHANGED
|
@@ -4,7 +4,12 @@ import { configPath } from "./paths.mjs";
|
|
|
4
4
|
import { CliError } from "./errors.mjs";
|
|
5
5
|
|
|
6
6
|
export function emptyConfig() {
|
|
7
|
-
return {
|
|
7
|
+
return {
|
|
8
|
+
schema_version: 1,
|
|
9
|
+
default_target: null,
|
|
10
|
+
targets: {},
|
|
11
|
+
settings: { auto_update: true, update_check_hours: 6 }
|
|
12
|
+
};
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
export function loadConfig({ required = false } = {}) {
|
|
@@ -18,7 +23,15 @@ export function loadConfig({ required = false } = {}) {
|
|
|
18
23
|
if (parsed.schema_version !== 1 || typeof parsed.targets !== "object") {
|
|
19
24
|
throw new Error("unsupported configuration schema");
|
|
20
25
|
}
|
|
21
|
-
return
|
|
26
|
+
return {
|
|
27
|
+
...parsed,
|
|
28
|
+
settings: {
|
|
29
|
+
auto_update: parsed.settings?.auto_update !== false,
|
|
30
|
+
update_check_hours: Number.isFinite(parsed.settings?.update_check_hours)
|
|
31
|
+
? parsed.settings.update_check_hours
|
|
32
|
+
: 6
|
|
33
|
+
}
|
|
34
|
+
};
|
|
22
35
|
} catch (error) {
|
|
23
36
|
throw new CliError("CONFIG_INVALID", `Cannot read configuration: ${error.message}`, { path: filename });
|
|
24
37
|
}
|
package/src/paths.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
|
+
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
|
|
5
6
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
export const packageRoot = path.resolve(here, "..");
|
|
8
|
+
export const binScript = path.join(packageRoot, "bin", "topazlabscli.mjs");
|
|
7
9
|
export const bundledSkill = path.join(packageRoot, "skill", "topazlabscli");
|
|
8
10
|
export const workerScript = path.join(packageRoot, "worker", "windows", "topazlabs-worker.ps1");
|
|
9
11
|
|
|
@@ -15,13 +17,27 @@ export function configPath() {
|
|
|
15
17
|
return path.join(base, "topazlabscli", "config.json");
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
export function
|
|
19
|
-
|
|
20
|
+
export function updateStatePath() {
|
|
21
|
+
if (process.env.TOPAZLABSCLI_UPDATE_STATE) return path.resolve(process.env.TOPAZLABSCLI_UPDATE_STATE);
|
|
22
|
+
return path.join(path.dirname(configPath()), "update-state.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function skillTarget(agent, options = {}) {
|
|
26
|
+
const home = options.home || os.homedir();
|
|
27
|
+
const env = options.env || process.env;
|
|
28
|
+
const platform = options.platform || process.platform;
|
|
29
|
+
const exists = options.exists || fs.existsSync;
|
|
30
|
+
const pathApi = platform === "win32" ? path.win32 : path;
|
|
20
31
|
if (agent === "codex") {
|
|
21
|
-
return
|
|
32
|
+
return pathApi.join(env.CODEX_HOME || pathApi.join(home, ".codex"), "skills", "topazlabscli");
|
|
22
33
|
}
|
|
23
34
|
if (agent === "sealseek") {
|
|
24
|
-
|
|
35
|
+
if (env.SEALSEEK_SKILLS_HOME) return pathApi.join(env.SEALSEEK_SKILLS_HOME, "topazlabscli");
|
|
36
|
+
const workspace = pathApi.join(home, ".sealseek", "workspace");
|
|
37
|
+
const root = platform === "win32" && (exists(workspace) || exists(pathApi.dirname(workspace)))
|
|
38
|
+
? pathApi.join(workspace, "skills")
|
|
39
|
+
: pathApi.join(home, ".agents", "skills");
|
|
40
|
+
return pathApi.join(root, "topazlabscli");
|
|
25
41
|
}
|
|
26
42
|
throw new Error(`Unknown Agent: ${agent}`);
|
|
27
43
|
}
|
package/src/process.mjs
CHANGED
|
@@ -1,21 +1,50 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { resolveExecutable } from "./runtime.mjs";
|
|
2
3
|
|
|
3
4
|
export function run(command, args, options = {}) {
|
|
4
|
-
return new Promise((resolve
|
|
5
|
-
const
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
const executable = resolveExecutable(command, { env: options.env || process.env });
|
|
7
|
+
const child = spawn(executable.command, [...executable.argsPrefix, ...args], {
|
|
6
8
|
cwd: options.cwd,
|
|
7
|
-
env: options.env || process.env,
|
|
9
|
+
env: { ...(options.env || process.env), ...(executable.envPatch || {}) },
|
|
8
10
|
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
9
11
|
windowsHide: true
|
|
10
12
|
});
|
|
11
13
|
let stdout = "";
|
|
12
14
|
let stderr = "";
|
|
15
|
+
let timedOut = false;
|
|
16
|
+
let timer = null;
|
|
17
|
+
const finish = (value) => {
|
|
18
|
+
if (timer) clearTimeout(timer);
|
|
19
|
+
resolve({ ...value, resolvedCommand: executable.command, resolution: executable.resolution, timedOut });
|
|
20
|
+
};
|
|
13
21
|
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
14
22
|
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
15
|
-
child.on("error",
|
|
16
|
-
child.on("close", (code, signal) =>
|
|
23
|
+
child.on("error", (error) => finish({ code: 127, signal: null, stdout, stderr: stderr || error.message, error: error.message }));
|
|
24
|
+
child.on("close", (code, signal) => finish({ code, signal, stdout, stderr }));
|
|
25
|
+
if (Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) {
|
|
26
|
+
timer = setTimeout(() => {
|
|
27
|
+
timedOut = true;
|
|
28
|
+
child.kill();
|
|
29
|
+
}, options.timeoutMs);
|
|
30
|
+
timer.unref?.();
|
|
31
|
+
}
|
|
17
32
|
if (options.input !== undefined) {
|
|
18
33
|
child.stdin.end(options.input);
|
|
19
34
|
}
|
|
20
35
|
});
|
|
21
36
|
}
|
|
37
|
+
|
|
38
|
+
export function runInherited(command, args, options = {}) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const executable = resolveExecutable(command, { env: options.env || process.env });
|
|
41
|
+
const child = spawn(executable.command, [...executable.argsPrefix, ...args], {
|
|
42
|
+
cwd: options.cwd,
|
|
43
|
+
env: { ...(options.env || process.env), ...(executable.envPatch || {}) },
|
|
44
|
+
stdio: "inherit",
|
|
45
|
+
windowsHide: true
|
|
46
|
+
});
|
|
47
|
+
child.on("error", (error) => resolve({ code: 127, signal: null, error: error.message }));
|
|
48
|
+
child.on("close", (code, signal) => resolve({ code, signal }));
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function firstExisting(candidates, exists) {
|
|
5
|
+
return candidates.find((candidate) => candidate && exists(candidate)) || null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function resolveExecutable(name, options = {}) {
|
|
9
|
+
const platform = options.platform || process.platform;
|
|
10
|
+
const env = options.env || process.env;
|
|
11
|
+
const execPath = options.execPath || process.execPath;
|
|
12
|
+
const exists = options.exists || fs.existsSync;
|
|
13
|
+
const pathApi = platform === "win32" ? path.win32 : path;
|
|
14
|
+
|
|
15
|
+
if (name === "node") {
|
|
16
|
+
return { command: execPath, argsPrefix: [], resolution: "process.execPath" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (name === "npm") {
|
|
20
|
+
if (env.TOPAZLABSCLI_NPM) {
|
|
21
|
+
return { command: env.TOPAZLABSCLI_NPM, argsPrefix: [], resolution: "TOPAZLABSCLI_NPM" };
|
|
22
|
+
}
|
|
23
|
+
const npmExecPath = env.npm_execpath && pathApi.resolve(env.npm_execpath);
|
|
24
|
+
const managedNpm = firstExisting([
|
|
25
|
+
npmExecPath,
|
|
26
|
+
pathApi.join(pathApi.dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js")
|
|
27
|
+
], exists);
|
|
28
|
+
if (managedNpm) {
|
|
29
|
+
const versionDirectory = pathApi.dirname(execPath);
|
|
30
|
+
const versionsDirectory = pathApi.dirname(versionDirectory);
|
|
31
|
+
const managedRoot = pathApi.basename(versionsDirectory).toLowerCase() === "versions"
|
|
32
|
+
? pathApi.dirname(versionsDirectory)
|
|
33
|
+
: null;
|
|
34
|
+
const managedPrefix = managedRoot && pathApi.join(managedRoot, "global");
|
|
35
|
+
const managedCache = managedRoot && pathApi.join(managedRoot, "cache");
|
|
36
|
+
const envPatch = managedPrefix && exists(managedPrefix)
|
|
37
|
+
? {
|
|
38
|
+
npm_config_prefix: env.npm_config_prefix || managedPrefix,
|
|
39
|
+
...(managedCache && exists(managedCache) ? { npm_config_cache: env.npm_config_cache || managedCache } : {})
|
|
40
|
+
}
|
|
41
|
+
: {};
|
|
42
|
+
return { command: execPath, argsPrefix: [managedNpm], envPatch, resolution: "managed-node-npm-cli" };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (platform === "win32" && ["ssh", "sftp"].includes(name)) {
|
|
47
|
+
const override = env[`TOPAZLABSCLI_${name.toUpperCase()}`];
|
|
48
|
+
if (override) return { command: override, argsPrefix: [], resolution: `TOPAZLABSCLI_${name.toUpperCase()}` };
|
|
49
|
+
const windowsRoot = env.SystemRoot || env.WINDIR || "C:\\Windows";
|
|
50
|
+
const openSsh = pathApi.join(windowsRoot, "System32", "OpenSSH", `${name}.exe`);
|
|
51
|
+
if (exists(openSsh)) return { command: openSsh, argsPrefix: [], resolution: "windows-openssh" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { command: name, argsPrefix: [], resolution: "PATH" };
|
|
55
|
+
}
|
package/src/skill.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { bundledSkill, skillTarget } from "./paths.mjs";
|
|
3
|
+
import { binScript, bundledSkill, packageRoot, skillTarget } from "./paths.mjs";
|
|
4
4
|
import { CliError } from "./errors.mjs";
|
|
5
5
|
|
|
6
6
|
const agents = ["codex", "sealseek"];
|
|
@@ -23,7 +23,24 @@ export function skillStatus(selected = "all") {
|
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export function effectiveSkillMode(agent, requestedMode, platform = process.platform) {
|
|
27
|
+
if (platform === "win32" && agent === "sealseek" && requestedMode === "link") return "copy";
|
|
28
|
+
return requestedMode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeRuntimeManifest(target) {
|
|
32
|
+
const manifest = {
|
|
33
|
+
schema: "topazlabscli-skill-runtime",
|
|
34
|
+
schema_version: 1,
|
|
35
|
+
node: process.execPath,
|
|
36
|
+
bin: binScript,
|
|
37
|
+
package_root: packageRoot
|
|
38
|
+
};
|
|
39
|
+
fs.writeFileSync(path.join(target, ".topazlabscli-runtime.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
function installOne(agent, mode, update) {
|
|
43
|
+
const effectiveMode = effectiveSkillMode(agent, mode);
|
|
27
44
|
const target = skillTarget(agent);
|
|
28
45
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
29
46
|
if (fs.existsSync(target)) {
|
|
@@ -32,9 +49,11 @@ function installOne(agent, mode, update) {
|
|
|
32
49
|
if (stat.isSymbolicLink()) fs.unlinkSync(target);
|
|
33
50
|
else fs.rmSync(target, { recursive: true, force: true });
|
|
34
51
|
}
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
if (effectiveMode === "copy") {
|
|
53
|
+
fs.cpSync(bundledSkill, target, { recursive: true });
|
|
54
|
+
writeRuntimeManifest(target);
|
|
55
|
+
} else fs.symlinkSync(bundledSkill, target, process.platform === "win32" ? "junction" : "dir");
|
|
56
|
+
return { agent, target, mode: effectiveMode, source: bundledSkill };
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
export function skillInstall(selected = "all", mode = "link", update = false) {
|
package/src/ssh.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { run } from "./process.mjs";
|
|
5
|
+
import { resolveExecutable } from "./runtime.mjs";
|
|
5
6
|
import { CliError } from "./errors.mjs";
|
|
6
7
|
|
|
7
8
|
function destination(target, endpoint) {
|
|
@@ -22,13 +23,15 @@ export function encodePowerShell(script) {
|
|
|
22
23
|
export async function runPowerShell(target, endpoint, script, { timeout = 7 } = {}) {
|
|
23
24
|
const args = [...commonArgs(target, endpoint, { timeout }), destination(target, endpoint),
|
|
24
25
|
"powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(script)];
|
|
25
|
-
return run("ssh", args);
|
|
26
|
+
return run("ssh", args, { timeoutMs: (timeout + 3) * 1000 });
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export function startPowerShellDetached(target, endpoint, script) {
|
|
29
30
|
const args = [...commonArgs(target, endpoint, { timeout: 10 }), destination(target, endpoint),
|
|
30
31
|
"powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(script)];
|
|
31
|
-
const
|
|
32
|
+
const executable = resolveExecutable("ssh");
|
|
33
|
+
const child = spawn(executable.command, [...executable.argsPrefix, ...args], { detached: true, stdio: "ignore", windowsHide: true });
|
|
34
|
+
child.on("error", () => {});
|
|
32
35
|
child.unref();
|
|
33
36
|
return child.pid;
|
|
34
37
|
}
|
|
@@ -38,7 +41,13 @@ export async function selectEndpoint(target) {
|
|
|
38
41
|
for (const endpoint of target.endpoints) {
|
|
39
42
|
const result = await runPowerShell(target, endpoint, "[Console]::Out.Write('TOPAZLABSCLI_OK')");
|
|
40
43
|
const ok = result.code === 0 && result.stdout.includes("TOPAZLABSCLI_OK");
|
|
41
|
-
attempts.push({
|
|
44
|
+
attempts.push({
|
|
45
|
+
name: endpoint.name,
|
|
46
|
+
host: endpoint.host,
|
|
47
|
+
ok,
|
|
48
|
+
timed_out: Boolean(result.timedOut),
|
|
49
|
+
error: ok ? null : result.stderr.trim()
|
|
50
|
+
});
|
|
42
51
|
if (ok) return { endpoint, attempts };
|
|
43
52
|
}
|
|
44
53
|
throw new CliError("CONNECTION_FAILED", `No endpoint is reachable for target ${target.name}.`, { attempts });
|
|
@@ -61,7 +70,7 @@ export async function sftpPut(target, endpoint, localPath, remotePath) {
|
|
|
61
70
|
if (endpoint.port) args.push("-P", String(endpoint.port));
|
|
62
71
|
args.push(destination(target, endpoint));
|
|
63
72
|
const input = `put ${quoteSftp(path.resolve(localPath))} ${quoteSftp(windowsToSftp(remotePath))}\n`;
|
|
64
|
-
const result = await run("sftp", args, { input });
|
|
73
|
+
const result = await run("sftp", args, { input, timeoutMs: 15000 });
|
|
65
74
|
if (result.code !== 0) throw new CliError("UPLOAD_FAILED", result.stderr.trim() || "SFTP upload failed.");
|
|
66
75
|
return result;
|
|
67
76
|
}
|
|
@@ -73,7 +82,7 @@ export async function sftpGet(target, endpoint, remotePath, localPath) {
|
|
|
73
82
|
if (endpoint.port) args.push("-P", String(endpoint.port));
|
|
74
83
|
args.push(destination(target, endpoint));
|
|
75
84
|
const input = `get ${quoteSftp(windowsToSftp(remotePath))} ${quoteSftp(path.resolve(localPath))}\n`;
|
|
76
|
-
const result = await run("sftp", args, { input });
|
|
85
|
+
const result = await run("sftp", args, { input, timeoutMs: 15000 });
|
|
77
86
|
if (result.code !== 0) throw new CliError("DOWNLOAD_FAILED", result.stderr.trim() || "SFTP download failed.");
|
|
78
87
|
return result;
|
|
79
88
|
}
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadConfig } from "./config.mjs";
|
|
4
|
+
import { binScript, updateStatePath } from "./paths.mjs";
|
|
5
|
+
import { run, runInherited } from "./process.mjs";
|
|
6
|
+
import { skillInstall, skillStatus } from "./skill.mjs";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_INTERVAL_HOURS = 6;
|
|
9
|
+
const UPDATE_GUARD = "TOPAZLABSCLI_AUTO_UPDATE_GUARD";
|
|
10
|
+
|
|
11
|
+
function numericParts(version) {
|
|
12
|
+
return String(version).replace(/^v/, "").split(/[.+-]/).slice(0, 3).map((part) => Number(part) || 0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isNewerVersion(candidate, current) {
|
|
16
|
+
const left = numericParts(candidate);
|
|
17
|
+
const right = numericParts(current);
|
|
18
|
+
for (let index = 0; index < 3; index += 1) {
|
|
19
|
+
if (left[index] !== right[index]) return left[index] > right[index];
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readState(filename) {
|
|
25
|
+
try { return JSON.parse(fs.readFileSync(filename, "utf8")); }
|
|
26
|
+
catch { return {}; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeState(filename, value) {
|
|
30
|
+
fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
31
|
+
const temporary = `${filename}.${process.pid}.tmp`;
|
|
32
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
33
|
+
fs.renameSync(temporary, filename);
|
|
34
|
+
try { fs.chmodSync(filename, 0o600); } catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function automaticUpdateEnabled(config, env) {
|
|
38
|
+
const override = env.TOPAZLABSCLI_AUTO_UPDATE?.toLowerCase();
|
|
39
|
+
if (["0", "false", "off", "no"].includes(override)) return false;
|
|
40
|
+
if (["1", "true", "on", "yes"].includes(override)) return true;
|
|
41
|
+
return config.settings?.auto_update !== false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function maybeAutoUpdate(rawArgs, pkg, dependencies = {}) {
|
|
45
|
+
const env = dependencies.env || process.env;
|
|
46
|
+
if (env[UPDATE_GUARD] === "1") return { checked: false, reason: "guard" };
|
|
47
|
+
|
|
48
|
+
const config = (dependencies.loadConfig || loadConfig)();
|
|
49
|
+
if (!automaticUpdateEnabled(config, env)) return { checked: false, reason: "disabled" };
|
|
50
|
+
|
|
51
|
+
const stateFile = dependencies.stateFile || updateStatePath();
|
|
52
|
+
const now = dependencies.now?.() || Date.now();
|
|
53
|
+
const intervalHours = Number(config.settings?.update_check_hours ?? DEFAULT_INTERVAL_HOURS);
|
|
54
|
+
const intervalMs = Math.max(0, intervalHours) * 60 * 60 * 1000;
|
|
55
|
+
const state = readState(stateFile);
|
|
56
|
+
if (intervalMs > 0 && Number.isFinite(state.last_checked_at) && now - state.last_checked_at < intervalMs) {
|
|
57
|
+
return { checked: false, reason: "fresh", latest: state.latest || null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const execute = dependencies.run || run;
|
|
61
|
+
let query;
|
|
62
|
+
try {
|
|
63
|
+
query = await execute("npm", ["view", pkg.name, "version", "--json"], {
|
|
64
|
+
env: { ...env, npm_config_fetch_timeout: env.npm_config_fetch_timeout || "5000", npm_config_fetch_retries: "0" }
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { checked: true, warning: `Unable to check npm for updates: ${error.message}` };
|
|
68
|
+
}
|
|
69
|
+
if (query.code !== 0) {
|
|
70
|
+
return { checked: true, warning: query.stderr.trim() || "Unable to check npm for updates." };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let latest;
|
|
74
|
+
try { latest = JSON.parse(query.stdout.trim()); }
|
|
75
|
+
catch { latest = query.stdout.trim().replace(/^"|"$/g, ""); }
|
|
76
|
+
writeState(stateFile, { last_checked_at: now, latest, current: pkg.version });
|
|
77
|
+
if (!isNewerVersion(latest, pkg.version)) return { checked: true, updated: false, latest };
|
|
78
|
+
|
|
79
|
+
const getSkillStatus = dependencies.skillStatus || skillStatus;
|
|
80
|
+
const installSkill = dependencies.skillInstall || skillInstall;
|
|
81
|
+
const installedSkills = getSkillStatus("all").filter((item) => item.installed);
|
|
82
|
+
let install;
|
|
83
|
+
try {
|
|
84
|
+
install = await execute("npm", ["install", "--global", `${pkg.name}@latest`], { env });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return { checked: true, warning: `Automatic npm update failed: ${error.message}`, latest };
|
|
87
|
+
}
|
|
88
|
+
if (install.code !== 0) {
|
|
89
|
+
return { checked: true, warning: install.stderr.trim() || "Automatic npm update failed.", latest };
|
|
90
|
+
}
|
|
91
|
+
const warnings = [];
|
|
92
|
+
for (const item of installedSkills) {
|
|
93
|
+
try { installSkill(item.agent, item.mode || "link", true); }
|
|
94
|
+
catch (error) { warnings.push(`Skill refresh failed for ${item.agent}: ${error.message}`); }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const reexecute = dependencies.runInherited || runInherited;
|
|
98
|
+
try {
|
|
99
|
+
const child = await reexecute(process.execPath, [binScript, ...rawArgs], {
|
|
100
|
+
env: { ...env, [UPDATE_GUARD]: "1" }
|
|
101
|
+
});
|
|
102
|
+
return { checked: true, updated: true, latest, reexecuted: true, exitCode: child.code ?? 1, warnings };
|
|
103
|
+
} catch (error) {
|
|
104
|
+
warnings.push(`Updated package could not restart the command: ${error.message}`);
|
|
105
|
+
return { checked: true, updated: true, latest, warning: warnings.join(" ") };
|
|
106
|
+
}
|
|
107
|
+
}
|