@fnnas-labs/fnos-cli 0.0.0-stage → 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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -2
  3. package/THIRD_PARTY_NOTICES.txt +38904 -0
  4. package/cordis.patch.yml +3 -0
  5. package/npm/README.dsh.md +49 -0
  6. package/npm/acceptance-report.cjs +53 -0
  7. package/npm/dsh-integration.mjs +159 -0
  8. package/npm/dsh-test-support.mjs +55 -0
  9. package/npm/dsh.mjs +31 -0
  10. package/npm/install.cjs +130 -0
  11. package/npm/package-lib.cjs +274 -0
  12. package/npm/run.cjs +39 -0
  13. package/package.json +46 -4
  14. package/skill/LICENSE +21 -0
  15. package/skill/SKILL.md +143 -0
  16. package/skill/THIRD_PARTY_NOTICES.txt +38904 -0
  17. package/skill/bin/trim-cli-darwin-arm64 +0 -0
  18. package/skill/bin/trim-cli-darwin-x64 +0 -0
  19. package/skill/bin/trim-cli-linux-arm64 +0 -0
  20. package/skill/bin/trim-cli-linux-x64 +0 -0
  21. package/skill/bin/trim-cli-windows-arm64.exe +0 -0
  22. package/skill/bin/trim-cli-windows-x64.exe +0 -0
  23. package/skill/build-provenance.json +44 -0
  24. package/skill/entries/trim-app.md +63 -0
  25. package/skill/entries/trim-baidu-netdisk.md +42 -0
  26. package/skill/entries/trim-docker.md +35 -0
  27. package/skill/entries/trim-download.md +38 -0
  28. package/skill/entries/trim-file.md +30 -0
  29. package/skill/entries/trim-log.md +32 -0
  30. package/skill/entries/trim-monitor.md +41 -0
  31. package/skill/entries/trim-network.md +22 -0
  32. package/skill/entries/trim-photos.md +32 -0
  33. package/skill/entries/trim-shared.md +54 -0
  34. package/skill/entries/trim-storage.md +34 -0
  35. package/skill/entries/trim-system.md +59 -0
  36. package/skill/entries/trim-user.md +28 -0
  37. package/skill/manifest.json +43 -0
  38. package/skill/reference/_conventions.md +76 -0
  39. package/skill/reference/_index.md +78 -0
  40. package/skill/reference/app-center.md +234 -0
  41. package/skill/reference/baidu-netdisk.md +221 -0
  42. package/skill/reference/dockermgr.md +244 -0
  43. package/skill/reference/download.md +357 -0
  44. package/skill/reference/file.md +752 -0
  45. package/skill/reference/log.md +224 -0
  46. package/skill/reference/network.md +41 -0
  47. package/skill/reference/oauth.md +51 -0
  48. package/skill/reference/photos.md +158 -0
  49. package/skill/reference/power.md +34 -0
  50. package/skill/reference/resmon.md +160 -0
  51. package/skill/reference/stor.md +525 -0
  52. package/skill/reference/sysinfo.md +133 -0
  53. package/skill/reference/user.md +139 -0
  54. package/skill/reference/workflows/device-validation.md +66 -0
  55. package/skill/reference/workflows/file-routing.md +44 -0
  56. package/skill/reference/workflows/file-upload-validation.md +63 -0
  57. package/skill/reference/workflows/photos-routing.md +83 -0
  58. package/skill/reference/workflows/storage-dangerous-ops.md +53 -0
  59. package/skill/scripts/fnos-cli +63 -0
  60. package/skill/scripts/fnos-cli.cmd +36 -0
  61. package/skill/scripts/fnos-cli.ps1 +31 -0
  62. package/skill/scripts/trim-cli +63 -0
  63. package/skill/scripts/trim-cli.cmd +36 -0
  64. package/skill/scripts/trim-cli.ps1 +31 -0
