@fnnas-labs/fnos-cli 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +169 -2
- package/THIRD_PARTY_NOTICES.txt +38904 -0
- package/cordis.patch.yml +3 -0
- package/npm/README.dsh.md +49 -0
- package/npm/acceptance-report.cjs +53 -0
- package/npm/dsh-integration.mjs +159 -0
- package/npm/dsh-test-support.mjs +55 -0
- package/npm/dsh.mjs +31 -0
- package/npm/install.cjs +140 -0
- package/npm/package-lib.cjs +274 -0
- package/npm/run.cjs +39 -0
- package/package.json +46 -4
- package/skill/LICENSE +21 -0
- package/skill/SKILL.md +143 -0
- package/skill/THIRD_PARTY_NOTICES.txt +38904 -0
- package/skill/bin/trim-cli-darwin-arm64 +0 -0
- package/skill/bin/trim-cli-darwin-x64 +0 -0
- package/skill/bin/trim-cli-linux-arm64 +0 -0
- package/skill/bin/trim-cli-linux-x64 +0 -0
- package/skill/bin/trim-cli-windows-arm64.exe +0 -0
- package/skill/bin/trim-cli-windows-x64.exe +0 -0
- package/skill/build-provenance.json +44 -0
- package/skill/entries/trim-app.md +63 -0
- package/skill/entries/trim-baidu-netdisk.md +42 -0
- package/skill/entries/trim-docker.md +35 -0
- package/skill/entries/trim-download.md +38 -0
- package/skill/entries/trim-file.md +30 -0
- package/skill/entries/trim-log.md +32 -0
- package/skill/entries/trim-monitor.md +41 -0
- package/skill/entries/trim-network.md +22 -0
- package/skill/entries/trim-photos.md +32 -0
- package/skill/entries/trim-shared.md +54 -0
- package/skill/entries/trim-storage.md +34 -0
- package/skill/entries/trim-system.md +59 -0
- package/skill/entries/trim-user.md +28 -0
- package/skill/manifest.json +43 -0
- package/skill/reference/_conventions.md +76 -0
- package/skill/reference/_index.md +78 -0
- package/skill/reference/app-center.md +234 -0
- package/skill/reference/baidu-netdisk.md +221 -0
- package/skill/reference/dockermgr.md +244 -0
- package/skill/reference/download.md +357 -0
- package/skill/reference/file.md +752 -0
- package/skill/reference/log.md +224 -0
- package/skill/reference/network.md +41 -0
- package/skill/reference/oauth.md +51 -0
- package/skill/reference/photos.md +158 -0
- package/skill/reference/power.md +34 -0
- package/skill/reference/resmon.md +160 -0
- package/skill/reference/stor.md +525 -0
- package/skill/reference/sysinfo.md +133 -0
- package/skill/reference/user.md +139 -0
- package/skill/reference/workflows/device-validation.md +66 -0
- package/skill/reference/workflows/file-routing.md +44 -0
- package/skill/reference/workflows/file-upload-validation.md +63 -0
- package/skill/reference/workflows/photos-routing.md +83 -0
- package/skill/reference/workflows/storage-dangerous-ops.md +53 -0
- package/skill/scripts/fnos-cli +63 -0
- package/skill/scripts/fnos-cli.cmd +36 -0
- package/skill/scripts/fnos-cli.ps1 +31 -0
- package/skill/scripts/trim-cli +63 -0
- package/skill/scripts/trim-cli.cmd +36 -0
- package/skill/scripts/trim-cli.ps1 +31 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# fnOS CLI for DSH
|
|
2
|
+
|
|
3
|
+
The `@fnnas-labs/fnos-cli` package includes its native CLI, matching Skill, and a DSH bundle. The bundle activates `npm/dsh.mjs`, which explicitly registers `fnos-cli` with the Skill service. Installing a bundle and registering a Skill are separate steps.
|
|
4
|
+
|
|
5
|
+
Install the package into the DSH profile you use, for example:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
dsh plugin --profile headless add @fnnas-labs/fnos-cli@0.2.1
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The Skill derives `../npm/run.cjs` from its resource directory and instructs the Agent to call that launcher through Node. Global `fnos-cli` and `trim-cli` commands are not required. The adapter loads only local packaged documents; it does not execute commands, add write confirmation flags, or log in.
|
|
12
|
+
|
|
13
|
+
For first login, the user runs this in a real terminal on the CLI execution host:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npx -y --package=@fnnas-labs/fnos-cli@0.2.1 fnos-cli login --profile <profile> --host <nas-host> --port <port>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Replace the NAS host/port with the user's chosen endpoint; ask when unknown rather than assuming localhost. For an existing profile, omit these options to reuse its saved endpoint unless changing devices. The terminal and DSH invocation must use the same DSH Host, system user, configuration environment and profile. The user completes browser authorization and enters the code in that terminal. Default one-shot DSH shells have no interactive login TTY. Remote browsers, SSH providers and containers do not automatically share the host's installation or session. macOS Keychain access may require system approval.
|
|
20
|
+
|
|
21
|
+
## Deterministic integration check
|
|
22
|
+
|
|
23
|
+
DSH acceptance is run manually by the user, not on every CI run. Choose when to
|
|
24
|
+
run these checks against a frozen candidate. Successful runners save a private
|
|
25
|
+
`report.json` with an `acceptance` receipt containing the package identity,
|
|
26
|
+
tarball SHA-256, runtime and coverage limits. A receipt for a different tarball
|
|
27
|
+
cannot validate a rebuilt package, even when the version is unchanged.
|
|
28
|
+
|
|
29
|
+
With Node.js, npm, pnpm and a DSH-supported macOS/Linux shell, run against an actual staged npm tarball:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
node npm/dsh-integration.mjs --tarball /absolute/path/fnnas-labs-fnos-cli-0.2.1.tgz
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The runner pins DSH to `0.2.0-rc.2`, installs the tarball in a fresh DSH Home, and runs the real headless Agent's `skill` and `bash` tools using a local **scripted model**. No provider key, NAS or OAuth is required. It verifies Skill discovery, a resource-derived same-package launcher, native help, a fake-profile read-only connection attempt and refusal of an unconfirmed write. The shell PATH contains Node only; installation paths include spaces and an apostrophe. Logs and isolated configuration remain under `.tmp/` for inspection. An explicit `FNOS_DSH_CLI` can select an already installed DSH CLI entry; `FNOS_DSH_NPM_CACHE` and `FNOS_DSH_PNPM_STORE` can reuse dependency caches. These options do not change the isolated DSH Home or CLI session directory.
|
|
36
|
+
|
|
37
|
+
This check proves the tool chain, not autonomous Skill selection by a real model, successful NAS authorization, Keychain background access or Windows behavior. Linux/Windows manual checks and real-model acceptance are separate. The existing shell path is used first; a dedicated argv tool should only be added if platform acceptance demonstrates a need.
|
|
38
|
+
|
|
39
|
+
## Opt-in macOS device acceptance (source checkout)
|
|
40
|
+
|
|
41
|
+
After the user completes OAuth in a real terminal, maintainers can run the separate source-checkout runner against a reviewed candidate tarball:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
node npm/dsh-device-acceptance.mjs --tarball /absolute/path/fnnas-labs-fnos-cli-0.2.1.tgz --profile <existing-profile> --use-existing-session
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Add `--allow-insecure-http` only when the authorized device uses plaintext HTTP. The runner preserves the invoking user's HOME, CLI configuration environment and secure session storage; it creates only a fresh DSH Home/workspace. File/ask-file storage and profiles with an existing legacy file fallback are rejected (files are left unchanged and unread). It never performs login, receives OAuth codes or requests passwords. The scripted model directs the real Skill and shell tools to package-local native help, system info, current-user info and a repeated read-only request. The PATH retains macOS system utilities for Keychain access, while explicitly checking that neither global CLI name resolves.
|
|
48
|
+
|
|
49
|
+
Reports identify the candidate tarball hash and the scripted-model limitation. Device output stays in private `.tmp/` logs; the summary includes output hashes, not device payloads. A passing run confirms existing-session reuse from a background DSH process, not the manual first-login ceremony, natural-expiry refresh, autonomous real-model selection, Linux/Windows, npm registry or Market installation. This runner is opt-in and is not run by default CI or shipped as a consumer command.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Shared receipt for local, opt-in acceptance runners. No registry or device calls.
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const { createHash } = require("node:crypto");
|
|
4
|
+
|
|
5
|
+
const KINDS = ["candidate-install", "dsh-scripted", "dsh-device", "public-bridge"];
|
|
6
|
+
const nonempty = (value) => typeof value === "string" && value.length > 0;
|
|
7
|
+
const validArtifact = (value) => value && nonempty(value.name) && nonempty(value.version) && /^[a-f0-9]{64}$/.test(value.sha256);
|
|
8
|
+
function sha256(file) {
|
|
9
|
+
return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function receipt({ tarball, name, version, kind, expectedSha256, notVerified = [], tools = {} }) {
|
|
13
|
+
if (!KINDS.includes(kind)) throw new Error(`Unknown acceptance kind: ${kind}`);
|
|
14
|
+
if (!nonempty(name) || !nonempty(version)) throw new Error("Acceptance requires package name and version");
|
|
15
|
+
const digest = sha256(tarball);
|
|
16
|
+
if (!expectedSha256 || digest !== expectedSha256) throw new Error("Tarball changed during acceptance; rerun against an immutable candidate");
|
|
17
|
+
return {
|
|
18
|
+
schema: 1, kind, result: "PASS", checkedAt: new Date().toISOString(),
|
|
19
|
+
artifact: { name, version, sha256: digest },
|
|
20
|
+
environment: { platform: process.platform, arch: process.arch, node: process.version, tools },
|
|
21
|
+
notVerified,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function collect(artifact, reports) {
|
|
26
|
+
if (!validArtifact(artifact)) throw new Error("Invalid candidate artifact identity");
|
|
27
|
+
if (!reports.length) throw new Error("Provide at least one acceptance report");
|
|
28
|
+
const receipts = reports.map((report) => {
|
|
29
|
+
const entry = report?.acceptance;
|
|
30
|
+
if (entry?.schema !== 1 || !KINDS.includes(entry.kind) || entry.result !== "PASS"
|
|
31
|
+
|| !validArtifact(entry.artifact)
|
|
32
|
+
|| !Array.isArray(entry.notVerified) || !entry.notVerified.every(nonempty)
|
|
33
|
+
|| !["platform", "arch", "node"].every((key) => nonempty(entry.environment?.[key]))
|
|
34
|
+
|| !nonempty(entry.checkedAt) || !Number.isFinite(Date.parse(entry.checkedAt))
|
|
35
|
+
|| (report.result !== undefined && report.result !== "PASS")
|
|
36
|
+
|| (report.results !== undefined && report.results !== "PASS")) {
|
|
37
|
+
throw new Error("Invalid or legacy acceptance report; rerun the relevant check manually");
|
|
38
|
+
}
|
|
39
|
+
if (["name", "version", "sha256"].some((key) => artifact[key] !== entry.artifact?.[key])) {
|
|
40
|
+
throw new Error("Acceptance report belongs to a different tarball (name/version/SHA256)");
|
|
41
|
+
}
|
|
42
|
+
return entry;
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
schema: 1, artifact, receipts,
|
|
46
|
+
missing: KINDS.filter((kind) => !receipts.some((entry) => entry.kind === kind)),
|
|
47
|
+
notVerified: [...new Set(receipts.flatMap((entry) => entry.notVerified))],
|
|
48
|
+
// Deliberately no overall PASS: collecting evidence does not approve a release.
|
|
49
|
+
approval: "manual review required; DSH acceptance is user-run, not a CI gate",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { KINDS, sha256, receipt, collect };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Deterministic integration check: scripted model, real DSH Agent and shell tools.
|
|
2
|
+
// This is not evidence that a real model selects the Skill autonomously.
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { createServer } from 'node:http'
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, join, resolve } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { createDshRunner, quote, sendMessage, stringsInside } from './dsh-test-support.mjs'
|
|
9
|
+
import acceptance from './acceptance-report.cjs'
|
|
10
|
+
|
|
11
|
+
const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
12
|
+
const args = process.argv.slice(2)
|
|
13
|
+
assert.equal(args.length, 2, 'Usage: node npm/dsh-integration.mjs --tarball /absolute/path/package.tgz')
|
|
14
|
+
assert.equal(args[0], '--tarball')
|
|
15
|
+
assert.notEqual(process.platform, 'win32', 'This Bash integration runner requires macOS/Linux; Windows acceptance is separate.')
|
|
16
|
+
const tarball = resolve(args[1])
|
|
17
|
+
assert.ok(existsSync(tarball), `Tarball does not exist: ${tarball}`)
|
|
18
|
+
const tarballSha256 = acceptance.sha256(tarball)
|
|
19
|
+
mkdirSync(join(repository, '.tmp'), { recursive: true })
|
|
20
|
+
// Spaces and an apostrophe exercise installation-path quoting as well as argv.
|
|
21
|
+
const scratch = mkdtempSync(join(repository, '.tmp', "fnos dsh 'integration-"))
|
|
22
|
+
const workspace = join(scratch, 'workspace')
|
|
23
|
+
const dshHome = join(scratch, 'home')
|
|
24
|
+
const nodeBin = join(scratch, 'node-only-path')
|
|
25
|
+
mkdirSync(workspace)
|
|
26
|
+
mkdirSync(nodeBin)
|
|
27
|
+
symlinkSync(process.execPath, join(nodeBin, 'node'))
|
|
28
|
+
mkdirSync(join(scratch, 'session', 'profiles'), { recursive: true })
|
|
29
|
+
writeFileSync(join(scratch, 'session', 'profiles', 'integration.json'), JSON.stringify({
|
|
30
|
+
host: '127.0.0.1', port: 1, username: 'integration', uid: 1, admin: false,
|
|
31
|
+
accessToken: 'integration-not-a-real-token', oauthClientId: 'YJNMPJUGA9',
|
|
32
|
+
oauthScope: 'trim.user.all trim.file.read trim.file.write trim.download.all trim.appcenter.all trim.photo.all trim.baidu_netdisk.all trim.system.all trim.log.all trim.storage.all trim.docker.all', updatedAt: '2026-10-09T00:00:00Z',
|
|
33
|
+
}), { mode: 0o600 })
|
|
34
|
+
const env = {
|
|
35
|
+
...process.env, DSH_HOME: dshHome,
|
|
36
|
+
TRIM_CLI_CONFIG_DIR: join(scratch, 'session'), TRIM_CLI_SESSION_STORAGE: 'file',
|
|
37
|
+
DSH_TELEMETRY_MODE: 'DISABLED', DSH_TELEMETRY_DISABLED: '1',
|
|
38
|
+
DSH_PERMISSION_MODE: 'workspace-write',
|
|
39
|
+
npm_config_cache: process.env.FNOS_DSH_NPM_CACHE || join(scratch, 'npm-cache'),
|
|
40
|
+
}
|
|
41
|
+
const runDsh = createDshRunner({ workspace, env, scratch })
|
|
42
|
+
|
|
43
|
+
// A bridge test changes only this isolated profile's dependency resolution.
|
|
44
|
+
// The generated public bridge keeps its exact registry-version dependency.
|
|
45
|
+
const bridgeTarball = process.env.FNOS_DSH_BRIDGE_TARBALL && resolve(process.env.FNOS_DSH_BRIDGE_TARBALL)
|
|
46
|
+
if (bridgeTarball) {
|
|
47
|
+
assert.ok(existsSync(bridgeTarball))
|
|
48
|
+
const profileDirectory = join(dshHome, 'profiles', 'headless')
|
|
49
|
+
mkdirSync(profileDirectory, { recursive: true })
|
|
50
|
+
writeFileSync(join(profileDirectory, 'package.json'), JSON.stringify({
|
|
51
|
+
name: 'dsh-profile-headless', private: true, dependencies: {},
|
|
52
|
+
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'] } },
|
|
53
|
+
}, null, 2))
|
|
54
|
+
writeFileSync(join(profileDirectory, 'pnpm-workspace.yaml'),
|
|
55
|
+
`packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\noverrides:\n '@fnnas-labs/fnos-cli': ${JSON.stringify(`file:${tarball}`)}\n`)
|
|
56
|
+
}
|
|
57
|
+
const installArgs = ['plugin', '--profile', 'headless', 'add', bridgeTarball || tarball]
|
|
58
|
+
if (process.env.FNOS_DSH_PNPM_STORE) installArgs.push('--store-dir', resolve(process.env.FNOS_DSH_PNPM_STORE))
|
|
59
|
+
console.log(`DSH integration scratch: ${scratch}`)
|
|
60
|
+
const dshVersion = (await runDsh(['--version'])).trim()
|
|
61
|
+
assert.equal(dshVersion, '0.2.0-rc.2', 'Integration acceptance is pinned to the verified DSH version')
|
|
62
|
+
await runDsh(installArgs)
|
|
63
|
+
const installed = join(dshHome, 'profiles', 'headless', 'node_modules', '@fnnas-labs', 'fnos-cli')
|
|
64
|
+
const metadata = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8'))
|
|
65
|
+
const manifest = JSON.parse(readFileSync(join(installed, 'skill', 'manifest.json'), 'utf8'))
|
|
66
|
+
assert.equal(metadata.name, '@fnnas-labs/fnos-cli')
|
|
67
|
+
assert.equal(manifest.name, 'fnos-cli')
|
|
68
|
+
assert.equal(manifest.version, metadata.version, 'CLI and Skill must ship in the same versioned package')
|
|
69
|
+
const expectedVersion = `fnos-cli ${metadata.version}`
|
|
70
|
+
const expectedLauncher = join(installed, 'npm', 'run.cjs')
|
|
71
|
+
const steps = [
|
|
72
|
+
{ name: 'skill', input: { name: 'fnos-cli' }, label: 'skill' },
|
|
73
|
+
{ name: 'bash', label: 'no-global-cli', input: { command: `PATH=${quote(nodeBin)}; export PATH; command -v fnos-cli; command -v trim-cli`, description: 'Verify no global CLI is accessible' } },
|
|
74
|
+
{ name: 'bash', label: 'version', input: { description: 'Read the package-local CLI version' } },
|
|
75
|
+
{ name: 'bash', label: 'help', input: { description: 'Read native CLI help' } },
|
|
76
|
+
{ name: 'bash', label: 'write-confirmation', input: { description: 'Verify an unconfirmed write is rejected without connecting' } },
|
|
77
|
+
{ name: 'bash', label: 'read-only-session', input: { description: 'Check the isolated fake profile with a read-only command' } },
|
|
78
|
+
]
|
|
79
|
+
const observations = []
|
|
80
|
+
let stepIndex = 0
|
|
81
|
+
let serverError
|
|
82
|
+
|
|
83
|
+
function launcherFromSkill(messages) {
|
|
84
|
+
const content = stringsInside(messages).find(value => value.includes('Base directory for this skill: '))
|
|
85
|
+
const base = /Base directory for this skill: ([^\n]+)/.exec(content || '')?.[1]
|
|
86
|
+
assert.ok(base, 'DSH Skill tool must return a resource directory')
|
|
87
|
+
assert.match(content, /DSH package-local invocation/, 'Skill must explain its package-local entry')
|
|
88
|
+
assert.match(content, /\.\.\/npm\/run\.cjs/)
|
|
89
|
+
assert.match(content, /same (?:system )?user/)
|
|
90
|
+
assert.match(content, /npx -y --package=@fnnas-labs\/fnos-cli@[^ ]+ fnos-cli login/)
|
|
91
|
+
assert.match(content, /--host <nas-host> --port <port>/, 'First login must identify the user-selected NAS endpoint')
|
|
92
|
+
assert.match(content, /--yes/)
|
|
93
|
+
const launcher = resolve(base, '../npm/run.cjs')
|
|
94
|
+
assert.equal(launcher, expectedLauncher, 'Derive the same-package launcher from Skill output, not a preseeded command')
|
|
95
|
+
return launcher
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const server = createServer(async (request, response) => {
|
|
99
|
+
try {
|
|
100
|
+
assert.equal(request.method, 'POST')
|
|
101
|
+
assert.equal(request.url, '/v1/messages')
|
|
102
|
+
const chunks = []
|
|
103
|
+
for await (const chunk of request) chunks.push(chunk)
|
|
104
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
105
|
+
const step = body.tools?.length ? steps[stepIndex] : null
|
|
106
|
+
if (step) {
|
|
107
|
+
assert.ok(body.tools.some(tool => tool.name === step.name), `DSH must advertise ${step.name}`)
|
|
108
|
+
if (stepIndex === 0) {
|
|
109
|
+
assert.match(JSON.stringify(body), /<available_skills>[\s\S]*fnos-cli/, 'DSH must expose fnos-cli in the model-facing Skill catalog')
|
|
110
|
+
}
|
|
111
|
+
if (stepIndex >= 2) {
|
|
112
|
+
const launcher = launcherFromSkill(body.messages)
|
|
113
|
+
const commands = {
|
|
114
|
+
version: '--version', help: '--help',
|
|
115
|
+
'write-confirmation': `--profile integration file rename ${quote('/vol1/1/a file;$(echo forbidden)')} ${quote("new 'name'")}`,
|
|
116
|
+
'read-only-session': '--profile integration system info',
|
|
117
|
+
}
|
|
118
|
+
step.input.command = `PATH=${quote(nodeBin)}; export PATH; node ${quote(launcher)} ${commands[step.label]}`
|
|
119
|
+
}
|
|
120
|
+
observations.push({ label: step.label, tool: step.name })
|
|
121
|
+
}
|
|
122
|
+
sendMessage(response, step, stepIndex)
|
|
123
|
+
if (step) stepIndex += 1
|
|
124
|
+
} catch (error) {
|
|
125
|
+
serverError = error
|
|
126
|
+
sendMessage(response, null, stepIndex)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
await new Promise(ready => server.listen(0, '127.0.0.1', ready))
|
|
130
|
+
env.DEEPSEEK_BASE_URL = `http://127.0.0.1:${server.address().port}`
|
|
131
|
+
env.DEEPSEEK_API_KEY = 'scripted-local-model-not-a-secret'
|
|
132
|
+
let stdout
|
|
133
|
+
try {
|
|
134
|
+
stdout = await runDsh(['headless', '--json', 'Load the fnOS Skill and check its package-local CLI.'])
|
|
135
|
+
} finally {
|
|
136
|
+
await new Promise(done => server.close(done))
|
|
137
|
+
}
|
|
138
|
+
if (serverError) throw serverError
|
|
139
|
+
const events = stdout.split('\n').flatMap(line => { try { return [JSON.parse(line)] } catch { return [] } })
|
|
140
|
+
const results = events.filter(event => event.type === 'tool_result')
|
|
141
|
+
assert.equal(results.length, steps.length, 'Every scripted command must reach a real DSH tool result')
|
|
142
|
+
assert.ok(results.every(result => result.status === 'completed'), 'Every tool must execute rather than return a tool-layer error')
|
|
143
|
+
const outputs = results.map(result => JSON.stringify(result))
|
|
144
|
+
assert.match(outputs[0], /DSH package-local invocation/)
|
|
145
|
+
assert.equal(results[1].result, '(no output)\n[exit code: 1]', 'Neither CLI command may resolve on the isolated PATH')
|
|
146
|
+
assert.ok(outputs[2].includes(expectedVersion), `Expected ${expectedVersion}`)
|
|
147
|
+
assert.match(outputs[3], /Usage:/, 'Help must execute the native binary, not just the Node version shortcut')
|
|
148
|
+
assert.match(outputs[4], /pass --yes to confirm/)
|
|
149
|
+
assert.doesNotMatch(outputs[4], /failed to get file data/)
|
|
150
|
+
assert.match(outputs[5], /failed to get system info from 127\.0\.0\.1:1/)
|
|
151
|
+
const report = {
|
|
152
|
+
acceptance: acceptance.receipt({ tarball, name: metadata.name, version: metadata.version,
|
|
153
|
+
kind: 'dsh-scripted', expectedSha256: tarballSha256, tools: { dsh: dshVersion },
|
|
154
|
+
notVerified: ['real-model autonomous selection', 'OAuth/device acceptance', 'npm registry install', 'DSH Market install'] }),
|
|
155
|
+
model: 'scripted (not real-model acceptance)', dsh: dshVersion, version: metadata.version,
|
|
156
|
+
observations, results: 'PASS', scratch,
|
|
157
|
+
}
|
|
158
|
+
writeFileSync(join(scratch, 'report.json'), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 })
|
|
159
|
+
console.log(JSON.stringify(report, null, 2))
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Shared test harness only; the consumer DSH adapter does not import this file.
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { writeFileSync } from 'node:fs'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export const quote = value => `'${value.replaceAll("'", "'\\''")}'`
|
|
8
|
+
|
|
9
|
+
export function stringsInside(value) {
|
|
10
|
+
if (typeof value === 'string') return [value]
|
|
11
|
+
if (Array.isArray(value)) return value.flatMap(stringsInside)
|
|
12
|
+
return value && typeof value === 'object' ? Object.values(value).flatMap(stringsInside) : []
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createDshRunner({ workspace, env, scratch }) {
|
|
16
|
+
const prefix = env.FNOS_DSH_CLI
|
|
17
|
+
? { command: process.execPath, args: [resolve(env.FNOS_DSH_CLI)] }
|
|
18
|
+
: { command: 'npm', args: ['exec', '--yes', '--package=@deepseek-ai/dsh@0.2.0-rc.2', '--', 'dsh'] }
|
|
19
|
+
return async args => {
|
|
20
|
+
const child = spawn(prefix.command, [...prefix.args, ...args], { cwd: workspace, env, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
21
|
+
let stdout = ''
|
|
22
|
+
let stderr = ''
|
|
23
|
+
child.stdout.on('data', data => { stdout += data })
|
|
24
|
+
child.stderr.on('data', data => { stderr += data })
|
|
25
|
+
const timer = setTimeout(() => child.kill('SIGTERM'), 180_000)
|
|
26
|
+
try {
|
|
27
|
+
const code = await new Promise((done, reject) => {
|
|
28
|
+
child.once('error', reject)
|
|
29
|
+
child.once('close', done)
|
|
30
|
+
})
|
|
31
|
+
assert.equal(code, 0, `DSH ${args[0]} failed (${code}); inspect private scratch logs: ${scratch}`)
|
|
32
|
+
return stdout
|
|
33
|
+
} finally {
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
writeFileSync(join(scratch, `${args[0]}.stdout.log`), stdout, { mode: 0o600 })
|
|
36
|
+
writeFileSync(join(scratch, `${args[0]}.stderr.log`), stderr, { mode: 0o600 })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sendMessage(response, step, index) {
|
|
42
|
+
const events = [
|
|
43
|
+
{ type: 'message_start', message: { id: `msg-${index}`, type: 'message', role: 'assistant', model: 'deepseek-chat', content: [], stop_reason: null, usage: { input_tokens: 10, output_tokens: 0 } } },
|
|
44
|
+
{ type: 'content_block_start', index: 0, content_block: step
|
|
45
|
+
? { type: 'tool_use', id: `call-${index}`, name: step.name, input: {} }
|
|
46
|
+
: { type: 'text', text: 'Scripted-model tool-chain check complete.' } },
|
|
47
|
+
...(step ? [{ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: JSON.stringify(step.input) } }] : []),
|
|
48
|
+
{ type: 'content_block_stop', index: 0 },
|
|
49
|
+
{ type: 'message_delta', delta: { stop_reason: step ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 10 } },
|
|
50
|
+
{ type: 'message_stop' },
|
|
51
|
+
]
|
|
52
|
+
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
53
|
+
for (const event of events) response.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
|
|
54
|
+
response.end()
|
|
55
|
+
}
|
package/npm/dsh.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
export const name = 'fnos-cli'
|
|
5
|
+
export const inject = ['skills']
|
|
6
|
+
|
|
7
|
+
export function apply(ctx) {
|
|
8
|
+
const skillDirectory = new URL('../skill/', import.meta.url)
|
|
9
|
+
const metadata = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
10
|
+
const markdown = readFileSync(new URL('SKILL.md', skillDirectory), 'utf8')
|
|
11
|
+
const body = markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim()
|
|
12
|
+
const invocation = `## DSH package-local invocation
|
|
13
|
+
|
|
14
|
+
1. Resolve \`../npm/run.cjs\` against the base directory returned by this Skill. This is the launcher shipped with this Skill's npm package, version ${metadata.version}.
|
|
15
|
+
2. In the local DSH Host shell, run \`node "<resolved-launcher>" <arguments>\`. Replace every \`fnos-cli\` or \`trim-cli\` command prefix in this Skill and its references with that invocation. Quote the resolved path and each argument for the active shell (Bash or PowerShell), including spaces, apostrophes and shell metacharacters. Keep each argument separate. The launcher's path is outside the workspace and does not require changing the working directory.
|
|
16
|
+
3. Begin with \`--version\` or \`--help\`, then use the requested read-only command and the chosen \`--profile\`. This entry does not require a global CLI installation and never adds \`--yes\` for you. Only include \`--yes\` after the user explicitly authorizes the corresponding write.
|
|
17
|
+
|
|
18
|
+
### First login or an expired session
|
|
19
|
+
|
|
20
|
+
For a new profile, ask the user to run \`npx -y --package=@fnnas-labs/fnos-cli@${metadata.version} fnos-cli login --profile <profile> --host <nas-host> --port <port>\` in their real terminal. Use the user's chosen NAS host and port; ask when unknown instead of assuming localhost. An existing profile may reuse its saved endpoint by omitting the host/port options unless the user chooses another device. The terminal must be on the same DSH Host, as the same system user, with the same configuration environment (including \`TRIM_CLI_CONFIG_DIR\`) and profile as the CLI invocation. The user completes browser authorization and enters the code in that terminal. Resume the requested read-only command only after the user confirms login. Installation and Skill loading do not log in. Keep passwords and authorization codes in the user's terminal; do not request or submit them through Agent tools.
|
|
21
|
+
|
|
22
|
+
The default one-shot DSH shell has no interactive login TTY. A remote browser does not share the DSH Host's session. SSH shell providers and containers may execute on a different host with different files and sessions: ask the user to install and log in in the actual execution environment before proceeding; this local package path must only be used on the host where this package is installed. macOS Keychain access may require the user's system approval.
|
|
23
|
+
`
|
|
24
|
+
return ctx.skills.register({
|
|
25
|
+
name: 'fnos-cli',
|
|
26
|
+
source: 'runtime',
|
|
27
|
+
description: 'Manage fnOS NAS files, downloads, storage, apps and system state with the package-local fnOS CLI. Use for fnOS operations and authentication guidance.',
|
|
28
|
+
content: `${invocation}\n${body}`,
|
|
29
|
+
resourceBase: { kind: 'directory', path: fileURLToPath(skillDirectory) },
|
|
30
|
+
})
|
|
31
|
+
}
|
package/npm/install.cjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require("node:child_process");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const {
|
|
6
|
+
PACKAGE_NAME,
|
|
7
|
+
PRIMARY_COMMAND,
|
|
8
|
+
copySkillAtomically,
|
|
9
|
+
resolveSkillDestination,
|
|
10
|
+
validatePackagedSkill
|
|
11
|
+
} = require("./package-lib.cjs");
|
|
12
|
+
|
|
13
|
+
function usage() {
|
|
14
|
+
return `Usage: ${PRIMARY_COMMAND} install [options]
|
|
15
|
+
|
|
16
|
+
Install the native ${PRIMARY_COMMAND} command and its Agent Skill.
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--project Install the Skill under ./.agents/skills instead of the user home
|
|
20
|
+
--skill-only Install only the Skill; do not install ${PRIMARY_COMMAND} globally with npm
|
|
21
|
+
--skip-skill Install only the global ${PRIMARY_COMMAND} command
|
|
22
|
+
-h, --help Show this help`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseArgs(args) {
|
|
26
|
+
const options = { project: false, skillOnly: false, skipSkill: false, help: false };
|
|
27
|
+
for (const arg of args) {
|
|
28
|
+
switch (arg) {
|
|
29
|
+
case "--project":
|
|
30
|
+
options.project = true;
|
|
31
|
+
break;
|
|
32
|
+
case "--skill-only":
|
|
33
|
+
options.skillOnly = true;
|
|
34
|
+
break;
|
|
35
|
+
case "--skip-skill":
|
|
36
|
+
options.skipSkill = true;
|
|
37
|
+
break;
|
|
38
|
+
case "-h":
|
|
39
|
+
case "--help":
|
|
40
|
+
options.help = true;
|
|
41
|
+
break;
|
|
42
|
+
default:
|
|
43
|
+
throw new Error(`Unknown install option: ${arg}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (options.skillOnly && options.skipSkill) {
|
|
47
|
+
throw new Error("--skill-only and --skip-skill cannot be used together");
|
|
48
|
+
}
|
|
49
|
+
return options;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function npmCommand(platform = process.platform) {
|
|
53
|
+
return platform === "win32" ? "npm.cmd" : "npm";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function runNpm(command, args, options) {
|
|
57
|
+
// Windows cannot exec a .cmd shim without a shell. Invoke npm's JS entrypoint
|
|
58
|
+
// with Node instead, keeping package arguments out of shell interpolation.
|
|
59
|
+
if (process.platform === "win32" && command === "npm.cmd") {
|
|
60
|
+
const entry = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
|
|
61
|
+
return spawnSync(process.execPath, [entry, ...args], options);
|
|
62
|
+
}
|
|
63
|
+
return spawnSync(command, args, options);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function globalPackageVersion(command = npmCommand()) {
|
|
67
|
+
const result = runNpm(command, ["list", "--global", PACKAGE_NAME, "--depth=0", "--json"], {
|
|
68
|
+
encoding: "utf8",
|
|
69
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
70
|
+
});
|
|
71
|
+
if (!result.stdout) return null;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(result.stdout);
|
|
74
|
+
return parsed.dependencies && parsed.dependencies[PACKAGE_NAME]
|
|
75
|
+
? parsed.dependencies[PACKAGE_NAME].version || null
|
|
76
|
+
: null;
|
|
77
|
+
} catch (_) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function installGlobalPackage(version, command = npmCommand()) {
|
|
83
|
+
if (globalPackageVersion(command) === version) {
|
|
84
|
+
console.log(`${PACKAGE_NAME} ${version} is already installed globally.`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const packageSpec = `${PACKAGE_NAME}@${version}`;
|
|
89
|
+
console.log(`Installing ${packageSpec} globally...`);
|
|
90
|
+
const result = runNpm(command, ["install", "--global", packageSpec], { stdio: "inherit" });
|
|
91
|
+
if (result.error) {
|
|
92
|
+
throw new Error(`Cannot run npm: ${result.error.message}`);
|
|
93
|
+
}
|
|
94
|
+
if (result.status !== 0) {
|
|
95
|
+
throw new Error(`npm install failed with exit code ${result.status}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function main(args = process.argv.slice(2), context = {}) {
|
|
100
|
+
const options = parseArgs(args);
|
|
101
|
+
if (options.help) {
|
|
102
|
+
console.log(usage());
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const packageRoot = context.packageRoot || path.resolve(__dirname, "..");
|
|
107
|
+
const validated = validatePackagedSkill(packageRoot, {
|
|
108
|
+
platform: context.platform,
|
|
109
|
+
arch: context.arch
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!options.skillOnly) {
|
|
113
|
+
installGlobalPackage(validated.packageJson.version, context.npmCommand);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!options.skipSkill) {
|
|
117
|
+
const destination = resolveSkillDestination({
|
|
118
|
+
project: options.project,
|
|
119
|
+
cwd: context.cwd,
|
|
120
|
+
homeDir: context.homeDir
|
|
121
|
+
});
|
|
122
|
+
for (const warning of copySkillAtomically(validated.skillRoot, destination)) {
|
|
123
|
+
console.warn(`Warning: ${warning}`);
|
|
124
|
+
}
|
|
125
|
+
console.log(`Agent Skill installed: ${destination}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(`Installation complete. Existing sessions are unchanged. If needed, run \`${PRIMARY_COMMAND} login\` to start OAuth authorization.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (require.main === module) {
|
|
132
|
+
try {
|
|
133
|
+
main();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.error(`${PRIMARY_COMMAND} install failed: ${error.message}`);
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { globalPackageVersion, installGlobalPackage, main, npmCommand, parseArgs, runNpm, usage };
|