Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion skills/fessctl/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: fessctl
description: Operate Fess via fessctl CLI (admin API client). Use when managing webconfigs, file/data configs, schedulers, users/roles/groups, dictionaries, access tokens, or any Fess admin resource from Claude. Covers 31 resource types and cross-feature workflows like initial crawl setup.
description: Operate Fess via fessctl CLI (admin API client). Use when managing webconfigs, file/data configs, schedulers, users/roles/groups, dictionaries, access tokens, or any Fess admin resource from Claude. Covers 34 resource types and cross-feature workflows like initial crawl setup.
license: Apache-2.0
version: 0.1.0
---
Expand Down Expand Up @@ -61,6 +61,9 @@ Each file documents one Fess admin feature: what it is, when to use it, fessctl
| Label types | references/features/labeltype.md |
| User tags | references/features/tagtype.md |
| Themes | references/features/theme.md |
| General settings | references/features/general.md |
| Plugins | references/features/plugin.md |
| Object storage | references/features/storage.md |
| Dictionaries | references/features/dict.md |
| Key match | references/features/keymatch.md |
| Boost document | references/features/boostdoc.md |
Expand Down
53 changes: 53 additions & 0 deletions skills/fessctl/references/features/general.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# General settings (fessctl `general`)

## What it is
The General page of the Admin UI (**System → General**): crawl and purge defaults, search-log and user-info switches, LDAP, notification text, log levels, object-storage connection, RAG chat and the SSO provider settings (OpenID Connect, SAML, SPNEGO, Entra ID). The admin API exposes the whole page as one flat object of snake_case keys.

Available in every supported Fess version (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). The set of keys grows with the version (`get` returned 37 keys on 14.19.2, 100 on 15.8.0 and 102 on 15.9.0; six LDAP keys are left out while unset); `get` shows exactly what the server under test has. Update is `POST` on 14.x and `PUT` on 15.x; fessctl picks the method.

## Subcommand surface
| Subcommand | Purpose | Required arguments |
| --- | --- | --- |
| `get` | Show every setting. | none |
| `update` | Change settings; every setting not given keeps its value. | one or more `--set KEY=VALUE` |

Always reconfirm with `fessctl general <sub> --help`.

## How update works
`update` first reads the current settings, then sends the four fields the server requires on every request (`day_for_cleanup`, `crawling_thread_count`, `failure_count_threshold`, `csv_file_encoding`, with their current values) plus only the keys given with `--set`. The server merges the request into the stored settings (a key that is absent or null keeps its value), so a one-key change leaves all other settings as they were; read back with `get` they are identical.

- `KEY` must be a name `get` shows (the LDAP keys `ldap_provider_url`, `ldap_security_principal`, `ldap_admin_security_principal`, `ldap_admin_security_credentials`, `ldap_base_dn`, `ldap_account_filter` are left out of `get` while unset but can be set). An unknown key is rejected locally, because the server would accept it and ignore it silently.
- A setting that is an integer in `get` takes an integer; checkbox-like settings are the strings `true` / `false`.
- The value after the first `=` is taken literally, so it may contain `=`.

## Resource JSON shape
```json
{
"response": {
"setting": {
"day_for_cleanup": 3,
"crawling_thread_count": 5,
"failure_count_threshold": -1,
"csv_file_encoding": "UTF-8",
"login_required": "false",
"purge_job_log_day": -1,
"storage_secret_key": "**********"
},
"version": "15.9",
"status": 0
}
}
```

## Gotchas
- Secrets (`storage_access_key`, `storage_secret_key`, `oic_client_id`, `oic_client_secret`, `entraid_client_id`, `entraid_client_secret`, `spnego_preauth_password`) are masked as `**********` by `get` (`ldap_admin_security_credentials` is never returned at all) and are never sent back by `update`. A new value replaces the secret; a value made only of `*` is ignored by the server. A secret cannot be cleared through the API (an empty value is ignored); the exception is `spnego_preauth_password`, where an empty value removes it.
- There is no way to remove a key: an empty value (`--set key=`) stores an empty string. For the unset LDAP keys that turns "absent" into "empty". This differs from the Admin UI, where a blank field removes the system property.
- Bad values are rejected by the server with a message and a non-zero exit (for example `day_for_cleanup` above 1000, or from 15.8 on `spnego_prompt_ntlm=true` together with `spnego_allow_basic=false` when `sso_type=spnego`); nothing is changed in that case.

