Skill: pettifordo/solidpod-data (version 1.2.6)

--- name: solidpod-data description: Interact with SOLID Pods — read data, write RDF data, create containers, manage access control, and provision new SOLID Pods. Supports Inrupt ESS, CSS, and any spec-compliant SOLID server. homepage: https://github.com/pettifordo/SOLIDPOD-DATA metadata: {"openclaw":{"requires":{"bins":["node"],"env":["SOLID_IDP","SOLID_CLIENT_ID","SOLID_OIDC_KEY","SOLID_POD_URL","SOLID_OIDC_ISSUER"]},"primaryEnv":"SOLID_IDP","emoji":"🪣","install":"npm install --prefix {baseDir} --omit=dev"}} --- # SolidPod-Data — SOLID Pod Skill This skill lets you interact with [SOLID Pods](https://solidproject.org/) — personal decentralised data stores — directly from OpenClaw. It uses Inrupt's `@inrupt/solid-client` and `@inrupt/solid-client-authn-node` under the hood. ## Environment Variables | Variable | Required | Description | |---|---|---| | `SOLID_IDP` | Yes | Identity Provider URL (e.g. `https://login.inrupt.com`) | | `SOLID_CLIENT_ID` | Yes | Client ID from your SOLID app registration | | `SOLID_OIDC_KEY` | Yes | OIDC client key from your SOLID app registration | | `SOLID_POD_URL` | Optional | Default Pod URL to operate against | | `SOLID_OIDC_ISSUER` | Optional | Override OIDC issuer (defaults to `SOLID_IDP`) | ## Setup 1. **Register a client app** at your Identity Provider (e.g. https://login.inrupt.com/registration.html) 2. Set the three required env vars above in your OpenClaw environment 3. Install: `npm install --prefix {baseDir} --omit=dev` --- ## Commands ### Read Operations **Read a dataset (RDF resource)** ``` /solidpod-data read <url> ``` Fetches and displays all Things and their predicates from the dataset at `<url>`. **Read a specific Thing** ``` /solidpod-data thing <dataset-url> <thing-url> ``` Displays all properties of a specific Thing within a dataset. **List a container** ``` /solidpod-data list <container-url> ``` Lists all resources and sub-containers inside a SOLID container (directory). **Read a non-RDF resource** ``` /solidpod-data resource <url> ``` Downloads and displays a non-RDF resource (text, JSON, etc.) from the pod. --- ### Write Operations **Create a container** ``` /solidpod-data mkdir <url> ``` Creates a new container (directory) at the given URL. Creates parent containers as needed. **Create or update a dataset** ``` /solidpod-data write <url> <predicate> <object> ``` Creates or updates an RDF dataset. Adds a triple `<thing-in-dataset> <predicate> <object>`. **Delete a resource** ``` /solidpod-data delete <url> ``` Deletes a resource or empty container from the pod. --- ### Access Control **View access for a resource** ``` /solidpod-data acl show <url> ``` Displays current access control rules for the resource (WAC or ACP depending on server). **Grant access** ``` /solidpod-data acl grant <url> <webid> <modes> ``` Grants access to a WebID. `<modes>` is a comma-separated list: `read`, `write`, `append`, `control`. Example: `/solidpod-data acl grant https://mypod.example/data/ https://alice.example/profile/card#me read,write` **Revoke access** ``` /solidpod-data acl revoke <url> <webid>` ``` Removes all access for the given WebID on the resource. **Request access (ACP)** ``` /solidpod-data acl request <url> <reason> ``` Sends an access request message to the resource owner (requires ACP-enabled server). --- ### Pod Management **Show pod info** ``` /solidpod-data pod info [pod-url] ``` Shows storage root, owner WebID, and type info for a pod. Uses `SOLID_POD_URL` if URL is omitted. **Create a new SOLID Pod** ``` /solidpod-data pod create <username> --server <css-base-url> ``` Prints step-by-step registration instructions for provisioning a new Pod on a Community Solid Server (CSS) instance, including the registration endpoint URL and required fields. The actual registration is completed by the user in their own browser or shell. **Register a WebID** ``` /solidpod-data pod register <webid-url> ``` Registers/links a WebID profile document on the pod. --- ### Area / Topic Management Areas are top-level containers in a pod that group related data. The skill uses a `solidpod-data:Area` type to mark them. **List areas** ``` /solidpod-data area list [pod-url] ``` Lists all registered SolidPod-Data areas in the pod. **Create an area** ``` /solidpod-data area create <name> [--description "text"] [--pod-url <url>] ``` Creates a new named area container in the pod and registers it in the pod's index. **Request access to a topic/area** ``` /solidpod-data area request <area-url> [--reason "why you need access"] ``` Sends an access request for an area to its owner using SOLID notifications or access request resources. --- ## How the Skill Works All commands run through `{baseDir}/scripts/solidpod-data.js` via Node.js. Authentication uses the Client Credentials flow (machine-to-machine, no browser needed) via `@inrupt/solid-client-authn-node`. The authenticated session is reused across operations within a single invocation. When you ask me to interact with a SOLID pod, I will: 1. Confirm the target Pod URL and operation with you before writing anything 2. Show you exactly what will be created/modified/deleted 3. Execute via `node {baseDir}/scripts/solidpod-data.js <command> [args]` 4. Display the result clearly **I will always ask for confirmation before:** - Deleting any resource - Changing access control rules - Creating a new Pod - Uploading files that overwrite existing resources --- ## Security Notes - Client credentials are passed via environment variables, never command-line args - The skill does not store tokens to disk — each run re-authenticates - ACL changes are shown before confirmation to prevent accidental exposure - Private data from the pod is shown only in your local terminal session --- ## Examples ``` # List everything in your pod root /solidpod-data list https://mypod.inrupt.net/ # Create an area called "health" /solidpod-data area create health --description "My health data" --pod-url https://mypod.inrupt.net/ # Grant Alice read access to the health area /solidpod-data acl grant https://mypod.inrupt.net/health/ https://alice.solidcommunity.net/profile/card#me read # Read a dataset /solidpod-data read https://mypod.inrupt.net/health/summary.ttl ``` --- ## External Endpoints This skill contacts **only URLs that the user explicitly provides** via environment variables or command arguments. No fixed third-party endpoints are hardcoded. | Variable / Argument | Purpose | Who controls it | |---|---|---| | `SOLID_IDP` | OIDC authentication — the user's own Identity Provider | The user | | `SOLID_POD_URL` | The user's own SOLID Pod storage root | The user | | `<url>` arguments | SOLID Pod resources to read/write/manage | The user | No data is sent to any endpoint not supplied by the user. No telemetry, analytics, or third-party services are contacted. --- ## Security & Privacy **Authentication:** `SOLID_OIDC_KEY` is used exclusively for the OIDC Client Credentials grant to the user's own Identity Provider (`SOLID_IDP`). This is the standard machine-to-machine authentication pattern for SOLID applications, equivalent to any OAuth2 client credential flow. The key is read from an environment variable (the recommended practice) and passed directly to the `@inrupt/solid-client-authn-node` SDK — it is never logged, stored to disk, or sent anywhere except the user's own IDP. **Data flow:** All network requests are authenticated SOLID protocol operations (LDP/HTTP) against the user's own Pod. No data from the Pod is forwarded to any third party. **Local filesystem:** This skill does not read from or write to the local filesystem. All read/write operations target the user's SOLID Pod over HTTPS. **No persistence:** No tokens, credentials, or Pod data are written to disk by this skill.