@@ -0,0 +1,274 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+ const { createHash } = require("node:crypto");
5
+
6
+ const runtimePackageJson = JSON.parse(
7
+ fs.readFileSync(path.resolve(__dirname, "..", "package.json"), "utf8")
8
+ );
9
+ const PACKAGE_NAME = runtimePackageJson.name;
10
+ const SKILL_NAME = runtimePackageJson.trimCliSkillName;
11
+ const PRIMARY_COMMAND = runtimePackageJson.cliPrimaryCommand || "trim-cli";
12
+ const OWNERSHIP_FILE = ".fnos-cli-ownership.json";
13
+ if (runtimePackageJson.bin?.[PRIMARY_COMMAND] !== "npm/run.cjs") {
14
+ throw new Error(`Package does not expose the primary command ${PRIMARY_COMMAND}`);
15
+ }
16
+ const PLATFORM_BINARIES = Object.freeze({
17
+ "darwin-arm64": "trim-cli-darwin-arm64",
18
+ "darwin-x64": "trim-cli-darwin-x64",
19
+ "linux-arm64": "trim-cli-linux-arm64",
20
+ "linux-x64": "trim-cli-linux-x64",
21
+ "windows-arm64": "trim-cli-windows-arm64.exe",
22
+ "windows-x64": "trim-cli-windows-x64.exe"
23
+ });
24
+
25
+ function platformKey(platform = process.platform, arch = process.arch) {
26
+ const platformName = {
27
+ darwin: "darwin",
28
+ linux: "linux",
29
+ win32: "windows"
30
+ }[platform];
31
+ const archName = {
32
+ arm64: "arm64",
33
+ x64: "x64"
34
+ }[arch];
35
+
36
+ if (!platformName || !archName) {
37
+ throw new Error(`Unsupported platform: ${platform}-${arch}`);
38
+ }
39
+ return `${platformName}-${archName}`;
40
+ }
41
+
42
+ function packagedSkillRoot(packageRoot, options = {}) {
43
+ if (options.skillRoot) return options.skillRoot;
44
+ const skillName = options.expectedSkillName || SKILL_NAME;
45
+ const packaged = path.join(packageRoot, "skill");
46
+ if (fs.existsSync(path.join(packaged, "SKILL.md"))) return packaged;
47
+ return path.join(packaged, skillName === "fnos-cli" ? "fnos" : skillName);
48
+ }
49
+
50
+ function readJson(filePath, label) {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
53
+ } catch (error) {
54
+ throw new Error(`Cannot read ${label} at ${filePath}: ${error.message}`);
55
+ }
56
+ }
57
+
58
+ function resolvePackagedBinary(packageRoot, platform, arch) {
59
+ const key = platformKey(platform, arch);
60
+ const binaryName = PLATFORM_BINARIES[key];
61
+ const binaryPath = path.join(packagedSkillRoot(packageRoot), "bin", binaryName);
62
+ if (!fs.existsSync(binaryPath)) {
63
+ throw new Error(`Package is missing the ${key} binary: ${binaryPath}`);
64
+ }
65
+ return binaryPath;
66
+ }
67
+
68
+ function validatePackagedSkill(packageRoot, options = {}) {
69
+ const packageJson = readJson(path.join(packageRoot, "package.json"), "package.json");
70
+ const expectedPackageName = options.expectedPackageName || PACKAGE_NAME;
71
+ const expectedSkillName = options.expectedSkillName || SKILL_NAME;
72
+ if (packageJson.name !== expectedPackageName) {
73
+ throw new Error(`Expected package name ${expectedPackageName}, got ${packageJson.name || "<missing>"}`);
74
+ }
75
+
76
+ const skillRoot = packagedSkillRoot(packageRoot, options);
77
+ const skillEntry = path.join(skillRoot, "SKILL.md");
78
+ if (!fs.existsSync(skillEntry)) {
79
+ throw new Error(`Package is missing the Skill entry: ${skillEntry}`);
80
+ }
81
+
82
+ const manifest = readJson(path.join(skillRoot, "manifest.json"), "Skill manifest");
83
+ if (manifest.name !== expectedSkillName) {
84
+ throw new Error(`Expected Skill name ${expectedSkillName}, got ${manifest.name || "<missing>"}`);
85
+ }
86
+ if (manifest.version !== packageJson.version) {
87
+ throw new Error(
88
+ `Package version ${packageJson.version} does not match Skill version ${manifest.version || "<missing>"}`
89
+ );
90
+ }
91
+
92
+ const requiredKeys = options.requireAllPlatforms
93
+ ? Object.keys(PLATFORM_BINARIES)
94
+ : [platformKey(options.platform, options.arch)];
95
+ for (const key of requiredKeys) {
96
+ const expectedRelativePath = `bin/${PLATFORM_BINARIES[key]}`;
97
+ if (!manifest.bin || manifest.bin[key] !== expectedRelativePath) {
98
+ throw new Error(`Skill manifest does not map ${key} to ${expectedRelativePath}`);
99
+ }
100
+ const binaryPath = path.join(skillRoot, expectedRelativePath);
101
+ if (!fs.existsSync(binaryPath)) {
102
+ throw new Error(`Skill is missing the ${key} binary: ${binaryPath}`);
103
+ }
104
+ }
105
+
106
+ return { packageJson, manifest, skillRoot };
107
+ }
108
+
109
+ function resolveSkillDestination({ project = false, cwd = process.cwd(), homeDir = os.homedir() } = {}) {
110
+ const base = project ? cwd : homeDir;
111
+ return path.join(base, ".agents", "skills", SKILL_NAME);
112
+ }
113
+
114
+ // The full tree is recorded, including empty directories. A changed file,
115
+ // additional user file or symlink makes the installation ineligible for removal.
116
+ function skillDigests(root) {
117
+ const entries = Object.create(null);
118
+ if (!fs.lstatSync(root).isDirectory()) throw new Error(`Skill is not a directory: ${root}`);
119
+ function visit(directory, prefix = "") {
120
+ for (const name of fs.readdirSync(directory).sort()) {
121
+ if (!prefix && name === OWNERSHIP_FILE) continue;
122
+ const relative = prefix ? `${prefix}/${name}` : name;
123
+ const absolute = path.join(directory, name);
124
+ const stat = fs.lstatSync(absolute);
125
+ if (stat.isDirectory()) {
126
+ entries[relative] = null;
127
+ visit(absolute, relative);
128
+ } else if (stat.isFile()) {
129
+ entries[relative] = createHash("sha256").update(fs.readFileSync(absolute)).digest("hex");
130
+ } else {
131
+ throw new Error(`Skill contains a symlink or unsupported file: ${absolute}`);
132
+ }
133
+ }
134
+ }
135
+ visit(root);
136
+ return entries;
137
+ }
138
+
139
+ function verifiedOwnership(root) {
140
+ try {
141
+ const markerPath = path.join(root, OWNERSHIP_FILE);
142
+ if (!fs.lstatSync(markerPath).isFile()) return false;
143
+ const marker = readJson(markerPath, "Skill ownership marker");
144
+ const knownPackage = marker.package === PACKAGE_NAME
145
+ || (SKILL_NAME === "fnos-cli" && marker.package === "@trimjs/trim-cli");
146
+ const knownSkill = marker.skill === SKILL_NAME
147
+ || (SKILL_NAME === "fnos-cli" && ["fnos", "trim-cli"].includes(marker.skill));
148
+ if (marker.schema !== 1 || !knownPackage || !knownSkill
149
+ || typeof marker.version !== "string" || !marker.version
150
+ || !marker.entries || typeof marker.entries !== "object" || Array.isArray(marker.entries)) return false;
151
+ const actual = skillDigests(root);
152
+ const names = Object.keys(actual);
153
+ return names.length > 0 && names.length === Object.keys(marker.entries).length
154
+ && names.every((name) => Object.hasOwn(marker.entries, name) && marker.entries[name] === actual[name]);
155
+ } catch (_) {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ function writeOwnership(root) {
161
+ if (fs.existsSync(path.join(root, OWNERSHIP_FILE))) {
162
+ throw new Error("Packaged Skill unexpectedly contains an installation ownership marker");
163
+ }
164
+ const marker = {
165
+ schema: 1, package: PACKAGE_NAME, skill: SKILL_NAME,
166
+ version: runtimePackageJson.version, entries: skillDigests(root)
167
+ };
168
+ fs.writeFileSync(path.join(root, OWNERSHIP_FILE), `${JSON.stringify(marker, null, 2)}\n`, { flag: "wx" });
169
+ }
170
+
171
+ function pathExists(file) {
172
+ try { fs.lstatSync(file); return true; }
173
+ catch (error) { if (error.code === "ENOENT") return false; throw error; }
174
+ }
175
+
176
+ function copySkillAtomically(source, destination) {
177
+ const parent = path.dirname(destination);
178
+ fs.mkdirSync(parent, { recursive: true });
179
+
180
+ const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
181
+ const lockPath = path.join(parent, `.${SKILL_NAME}.install.lock`);
182
+ const stagingPath = path.join(parent, `.${SKILL_NAME}.install-${nonce}`);
183
+ let lockHandle;
184
+ const moved = [];
185
+ const warnings = [];
186
+
187
+ try {
188
+ try {
189
+ lockHandle = fs.openSync(lockPath, "wx", 0o600);
190
+ } catch (error) {
191
+ if (error.code === "EEXIST") {
192
+ throw new Error(`Another ${SKILL_NAME} installation is already running: ${lockPath}`);
193
+ }
194
+ throw error;
195
+ }
196
+
197
+ fs.cpSync(source, stagingPath, { recursive: true, errorOnExist: true });
198
+ if (!fs.existsSync(path.join(stagingPath, "SKILL.md"))) {
199
+ throw new Error(`Staged Skill is missing SKILL.md: ${stagingPath}`);
200
+ }
201
+ writeOwnership(stagingPath);
202
+
203
+ if (pathExists(destination)) {
204
+ if (!verifiedOwnership(destination)) {
205
+ throw new Error(`Cannot verify ownership of existing Skill; preserved without changes: ${destination}. Move it aside after reviewing your files, then retry.`);
206
+ }
207
+ }
208
+
209
+ // An unmarked historical installation cannot be distinguished from a user's
210
+ // own Skill. Never infer ownership from a familiar name or SKILL.md alone.
211
+ const legacy = SKILL_NAME === "fnos-cli" ? ["fnos", "trim-cli"] : [];
212
+ const candidates = [];
213
+ for (const name of legacy) {
214
+ const old = path.join(parent, name);
215
+ if (!pathExists(old)) continue;
216
+ if (verifiedOwnership(old)) candidates.push(old);
217
+ else warnings.push(`Cannot verify ownership; preserved legacy Skill: ${old}. Review it manually before removing it.`);
218
+ }
219
+ if (pathExists(destination)) candidates.push(destination);
220
+ for (const original of candidates) {
221
+ // Recheck after staging so edits made during a slow copy stay protected.
222
+ if (!verifiedOwnership(original)) throw new Error(`Skill ownership changed during installation; preserved: ${original}`);
223
+ const backup = path.join(parent, `.${SKILL_NAME}.backup-${nonce}-${path.basename(original)}`);
224
+ fs.renameSync(original, backup);
225
+ moved.push({ original, backup });
226
+ }
227
+ fs.renameSync(stagingPath, destination);
228
+ } catch (error) {
229
+ try { fs.rmSync(stagingPath, { recursive: true, force: true }); }
230
+ catch (cleanupError) { error.message += `; staging cleanup requires review at ${stagingPath}: ${cleanupError.message}`; }
231
+ for (const { original, backup } of moved.reverse()) {
232
+ try {
233
+ if (!pathExists(original) && pathExists(backup)) fs.renameSync(backup, original);
234
+ else error.message += `; restore requires review: ${backup} -> ${original}`;
235
+ } catch (restoreError) {
236
+ error.message += `; original data retained at ${backup}: ${restoreError.message}`;
237
+ }
238
+ }
239
+ throw error;
240
+ } finally {
241
+ if (lockHandle !== undefined) {
242
+ fs.closeSync(lockHandle);
243
+ fs.rmSync(lockPath, { force: true });
244
+ }
245
+ }
246
+
247
+ // Publication succeeded. Cleanup failures must not pretend the old tree can
248
+ // still be rolled back after some owned backups have already been removed.
249
+ for (const { backup } of moved) {
250
+ try {
251
+ if (!verifiedOwnership(backup)) {
252
+ warnings.push(`Backup ownership changed; preserved for review: ${backup}`);
253
+ continue;
254
+ }
255
+ fs.rmSync(backup, { recursive: true, force: true });
256
+ }
257
+ catch (error) { warnings.push(`Installed Skill, but could not remove owned backup ${backup}: ${error.message}`); }
258
+ }
259
+ return warnings;
260
+ }
261
+
262
+ module.exports = {
263
+ PACKAGE_NAME,
264
+ PRIMARY_COMMAND,
265
+ PLATFORM_BINARIES,
266
+ SKILL_NAME,
267
+ copySkillAtomically,
268
+ packagedSkillRoot,
269
+ platformKey,
270
+ readJson,
271
+ resolvePackagedBinary,
272
+ resolveSkillDestination,
273
+ validatePackagedSkill
274
+ };
package/npm/run.cjs ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require("node:child_process");
4
+ const path = require("node:path");
5
+ const { main: install } = require("./install.cjs");
6
+ const { PRIMARY_COMMAND, resolvePackagedBinary } = require("./package-lib.cjs");
7
+ const packageJson = require("../package.json");
8
+
9
+ const args = process.argv.slice(2);
10
+ if (args.length === 1 && (args[0] === "--version" || args[0] === "-V")) {
11
+ console.log(`${PRIMARY_COMMAND} ${packageJson.version}`);
12
+ } else if (args[0] === "install") {
13
+ try {
14
+ install(args.slice(1));
15
+ } catch (error) {
16
+ console.error(`${PRIMARY_COMMAND} install failed: ${error.message}`);
17
+ process.exitCode = 1;
18
+ }
19
+ } else {
20
+ try {
21
+ const packageRoot = path.resolve(__dirname, "..");
22
+ const binary = resolvePackagedBinary(packageRoot);
23
+ const child = spawn(binary, args, { stdio: "inherit", windowsHide: true });
24
+ child.once("error", (error) => {
25
+ console.error(`Cannot start ${PRIMARY_COMMAND}: ${error.message}`);
26
+ process.exitCode = 1;
27
+ });
28
+ child.once("exit", (code, signal) => {
29
+ if (signal && process.platform !== "win32") {
30
+ process.kill(process.pid, signal);
31
+ } else {
32
+ process.exitCode = code === null ? 1 : code;
33
+ }
34
+ });
35
+ } catch (error) {
36
+ console.error(`Cannot start ${PRIMARY_COMMAND}: ${error.message}`);
37
+ process.exitCode = 1;
38
+ }
39
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@fnnas-labs/fnos-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "fnOS CLI and Agent Skill",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/fnnas-labs/fnos-cli-skill.git"
8
+ },
9
+ "trimCliSkillName": "fnos-cli",
10
+ "cliPrimaryCommand": "fnos-cli",
11
+ "main": "npm/dsh.mjs",
12
+ "dsh": {
13
+ "bundle": {
14
+ "patch": "./cordis.patch.yml"
15
+ }
16
+ },
17
+ "license": "MIT",
18
+ "bin": {
19
+ "fnos-cli": "npm/run.cjs",
20
+ "trim-cli": "npm/run.cjs"
21
+ },
22
+ "files": [
23
+ "npm/*.cjs",
24
+ "skill/**",
25
+ "npm/dsh.mjs",
26
+ "npm/dsh-integration.mjs",
27
+ "npm/dsh-test-support.mjs",
28
+ "npm/acceptance-report.cjs",
29
+ "npm/README.dsh.md",
30
+ "cordis.patch.yml",
31
+ "THIRD_PARTY_NOTICES.txt"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "os": [
37
+ "darwin",
38
+ "linux",
39
+ "win32"
40
+ ],
41
+ "cpu": [
42
+ "x64",
43
+ "arm64"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }
package/skill/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Guangzhou TR Intelligent Manufacturing Technology Co., Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/skill/SKILL.md ADDED
@@ -0,0 +1,143 @@
1
+ ---
2
+ name: fnos-cli
3
+ description: Use fnos-cli to sign in to and manage fnOS devices, including files, photos, applications, Docker, downloads, logs, monitoring, networking, storage, users, and Baidu Netdisk. Use when the user mentions fnOS, Feiniu OS, or a mainland China device. Excludes Media APIs and international-edition devices.
4
+ ---
5
+
6
+ # fnOS
7
+
8
+ The command-line client for fnOS. It uses the HTTP API proxy for
9
+ system services and native applications: authentication, files, photos, apps,
10
+ Docker, downloads, logs, monitoring, networking, power, storage, and users.
11
+
12
+ Examples assume `fnos-cli` is available on PATH. An integration may provide
13
+ an explicit package-local launcher instead; follow that integration's instructions.
14
+
15
+ ## Standalone bundle launcher
16
+
17
+ For a standalone Skill bundle containing `scripts/`, use the absolute path to
18
+ `scripts/fnos-cli` (macOS/Linux) or `scripts/fnos-cli.cmd` (Windows)
19
+ inside this Skill's directory. Resolve it from the loaded Skill location, not the
20
+ working directory. Check `--version` before device operations.
21
+
22
+ ## Task routing
23
+
24
+ Choose the most specific entry, then follow its reference or workflow. For a
25
+ product-specific task not listed below, inspect the descriptions in `entries/`.
26
+
27
+ | Task | Entry |
28
+ | --- | --- |
29
+ | Sign in, sessions, connection failures, profiles | `entries/trim-shared.md`, `reference/_index.md` |
30
+ | Files, finder, shares, ACLs, uploads and downloads | `entries/trim-file.md`, `reference/workflows/file-routing.md` |
31
+ | Photo search, AI search, details and previews | `entries/trim-photos.md`, `reference/workflows/photos-routing.md` |
32
+ | Apps, Docker, downloads, logs and monitoring | Matching file in `entries/`, `reference/_index.md` |
33
+ | Storage writes | `entries/trim-storage.md`, `reference/workflows/storage-dangerous-ops.md` |
34
+ | Users, groups and permissions | `entries/trim-user.md`, `reference/user.md` |
35
+ | Device validation | `reference/workflows/device-validation.md` |
36
+
37
+ ## Safety rules
38
+
39
+ - Media APIs are outside this Skill's scope; use the documented capabilities only.
40
+ - Authentication uses OAuth 2.0 Authorization Code + PKCE. The user handles NAS
41
+ credentials and 2FA in their browser; never collect passwords or old tokens.
42
+ - OAuth uses `client_id=YJNMPJUGA9` and the CLI's required `trim.*` scopes. See
43
+ `reference/oauth.md` for the full list. Authorization URLs omit `redirect_uri`.
44
+ - Production login is interactive: the CLI displays a link and waits while the
45
+ user signs in, approves access, copies the one-time code, and pastes it into the
46
+ CLI's `Authorization code` prompt. The Agent does not fill credentials, approve
47
+ access, read browser codes, or pass codes in command arguments.
48
+ - Run login in the actual execution environment. DSH requires the same Host,
49
+ system user, configuration environment and profile as the user's login terminal.
50
+ Remote browsers, SSH shells and containers do not automatically share sessions.
51
+ - Sessions hold access/refresh tokens, expiry and OAuth device information. Store
52
+ secrets only in platform secure storage or an explicitly selected file backend;
53
+ keep them out of ordinary output, logs and third-party messages.
54
+ - Tokens refresh near expiry. HTTP 401 from `/ogh/ac/w` or `/ogh/ac/h/*` triggers
55
+ one refresh and one retry. Repeated 401 or refresh failure requires a new OAuth
56
+ login, not a retry loop or a legacy authentication fallback.
57
+ - Refresh first checks that the profile's session store is writable. Concurrent
58
+ refreshes reuse an already-written newer token.
59
+ - Missing module scopes, including legacy `file photo` grants, require login
60
+ again; refresh cannot add scopes.
61
+ - `logout` removes the profile's session and OAuth pending file. It does not
62
+ claim to revoke server-side tokens.
63
+ - Remote plain HTTP requires `--allow-insecure-http`; self-signed HTTPS requires
64
+ `--tls-insecure`.
65
+ - Agent and noninteractive writes explicitly pass `--yes`. High-risk storage
66
+ writes also require the relevant password precheck.
67
+
68
+ ## Connection options
69
+
70
+ ```text
71
+ --host <host> NAS host; defaults to localhost
72
+ --port <port> Explicit port takes precedence
73
+ --profile <name> Independent session profile
74
+ --scheme auto|http|https Transport; defaults to auto
75
+ --allow-insecure-http Allow remote plain HTTP
76
+ --tls-insecure Allow self-signed HTTPS
77
+ ```
78
+
79
+ Without explicit connection options, the CLI reuses the profile's saved host,
80
+ port, scheme and TLS settings. Defaults are `http://localhost:5666` for loopback,
81
+ HTTPS 5667 for remote IPs, and HTTPS 443 for domain names.
82
+
83
+ ## Authentication
84
+
85
+ The user runs these commands in their own interactive terminal:
86
+
87
+ ```bash
88
+ fnos-cli --profile home --host <host> --port <port> --scheme https login
89
+ fnos-cli --profile home login --refresh
90
+ fnos-cli --profile home logout
91
+ ```
92
+
93
+ `login` displays the authorization link, attempts to open a browser, and waits
94
+ for the user to paste a one-time code. `login --no-open` keeps the same flow
95
+ without opening the browser automatically. For a remote plain HTTP device:
96
+
97
+ ```bash
98
+ fnos-cli --profile home --host <host> --port 5666 --scheme http --allow-insecure-http login --no-open
99
+ ```
100
+
101
+ ## Common read-only commands
102
+
103
+ ```bash
104
+ fnos-cli --profile home system info
105
+ fnos-cli --profile home file ls /vol1
106
+ fnos-cli --profile home file ls-dir /vol1
107
+ fnos-cli --profile home file usage 1
108
+ fnos-cli --profile home photos folders
109
+ fnos-cli --profile home photos search <keyword> --limit 20
110
+ fnos-cli --profile home app list
111
+ fnos-cli --profile home docker image ls
112
+ fnos-cli --profile home download ls
113
+ fnos-cli --profile home monitor cpu
114
+ fnos-cli --profile home monitor beep-supported
115
+ fnos-cli --profile home storage pools
116
+ fnos-cli --profile home user info
117
+ ```
118
+
119
+ ## Raw requests
120
+
121
+ Use `raw` for system services without a named command, and `photos request` for
122
+ the native Photos HTTP API. Only audited read-only endpoints built into the CLI
123
+ may omit `--yes`; every other endpoint, including unknown endpoints, requires
124
+ confirmation and explicit `--yes`. Paths and request bodies are still validated.
125
+ Check both HTTP status and business response code/final state, not just exit code.
126
+
127
+ ## Output
128
+
129
+ Named commands output JSON plans or business data. Photos search `total` is the
130
+ match count. AI search may report an exact total only when the response provides
131
+ a stable total; otherwise report the returned count. Preview URLs usually still
132
+ require a signed-in browser cookie.
133
+
134
+ `file ls-dir` collects directory chunks into a JSON array. `file usage` collects
135
+ capacity categories into a JSON array; repeated categories keep their last
136
+ result instead of being added together.
137
+
138
+ ## References
139
+
140
+ - Index: `reference/_index.md`
141
+ - OAuth contract: `reference/oauth.md`
142
+ - Photos API: `reference/photos.md`
143
+ - Connections and validation: `entries/trim-shared.md`, `reference/workflows/device-validation.md`