## Examples
```bash
fessctl general get
fessctl general get --output json | jq -r '.response.setting.crawling_user_agent'
fessctl general update --set purge_job_log_day=30 --set notification_login="Maintenance tonight"
fessctl general update --set storage_endpoint=http://s3.example.com:4566 --set storage_access_key=KEY --set storage_secret_key=SECRET --set storage_bucket=fess
```
45 changes: 45 additions & 0 deletions skills/fessctl/references/features/plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Plugins (fessctl `plugin`)

## What it is
Plugins are jars (data stores, ingest, script engines, web apps, thumbnail generators, crawlers, LLM clients, and from 15.9 object storage and SSO) that Fess downloads from its plugin repositories into `WEB-INF/plugin`. The Admin UI manages them under **System → Plugin**.

Available in every supported Fess version (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). Install is `PUT` on 14.x and `POST` on 15.x; fessctl picks the method. The versions on offer follow the Fess line (for example `15.9.0` plugins on Fess 15.9.0).

## Subcommand surface
| Subcommand | Purpose | Required arguments |
| --- | --- | --- |
| `installed` | List the installed plugins. | none |
| `available` | List what the plugin repositories offer. | none |
| `install` | Ask Fess to download and install one plugin. | `<name> <version>` |
| `uninstall` | Ask Fess to delete one plugin jar. Asks for confirmation unless `--yes` is given. | `<name> <version>` |

Always reconfirm with `fessctl plugin <sub> --help`.

## Resource JSON shape
```json
{
"response": {
"plugins": [
{"name": "fess-ds-csv", "id": "fess-ds-csv:15.9.0", "type": "fess-ds", "version": "15.9.0",
"url": "https://maven.codelibs.org/release/org/codelibs/fess/fess-ds-csv/15.9.0/fess-ds-csv-15.9.0.jar"}
],
"status": 0
}
}
```
`installed` entries have no `url`.

## Gotchas
- `install` and `uninstall` only report that the request was accepted: Fess downloads or deletes the jar in a background thread and logs any failure instead of reporting it. Check `plugin installed` afterwards (it can take a few seconds), and a plugin that is not installed can still be "uninstalled" successfully.
- A new plugin is active only after Fess is restarted; so is the removal of one.
- `install` makes the Fess server download the jar, so the server needs outbound access to the plugin repository. A name/version pair that is not in `available` is rejected with `invalid name or version`.
- Installing a plugin also deletes other installed versions of the same plugin.
- `uninstall` needs the exact installed version; check `plugin installed`.

## Examples
```bash
fessctl plugin available --output json | jq -r '.response.plugins[] | select(.type == "fess-ds") | .id'
fessctl plugin install fess-ds-csv 15.9.0
fessctl plugin installed
fessctl plugin uninstall fess-ds-csv 15.9.0 --yes
```
52 changes: 52 additions & 0 deletions skills/fessctl/references/features/storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Object storage (fessctl `storage`)

## What it is
The storage browser of the Admin UI (**System → Storage**): list, download, upload and delete objects in the S3-compatible or Google Cloud Storage bucket that Fess is configured to use (`storage_*` keys of `fessctl general`).

Every supported version has the API (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0), with two differences:
- **Upload does not work on 15.6.1 to 15.8.0.** Those releases declare the upload endpoint as `PUT` only and do not parse a multipart `PUT`; the server answers `file is required.`, so fessctl refuses with a message instead of sending the request. Upload works on 14.19.2 (`POST`) and 15.9.0+ (`POST`).
- **An unreachable or unconfigured storage is reported only from 15.9.0.** Before that `list` answers an empty list (`No Storage items found.`) whatever is wrong. 15.9.0 returns an error such as `Storage Access Error: Access key ID cannot be blank.`, or, for a `storage_type` no plugin serves, `No storage client is registered as <type>StorageClient ... install fess-storage-s3 ... or fess-storage-gcs`. In 15.9.0 the S3 and GCS clients are the `fess-storage-s3` / `fess-storage-gcs` plugins (bundled in the 15.9.0 distribution; see `fessctl plugin installed`).

