Browse Source

Merge pull request #1509 from Lezek123/cli-content-directory-classes

CLI: Managing content directory classes, groups and permissions
Mokhtar Naamani 4 years ago
parent
commit
cea7c04c7b
43 changed files with 1463 additions and 83 deletions
  1. 1 0
      cli/.eslintignore
  2. 7 3
      cli/.eslintrc.js
  3. 246 18
      cli/README.md
  4. 5 1
      cli/package.json
  5. 40 1
      cli/src/Api.ts
  6. 5 1
      cli/src/Types.ts
  7. 58 37
      cli/src/base/ApiCommandBase.ts
  8. 166 0
      cli/src/base/ContentDirectoryCommandBase.ts
  9. 1 1
      cli/src/commands/api/setUri.ts
  10. 67 0
      cli/src/commands/content-directory/addClassSchema.ts
  11. 42 0
      cli/src/commands/content-directory/addCuratorToGroup.ts
  12. 44 0
      cli/src/commands/content-directory/addMaintainerToClass.ts
  13. 54 0
      cli/src/commands/content-directory/class.ts
  14. 24 0
      cli/src/commands/content-directory/classes.ts
  15. 44 0
      cli/src/commands/content-directory/createClass.ts
  16. 18 0
      cli/src/commands/content-directory/createCuratorGroup.ts
  17. 39 0
      cli/src/commands/content-directory/curatorGroup.ts
  18. 21 0
      cli/src/commands/content-directory/curatorGroups.ts
  19. 40 0
      cli/src/commands/content-directory/entities.ts
  20. 37 0
      cli/src/commands/content-directory/entity.ts
  21. 30 0
      cli/src/commands/content-directory/removeCuratorGroup.ts
  22. 44 0
      cli/src/commands/content-directory/removeMaintainerFromClass.ts
  23. 61 0
      cli/src/commands/content-directory/setCuratorGroupStatus.ts
  24. 55 0
      cli/src/commands/content-directory/updateClassPermissions.ts
  25. 1 3
      cli/src/commands/working-groups/createOpening.ts
  26. 1 1
      cli/src/commands/working-groups/decreaseWorkerStake.ts
  27. 1 1
      cli/src/commands/working-groups/evictWorker.ts
  28. 1 1
      cli/src/commands/working-groups/fillOpening.ts
  29. 1 4
      cli/src/commands/working-groups/increaseStake.ts
  30. 1 1
      cli/src/commands/working-groups/leaveRole.ts
  31. 1 1
      cli/src/commands/working-groups/slashWorker.ts
  32. 1 1
      cli/src/commands/working-groups/startAcceptingApplications.ts
  33. 1 1
      cli/src/commands/working-groups/startReviewPeriod.ts
  34. 1 1
      cli/src/commands/working-groups/terminateApplication.ts
  35. 1 1
      cli/src/commands/working-groups/updateRewardAccount.ts
  36. 1 1
      cli/src/commands/working-groups/updateRoleAccount.ts
  37. 1 1
      cli/src/commands/working-groups/updateWorkerReward.ts
  38. 68 0
      cli/src/helpers/InputOutput.ts
  39. 206 0
      cli/src/helpers/JsonSchemaPrompt.ts
  40. 1 0
      cli/src/helpers/display.ts
  41. 9 0
      cli/src/helpers/prompting.ts
  42. 3 1
      cli/tsconfig.json
  43. 14 2
      types/src/content-directory/index.ts

+ 1 - 0
cli/.eslintignore

@@ -1 +1,2 @@
 /lib
+.eslintrc.js

+ 7 - 3
cli/.eslintrc.js

@@ -2,6 +2,9 @@ module.exports = {
   env: {
     mocha: true,
   },
+  parserOptions: {
+    project: './tsconfig.json'
+  },
   extends: [
     // The oclif rules have some code-style/formatting rules which may conflict with
     // our prettier global settings. Disabling for now
@@ -11,7 +14,8 @@ module.exports = {
     // "oclif-typescript",
   ],
   rules: {
-    "no-unused-vars": "off", // Required by the typescript rule below
-    "@typescript-eslint/no-unused-vars": ["error"]
-  }
+    'no-unused-vars': 'off', // Required by the typescript rule below
+    '@typescript-eslint/no-unused-vars': ['error'],
+    '@typescript-eslint/no-floating-promises': 'error',
+  },
 }

+ 246 - 18
cli/README.md

@@ -44,7 +44,7 @@ $ npm install -g @joystream/cli
 $ joystream-cli COMMAND
 running command...
 $ joystream-cli (-v|--version|version)
-@joystream/cli/0.1.0 linux-x64 node-v13.12.0
+@joystream/cli/0.2.0 linux-x64 node-v13.12.0
 $ joystream-cli --help [COMMAND]
 USAGE
   $ joystream-cli COMMAND