path: README.md
content: # SolidPod-Data

An [OpenClaw](https://openclaw-ai.com) skill for interacting with [SOLID Pods](https://solidproject.org/) — the decentralised personal data stores pioneered by Sir Tim Berners-Lee.

Available on [ClawHub](https://clawhub.io) as `solidpod-data`.

---

## What is SOLID?

SOLID (Social Linked Data) lets you store your data in a Pod you control. Apps and agents ask for access; you decide who sees what. Your data, your rules.

## What SolidPod-Data Does

| Capability | Commands |
|---|---|
| Read RDF datasets & Things | `read`, `thing` |
| List containers | `list` |
| Read pod resources | `resource` |
| Create containers / areas | `mkdir`, `area create` |
| Write RDF data | `write` |
| Delete resources | `delete` |
| View & manage access (WAC/ACP) | `acl show/grant/revoke` |
| Request access to topics | `acl request`, `area request` |
| Show pod info | `pod info` |
| Provision new SOLID Pods (CSS) | `pod create` |

## Installation

```bash
# In your OpenClaw skills directory:
clawhub install solidpod-data

# Or clone directly:
git clone https://github.com/pettifordo/SOLIDPOD-DATA.git
cd SOLIDPOD-DATA
npm install
```

## Setup

1. Register a client application at your SOLID Identity Provider
   (e.g. [Inrupt registration](https://login.inrupt.com/registration.html))

2. Set environment variables:

```bash
export SOLID_IDP=https://login.inrupt.com
export SOLID_CLIENT_ID=your-client-id
export SOLID_OIDC_KEY=your-oidc-key
export SOLID_POD_URL=https://storage.inrupt.com/your-pod-id/
```

3. Test it:

```bash
node scripts/solidpod-data.js pod info
```

## Usage Examples

```bash
# List your pod root
/solidpod-data list https://mypod.inrupt.net/

# Create an area called "finance"
/solidpod-data area create finance --description "Financial records"

# Grant a friend read access
/solidpod-data acl grant https://mypod.inrupt.net/finance/ https://friend.solidcommunity.net/profile/card#me read

# Request access to someone else's area
/solidpod-data area request https://alice.inrupt.net/health/ --reason "Shared care plan"

# Read a dataset
/solidpod-data read https://mypod.inrupt.net/profile/card

# Create a new pod (Community Solid Server)
/solidpod-data pod create alice --server https://my-css-instance.example
```

## Architecture

```
SolidPod-Data/
├── SKILL.md              # OpenClaw skill definition (entry point)
├── package.json          # npm dependencies
├── scripts/
│   ├── solidpod-data.js  # CLI dispatcher
│   ├── pod.js            # Core pod operations
│   ├── auth.js           # Client credentials authentication
│   └── access.js         # WAC/ACP access control
└── README.md
```

## Security

- Credentials are read from environment variables only — never passed as CLI arguments
- No tokens are written to disk — each process re-authenticates
- ACL changes are confirmed before execution when used via the OpenClaw skill
- Private pod data stays in your terminal session

## Dependencies

- [`@inrupt/solid-client`](https://github.com/inrupt/solid-client-js) — SOLID data operations
- [`@inrupt/solid-client-authn-node`](https://github.com/inrupt/solid-client-authn-js) — Client credentials auth
- [`@inrupt/vocab-common-rdf`](https://github.com/inrupt/solid-common-vocab-rdf) — RDF vocabulary constants

## License

MIT — Owen Pettiford


path: package.json
content: {
  "name": "solidpod-data",
  "version": "1.2.6",
  "description": "OpenClaw skill for interacting with SOLID Pods",
  "type": "module",
  "main": "scripts/solidpod-data.js",
  "scripts": {
    "start": "node scripts/solidpod-data.js"
  },
  "dependencies": {
    "@inrupt/solid-client": "^2.1.0",
    "@inrupt/solid-client-authn-node": "^2.5.0",
    "@inrupt/vocab-common-rdf": "^1.0.0"
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "keywords": [
    "solidpod-data",
    "solid",
    "pod",
    "linked-data",
    "rdf",
    "openclaw",
    "skill",
    "decentralised"
  ],
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/pettifordo/SOLIDPOD-DATA.git"
  }
}


path: scripts/access.js
content: /**
 * SolidPod-Data — Access Control Operations
 * Supports both WAC (Web Access Control) and ACP (Access Control Policies).
 * Detects which scheme the server uses automatically.
 *
 * All exported functions accept an optional `log` callback (default: console.log)
 * so callers can collect output without monkey-patching.
 */

import {
  getAgentAccess,
  getAgentAccessAll,
  setAgentAccess,
  getSolidDataset,
  saveSolidDatasetAt,
  createSolidDataset,
  buildThing,
  setThing,
  createThing,
  createContainerAt,
} from "@inrupt/solid-client";

import { RDF } from "@inrupt/vocab-common-rdf";
import { getAuthFetch, getWebId } from "./auth.js";

// ─── Helpers ──────────────────────────────────────────────────────────────────

function formatAccess(access) {
  const modes = [];
  if (access.read) modes.push("read");
  if (access.write) modes.push("write");
  if (access.append) modes.push("append");
  if (access.controlRead || access.control) modes.push("control-read");
  if (access.controlWrite || access.control) modes.push("control-write");
  return modes.length > 0 ? modes.join(", ") : "(none)";
}

function parseModes(modesStr) {
  const parts = modesStr.split(",").map(s => s.trim().toLowerCase());
  return {
    read: parts.includes("read"),
    write: parts.includes("write"),
    append: parts.includes("append"),
    controlRead: parts.includes("control") || parts.includes("control-read"),
    controlWrite: parts.includes("control") || parts.includes("control-write"),
  };
}

// ─── Show Access ───────────────────────────────────────────────────────────────

export async function showAccess(resourceUrl, log = console.log) {
  const authFetch = await getAuthFetch();

  let accessMap;
  try {
    accessMap = await getAgentAccessAll(resourceUrl, { fetch: authFetch });
  } catch (e) {
    throw new Error(`Could not read access for ${resourceUrl}: ${e.message}`);
  }

  log(`\nAccess control for: ${resourceUrl}`);
  log(`${"─".repeat(60)}`);

  const webIds = Object.keys(accessMap);
  if (webIds.length === 0) {
    log("  No agent-specific access entries.");
    return;
  }

  for (const webId of webIds) {
    log(`  ${webId}`);
    log(`    modes: ${formatAccess(accessMap[webId])}`);
  }
}

// ─── Grant Access ──────────────────────────────────────────────────────────────

export async function grantAccess(resourceUrl, webId, modesStr, log = console.log) {
  const authFetch = await getAuthFetch();
  const modes = parseModes(modesStr);

  try {
    await setAgentAccess(resourceUrl, webId, modes, { fetch: authFetch });
    log(`Granted access on ${resourceUrl}`);
    log(`  Agent: ${webId}`);
    log(`  Modes: ${formatAccess(modes)}`);
  } catch (e) {
    throw new Error(`Could not grant access: ${e.message}`);
  }
}

// ─── Revoke Access ─────────────────────────────────────────────────────────────

export async function revokeAccess(resourceUrl, webId, log = console.log) {
  const authFetch = await getAuthFetch();

  const noAccess = {
    read: false,
    write: false,
    append: false,
    controlRead: false,
    controlWrite: false,
  };

  try {
    await setAgentAccess(resourceUrl, webId, noAccess, { fetch: authFetch });
    log(`Revoked all access for ${webId} on ${resourceUrl}`);
  } catch (e) {
    throw new Error(`Could not revoke access: ${e.message}`);
  }
}

// ─── Access Requests ───────────────────────────────────────────────────────────

// RDF namespace URI for Solid interoperability — https:// per W3C Solid spec
const ACCESS_REQUEST_TYPE = "https://www.w3.org/ns/solid/interop#AccessRequest";
const SOLIDPOD_DATA_NS = "https://solidpod-data.example/ns#";

export async function requestAccess(resourceUrl, reason, log = console.log) {
  const authFetch = await getAuthFetch();
  const requesterWebId = await getWebId();

  const podUrl = process.env.SOLID_POD_URL;
  if (!podUrl) throw new Error("SOLID_POD_URL required to place access requests.");

  const containerUrl = podUrl.replace(/\/$/, "") + "/access-requests/";
  const requestUrl = containerUrl + Date.now() + ".ttl";

  const requestThing = buildThing(createThing({ url: requestUrl }))
    .addUrl(RDF.type, ACCESS_REQUEST_TYPE)
    .addStringNoLocale(`${SOLIDPOD_DATA_NS}requestedResource`, resourceUrl)
    .addStringNoLocale(`${SOLIDPOD_DATA_NS}requester`, requesterWebId)
    .addStringNoLocale(`${SOLIDPOD_DATA_NS}reason`, reason || "(no reason given)")
    .addStringNoLocale(`${SOLIDPOD_DATA_NS}createdAt`, new Date().toISOString())
    .build();

  // Ensure the access-requests container exists using the authenticated SOLID client
  try {
    await createContainerAt(containerUrl, { fetch: authFetch });
  } catch (e) {
    // Container likely already exists — continue
    if (process.env.SOLIDPOD_DATA_DEBUG) console.error(`[debug] createContainerAt ${containerUrl}: ${e.message}`);
  }

  let dataset = createSolidDataset();
  dataset = setThing(dataset, requestThing);
  await saveSolidDatasetAt(requestUrl, dataset, { fetch: authFetch });

  log(`Access request created: ${requestUrl}`);
  log(`  Requesting access to: ${resourceUrl}`);
  log(`  As: ${requesterWebId}`);
  if (reason) log(`  Reason: ${reason}`);
  log("\n  Share this URL with the resource owner so they can grant access.");
}

// ─── Check My Access ───────────────────────────────────────────────────────────

export async function checkMyAccess(resourceUrl, log = console.log) {
  const authFetch = await getAuthFetch();
  const webId = await getWebId();

  let access;
  try {
    access = await getAgentAccess(resourceUrl, webId, { fetch: authFetch });
  } catch (e) {
    throw new Error(`Could not check access for ${resourceUrl}: ${e.message}`);
  }

  log(`\nYour access (${webId})`);
  log(`Resource: ${resourceUrl}`);
  log(`${"─".repeat(60)}`);
  log(`  Modes: ${formatAccess(access || {})}`);
}


path: scripts/auth.js
content: /**
 * SolidPod-Data — Authentication Helper
 * Provides a cached, authenticated SOLID session using Client Credentials.
 * No browser required — uses machine-to-machine OIDC auth.
 */

import { Session } from "@inrupt/solid-client-authn-node";

let _session = null;

/**
 * Returns an authenticated SOLID session, creating one if needed.
 *
 * Required environment variables:
 *   SOLID_IDP        - Identity Provider URL (e.g. https://login.inrupt.com)
 *   SOLID_CLIENT_ID  - Client ID from your registered SOLID application
 *   SOLID_OIDC_KEY   - OIDC client key from your registered SOLID application
 *
 * Optional:
 *   SOLID_OIDC_ISSUER - Override OIDC issuer (defaults to SOLID_IDP)
 */
export async function getSession() {
  if (_session && _session.info.isLoggedIn) return _session;

  const idp      = process.env.SOLID_IDP;
  const clientId = process.env.SOLID_CLIENT_ID;
  const oidcKey  = process.env.SOLID_OIDC_KEY;
  const issuer   = process.env.SOLID_OIDC_ISSUER || idp;

  if (!idp || !clientId || !oidcKey) {
    throw new Error(
      "Missing required environment variables.\n" +
      "Please set: SOLID_IDP, SOLID_CLIENT_ID, SOLID_OIDC_KEY\n" +
      "See SKILL.md for setup instructions."
    );
  }

  _session = new Session();

  await _session.login({
    oidcIssuer:  issuer,
    clientName:  "SolidPod-Data",
    clientId,
    clientSecret: oidcKey,   // inrupt SDK parameter name — maps to our SOLID_OIDC_KEY
    tokenType:   "DPoP",
  });

  if (!_session.info.isLoggedIn) {
    throw new Error(
      "Authentication failed. Verify SOLID_CLIENT_ID and SOLID_OIDC_KEY are correct."
    );
  }

  return _session;
}

/** Returns the authenticated fetch function for use with @inrupt/solid-client. */
export async function getAuthFetch() {
  const session = await getSession();
  return session.fetch;
}

/** Returns the authenticated WebID. */
export async function getWebId() {
  const session = await getSession();
  return session.info.webId;
}


path: scripts/pod.js
content: /**
 * SolidPod-Data — Core Pod Operations
 * Read datasets, list containers, write data, delete resources,
 * and provision new pods on Community Solid Server instances.
 *
 * All exported functions accept an optional `log` callback (default: console.log)
 * so callers can collect output without monkey-patching.
 */

import {
  getSolidDataset,
  getThingAll,
  getUrlAll,
  getStringNoLocaleAll,
  saveSolidDatasetAt,
  createSolidDataset,
  buildThing,
  setThing,
  createThing,
  deleteSolidDataset,
  getContainedResourceUrlAll,
  createContainerAt,
  getSourceUrl,
  getPodUrlAll,
} from "@inrupt/solid-client";

import { RDF } from "@inrupt/vocab-common-rdf";
import { getAuthFetch, getWebId } from "./auth.js";

// ─── Helpers ──────────────────────────────────────────────────────────────────

let _podUrlOverride = null;

/** Override the pod URL at runtime without mutating process.env. */
export function setPodUrl(url) {
  _podUrlOverride = url;
}

function defaultPodUrl() {
  const url = _podUrlOverride || process.env.SOLID_POD_URL;
  if (!url) throw new Error("SOLID_POD_URL is not set. Pass --pod-url or set the env var.");
  return url.endsWith("/") ? url : url + "/";
}

function ensureTrailingSlash(url) {
  return url.endsWith("/") ? url : url + "/";
}

function printThing(thing, log) {
  const predicates = Object.keys(thing.predicates || {});
  if (predicates.length === 0) {
    log("  (no predicates)");
    return;
  }
  for (const pred of predicates) {
    const values = [
      ...(getUrlAll(thing, pred) || []),
      ...(getStringNoLocaleAll(thing, pred) || []),
    ];
    for (const val of values) {
      log(`  ${pred}`);
      log(`    = ${val}`);
    }
  }
}

// ─── Read ──────────────────────────────────────────────────────────────────────

export async function readDataset(url, log = console.log) {
  const authFetch = await getAuthFetch();
  let dataset;
  try {
    dataset = await getSolidDataset(url, { fetch: authFetch });
  } catch (e) {
    throw new Error(`Could not read dataset at ${url}: ${e.message}`);
  }

  const things = getThingAll(dataset);
  if (things.length === 0) {
    log(`Dataset at ${url} is empty.`);
    return;
  }

  log(`\nDataset: ${url}\n${"─".repeat(60)}`);
  for (const thing of things) {
    log(`\nThing: ${getSourceUrl(thing) || thing.url || "(blank)"}`);
    printThing(thing, log);
  }
}

export async function readThing(datasetUrl, thingUrl, log = console.log) {
  const authFetch = await getAuthFetch();
  const dataset = await getSolidDataset(datasetUrl, { fetch: authFetch });
  const things = getThingAll(dataset);
  const thing = things.find(t => (getSourceUrl(t) || t.url) === thingUrl);
  if (!thing) {
    throw new Error(`Thing ${thingUrl} not found in dataset ${datasetUrl}`);
  }
  log(`\nThing: ${thingUrl}\n${"─".repeat(60)}`);
  printThing(thing, log);
}

export async function listContainer(url, log = console.log) {
  const authFetch = await getAuthFetch();
  url = ensureTrailingSlash(url);
  let dataset;
  try {
    dataset = await getSolidDataset(url, { fetch: authFetch });
  } catch (e) {
    throw new Error(`Could not list container at ${url}: ${e.message}`);
  }

  const contained = getContainedResourceUrlAll(dataset);
  log(`\nContainer: ${url}`);
  log(`${"─".repeat(60)}`);

  if (contained.length === 0) {
    log("  (empty)");
    return;
  }

  for (const resourceUrl of contained) {
    const isDir = resourceUrl.endsWith("/");
    log(`  ${isDir ? "[DIR] " : "[RES] "} ${resourceUrl}`);
  }
  log(`\n${contained.length} item(s)`);
}

export async function readPodResource(url, log = console.log) {
  const authFetch = await getAuthFetch();
  let response;
  try {
    response = await authFetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
  } catch (e) {
    throw new Error(`Could not fetch resource at ${url}: ${e.message}`);
  }

  const text = await response.text();
  log(`\nResource: ${url}`);
  log(`Content-Type: ${response.headers.get("content-type") || "unknown"}`);
  log(`${"─".repeat(60)}`);
  log(text);
}

export async function podInfo(url, log = console.log) {
  url = url || defaultPodUrl();
  const authFetch = await getAuthFetch();

  let dataset;
  try {
    dataset = await getSolidDataset(url, { fetch: authFetch });
  } catch (e) {
    throw new Error(`Could not fetch pod info at ${url}: ${e.message}`);
  }

  const webId = await getWebId();
  const things = getThingAll(dataset);

  log(`\nPod: ${url}`);
  log(`Authenticated as: ${webId}`);
  log(`${"─".repeat(60)}`);

  const storages = getPodUrlAll(dataset);
  if (storages.length > 0) {
    log("Storage roots:");
    for (const s of storages) log(`  ${s}`);
  } else {
    log("Storage roots: (none advertised in dataset)");
  }

  log(`Things in root: ${things.length}`);
}

// ─── Write ─────────────────────────────────────────────────────────────────────

export async function createContainer(url, log = console.log) {
  const authFetch = await getAuthFetch();
  url = ensureTrailingSlash(url);
  try {
    await createContainerAt(url, { fetch: authFetch });
    log(`Created container: ${url}`);
  } catch (e) {
    throw new Error(`Could not create container at ${url}: ${e.message}`);
  }
}

export async function writeDataset(datasetUrl, predicate, object, log = console.log) {
  const authFetch = await getAuthFetch();

  let dataset;
  let exists = true;
  try {
    dataset = await getSolidDataset(datasetUrl, { fetch: authFetch });
  } catch (e) {
    if (process.env.SOLIDPOD_DATA_DEBUG) console.error(`[debug] Dataset not found at ${datasetUrl}, will create: ${e.message}`);
    dataset = createSolidDataset();
    exists = false;
  }

  const thingUrl = datasetUrl + "#data";
  const isUrl = /^https?:\/\//.test(object);
  const thing = isUrl
    ? buildThing(createThing({ url: thingUrl })).addUrl(predicate, object).build()
    : buildThing(createThing({ url: thingUrl })).addStringNoLocale(predicate, object).build();

  dataset = setThing(dataset, thing);
  await saveSolidDatasetAt(datasetUrl, dataset, { fetch: authFetch });
  log(`${exists ? "Updated" : "Created"} dataset: ${datasetUrl}`);
  log(`  <${thingUrl}> <${predicate}> "${object}"`);
}


export async function deleteResource(url, log = console.log) {
  const authFetch = await getAuthFetch();
  try {
    if (url.endsWith("/")) {
      await deleteSolidDataset(url, { fetch: authFetch });
    } else {
      const res = await authFetch(url, { method: "DELETE" });
      if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
    }
    log(`Deleted: ${url}`);
  } catch (e) {
    throw new Error(`Could not delete ${url}: ${e.message}`);
  }
}

// ─── Area / Topic Management ───────────────────────────────────────────────────

const SOLIDPOD_DATA_NS = "https://solidpod-data.example/ns#";
const AREA_TYPE = `${SOLIDPOD_DATA_NS}Area`;
const AREA_INDEX = "solidpod-data-areas.ttl";

function areaIndexUrl(basePodUrl) {
  return ensureTrailingSlash(basePodUrl) + AREA_INDEX;
}

export async function listAreas(basePodUrl, log = console.log) {
  basePodUrl = basePodUrl || defaultPodUrl();
  const authFetch = await getAuthFetch();
  const indexUrl = areaIndexUrl(basePodUrl);

  let dataset;
  try {
    dataset = await getSolidDataset(indexUrl, { fetch: authFetch });
  } catch (e) {
    if (process.env.SOLIDPOD_DATA_DEBUG) console.error(`[debug] Area index not found at ${indexUrl}: ${e.message}`);
    log("No areas registered yet. Use: /solidpod-data area create <name>");
    return;
  }

  const things = getThingAll(dataset);
  const areas = things.filter(t => getUrlAll(t, RDF.type).includes(AREA_TYPE));

  if (areas.length === 0) {
    log("No areas found.");
    return;
  }

  log(`\nSolidPod-Data Areas in ${basePodUrl}\n${"─".repeat(60)}`);
  for (const area of areas) {
    const url = getSourceUrl(area) || area.url;
    const names = getStringNoLocaleAll(area, `${SOLIDPOD_DATA_NS}name`);
    const descs = getStringNoLocaleAll(area, `${SOLIDPOD_DATA_NS}description`);
    log(`\n  Area: ${names[0] || "(unnamed)"}`);
    log(`  URL:  ${url}`);
    if (descs[0]) log(`  Desc: ${descs[0]}`);
  }
}

export async function createArea(name, description, basePodUrl, log = console.log) {
  basePodUrl = basePodUrl || defaultPodUrl();
  const authFetch = await getAuthFetch();
  const areaContainerUrl = ensureTrailingSlash(basePodUrl) + name + "/";
  const indexUrl = areaIndexUrl(basePodUrl);

  await createContainerAt(areaContainerUrl, { fetch: authFetch });
  log(`Created area container: ${areaContainerUrl}`);

  let indexDataset;
  try {
    indexDataset = await getSolidDataset(indexUrl, { fetch: authFetch });
  } catch (e) {
    if (process.env.SOLIDPOD_DATA_DEBUG) console.error(`[debug] Area index not found at ${indexUrl}, will create: ${e.message}`);
    indexDataset = createSolidDataset();
  }

  let thing = buildThing(createThing({ url: areaContainerUrl }))
    .addUrl(RDF.type, AREA_TYPE)
    .addStringNoLocale(`${SOLIDPOD_DATA_NS}name`, name);

  if (description) {
    thing = thing.addStringNoLocale(`${SOLIDPOD_DATA_NS}description`, description);
  }

  indexDataset = setThing(indexDataset, thing.build());
  await saveSolidDatasetAt(indexUrl, indexDataset, { fetch: authFetch });
  log(`Registered area "${name}" in ${indexUrl}`);
}

// ─── Pod Provisioning (Community Solid Server) ────────────────────────────────

/**
 * Print step-by-step instructions for creating a new SOLID Pod on a
 * Community Solid Server (CSS) instance.
 *
 * We output the registration command rather than executing it directly,
 * so the user retains full control over credentials and the registration
 * request is made from their own shell session.
 */
export async function createPod(username, cssBaseUrl, log = console.log) {
  const parsed = new URL(cssBaseUrl);
  if (!["https:", "http:"].includes(parsed.protocol)) {
    throw new Error("cssBaseUrl must be an http or https URL.");
  }

  const registrationUrl = ensureTrailingSlash(cssBaseUrl) + "idp/register/";
  const email = `${username}@solidpod-data.local`;

  log(`\nTo create a SOLID Pod for "${username}" on ${cssBaseUrl}:`);
  log(`\n${"─".repeat(60)}`);
  log(`Option 1 — Web UI (recommended):`);
  log(`  Open ${cssBaseUrl} in your browser and complete registration.`);
  log(`  Use pod name: ${username}`);
  log(`  Use email:    ${email}`);
  log(`\nOption 2 — CSS HTTP registration endpoint:`);
  log(`  POST to: ${registrationUrl}`);
  log(`  See Community Solid Server docs for the required JSON fields:`);
  log(`  https://communitysolidserver.github.io/CommunitySolidServer/`);
  log(`\n${"─".repeat(60)}`);
  log(`After registration, set these env vars to use the pod with SolidPod-Data:`);
  log(`  SOLID_IDP=${cssBaseUrl}`);
  log(`  SOLID_POD_URL=${ensureTrailingSlash(cssBaseUrl)}${username}/`);
}


path: scripts/solidpod-data.js
content: #!/usr/bin/env node
/**
 * SolidPod-Data — Main CLI Dispatcher
 * Routes commands to pod.js or access.js handlers.
 *
 * Usage: node solidpod-data.js <command> [args...]
 */

import {
  readDataset,
  readThing,
  listContainer,
  readPodResource,
  podInfo,
  createContainer,
  writeDataset,
  deleteResource,
  listAreas,
  createArea,
  createPod,
  setPodUrl,
} from "./pod.js";

import {
  showAccess,
  grantAccess,
  revokeAccess,
  requestAccess,
  checkMyAccess,
} from "./access.js";

// ─── Argument Parsing ─────────────────────────────────────────────────────────

function parseArgs(argv) {
  const args = argv.slice(2);
  const flags = {};
  const positional = [];

  for (let i = 0; i < args.length; i++) {
    if (args[i].startsWith("--")) {
      const key = args[i].slice(2);
      const val = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : true;
      flags[key] = val;
    } else {
      positional.push(args[i]);
    }
  }

  return { positional, flags };
}

function usage() {
  console.log(`
SolidPod-Data — SOLID Pod CLI

Usage: solidpod-data <command> [options]

Read commands:
  read <url>                         Read all Things from an RDF dataset
  thing <dataset-url> <thing-url>    Read a specific Thing
  list <container-url>               List contents of a container
  resource <url>                     Read a non-RDF pod resource
  pod info [url]                     Show pod info

Write commands:
  mkdir <url>                        Create a container
  write <url> <predicate> <object>   Write a triple to a dataset
  delete <url>                       Delete a resource

Access commands:
  acl show <url>                     Show access control entries
  acl grant <url> <webid> <modes>    Grant access (modes: read,write,append,control)
  acl revoke <url> <webid>           Revoke all access for a WebID
  acl request <url>                  Request access  [--reason "text"]
  acl check <url>                    Check your own access

Area commands:
  area list [pod-url]                List SolidPod-Data areas
  area create <name>                 Create an area  [--description "text"] [--pod-url <url>]
  area request <url>                 Request access to an area  [--reason "text"]

Pod provisioning:
  pod create <username>              Print CSS registration instructions  --server <url>

Options:
  --description <t>  Description for area create
  --pod-url <url>    Override SOLID_POD_URL
  --server <url>     CSS base URL for pod create
  --reason <text>    Reason for access request
`);
}

// ─── Command Router ───────────────────────────────────────────────────────────

async function main() {
  const { positional, flags } = parseArgs(process.argv);

  if (flags["pod-url"]) setPodUrl(flags["pod-url"]);

  const [cmd, sub, ...rest] = positional;

  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
    usage();
    process.exit(0);
  }

  try {
    switch (cmd) {

      // ── Read ──
      case "read":
        if (!sub) throw new Error("Usage: solidpod-data read <url>");
        await readDataset(sub);
        break;

      case "thing": {
        const [datasetUrl, thingUrl] = [sub, rest[0]];
        if (!datasetUrl || !thingUrl) throw new Error("Usage: solidpod-data thing <dataset-url> <thing-url>");
        await readThing(datasetUrl, thingUrl);
        break;
      }

      case "list":
        if (!sub) throw new Error("Usage: solidpod-data list <container-url>");
        await listContainer(sub);
        break;

      case "resource":
        if (!sub) throw new Error("Usage: solidpod-data resource <url>");
        await readPodResource(sub);
        break;

      // ── Write ──
      case "mkdir":
        if (!sub) throw new Error("Usage: solidpod-data mkdir <url>");
        await createContainer(sub);
        break;

      case "write": {
        const [url, predicate, object] = [sub, rest[0], rest[1]];
        if (!url || !predicate || !object)
          throw new Error("Usage: solidpod-data write <url> <predicate> <object>");
        await writeDataset(url, predicate, object);
        break;
      }

      case "delete":
        if (!sub) throw new Error("Usage: solidpod-data delete <url>");
        await deleteResource(sub);
        break;

      // ── Pod ──
      case "pod":
        if (sub === "info") {
          await podInfo(rest[0] || null);
        } else if (sub === "create") {
          const username = rest[0];
          if (!username || !flags.server)
            throw new Error("Usage: solidpod-data pod create <username> --server <css-base-url>");
          await createPod(username, flags.server);
        } else {
          throw new Error(`Unknown pod subcommand: ${sub}. Use: info, create`);
        }
        break;

      // ── ACL ──
      case "acl":
        if (sub === "show") {
          if (!rest[0]) throw new Error("Usage: solidpod-data acl show <url>");
          await showAccess(rest[0]);
        } else if (sub === "grant") {
          const [url, webId, modes] = rest;
          if (!url || !webId || !modes)
            throw new Error("Usage: solidpod-data acl grant <url> <webid> <modes>");
          await grantAccess(url, webId, modes);
        } else if (sub === "revoke") {
          const [url, webId] = rest;
          if (!url || !webId)
            throw new Error("Usage: solidpod-data acl revoke <url> <webid>");
          await revokeAccess(url, webId);
        } else if (sub === "request") {
          const url = rest[0];
          if (!url) throw new Error("Usage: solidpod-data acl request <url> [--reason <text>]");
          await requestAccess(url, flags.reason || "");
        } else if (sub === "check") {
          const url = rest[0];
          if (!url) throw new Error("Usage: solidpod-data acl check <url>");
          await checkMyAccess(url);
        } else {
          throw new Error(`Unknown acl subcommand: ${sub}. Use: show, grant, revoke, request, check`);
        }
        break;

      // ── Area ──
      case "area":
        if (sub === "list") {
          await listAreas(rest[0] || null);
        } else if (sub === "create") {
          const name = rest[0];
          if (!name) throw new Error("Usage: solidpod-data area create <name> [--description <text>]");
          await createArea(name, flags.description || "", flags["pod-url"] || null);
        } else if (sub === "request") {
          const url = rest[0];
          if (!url) throw new Error("Usage: solidpod-data area request <url> [--reason <text>]");
          await requestAccess(url, flags.reason || "");
        } else {
          throw new Error(`Unknown area subcommand: ${sub}. Use: list, create, request`);
        }
        break;

      default:
        console.error(`Unknown command: ${cmd}`);
        usage();
        process.exit(1);
    }
  } catch (err) {
    console.error(`\nError: ${err.message}`);
    if (process.env.SOLIDPOD_DATA_DEBUG) console.error(err.stack);
    process.exit(1);
  }
}

main();


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)