## Subcommand surface
| Subcommand | Purpose | Required arguments |
| --- | --- | --- |
| `list` | List objects and directories under a path. | optional `[PATH]` (default: bucket root) |
| `download` | Write an object to `--file`, or to standard output without it. An existing `--file` is kept unless `--force`. | `<PATH>` |
| `upload` | Upload a local file as `<--dir>/<file name>` (default: bucket root). An existing object of that name is overwritten. | `<FILE>` |
| `delete` | Delete one object. Asks for confirmation unless `--yes` is given. | `<PATH>` |

Always reconfirm with `fessctl storage <sub> --help`.

## Paths
Commands take the plain object path (`docs/2026/report.pdf`, no leading slash); `list` prints the same form in its PATH column. The API itself addresses an object by the URL-safe Base64 of that path (`ZG9jcy8yMDI2L3JlcG9ydC5wZGY=`), which fessctl computes. Directories are prefixes: there is nothing to create or delete.

## Resource JSON shape
```json
{
"response": {
"items": [
{"id": "ZG9jcy8=", "name": "docs", "directory": true, "size": 0, "hashCode": 348235201},
{"id": "ZG9jcy9hLnR4dA==", "path": "docs", "name": "a.txt", "directory": false, "size": 6,
"lastModified": "2026-10-16T12:28:45Z", "hashCode": 755113370}
],
"status": 0
}
}
```
Item keys are camelCase here (`lastModified`, `hashCode`); `path` is absent for items at the bucket root.

## Gotchas
- `download` cannot tell a failure from success by status: Fess streams the object after it has committed a `200`, so a missing object or unreachable storage comes back as `200` with `Content-Length: 0`. fessctl treats that as an error. A real empty object is sent chunked and is written as a 0-byte file.
- `delete` of an object that does not exist succeeds (S3 semantics).
- `list` returns at most `storage.max.items.in.page` items (default 1000); there is no paging.
- Uploaded content goes through multipart `file` plus `path`; names are kept as given.

## Examples
```bash
fessctl storage list
fessctl storage list docs/2026
fessctl storage download docs/2026/report.pdf --file ./report.pdf
fessctl storage download docs/notes.txt | head
fessctl storage upload ./report.pdf --dir docs/2026
fessctl storage delete docs/2026/report.pdf --yes
```
2 changes: 1 addition & 1 deletion skills/fessctl/references/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,4 @@ fessctl badword create --suggest-word internal-test

## Out of scope (today)

These recipes intentionally restrict themselves to features fessctl currently wraps. Operations that depend on `failureurl`, `plugin`, `searchlist`, `storage`, or `documents` are not represented here because fessctl 0.1.0 does not expose those subcommands. When fessctl adds them, update this file with the additional recipes.
These recipes intentionally restrict themselves to features fessctl currently wraps. Operations that depend on `searchlist` or `documents` are not represented here because fessctl 0.1.0 does not expose those subcommands. When fessctl adds them, update this file with the additional recipes.
124 changes: 121 additions & 3 deletions src/fessctl/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import httpx

from fessctl.config.settings import Settings
from fessctl.utils import encode_to_urlsafe_base64


class Action(Enum):
Expand Down Expand Up @@ -108,9 +109,15 @@ def send_request(
url, headers=headers, json=json_data, params=params, timeout=self.timeout
)
elif action == Action.DELETE:
response = httpx.delete(
url, headers=headers, params=params, timeout=self.timeout
)
if json_data is None:
response = httpx.delete(
url, headers=headers, params=params, timeout=self.timeout
)
else:
# httpx.delete cannot carry a body; some endpoints (plugin) bind it.
response = httpx.request(
"DELETE", url, headers=headers, params=params, json=json_data, timeout=self.timeout
)
elif action == Action.LIST:
# Fess admin list endpoints bind size, page and filters from the
# JSON request body and ignore the query string, so send both.
Expand Down Expand Up @@ -146,6 +153,9 @@ def send_request(
content=f"Network error: {str(e)}"
) from e

return self._parse_json(response)

