diff --git a/CMakeLists.txt b/CMakeLists.txt index 7c589eb..704785c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -977,7 +977,7 @@ endif() # already owns the `wolfram` target name; OUTPUT_NAME keeps the binary # `wolfram`. if(NOT WOLFRAM_BUILD_EMBEDDED) - add_executable(wolfram_cli src/cli/main.c src/cli/cli_oauth.c src/cli/cli_auth.c src/cli/cli_post.c src/cli/cli_thread.c src/cli/cli_social.c src/cli/cli_search.c src/cli/cli_feed.c src/cli/cli_graph.c src/cli/cli_server.c src/cli/cli_identity.c src/cli/cli_sync.c src/cli/cli_prefs.c src/cli/cli_chat.c src/cli/cli_stream.c src/cli/cli_repo.c src/cli/cli_video.c src/cli/cli_admin.c src/cli/cli_ozone.c) + add_executable(wolfram_cli src/cli/main.c src/cli/cli_oauth.c src/cli/cli_auth.c src/cli/cli_post.c src/cli/cli_thread.c src/cli/cli_social.c src/cli/cli_search.c src/cli/cli_feed.c src/cli/cli_graph.c src/cli/cli_server.c src/cli/cli_identity.c src/cli/cli_sync.c src/cli/cli_prefs.c src/cli/cli_chat.c src/cli/cli_stream.c src/cli/cli_repo.c src/cli/cli_video.c src/cli/cli_admin.c src/cli/cli_ozone.c src/cli/cli_explore.c) target_link_libraries(wolfram_cli PRIVATE wolfram) target_include_directories(wolfram_cli PRIVATE ${cjson_SOURCE_DIR} ${cjson_BINARY_DIR}) diff --git a/src/cli/cli_explore.c b/src/cli/cli_explore.c new file mode 100644 index 0000000..bb09373 --- /dev/null +++ b/src/cli/cli_explore.c @@ -0,0 +1,364 @@ +#include "cli_explore.h" +#include "main_internal.h" + +#include "wolfram/agent.h" +#include "wolfram/identity.h" +#include "wolfram/plc.h" +#include "wolfram/repo_typed.h" + +#include +#include +#include +#include + +/* Default public PLC directory. Overridable with --plc-directory for + * pointing at a test/self-hosted directory. */ +#define DEFAULT_PLC_DIRECTORY "https://plc.directory" + +int cmd_browse(int argc, char **argv) { + if (argc < 3) { + fprintf(stderr, + "error: usage: wolfram browse " + "[collection] [rkey] [--limit N] [--cursor C] [--json]\n"); + return 1; + } + const char *service = argv[1]; + const char *actor = argv[2]; + const char *collection = NULL; + const char *rkey = NULL; + int limit = 50; + const char *cursor = NULL; + + /* collection and rkey are positional if present; anything starting + * with "--" ends the positional run. */ + int i = 3; + if (i < argc && strncmp(argv[i], "--", 2) != 0) collection = argv[i++]; + if (i < argc && strncmp(argv[i], "--", 2) != 0) rkey = argv[i++]; + for (; i < argc; ++i) { + if (strcmp(argv[i], "--limit") == 0 && i + 1 < argc) + limit = atoi(argv[++i]); + else if (strcmp(argv[i], "--cursor") == 0 && i + 1 < argc) + cursor = argv[++i]; + else if (strcmp(argv[i], "--json") == 0) + g_json = true; + } + + wf_agent *agent = wf_agent_new(service); + if (!agent) { + fprintf(stderr, "error: failed to create agent\n"); + return 1; + } + + char *did = NULL; + wf_status s = resolve_actor_to_did(agent, actor, &did); + if (s != WF_OK || !did) { + fprintf(stderr, "error: could not resolve '%s' (status %d)\n", actor, + (int)s); + free(did); + wf_agent_free(agent); + return 1; + } + + int rc = 0; + if (!collection) { + wf_repo_description desc = {0}; + s = wf_agent_describe_repo_typed(agent, did, &desc); + if (s != WF_OK) { + fprintf(stderr, "error: describeRepo failed (status %d)\n", (int)s); + rc = 1; + } else if (g_json) { + cJSON *out = cJSON_CreateObject(); + cJSON_AddStringToObject(out, "did", desc.did ? desc.did : did); + cJSON_AddStringToObject(out, "handle", + desc.handle ? desc.handle : ""); + if (desc.has_handle_is_correct) + cJSON_AddBoolToObject(out, "handleIsCorrect", + desc.handle_is_correct); + cJSON *cols = cJSON_CreateArray(); + for (size_t c = 0; c < desc.collection_count; ++c) + if (desc.collections[c]) + cJSON_AddItemToArray( + cols, cJSON_CreateString(desc.collections[c])); + cJSON_AddItemToObject(out, "collections", cols); + if (desc.did_doc) + cJSON_AddItemToObject(out, "didDoc", + cJSON_Duplicate(desc.did_doc, true)); + char *pretty = cJSON_Print(out); + if (pretty) { + printf("%s\n", pretty); + free(pretty); + } + cJSON_Delete(out); + } else { + printf("did: %s\n", desc.did ? desc.did : did); + printf("handle: %s\n", desc.handle ? desc.handle : "?"); + if (desc.has_handle_is_correct) + printf("handle verified: %s\n", + desc.handle_is_correct ? "yes" : "no"); + printf("collections (%zu):\n", desc.collection_count); + for (size_t c = 0; c < desc.collection_count; ++c) + printf(" %s\n", + desc.collections[c] ? desc.collections[c] : "?"); + if (desc.collection_count == 0) printf(" (none)\n"); + } + wf_repo_description_free(&desc); + } else if (!rkey) { + wf_repo_record_list list = {0}; + s = wf_agent_list_records_typed(agent, did, collection, limit, cursor, + 0, &list); + if (s != WF_OK) { + fprintf(stderr, "error: listRecords failed (status %d)\n", (int)s); + rc = 1; + } else if (g_json) { + cJSON *out = cJSON_CreateObject(); + cJSON *records = cJSON_CreateArray(); + for (size_t r = 0; r < list.count; ++r) { + cJSON *rec = cJSON_CreateObject(); + if (list.items[r].uri) + cJSON_AddStringToObject(rec, "uri", list.items[r].uri); + if (list.items[r].has_cid && list.items[r].cid) + cJSON_AddStringToObject(rec, "cid", list.items[r].cid); + if (list.items[r].value) + cJSON_AddItemToObject( + rec, "value", + cJSON_Duplicate(list.items[r].value, true)); + cJSON_AddItemToArray(records, rec); + } + cJSON_AddItemToObject(out, "records", records); + if (list.cursor) + cJSON_AddStringToObject(out, "cursor", list.cursor); + char *pretty = cJSON_Print(out); + if (pretty) { + printf("%s\n", pretty); + free(pretty); + } + cJSON_Delete(out); + } else { + printf("%zu record(s) in %s:\n", list.count, collection); + for (size_t r = 0; r < list.count; ++r) { + const char *uri = list.items[r].uri ? list.items[r].uri : ""; + const char *slash = strrchr(uri, '/'); + printf(" %-20s cid=%s\n", slash ? slash + 1 : uri, + list.items[r].has_cid && list.items[r].cid + ? list.items[r].cid + : "?"); + } + if (list.count == 0) printf(" (empty)\n"); + if (list.cursor) printf("cursor: %s\n", list.cursor); + } + wf_repo_record_list_free(&list); + } else { + wf_repo_record record = {0}; + s = wf_agent_get_record_typed(agent, did, collection, rkey, NULL, + &record); + if (s != WF_OK) { + fprintf(stderr, "error: getRecord failed (status %d)\n", (int)s); + rc = 1; + } else { + printf("uri: %s\n", record.uri ? record.uri : "?"); + printf("cid: %s\n", + record.has_cid && record.cid ? record.cid : "?"); + if (record.value) { + char *pretty = g_json ? cJSON_PrintUnformatted(record.value) + : cJSON_Print(record.value); + if (pretty) { + printf("%s\n", pretty); + free(pretty); + } + } + } + wf_repo_record_free(&record); + } + + free(did); + wf_agent_free(agent); + return rc; +} + +/* Print an array of {id, type, publicKeyMultibase} verification-method + * objects (a DID document's `verificationMethod`). */ +static void print_verification_methods(const cJSON *vms) { + printf("verification methods:\n"); + if (!cJSON_IsArray(vms) || cJSON_GetArraySize(vms) == 0) { + printf(" (none)\n"); + return; + } + const cJSON *vm = NULL; + cJSON_ArrayForEach(vm, vms) { + const cJSON *id = cJSON_GetObjectItemCaseSensitive(vm, "id"); + const cJSON *type = cJSON_GetObjectItemCaseSensitive(vm, "type"); + const cJSON *key = + cJSON_GetObjectItemCaseSensitive(vm, "publicKeyMultibase"); + printf(" %s (%s) %s\n", cJSON_IsString(id) ? id->valuestring : "?", + cJSON_IsString(type) ? type->valuestring : "?", + cJSON_IsString(key) ? key->valuestring : ""); + } +} + +/* Print an array of {id, type, serviceEndpoint} service objects (a DID + * document's `service`). */ +static void print_services(const cJSON *services) { + printf("services:\n"); + if (!cJSON_IsArray(services) || cJSON_GetArraySize(services) == 0) { + printf(" (none)\n"); + return; + } + const cJSON *svc = NULL; + cJSON_ArrayForEach(svc, services) { + const cJSON *id = cJSON_GetObjectItemCaseSensitive(svc, "id"); + const cJSON *type = cJSON_GetObjectItemCaseSensitive(svc, "type"); + const cJSON *endpoint = + cJSON_GetObjectItemCaseSensitive(svc, "serviceEndpoint"); + printf(" %s (%s) -> %s\n", cJSON_IsString(id) ? id->valuestring : "?", + cJSON_IsString(type) ? type->valuestring : "?", + cJSON_IsString(endpoint) ? endpoint->valuestring : "?"); + } +} + +/* Print every alsoKnownAs handle claim with a live bidirectional + * verification check against `agent`. */ +static void print_handles(wf_agent *agent, const cJSON *aka) { + printf("handles:\n"); + if (!cJSON_IsArray(aka) || cJSON_GetArraySize(aka) == 0) { + printf(" (none)\n"); + return; + } + const cJSON *h = NULL; + cJSON_ArrayForEach(h, aka) { + if (!cJSON_IsString(h)) continue; + const char *raw = h->valuestring; + const char *handle = strncmp(raw, "at://", 5) == 0 ? raw + 5 : raw; + int valid = 0; + wf_status vs = wf_agent_verify_handle(agent, handle, &valid); + const char *tag = vs != WF_OK ? "[unverified: lookup failed]" + : valid ? "[verified]" + : "[MISMATCH]"; + printf(" %s %s\n", raw, tag); + } +} + +/* Fetch and print the account's current PLC rotation keys. Rotation keys + * are a PLC-log concept, not part of the resolved (did:core-shaped) DID + * document, so this is a separate fetch from the identity doc above. */ +static void print_plc_rotation_keys(const char *did, + const char *plc_directory) { + wf_xrpc_client *client = wf_xrpc_client_new(plc_directory); + if (!client) return; + + char *cid = NULL; + char *op_json = NULL; + wf_status s = + wf_plc_get_last_op(client, plc_directory, did, &cid, &op_json); + wf_xrpc_client_free(client); + if (s != WF_OK || !op_json) { + free(cid); + free(op_json); + return; + } + + cJSON *op = cJSON_Parse(op_json); + free(cid); + free(op_json); + if (!op) return; + + cJSON *rk = cJSON_GetObjectItemCaseSensitive(op, "rotationKeys"); + printf("rotation keys (PLC):\n"); + if (cJSON_IsArray(rk) && cJSON_GetArraySize(rk) > 0) { + cJSON *k = NULL; + cJSON_ArrayForEach(k, rk) { + if (cJSON_IsString(k)) printf(" %s\n", k->valuestring); + } + } else { + printf(" (none)\n"); + } + cJSON_Delete(op); +} + +int cmd_identity(int argc, char **argv) { + if (argc < 3) { + fprintf(stderr, + "error: usage: wolfram identity " + "[--plc-directory URL] [--json]\n"); + return 1; + } + const char *service = argv[1]; + const char *actor = argv[2]; + const char *plc_directory = DEFAULT_PLC_DIRECTORY; + for (int i = 3; i < argc; ++i) { + if (strcmp(argv[i], "--plc-directory") == 0 && i + 1 < argc) + plc_directory = argv[++i]; + else if (strcmp(argv[i], "--json") == 0) + g_json = true; + } + + wf_agent *agent = wf_agent_new(service); + if (!agent) { + fprintf(stderr, "error: failed to create agent\n"); + return 1; + } + + char *did = NULL; + wf_status s = resolve_actor_to_did(agent, actor, &did); + if (s != WF_OK || !did) { + fprintf(stderr, "error: could not resolve '%s' (status %d)\n", actor, + (int)s); + free(did); + wf_agent_free(agent); + return 1; + } + + /* Resolved directly against the DID's own authority (PLC directory for + * did:plc, HTTPS well-known for did:web) via wf_did_resolve_raw, not + * through `agent`'s com.atproto.identity.resolveDid -- that XRPC call + * is proxied by whichever PDS `service` happens to be, and at least one + * major production PDS (bsky.social) gates it behind auth, which would + * make this command only work for repos hosted on a PDS the caller can + * log into. A DID resolves the same way no matter who is asking. */ + wf_xrpc_client *raw_client = wf_xrpc_client_new(service); + if (!raw_client) { + fprintf(stderr, "error: failed to create XRPC client\n"); + free(did); + wf_agent_free(agent); + return 1; + } + char *doc_json = NULL; + s = wf_did_resolve_raw(raw_client, did, &doc_json); + wf_xrpc_client_free(raw_client); + if (s != WF_OK) { + fprintf(stderr, "error: resolving DID document failed (status %d)\n", + (int)s); + free(did); + wf_agent_free(agent); + return 1; + } + + if (g_json) { + printf("%s\n", doc_json); + free(doc_json); + free(did); + wf_agent_free(agent); + return 0; + } + + cJSON *doc = cJSON_Parse(doc_json); + free(doc_json); + if (!doc) { + fprintf(stderr, "error: could not parse DID document\n"); + free(did); + wf_agent_free(agent); + return 1; + } + + printf("did: %s\n", did); + print_handles(agent, cJSON_GetObjectItemCaseSensitive(doc, "alsoKnownAs")); + print_verification_methods( + cJSON_GetObjectItemCaseSensitive(doc, "verificationMethod")); + print_services(cJSON_GetObjectItemCaseSensitive(doc, "service")); + if (strncmp(did, "did:plc:", 8) == 0) + print_plc_rotation_keys(did, plc_directory); + + cJSON_Delete(doc); + free(did); + wf_agent_free(agent); + return 0; +} diff --git a/src/cli/cli_explore.h b/src/cli/cli_explore.h new file mode 100644 index 0000000..fe7aa87 --- /dev/null +++ b/src/cli/cli_explore.h @@ -0,0 +1,32 @@ +#ifndef WOLFRAM_CLI_EXPLORE_H +#define WOLFRAM_CLI_EXPLORE_H + +/* `browse` / `identity` subcommand handlers -- an at:// repo explorer and a + * DID/handle inspector, the CLI equivalent of what a tool like PDSls + * (pdsls.dev) gives you in a browser. Read-only and anonymous: no login is + * needed to browse a public repo or inspect a public identity, matching the + * underlying XRPC endpoints (describeRepo/listRecords/getRecord/resolveDid + * are all public). Dispatched from main.c's argv switch. Not part of any + * installed API -- this is the CLI demo program, not the SDK. */ + +/* wolfram browse [collection] [rkey] + * [--limit N] [--cursor C] [--json] + * + * No collection: prints the repo's DID, handle, and collection list + * (describeRepo). Collection only: lists that collection's records + * (listRecords), paginated. Collection + rkey: prints the full record + * (getRecord). */ +int cmd_browse(int argc, char **argv); + +/* wolfram identity [--plc-directory URL] [--json] + * + * Resolves to a DID, fetches its DID document, and prints every + * alsoKnownAs handle with a live bidirectional verification check + * (does the handle's DNS TXT / well-known actually point back at this + * DID?), every verification method, and every service endpoint. For a + * did:plc subject, also fetches the account's current PLC operation to + * show its rotation keys -- those are a PLC-log concept, not part of the + * resolved (did:core-shaped) DID document itself. */ +int cmd_identity(int argc, char **argv); + +#endif /* WOLFRAM_CLI_EXPLORE_H */ diff --git a/src/cli/cli_identity.c b/src/cli/cli_identity.c index 9ca4536..efceae9 100644 --- a/src/cli/cli_identity.c +++ b/src/cli/cli_identity.c @@ -119,6 +119,119 @@ int cmd_rotate_handle(int argc, char **argv) { return 0; } +/* Compare two cJSON string arrays for exact, order-sensitive equality. + * Two absent/non-array values count as unchanged. */ +static bool plc_json_array_eq(const cJSON *a, const cJSON *b) { + bool a_arr = cJSON_IsArray(a), b_arr = cJSON_IsArray(b); + if (!a_arr && !b_arr) return true; + if (a_arr != b_arr) return false; + int na = cJSON_GetArraySize(a), nb = cJSON_GetArraySize(b); + if (na != nb) return false; + for (int i = 0; i < na; ++i) { + cJSON *ea = cJSON_GetArrayItem(a, i); + cJSON *eb = cJSON_GetArrayItem(b, i); + if (!cJSON_IsString(ea) || !cJSON_IsString(eb)) return false; + if (strcmp(ea->valuestring, eb->valuestring) != 0) return false; + } + return true; +} + +/* Effective alsoKnownAs array for a PLC operation, always caller-owned + * (cJSON_Delete when done). Normalizes the pre-2022 legacy genesis format + * (`type: "create"`, a single `handle` string instead of an `alsoKnownAs` + * array) to a one-element array so callers never need to special-case it -- + * plenty of real accounts still have a legacy op as entry [0] of their + * audit log. */ +static cJSON *plc_op_handles(const cJSON *op) { + cJSON *out = cJSON_CreateArray(); + if (!out || !op) return out; + cJSON *aka = cJSON_GetObjectItemCaseSensitive((cJSON *)op, "alsoKnownAs"); + if (cJSON_IsArray(aka)) { + cJSON *item = NULL; + cJSON_ArrayForEach(item, aka) { + if (cJSON_IsString(item)) + cJSON_AddItemToArray(out, + cJSON_CreateString(item->valuestring)); + } + return out; + } + cJSON *legacy = cJSON_GetObjectItemCaseSensitive((cJSON *)op, "handle"); + if (cJSON_IsString(legacy)) { + char buf[300]; + snprintf(buf, sizeof(buf), "at://%s", legacy->valuestring); + cJSON_AddItemToArray(out, cJSON_CreateString(buf)); + } + return out; +} + +/* Effective rotationKeys array for a PLC operation, always caller-owned. + * Normalizes the legacy genesis format's single `recoveryKey` string to a + * one-element array, same rationale as plc_op_handles. */ +static cJSON *plc_op_rotation_keys(const cJSON *op) { + cJSON *out = cJSON_CreateArray(); + if (!out || !op) return out; + cJSON *rk = cJSON_GetObjectItemCaseSensitive((cJSON *)op, "rotationKeys"); + if (cJSON_IsArray(rk)) { + cJSON *item = NULL; + cJSON_ArrayForEach(item, rk) { + if (cJSON_IsString(item)) + cJSON_AddItemToArray(out, + cJSON_CreateString(item->valuestring)); + } + return out; + } + cJSON *legacy = + cJSON_GetObjectItemCaseSensitive((cJSON *)op, "recoveryKey"); + if (cJSON_IsString(legacy)) + cJSON_AddItemToArray(out, cJSON_CreateString(legacy->valuestring)); + return out; +} + +/* Effective PDS endpoint for a PLC operation: modern + * services.atproto_pds.endpoint, falling back to the legacy `service` + * string. Borrowed pointer into `op`, no ownership. */ +static const char *plc_op_pds_endpoint(const cJSON *op) { + if (!op) return NULL; + cJSON *services = cJSON_GetObjectItemCaseSensitive((cJSON *)op, "services"); + cJSON *pds = services + ? cJSON_GetObjectItemCaseSensitive(services, "atproto_pds") + : NULL; + cJSON *endpoint = + pds ? cJSON_GetObjectItemCaseSensitive(pds, "endpoint") : NULL; + if (cJSON_IsString(endpoint)) return endpoint->valuestring; + cJSON *legacy = cJSON_GetObjectItemCaseSensitive((cJSON *)op, "service"); + return cJSON_IsString(legacy) ? legacy->valuestring : NULL; +} + +/* Effective atproto signing key for a PLC operation: modern + * verificationMethods.atproto, falling back to the legacy `signingKey` + * string. Borrowed pointer into `op`, no ownership. */ +static const char *plc_op_signing_key(const cJSON *op) { + if (!op) return NULL; + cJSON *vms = + cJSON_GetObjectItemCaseSensitive((cJSON *)op, "verificationMethods"); + cJSON *atproto_vm = + vms ? cJSON_GetObjectItemCaseSensitive(vms, "atproto") : NULL; + if (cJSON_IsString(atproto_vm)) return atproto_vm->valuestring; + cJSON *legacy = cJSON_GetObjectItemCaseSensitive((cJSON *)op, "signingKey"); + return cJSON_IsString(legacy) ? legacy->valuestring : NULL; +} + +/* Print a cJSON string array as a single comma-joined line, or "(none)". */ +static void plc_print_array_line(const cJSON *arr) { + if (!cJSON_IsArray(arr) || cJSON_GetArraySize(arr) == 0) { + printf("(none)"); + return; + } + const cJSON *item = NULL; + bool first = true; + cJSON_ArrayForEach(item, arr) { + if (!cJSON_IsString(item)) continue; + printf("%s%s", first ? "" : ", ", item->valuestring); + first = false; + } +} + int cmd_plc(int argc, char **argv) { if (argc < 3) { fprintf(stderr, "error: usage: wolfram plc " + "[--plc-directory URL] [--json]\n"); + return 1; + } + const char *did = argv[2]; + const char *plc_directory = "https://plc.directory"; + for (int i = 3; i < argc; ++i) { + if (strcmp(argv[i], "--plc-directory") == 0 && i + 1 < argc) + plc_directory = argv[++i]; + else if (strcmp(argv[i], "--json") == 0) + g_json = true; + } + + wf_xrpc_client *client = wf_xrpc_client_new(plc_directory); + if (!client) { + fprintf(stderr, "error: failed to create XRPC client\n"); + return 1; + } + + char *log_json = NULL; + wf_status s = + wf_plc_get_audit_log(client, plc_directory, did, &log_json); + wf_xrpc_client_free(client); + if (s != WF_OK) { + fprintf(stderr, "error: fetching audit log failed (status %d)\n", + (int)s); + return 1; + } + + if (g_json) { + printf("%s\n", log_json); + free(log_json); + return 0; + } + + cJSON *log = cJSON_Parse(log_json); + free(log_json); + if (!cJSON_IsArray(log)) { + fprintf(stderr, "error: could not parse audit log\n"); + cJSON_Delete(log); + return 1; + } + + cJSON *prev_op = NULL; + int idx = 0; + cJSON *entry = NULL; + cJSON_ArrayForEach(entry, log) { + cJSON *op = cJSON_GetObjectItemCaseSensitive(entry, "operation"); + cJSON *cid = cJSON_GetObjectItemCaseSensitive(entry, "cid"); + cJSON *created = + cJSON_GetObjectItemCaseSensitive(entry, "createdAt"); + cJSON *nullified = + cJSON_GetObjectItemCaseSensitive(entry, "nullified"); + cJSON *type = + op ? cJSON_GetObjectItemCaseSensitive(op, "type") : NULL; + + printf("[%d] %s cid=%s%s\n", idx, + cJSON_IsString(created) ? created->valuestring : "?", + cJSON_IsString(cid) ? cid->valuestring : "?", + cJSON_IsBool(nullified) && cJSON_IsTrue(nullified) + ? " [NULLIFIED]" + : ""); + + if (cJSON_IsString(type) && + strcmp(type->valuestring, "plc_tombstone") == 0) { + printf(" tombstone (account deleted)\n"); + prev_op = op; + idx++; + continue; + } + + cJSON *aka = plc_op_handles(op); + cJSON *rk = plc_op_rotation_keys(op); + const char *ep = plc_op_pds_endpoint(op); + const char *vk = plc_op_signing_key(op); + + cJSON *prev_aka = plc_op_handles(prev_op); + cJSON *prev_rk = plc_op_rotation_keys(prev_op); + const char *prev_ep = plc_op_pds_endpoint(prev_op); + const char *prev_vk = plc_op_signing_key(prev_op); + + if (!prev_op) { + printf(" handles: "); + plc_print_array_line(aka); + printf("\n rotation keys: "); + plc_print_array_line(rk); + printf("\n pds: %s\n", ep ? ep : "(none)"); + printf(" signing key: %s\n", vk ? vk : "(none)"); + } else { + bool any_change = false; + if (!plc_json_array_eq(aka, prev_aka)) { + printf(" handles: "); + plc_print_array_line(prev_aka); + printf(" -> "); + plc_print_array_line(aka); + printf("\n"); + any_change = true; + } + if (!plc_json_array_eq(rk, prev_rk)) { + printf(" rotation keys: "); + plc_print_array_line(prev_rk); + printf(" -> "); + plc_print_array_line(rk); + printf("\n"); + any_change = true; + } + if ((ep && !prev_ep) || (!ep && prev_ep) || + (ep && prev_ep && strcmp(ep, prev_ep) != 0)) { + printf(" pds: %s -> %s\n", prev_ep ? prev_ep : "(none)", + ep ? ep : "(none)"); + any_change = true; + } + if ((vk && !prev_vk) || (!vk && prev_vk) || + (vk && prev_vk && strcmp(vk, prev_vk) != 0)) { + printf(" signing key: %s -> %s\n", + prev_vk ? prev_vk : "(none)", vk ? vk : "(none)"); + any_change = true; + } + if (!any_change) printf(" (no tracked fields changed)\n"); + } + + cJSON_Delete(aka); + cJSON_Delete(rk); + cJSON_Delete(prev_aka); + cJSON_Delete(prev_rk); + prev_op = op; + idx++; + } + cJSON_Delete(log); + return 0; + } + fprintf(stderr, "error: unknown plc subcommand '%s' (try " - "request-signature/sign/submit)\n", + "request-signature/sign/submit/log)\n", sub); return 1; } diff --git a/src/cli/cli_identity.h b/src/cli/cli_identity.h index 1a81dc5..af93360 100644 --- a/src/cli/cli_identity.h +++ b/src/cli/cli_identity.h @@ -22,7 +22,10 @@ int cmd_rotate_handle(int argc, char **argv); /* wolfram plc request-signature * wolfram plc sign [--rotation-key ]... * [--also-known-as ]... - * wolfram plc submit */ + * wolfram plc submit + * wolfram plc log [--plc-directory URL] [--json] -- full operation + * history, oldest first, with a diff of handles/rotation keys/PDS/signing + * key against the previous entry */ int cmd_plc(int argc, char **argv); #endif /* WOLFRAM_CLI_IDENTITY_H */ diff --git a/src/cli/main.c b/src/cli/main.c index f736ae7..36ea864 100644 --- a/src/cli/main.c +++ b/src/cli/main.c @@ -108,6 +108,7 @@ #include "cli_video.h" #include "cli_ozone.h" #include "cli_admin.h" +#include "cli_explore.h" #include "wolfram/thread_typed.h" #include "wolfram/feed_typed.h" #include "wolfram/actor_typed.h" @@ -338,6 +339,17 @@ static void cmd_help_stream(FILE *out, const char *cmd) { {"resolve", "resolve ", "Resolve a handle to a DID via wf_handle_resolve. A DID is echoed " "back unchanged."}, + {"browse", + "browse [collection] [rkey] " + "[--limit N] [--cursor C] [--json]", + "Anonymous at:// repo explorer, the CLI equivalent of PDSls: no " + "args past the actor lists collections, a collection lists its " + "records, collection+rkey shows the full record."}, + {"identity", + "identity [--plc-directory URL] [--json]", + "Show a DID's handles (with live bidirectional verification), " + "verification methods, service endpoints, and (for did:plc) " + "current PLC rotation keys."}, {"labels", "labels subscribe [--cursor N] [--seconds N]", "Subscribe to com.atproto.label.subscribeLabels via the label.h " "streaming API and print each arriving label. Bounded by --seconds " @@ -936,6 +948,12 @@ int main(int argc, char **argv) { if (strcmp(cmd, "resolve") == 0) { return cmd_resolve(rest, cargv); } + if (strcmp(cmd, "browse") == 0) { + return cmd_browse(rest, cargv); + } + if (strcmp(cmd, "identity") == 0) { + return cmd_identity(rest, cargv); + } if (strcmp(cmd, "thread") == 0) { return cmd_thread(rest, cargv); }