@@ -76,6 +76,21 @@ When using the CLI for the first time there are a few common steps you might wan
 * [`joystream-cli api:inspect`](#joystream-cli-apiinspect)
 * [`joystream-cli api:setUri [URI]`](#joystream-cli-apiseturi-uri)
 * [`joystream-cli autocomplete [SHELL]`](#joystream-cli-autocomplete-shell)
+* [`joystream-cli content-directory:addClassSchema`](#joystream-cli-content-directoryaddclassschema)
+* [`joystream-cli content-directory:addCuratorToGroup [GROUPID] [CURATORID]`](#joystream-cli-content-directoryaddcuratortogroup-groupid-curatorid)
+* [`joystream-cli content-directory:addMaintainerToClass [CLASSNAME] [GROUPID]`](#joystream-cli-content-directoryaddmaintainertoclass-classname-groupid)
+* [`joystream-cli content-directory:class CLASSNAME`](#joystream-cli-content-directoryclass-classname)
+* [`joystream-cli content-directory:classes`](#joystream-cli-content-directoryclasses)
+* [`joystream-cli content-directory:createClass`](#joystream-cli-content-directorycreateclass)
+* [`joystream-cli content-directory:createCuratorGroup`](#joystream-cli-content-directorycreatecuratorgroup)
+* [`joystream-cli content-directory:curatorGroup ID`](#joystream-cli-content-directorycuratorgroup-id)
+* [`joystream-cli content-directory:curatorGroups`](#joystream-cli-content-directorycuratorgroups)
+* [`joystream-cli content-directory:entities CLASSNAME [PROPERTIES]`](#joystream-cli-content-directoryentities-classname-properties)
+* [`joystream-cli content-directory:entity ID`](#joystream-cli-content-directoryentity-id)
+* [`joystream-cli content-directory:removeCuratorGroup [ID]`](#joystream-cli-content-directoryremovecuratorgroup-id)
+* [`joystream-cli content-directory:removeMaintainerFromClass [CLASSNAME] [GROUPID]`](#joystream-cli-content-directoryremovemaintainerfromclass-classname-groupid)
+* [`joystream-cli content-directory:setCuratorGroupStatus [ID] [STATUS]`](#joystream-cli-content-directorysetcuratorgroupstatus-id-status)
+* [`joystream-cli content-directory:updateClassPermissions [CLASSNAME]`](#joystream-cli-content-directoryupdateclasspermissions-classname)
 * [`joystream-cli council:info`](#joystream-cli-councilinfo)
 * [`joystream-cli help [COMMAND]`](#joystream-cli-help-command)
 * [`joystream-cli working-groups:application WGAPPLICATIONID`](#joystream-cli-working-groupsapplication-wgapplicationid)
@@ -288,6 +303,219 @@ EXAMPLES
 
 _See code: [@oclif/plugin-autocomplete](https://github.com/oclif/plugin-autocomplete/blob/v0.2.0/src/commands/autocomplete/index.ts)_
 
+## `joystream-cli content-directory:addClassSchema`
+
+Add a new schema to a class inside content directory. Requires lead access.
+
+```
+USAGE
+  $ joystream-cli content-directory:addClassSchema
+
+OPTIONS
+  -i, --input=input    Path to JSON file to use as input (if not specified - the input can be provided interactively)
+  -o, --output=output  Path where the output JSON file should be placed (can be then reused as input)
+```
+
+_See code: [src/commands/content-directory/addClassSchema.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/addClassSchema.ts)_
+
+## `joystream-cli content-directory:addCuratorToGroup [GROUPID] [CURATORID]`
+
+Add Curator to existing Curator Group.
+
+```
+USAGE
+  $ joystream-cli content-directory:addCuratorToGroup [GROUPID] [CURATORID]
+
+ARGUMENTS
+  GROUPID    ID of the Curator Group
+  CURATORID  ID of the curator
+```
+
+_See code: [src/commands/content-directory/addCuratorToGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/addCuratorToGroup.ts)_
+
+## `joystream-cli content-directory:addMaintainerToClass [CLASSNAME] [GROUPID]`
+
+Add maintainer (Curator Group) to a class.
+
+```
+USAGE
+  $ joystream-cli content-directory:addMaintainerToClass [CLASSNAME] [GROUPID]
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the class (ie. Video)
+  GROUPID    ID of the Curator Group to add as class maintainer
+```
+
+_See code: [src/commands/content-directory/addMaintainerToClass.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/addMaintainerToClass.ts)_
+
+## `joystream-cli content-directory:class CLASSNAME`
+
+Show Class details by id or name.
+
+```
+USAGE
+  $ joystream-cli content-directory:class CLASSNAME
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the Class
+```
+
+_See code: [src/commands/content-directory/class.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/class.ts)_
+
+## `joystream-cli content-directory:classes`
+
+List existing content directory classes.
+
+```
+USAGE
+  $ joystream-cli content-directory:classes
+```
+
+_See code: [src/commands/content-directory/classes.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/classes.ts)_
+
+## `joystream-cli content-directory:createClass`
+
+Create class inside content directory. Requires lead access.
+
+```
+USAGE
+  $ joystream-cli content-directory:createClass
+
+OPTIONS
+  -i, --input=input    Path to JSON file to use as input (if not specified - the input can be provided interactively)
+  -o, --output=output  Path where the output JSON file should be placed (can be then reused as input)
+```
+
+_See code: [src/commands/content-directory/createClass.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/createClass.ts)_
+
+## `joystream-cli content-directory:createCuratorGroup`
+
+Create new Curator Group.
+
+```
+USAGE
+  $ joystream-cli content-directory:createCuratorGroup
+
+ALIASES
+  $ joystream-cli addCuratorGroup
+```
+
+_See code: [src/commands/content-directory/createCuratorGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/createCuratorGroup.ts)_
+
+## `joystream-cli content-directory:curatorGroup ID`
+
+Show Curator Group details by ID.
+
+```
+USAGE
+  $ joystream-cli content-directory:curatorGroup ID
+
+ARGUMENTS
+  ID  ID of the Curator Group
+```
+
+_See code: [src/commands/content-directory/curatorGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/curatorGroup.ts)_
+
+## `joystream-cli content-directory:curatorGroups`
+
+List existing Curator Groups.
+
+```
+USAGE
+  $ joystream-cli content-directory:curatorGroups
+```
+
+_See code: [src/commands/content-directory/curatorGroups.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/curatorGroups.ts)_
+
+## `joystream-cli content-directory:entities CLASSNAME [PROPERTIES]`
+
+Show entities list by class id or name.
+
+```
+USAGE
+  $ joystream-cli content-directory:entities CLASSNAME [PROPERTIES]
+
+ARGUMENTS
+  CLASSNAME   Name or ID of the Class
+
+  PROPERTIES  Comma-separated properties to include in the results table (ie. code,name). By default all property values
+              will be included.
+```
+
+_See code: [src/commands/content-directory/entities.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/entities.ts)_
+
+## `joystream-cli content-directory:entity ID`
+
+Show Entity details by id.
+
+```
+USAGE
+  $ joystream-cli content-directory:entity ID
+
+ARGUMENTS
+  ID  ID of the Entity
+```
+
+_See code: [src/commands/content-directory/entity.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/entity.ts)_
+
+## `joystream-cli content-directory:removeCuratorGroup [ID]`
+
+Remove existing Curator Group.
+
+```
+USAGE
+  $ joystream-cli content-directory:removeCuratorGroup [ID]
+
+ARGUMENTS
+  ID  ID of the Curator Group to remove
+```
+
+_See code: [src/commands/content-directory/removeCuratorGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/removeCuratorGroup.ts)_
+
+## `joystream-cli content-directory:removeMaintainerFromClass [CLASSNAME] [GROUPID]`
+
+Remove maintainer (Curator Group) from class.
+
+```
+USAGE
+  $ joystream-cli content-directory:removeMaintainerFromClass [CLASSNAME] [GROUPID]
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the class (ie. Video)
+  GROUPID    ID of the Curator Group to remove from maintainers
+```
+
+_See code: [src/commands/content-directory/removeMaintainerFromClass.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/removeMaintainerFromClass.ts)_
+
+## `joystream-cli content-directory:setCuratorGroupStatus [ID] [STATUS]`
+
+Set Curator Group status (Active/Inactive).
+
+```
+USAGE
+  $ joystream-cli content-directory:setCuratorGroupStatus [ID] [STATUS]
+
+ARGUMENTS
+  ID      ID of the Curator Group
+  STATUS  New status of the group (1 - active, 0 - inactive)
+```
+
+_See code: [src/commands/content-directory/setCuratorGroupStatus.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/setCuratorGroupStatus.ts)_
+
+## `joystream-cli content-directory:updateClassPermissions [CLASSNAME]`
+
+Update permissions in given class.
+
+```
+USAGE
+  $ joystream-cli content-directory:updateClassPermissions [CLASSNAME]
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the class (ie. Video)
+```
+
+_See code: [src/commands/content-directory/updateClassPermissions.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/updateClassPermissions.ts)_
+
 ## `joystream-cli council:info`
 
 Get current council and council elections information
@@ -330,7 +558,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/application.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/application.ts)_
@@ -352,7 +580,7 @@ OPTIONS
 
   -g, --group=group          (required) [default: storageProviders] The working group context in which the command
                              should be executed
-                             Available values are: storageProviders.
+                             Available values are: storageProviders, curators.
 
   -n, --draftName=draftName  Name of the draft to create the opening from.
 
@@ -375,7 +603,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/decreaseWorkerStake.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/decreaseWorkerStake.ts)_
@@ -394,7 +622,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/evictWorker.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/evictWorker.ts)_
@@ -413,7 +641,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/fillOpening.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/fillOpening.ts)_
@@ -429,7 +657,7 @@ USAGE
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/increaseStake.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/increaseStake.ts)_
@@ -445,7 +673,7 @@ USAGE
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/leaveRole.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/leaveRole.ts)_
@@ -464,7 +692,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/opening.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/opening.ts)_
@@ -480,7 +708,7 @@ USAGE
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/openings.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/openings.ts)_
@@ -496,7 +724,7 @@ USAGE
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/overview.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/overview.ts)_
@@ -515,7 +743,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/slashWorker.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/slashWorker.ts)_
@@ -534,7 +762,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/startAcceptingApplications.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/startAcceptingApplications.ts)_
@@ -553,7 +781,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/startReviewPeriod.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/startReviewPeriod.ts)_
@@ -572,7 +800,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/terminateApplication.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/terminateApplication.ts)_
@@ -591,7 +819,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/updateRewardAccount.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateRewardAccount.ts)_
@@ -610,7 +838,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/updateRoleAccount.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateRoleAccount.ts)_
@@ -629,7 +857,7 @@ ARGUMENTS
 OPTIONS
   -g, --group=group  (required) [default: storageProviders] The working group context in which the command should be
                      executed
-                     Available values are: storageProviders.
+                     Available values are: storageProviders, curators.
 ```
 
 _See code: [src/commands/working-groups/updateWorkerReward.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateWorkerReward.ts)_

+ 5 - 1
cli/package.json

@@ -8,6 +8,7 @@
   },
   "bugs": "https://github.com/Joystream/joystream/issues",
   "dependencies": {
+    "@apidevtools/json-schema-ref-parser": "^9.0.6",
     "@joystream/types": "^0.14.0",
     "@oclif/command": "^1.5.19",
     "@oclif/config": "^1.14.0",
@@ -86,6 +87,9 @@
       },
       "working-groups": {
         "description": "Working group lead and worker actions"
+      },
+      "content-directory": {
+        "description": "Interactions with content directory module - managing classes, schemas, entities and permissions"
       }
     }
   },
@@ -101,7 +105,7 @@
     "test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"",
     "build": "tsc --build tsconfig.json",
     "version": "oclif-dev readme && git add README.md",
-    "lint": "eslint ./ --ext .ts",
+    "lint": "eslint ./src --ext .ts",
     "checks": "tsc --noEmit --pretty && prettier ./ --check && yarn lint",
     "format": "prettier ./ --write"
   },

+ 40 - 1
cli/src/Api.ts

@@ -47,6 +47,7 @@ import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recur
 import { Stake, StakeId } from '@joystream/types/stake'
 
 import { InputValidationLengthConstraint } from '@joystream/types/common'
+import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity, EntityId } from '@joystream/types/content-directory'
 
 export const DEFAULT_API_URI = 'ws://localhost:9944/'
 const DEFAULT_DECIMALS = new BN(12)
@@ -54,6 +55,7 @@ const DEFAULT_DECIMALS = new BN(12)
 // Mapping of working group to api module
 export const apiModuleByGroup: { [key in WorkingGroups]: string } = {
   [WorkingGroups.StorageProviders]: 'storageWorkingGroup',
+  [WorkingGroups.Curators]: 'contentDirectoryWorkingGroup',
 }
 
 // Api wrapper for handling most common api calls and allowing easy API implementation switch in the future
@@ -284,7 +286,7 @@ export default class Api {
   }
 
   async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
-    const workerEntries = await this.entriesByIds<WorkerId, Worker>(this.workingGroupApiQuery(group).workerById)
+    const workerEntries = await this.groupWorkers(group)
 
     const groupMembers: GroupMember[] = await Promise.all(
       workerEntries.map(([id, worker]) => this.parseGroupMember(id, worker))
@@ -293,6 +295,10 @@ export default class Api {
     return groupMembers.reverse() // Sort by newest
   }
 
+  groupWorkers(group: WorkingGroups): Promise<[WorkerId, Worker][]> {
+    return this.entriesByIds<WorkerId, Worker>(this.workingGroupApiQuery(group).workerById)
+  }
+
   async openingsByGroup(group: WorkingGroups): Promise<GroupOpening[]> {
     let openings: GroupOpening[] = []
     const nextId = await this.workingGroupApiQuery(group).nextOpeningId<OpeningId>()
@@ -473,4 +479,37 @@ export default class Api {
   async workerExitRationaleConstraint(group: WorkingGroups): Promise<InputValidationLengthConstraint> {
     return await this.workingGroupApiQuery(group).workerExitRationaleText<InputValidationLengthConstraint>()
   }
+
+  // Content directory
+  availableClasses(): Promise<[ClassId, Class][]> {
+    return this.entriesByIds<ClassId, Class>(this._api.query.contentDirectory.classById)
+  }
+
+  availableCuratorGroups(): Promise<[CuratorGroupId, CuratorGroup][]> {
+    return this.entriesByIds<CuratorGroupId, CuratorGroup>(this._api.query.contentDirectory.curatorGroupById)
+  }
+
+  async curatorGroupById(id: number): Promise<CuratorGroup | null> {
+    const exists = !!(await this._api.query.contentDirectory.curatorGroupById.size(id))
+    return exists ? await this._api.query.contentDirectory.curatorGroupById<CuratorGroup>(id) : null
+  }
+
+  async nextCuratorGroupId(): Promise<number> {
+    return (await this._api.query.contentDirectory.nextCuratorGroupId<CuratorGroupId>()).toNumber()
+  }
+
+  async classById(id: number): Promise<Class | null> {
+    const c = await this._api.query.contentDirectory.classById<Class>(id)
+    return c.isEmpty ? null : c
+  }
+
+  async entitiesByClassId(classId: number): Promise<[EntityId, Entity][]> {
+    const entityEntries = await this.entriesByIds<EntityId, Entity>(this._api.query.contentDirectory.entityById)
+    return entityEntries.filter(([, entity]) => entity.class_id.toNumber() === classId)
+  }
+
+  async entityById(id: number): Promise<Entity | null> {
+    const exists = !!(await this._api.query.contentDirectory.curatorGroupById.size(id))
+    return exists ? await this._api.query.contentDirectory.entityById<Entity>(id) : null
+  }
 }

+ 5 - 1
cli/src/Types.ts

@@ -87,10 +87,14 @@ export type NameValueObj = { name: string; value: string }
 // Working groups related types
 export enum WorkingGroups {
   StorageProviders = 'storageProviders',
+  Curators = 'curators',
 }
 
 // In contrast to Pioneer, currently only StorageProviders group is available in CLI
-export const AvailableGroups: readonly WorkingGroups[] = [WorkingGroups.StorageProviders] as const
+export const AvailableGroups: readonly WorkingGroups[] = [
+  WorkingGroups.StorageProviders,
+  WorkingGroups.Curators,
+] as const
 
 export type Reward = {
   totalRecieved: Balance

+ 58 - 37
cli/src/base/ApiCommandBase.ts

@@ -13,6 +13,9 @@ import { InterfaceTypes } from '@polkadot/types/types/registry'
 import ajv from 'ajv'
 import { ApiMethodArg, ApiMethodNamedArgs, ApiParamsOptions, ApiParamOptions } from '../Types'
 import { createParamOptions } from '../helpers/promptOptions'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { DistinctQuestion } from 'inquirer'
+import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
 
 class ExtrinsicFailedError extends Error {}
 
@@ -131,9 +134,15 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     // If no default provided - get default value resulting from providing empty string
     const defaultValueString =
       paramOptions?.value?.default?.toString() || this.createType(typeDef.type as any, '').toString()
+
+    let typeSpecificOptions: DistinctQuestion = { type: 'input' }
+    if (typeDef.type === 'bool') {
+      typeSpecificOptions = BOOL_PROMPT_OPTIONS
+    }
+
     const providedValue = await this.simplePrompt({
       message: `Provide value for ${this.paramName(typeDef)}`,
-      type: 'input',
+      ...typeSpecificOptions,
       // We want to avoid showing default value like '0x', because it falsely suggests
       // that user needs to provide the value as hex
       default: (defaultValueString === '0x' ? '' : defaultValueString) || undefined,
@@ -313,6 +322,11 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     }
   }
 
+  // More typesafe version
+  async promptForType(type: keyof InterfaceTypes, options?: ApiParamOptions) {
+    return await this.promptForParam(type, options)
+  }
+
   async promptForJsonBytes(
     jsonStruct: Constructor<Struct>,
     argName?: string,
@@ -379,32 +393,30 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return values
   }
 
-  sendExtrinsic(account: KeyringPair, module: string, method: string, params: CodecArg[]) {
+  sendExtrinsic(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>) {
     return new Promise((resolve, reject) => {
-      const extrinsicMethod = this.getOriginalApi().tx[module][method]
       let unsubscribe: () => void
-      extrinsicMethod(...params)
-        .signAndSend(account, {}, (result) => {
-          // Implementation loosely based on /pioneer/packages/react-signer/src/Modal.tsx
-          if (!result || !result.status) {
-            return
-          }
-
-          if (result.status.isInBlock) {
-            unsubscribe()
-            result.events
-              .filter(({ event: { section } }): boolean => section === 'system')
-              .forEach(({ event: { method } }): void => {
-                if (method === 'ExtrinsicFailed') {
-                  reject(new ExtrinsicFailedError('Extrinsic execution error!'))
-                } else if (method === 'ExtrinsicSuccess') {
-                  resolve()
-                }
-              })
-          } else if (result.isError) {
-            reject(new ExtrinsicFailedError('Extrinsic execution error!'))
-          }
-        })
+      tx.signAndSend(account, {}, (result) => {
+        // Implementation loosely based on /pioneer/packages/react-signer/src/Modal.tsx
+        if (!result || !result.status) {
+          return
+        }
+
+        if (result.status.isInBlock) {
+          unsubscribe()
+          result.events
+            .filter(({ event: { section } }): boolean => section === 'system')
+            .forEach(({ event: { method } }): void => {
+              if (method === 'ExtrinsicFailed') {
+                reject(new ExtrinsicFailedError('Extrinsic execution error!'))
+              } else if (method === 'ExtrinsicSuccess') {
+                resolve()
+              }
+            })
+        } else if (result.isError) {
+          reject(new ExtrinsicFailedError('Extrinsic execution error!'))
+        }
+      })
         .then((unsubFunc) => (unsubscribe = unsubFunc))
         .catch((e) =>
           reject(new ExtrinsicFailedError(`Cannot send the extrinsic: ${e.message ? e.message : JSON.stringify(e)}`))
@@ -412,37 +424,46 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     })
   }
 
-  async sendAndFollowExtrinsic(
+  async sendAndFollowTx(
     account: KeyringPair,
-    module: string,
-    method: string,
-    params: CodecArg[],
-    warnOnly = false // If specified - only warning will be displayed (instead of error beeing thrown)
-  ) {
+    tx: SubmittableExtrinsic<'promise'>,
+    warnOnly = true // If specified - only warning will be displayed in case of failure (instead of error beeing thrown)
+  ): Promise<void> {
     try {
-      this.log(chalk.white(`\nSending ${module}.${method} extrinsic...`))
-      await this.sendExtrinsic(account, module, method, params)
+      await this.sendExtrinsic(account, tx)
       this.log(chalk.green(`Extrinsic successful!`))
     } catch (e) {
       if (e instanceof ExtrinsicFailedError && warnOnly) {
-        this.warn(`${module}.${method} extrinsic failed! ${e.message}`)
+        this.warn(`Extrinsic failed! ${e.message}`)
       } else if (e instanceof ExtrinsicFailedError) {
-        throw new CLIError(`${module}.${method} extrinsic failed! ${e.message}`, { exit: ExitCodes.ApiError })
+        throw new CLIError(`Extrinsic failed! ${e.message}`, { exit: ExitCodes.ApiError })
       } else {
         throw e
       }
     }
   }
 
+  async sendAndFollowNamedTx(
+    account: KeyringPair,
+    module: string,
+    method: string,
+    params: CodecArg[],
+    warnOnly = false
+  ): Promise<void> {
+    this.log(chalk.white(`\nSending ${module}.${method} extrinsic...`))
+    const tx = await this.getOriginalApi().tx[module][method](...params)
+    await this.sendAndFollowTx(account, tx, warnOnly)
+  }
+
   async buildAndSendExtrinsic(
     account: KeyringPair,
     module: string,
     method: string,
-    paramsOptions: ApiParamsOptions,
+    paramsOptions?: ApiParamsOptions,
     warnOnly = false // If specified - only warning will be displayed (instead of error beeing thrown)
   ): Promise<ApiMethodArg[]> {
     const params = await this.promptForExtrinsicParams(module, method, paramsOptions)
-    await this.sendAndFollowExtrinsic(account, module, method, params, warnOnly)
+    await this.sendAndFollowNamedTx(account, module, method, params, warnOnly)
 
     return params
   }

+ 166 - 0
cli/src/base/ContentDirectoryCommandBase.ts

@@ -0,0 +1,166 @@
+import ExitCodes from '../ExitCodes'
+import AccountsCommandBase from './AccountsCommandBase'
+import { WorkingGroups, NamedKeyringPair } from '../Types'
+import { ReferenceProperty } from 'cd-schemas/types/extrinsics/AddClassSchema'
+import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
+import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity } from '@joystream/types/content-directory'
+import { Worker } from '@joystream/types/working-group'
+import { CLIError } from '@oclif/errors'
+import { Codec } from '@polkadot/types/types'
+
+/**
+ * Abstract base class for commands related to working groups
+ */
+export default abstract class ContentDirectoryCommandBase extends AccountsCommandBase {
+  // Use when lead access is required in given command
+  async requireLead(): Promise<void> {
+    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
+    const lead = await this.getApi().groupLead(WorkingGroups.Curators)
+
+    if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
+      this.error('Content Working Group Lead access required for this command!', { exit: ExitCodes.AccessDenied })
+    }
+  }
+
+  async promptForClass(message = 'Select a class'): Promise<Class> {
+    const classes = await this.getApi().availableClasses()
+    const choices = classes.map(([, c]) => ({ name: c.name.toString(), value: c }))
+    if (!choices.length) {
+      this.warn('No classes exist to choose from!')
+      this.exit(ExitCodes.InvalidInput)
+    }
+
+    const selectedClass = await this.simplePrompt({ message, type: 'list', choices })
+
+    return selectedClass
+  }
+
+  async classEntryByNameOrId(classNameOrId: string): Promise<[ClassId, Class]> {
+    const classes = await this.getApi().availableClasses()
+    const foundClass = classes.find(([id, c]) => id.toString() === classNameOrId || c.name.toString() === classNameOrId)
+    if (!foundClass) {
+      this.error(`Class id not found by class name or id: "${classNameOrId}"!`)
+    }
+
+    return foundClass
+  }
+
+  private async curatorGroupChoices(ids?: CuratorGroupId[]) {
+    const groups = await this.getApi().availableCuratorGroups()
+    return groups
+      .filter(([id]) => (ids ? ids.some((allowedId) => allowedId.eq(id)) : true))
+      .map(([id, group]) => ({
+        name:
+          `Group ${id.toString()} (` +
+          `${group.active.valueOf() ? 'Active' : 'Inactive'}, ` +
+          `${group.curators.toArray().length} member(s), ` +
+          `${group.number_of_classes_maintained.toNumber()} classes maintained)`,
+        value: id.toNumber(),
+      }))
+  }
+
+  async promptForCuratorGroup(message = 'Select a Curator Group', ids?: CuratorGroupId[]): Promise<number> {
+    const choices = await this.curatorGroupChoices(ids)
+    if (!choices.length) {
+      this.warn('No Curator Groups to choose from!')
+      this.exit(ExitCodes.InvalidInput)
+    }
+    const selectedId = await this.simplePrompt({ message, type: 'list', choices })
+
+    return selectedId
+  }
+
+  async promptForCuratorGroups(message = 'Select Curator Groups'): Promise<number[]> {
+    const choices = await this.curatorGroupChoices()
+    const selectedIds = await this.simplePrompt({ message, type: 'checkbox', choices })
+
+    return selectedIds
+  }
+
+  async promptForClassReference(): Promise<ReferenceProperty['Reference']> {
+    const selectedClass = await this.promptForClass()
+    const sameOwner = await this.simplePrompt({ message: 'Same owner required?', ...BOOL_PROMPT_OPTIONS })
+    return { className: selectedClass.name.toString(), sameOwner }
+  }
+
+  async promptForCurator(message = 'Choose a Curator'): Promise<number> {
+    const curators = await this.getApi().groupMembers(WorkingGroups.Curators)
+    const selectedCuratorId = await this.simplePrompt({
+      message,
+      type: 'list',
+      choices: curators.map((c) => ({
+        name: `${c.profile.handle.toString()} (Worker ID: ${c.workerId})`,
+        value: c.workerId,
+      })),
+    })
+
+    return selectedCuratorId
+  }
+
+  async getCurator(id: string | number): Promise<Worker> {
+    if (typeof id === 'string') {
+      id = parseInt(id)
+    }
+
+    let curator
+    try {
+      curator = await this.getApi().workerByWorkerId(WorkingGroups.Curators, id)
+    } catch (e) {
+      if (e instanceof CLIError) {
+        throw new CLIError('Invalid Curator id!')
+      }
+      throw e
+    }
+
+    return curator
+  }
+
+  async getCuratorGroup(id: string | number): Promise<CuratorGroup> {
+    if (typeof id === 'string') {
+      id = parseInt(id)
+    }
+
+    const group = await this.getApi().curatorGroupById(id)
+
+    if (!group) {
+      this.error('Invalid Curator Group id!', { exit: ExitCodes.InvalidInput })
+    }
+
+    return group
+  }
+
+  async getEntity(id: string | number): Promise<Entity> {
+    if (typeof id === 'string') {
+      id = parseInt(id)
+    }
+
+    const entity = await this.getApi().entityById(id)
+
+    if (!entity) {
+      this.error('Invalid entity id!', { exit: ExitCodes.InvalidInput })
+    }
+
+    return entity
+  }
+
+  parseEntityPropertyValues(
+    entity: Entity,
+    entityClass: Class,
+    includedProperties?: string[]
+  ): Record<string, { value: Codec; type: string }> {
+    const { properties } = entityClass
+    return Array.from(entity.getField('values').entries()).reduce((columns, [propId, propValue]) => {
+      const prop = properties[propId.toNumber()]
+      const propName = prop.name.toString()
+      const included = !includedProperties || includedProperties.some((p) => p.toLowerCase() === propName.toLowerCase())
+
+      if (included) {
+        columns[propName] = {
+          value: propValue.getValue(),
+          type: `${prop.property_type.type}<${prop.property_type.subtype}>`,
+        }
+      }
+      return columns
+    }, {} as Record<string, { value: Codec; type: string }>)
+  }
+}

+ 1 - 1
cli/src/commands/api/setUri.ts

@@ -16,7 +16,7 @@ export default class ApiSetUri extends ApiCommandBase {
 
   async init() {
     this.forceSkipApiUriPrompt = true
-    super.init()
+    await super.init()
   }
 
   async run() {

+ 67 - 0
cli/src/commands/content-directory/addClassSchema.ts

@@ -0,0 +1,67 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import AddClassSchemaSchema from 'cd-schemas/schemas/extrinsics/AddClassSchema.schema.json'
+import { AddClassSchema } from 'cd-schemas/types/extrinsics/AddClassSchema'
+import { InputParser } from 'cd-schemas'
+import { JsonSchemaPrompter, JsonSchemaCustomPrompts } from '../../helpers/JsonSchemaPrompt'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
+import { Class } from '@joystream/types/content-directory'
+
+export default class AddClassSchemaCommand extends ContentDirectoryCommandBase {
+  static description = 'Add a new schema to a class inside content directory. Requires lead access.'
+
+  static flags = {
+    ...IOFlags,
+  }
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    const { input, output } = this.parse(AddClassSchemaCommand).flags
+
+    let inputJson = getInputJson<AddClassSchema>(input)
+    if (!inputJson) {
+      let selectedClass: Class | undefined
+      const customPrompts: JsonSchemaCustomPrompts = [
+        [
+          'className',
+          async () => {
+            selectedClass = await this.promptForClass('Select a class to add schema to')
+            return selectedClass.name.toString()
+          },
+        ],
+        [
+          'existingProperties',
+          async () =>
+            this.simplePrompt({
+              type: 'checkbox',
+              message: 'Choose existing properties to keep',
+              choices: selectedClass!.properties.map((p, i) => ({ name: `${i}: ${p.name.toString()}`, value: i })),
+            }),
+        ],
+        [/^newProperties\[\d+\]\.property_type\.Single\.Reference/, async () => this.promptForClassReference()],
+      ]
+
+      const prompter = new JsonSchemaPrompter<AddClassSchema>(
+        AddClassSchemaSchema as JSONSchema,
+        undefined,
+        customPrompts
+      )
+
+      inputJson = await prompter.promptAll()
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(inputJson))
+    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+
+    if (confirmed) {
+      await this.requestAccountDecoding(account)
+      const inputParser = new InputParser(this.getOriginalApi())
+      this.log('Sending the extrinsic...')
+      await this.sendAndFollowTx(account, await inputParser.parseAddClassSchemaExtrinsic(inputJson), true)
+
+      saveOutputJson(output, `${inputJson.className}Schema.json`, inputJson)
+    }
+  }
+}

+ 42 - 0
cli/src/commands/content-directory/addCuratorToGroup.ts

@@ -0,0 +1,42 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Add Curator to existing Curator Group.'
+  static args = [
+    {
+      name: 'groupId',
+      required: false,
+      description: 'ID of the Curator Group',
+    },
+    {
+      name: 'curatorId',
+      required: false,
+      description: 'ID of the curator',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { groupId, curatorId } = this.parse(AddCuratorToGroupCommand).args
+
+    if (groupId === undefined) {
+      groupId = await this.promptForCuratorGroup()
+    } else {
+      await this.getCuratorGroup(groupId)
+    }
+
+    if (curatorId === undefined) {
+      curatorId = await this.promptForCurator()
+    } else {
+      await this.getCurator(curatorId)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'addCuratorToGroup', [groupId, curatorId])
+
+    console.log(chalk.green(`Curator ${chalk.white(curatorId)} succesfully added to group ${chalk.white(groupId)}!`))
+  }
+}

+ 44 - 0
cli/src/commands/content-directory/addMaintainerToClass.ts

@@ -0,0 +1,44 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddMaintainerToClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Add maintainer (Curator Group) to a class.'
+  static args = [
+    {
+      name: 'className',
+      required: false,
+      description: 'Name or ID of the class (ie. Video)',
+    },
+    {
+      name: 'groupId',
+      required: false,
+      description: 'ID of the Curator Group to add as class maintainer',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { groupId, className } = this.parse(AddMaintainerToClassCommand).args
+
+    if (className === undefined) {
+      className = (await this.promptForClass()).name.toString()
+    }
+
+    const classId = (await this.classEntryByNameOrId(className))[0].toNumber()
+
+    if (groupId === undefined) {
+      groupId = await this.promptForCuratorGroup()
+    } else {
+      await this.getCuratorGroup(groupId)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'addMaintainerToClass', [classId, groupId])
+
+    console.log(
+      chalk.green(`Curator Group ${chalk.white(groupId)} added as maintainer to ${chalk.white(className)} class!`)
+    )
+  }
+}

+ 54 - 0
cli/src/commands/content-directory/class.ts

@@ -0,0 +1,54 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import { displayCollapsedRow, displayHeader, displayTable } from '../../helpers/display'
+
+export default class ClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Class details by id or name.'
+  static args = [
+    {
+      name: 'className',
+      required: true,
+      description: 'Name or ID of the Class',
+    },
+  ]
+
+  async run() {
+    const { className } = this.parse(ClassCommand).args
+    const [id, aClass] = await this.classEntryByNameOrId(className)
+    const permissions = aClass.class_permissions
+    const maintainers = permissions.maintainers.toArray()
+
+    displayCollapsedRow({
+      'Name': aClass.name.toString(),
+      'ID': id.toString(),
+      'Any member': permissions.any_member.toString(),
+      'Entity creation blocked': permissions.entity_creation_blocked.toString(),
+      'All property values locked': permissions.all_entity_property_values_locked.toString(),
+      'Number of entities': aClass.current_number_of_entities.toNumber(),
+      'Max. number of entities': aClass.maximum_entities_count.toNumber(),
+      'Default entity creation voucher max.': aClass.default_entity_creation_voucher_upper_bound.toNumber(),
+    })
+
+    displayHeader(`Maintainers`)
+    this.log(
+      maintainers.length ? maintainers.map((groupId) => chalk.white(`Group ${groupId.toString()}`)).join(', ') : 'NONE'
+    )
+
+    displayHeader(`Properties`)
+    if (aClass.properties.length) {
+      displayTable(
+        aClass.properties.map((p) => ({
+          'Name': p.name.toString(),
+          'Type': JSON.stringify(p.property_type.toJSON()),
+          'Required': p.required.toString(),
+          'Unique': p.unique.toString(),
+          'Controller lock': p.locking_policy.is_locked_from_controller.toString(),
+          'Maintainer lock': p.locking_policy.is_locked_from_maintainer.toString(),
+        })),
+        3
+      )
+    } else {
+      this.log('NONE')
+    }
+  }
+}

+ 24 - 0
cli/src/commands/content-directory/classes.ts

@@ -0,0 +1,24 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+// import chalk from 'chalk'
+import { displayTable } from '../../helpers/display'
+
+export default class ClassesCommand extends ContentDirectoryCommandBase {
+  static description = 'List existing content directory classes.'
+
+  async run() {
+    const classes = await this.getApi().availableClasses()
+
+    displayTable(
+      classes.map(([id, c]) => ({
+        'ID': id.toString(),
+        'Name': c.name.toString(),
+        'Any member': c.class_permissions.any_member.toString(),
+        'Entities': c.current_number_of_entities.toNumber(),
+        'Schemas': c.schemas.length,
+        'Maintainers': c.class_permissions.maintainers.toArray().length,
+        'Properties': c.properties.length,
+      })),
+      3
+    )
+  }
+}

+ 44 - 0
cli/src/commands/content-directory/createClass.ts

@@ -0,0 +1,44 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import CreateClassSchema from 'cd-schemas/schemas/extrinsics/CreateClass.schema.json'
+import { CreateClass } from 'cd-schemas/types/extrinsics/CreateClass'
+import { InputParser } from 'cd-schemas'
+import { JsonSchemaPrompter, JsonSchemaCustomPrompts } from '../../helpers/JsonSchemaPrompt'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
+
+export default class CreateClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Create class inside content directory. Requires lead access.'
+  static flags = {
+    ...IOFlags,
+  }
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    const { input, output } = this.parse(CreateClassCommand).flags
+
+    let inputJson = getInputJson<CreateClass>(input)
+    if (!inputJson) {
+      const customPrompts: JsonSchemaCustomPrompts = [
+        ['class_permissions.maintainers', () => this.promptForCuratorGroups('Select class maintainers')],
+      ]
+
+      const prompter = new JsonSchemaPrompter<CreateClass>(CreateClassSchema as JSONSchema, undefined, customPrompts)
+
+      inputJson = await prompter.promptAll()
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(inputJson))
+    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+
+    if (confirmed) {
+      await this.requestAccountDecoding(account)
+      this.log('Sending the extrinsic...')
+      const inputParser = new InputParser(this.getOriginalApi())
+      await this.sendAndFollowTx(account, inputParser.parseCreateClassExtrinsic(inputJson))
+
+      saveOutputJson(output, `${inputJson.name}Class.json`, inputJson)
+    }
+  }
+}

+ 18 - 0
cli/src/commands/content-directory/createCuratorGroup.ts

@@ -0,0 +1,18 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddCuratorGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Create new Curator Group.'
+  static aliases = ['addCuratorGroup']
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    await this.requestAccountDecoding(account)
+    await this.buildAndSendExtrinsic(account, 'contentDirectory', 'addCuratorGroup')
+
+    const newGroupId = (await this.getApi().nextCuratorGroupId()) - 1
+    console.log(chalk.green(`New group succesfully created! (ID: ${chalk.white(newGroupId)})`))
+  }
+}

+ 39 - 0
cli/src/commands/content-directory/curatorGroup.ts

@@ -0,0 +1,39 @@
+import { WorkingGroups } from '../../Types'
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import { displayCollapsedRow, displayHeader } from '../../helpers/display'
+
+export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Curator Group details by ID.'
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the Curator Group',
+    },
+  ]
+
+  async run() {
+    const { id } = this.parse(CuratorGroupCommand).args
+    const group = await this.getCuratorGroup(id)
+    const classesMaintained = (await this.getApi().availableClasses()).filter(([, c]) =>
+      c.class_permissions.maintainers.toArray().some((gId) => gId.toNumber() === parseInt(id))
+    )
+    const members = (await this.getApi().groupMembers(WorkingGroups.Curators)).filter((curator) =>
+      group.curators.toArray().some((groupCurator) => groupCurator.eq(curator.workerId))
+    )
+
+    displayCollapsedRow({
+      'ID': id,
+      'Status': group.active.valueOf() ? 'Active' : 'Inactive',
+    })
+    displayHeader(`Classes maintained (${classesMaintained.length})`)
+    this.log(classesMaintained.map(([, c]) => chalk.white(c.name.toString())).join(', '))
+    displayHeader(`Group Members (${members.length})`)
+    this.log(
+      members
+        .map((curator) => chalk.white(`${curator.profile.handle} (WorkerID: ${curator.workerId.toString()})`))
+        .join(', ')
+    )
+  }
+}

+ 21 - 0
cli/src/commands/content-directory/curatorGroups.ts

@@ -0,0 +1,21 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+// import chalk from 'chalk'
+import { displayTable } from '../../helpers/display'
+
+export default class CuratorGroupsCommand extends ContentDirectoryCommandBase {
+  static description = 'List existing Curator Groups.'
+
+  async run() {
+    const groups = await this.getApi().availableCuratorGroups()
+
+    displayTable(
+      groups.map(([id, group]) => ({
+        'ID': id.toString(),
+        'Status': group.active.valueOf() ? 'Active' : 'Inactive',
+        'Classes maintained': group.number_of_classes_maintained.toNumber(),
+        'Members': group.curators.toArray().length,
+      })),
+      5
+    )
+  }
+}

+ 40 - 0
cli/src/commands/content-directory/entities.ts

@@ -0,0 +1,40 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { displayTable } from '../../helpers/display'
+import _ from 'lodash'
+
+export default class ClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Show entities list by class id or name.'
+  static args = [
+    {
+      name: 'className',
+      required: true,
+      description: 'Name or ID of the Class',
+    },
+    {
+      name: 'properties',
+      required: false,
+      description:
+        'Comma-separated properties to include in the results table (ie. code,name). ' +
+        'By default all property values will be included.',
+    },
+  ]
+
+  async run() {
+    const { className, properties } = this.parse(ClassCommand).args
+    const [classId, entityClass] = await this.classEntryByNameOrId(className)
+    const entityEntries = await this.getApi().entitiesByClassId(classId.toNumber())
+    const propertiesToInclude = properties && (properties as string).split(',')
+
+    displayTable(
+      await Promise.all(
+        entityEntries.map(([id, entity]) => ({
+          'ID': id.toString(),
+          ..._.mapValues(this.parseEntityPropertyValues(entity, entityClass, propertiesToInclude), (v) =>
+            v.value.toString()
+          ),
+        }))
+      ),
+      3
+    )
+  }
+}

+ 37 - 0
cli/src/commands/content-directory/entity.ts

@@ -0,0 +1,37 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import { displayCollapsedRow, displayHeader } from '../../helpers/display'
+import _ from 'lodash'
+
+export default class EntityCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Entity details by id.'
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the Entity',
+    },
+  ]
+
+  async run() {
+    const { id } = this.parse(EntityCommand).args
+    const entity = await this.getEntity(id)
+    const { controller, frozen, referenceable } = entity.entity_permissions
+    const [classId, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
+    const propertyValues = this.parseEntityPropertyValues(entity, entityClass)
+
+    displayCollapsedRow({
+      'ID': id,
+      'Class name': entityClass.name.toString(),
+      'Class ID': classId.toNumber(),
+      'Supported schemas': JSON.stringify(entity.supported_schemas.toJSON()),
+      'Controller': controller.type + (controller.isOfType('Member') ? `(${controller.asType('Member')})` : ''),
+      'Frozen': frozen.toString(),
+      'Refrecencable': referenceable.toString(),
+      'Same owner references': entity.reference_counter.same_owner.toNumber(),
+      'Total references': entity.reference_counter.total.toNumber(),
+    })
+    displayHeader('Property values')
+    displayCollapsedRow(_.mapValues(propertyValues, (v) => `${v.value.toString()} ${chalk.green(`${v.type}`)}`))
+  }
+}

+ 30 - 0
cli/src/commands/content-directory/removeCuratorGroup.ts

@@ -0,0 +1,30 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddCuratorGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Remove existing Curator Group.'
+  static args = [
+    {
+      name: 'id',
+      required: false,
+      description: 'ID of the Curator Group to remove',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { id } = this.parse(AddCuratorGroupCommand).args
+    if (id === undefined) {
+      id = await this.promptForCuratorGroup('Select Curator Group to remove')
+    } else {
+      await this.getCuratorGroup(id)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeCuratorGroup', [id])
+
+    console.log(chalk.green(`Curator Group ${chalk.white(id)} succesfully removed!`))
+  }
+}

+ 44 - 0
cli/src/commands/content-directory/removeMaintainerFromClass.ts

@@ -0,0 +1,44 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class AddMaintainerToClassCommand extends ContentDirectoryCommandBase {
+  static description = 'Remove maintainer (Curator Group) from class.'
+  static args = [
+    {
+      name: 'className',
+      required: false,
+      description: 'Name or ID of the class (ie. Video)',
+    },
+    {
+      name: 'groupId',
+      required: false,
+      description: 'ID of the Curator Group to remove from maintainers',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { groupId, className } = this.parse(AddMaintainerToClassCommand).args
+
+    if (className === undefined) {
+      className = (await this.promptForClass()).name.toString()
+    }
+
+    const [classId, aClass] = await this.classEntryByNameOrId(className)
+
+    if (groupId === undefined) {
+      groupId = await this.promptForCuratorGroup('Select a maintainer', aClass.class_permissions.maintainers.toArray())
+    } else {
+      await this.getCuratorGroup(groupId)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeMaintainerFromClass', [classId, groupId])
+
+    console.log(
+      chalk.green(`Curator Group ${chalk.white(groupId)} removed as maintainer of ${chalk.white(className)} class!`)
+    )
+  }
+}

+ 61 - 0
cli/src/commands/content-directory/setCuratorGroupStatus.ts

@@ -0,0 +1,61 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+
+export default class SetCuratorGroupStatusCommand extends ContentDirectoryCommandBase {
+  static description = 'Set Curator Group status (Active/Inactive).'
+  static args = [
+    {
+      name: 'id',
+      required: false,
+      description: 'ID of the Curator Group',
+    },
+    {
+      name: 'status',
+      required: false,
+      description: 'New status of the group (1 - active, 0 - inactive)',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { id, status } = this.parse(SetCuratorGroupStatusCommand).args
+
+    if (id === undefined) {
+      id = await this.promptForCuratorGroup()
+    } else {
+      await this.getCuratorGroup(id)
+    }
+
+    if (status === undefined) {
+      status = await this.simplePrompt({
+        type: 'list',
+        message: 'Select new status',
+        choices: [
+          { name: 'Active', value: true },
+          { name: 'Inactive', value: false },
+        ],
+      })
+    } else {
+      if (status !== '0' && status !== '1') {
+        this.error('Invalid status provided. Use "1" for Active and "0" for Inactive.', {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      status = !!parseInt(status)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'setCuratorGroupStatus', [id, status])
+
+    console.log(
+      chalk.green(
+        `Curator Group ${chalk.white(id)} status succesfully changed to: ${chalk.white(
+          status ? 'Active' : 'Inactive'
+        )}!`
+      )
+    )
+  }
+}

+ 55 - 0
cli/src/commands/content-directory/updateClassPermissions.ts

@@ -0,0 +1,55 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import CreateClassSchema from 'cd-schemas/schemas/extrinsics/CreateClass.schema.json'
+import chalk from 'chalk'
+import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { CreateClass } from 'cd-schemas/types/extrinsics/CreateClass'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+
+export default class UpdateClassPermissionsCommand extends ContentDirectoryCommandBase {
+  static description = 'Update permissions in given class.'
+  static args = [
+    {
+      name: 'className',
+      required: false,
+      description: 'Name or ID of the class (ie. Video)',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { className } = this.parse(UpdateClassPermissionsCommand).args
+
+    if (className === undefined) {
+      className = (await this.promptForClass()).name.toString()
+    }
+
+    const [classId, aClass] = await this.classEntryByNameOrId(className)
+    const currentPermissions = aClass.class_permissions
+
+    const customPrompts: JsonSchemaCustomPrompts = [
+      ['class_permissions.maintainers', () => this.promptForCuratorGroups('Select class maintainers')],
+    ]
+
+    const prompter = new JsonSchemaPrompter<CreateClass>(
+      CreateClassSchema as JSONSchema,
+      { class_permissions: currentPermissions.toJSON() as CreateClass['class_permissions'] },
+      customPrompts
+    )
+
+    const newPermissions = await prompter.promptSingleProp('class_permissions')
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'updateClassPermissions', [
+      classId,
+      newPermissions.any_member,
+      newPermissions.entity_creation_blocked,
+      newPermissions.all_entity_property_values_locked,
+      newPermissions.maintainers,
+    ])
+
+    console.log(chalk.green(`${chalk.white(className)} class permissions updated to:`))
+    this.jsonPrettyPrint(JSON.stringify(newPermissions))
+  }
+}

+ 1 - 3
cli/src/commands/working-groups/createOpening.ts

@@ -79,9 +79,7 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
       this.log(chalk.white('Sending the extrinsic...'))
       await this.sendExtrinsic(
         account,
-        apiModuleByGroup[this.group],
-        'addOpening',
-        defaultValues!.map((v) => v.value)
+        this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...defaultValues!.map((v) => v.value))
       )
       this.log(chalk.green('Opening succesfully created!'))
     }

+ 1 - 1
cli/src/commands/working-groups/decreaseWorkerStake.ts

@@ -42,7 +42,7 @@ export default class WorkingGroupsDecreaseWorkerStake extends WorkingGroupsComma
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'decreaseStake', [workerId, balance])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'decreaseStake', [workerId, balance])
 
     this.log(
       chalk.green(

+ 1 - 1
cli/src/commands/working-groups/evictWorker.ts

@@ -41,7 +41,7 @@ export default class WorkingGroupsEvictWorker extends WorkingGroupsCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'terminateRole', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'terminateRole', [
       workerId,
       rationale,
       shouldSlash,

+ 1 - 1
cli/src/commands/working-groups/fillOpening.ts

@@ -33,7 +33,7 @@ export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'fillOpening', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'fillOpening', [
       openingId,
       applicationIds,
       rewardPolicyOpt,

+ 1 - 4
cli/src/commands/working-groups/increaseStake.ts

@@ -30,10 +30,7 @@ export default class WorkingGroupsIncreaseStake extends WorkingGroupsCommandBase
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'increaseStake', [
-      worker.workerId,
-      balance,
-    ])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'increaseStake', [worker.workerId, balance])
 
     this.log(
       chalk.green(

+ 1 - 1
cli/src/commands/working-groups/leaveRole.ts

@@ -21,7 +21,7 @@ export default class WorkingGroupsLeaveRole extends WorkingGroupsCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'leaveRole', [worker.workerId, rationale])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'leaveRole', [worker.workerId, rationale])
 
     this.log(chalk.green(`Succesfully left the role! (worker id: ${chalk.white(worker.workerId.toNumber())})`))
   }

+ 1 - 1
cli/src/commands/working-groups/slashWorker.ts

@@ -39,7 +39,7 @@ export default class WorkingGroupsSlashWorker extends WorkingGroupsCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'slashStake', [workerId, balance])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'slashStake', [workerId, balance])
 
     this.log(
       chalk.green(

+ 1 - 1
cli/src/commands/working-groups/startAcceptingApplications.ts

@@ -29,7 +29,7 @@ export default class WorkingGroupsStartAcceptingApplications extends WorkingGrou
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'acceptApplications', [openingId])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'acceptApplications', [openingId])
 
     this.log(
       chalk.green(`Opening ${chalk.white(openingId)} status changed to: ${chalk.white('Accepting Applications')}`)

+ 1 - 1
cli/src/commands/working-groups/startReviewPeriod.ts

@@ -29,7 +29,7 @@ export default class WorkingGroupsStartReviewPeriod extends WorkingGroupsCommand
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'beginApplicantReview', [openingId])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'beginApplicantReview', [openingId])
 
     this.log(chalk.green(`Opening ${chalk.white(openingId)} status changed to: ${chalk.white('In Review')}`))
   }

+ 1 - 1
cli/src/commands/working-groups/terminateApplication.ts

@@ -30,7 +30,7 @@ export default class WorkingGroupsTerminateApplication extends WorkingGroupsComm
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'terminateApplication', [applicationId])
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'terminateApplication', [applicationId])
 
     this.log(chalk.green(`Application ${chalk.white(applicationId)} has been succesfully terminated!`))
   }

+ 1 - 1
cli/src/commands/working-groups/updateRewardAccount.ts

@@ -38,7 +38,7 @@ export default class WorkingGroupsUpdateRewardAccount extends WorkingGroupsComma
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'updateRewardAccount', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRewardAccount', [
       worker.workerId,
       newRewardAccount,
     ])

+ 1 - 1
cli/src/commands/working-groups/updateRoleAccount.ts

@@ -32,7 +32,7 @@ export default class WorkingGroupsUpdateRoleAccount extends WorkingGroupsCommand
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'updateRoleAccount', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRoleAccount', [
       worker.workerId,
       newRoleAccount,
     ])

+ 1 - 1
cli/src/commands/working-groups/updateWorkerReward.ts

@@ -55,7 +55,7 @@ export default class WorkingGroupsUpdateWorkerReward extends WorkingGroupsComman
 
     await this.requestAccountDecoding(account)
 
-    await this.sendAndFollowExtrinsic(account, apiModuleByGroup[this.group], 'updateRewardAmount', [
+    await this.sendAndFollowNamedTx(account, apiModuleByGroup[this.group], 'updateRewardAmount', [
       workerId,
       newRewardValue,
     ])

+ 68 - 0
cli/src/helpers/InputOutput.ts

@@ -0,0 +1,68 @@
+import { flags } from '@oclif/command'
+import { CLIError } from '@oclif/errors'
+import ExitCodes from '../ExitCodes'
+import fs from 'fs'
+import path from 'path'
+import Ajv from 'ajv'
+import { JSONSchema7 } from 'json-schema'
+import chalk from 'chalk'
+
+export const IOFlags = {
+  input: flags.string({
+    char: 'i',
+    required: false,
+    description: `Path to JSON file to use as input (if not specified - the input can be provided interactively)`,
+  }),
+  output: flags.string({
+    char: 'o',
+    required: false,
+    description: 'Path where the output JSON file should be placed (can be then reused as input)',
+  }),
+}
+
+export function getInputJson<T>(inputPath?: string, schema?: JSONSchema7): T | null {
+  if (inputPath) {
+    let content, jsonObj
+    try {
+      content = fs.readFileSync(inputPath).toString()
+    } catch (e) {
+      throw new CLIError(`Cannot access the input file at: ${inputPath}`, { exit: ExitCodes.FsOperationFailed })
+    }
+    try {
+      jsonObj = JSON.parse(content)
+    } catch (e) {
+      throw new CLIError(`JSON parsing failed for file: ${inputPath}`, { exit: ExitCodes.InvalidInput })
+    }
+    if (schema) {
+      const ajv = new Ajv()
+      const valid = ajv.validate(schema, jsonObj)
+      if (!valid) {
+        throw new CLIError(`Input JSON file is not valid: ${ajv.errorsText()}`)
+      }
+    }
+
+    return jsonObj as T
+  }
+
+  return null
+}
+
+export function saveOutputJson(outputPath: string | undefined, fileName: string, data: any): void {
+  if (outputPath) {
+    let outputFilePath = path.join(outputPath, fileName)
+    let postfix = 0
+    while (fs.existsSync(outputFilePath)) {
+      fileName = fileName.replace(/(_[0-9]+)?\.json/, `_${++postfix}.json`)
+      outputFilePath = path.join(outputPath, fileName)
+    }
+    try {
+      fs.writeFileSync(outputFilePath, JSON.stringify(data, null, 4))
+    } catch (e) {
+      throw new CLIError(`Could not save the output to: ${outputFilePath}. Check directory permissions`, {
+        exit: ExitCodes.FsOperationFailed,
+      })
+    }
+
+    console.log(`${chalk.green('Output succesfully saved to:')} ${chalk.white(outputFilePath)}`)
+  }
+}

+ 206 - 0
cli/src/helpers/JsonSchemaPrompt.ts

@@ -0,0 +1,206 @@
+import Ajv from 'ajv'
+import inquirer, { DistinctQuestion } from 'inquirer'
+import _ from 'lodash'
+import RefParser, { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import chalk from 'chalk'
+import { BOOL_PROMPT_OPTIONS } from './prompting'
+
+type CustomPromptMethod = () => Promise<any>
+type CustomPrompt = DistinctQuestion | CustomPromptMethod | { $item: CustomPrompt }
+
+export type JsonSchemaCustomPrompts = [string | RegExp, CustomPrompt][]
+
+export class JsonSchemaPrompter<JsonResult> {
+  schema: JSONSchema
+  customPropmpts?: JsonSchemaCustomPrompts
+  ajv: Ajv.Ajv
+  filledObject: Partial<JsonResult>
+
+  constructor(schema: JSONSchema, defaults?: Partial<JsonResult>, customPrompts?: JsonSchemaCustomPrompts) {
+    this.customPropmpts = customPrompts
+    this.schema = schema
+    this.ajv = new Ajv()
+    this.filledObject = defaults || {}
+  }
+
+  private oneOfToChoices(oneOf: JSONSchema[]) {
+    const choices: { name: string; value: number | string }[] = []
+
+    oneOf.forEach((pSchema, index) => {
+      if (pSchema.description) {
+        choices.push({ name: pSchema.description, value: index })
+      } else if (pSchema.type === 'object' && pSchema.properties) {
+        choices.push({ name: `{ ${Object.keys(pSchema.properties).join(', ')} }`, value: index })
+      } else {
+        choices.push({ name: index.toString(), value: index })
+      }
+    })
+
+    return choices
+  }
+
+  private getCustomPrompt(propertyPath: string): CustomPrompt | undefined {
+    const found = this.customPropmpts?.find(([pathToMatch]) =>
+      typeof pathToMatch === 'string' ? propertyPath === pathToMatch : pathToMatch.test(propertyPath)
+    )
+
+    return found ? found[1] : undefined
+  }
+
+  private propertyDisplayName(propertyPath: string) {
+    return chalk.green(propertyPath)
+  }
+
+  private async prompt(schema: JSONSchema, propertyPath = ''): Promise<any> {
+    const customPrompt: CustomPrompt | undefined = this.getCustomPrompt(propertyPath)
+    const propDisplayName = this.propertyDisplayName(propertyPath)
+
+    // Custom prompt
+    if (typeof customPrompt === 'function') {
+      return await this.promptWithRetry(customPrompt, propertyPath, true)
+    }
+
+    // oneOf
+    if (schema.oneOf) {
+      const oneOf = schema.oneOf as JSONSchema[]
+      const choices = this.oneOfToChoices(oneOf)
+      const { choosen } = await inquirer.prompt({ name: 'choosen', message: propDisplayName, type: 'list', choices })
+      return await this.prompt(oneOf[choosen], propertyPath)
+    }
+
+    // object
+    if (schema.type === 'object' && schema.properties) {
+      const value: Record<string, any> = {}
+      for (const [pName, pSchema] of Object.entries(schema.properties)) {
+        value[pName] = await this.prompt(pSchema, propertyPath ? `${propertyPath}.${pName}` : pName)
+      }
+      return value
+    }
+
+    // array
+    if (schema.type === 'array' && schema.items) {
+      return await this.promptWithRetry(() => this.promptArray(schema, propertyPath), propertyPath, true)
+    }
+
+    // "primitive" values:
+    const basicPromptOptions: DistinctQuestion = {
+      message: propDisplayName,
+      default: _.get(this.filledObject, propertyPath) || schema.default,
+    }
+
+    let additionalPromptOptions: DistinctQuestion | undefined
+    let normalizer: (v: any) => any = (v) => v
+
+    // Prompt options
+    if (schema.enum) {
+      additionalPromptOptions = { type: 'list', choices: schema.enum as any[] }
+    } else if (schema.type === 'boolean') {
+      additionalPromptOptions = BOOL_PROMPT_OPTIONS
+    }
+
+    // Normalizers
+    if (schema.type === 'integer') {
+      normalizer = (v) => parseInt(v)
+    }
+
+    if (schema.type === 'number') {
+      normalizer = (v) => Number(v)
+    }
+
+    const promptOptions = { ...basicPromptOptions, ...additionalPromptOptions, ...customPrompt }
+    // Need to wrap in retry, because "validate" will not get called if "type" is "list" etc.
+    return await this.promptWithRetry(
+      async () => normalizer(await this.promptSimple(promptOptions, propertyPath, schema, normalizer)),
+      propertyPath
+    )
+  }
+
+  private setValueAndGetError(propertyPath: string, value: any, nestedErrors = false): string | null {
+    _.set(this.filledObject as Record<string, unknown>, propertyPath, value)
+    this.ajv.validate(this.schema, this.filledObject) as boolean
+    return this.ajv.errors
+      ? this.ajv.errors
+          .filter((e) => (nestedErrors ? e.dataPath.startsWith(`.${propertyPath}`) : e.dataPath === `.${propertyPath}`))
+          .map((e) => (e.dataPath.replace(`.${propertyPath}`, '') || 'This value') + ` ${e.message}`)
+          .join(', ')
+      : null
+  }
+
+  private async promptArray(schema: JSONSchema, propertyPath: string) {
+    if (!schema.items) {
+      return []
+    }
+    const { maxItems = Number.MAX_SAFE_INTEGER } = schema
+    let currItem = 0
+    const result = []
+    while (currItem < maxItems) {
+      const { next } = await inquirer.prompt([
+        {
+          ...BOOL_PROMPT_OPTIONS,
+          name: 'next',
+          message: `Do you want to add another item to ${this.propertyDisplayName(propertyPath)} array?`,
+        },
+      ])
+      if (!next) {
+        break
+      }
+      const itemSchema = Array.isArray(schema.items) ? schema.items[schema.items.length % currItem] : schema.items
+      result.push(await this.prompt(typeof itemSchema === 'boolean' ? {} : itemSchema, `${propertyPath}[${currItem}]`))
+
+      ++currItem
+    }
+
+    return result
+  }
+
+  private async promptSimple(
+    promptOptions: DistinctQuestion,
+    propertyPath: string,
+    schema: JSONSchema,
+    normalize?: (v: any) => any
+  ) {
+    const { result } = await inquirer.prompt([
+      {
+        ...promptOptions,
+        name: 'result',
+        validate: (v) => {
+          v = normalize ? normalize(v) : v
+          return (
+            this.setValueAndGetError(propertyPath, v) ||
+            (promptOptions.validate ? promptOptions.validate(v) : true) ||
+            true
+          )
+        },
+      },
+    ])
+
+    return result
+  }
+
+  private async promptWithRetry(customMethod: CustomPromptMethod, propertyPath: string, nestedErrors = false) {
+    let error: string | null
+    let value: any
+    do {
+      value = await customMethod()
+      error = this.setValueAndGetError(propertyPath, value, nestedErrors)
+      if (error) {
+        console.log('\n')
+        console.warn(error)
+        console.warn(`Try providing the input for ${propertyPath} again...`)
+      }
+    } while (error)
+
+    return value
+  }
+
+  async promptAll() {
+    await this.prompt(await RefParser.dereference(this.schema))
+    return this.filledObject as JsonResult
+  }
+
+  async promptSingleProp<P extends keyof JsonResult & string>(p: P): Promise<Exclude<JsonResult[P], undefined>> {
+    const dereferenced = await RefParser.dereference(this.schema)
+    await this.prompt(dereferenced.properties![p] as JSONSchema, p)
+    return this.filledObject[p] as Exclude<JsonResult[P], undefined>
+  }
+}

+ 1 - 0
cli/src/helpers/display.ts

@@ -48,6 +48,7 @@ export function displayTable(rows: { [k: string]: string | number }[], cellHoriz
       return Math.max(maxLength, valLength)
     }, columnName.length)
   const columnDef = (columnName: string) => ({
+    header: columnName,
     get: (row: typeof rows[number]) => chalk.white(`${row[columnName]}`),
     minWidth: maxLength(columnName) + cellHorizontalPadding,
   })

+ 9 - 0
cli/src/helpers/prompting.ts

@@ -0,0 +1,9 @@
+import { DistinctQuestion } from 'inquirer'
+
+export const BOOL_PROMPT_OPTIONS: DistinctQuestion = {
+  type: 'list',
+  choices: [
+    { name: 'Yes', value: true },
+    { name: 'No', value: false },
+  ],
+}

+ 3 - 1
cli/tsconfig.json

@@ -13,7 +13,9 @@
     "baseUrl": ".",
     "paths": {
       "@polkadot/types/augment": ["../types/augment-codec/augment-types.ts"],
-    }
+    },
+    "resolveJsonModule": true,
+    "skipLibCheck": true
   },
   "include": [
     "src/**/*"

+ 14 - 2
types/src/content-directory/index.ts

@@ -45,9 +45,13 @@ export class PropertyType extends JoyEnum({
   Single: PropertyTypeSingle,
   Vector: PropertyTypeVector,
 }) {
+  get subtype() {
+    return this.isOfType('Single') ? this.asType('Single').type : this.asType('Vector').vec_type.type
+  }
+
   toInputPropertyValue(value: any): InputPropertyValue {
     const inputPwType: keyof typeof InputPropertyValue['typeDefinitions'] = this.type
-    const subtype = this.isOfType('Single') ? this.asType('Single').type : this.asType('Vector').vec_type.type
+    const subtype = this.subtype
 
     if (inputPwType === 'Single') {
       const inputPwSubtype: keyof typeof InputValue['typeDefinitions'] = subtype === 'Hash' ? 'TextToHash' : subtype
@@ -137,7 +141,15 @@ export class VecStoredPropertyValue extends JoyStructDecorated({
 export class StoredPropertyValue extends JoyEnum({
   Single: StoredValue,
   Vector: VecStoredPropertyValue,
-}) {}
+}) {
+  get subtype() {
+    return this.isOfType('Single') ? this.asType('Single').type : this.asType('Vector').vec_value.type
+  }
+
+  public getValue() {
+    return this.isOfType('Single') ? this.asType('Single').value : this.asType('Vector').vec_value.value
+  }
+}
 
 export class InboundReferenceCounter extends JoyStructDecorated({
   total: u32,