def _parse_json(self, response) -> dict:
# Parse JSON response - Fess API returns JSON even for errors
try:
return response.json()
Expand Down Expand Up @@ -863,6 +873,114 @@ def install_theme(self, name: str, version: str) -> dict:
url = f"{self.base_url}/api/admin/theme"
return self.send_request(Action.CREATE, url, json_data={"name": name, "version": version})

# General APIs

def get_general(self) -> dict:
"""
Retrieves the General system settings.
"""
url = f"{self.base_url}/api/admin/general"
return self.send_request(Action.GET, url)

def update_general(self, setting: dict) -> dict:
"""
Updates the General system settings. The server merges the request with the stored
settings: a key that is left out (or null) keeps its stored value.
"""
url = f"{self.base_url}/api/admin/general"
return self.send_request(Action.EDIT, url, json_data=setting)

# Plugin APIs

def get_installed_plugins(self) -> dict:
"""
Retrieves the installed plugins.
"""
url = f"{self.base_url}/api/admin/plugin/installed"
return self.send_request(Action.GET, url)

def get_available_plugins(self) -> dict:
"""
Retrieves the plugins that can be installed.
"""
url = f"{self.base_url}/api/admin/plugin/available"
return self.send_request(Action.GET, url)

def install_plugin(self, name: str, version: str) -> dict:
"""
Installs a plugin by name and version.
"""
url = f"{self.base_url}/api/admin/plugin"
return self.send_request(Action.CREATE, url, json_data={"name": name, "version": version})

def uninstall_plugin(self, name: str, version: str) -> dict:
"""
Uninstalls a plugin by name and version (the request carries a JSON body).
"""
url = f"{self.base_url}/api/admin/plugin"
return self.send_request(Action.DELETE, url, json_data={"name": name, "version": version})

# Storage APIs
# The storage endpoints identify an object by the URL-safe Base64 of its path ("dir/file").

def get_storage_items(self, path: str = "") -> dict:
"""
Lists the objects and directories under a path (the bucket root when empty).
"""
url = f"{self.base_url}/api/admin/storage/list"
if path:
url = f"{url}/{encode_to_urlsafe_base64(path)}"
return self.send_request(Action.GET, url)

def download_storage_file(self, path: str) -> httpx.Response:
"""
Downloads an object. Returns the raw response: the body is the object on success and a
JSON error otherwise, so the caller decides by Content-Type.
"""
url = f"{self.base_url}/api/admin/storage/download/{encode_to_urlsafe_base64(path)}/"
try:
return httpx.get(url, headers=self.admin_api_headers, timeout=self.timeout)
except httpx.RequestError as e:
raise FessAPIClientError(
status_code=-1,
content=f"Network error: {str(e)}"
) from e

def delete_storage_file(self, path: str) -> dict:
"""
Deletes an object.
"""
url = f"{self.base_url}/api/admin/storage/delete/{encode_to_urlsafe_base64(path)}/"
return self.send_request(Action.DELETE, url)

def upload_storage_file(self, file_path: Path, directory: str = "") -> dict:
"""
Uploads a local file as multipart/form-data into a directory of the storage.

Fess 15.6 to 15.8 declare the endpoint as PUT only and do not parse a multipart PUT
(the server answers "file is required."), so no request is sent there.
"""
if (15, 6) <= (self._major_version, self._minor_version) <= (15, 8):
return {"response": {
"status": 1,
"message": "Fess 15.6 to 15.8 cannot receive uploads through the admin API (fixed in 15.9).",
}}
url = f"{self.base_url}/api/admin/storage/upload/"
# no Content-Type: httpx sets the multipart boundary
headers = {"Authorization": self.admin_api_headers["Authorization"]}
try:
with file_path.open("rb") as f:
response = httpx.post(
url, headers=headers, data={"path": directory.strip("/")},
files={"file": (file_path.name, f)}, timeout=self.timeout
)
except httpx.RequestError as e:
raise FessAPIClientError(
status_code=-1,
content=f"Network error: {str(e)}"
) from e
return self._parse_json(response)

# SystemInfo / Stats APIs

def get_systeminfo(self) -> dict:
Expand Down
Loading
Loading